ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

小程序日期格式化: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. 动手封装:一个能“抗造”的日期格式化 Util

2.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加YY,MM会被拆成两个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 模板替换为什么用一次正则,而不是链式 replace

formatDate里只做了一次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日 X",X不在映射表里,就原样保留,而不是变成undefined。这样模板容错性更好,也不会把调试信息带到页面上。

4. 别以为只有 iOS 会坑你:时区、getMonth 和 WXML 的性能陷阱

4.1 getMonth() 从 0 开始,拼字符串时最容易翻车

JavaScript 的Date对象里,getMonth()返回值范围是 0 到 11,0 表示一月。这是 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 mode="date">拿到的值是"2025-01-05"这种纯日期字符串,如果不做处理就丢给new Date(),它同样会被解释成 UTC 时间。如果后续又把它转成时间戳传给后端,后端再按东八区解析,日期可能还是对的,但时间会多出 8 小时。正确的做法是先把纯日期字符串转成"2025/01/05"再解析,或者用new Date(year, month - 1, day)这种显式构造方式。这也是为什么parseInput要把非 ISO 格式统一在“斜杠路径”里处理,因为斜杠字符串在各引擎里都按本地时间解析,行为一致。

4.3 用户输入格式不统一:Picker 比兜底解析更靠谱

parseInput的兼容能力再强,也扛不住用户随意输入。现实中你可能收到这些千奇百怪的格式:

  • 2025/1/5
  • 2025-1-5
  • 2025.01.05
  • 2025年1月5日
  • 2025-01-05 下午 2:30

横线转斜杠能解决一部分,但点号分隔、中文年月日、带“下午”这种描述性文本,很难用一套通用规则完全覆盖。往parseInput里堆更多正则当然可以,但每多一条规则,就多一份误判风险。

我的建议是:如果能用控件约束输入,就不要依赖文本解析。小程序场景下,优先使用<picker mode="date">和<picker mode="time">,让用户选择而不是输入。这样前端拿到的永远是"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:00
"2025-01-05 14:30:00"formatDate(input, "YYYY-MM-DD HH:mm:ss")2025-01-05 14:30:00
"2025-1-5 14:30:00"formatDate(input, "YYYY-MM-DD HH:mm:ss")2025-01-05 14:30:00
"2025-01-05"formatDate(input, "YYYY年MM月DD日")2025年01月05日
"2025/01/05 14:30:00"formatDate(input, "YYYY-MM-DD HH:mm")2025-01-05 14:30
1577808000000formatDate(input, "YYYY-MM-DD HH:mm")2020-01-01 00:00(东八区示例,实际以本机时区为准)
"hello"formatDate(input, "YYYY-MM-DD")""
nullformatDate(input, "YYYY-MM-DD")""
"2025-01-05T00:00:00.000Z"formatDate(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 解析差异,只要跑一遍用例,也知道这个文件是专门用来防各种日期坑的。日期这个东西看着简单,但引擎差异、时区、格式偏好叠在一起,能玩出很多花来。希望这套封装和排查思路,能帮你少走点弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进