资讯详情

小程序日期格式化:iOS NaN问题解析与通用Util封装实践

📅 2026/10/16 11:24:13 | 华诺云谱 👁 阅读
小程序日期格式化:iOS NaN问题解析与通用Util封装实践
如果你在小程序里写过日期格式化大概率碰到过这个场景代码在开发者工具里跑得好好的Android 真机上也正常一拿到 iOS 上预览界面直接给你一排NaN-NaN-NaN NaN:NaN:NaN。我第一次遇到这问题是在某次项目评审前的自测环节当时整个人是懵的——业务逻辑明明没动过怎么换个系统就崩了。后来查了一圈才发现根本不是你的代码有 bug而是 iOS 的 JavaScript 引擎对日期字符串的解析规则和 Android 不一样。今天这篇文章我就把这个问题彻底拆开为什么 iOS 会显示 NaN、怎么封装一个抗造的日期格式化 Util以及顺带把时区、getMonth()、WXML 渲染性能这些更隐蔽的坑一起避掉。1. 一样的代码两种命运iOS 与 Android 的日期解析差异1.1 问题现场真机预览里突如其来的 NaN先还原一下最常见的翻车现场。很多同事写日期格式化时最顺手的方式就是直接把后端返回的字符串丢给new Date()var date new Date(2025-01-05 14:30:00); var text date.getFullYear() - (date.getMonth() 1) - date.getDate();这段代码在开发者工具里输出2025-1-5在 Android 真机上输出2025-1-5可一到 iOS 上就变成NaN-NaN-NaN后面如果还拼了时分秒那就是一排整整齐齐的NaN。关键问题是你的业务逻辑完全没变数据格式也没变纯粹是运行环境换了。我印象很深的一次是在某次项目演示前一晚测试机传回来的截图里所有时间字段全是NaN。当时第一反应是setData传参出了问题后来把页面 JS 单独在 Safari 里跑了一遍才发现问题出在new Date(2025-01-05 14:30:00)这一句。同一段代码放在不同 JavaScript 引擎里执行结果居然不一样。1.2 根因JavaScript 引擎对日期字符串的解析规范这里要说到 JavaScript 引擎的差异。小程序 iOS 端跑在 JavaScriptCore 上Android 端跑在 V8 上而开发者工具通常也基于 V8 或类似实现。两个引擎对日期字符串的解析标准执行力度完全不同。ECMAScript 规范里定义了一个“日期时间字符串格式”Date Time String Format最标准的样子是这样的YYYY-MM-DDTHH:mm:ss.sssZ。注意两个细节日期和时间之间用的是英文字母T分隔而不是空格末尾的Z表示 UTC 时间也可以写成08:00这种时区偏移。规范只强制引擎支持这种格式对于其他自定义格式——比如2025-01-05 14:30:00这种带空格的——引擎“可以支持也可以不支持”。V8 比较宽容你丢给它一个带空格的字符串它能猜出你想表达本地时间愉快地解析出正确日期JavaScriptCore 则比较严格遇到非规范格式直接返回 Invalid Date。iOS 上之所以显示NaN就是因为引擎解析失败后getFullYear()、getMonth()、getDate()这些方法全都在 Invalid Date 上运行结果自然全是NaN。如果用生活化的类比来解释就像同一个快递地址有的快递员能根据门牌号推理出具体位置有的快递员必须看到规范填写的收件信息才肯派送。这跟你的包裹本身没关系纯粹是派送规则不同。1.3 通用补丁把横线换成斜杠社区里最早的解决方案很朴素就是把字符串里的-全部替换成/var date new Date(2025/01/05 14:30:00);改成斜杠之后iOS 和 Android 都能正确解析而且会统一按本地时间处理。为什么斜杠形式好用因为斜杠分隔的日期不是 ISO 规范格式各引擎都不把它当标准格式对待反而都统一按本地时间解析行为更容易对齐。这个方法几乎成了前端圈的“祖传偏方”但它的缺陷也很明显你不能指望团队每个成员都记得先replace(/-/g, /)再new Date()更不能让每个页面都写一遍。日子久了总会有人漏掉这一步于是 iOS 上的NaN又回来了。更稳妥的方式是把这套兼容逻辑收拢到一个公共工具里让所有业务代码都走同一个入口。这也是下面要封装 Util 的动机。1.4 一个容易忽略的连锁反应UTC 与本地时间仅把横线替换成斜杠还不够因为new Date(2025-01-05)和new Date(2025/01/05)的语义在规范层面就不一样。严格按 ES 规范2025-01-05这种纯日期字符串会按UTC时间解析也就是 UTC 的 2025 年 1 月 5 日零点而2025/01/05这种斜杠格式会按本地时间解析。在中国东八区new Date(2025-01-05).getHours()返回的是8而不是0。如果你只是格式化成年月日看起来影响不大但如果业务里需要“选择某一天并默认为当天零点”提交给后端时间就会悄悄差了 8 个小时。更麻烦的是iOS 不同版本对纯日期字符串的行为也不完全一致有的版本返回 Invalid Date有的版本按 UTC 解析。所以一个合格的日期工具不能拿到字符串就无脑替换横线而是要先判断字符串类型再决定用原生解析还是走兼容路径。这个设计正好是下一节封装 Util 的核心思路。2. 动手封装一个能“抗造”的日期格式化 Util2.1 先定规矩这个工具要解决哪些输入在写代码之前先明确这个 Util 要服务哪些场景。一个成熟的日期格式化工具至少要能处理这四类输入Date 对象可能是正常对象也可能是别人传进来的 Invalid Date数字时间戳比如1736000000000纯数字字符串比如1736000000000常见的日期时间字符串包括2025-01-05、2025-1-5、2025-01-05 14:30:00、2025/01/05 14:30:00以及带T和时区标记的 ISO 字符串。输出端要支持常见的模板占位符YYYY、YY、MM、M、DD、D、HH、H、mm、m、ss、s。其中双字母表示补零单字母表示不补零。这样既能满足2025-01-05 14:30:00这种标准展示也能满足2025年1月5日这种偏口语化的展示。另外还有一条硬性要求解析失败的输入统一返回空字符串而不是把NaN继续往页面上抛。业务代码拿到NaN很难判断到底哪里出了问题但拿到空字符串至少可以做一个显式的空态兜底。2.2 逐层实现补零、解析输入、模板格式化第一层是最基础的补零函数不要依赖 ES2017 的padStart一个三元判断就够用function pad(n) { return n 10 ? 0 n : n; }第二层是输入归一化。这里要重点处理 iOS 的兼容问题我把判断分成几步先处理 Date 对象和数字再处理字符串字符串里先判断是不是纯数字时间戳再判断是不是 ISO 标准格式最后才轮到横线转斜杠。完整逻辑放到下一节讨论先看整体代码。第三层是格式化核心。思路是把日期各个字段提取到一个映射表里再用一个正则表达式做模板替换。正则里各占位符的顺序很有讲究长的必须排在短的前面否则YYYY会被拆成YY加YYMM会被拆成两个M。2.3 完整代码可以直接复制进 utils/date.js// utils/date.js function pad(n) { return n 10 ? 0 n : n; } function isDate(obj) { return Object.prototype.toString.call(obj) [object Date]; } function toValidDate(date) { return isNaN(date.getTime()) ? null : date; } function parseInput(input) { // Date 对象复制一份再返回避免外部修改污染原对象 if (isDate(input)) { return toValidDate(new Date(input.getTime())); } // 数字直接当毫秒时间戳处理 if (typeof input number) { return toValidDate(new Date(input)); } // 非字符串一律无效 if (typeof input ! string) { return null; } var str input.trim(); if (str ) { return null; } // 纯数字字符串也当时间戳处理 if (/^\d$/.test(str)) { return toValidDate(new Date(parseInt(str, 10))); } // 标准 ISO 格式带 T 分隔或时区标记交给原生解析保留 UTC/时区语义 if (/^\d{4}-\d{2}-\d{2}T/.test(str) || /(Z|[-]\d{2}:\d{2})$/.test(str)) { return toValidDate(new Date(str)); } // 其余横线连接格式转成斜杠按本地时间解析兼容 iOS var normalized str.replace(/-/g, /); return toValidDate(new Date(normalized)); } function formatDate(input, pattern) { var tpl pattern || YYYY-MM-DD HH:mm:ss; var date parseInput(input); if (!date) { return ; } var map { YYYY: date.getFullYear(), YY: String(date.getFullYear()).slice(-2), MM: pad(date.getMonth() 1), M: date.getMonth() 1, DD: pad(date.getDate()), D: date.getDate(), HH: pad(date.getHours()), H: date.getHours(), mm: pad(date.getMinutes()), m: date.getMinutes(), ss: pad(date.getSeconds()), s: date.getSeconds() }; return tpl.replace(/YYYY|YY|MM|M|DD|D|HH|H|mm|m|ss|s/g, function(match) { return map[match] ! undefined ? map[match] : match; }); } function getWeek(input) { var date parseInput(input); if (!date) { return ; } var weeks [日, 一, 二, 三, 四, 五, 六]; return 星期 weeks[date.getDay()]; } function dateAdd(input, days) { var date parseInput(input); if (!date) { return null; } date.setDate(date.getDate() days); return date; } function dateDiff(start, end) { var startDate parseInput(start); var endDate parseInput(end); if (!startDate || !endDate) { return null; } return endDate.getTime() - startDate.getTime(); } module.exports { parseInput: parseInput, formatDate: formatDate, getWeek: getWeek, dateAdd: dateAdd, dateDiff: dateDiff };这段代码已经可以支撑绝大多数小程序项目的日期格式化需求了。额外附带的getWeek、dateAdd、dateDiff分别是星期几、日期加减、毫秒差计算属于实际开发里复用频率很高的能力顺手就放进同一个文件里。3. 这套 Util 是怎么扛住各种输入格式的核心函数逐段解读3.1 为什么先处理 Date 对象而且还要复制一份parseInput第一个分支是isDate(input)。判断方式没用instanceof Date而是用了Object.prototype.toString.call(obj)。原因很简单在小程序的多 WebView 或跨 JS 环境场景下一个 Date 对象可能来自另一个执行环境instanceof会因原型链不同而判断失败Object.prototype.toString的判断则稳定得多。这里还有一个容易被忽略的细节我返回的是new Date(input.getTime())也就是原 Date 对象的副本。为什么要复制因为业务代码拿到工具返回的日期后可能会对它做setDate、setHours这类操作。如果直接把原对象返回这些操作会污染到数据源下一次渲染时你可能会惊讶地发现某个列表项的时间悄悄变了 1 天。返回副本可以在源头切断这类副作用。另外分支里还加了toValidDate做有效性校验。很多人容易漏掉这一点Date 对象也可能是 Invalid Date。比如某个字段被第三方库处理过或者后端直接给了0000-00-00这种字符串new Date()后就是一个 Invalid Date 对象它是对象真值判断无法排除它。必须用isNaN(date.getTime())才能准确识别。3.2 时间戳数字和纯数字字符串的边界数字入参直接new Date(input)这个好理解。但很多人会忽略纯数字字符串的情况后端偶尔会把时间戳序列化成字符串返回比如1736000000000。如果不做处理这个字符串会走后面的replace(/-/g, /)逻辑变成原样字符串new Date(1736000000000)直接返回 Invalid Date。所以我在字符串分支里专门加了一步/^\d$/.test(str)命中后用parseInt(str, 10)转成数字再交给new Date()。这里有个使用提醒时间戳必须是毫秒单位也就是 13 位左右。如果是后端返回的 10 位秒级时间戳需要先在业务层乘以 1000否则即使解析成功日期也会跑到 1970 年附近。这个乘法不建议放在工具里自动做因为工具无法判断输入到底是多少位猜测反而会引入新的不确定行为。3.3 横线转斜杠前先给 ISO 标准格式让路parseInput里最关键的兼容逻辑是这一段先判断字符串是不是 ISO 标准格式如果是就直接交给原生new Date()只有非标准格式才执行replace(/-/g, /)。为什么顺序这么重要因为replace(/-/g, /)虽然能解决 iOS 上2025-01-05 14:30:00解析失败的问题但它有一个副作用会把2025-01-05T14:30:00.000Z也替换成2025/01/05T14:30:00.000Z。这个斜杠加T加Z的混合格式不是标准 ISO 格式各引擎对它的解释并不一致很可能把原本的 UTC 语义搞错导致显示时间差 8 小时。所以对于带T或带时区标记的字符串应该信任原生解析保留它本来的时区语义。而对于2025-01-05 14:30:00这种带空格的日常格式才走横线转斜杠的兼容路径并统一按本地时间解析。这样设计以后iOS 和 Android 两端在这些常见格式上的行为能对齐同时又不会误伤标准 ISO 字符串。3.4 模板替换为什么用一次正则而不是链式 replaceformatDate里只做了一次replace正则把所有占位符都覆盖到了。有些新手可能会写成这样tpl.replace(YYYY, date.getFullYear()) .replace(MM, pad(month)) .replace(DD, pad(day))这条链式写法实际上有隐患。如果模板里的YYYY先被替换成2025后续replace(YY, ...)虽然不会误伤数字文本但MM和M的顺序一旦搞错就会出现2025-01-05先被M替换成2025-1-05再被MM替换出重复内容之类的混乱结果。这种 bug 平时不显眼一旦某天改模板顺序就会冒出来。一次正则替换通过YYYY|YY|MM|M|DD|D|HH|H|mm|m|ss|s的排列顺序天然保证了长占位符优先匹配。另一种做法是用对象遍历各有利弊但既然模板字符串本身规模很小正则替换仍然是可读性和性能最均衡的方案。还有一个小细节return map[match] ! undefined ? map[match] : match。这段是为了防止模板里出现未知占位符时被替换成undefined。比如某人写了YYYY年MM月DD日 XX不在映射表里就原样保留而不是变成undefined。这样模板容错性更好也不会把调试信息带到页面上。4. 别以为只有 iOS 会坑你时区、getMonth 和 WXML 的性能陷阱4.1 getMonth() 从 0 开始拼字符串时最容易翻车JavaScript 的Date对象里getMonth()返回值范围是 0 到 110 表示一月。这是 ECMAScript 的历史惯例但它非常反直觉几乎所有刚接触前端的人都要在这里栽一次。最常见的 bug 是这种写法var text date.getFullYear() - date.getMonth() - date.getDate();如果当前是 1 月这段代码会输出2025-0-15而不是2025-01-15。有的同事会在后面补一句“我明明加了 1 呀”结果发现加错了地方把getDate()给加了 1。格式化 Util 里处理这个坑的方法很简单构造映射表时统一用date.getMonth() 1双字母模板再经过pad补零。这样既能保证语义正确也让业务代码永远不用直接接触getMonth()的原始返回值。如果你在别的代码片段里手写日期拼接建议强制自己走formatDate不要自己去拼字段。4.2 纯日期字符串也有时区语义前面第 1.4 节已经提到new Date(2025-01-05)在规范里会按 UTC 解析在东八区你会得到本地时间早上 8 点而不是凌晨 0 点。这里再展开讲一个实际影响。假设业务里有个“上架时间”字段后端返回2025-01-05前端想展示成2025-01-05直接格式化看不出问题。但如果要把这个日期拿来计算倒计时或者跟另一个本地时间戳比较大小8 小时偏差就会导致“当天结束时间”和“次日开始时间”的判断出错。另一个场景是日历选择。小程序里用picker modedate拿到的值是2025-01-05这种纯日期字符串如果不做处理就丢给new Date()它同样会被解释成 UTC 时间。如果后续又把它转成时间戳传给后端后端再按东八区解析日期可能还是对的但时间会多出 8 小时。正确的做法是先把纯日期字符串转成2025/01/05再解析或者用new Date(year, month - 1, day)这种显式构造方式。这也是为什么parseInput要把非 ISO 格式统一在“斜杠路径”里处理因为斜杠字符串在各引擎里都按本地时间解析行为一致。4.3 用户输入格式不统一Picker 比兜底解析更靠谱parseInput的兼容能力再强也扛不住用户随意输入。现实中你可能收到这些千奇百怪的格式2025/1/52025-1-52025.01.052025年1月5日2025-01-05 下午 2:30横线转斜杠能解决一部分但点号分隔、中文年月日、带“下午”这种描述性文本很难用一套通用规则完全覆盖。往parseInput里堆更多正则当然可以但每多一条规则就多一份误判风险。我的建议是如果能用控件约束输入就不要依赖文本解析。小程序场景下优先使用picker modedate和picker modetime让用户选择而不是输入。这样前端拿到的永远是2025-01-05、14:30:00这种受控格式后端接口也统一返回 ISO 或固定格式解析压力会小很多。文本解析方案只作为兜底而不是主要入口。4.4 别在 WXML 里做格式化也别高频 setData 原始数据小程序 WXML 模板的能力比 Vue 和 React 弱很多它不支持直接调用 JS 方法。有些刚转小程序的同学会下意识想写{{ formatDate(item.createTime) }}这在 WXML 里是跑不通的。正确做法是在 Page 或 Component 的 JS 层预先格式化把要渲染的字符串直接放进 data。// 反例把原始列表塞进 data想等到渲染时再处理 // this.setData({ list: rawList }); // 正例渲染前一次性加工 var renderList rawList.map(function(item) { return { id: item.id, title: item.title, timeText: formatDate(item.createTime, YYYY-MM-DD HH:mm) }; }); this.setData({ list: renderList });这样改还有一个性能收益如果列表有几十上百条数据每条都存一个完整的 Date 对象或原始字符串setData传输的数据体积会增大而预先格式化成短短十几字节的字符串能显著降低渲染层和逻辑层之间的通信开销。小程序每次setData都会走一次序列化和 diff 计算数据字段越精简越好。至于日期计算本身new Date、getFullYear这些操作的耗时远没有网络请求和setData高真正的性能瓶颈在传输不在格式化那一两毫秒。5. 完整代码与测试用例直接抄作业的版本5.1 测试用例输入五花八门输出一个都不许错写工具类最怕的就是“改了一处细节另一处依赖它的页面挂了”。所以我建议把这个 Util 的测试用例直接写在代码注释里或者单独建一个date-util.test.js跑一遍控制台断言。下面这几个用例覆盖了本节前面提到的绝大多数边界输入调用期望输出new Date(2025, 0, 5, 14, 30, 0)formatDate(input, YYYY-MM-DD HH:mm:ss)2025-01-05 14:30:002025-01-05 14:30:00formatDate(input, YYYY-MM-DD HH:mm:ss)2025-01-05 14:30:002025-1-5 14:30:00formatDate(input, YYYY-MM-DD HH:mm:ss)2025-01-05 14:30:002025-01-05formatDate(input, YYYY年MM月DD日)2025年01月05日2025/01/05 14:30:00formatDate(input, YYYY-MM-DD HH:mm)2025-01-05 14:301577808000000formatDate(input, YYYY-MM-DD HH:mm)2020-01-01 00:00东八区示例实际以本机时区为准helloformatDate(input, YYYY-MM-DD)nullformatDate(input, YYYY-MM-DD)2025-01-05T00:00:00.000ZformatDate(input, YYYY-MM-DD HH:mm)2025-01-05 08:00东八区示例如果你想把断言跑起来可以直接在开发者工具的控制台里执行function assertEqual(actual, expected, desc) { if (actual expected) { console.log(PASS: desc); } else { console.error(FAIL: desc 期望 expected 实际 actual); } } assertEqual(formatDate(2025-01-05 14:30:00, YYYY-MM-DD HH:mm:ss), 2025-01-05 14:30:00, 横线空格格式在 iOS 上也能解析); assertEqual(formatDate(2025-1-5, YYYY-MM-DD), 2025-01-05, 单数字月份补零); assertEqual(formatDate(hello, YYYY-MM-DD), , 无效输入返回空字符串);这里时间戳的输出和本机时区有关测试时不要用带绝对时区的断言可以换成new Date(2020, 0, 1).getTime()作为输入这样它在任何时区都能格式化成本地时间的2020-01-01 00:00。5.2 小程序里的接入方式与扩展思路在项目里复用很简单把date.js放到utils目录在需要的页面 JS 文件里引入即可。const { formatDate, dateAdd, getWeek } require(../../utils/date); Page({ data: { nowText: }, onLoad() { const now new Date(); this.setData({ nowText: formatDate(now, YYYY-MM-DD HH:mm:ss), tomorrowText: formatDate(dateAdd(now, 1), YYYY-MM-DD), weekText: getWeek(now) }); } });如果你的项目里已经引入了 dayjs 这类第三方库也建议保留parseInput这一段兼容逻辑。dayjs 内部对字符串的解析最终还是依赖原生 Date如果你直接dayjs(2025-01-05 14:30:00)在 iOS 上依然可能得到无效日期。正确的用法是先用parseInput归一化成 Date 对象或时间戳再交给 dayjs 处理。换句话说parseInput不只是一套临时补丁它可以作为各种日期库前面的一道“输入清洗层”。最后分享一个小技巧。我们当时排查 iOS 上的 NaN 问题一度以为是setData的数据类型出了状况后来直接在 Safari 里打开同样的页面复现才发现是字符串解析。现在我的习惯是项目在建号第一天就把日期 Util 写好并把测试用例贴在文件顶部注释里。后面接手的人哪怕不知道当年的 iOS 解析差异只要跑一遍用例也知道这个文件是专门用来防各种日期坑的。日期这个东西看着简单但引擎差异、时区、格式偏好叠在一起能玩出很多花来。希望这套封装和排查思路能帮你少走点弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑