
前阵子做一个日志检索界面后端给的查询条件里有一个时间字段长这样2024-06-15 14:30:00。我在QML里拿到这个字符串第一反应是new Date(str)然后getTime()拿时间戳再Qt.formatDateTime做显示。结果预估三分钟的事实际排了半个多小时——console.log打出来是一个Invalid Date。后来我把QML里日期解析这套东西彻底捋了一遍才发现坑比想象中多Date.parse不稳定、时区容易被忽略、时间戳还有秒和毫秒的区别。这篇文章就把“字符串转日期、日期转时间戳、时间戳再转显示格式”整条链路写清楚也给出一份可以直接复用的DateUtils.js。不管你是刚接触QML还是从C Qt转过来做界面只要项目里要处理后端返回的时间字段这篇文章应该能帮你少走不少弯路。1. 先认清QML里的日期体系Date对象、Qt工具函数和C侧的关系1.1 QML里的Date其实就是ECMAScript标准对象QML内置的JavaScript引擎从Qt 5.2开始就是V4引擎它实现了大部分ECMAScript标准所以你在QML里能直接使用Date对象、Math对象这些JS原生能力。很多人容易把QML里的Date和C的QDate、QDateTime混在一起其实它们完全是两套东西C侧QDate只管日期QTime只管时间QDateTime是日期时间底层用msecsSinceEpoch()或toSecsSinceEpoch()表示时间戳。QML侧能用的就是ECMAScript的Date内部只存一个数字单位是毫秒代表从1970-01-01T00:00:00Z到当前时刻的毫秒数。所以在写QML日期逻辑之前先要把脑子里C那套清空QML里没有现成的QDateTime::fromString所有字符串解析都得自己写或用JS语法。1.2 Qt.formatDateTime不是万能的能输出但不负责解析QML里最常用的日期辅助函数是Qt.formatDate、Qt.formatTime、Qt.formatDateTime它们的作用是把一个Date对象格式化成字符串。比如var d new Date(); var text Qt.formatDateTime(d, yyyy-MM-dd hh:mm:ss); console.log(text); // 输出类似 2024-06-15 14:30:00这套yyyy、MM、dd、hh、mm、ss的格式标记和QDateTime::toString的格式接近用起来确实方便。但请注意Qt.formatDateTime只做“Date 转字符串”这一个方向。QML没有内置的“字符串转Date”函数你翻遍文档也找不到Qt.parseDateTime这类东西。所以字符串解析只能靠JS写或者借助C方法从外部转好了再传给QML。1.3 时间戳的本质从1970-01-01T00:00:00Z算起的毫秒数Date对象内部就是时间戳理解这一点很重要。常用API可以这样归纳API作用单位常见坑date.getTime()返回Date内部毫秒时间戳毫秒不是秒别直接拿去给后端Date.now()返回当前毫秒时间戳毫秒和new Date().getTime()等价new Date(ms)毫秒时间戳构造Date毫秒传秒级时间戳会得到1970年的日期Date.UTC(y, m, d, h, min, s)按UTC语义构造毫秒时间戳毫秒月份从0开始简单类比一下时间戳就是一个“绝对时间的坐标点”它本身跟时区无关。同一个时间戳在纽约和北京用getHours()显示出来的数字不一样因为getHours()返回的是本地时区换算后的小时但getTime()在任何设备上都一样。这个特性后面做时区处理时会反复用到。2. 字符串转日期从固定格式拆分到通用模板解析2.1 翻车现场为什么new Date(2024-06-15 14:30:00)在QML里经常是Invalid Date我在浏览器里写JS时new Date(2024-06-15 14:30:00)基本都能解析出一个正常Date对象所以到了QML里我理所当然地直接用了。结果QML控制台打出来一个Invalid Date当时我第一反应是“我代码写错了”后来把字符串换成2024-06-15T14:30:00中间带个大写的T又能解析了。原因在ECMAScript规范里标准明确要求JS引擎必须支持的是ISO 8601格式比如2024-06-15T14:30:00.000Z这种带T带时区的写法。而2024-06-15 14:30:00这种带空格、不带时区后缀的字符串规范说“实现可以自行决定是否解析”。浏览器的V8引擎比较宽容能解析很多非标准格式但QML的V4引擎明显选择了收紧策略导致同一个字符串在Chrome里能用、在QML里就是Invalid Date。这个坑的直接教训是不要依赖Date.parse或new Date(str)去解析业务字符串老老实实自己拆字段或者用日期库。2.2 最推荐的一步到位按格式拆分后构造Date如果你要解析的字符串格式固定比如后端统一返回2024-06-15 14:30:00最高效可靠的做法是用正则拆分然后再new Date(year, monthIndex, day, hour, minute, second)构造。function parseStandard(str) { var m str.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; var year parseInt(m[1], 10); var month parseInt(m[2], 10); var day parseInt(m[3], 10); var hour parseInt(m[4], 10); var minute parseInt(m[5], 10); var second parseInt(m[6], 10); var date new Date(year, month - 1, day, hour, minute, second); // 回读校验2月30日这类非法日期会被Date自动进位 if (date.getFullYear() ! year || date.getMonth() ! month - 1 || date.getDate() ! day) { return null; } return date; }这里有两个关键点要解释清楚第一new Date(year, month, day, ...)的月份参数是从0开始的。1月是02月是1所以真实月份必须减1。很多初学者在这个地方踩坑传进去month 6结果得到的是7月的日期。第二为什么构造之后还要“回读校验”因为JS的Date构造函数非常“宽容”如果你传入new Date(2024, 1, 30)2月没有30号Date会自动进位成3月1号并且不报任何错误。等你在界面上显示的时候用户看到日期变成了3月1日但你根本不知道是什么时候错的。所以解析函数里必须做一次回读校验年月日是否和输入一致。我给的parseStandard还兼容了T和空格两种分隔符。后端如果返回的是2024-06-15T14:30:00同样能解析这样接口小范围调整时你不用改代码。2.3 用正则兼容常见的杂格式实际项目里字符串可不止一种有的接口返回斜杠日期2024/06/15 14:30:00有的日志系统返回纯数字时间戳字符串20240615143000还有的带毫秒2024-06-15 14:30:00.123。如果你不想为了每种格式都写一个函数可以写一个更宽松的parseFlexible。function parseFlexible(str) { if (!str) return null; // 匹配 2024-06-15 14:30:00.123 / 2024/06/15 14:30:00 / 2024-06-15T14:30:00 var m str.match(/^(\d{4})[-\/](\d{2})[-\/](\d{2})[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/); if (m) { var year parseInt(m[1], 10); var month parseInt(m[2], 10); var day parseInt(m[3], 10); var hour parseInt(m[4], 10); var minute parseInt(m[5], 10); var second parseInt(m[6], 10); var ms m[7] ? parseInt(m[7], 10) : 0; return new Date(year, month - 1, day, hour, minute, second, ms); } // 匹配 20240615143000 这种14位纯数字 var m2 str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/); if (m2) { return new Date(parseInt(m2[1], 10), parseInt(m2[2], 10) - 1, parseInt(m2[3], 10), parseInt(m2[4], 10), parseInt(m2[5], 10), parseInt(m2[6], 10)); } return null; }这种宽松解析适合做数据清理、日志导入这类场景。但要注意宽松解析只适合你明确知道数据来源的格式如果格式五花八门到无法穷举问题就不在解析函数而是应该找上游接口统一规范。2.4 进阶写一个按format模板逆向解析的通用函数比parseFlexible更进一步的做法是模仿Qt.formatDateTime的格式模板写一个通用的parseDateTime(str, format)。这样你只需要写一个函数就能支持yyyy-MM-dd HH:mm:ss、yyyy/MM/dd HH:mm:ss、dd.MM.yyyy HH:mm等不同习惯的格式。核心思路其实不复杂把模板里的yyyy、MM、dd、HH、mm、ss、zzz这些token替换成正则捕获组比如yyyy替换成(\d{4})MM替换成(\d{1,2})。模板里的其他字符横杠、冒号、斜杠、空格做正则转义。然后用生成的正则去匹配输入字符串把捕获到的字段取出来构造Date。function escapeRegExp(s) { // 转义正则特殊字符 return s.replace(/[-\/\\^$*?.()|[\]{}]/g, \\$); } function parseDateTime(str, format) { if (str null || str undefined || str ) return null; format format || yyyy-MM-dd HH:mm:ss; var tokenDefs [ { token: yyyy, name: year, pattern: (\\d{4}) }, { token: MM, name: month, pattern: (\\d{1,2}) }, { token: dd, name: day, pattern: (\\d{1,2}) }, { token: HH, name: hour, pattern: (\\d{1,2}) }, { token: hh, name: hour, pattern: (\\d{1,2}) }, { token: mm, name: minute, pattern: (\\d{1,2}) }, { token: ss, name: second, pattern: (\\d{1,2}) }, { token: zzz, name: ms, pattern: (\\d{1,3}) } ]; var regexParts []; var captured {}; var i 0; while (i format.length) { var matched false; for (var k 0; k tokenDefs.length; k) { var token tokenDefs[k].token; if (format.substr(i, token.length) token) { if (!captured[tokenDefs[k].name]) { captured[tokenDefs[k].name] regexParts.length 1; } regexParts.push(tokenDefs[k].pattern); i token.length; matched true; break; } } if (!matched) { regexParts.push(escapeRegExp(format.charAt(i))); i 1; } } var m str.match(new RegExp(^ regexParts.join() $)); if (!m) return null; function getVal(name, defaultVal) { if (captured[name]) return parseInt(m[captured[name]], 10); return defaultVal; } var year getVal(year, 0); var month getVal(month, 1); var day getVal(day, 1); var hour getVal(hour, 0); var minute getVal(minute, 0); var second getVal(second, 0); var ms getVal(ms, 0); var date new Date(year, month - 1, day, hour, minute, second, ms); // 回读校验 if (date.getFullYear() ! year || date.getMonth() ! month - 1 || date.getDate() ! day || date.getHours() ! hour || date.getMinutes() ! minute || date.getSeconds() ! second || date.getMilliseconds() ! ms) { return null; } return date; }这个实现里hh和HH我都统一按24小时制处理不区分12小时制。如果你需要支持AP/A这种上下午标记可以在token表里再加逻辑就是判断hour 12这里不展开。模板解析最大的价值是当项目里不同模块用了不同日期格式时你不用维护七八个解析函数只传不同format就行。3. 日期与时间戳互转毫秒原点、秒级换算与格式化输出3.1 getTime()、Date.now()与new Date(ms)是互转的全部字符串解析完成后下一步通常就是转时间戳。这个转换其实就是三件套var date new Date(); // 当前时间 var ms date.getTime(); // Date - 毫秒时间戳 var back new Date(ms); // 毫秒时间戳 - Date console.log(ms back.getTime()); // true后端如果要求你传时间戳给查询接口传ms就行。前端如果拿到一个时间戳要显示先new Date(ts)得到Date对象再格式化。整个过程没有别的魔法。要注意的是getTime()返回的是毫秒很多后端接口习惯用秒级时间戳这里的换算很容易出错。后面我单独拿出一节说。3.2 后端惯用的10位秒级时间戳如何安全换算后端接口最常见的时间戳是10位秒级也就是Unix时间戳的标准形式例如1718433000。JS的Date最小精确到毫秒所以秒级时间戳必须乘以1000才能给new Date使用。function unixToDate(sec) { return new Date(sec * 1000); } function dateToUnix(date) { return Math.floor(date.getTime() / 1000); }为什么dateToUnix要用Math.floor因为如果当前时间带毫秒比如1718433000123除以1000得到1718433000.123而后端期望的是整数秒直接Math.floor丢掉小数部分。如果用Math.round在毫秒超过500时会进位可能把一个还没到的未来时间戳传给后端时间上会出现轻微的“未卜先知”。这种细节在联调时很难发现但会造成数据统计在边界处差一秒。我之前对接过不同后端语言时间戳单位可以说是五花八门时间戳形态位数典型来源秒级10位MySQL unix_timestamp、很多Java老接口毫秒级13位Java System.currentTimeMillis()、JS Date.now()微秒级16位部分嵌入式上报协议纳秒级19位Go time.Now().UnixNano()所以拿到一个时间戳先看位数基本能猜出单位。但更稳妥的方式是看接口文档不要凭位数猜。位数一旦分辨错误整个时间都会错乱。3.3 把时间戳格式化成yyyy-MM-dd HH:mm:ss的标准写法前端拿到毫秒时间戳之后最终要显示给用户所以要有一个格式化函数。我会自己写一个formatDate而不是每次都调Qt.formatDateTime因为自定义函数能全局替换token也更方便单元测试。function pad2(n) { return n 10 ? 0 n : n; } function pad3(n) { return n 10 ? 00 n : n 100 ? 0 n : n; } function formatDate(date, format) { if (!(date instanceof Date) || isNaN(date.getTime())) return ; var tokens [ { k: yyyy, v: date.getFullYear() }, { k: MM, v: pad2(date.getMonth() 1) }, { k: dd, v: pad2(date.getDate()) }, { k: HH, v: pad2(date.getHours()) }, { k: hh, v: pad2(date.getHours()) }, { k: mm, v: pad2(date.getMinutes()) }, { k: ss, v: pad2(date.getSeconds()) }, { k: zzz, v: pad3(date.getMilliseconds()) } ]; var result format; for (var i 0; i tokens.length; i) { result result.split(tokens[i].k).join( tokens[i].v); } return result; }这里用split().join()是为了全局替换防止string.replace(tokens[i].k, ...)只替换第一处。比如模板里出现两个dd或者替换完yyyy后后面的字符串又被误操作用split().join()更稳。pad2是做补零的月份是6就显示成06小时是9就显示成09。很多显示问题其实不是因为时间戳算错而是忘了补零界面上出现2024-6-9 9:5:3这种很业余的效果。配合new Date(ms)一行就能完成时间戳转显示字符串var text formatDate(new Date(ms), yyyy-MM-dd HH:mm:ss);如果你图省事直接用Qt.formatDateTime(new Date(ms), yyyy-MM-dd hh:mm:ss)效果一样。区别在于Qt.formatDateTime的hh在Qt里是00-23的小时而很多人从Java/JS的习惯过来会把hh当成12小时制。为了避免团队认知分歧我自定义的formatDate里hh和HH统一按24小时制处理至少在项目内部不会出现两套理解。3.4 关于毫秒时间戳精度我的一点提醒QML和JS的Date精度只到毫秒这是ECMAScript标准决定的。如果后端返回的是微秒或纳秒级时间戳比如1718433000123456你直接new Date(1718433000123456)得到的其实不是真实时间因为超出毫秒精度的部分会被Date内部处理成一个完全错误的值。处理高精度时间戳的正确姿势是让后端在返回前就把单位换算成毫秒或秒或者把原始高精度时间戳作为字符串原样返回前端只做展示不要轻易parseInt后去构造Date。还有一个常见场景是通信双方做时间同步比如设备上报一个“采集时间戳”单位到底是秒还是毫秒必须在协议里写明。我在实际项目里见过两次设备端以为是毫秒服务端按秒解析结果所有数据时间全部错了八万年。这种事情一旦上线排查成本很高。所以我在对接时都会在接口文档里标一句时间戳统一按UTC毫秒传递字符串统一按带时区ISO格式传递。4. 时区、无效日期和异常处理最容易排错一晚上的地方4.1 本地时间与UTC为什么new Date(0).getHours()在中国是8时区是日期解析里最大的暗坑。先看这段代码var d new Date(0); console.log(d.toISOString()); // 1970-01-01T00:00:00.000Z console.log(d.getHours()); // 在中国是8在美国西部可能是17new Date(0)生成的Date对象在时间戳上就是1970-01-01T00:00:00Z这个瞬时时刻全球一致。但getHours()返回的是本机时区换算后的小时中国在东八区所以看到的是8点。这意味着什么意味着new Date(year, monthIndex, day, hour, minute, second)这套构造方式默认把参数当成本地时间来构造Date。而Date.UTC(year, monthIndex, day, hour, minute, second)则是把参数当成UTC时间来构造毫秒时间戳。两种写法得到的getTime()完全不同var a new Date(2024, 5, 15, 14, 30, 0); // 参数按本地时间解释 var b new Date(Date.UTC(2024, 5, 15, 14, 30, 0)); // 参数按UTC解释 console.log(a.getTime() b.getTime()); // 在中国为false相差8小时所以当你从字符串解析出一个“2024-06-15 14:30:00”时必须先问自己这个时间到底是服务器记录的UTC时间还是客户端本地时间这决定了用new Date(...)还是Date.UTC(...)。如果这个字符串来自后端数据库字段类型是DATETIME通常是服务端所在时区的本地时间。如果字段类型是TIMESTAMP底层其实存的是UTC时间但展示时由客户端转换。最省心的做法是前后端约定所有接口统一返回毫秒时间戳或带时区后缀的ISO字符串不要返回光秃秃的本地时间字符串。4.2 带Z或08:00时区后缀的字符串到底怎么解析ISO 8601字符串经常带时区后缀比如2024-06-15T14:30:00Z表示UTC时间2024-06-15T14:30:0008:00表示东八区时间。这种格式不能简单用new Date(year, month-1, day, hour, minute, second)处理因为后缀的偏移量要和字面时间叠加。一个可靠的解析思路是先把字符串里的字面时间用Date.UTC算出一个“假想UTC时间戳”再减去时区偏移量得到真实的UTC时间戳。代码如下function parseISOWithZone(str) { var m str.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|([-])(\d{2}):(\d{2}))?$/); if (!m) return null; var year parseInt(m[1], 10); var month parseInt(m[2], 10); var day parseInt(m[3], 10); var hour parseInt(m[4], 10); var minute parseInt(m[5], 10); var second parseInt(m[6], 10); // 先把字面时间当成UTC算出临时毫秒 var utcMs Date.UTC(year, month - 1, day, hour, minute, second); var offsetMs 0; if (m[7] Z) { offsetMs 0; } else if (m[7]) { var offsetMin parseInt(m[9], 10) * 60 parseInt(m[10], 10); if (m[8] -) offsetMin -offsetMin; offsetMs offsetMin * 60000; } var date new Date(utcMs - offsetMs); // 回读校验 if (date.getFullYear() ! year || date.getMonth() ! month - 1 || date.getDate() ! day) { return null; } return date; }举个例子字符串是2024-06-15T14:30:0008:00。字面时间是14:30时区偏移是8小时那么真实UTC瞬时是14:30 - 8小时 06:30。utcMs先按14:30算再减去8小时的毫秒数得到的就是2024-06-15T06:30:00Z对应的时间戳。这样无论在哪个时区的设备上解析getTime()完全一致显示成本地时间时也能正确转换。这套代码不管QML还是纯JS环境都能跑不需要依赖IntlV4引擎完全支持。4.3 2月30日、空字符串和null解析函数必须有的防御日期解析函数最容易在边界数据上翻车。常见的边界条件有三个第一非法日期自动进位。前面说过new Date(2024, 1, 30)会变成3月1日。所以每个解析函数我都做了回读校验解析后年份、月份、日期对不上就直接返回null。第二空字符串和null。很多接口在时间字段为空时会返回空串、null甚至一个非法格式的占位符。解析函数必须在入口就拦住这些值返回null而不是让后续代码拿着一个Invalid Date继续算。第三不要在解析函数里抛异常。QML的属性绑定对异常的处理不像C那样有完整的try-catch流程一个绑定函数抛异常可能导致整个绑定链失效界面上某个控件直接不更新那种问题排查起来非常头疼。所以我的习惯是解析函数只返回Date或null格式化函数只返回字符串或空串所有异常都在业务侧统一判断。var d DateUtils.parseDateTime(rawTime, yyyy-MM-dd HH:mm:ss); if (d) { text DateUtils.formatDate(d, yyyy/MM/dd); } else { text --; }这样的代码可读性也更好UI层一眼就能看出空值处理逻辑。4.4 一个可直接复用的DateUtils.js完整版把前面几节的函数汇总到一个DateUtils.js文件里放到项目的shared/qml目录所有页面统一import使用import DateUtils.js as DateUtils Item { Component.onCompleted: { var raw 2024-06-15 14:30:00; var d DateUtils.parseDateTime(raw, yyyy-MM-dd HH:mm:ss); console.log(d.getTime()); // 毫秒时间戳 var ms 1718433000000; console.log(DateUtils.fromTimestamp(ms, yyyy-MM-dd HH:mm:ss)); console.log(DateUtils.formatDate(new Date(), yyyy/MM/dd HH:mm:ss)); } }DateUtils.js的函数清单建议包含函数作用parseStandard(str)解析2024-06-15 14:30:00或2024-06-15T14:30:00parseFlexible(str)解析斜杠、空格、纯数字等杂格式parseDateTime(str, format)按模板解析最通用parseISOWithZone(str)解析带Z或08:00的ISO字符串formatDate(date, format)Date转字符串支持yyyy/MM/dd/HH/mm/ss/zzzfromTimestamp(ms, format)毫秒时间戳转字符串toTimestamp(str, format)字符串转毫秒时间戳失败返回nullunixSec(date)Date转10位秒级时间戳把解析和格式化收敛到一个JS模块里之后整个项目的时间处理口径就统一了。后续要支持新格式只需要在这个文件里加函数不用到处改UI代码。5. 高频调用场景的性能取舍不要在每一帧里反复转换5.1 动画、图表刷新场景下的临时字符串问题日期转换本身不复杂但如果在高频刷新场景里滥用性能问题非常明显。我见过一个实时曲线页面底层模型每200毫秒推一个时间戳界面上几个Text元素同时做这样的绑定text: DateUtils.formatDate(new Date(model.timestamp), yyyy-MM-dd HH:mm:ss)当时在PC上跑还好换到嵌入式设备上整个界面掉帧明显。原因是formatDate内部有多次字符串split().join()每次都会产生临时字符串再加上new Date(...)、getTime()等操作QML的垃圾回收器会频繁回收这些小对象渲染线程被拖住。这个问题的本质是字符串是值类型每次拼接和替换都会重新分配内存。而QML的属性绑定一旦依赖某个时间戳变化就会重新执行整个表达式。高频更新时反复的字符串分配和GC就是性能杀手。5.2 实用优化思路预转换、缓存与数据层处理优化的核心思路很简单能不在UI层算就不要在UI层算。如果数据源是C侧提供的最好的方案是在C的model层就把时间戳转换成显示用的字符串QML只负责展示字符串。C的QDateTime::toString性能不差而且可以把格式化逻辑和UI解耦。如果是纯QML项目也有几个可行手段第一用缓存。同一个时间戳在短时间内重复格式化结果一定是一样的没必要每次重新计算。可以做一个简单的缓存Map以输入时间戳为key格式化结果做value。但要注意容量不要无限增长否则又引入了新的内存问题。做1000条以内的LRU或者直接定长覆盖都行。第二降低刷新频率。如果只是显示实时时间比如“当前时间”这种没必要每帧更新用Timer每500毫秒刷新一次就够用户感知了。游戏循环里的deltaTime只用数字运算不要转成字符串。第三不要在动画onPaint里调用任何格式化函数。动画引擎每帧调用批量属性更新如果每次更新都做字符串分配帧率必然受影响。把格式化结果先算好存到普通属性再交给动画绑定代价小很多。第四数据进QML之前先转换。从网络回调拿到JSON后第一时间在数据处理函数里把时间字段变成显示字符串或Date对象不要在Text的绑定表达式里才开始解析。这样绑定表达式足够简单也方便排查。5.3 绑定表达式保持简单一切封装进纯函数最后再说一个长期受益的习惯QML里所有日期相关逻辑都封装成纯函数别让界面层承担解析细节。property string displayTime: { var d DateUtils.parseDateTime(rawTime, yyyy-MM-dd HH:mm:ss); return d ? DateUtils.formatDate(d, MM.dd HH:mm) : --; }这样写有几个好处界面层只关心“传入字符串得到展示文本”解析失败时返回一个安全的占位符不影响任何绑定链单元测试时可以直接用Qt Test的TestCase去测DateUtils.js里的函数验证各种边界输入不用启动整个UI。我在项目里维护DateUtils.js时固定会在里面加一组测试样例比如“A正常格式返回Date” “A空字符串返回null” “A2月30日返回null” “A带时区后缀按偏移解析”。每次改动解析逻辑先跑一遍这些用例基本不会在回归时出问题。最后再分享一个经验和后端约定时间字段时能传时间戳就传时间戳能带Z就带Z纯字符串最容易产生分歧。如果必须用2024-06-15 14:30:00这种格式一定要在接口文档里写清楚是本地时间还是UTC。前端代码写得再稳也架不住两边对“同一个字符串到底代表哪个时间”理解不一致。日期解析这条链路工具只是最后一公里前面的规范和约定才是真正省时间的部分。