大家好,这里是大家的林语冰。
本文属于是语冰的直男翻译了属于是,仅供粉丝参考,原文请临幸 英文原味版。
JS(JavaScript)中的 Date 烂透了。好吧,讲真的,它们在所有语言中都一样烂。比较反直觉的是,这件事要做好本身就很困难。
原生 Date
的能力超级有限。当然,你可以:
new Date('2015-10-21T01:22:00.000Z')
date.toISOString()
dateA < dateB
(也许能行)
虽然但是,仅此而已。你需要为一个日期添加分钟、小时或者其他东东,检查距离某日期还有多少天等等。祝你好运(注 1)。
注 1:
是的,你可以原生添加毫秒乘以单位,但这在很多情况下并不准确。忘记处理时区、DST 等等。
于是乎,社区造了很多轮子来"技术扶贫"。曾几何时,MomentJS 乃最佳实践。2020 年(注 2),该项目添加了一个项目状态网页,官宣 MomentJS 废弃为一个遗留项目,并且建议诸君切换赛道。Luxon 正是备胎之一,事实上,该项目时过境迁依旧优秀。
注 2:
请临幸 提交。
虽然但是,如果诉诸浏览器、NodeJS、Deno、Bun 或者其他也许下周发布的新型 JS 运行时,提供一个开箱即用的标准解决方案,那不是很棒棒吗?
开始进入主题......
Temporal API
Temporal API 应运而生,它就是我们望眼欲穿的标准。
上周末我试玩了下 Temporal API,夭寿啦,它也太好看了吧,以至于哪怕该 polyfill(填充补丁)的 README 温馨提示我们"禁止在生产环境中使用它",我也愿意猫急跳墙直接落地生产环境。
Temporal API 微言大义:
- 万物皆不变。
- 它提供了 Date、Time、DateTime、Duration 和 TimeZone(时区)等对象。
- 它还提供了我们一般不需要的 Calendar(日历)对象,虽然但是,不妨让我们一劳永逸全都要。
- 所有对象和函数都有不同版本,适用所有用例:Instant(即时版)、Plain(普通版)和 Zoned(区域版)。只要你的屁股坐得住,那我们待会就能看到这些用例。
- 你不需要导入它。它像
Math
一样全局可用。
该提案的工作始于 2017 年初(注 3),并于 2021 年 3 月到达 stage 3。stage 3(真祂喵地)"普大喜奔",因为即使 stage 4 才确实是"码到功成",stage 3 也是这些提案变得稳定以及供应商开始实现并在 feature flags(功能标志)后赋能它们的阶段。TS(TypeScript)通常也会在提案到达 stage 3 时实现它们。
注 3:
首次 tc39 提案提交
虽然但是,Taro(作者)我跟你说吼,这个情况两年多来依然如故,夭寿啦!为什么它没有进入 stage 4?它一定是被放弃了!它永远不会被采用了!JS 讨厌我们,TC39 大可爱。
夭寿啦!"蛋定点",你知道吗?Temporal API 几周前刚刚登陆了 Firefox 浏览器,它在 Safari 浏览器已经打工一段时间了,并且 Chrome 浏览器正在积极开发它。它位于 feature flag 之后 ------ Safari 浏览器的 --useTemporal
,Firefox 浏览器(注 4)的 --with-temporal-api
构建时 flag,以及 Chrome 浏览器(任何使用 V8 的运行时)的 --harmony-temporal
。
注 4:
请临幸 moz.configure 和跟踪 issue 中的 相关讨论。
猛戳安装即可愉快玩耍!
让我们用 Temporal 来小试牛刀吧。我们可以通过开启 feature flag 或使用 polyfill 来试试水。
我们可以通过 --harmony-temporal
flag 在 V8 筑基的运行时中开启 Temporal API。
css
google-chrome --js-flags="--harmony-temporal"
node --harmony-temporal # use node --v8-options to list all v8 supported flags
deno repl --v8-flags=--harmony-temporal
在 Firefox 浏览器中,这是一个构建时 flag,而非一个运行时 flag,所以你必须自己构建二进制文件。Nightly releases(每夜先行版)默认会打开大多数 flag,虽然但是,我至今无法在其中使用 Temporal API。我尚未尝试过 Safari 浏览器,因为它不适用于 Linux,而且我懒得去拿我的旧 MacBook。事实上,我的旧 MacBook 还没安装新版的 OS(操作系统)、Safari 浏览器和 Safari Nightly/Dev releases(先行版/开发版),所以我无法测试它。
无论如何,今天的打开方式是 polyfill:npm install @js-temporal/polyfill
。
下一步我们必须导入它:import { Temporal } from '@js-temporal/polyfill;'
。这是集成 polyfill 的必由之路,虽然但是,一旦原生支持 Temporal,我们就可以像使用 fetch
或 Math
一样无需导入。
注意:在下述章节中,我将提供可执行脚本,让你可以在浏览器中测试 Temporal API。
它们不会使用任何第三方库 ------ 我决定完全避免在本站点(和你的浏览器)中加载任何第三方脚本。我对它们的幕后工作一无所知且为此感到厌烦,它们似乎总是添加一堆跟踪 cookie。这不是我想给予读者的体验。
唯一的例外是 Temporal API polyfill ------ 当且仅当原生 API 不可用时,我们才会加载它。这会从 这里 加载。
表面上看,你的浏览器尚不支持 Temporal API。不用担心,我们只要加载 polyfill 即可。
千里之行,始于足下:
javascript
export function run() {
const now = Temporal.Now.instant().toString()
return now
}
// > RUN
如果一切正常,你应该可以看到当前日期显示在 RUN 按钮旁边。虽然但是,Date 无所不能。我们来点刺激的:
javascript
export function run() {
const now = Temporal.Now.zonedDateTimeISO()
const startedAt = now
.subtract({ hours: 2 })
.toLocaleString('en-us')
const endsAt = now
.add({ hours: 2 })
.toLocaleString('en-us')
return `Event started at ${startedAt} and will end at ${endsAt}.`
}
// RUN
这就更有意思了。虽然但是,人类可读的日期有点猪头 ------ 针对持续时间不到一天的事件,我们可以循序渐进:
javascript
export function run() {
const now = Temporal.Now.zonedDateTimeISO()
const format = {
weekday: 'long',
hour: 'numeric',
minute: 'numeric'
}
const startedAt = now
.subtract({ hours: 2 })
.toLocaleString(navigator.language, format)
const endsAt = now
.add({ hours: 2 })
.toLocaleString(navigator.language, format)
return `Event started at ${startedAt} and will end at ${endsAt}.`
}
// RUN
这就是用户需要的所有精准信息。尝试运行持续时间更长的脚本 ------ 它会正确更改日期名称。我还会按部就班,把硬编码的 en-US
区域替换为 navigator.language
。
虽然但是,我们还可以稳中求进:
javascript
export function run(hoursSince = 2, hoursUntil = 2) {
const now = Temporal.Now.zonedDateTimeISO()
const startedAt = now.subtract({ hours: hoursSince })
const endsAt = now.add({ hours: hoursUntil })
const startedAtDaysSince = now
.withPlainTime()
.since(startedAt.withPlainTime())
.round({ smallestUnit: 'day', largestUnit: 'day' }).days
const endsAtDaysUntil = now
.withPlainTime()
.until(endsAt.withPlainTime())
.round({ smallestUnit: 'day', largestUnit: 'day' }).days
const rtf1 = new Intl.RelativeTimeFormat('en', {
numeric: 'auto'
})
const relativeDayStarts = rtf1.format(
-startedAtDaysSince,
'day'
)
const relativeDayEnds = rtf1.format(
endsAtDaysUntil,
'day'
)
const format = { hour: 'numeric', minute: 'numeric' }
return `Event started ${relativeDayStarts} at ${startedAt.toLocaleString(
navigator.language,
format
)} and will end ${relativeDayEnds} at ${endsAt.toLocaleString(
navigator.language,
format
)}`
}
// RUN
现在的输出应该显示为类似 Event started today at 3:46 PM and will end today at 7:46 PM
。
如果我们用小时尝试,我们会看到它精准修正为 yesterday/tomorrow
:
vbnet
// now = 5.28pm
run(2, 12)
> Event started today at 3:28 PM and will end tomorrow at 5:28 AM
run(12, 2)
> Event started today at 5:28 AM and will end today at 7:28 PM
run(20, 2)
> Event started yesterday at 9:28 PM and will end today at 7:28 PM
run(80, 2)
> Event started 3 days ago at 9:28 AM and will end today at 7:28 PM
让我们来拆解这玩意:
我们正在使用的是 zonedDateTimeISO
,因为它是包含所有日期信息的唯一格式。文档中有更多关于它的信息,简而言之,如果你需要在日期上进行任何数学运算,请使用它。
我们首先存储 now
并用它加减几个小时,这见怪不怪了。下一部分更加好玩:
php
const startedAtDaysSince = now
.withPlainTime()
.since(startedAt.withPlainTime())
.round({ smallestUnit: 'day', largestUnit: 'day' }).days
const endsAtDaysUntil = now
.withPlainTime()
.until(endsAt.withPlainTime())
.round({ smallestUnit: 'day', largestUnit: 'day' }).days
这两行代码大抵相同,until(end time)
切换为 since(start time)
。我们调用 withPlainTime()
,这允许我们设置单个字段,比如 withPlainTime({ hours: 17 })
------ 虽然但是,如果不传参给它,那么它的默认参数是 00:00:00
,这基本上清除了时间,留下的有且仅有日期部分。
vbscript
Temporal.Now.zonedDateTimeISO().toString()
> '2023-09-19T19:22:40.195128985-03:00[America/Buenos_Aires]'
Temporal.Now.zonedDateTimeISO().withPlainTime().toString()
> '2023-09-19T00:00:00-03:00[America/Buenos_Aires]'
在计算 now
和 endsAt/startedAt
之间的持续时间之前,我们对这两者都进行类似的处理,这样持续时间是基于每月的日历日的天数计算的 ------ 否则我们会返回最初传递给 .add/.substract
的输入。
until()/since()
函数会返回 Duration(持续时间)。Duration 默认并不平衡,你需要调用 round()
函数来平衡它们。"Balance(平衡)"在这里是一个花里胡哨的说法:
php
const now = Temporal.Now.zonedDateTimeISO()
const hours27 = Temporal.Duration.from({ hours: 27 })
hours27.toString()
> 'PT27H' // 27 hours, zero days
hours27.round({ smallestUnit: 'hour' }).toString()
> 'PT27H' // same thing, still 27 hours, zero days
hours27.round({ smallestUnit: 'hour', largestUnit: 'day' }).toString()
'P1DT3H' // 1 day, 3 hours
hours27.round({ smallestUnit: 'day', largestUnit: 'day' }).toString()
'P1D' // 1 day, which is what we want, but...
const hours23 = Temporal.Duration.from({ hours: 23 })
hours23.toString()
> 'PT23H'
hours23.round({ smallestUnit: 'day', largestUnit: 'day' }).toString()
> 'P1D' // 23 hours round up to 1 day, rather than down to 0 days
hours23.round({ smallestUnit: 'day', largestUnit: 'day', roundingMode: 'halfExpand' }).toString()
> 'P1D' // if we pass no roundingMode, it defaults to `halfExpand`. what we want here is `trunc`.
hours23.round({ smallestUnit: 'day', largestUnit: 'day', roundingMode: 'trunc' }).toString()
> 'PT0S' // boom! that's what we want
为了完整起见,在我们的场景中,使用 toPlainDate
而不是 withPalinTime
也能奏效:
vbscript
const startedAtDaysSince = now
.toPlainDate()
.since(startedAt.toPlainDate()).days
现在我们已经将从 now
到 start/end
日期的持续时间四舍五入为整数天数,我们只需要获取 .days
属性并将其传递给棒棒的 Intl.RelativeTimeFormat()
:
javascript
const rtf1 = new Intl.RelativeTimeFormat('en', {
numeric: 'auto'
})
const relativeDayStarts = rtf1.format(
-startedAtDaysSince,
'day'
) // could also use duration.negated().days ¯_(ツ)_/¯
const relativeDayEnds = rtf1.format(endsAtDaysUntil, 'day')
这简直酷毙了。你当然可以使用现存库这样做,虽然但是,很快我们也可以无库开发。而且 Temporal API 就是如此优雅!
我们还可以用 Temporal API 实现更多功能,虽然但是,今天的知识面就吃这么多吧。你可以临幸 人性化文档 获取更多信息。
买家须知
那么,我们应该在生产环境中使用 Temporal API 吗?也许不是。地球人都知道,caniuse.com/temporal 百分百还是红的。该 polyfill 和规范文档都清楚说明了"暂不支持生产环境"。该 polyfill npm 包每周约有 21k 次下载,而 Luxon 有 1.5m 次。最骚的是,Moment.js 有 5.3m 次。WTF(肝里凉)
无论如何,Moment.js 已被弃用多年了,拜托。
粉丝福利
"Can I use"提供最新的浏览器支持兼容表,红色表示该功能浏览器尚不支持。
另一方面,得有人测试一下才能上线。如果你能承担一些风险,封装它并限制它可能造成的最大损失,同时拥有一个优秀的测试套件,私以为这完全值得。风险管控的若干指导方针:
- 在单个功能而不是整个 App 中使用 Temporal API,能大大地降低风险。
- 只在前端使用 Temporal API,而不是将 polyfill 处理的日期发送给后端,风险应该很低。除非你的 App 服务于外科医生,用来显示它们应该呆在手术室的精确日期和时间,诸如此类吹毛求疵的场景。那请你切换赛道。
- 在后端使用 Temporal API 作为驱动业务逻辑的 predicate(谓词),在 false positives/negatives(假阳性/假阴性)的情况下不会污染任何数据,对于特定用例也许是可接受的。显然,禁止在决定某人是否入狱的函数上使用 Temporal API。
- 在事件源系统(event-sourced system)中使用 Temporal API 也许是 OK 的。如果发现了 bug,你可以轻松回溯。
- 在后端使用 Temporal API,使用某些 Temporal API 函数的输出存储或永久覆盖数据可能是一个坏主意。
- 请在纯函数中编写所有日期算术,并为它们编写一堆测试。小心 edge cases(特殊情况)。无论你使用什么日期/时间库,你都应该这样做,但这值得反复提醒。
Union API 现状
如果你和我一样兴奋,以下是该提案和实现的现状,以及若干进度跟踪的链接。
TC39 提案
Temporal API 主要活动的地方是它的 GitHub 仓库。
有两个跟踪证明:与 IETF 标准化工作同步的 跟踪 issue 和 最终规范规格文本计划。
前者似乎阻碍了浏览器和其他实现者取消标志:
虽然为了收集实现者的反馈,Temporal 目前处于 Stage 3,但是直到我们完成与 IETF 的合作,标准化日历和时区注释的字符串格式之前,实现者都必须在 feature flag 后面提供 Temporal 支持。
目前,这正在等待 IETF 批准 互联网上的日期和时间:附加信息的时间戳,并停留在该流程,这是关于 20 年前的标准跟踪 RFC 3339:因特网上的日期和时间:时间戳 的拟议更新。
该文件最近进行了 3 次审查:
- 整体审查,认为文件已经准备好了问题。
- 运营局审查小组(Operational Directorate Reviews groups)的审查,认为它在形式上存在问题,但实践中不确定这是否是是正确的审查结果。
- 一个聚焦于安全和隐私的审查,认为它已经准备就绪。
最近,有人询问是否可以拆分 TC39 Temporal API 提案,那么它不再被 RFC 提案阻碍。该提案的倡议者和主要贡献者 Philip Chimento(注 5)回答道:
拆分提案以继续使用不依赖于字符串格式的部分;我也一直在考虑这个问题。私以为这需要在委员会中进行大量的沟通努力,这将使我们推动提案进入下一阶段而必须做的其他事情付诸东流。你可以说我们的流程应该简化处理这种问题,而且你可能说得对,但现状就是如此。
注 5:如仓库的 见解/贡献者 所示,计算提交和添加/修改的 LoC 可能不是最准确的指标,但我会采用。
后一个 GitHub issue,就像现在一样,似乎只需要合并若干开放的 PR
截至 2023-07-12,我们已经解决了所有可能导致规范性变更的已知讨论,并且所有规范性变更均已提交给 TC39 并获得共识。此 issue 是将这些规范性 PR 合并到规范文本中的核对清单和计划,以便观察者可以一目了然地看到该提案的现状。
完成此核对清单后,除非为了修复在实现过程中发现的 bug,否则规范应处于最终规范形式,准确表示需要实现的内容。
订阅这些 GitHub issues 的通知可能是随时了解提案进入 Stage 4 进度的最佳方式。其次是 稳定的邮件列表。
还有 Stage 3.5 和 Stage 4 的里程碑,但我们无法订阅。
同样有趣的是:Temporal API 262 测试。
Firefox 浏览器
摘自 2023 年 8 月 7 日的 SpiderMonkey 资讯(Firefox 116-117):
我们已经实现了 Temporal 提案。
另请临幸 "实现 Temporal 提案"Bugzilla ticket,现已通过 RESOLVED FIXED
关闭。
与此同时,Temporal 是在 src/builtin/temporal 中实现的,它的 262 测试在 src/tests/test262/built-ins/Temporal 中。不过,我没有找到测试结果。
你也可以通过 GitHub 只读镜像 访问存储库。
谷歌 Chrome 浏览器
有一个 关于 Temporal API 的 chrome 状态网页,但它最后一次更新大约在一年前。这不是特别"令人鸡冻"。虽然但是,还有一个跟踪 bug,它最近确实有动静:Issue 11544:实现 Temporal 提案。
就代码而言,我能找到的所有与 Temporal API 相关的代码最后一次修改最多是在 2022-11。查看 git 历史,举个栗子:
- js-temporal-objects.h
- js-temporal-objects.cc
- js-temporal-objects-inl.h
- builtins-temporal.cc
- js-temporal-objects.tq
- test/mjsunit/temporal
似乎大多数或全部的 262 Temporal 测试都失败了:test262 状态 :(
Safari 浏览器
追踪 Safari 浏览器的实现状态有点蛋疼。似乎有一个包罗万象的实现 Temporal 实现 证据,虽然它最近有更多的提交,但它上次修改是在 2022-01。
提及 Temporal API 进展的最新 Safari 技术预览版发行说明是 156、155、154、153 ------ 全部来自 2022 年。
就源码而言,我能找到的最新相关提交是来自 2023-03 的 1490a5c。
features.json 将 Temporal 标记为"开发中",test262/config.yml 似乎跳过了许多/大多数 TemporalAPI 测试,expectation.yml 将大约 800 行用于预期的 Temporal API 测试失败(注 6)。
注 6:
若干 bocoup 咨询公司实现了一个脚本,将 262 测试套件克隆到 WebKit 的存储库中,并跟踪哪些测试通过/失败。请临幸 WebKit 中新的 Test262 导入和运行器。
feature flag 似乎定义在 runtime/OptionsList.h#L580,并在 JSGlobalObject.cpp#L1334 中使用。如果你想测试它,请告诉我:)
结语
一如既往,我在写这篇文章时离题万里。起初我只是想举几个栗子,仅此而已,但我最终在三大主要浏览器中对正式提案和实现进行了代码探索。
我还构建了自己的可运行脚本小部件,因为私以为未经你的同意加载 CodePen 或 RunKit 等第三方库蛮逊的,我决定通过创建自己的实现来完全规避这个问题,而不是征求你的同意。
我对此结果非常满意,希望你发现所有这些信息是有用的、有趣的并且/或者好玩的。有空的话可以关注我的资讯:)
祝你有愉快的一天,一期一会,不散不见!
学废了的小伙伴可以点赞给语冰打 call,欢迎关注最新动态和订阅前沿资讯。谢谢大家的彼芯,掰掰~