☰
告别1970和差八小时:小程序日期时间戳转换全攻略
2026/10/18 9:14:48 网站建设 项目流程

做小程序日期和时间戳转换,我算是被坑出条件反射了。这几年帮不同团队收拾过时间错乱的线上问题,发现一个共性规律:但凡页面上的时间看起来不对劲,十有八九不是后端给错数据,而是前端在“时间戳 → 日期”“日期字符串 → 时间戳”这一步踩了坑。有人看到列表里全是“1970年1月20日”,有人是苹果手机上白屏、安卓上一切正常,还有人发现自家页面永远比标准时间慢八小时。今天就把这个看似简单、实则全是细节的功能彻底拆开讲一遍,顺便把我平时直接抄进项目里的转换函数、排查套路和接口约定统统交出来。无论你是在写微信小程序,还是在做其他以 JavaScript 为逻辑层的轻量应用,这套思路都能复用。

1. 这个功能为什么值得认真对待

1.1 从一次“1970年”事故说起

我之前接过一个电商类小程序的小程序日期和时间戳转换相关需求,上线后用户反馈活动列表里的出价时间全部显示成 1970 年。查到最后发现:后端接口返回的是 10 位秒,比如1700000000,前端代码却直接把它当成 13 位毫秒扔给了new Date()。new Date(1700000000)在 JavaScript 里会被解析成“1970 年 1 月 20 日 21:33:20 UTC”,整页时间看着就像穿越回了计算机的史前时代。

这个坑定位起来并不难,但暴露了一个很普遍的问题:很多人对时间戳的理解停留在“能转就行”,没有先确认数据单位、再确认解析方式。尤其是跨端协作时,后端同事习惯说“秒”,前端同事习惯说“毫秒”,接口文档一模糊,线上就遭殃。

1.2 时间戳到底是个什么东西

Unix 时间戳的标准定义是:从 1970 年 1 月 1 日 0 点 0 分 0 秒(UTC)开始流逝的秒数。注意两个关键字:一是从 UTC 时刻起算,二是单位是秒。后来因为 JavaScript 的Date对象以毫秒为基准,前端又普遍流行起“毫秒时间戳”,也就是 10 位数字的 1000 倍。

时间戳本身是“无时区”的。全球任何地方在同一物理时刻拿到的时间戳数值完全一样,不存在“北京的时间戳”和“东京的时间戳”。真正有时区概念的是最终展示出来的日期字符串,因为“现在几点”取决于你在哪个时区看钟表。

打个比方:时间戳是一根全球统一的标准刻度尺,上面刻着一个绝对时刻;手机里的Date对象会把这根刻度翻译成本地时间。你在东京,它翻译成“18:30”;你在北京,它翻译成“17:30”,但刻度尺上的数值并没有变。所以时间戳在转字符串时,只要读取的是手机本地时区,不同时区的用户看到的时间就自然不同——这是设计如此,不是 bug。明白这一点后,后面遇到“差八小时”的问题时就不会慌,而是会去想:到底是后端给的字符串没带时区,还是前端错误地把一个 UTC 时间当成本地时间显示了。

1.3 “秒”和“毫秒”分不清是最大事故源

后端接口返回有两种常见风格:

  • 10 位 Unix 秒,比如1700000000
  • 13 位 Unix 毫秒,比如1700000000000

前端拿到数据后,第一步永远先做单位判断。10 位要乘以 1000,13 位直接用。如果接口返回的是 10 位,你忘记乘 1000,new Date(1700000000)就会给你一个 1970 年附近的坑爹结果;如果返回的是 13 位,你又习惯性乘了 1000,那时间会直接多出几十万年,看起来像“525 年后的某一天”,这种一眼假的数据反而容易识别,最怕的是那种只差几小时、让人以为是时区问题的隐蔽偏差。

还有一种更隐蔽的情况:后端字段类型是字符串,返回"1700000000000"。如果直接用new Date("1700000000000"),多数引擎会返回Invalid Date。原因是字符串形式必须符合日期格式才可能被解析,纯数字字符串并不属于合法日期格式。

经验判断法:看到“1970 年”“NaN”“Invalid Date”“所有时间都相同”这类现象,第一件事不是去查时区,而是先确认时间戳单位对不对。

2. 小程序里 Date 解析的兼容性坑

2.1 苹果手机独享的 Invalid Date

微信小程序里调new Date()解析日期字符串,最经典的现象是:安卓真机一切正常,苹果一到手页面就空白,Console 里报Invalid Date。根因在于苹果 JavaScriptCore 引擎对日期字符串的格式要求非常严格,只认 ISO 8601 的写法,也就是类似"2024-03-18T14:30:00"这种用大写字母T分隔日期和时间的格式;安卓的 V8 引擎则宽容得多,"2024-03-18 14:30:00"这种带空格的写法也能解析。

很多教程会教你把横线换成斜杠,比如:

const dateStr = '2024-03-18 14:30:00'; const parsed = new Date(dateStr.replace(/-/g, '/'));

这个写法在某些 iOS 版本上确实有效,利用了引擎对不同分隔符的容忍差异。但严格说,这属于“赌引擎行为”,不同系统版本、不同 WebView 内核下的表现并不能保证完全一致。我见过同一个替换写法在 iOS 15 上正常、在 iOS 12 上照样崩的情况,所以它的定位只能是“临时救急”,不能作为长期方案。

最稳的做法是:根本不让引擎去猜格式,而是自己按正则拆字段,再用数字传参的方式构造Date。new Date(year, monthIndex, day, hour, minute, second)这种写法在所有平台、所有引擎上的行为完全一致,没有任何解析歧义。

2.2 从根本上绕开引擎猜测:手动解析

一个能同时扛住 iOS 和安卓的自定义解析函数是这样写的:

function parseDateString(dateStr) { // 统一处理 '2024-03-18 14:30:00' 或 '2024-03-18' const parts = dateStr.split(' '); const datePart = parts[0].split('-'); const timePart = parts[1] ? parts[1].split(':') : [0, 0, 0]; const year = Number(datePart[0]); const month = Number(datePart[1]) - 1; // 月份从 0 开始 const day = Number(datePart[2]); const hour = Number(timePart[0] || 0); const minute = Number(timePart[1] || 0); const second = Number(timePart[2] || 0); return new Date(year, month, day, hour, minute, second); }

这个函数不考虑2024/03/18这类斜杠格式,也不考虑带时区后缀的 ISO 字符串。只要后端接口统一约定“YYYY-MM-DD HH:mm:ss”,它就足够用。它的好处是:拆出去的Number()转换非常直白,任何一位前端接手都能看懂逻辑,而不是在Date.parse的引擎差异里猜来猜去。

再补充一个容易错的小细节:getMonth()返回的是 0 到 11,getDate()才是 1 到 31。新手写格式化函数时如果只对月份加了 1、对日期忘了处理,最后所有日期都会少一个月。这种错误不会白屏,不会报错,只是所有日期整体偏移,排查起来比 Invalid Date 还费劲。

2.3 getTimezoneOffset 的符号陷阱

另一个隐蔽的坑是getTimezoneOffset()的返回值符号。它会返回“本地时间相对 UTC 的差值,单位分钟”,但方向正好是反的。北京时区是 UTC+8,它返回-480;如果你按直觉理解成“本地比 UTC 慢 8 小时,所以偏移量是加 480”,就会得到完全相反的结果。

这个函数在需要“固定显示北京时间”的需求里经常用到。比如后端返回一个 UTC 的 ISO 字符串,前端要转成北京时间的字符串展示,我一般这样处理:

function formatUTCToBeijing(isoStr) { const date = new Date(isoStr); const localOffset = date.getTimezoneOffset(); // 本地与 UTC 的差,单位分钟 const beijingOffset = 8 * 60; // 北京是 UTC+8 const diff = beijingOffset - localOffset; // 修正量,单位分钟 const beijingDate = new Date(date.getTime() + diff * 60000); return formatTime(beijingDate); }

逻辑是这样的:先拿到当前设备时区和 UTC 的差,再算出它跟北京时区的差,然后把时间戳平移对应毫秒数,最后用本地字段读取方法去读“伪装成北京时间的 Date”。只要目标时区固定为东八区,这套写法在全世界任何角落都能得到一致的北京展示时间。

实操建议:处理时区偏移前,先用一个已知时间戳在两个时区下各打一遍日志,确认计算方向没问题,再写进正式代码。一次调试的成本,远低于线上翻车后全链路排查的成本。

3. 一套可以直接抄走的转换函数库

3.1 日期字符串转时间戳

页面上通常需要把用户输入的日期、或者后端返回的日期字符串转成时间戳,用于传给接口、排序或者比较大小。

function toTimestamp(input) { if (typeof input === 'number') { // 已经是数字 if (input < 1e12) { // 10 位秒,转毫秒 return input * 1000; } return input; } if (typeof input === 'string') { const trimmed = input.trim(); if (/^\d{10}$/.test(trimmed)) { // 10 位秒字符串 return Number(trimmed) * 1000; } if (/^\d{13}$/.test(trimmed)) { // 13 位毫秒字符串 return Number(trimmed); } // 日期字符串 const date = parseDateString(trimmed); if (Number.isNaN(date.getTime())) { return NaN; } return date.getTime(); } return NaN; }

这里的核心设计是根据“输入类型 + 数字长度”自动识别单位,避免调用方每次都要手动判断。10 位和 13 位的区分用正则实现,简单直接,也不会误伤带负号的时间戳(业务里基本不会出现负数时间戳,可以忽略)。

3.2 时间戳转格式化日期

格式化是最常用的需求,目标是把时间戳渲染成2024-03-18 14:30:00这种人类易读的字符串。

function padLeft(num) { return String(num).padStart(2, '0'); } function formatTime(timestamp, pattern = 'YYYY-MM-DD HH:mm:ss') { const date = new Date(timestamp); if (Number.isNaN(date.getTime())) { return ''; } const year = date.getFullYear(); const month = padLeft(date.getMonth() + 1); const day = padLeft(date.getDate()); const hour = padLeft(date.getHours()); const minute = padLeft(date.getMinutes()); const second = padLeft(date.getSeconds()); return pattern .replace('YYYY', String(year)) .replace('MM', month) .replace('DD', day) .replace('HH', hour) .replace('mm', minute) .replace('ss', second); }

支持YYYY-MM-DD、HH:mm等常见模式。为什么不用第三方日期库?小程序包体积本来就敏感,一个成熟的日期库动辄几十 KB,压缩后也有十几 KB,为了一个格式化功能引进一个库并不划算;而且自己实现这套,逻辑完全可控,出了问题直接查自己的代码就够了。

这里还有一个很实用的冷知识:new Date(2024, 1, 31)这种构造方式自带“自动进位”能力。2 月没有 31 号,它会自动算出3 月 2 日。所以计算“下个月”“跨月日期”时,不用自己判断每个月有多少天,交给 Date 本身处理即可,能省掉大量蠢办法。

3.3 相对时间、倒计时与差值的通用实现

“刚刚 / 5 分钟前 / 3 小时前 / 昨天”这种相对时间展示,在列表类页面里几乎是标配。实现思路就是拿当前时间戳减去目标时间戳,然后按区间输出:

function timeAgo(timestamp, now = Date.now()) { const diff = now - Number(timestamp); if (diff < 0) return '刚刚'; if (diff < 60 * 1000) return '刚刚'; if (diff < 60 * 60 * 1000) return Math.floor(diff / (60 * 1000)) + '分钟前'; if (diff < 24 * 60 * 60 * 1000) return Math.floor(diff / (60 * 60 * 1000)) + '小时前'; if (diff < 7 * 24 * 60 * 60 * 1000) return Math.floor(diff / (24 * 60 * 60 * 1000)) + '天前'; return formatTime(timestamp, 'YYYY-MM-DD'); }

超过 7 天直接显示具体日期,这是交互上的常见做法,因为“10 天前”在用户心智里不如“2024-03-08”直观。

倒计时的核心更简单,只需要计算两个时间戳的差值,再做单位拆解:

function getTimeDiff(endTime, startTime = Date.now()) { const diff = Number(endTime) - Number(startTime); if (diff <= 0) { return { days: 0, hours: 0, minutes: 0, seconds: 0, totalSeconds: 0 }; } const totalSeconds = Math.floor(diff / 1000); return { days: Math.floor(totalSeconds / 86400), hours: Math.floor((totalSeconds % 86400) / 3600), minutes: Math.floor((totalSeconds % 3600) / 60), seconds: totalSeconds % 60, totalSeconds: totalSeconds, }; }

不要自己去算“这个月有几天、跨月怎么办”。两个时间戳一减,跨天、跨月、跨年全被自动消化了,你只需要做简单的取整和取余。

3.4 固定时区显示“北京时间”的写法

有些业务要求所有用户看到的时间都按北京时间展示,不管手机设置的是哪个时区。这种场景通常出现在活动页、后台统计页、直播间开播倒计时里。

解析 ISO 字符串并调整到北京时间的完整写法:

function formatISOToBeijing(isoStr) { const date = new Date(isoStr); const localOffset = date.getTimezoneOffset(); // 分钟 const beijingDate = new Date(date.getTime() - (localOffset + 8 * 60) * -60000); // 上面这步等价于:加上 (8*60 - localOffset) 分钟 // 注意 getTimezoneOffset 的符号是反的 return formatTime(beijingDate, 'YYYY-MM-DD HH:mm:ss'); }

实际上写成下面的逻辑更易读:

function formatISOToBeijing(isoStr) { const date = new Date(isoStr); const localOffsetMin = date.getTimezoneOffset(); const beijingOffsetMin = 480; const target = new Date(date.getTime() + (beijingOffsetMin - localOffsetMin) * 60000); return formatTime(target, 'YYYY-MM-DD HH:mm:ss'); }

如果用户手机正好在北京时区,localOffsetMin是 -480,480 - (-480) = 960,但实际上getTimezoneOffset的 -480 已经表示“本地比 UTC 快 8 小时”,加上 960 分钟反而会多出 16 小时。看到没有,符号问题就是很容易把人绕晕。

所以我建议:不要试图在脑子里推导这个偏移方向,直接写一个小工具,用同一个 ISO 时间戳在不同时区的模拟器里验证一遍输出,确认无误后固化成一个公共函数。写进代码里的注释也直接标明“这个函数已在东八区、UTC 区验证过”,后面的人就不会再去乱改。

4. 实际业务里的三类高频场景

4.1 列表流里的“多久前”

订单列表、消息列表、评论列表,基本都要在每个 item 上渲染一个相对时间。常见的错误是直接在 WXML 里写:

<text>{{item.createTime}}</text>

然后为了显示“多久前”,把原始时间戳原样传到页面上,再在 JS 里临时处理。更推荐的做法是在数据层预先处理,把渲染字符串直接算好:

const rawList = res.data.list; const list = rawList.map((item) => ({ ...item, timeText: timeAgo(item.createTime), })); this.setData({ list });

这样页面只管展示,不需要再关心时间戳逻辑。处理时要注意一个点:如果后端返回的是createTime字符串而不是时间戳,先用toTimestamp转一下,再传给timeAgo,否则Number('2024-03-18 14:30:00')会得到NaN。

4.2 秒杀倒计时和跨天计算

限时秒杀、活动开始倒计时这类需求,核心是每秒触发一次重算。这里有一个性能上的细节:不要每次setInterval都去请求接口或重新解析开始时间,而是在拿到活动开始时间戳之后就存起来,定时器里只做减法:

Page({ data: { countdownText: '', }, onLoad() { // 假设从接口拿到活动开始时间戳 this.startTime = 1710000000000; this.startTimer(); }, onUnload() { this.clearTimer(); }, startTimer() { const update = () => { const diff = getTimeDiff(this.startTime); const text = diff.totalSeconds > 0 ? `${diff.days}天 ${diff.hours}时 ${diff.minutes}分 ${diff.seconds}秒` : '活动已开始'; this.setData({ countdownText: text }); }; update(); this.timer = setInterval(update, 1000); }, clearTimer() { if (this.timer) { clearInterval(this.timer); this.timer = null; } }, });

特别注意:小程序的页面在切到后台时,定时器并不会自动停止,但 JS 线程会被冻结,回到前台时定时器一下子补执行多次。这会导致倒计时出现“跳秒”现象。稳妥做法是在onHide里清掉定时器,在onShow里重新启动。用时间戳差值还有一个额外好处:就算定时器在后台被冻结了十分钟,回到前台时重新计算一次getTimeDiff,结果依然是准确的,不会累积误差。

4.3 按日期分组的会话/动态列表

聊天记录、动态流、订单记录经常需要按“今天”“昨天”“更早”分组。实现的核心是先拿到每个 item 的“日期 key”,再按 key 聚合:

function getDateKey(timestamp) { return formatTime(timestamp, 'YYYY-MM-DD'); } function groupByDate(list) { const groups = []; const map = {}; list.forEach((item) => { const key = getDateKey(item.createTime); if (!map[key]) { map[key] = []; groups.push({ key, items: map[key] }); } map[key].push(item); }); return groups; }

渲染时再根据 key 和今天的差距显示“今天”“昨天”或者具体日期。分组逻辑完全不依赖日历计算,只依赖格式化函数的一致性,所以任何时区问题都会同步暴露在分组边界上,排查起来也简单。

这里有个容易被忽略的坑:如果用户手机时间不准,分组会乱。比如用户把手机调快了 5 分钟,某条消息明明刚发出来,可能已经出现在“昨天”分组里。服务端如果提供一个统一的当天开始时间戳,前端只负责把 item 的时间戳映射到“距离今天开始时间的差值”,就能绕开用户本地时间不准确的问题。在电商、内容社区这类对时间敏感的 App 里,这个约定值得和后端提前沟通。

5. 排查时间问题的实操路径

5.1 症状对照表:错到什么程度,对应什么原因

我把这些年遇到的时间错乱问题整理成了一张对照表,排查时直接按症状找原因:

症状大概率原因排查方向
全部显示 1970 年附近10 位秒被当成 13 位毫秒检查是否漏乘 1000
时间相差 8 小时整UTC 字符串被当本地时间,或本地字符串被当 UTC检查接口字符串有没有带时区标识
iOS 白屏 / Invalid Date日期字符串格式不被 iOS 解析改用手动解析,不要依赖new Date(str)
所有日期少一个月getMonth()忘记加 1检查格式化函数
时间多出几十万年毫秒被当成秒,或重复乘 1000检查单位换算
秒和分钟对不上、显示 1000 倍偏差毫秒当秒处理检查传入new Date()的数值范围
只有某几个机型错乱系统 WebView 版本差异优先用手动解析,规避引擎差异

这张表不是万能的,但能覆盖 90% 的日常问题。遇到没见过的现象时,先打印原始值、处理后数值、Date对象三个层面的日志,通常几行日志就能定位问题。

5.2 模拟器正常、真机错乱的元凶

微信开发者工具的模拟器是跟随电脑系统时区的,真机则跟随手机系统时区。如果你电脑在北京时区、手机在别的时区,同一个时间戳在两端显示自然不同,看起来就像“模拟器正常,真机错乱”。

还有一个容易被忽略的细节:切换时区后,微信开发者工具可能不会自动刷新基础库里的Date行为,需要彻底重启开发者工具,甚至清掉编译缓存,才能模拟新的时区环境。我曾经调试一个“差八小时”问题,改了半天代码发现在模拟器里根本复现不了,最后重启工具才正常,浪费了不少时间。

实战建议:真机调试时,把手机系统时区改成东京、洛杉矶、伦敦各跑一遍页面。如果时间显示稳定不变,说明你的代码对时区不敏感;如果变了但符合预期,说明业务上就是按设备时区展示;如果变了且不符合预期,那就是时区处理有 bug。

5.3 和后端定接口时的三条约定

很多时间问题是接口设计埋下的雷,前端只能被动接雷。如果让我给后端同事提三个约定,一定是这三点:

  1. 时间字段统一用毫秒时间戳,不要一会儿秒、一会儿毫秒,更不要返回字符串数字让前端自己猜单位。
  2. 字段命名说清楚类型:以ts结尾表示时间戳,以time结尾表示格式化字符串,一眼能看出类型,减少沟通成本。
  3. 如果有返回日期字符串的场景,必须带时区标识。要么用 ISO 格式2024-03-18T06:30:00Z,要么直接返回已经按目标时区格式化好的字符串,千万别返回“2024-03-18 14:30:00”这种既没有时区、又容易被不同引擎差异解析的中间产物。

这三条约定落实下来,能减少至少 80% 的时间相关线上问题。前端这边也尽量在公共模块里统一封装toTimestamp、formatTime、timeAgo,不要在业务代码里到处new Date(...),否则同一个 bug 会在十几个页面里重复出现,改起来让人头皮发麻。

最后再分享一点个人体会:日期和时间戳转换这个功能,看起来是人人都能写两行的“小工具”,但真正经历过线上事故的人都知道,它涉及时间单位、时区语义、字符串格式、引擎差异四个层面的知识。把这四层想清楚,写出来的代码不仅能在小程序里跑得稳,换到任何 JavaScript 运行环境里都不会出大问题。如果你的项目正在被“1970”或者“差八小时”折磨,不妨按上面这套函数重构一遍,再针对自己的场景补几个边界用例,时间这块基本就不会再闹脾气了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询