JavaScript – Temporal API & Date

前言

Temporal API 是 JS 的新東西, 用來取代 Date. 雖然現在 (15-01-2023) 沒有任何遊覽器支持. 但它已經是 stage 3 了. 而且有完整的 polyfill, 所以還是非常推薦大家積極的去使用它.

如果你對日期不熟悉, 可以先看看這篇 Time Zone, Leap Year, Date Format, Epoch Time 時區, 閏年, 日期格式

 

參考

YouTube – Learn Temporal API In 17 Minutes

Temporal Date API Ultimate Guide

tc39 – Temporal

 

吐槽 Date

Date 非常的爛, 它是 1995年 模仿 Java 設計的. 後來 1997年 Java 丟棄了這個設計, 但 JavaScript 卻一直沿用至今. 從這點你就知道它有多爛了.

month starts with zero

const date = new Date(2023, 1, 1);
console.log(date.toDateString()); // Wed Feb 01 2023

你以爲創建的是 1月1號 ? 不, JS 的 Date 月份是從 0 開始的. 1月 = 0, 2月 = 1....

parse string 倒是正確的, 但 getMonth 依舊是返回 0 哦.

const date2 = new Date('2023-01-01');
console.log(date2.toDateString()); // Sun Jan 01 2023
console.log(date2.getMonth()); // 0

alwasy time zone & only locale time zone

JS 的 Date 一定包含 time zone, 而且這個 time zone 是無法用 JS 控制的, 它總是依據遊覽器的設定.

創建一個早上八點的 Date

const date = new Date(2023, 0, 15, 8);
console.log(date.toLocaleString()); // 1/15/2023, 8:00:00 AM

查看 time zone offset, 馬來西亞的 offset 是 +08.00

console.log(date.getTimezoneOffset()); // -480

480 是 minutes, 換成 hours 就是 8 小時. 前面的(-)減符號讓人匪夷所思. +08:00 不應該是 positive 嗎?!

查看 UTC 時間

console.log(date.toUTCString()); // "Sun, 15 Jan 2023 00:00:00 GMT" 

果然, 8 小時沒了. 所以 Date 的 time zone 就是 +08:00.

Force UTC ?

有沒有辦法讓它變成 UTC time zone +00:00 呢? 沒有

const date = new Date(Date.UTC(2023, 0, 15, 8));
console.log(date.toUTCString()); // Sun, 15 Jan 2023 08:00:00 GMT

用 Date.UTC 看上去好像是 UTC time zone, 但其實不是.

Date.UTC 只是把日期變成了 number 然後交給 Date. 最終出來的 Date 依然是 locale time zone

不信, 查看 offset 和 locale date

console.log(date.getTimezoneOffset()); // -480
console.log(date.toLocaleString()); // 1/15/2023, 4:00:00 PM 

locale date 從早上 8 點變成了下午 4 點, 多了 8 小時.

總結: Date 總是有 time zone 概念的, 而且 time zone 是依據遊覽器 locale 設定的.

不直觀的 Date edit function

想添加 15天 到一個 datetime 是這樣的

const now = new Date(2023, 0, 29);
console.log(now.toDateString()); // Sun Jan 29 2023
now.setDate(30); // mutable
console.log(now.toDateString()); // Mon Jan 30 2023
now.setDate(now.getDate() + 15); // add 15 days
console.log(now.toDateString()); // Tue Feb 14 2023

通過 setDate 而且利用了它自動進位的功能來完成 add 15 days 操作.

直觀的操作應該是這樣的

const now = new Date();
const newDate = now.addDays(15); // immutable

sort date

我在 JavaScript – Sort 裏提過 sort date 的方式. 利用 date.getTime 得到 epoch time 然後對減.

這個方法很間接. 應該要提供一個類似 String.prototype.localeCompare 的方法用於 Date 排序.

總結

總之 Date 有一萬個不好, 不要用它, 寧願用 momentjs.

但隨着 momentjs 退役, 我們也需要找一個替代方案, 而 Temporal API 似乎是一個不錯的選擇.

 

Temporal Polyfill

目前沒有任何遊覽器支持. 所以一定要安裝 temporal-polyfill

yarn add @js-temporal/polyfill

它自帶 TypeScript 哦

import { Temporal, Intl, toTemporalInstant } from '@js-temporal/polyfill';
Date.prototype.toTemporalInstant = toTemporalInstant;

有三個主要 exports. Temporal 就是 Temporal API 的接口.

Intl 是爲了讓 Intl.DateTimeFormat 支持 Temporal 實例. 所以也需要 polyfill.

最後是 toTemporalInstant, 它是一個方便把 Date 轉換成 Temporal 實例的小擴展.

 

Date, Time, DateTime, Duration, TimeZone 概念

傳統的 Date 對象包含了 Date (日期), Time (時間), Time Zone (時區). 這種 always all in 的對象並不友好.

很多時候我們只想表達日期. 不需要時間也不需要 Time Zone. 有時候只想表達幾點幾分, 不需要日期. 這些時候用 Date 都很變扭.

Temporal 允許我們只定義日期 Date, 或者只定義時間 TIme, 或者兩者 DateTime. 而 DateTIme 也可以選擇要不要有 Time Zone 概念.

所以我們會有許多不同的對象, 對象的方法也都不一樣. 比如 Temporal 的 Date 對象沒有操控時間的 API, Time 對象沒有操控日期的 API.

另外 Temporal 還引入了 Duration 概念. 它表示一個時間單位. 比如 10 分鐘, 兩天, 一星期. 這個在日常生活很常見. 

總之, Temporal 把時間分的很細, 而不像傳統的 Date 對象, 完全不符合我們直觀的理解.

 

創建 Temporal 

Temporal.now 可以依據此時此刻創建一個 Temporal 對象

如同上面提到的, 它有很多種對象可以選擇. Date, Time, DateTime, zonedDateTIme. 我們依據需求範圍決定使用哪個對象就可以了.

Temporal.Now.plainDateISO().toString();     // 2023-01-15
Temporal.Now.plainTimeISO().toString();     // 21:16:35.06659506
Temporal.Now.plainDateTimeISO().toString(); // 2023-01-15T21:16:35.069595066
Temporal.Now.zonedDateTimeISO().toString(); // 2023-01-15T21:16:35.072595069+08:00[Asia/Kuala_Lumpur]
Temporal.Now.instant().toString();          // 2023-01-15T13:16:35.075595072Z

這裏有一個知識點要知道.

date, time, datetime 都缺少了 time zone, 它們是算不出 epoch time 的. 只有擁有 time zone 的 zonedDateTime 和 instant (它是 UTC) 纔可以算出 epoch time 哦.

因爲 epoch time 是 1970年1月1號 (UTC +00:00) 到某時間的納秒數. 而這個某時間一定要明確表明 Time Zone 不然秒數肯定不準.

Temporal.Now.zonedDateTimeISO().epochMilliseconds; // 1673789585487
Temporal.Now.instant().epochMilliseconds;          // 1673789585487

from

想創建任意時間/時區, 可以用 from 方法, 可以用 string format 或者傳 config 對象

const date = Temporal.ZonedDateTime.from('2012-01-01[Asia/Kuala_Lumpur]'); // 2012-01-01T00:00:00+08:00[Asia/Kuala_Lumpur]

const date = Temporal.ZonedDateTime.from({
  year: 2023,
  month: 1,
  day: 1,
  timeZone: 'Asia/Kuala_Lumpur',
});
// 2023-01-01T00:00:00+08:00[Asia/Kuala_Lumpur]

注: 一月是 1 而不是 0 哦,

完整的 time zone id 列表可以參考:Wikipedia – List of tz database time zones

 

日期格式

輸出日期格式不是 Temporal 的職責. 它只有少量的 API 可以控制.

Temporal.Now.zonedDateTimeISO().toString(); // 2023-01-15T21:49:51.000590989+08:00[Asia/Kuala_Lumpur]
Temporal.Now.zonedDateTimeISO().toLocaleString('en-US'); // 1/15/2023, 9:49:51 PM GMT+8
Temporal.Now.zonedDateTimeISO().toLocaleString('ms-MY'); // 15/1/2023, 9:49:51 PTG MYT
Temporal.Now.zonedDateTimeISO().toLocaleString('zh-TW'); // 2023/1/15 下午9:49:51 [GMT+8]
Temporal.Now.zonedDateTimeISO().toLocaleString('zh-CN'); // 2023/1/15 GMT+8 21:49:51
Temporal.Now.zonedDateTimeISO().toInstant().toString(); // 2023-01-15T13:49:51.015591013Z
Temporal.Now.zonedDateTimeISO().toInstant().toString({ smallestUnit: 'millisecond' }); // 2023-01-15T13:49:51.017Z

兩個點值得注意

1. 最小的單位是納秒 nanosecond, 可以通過 options smallestUnit 控制最小單位輸出. (millisecond > microsecond > nanosecond) 每一個級別差 1000 (1ms = 1000micro = 1000000ns)

2. instant 纔可以輸出程序員最常見的格式

Inlt.DateTimeFormat

想要配置多一點需要使用 Inlt.DateTimeFormat

const date = Temporal.Now.zonedDateTimeISO();
Intl.DateTimeFormat('en-MY', {
  year: 'numeric',
  month: 'short',
  day: '2-digit',
  hour: '2-digit',
  hour12: true,
  minute: '2-digit',
  second: '2-digit',
  weekday: 'short',
  timeZoneName: 'longOffset',
}).format(date); // Sun, 15 Jan 2023, 10:19:39 pm GMT+08:00

它已經可以配置很多東西了, 但依然沒有完全自定義 format 的能力. 比如你不能像 C# 那樣直接指定一個 format yyyy-MMM-dd HH:mm:ss tt %K 作爲輸出.

最後講一下 time zone 的 format

下面這樣會報錯. zonedDateTimeISO 已經有明確 time zone 了, format 的時候不可以再聲明其它的

const date = Temporal.Now.zonedDateTimeISO();
console.log(
  Intl.DateTimeFormat('en-MY', {
    dateStyle: 'long',
    timeStyle: 'long',
    timeZone: new Temporal.TimeZone('UTC'), // 不能配置不相同的 time zone
  }).format(date)
);

instand 則可以聲明任何 time zone

const date = Temporal.Now.instant();
console.log(
  Intl.DateTimeFormat('en-MY', {
    dateStyle: 'long',
    timeStyle: 'long',
    timeZone: new Temporal.TimeZone('America/Chicago'),
    // timeZone: new Temporal.TimeZone('UTC'),
    // timeZone: new Temporal.TimeZone('Asia/Kuala_Lumpur'),
  }).format(date)
);

它會自動轉換時間. 另外, 如果 instant 沒有聲明 time zone, 它不是 UTC 哦, 而是依據遊覽器的設置. 想要 UTC 需要聲明 time zone 是 UTC/GMT

 

Duration

它類似 C# 的 TimeSpan, 玩法很簡單

const duration1 = Temporal.Duration.from({ minutes: 5, seconds: 30 });
console.log(duration1.toString()); // PT5M30S
const duration2 = Temporal.Duration.from('PT10M35S');
console.log(duration2.minutes); // 10
console.log(duration2.seconds); // 35
console.log(duration2.total('second')); // 635

再來一個

Temporal.Duration.from({
  years: 1,
  months: 1,
  days: 1,
  hours: 1,
  minutes: 1,
  seconds: 1,
}).toString() // P1Y1M1DT1H1M1S

拆開來看 P 1Y 1M 1DT 1H 1M 1S.

P 應該是 stand for Plain

PT 是 PlainTime (PT for 只有 hours 和一下, 有 days 或以上一律 starts with P 沒有 T)

切換加減

Temporal.Duration.from({ minutes: 5, seconds: 30 }).negated().abs();

 

獲取時間細節

const date = Temporal.Now.zonedDateTimeISO();
const values = [
  date.year,        // 2023
  date.month,       // 1
  date.day,         // 15
  date.dayOfWeek,   // 7
  date.hour,        // 22
  date.minute,      // 35
  date.second,      // 37
  date.millisecond, // 663
  date.microsecond, // 337
  date.nanosecond,  // 652
  date.offset,      // '+08:00'
  date.offsetNanoseconds / 1000 / 1000 / 1000 / 60 / 60, // 8
];
date.toString() // 2023-01-15T22:37:14.513434495+08:00[Asia/Kuala_Lumpur]

算 ago

const commentDate = Temporal.ZonedDateTime.from('2023-01-02[Asia/Kuala_Lumpur]');
const now = Temporal.Now.zonedDateTimeISO();
console.log(now.since(commentDate).round({ smallestUnit: 'day' }).total('day') + 'days ago'); // 14 days ago

用了 since, round, total 技巧. 還有一個方法叫 until. 它和 since 是同個原理, 只是方向不一樣.

now.since(previous) 是說從過往某天到現在的時間. now.until(future) 是從現在到未來某天的時間. 

 

DateTime Adjustment

const date = Temporal.Now.plainDateISO();
const oneDay = Temporal.Duration.from({ days: 1 });
const newDate = date.add(oneDay);
console.log(date.toString());    // 2023-01-15
console.log(newDate.toString()); // 2023-01-16

它是 immutable 的.

直接傳對象也是可以的

const newDate = date.add({ days: 1 });

減法

const newDate = date.subtract({ days: 1 });

或者使用 add days: -1 也是可以的

直接修改

const date = Temporal.Now.plainDateISO();
const newDate = date.with({ day: 18 });
console.log(date.toString()); // 2023-01-15
console.log(newDate.toString()); // 2023-01-18

注: Temporal 的 with 和 傳統 Date 對象的 setDate 不同, 如果給的數超過當月份, 那麼它會停在最後一天, 比如 31號. 而 setDate 則會自動進位去修改月份.

 

Date Comparison for Sort

const today = Temporal.Now.plainDateISO();
const yesterday = today.subtract('P1D');
const tomorrow = today.add('P1D');
const days = [tomorrow, today, yesterday];
console.log(days.sort(Temporal.PlainDate.compare).map(d => d.toString())); // ['2023-01-14', '2023-01-15', '2023-01-16']

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章