前些天在翻一个老项目的代码时,看到评论区底部还挂着一串“2024-06-12 14:32:58”这样的完整时间戳,突然觉得特别违和。现在主流社区的评论、动态流、操作日志,早就默认把时间显示成“3分钟前”“昨天”“2小时前”这类相对时间了,谁还愿意去读一个精确到秒的时间戳再去心里换算?我顺手把项目中所有时间展示都换成了 jQuery Prettydate 来做统一处理,整个页面的人味一下子就出来了。
jQuery Prettydate 是一个专门把时间戳或 ISO 日期字符串转换成“人性化相对时间”的小插件,核心价值就一句话:让用户不用做任何心算,一眼就能判断这条内容是刚刚发生的、今天早些时候的,还是一周前的。适合评论系统、聊天记录、订单日志、后台操作审计、内容动态流这类的场景,尤其是那些还以 jQuery 为技术底座的存量项目,接入成本几乎为零。
1. “3分钟前”背后的产品逻辑:时间显示不只是格式化
1.1 从一段真实页面谈起:为什么我抛弃了“2024-06-12 14:32”
在一个老后台管理系统的操作日志页里,每条记录都带一个“发生时间”字段。最初开发图省事,直接new Date().toLocaleString()输出完整时间。测试的时候没觉得有问题,等到真实运营人员用起来,反馈就来了:他们要在一屏里扫几十条记录,每次都要先看到完整时间戳,然后自己默默做减法“这个操作是昨天下午的还是前天下午的”,效率极低,而且很容易看错。
后来我把这个页面切到 jQuery Prettydate,日志时间变成“35分钟前”“昨天 16:20”“3天前”。运营同事再扫页面,不需要任何换算,直接根据时间短语的粒度就能快速判断记录的新旧,肉眼可见地减少了误读。
这里要说明一点:相对时间并不是要取代绝对时间,而是在“快速浏览场景”里做默认显示。真正需要精确追溯时,用户把鼠标悬停到时间上,或者点击查看详情,仍然能看到原始时间戳。好的时间组件应该是“先给结论,再给明细”。
1.2 Prettydate 的定位:它不是 moment.js,也不是 dayjs
经常有人问我:现在都有 dayjs 了,还有必要用这种老插件吗?我的回答是,看场景。
dayjs、Moment.js 这类库本质是“日期处理工具箱”,它们的relativeTime功能需要自己配置、自己封装模板。而 jQuery Prettydate 是一个“即插即用的成品”:引入文件、调用一个函数或一个 jQuery 方法,页面上所有带时间属性的元素就自动变成相对时间了。它不需要你理解什么locale配置、什么fromNow管道,就是一个非常纯粹的转换器。
更关键的是,存量 jQuery 项目里往往已经跑着一堆基于 jQuery 的事件和 ajax 逻辑,引入 Prettydate 不需要改变任何既有架构。如果你的项目压根不用 jQuery,那当然没必要硬上一个 jQuery 插件,直接用原生函数封装几行就够了;但如果你已经在用 jQuery,这个插件就是最小成本、最大收益的选型。
2. 核心原理拆解:从 John Resig 那 20 行经典算法说起
2.1 经典 prettyDate 函数是怎么算的
jQuery Prettydate 的源头可以追溯到 John Resig 在 2008 年写的一篇博客里的 prettyDate 函数。虽然十多年过去,插件后续做了不少封装,但核心算法一直没变。我把它简化为可读性更好的版本,逻辑是下面这样的:
function prettyDate(time) { var date = new Date((time || '').replace(/-/g, '/').replace(/[TZ]/g, ' ')), diff = (Date.now() - date.getTime()) / 1000, dayDiff = Math.floor(diff / 86400); if (isNaN(dayDiff) || dayDiff < 0 || dayDiff >= 31) { return; } return dayDiff === 0 && ( diff < 60 && 'just now' || diff < 120 && '1 minute ago' || diff < 3600 && Math.floor(diff / 60) + ' minutes ago' || diff < 7200 && '1 hour ago' || diff < 86400 && Math.floor(diff / 3600) + ' hours ago' ) || dayDiff === 1 && 'Yesterday' || dayDiff < 7 && dayDiff + ' days ago' || dayDiff < 31 && Math.ceil(dayDiff / 7) + ' weeks ago'; }这个函数的计算过程非常直白:
- 先把 ISO 格式字符串里的
-和T、Z做替换,确保new Date()在 IE 老版本里也能正确解析(这是当年兼容老浏览器的关键一步,现代环境已经不那么必要了)。 - 算出当前时间与目标时间的差值,单位换成秒。
- 再把秒数换算成天数差值
dayDiff。 - 按照“秒级 → 分钟级 → 小时级 → 天级 → 周级”的顺序逐层判断,命中哪个区间就返回哪个文案。
这里的核心,是 JS 的&&和||组合出来的“短路求值”特性:只要命中一个条件,后面的表达式就不会再执行。理解了这个短路逻辑,就很容易自己扩展新的时间阈值。
2.2 双阈值设计:为什么 60 秒之后紧跟着 120 秒
我刚看这段代码时有个疑问:既然diff < 60是“刚刚”,为什么还要单独写一个diff < 120的“1 分钟前”,再往后才是“N 分钟前”?
后来想明白了,这段逻辑刻意把“1 分钟整”这个边界做了特殊处理。如果不加 120 秒的阈值,那么在diff = 90秒时,Math.floor(90 / 60)会得到 1,输出就变成了“1 minutes ago”,语法上很难看。用diff < 120先把“1 分钟整”兜住,直接输出单数形式,就不用去写复数判断了。同理,3600 秒到 7200 秒之间输出 “1 hour ago”,也是为了避免出现 “1 hours ago”。
在中文环境里,我们不需要处理单复数,但这个双阈值思想依然值得借鉴:它可以让输出文案的粒度更加平滑,而不是在临界点上出现突兀的 1/2 跳变。
2.3 超过 31 天:不再“人性化”,而是直接交给调用方
注意dayDiff >= 31的情况,函数直接return了一个 undefined,而不是输出“5 weeks ago”之类的话。这其实是刻意的设计决策。判断逻辑是:超过一个月的时间差异,用户已经很难凭“5 周前”快速感知具体时间点了,这时候强行模糊化反而增加理解成本。所以插件在这里“退位”,把原始时间留给调用方自己去展示。
实际工程里,我通常配合模板这样处理:如果纯函数返回 undefined,那就渲染成YYYY-MM-DD的绝对日期。用户看“2024-06-12”就知道这是两个月前的具体一天,比“8 weeks ago”直观得多。
3. 动手接入 jQuery Prettydate:三种调用方式与验证清单
3.1 文件引入与基础依赖
我手上的项目还在用 jQuery 3.x,所以直接下载了 prettydate 插件文件放在公共静态目录里。引入顺序有讲究,先引 jQuery,再引 jQuery Prettydate:
<script src="/assets/jquery.min.js"></script> <script src="/assets/jquery.prettydate.js"></script>如果是通过 npm 管理的项目,也可以按模块方式引入,本质没什么区别。有一点要提醒:这个插件既然依赖 jQuery 的全局对象jQuery,就必须保证在插件文件加载前,jQuery 已经被加载完成。我见过有人把顺序写反,结果插件注册失败,页面上所有时间都原样显示,控制台报$.prettyDate is not a function。排查方式也简单,打开控制台看类型错误指向哪个方法就行。
3.2 方式一:纯函数调用,处理动态拼接的时间
最简单的用法是直接调$.prettyDate(isoString),传入一个 ISO 8601 格式的时间字符串,函数返回对应的相对时间文案。比如:
$.prettyDate('2024-06-12T14:32:00'); // 假设当前是 2024-06-12 14:35 // 返回 "3 minutes ago"这种做法最适合用在 ajax 动态渲染模板的场景里。比如从后端接口拿到评论列表,在拼接 HTML 时直接调用:
$.ajax({ url: '/api/comments', type: 'GET', dataType: 'json', success: function(res) { var html = ''; res.data.forEach(function(item) { var prettyTime = $.prettyDate(item.created_at) || formatDate(item.created_at); html += '<div class="comment-item">' + '<p>' + item.content + '</p>' + '<span class="time">' + prettyTime + '</span>' + '</div>'; }); $('#commentList').html(html); } });这里有个关键细节:$.prettyDate()如果解析失败或超过 31 天,返回的是 undefined。所以我用|| formatDate(item.created_at)兜底,保证页面上永远有可读的时间文本。千万不要直接把 undefined 拼进模板里,不然页面上会出现一个 “undefined” 字符串,容易让用户以为是 bug。
3.3 方式二:jQuery 方法调用,批量美化既有 DOM
如果你不想动后端返回的数据结构,也不想改模板拼接逻辑,可以直接用选择器选中所有带时间属性的元素,一次性完成转换。这也是这个插件最便捷的地方:
<time title="2024-06-12T14:32:00">2024-06-12 14:32</time> <time title="2024-06-11T08:00:00">2024-06-11 08:00</time>$(function() { $('time').prettyDate(); });执行之后,这两个time元素里的文本会被自动替换成类似 “3 minutes ago” 和 “Yesterday” 的文案。需要注意的是,插件默认从title属性读取原始时间,所以 HTML 里必须给元素加上title属性,并且保持 ISO 格式。我也见过有的版本支持datetime属性,接入前最好看一眼当前版本的源码注释。
3.4 方式三:初始化已完成渲染的整块容器
有时候数据是后端模板引擎直接渲染到页面里的,DOM 初始就存在,但数量很多。此时不必逐个元素调用,可以选中它们的共同容器:
$(function() { $('#messageList time').prettyDate(); $('#notificationList time').prettyDate(); });这么做的好处是避免对整个页面做全局扫描,只处理需要美化的局部区域,性能更可控。我在改造后台日志页时就是这么干的,几十个time元素一次搞定,页面无闪烁。
3.5 完工后的验证清单
替换完成之后,我不会只看“页面上有没有变成相对时间”就收工,而是会过一遍下面这几个检查点:
- 打开控制台,确认没有
undefined文本出现在时间里。 - 用一个未来时间测试(比如
2025-01-01),插件应该返回 undefined 走兜底逻辑,而不是显示 “in the future”。 - 用一个超过 31 天的历史时间测试,确认显示的是绝对日期而不是奇怪的周数。
- 在弱网或 ajax 延迟时刷新页面,确认动态渲染的时间也能被正确转换。
4. 本地化改造:把 “2 hours ago” 变成 “2小时前”,还要符合中文习惯
4.1 改造输出映射的基本思路
英文环境下插件的默认输出是 “30 minutes ago”“Yesterday”这类文案,放到中文项目里显然不合适。不过我用的这个版本,输出文案是集中在一个映射对象里的,可以直接在加载插件之后做一次整体覆盖。
我做的第一版改造,只是简单地把英文文案换成逐字翻译:
| 英文原文 | 直译方案 | 最终采用的方案 |
|---|---|---|
| just now | 刚刚 | 刚刚 |
| 1 minute ago | 1分钟前 | 1分钟前 |
| N minutes ago | N分钟前 | N分钟前 |
| 1 hour ago | 1小时前 | 1小时前 |
| N hours ago | N小时前 | N小时前 |
| Yesterday | 昨天 | 昨天 |
| N days ago | N天前 | N天前 |
| N weeks ago | N周前 | N周前 |
4.2 中文语境下的两个特殊优化
直接翻译能用,但离“好用的中文体验”还有点距离。我在实际项目里做了两个针对性优化。
第一个是“1天前”的处理。中文里单数和复数都叫“天”,没有英文的 day/days 区分,所以这里反而简单了,不需要 7200 秒到 86400 秒之间的特殊阈值,直接统一成“N天前”就行。但我保留了算法里的双阈值分支,只是把文案换成中文,这样哪天要再输出“昨天”这种更口语化的表达,改动成本很低。
第二个是“昨天”和“前天”这种更符合中文表达习惯的称呼。英文习惯说 “Yesterday” 和 “2 days ago”,中文里则常说 “昨天” 和 “前天”。我在覆盖映射时,把dayDiff === 1输出“昨天”,把dayDiff === 2输出“前天”,其余天数统一输出“N天前”。这个细节做完之后,整个时间文案的中文味道就对了。
4.3 和 dayjs 混用的正确姿势
有些页面已经在用 dayjs 做日期格式化,我不想因为引入 prettydate 就两套逻辑打架。我的处理方式是:页面上给用户的“相对时间”显示统一走 prettydate,而需要精确到“某月某日某时某分”的地方仍然用 dayjs 的format('YYYY-MM-DD HH:mm')。两边各管一摊,互不干扰。
如果某个时间展示希望“今天显示时刻,昨天显示昨天+时刻,更早显示完整日期”,我会封装一个统一函数:
function chinesePrettyTime(isoString) { var relative = $.prettyDate(isoString); if (relative) { return relative; } var d = new Date(isoString); var today = new Date(); var startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); var target = d.getTime(); if (target >= startOfToday) { return '今天 ' + d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0'); } if (target >= startOfToday - 86400000) { return '昨天 ' + d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0'); } return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); }这套方案落地之后,评论区、日志页的体验是:今天的记录显示“今天 14:32”,昨天的显示“昨天 09:15”,再早的显示“2024-06-01”,一眼扫过去信息层次非常清晰。
5. 定时刷新与性能:再好的相对时间,不更新也是“死时间”
5.1 为什么必须引入定时刷新
页面加载时把时间转成“3分钟前”,如果用户停留在页面上 5 分钟不操作,那个“3分钟前”就变成假信息了。这在评论区和消息通知场景里会造成误解:用户以为这条内容是刚刚发布的,实际上是几分钟前的。所以凡是做相对时间的页面,必须配套“时间自动走动”的机制。
我的做法是启动一个定时器,每隔 60 秒重新计算一次页面上所有time元素的相对时间:
$(function() { function refreshPrettyTimes() { $('time[title], time[datetime]').prettyDate(); } refreshPrettyTimes(); setInterval(refreshPrettyTimes, 60000); });5.2 为什么是 60 秒而不是 10 秒
有人可能会问:既然是“刚刚”那种秒级变化,为什么不用 10 秒刷新一次?这里其实是性能和体验的平衡。
绝大多数相对时间文案的最小粒度是“分钟”,用户刷新看到“60秒前”和“1分钟前”的感知差异极小。但如果每 10 秒全量重刷一遍所有时间节点,在评论很多、页面还有图表渲染的场景下,会无谓地增加 DOM 操作频率。我一般把刷新间隔设在 60 秒,既保证时间不会严重失真,又不会对页面性能产生明显影响。
5.3 大数据量列表下的性能注意点
如果你的页面一次要渲染几百上千条带时间的条目,全量扫描所有节点再逐一遍历确实会有开销,尤其在低端移动设备上可能出现轻微卡顿。我的优化方案有两个:
第一,控制刷新范围。只刷新当前视口内可见的时间节点,或者按容器分批刷新,避免无关节点被反复重算。比如无限滚动列表里,只对已插入到 DOM 的节点做 prettyDate 处理。
第二,使用requestAnimationFrame替代setInterval,把刷新操作合并到浏览器渲染帧里,减少布局抖动。对于同时还有 echarts 图表的运营看板页面,这个优化尤为重要,因为图表实例本身也在持续重绘,DOM 操作过于频繁会让两者互相抢资源,导致掉帧。
5.4 与 echarts 等重型组件共存的经验
热搜词里提到“将原生 JS、jQuery、ajax、echarts 结合制作网页”,我很理解这种场景。后台看板页面往往左边的数据表格是 jQuery + ajax 渲染的,右边是 echarts 图表,标题栏还有一排“最后更新时间”。如果让这个“最后更新时间”每 60 秒刷新一次,同时 echarts 还在用setInterval轮询更新数据,两个定时器叠加在某些低性能机器上就会卡。
我的经验是:把时间刷新和图表刷新的定时器错开执行。时间刷新固定在第 30 秒触发,图表数据刷新在第 0 秒触发,或者干脆让图表数据刷新完成后再顺带刷新一次时间节点,利用同一个回调减少重复遍历。比如:
function refreshDashboard() { refreshChartData(); // echarts 更新 $('.dashboard-time').prettyDate(); // 时间一并更新 } setInterval(refreshDashboard, 30000);这样页面同时只有一个主定时器在工作,逻辑上也更清晰。
6. 踩坑实录:时区、动态内容、SEO 和 31 天之后的那些坑
6.1 时区陷阱:服务器时间戳和浏览器本地时间不一致
这个坑我印象最深。项目后端在海外服务器,返回的时间字段是带时区偏移的完整 ISO 字符串,比如2024-06-12T08:00:00Z。这种字符串没问题,new Date()会准确把它转成浏览器本地时间,计算差值时是对的。
但另一种情况就麻烦了。后端返回的是无时区的2024-06-12T08:00:00,我一开始没有多想,直接把它丢给 prettyDate。结果页面显示的时间比实际差了 8 小时。原因在于,JavaScript 对没有时区标记的 ISO 字符串可能按本地时间解析,也可能按 UTC 解析,具体行为取决于浏览器实现和字符串格式。如果服务器存的是 UTC 时间,但字符串没有带Z,解析出来的时间戳就错了。
我的对策是:在后端接口统一返回带时区偏移的时间字符串;如果后端改不了,就在前端解析前先做一次标准化处理:
function normalizeIsoString(str) { if (typeof str !== 'string') return str; // 没有时区标记的,按 UTC 处理,补上 Z if (!/[Zz]|[+-]\d{2}:\d{2}$/.test(str)) { return str + 'Z'; } return str; }这个看似不起眼的处理,能让时间差计算在全球任何时区的浏览器上保持一致。
6.2 动态加载内容的初始化时机:ajax 回调里必须再调一次
用插件的时候,很多人只记得在$(function(){})里初始化一次,却忘了 ajax 动态插入的新节点并不会自动被处理。页面初次加载后,用户点“加载更多”拉到的新评论,时间还是原始时间戳,非常突兀。
我踩过的坑就是这个。第一次改造时只在页面初始化时调了 prettyDate,结果翻页加载更多数据后,新渲染的时间全都没被转换。排查时才意识到,ajaxsuccess回调里插入 DOM 之后,需要对新节点再执行一次初始化:
success: function(res) { var html = ''; // ... 拼接 html $('#commentList').append(html); // 新节点插入后立即处理 $('#commentList time').prettyDate(); }更稳妥的做法是只对新容器处理,比如给每次加载的返回数据包一层固定类名,然后精确选中这个容器。这既保证了效率,也不会影响到已经处理过的旧节点。
6.3 SEO 与无障碍:不能让爬虫只看到“3分钟前”
这也是相对时间最容易忽略的问题。搜索引擎爬虫不会等你的脚本执行完再抓取内容,如果页面上的时间全部被 JavaScript 替换成了 “3分钟前”,爬虫抓到的就是一个没有精确日期的时间短语,这对 SEO 是不友好的。同理,屏幕阅读器用户听到的也只是模糊时间,无法获知具体日期。
我的处理原则是:原始时间永远保留在机器可读属性里。jQuery Prettydate 的设计本身就支持这一点,只要原始时间放在title或datetime属性里,展示文本即使被替换,机器仍然能读到底层数据。为了双保险,我还会设置time元素的datetime属性为完整的 ISO 时间,这样爬虫和辅助技术都能拿到精确值:
<time title="2024-06-12T14:32:00" datetime="2024-06-12T14:32:00">3 minutes ago</time>6.4 31 天之后显示什么:给“老龄化时间”一个体面的退路
前面提到,超过 31 天插件会返回 undefined。很多人在这一步犯难,不知道显示什么好。我见过最粗暴的做法是直接把原始时间戳显示出来,比如1718183520000,那体验比英文的 weeks ago 还糟。
我的默认策略是三级退路:一个月内显示相对时间;超过一个月但仍是今年,显示“6月12日”;去年及更早,显示“2024-06-12”。这样信息和阅读成本是逐步递增的,符合用户对时间粒度的预期。
function smartTimeDisplay(isoString) { var relative = $.prettyDate(isoString); if (relative) return relative; var d = new Date(isoString); var now = new Date(); if (d.getFullYear() === now.getFullYear()) { return (d.getMonth() + 1) + '月' + d.getDate() + '日'; } return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); }把这个函数作为通用时间展示出口,在项目里统一调用,后面再遇到“超过一个月显示什么”的问题就不用重复讨论了。
6.5 老项目引入新插件的边界:尽量不污染全局
如果你的项目里已经有大量针对time元素的事件绑定,直接全量执行$('time').prettyDate()可能改变多个页面的行为。我最后一次改造时,特意给需要的元素加了.js-pretty-date类,然后只处理带这个类的节点,避免跟项目里其他逻辑互相干扰。这种做法虽然多了一步标记,但长期维护起来省心得多——你永远不会突然发现某个本来显示完整日期的地方,变成了一串英文相对时间。
7. 写在最后的经验:这类插件的价值边界和我的使用习惯
用 jQuery Prettydate 做了几个项目之后,我的体会是:它不是什么高深的技术,但解决的问题非常具体。时间显示这个细节,恰恰是产品“有没有用心”的最直观体现。一个把时间显示成“2024-06-12 14:32:58”的评论区和一个显示成“5分钟前”的评论区,给用户的温度感是完全不同的。
而且这类老插件的价值不在于“新”,而在于“稳”。核心算法十几年没什么变化,说明它的设计已经足够成熟。即便未来新项目不可能再用 jQuery,我也会借鉴它这套阈值判断思路,自己封装一个原生 JS 版本,几行代码就能复刻。
最后再分享一个小技巧:如果你在同一页面同时处理多个时区的用户,千万别只依赖浏览器本地时间。最稳妥的方案是拿到时间字符串后先在公共函数里统一转成时间戳,再参与差值和文案计算。这样不管用户浏览器设置在哪个时区,相对时间的计算结果都不会乱。