【译】了不起的 Temporal API

大家好,这里是大家的林语冰。

本文属于是语冰的直男翻译了属于是,仅供粉丝参考,原文请临幸 英文原味版

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,我们就可以像使用 fetchMath 一样无需导入。


注意:在下述章节中,我将提供可执行脚本,让你可以在浏览器中测试 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]'

在计算 nowendsAt/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

现在我们已经将从 nowstart/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.5Stage 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 历史,举个栗子:

似乎大多数或全部的 262 Temporal 测试都失败了:test262 状态 :(

Safari 浏览器

追踪 Safari 浏览器的实现状态有点蛋疼。似乎有一个包罗万象的实现 Temporal 实现 证据,虽然它最近有更多的提交,但它上次修改是在 2022-01。

提及 Temporal API 进展的最新 Safari 技术预览版发行说明是 156155154153 ------ 全部来自 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,欢迎关注最新动态和订阅前沿资讯。谢谢大家的彼芯,掰掰~

相关推荐
万叶学编程2 小时前
Day02-JavaScript-Vue
前端·javascript·vue.js
天涯学馆4 小时前
Next.js与NextAuth:身份验证实践
前端·javascript·next.js
HEX9CF5 小时前
【CTF Web】Pikachu xss之href输出 Writeup(GET请求+反射型XSS+javascript:伪协议绕过)
开发语言·前端·javascript·安全·网络安全·ecmascript·xss
ConardLi5 小时前
Chrome:新的滚动捕捉事件助你实现更丝滑的动画效果!
前端·javascript·浏览器
ConardLi5 小时前
安全赋值运算符,新的 JavaScript 提案让你告别 trycatch !
前端·javascript
积水成江5 小时前
关于Generator,async 和 await的介绍
前端·javascript·vue.js
Z3r4y5 小时前
【Web】portswigger 服务端原型污染 labs 全解
javascript·web安全·nodejs·原型链污染·wp·portswigger
人生の三重奏5 小时前
前端——js补充
开发语言·前端·javascript
Tandy12356_5 小时前
js逆向——webpack实战案例(一)
前端·javascript·安全·webpack