ネイティブJavaScriptで日時を取り扱う場合、ビルトインされているDateオブジェクトを使うことになるのだが、このDateオブジェクトは取り扱いにクセが多く、直感的に日時を操作できない点でも有名である。そこで、専らサードパーティ製のプラグインmoment.jsやdayjsを使うのが一般的になっている。最新では、オワコン宣言したmoment.jsの後継と云われるLuxonや、次期JavaScriptへの実装が期待されているTemporalモジュールなどが出て来ていて、では結局どれを使うのが良いのか? という悩みを抱えている人もいるのではないだろうか。私がまさにその問題にぶち当たった次第である。
 そこで、それぞれの日時処理用プラグインやモジュール(本稿では統一してライブラリと称することにする)を比較してみることにした。

各種日時ライブラリの比較

 まず、ネイティブJSのDateオブジェクトと、LuxondayjsTemporalをそれぞれ仕様や機能別に単純に比較してみた結果を一覧化してみた。
 オワコン宣言されているmoment.jsについては、公式でも「もう使わないで」と謳われているので除外してある。またTemporalについては仕様策定中とあって、実装が確定していない(公式GitHubをwatchしていると毎日のように議論が起票されている)ので、ここでは2020年7月22日時点での実装内容なので注意が必要だ。

比較項目 Date Object Luxon dayjs Temporal
バージョン(2021/7/22時点) 2.0.1 1.10.6 0.1.0
公式サイト MDN moment.github.io/luxon day.js.org tc39.es
日本語ドキュメント あり なし READMEのみ なし
コア (ネイティブ) Dateのラッパー Dateのラッパー 独自モデル
インストール(npm) npm i --save luxon npm i --save dayjs 詳細は後述
CDN 配布あり 配布あり ×
ファイルサイズ(minify後) 68kB 6.34kB(コアのみ) 286kB(配布版にはminifyなし)
プリミティブ値 UNIX時間(ミリ秒)のNumber UNIX時間(ミリ秒)のNumber(Dateのラッパーなので) UNIX時間(ミリ秒)のNumber(Dateのラッパーなので) UNIX時間(ナノ秒)のBigInt
保持値特性 ミュータブル イミュータブル イミュータブル イミュータブル
タイムゾーン対応 オフセットのみ フルサポート(IANA Time Zone Database と独自拡張) 要プラグイン フルサポート(IANA Time Zone Database か TZ database 準拠)
DST対応 特になし サポート済み なし サポート済み
カレンダー(歴)対応 Intl.Localeによるサポート グレゴリオ暦とISO Week暦をサポート 要プラグイン サポートあり(太陰太陽暦など)
使用法(コード) const { DateTime } = require("luxon") もしくは <script src="luxon.js"></script> const dayjs = require('dayjs') もしくは <script src="dayjs.min.js"></script> const { Temporal } = require('proposal-temporal')
初期化 new Date() const DateTime = luxon.DateTimeなど dayjs() Temporal
現在日時 const now = new Date() const now = DateTime.now() const now = dayjs() const now = Temporal.Now
フォーマッター Intl.DateTimeFormat()によるサポート Intl.DateTimeFormat()依存の独自拡張 独自書式(YYYY/MM/DD等) Intl.DateTimeFormat()依存の独自拡張
国際化 Intl.DateTimeFormat()によるサポート Intl.DateTimeFormat()によるサポート 要プラグイン Intl.DateTimeFormat()によるサポート
日時比較 なし インスタンスメソッド(hasSame等) インスタンスメソッド(diff インスタンスメソッド(compare
日時更新 Setter インスタンスメソッド(plus, minus等) インスタンスメソッド(add, subtract等) インスタンスメソッド(add, subtract等)
期間算出 なし Duration API(Duration.fromObject({ hours: 2, minutes: 7 }) 要プラグイン(duration インスタンスメソッド(Duration
二点間の間隔 なし Interval API(Interval.fromDateTimes(now, later) 要プラグイン(isBetween インスタンスメソッド(Duration, until
拡張性 あり 不明(公式ドキュメントには記述なし) 高い(プラグイン追加が可能) 未知
ライセンス MIT MIT BSD-3-Clause

補足: Temporalのインストール手順

 Temporalは現在策定中のため、正式なインストール手順が公開されていない(GitHubのレポジトリのREADMEから奥深くに辿って行くとちょっとだけ記載されている程度だ)。現状では、評価バージョンであるPolyfillを自前でビルドして使うことになる。その手順を紹介しておこう。

git clone https://github.com/tc39/proposal-temporal.git temporal
cd temporal
npm run build:polyfill

 これでpolyfillディレクトリ内にscript.jsscript.js.mapがビルドされる。ビルドされたスクリプトはMinifyされていないので、520kBオーバーの巨大スクリプトになる。このファイルが、Temporalの実体とそのソースマップである。これをHTML側で読み込むことでTemporalがJavaScriptのグローバルスコープ内で使用できるようになる。

<script src="/path/to/temporal/polifill/script.js"></script>
<script>
const current = Temporal.Now
console.log(current.timeZone().id)// "Asia/Tokyo"
console.log(current.plainDate('japanese').era)// "reiwa"
console.log(current.instant().toString())// "2021-07-22T04:22:31.771751767Z"
</script>

 実際の使用例は上記のような感じだ。


 さて、ライブラリの比較はここまで。
 次に、それぞれのライブラリを実際に使用してみての私個人の所感をまとめてみた。

Luxonの所感

 今回比較したライブラリの中では、最もモダンでスマートなパッケージという感じだった。moment.jsの後継と謳っていることもあって、個人的にmoment.jsのフォーマッター仕様が苦手だったことから、そこが踏襲されていると嫌だなぁ……と思っていたが、幸いにもネイティブJSのIntl APIに準拠する形に落ち着いていたので好感度UPだったw
 タイムゾーンや更新系、算出系のインスタンスメソッドが豊富で、それらが元からコアにビルトインされているので、Luxonの1ファイルを読み込むだけでJavaScriptの日時処理は全てまかなえてしまうのがお手軽である。
 さらに、ファイルサイズも小さめで、全体的に無難で隙がない優秀なライブラリと云ったところだ。

DAYJSの所感

 とにかくファイルサイズが小さいのがウリだ。それゆえ、コアファイルに含まれる処理はかなり限定的である。ただ、JavaScript側で大仰な日時処理を必要としないのであれば、ネットワーク帯域を圧迫しない点でこのライブラリは最良であると云えるだろう。
 公式プラグインを追加することで様々な拡張処理に対応できるのも魅力的で、リッチな日時処理が必要であっても、必要最小限のプラグイン追加で機能が実装できるので、ネットワーク帯域を限界まで最適化したいアプリなどでは光るものがある。とはいえ、必要な機能を選別して都度プラグインをインポートする手間は面倒でもあり、国際化などで多言語対応を求められると各言語ごとのプラグインを読み込む煩雑なデプロイが必要になる可能性もある。また、拡張プラグインを多用するようなアプリなどでは極小ファイルサイズのメリットが薄れてしまうだろう。
 個人的にはフォーマッター仕様がmoment.js互換なのがいただけない点である。逆に、moment.jsからの乗り換えであれば、後継と云われるLuxonよりこちらのDAYJSの方が適しているかも知れない。

Temporalの所感

 前者2つのライブラリがネイティブJSのDateオブジェクトのラッパーであったのと比較すると、こちらは完全独立型のモジュールである。Dateオブジェクトの弱点であるタイムゾーン処理や各種比較・算出処理にも対応していて、これがネイティブJavaScriptの標準として追加されたら幸せになれそうな予感が半端ない。
 ただ、現状ではファイルサイズが大きすぎて商用としては使えない(ビルドしたスクリプトをMinifyしてみたが、それでも286kBオーバーだったので、かなり重い)。
 仕様的に国際化部分はIntl APIに準拠しているので、これが今後の日時国際化の標準仕様になるのだろうと思われる。特筆すべきはエポックタイムをナノ秒まで算出できるところだ。おそらくここまで精緻な時間が必要なケースは稀だろうが、面白い仕様である(まぁ、ナノ秒のBigIntを処理するコードなんて書きたくないが……w)。
 使い勝手的にはインスタンスメソッド名が長すぎるのが難点なのと、有用なフォーマッターを実装していないところが気になるところだ。まぁ、策定中でもあるし、今後どのように改善されていくのかが非常に気になるモジュールではある。

Dateオブジェクトの不満点への対応

 ここからは一部個人的な嗜好も含まれるのだが、ネイティブJavaScriptのDateオブジェクトを取り扱う上でよく難儀するケースに対して、それぞれのライブラリでの対応がどのようになっているかを調査してみた。

イミュータブル性

 一度インスタンス化した日時オブジェクトの値については、プリミティブ値として不変(イミュータブル)であることが望ましい。日時の利用ケース的に、できる限りSetterによるプリミティブ値の上書きを抑止して、インスタンスメソッドによる値の更新時にはその都度新たなインスタンスとして値を返すような仕組みが最適だと思うのだ。つまりは、起点となる日時が変動しないことで、どんな処理からでも起点日時を安定して参照できるようになり、それが処理全体の不確実性を低下させられるメリットを得られる。
 その点、ネイティブJSのDateオブジェクト以外は3つともイミュータブル性を持っているので、どれを選んでも日時の取り扱いは格段にし易くなっている。

検証: Dateオブジェクト

 Dateオブジェクトでは、インスタンスとして保持される日時データがミュータブルな値(状態が変化する値)であることを、実例で示してみる。

let oneDate = new Date(2021, 5, 9, 12, 0, 0, 0)

──と定義した日時データoneDateは、システムタイムゾーンがAsia/Tokyo(日本標準時)だった場合にUTCで2021-06-09T03:00:00.000Zとなる。この日時を1日進めてみよう。

oneDate.setDate(oneDate.getDate() + 1)
console.log(oneDate.toISOString())// "2021-06-10T03:00:00.000Z"

 まぁ、Setter使ってオブジェクト内のプリミティブ値を上書きしているので、挙動的には至極真っ当で、ここに不満を云っても仕方ない(オブジェクトのプリミティブ値というのは基本不変な値なのだが、Setterによる上書きは許可されるのだ)。そしてこの時点でoneDateが保持している日時データは、初期値より1日進んだプリミティブ値となる。そのため、これ以降に初期値としての日時が欲しい場合、再びSetterで1日前の日時データを作成するか、もとから初期日時をconstとして変更不可を宣言した定数に格納しておく必要がある(まぁ、JavaScriptではLint等でルールを縛らないと定数でも変更できてしまうが……)。通常は後者だろう(下記参照)。

const baseDate = new Date(2021, 5, 9, 12, 0, 0, 0),
      DAY_MS = 1 * 24 * 60 * 60 * 1000
let oneDate = new Date(baseDate.getTime() + DAY_MS)
console.log(oneDate.toISOString())// "2021-06-10T03:00:00.000Z"

 なんともまどろっこしいうえに、JavaScript自体にシステム的な不変の縛りがないため、定数baseDateのプリミティブ値が不変であることが以降の処理で担保されない不安定さが残る。
 もっと実践的な例として、現在日から一週間分の日付を取得して、それを配列に格納してみる。

const baseDate = new Date()
let weekDates = []
for (let  add = 0; add <= 7; add++) {
  weekDates.push(new Date(baseDate.setDate(baseDate.getDate() + add)))
}
console.log(weekDates.map(date => date.toISOString()))
// 結果: [
// 0:  "2021-07-28T12:53:24.355Z" <- baseDateが 2021-07-28 +0日で上書きされる
// 1:  "2021-07-29T12:53:24.355Z" <- baseDateは 2021-07-28 +1日で上書きされる
// 2:  "2021-07-31T12:53:24.355Z" <- baseDateは 2021-07-29 +2日で上書きされる
// 3:  "2021-08-03T12:53:24.355Z" <- baseDateは 2021-07-31 +3日で上書きされる
// 4:  "2021-08-07T12:53:24.355Z" <- baseDateは 2021-08-03 +4日で上書きされる
// 5:  "2021-08-12T12:53:24.355Z" <- baseDateは 2021-08-07 +5日で上書きされる
// 6:  "2021-08-18T12:53:24.355Z" <- baseDateは 2021-08-12 +6日で上書きされる
// 7:  "2021-08-25T12:53:24.355Z" <- baseDateは 2021-08-18 +7日で上書きされる
// ]
console.log(baseDate.toISOString())
// "2021-08-25T12:53:24.355Z" <- 起点日が変わっている

 Dateオブジェクトがミュータブルな値を持つことを考慮せずに、上記のように直感的な処理でコーディングしてしまうと、思わぬ結果となって混乱してしまうだろうw
 期待値通りの処理結果を得るためには、forループ内にて定数baseDateとは別に起点日時をクローンしたテンポラリ変数を定義して、それに対して日数加算を行う必要がある。

for (let  add = 0; add <= 7; add++) {
  let tempDate = new Date(baseDate.valueOf())// `let tempDate = baseDate` ではダメ
  weekDates.push(new Date(tempDate.setDate(tempDate.getDate() + add)))
}

 なお、JavaScriptではオブジェクトを変数に単純に代入してしまうと、プリミティブ値は参照扱いとなり実体はbaseDateの値になってしまうので、baseDateのプリミティブ値を使って新たにDateオブジェクトのインスタンスをtempDateに格納しないと完全なクローンができないのがさらにややこしい。また日付値の加算等ができるインスタンスメソッドもないので、愚直にGetterで値を取って加工してSetterで上書きしてあげる必要があるのも面倒だ。なんとももどかしい上に、処理のオーバーヘッドも高くて、およそ褒められる点が見つからない。

検証: Luxon

 では、Luxonで同じように現在日から一週間分の日付を取得してみよう。Luxonの場合、インスタンスメソッドplus()を使えるので簡単だ(減算時はminus()を使う)。このメソッドの戻り値は独立した日付インスタンスになるので、起点日を格納したbaseDateは処理後も不変(イミュータブル)である。

const baseDate = DateTime.now()
let weekDates = []
for (let add = 0; add <= 7; add++) {
  weekDates.push(baseDate.plus({days: add}))
}
console.log(weekDates.map(date => date.toISO()))
// 結果: [
// 0: "2021-07-28T21:56:57.886+09:00"
// 1: "2021-07-29T21:56:57.886+09:00"
// 2: "2021-07-30T21:56:57.886+09:00"
// 3: "2021-07-31T21:56:57.886+09:00"
// 4: "2021-08-01T21:56:57.886+09:00"
// 5: "2021-08-02T21:56:57.886+09:00"
// 6: "2021-08-03T21:56:57.886+09:00"
// 7: "2021-08-04T21:56:57.886+09:00"
// ]
console.log(baseDate.toISO())
// "2021-07-28T21:56:57.886+09:00" <- 起点日は変わっていない

検証: DAYJS

 DAYJSの場合、日時の加減計算はインスタンスメソッドのadd()subtract()で簡単に行える。このメソッドの戻り値は新規日時インスタンスになるので、起点日を格納したbaseDateは処理後も不変(イミュータブル)である。

const baseDate = dayjs()
let weekDates = []
for (let add = 0; add <= 7; add++) {
  weekDates.push(baseDate.add(add, 'day'))
}
console.log(weekDates.map(date  =>  date.toISOString()))
// 結果: [
// 0:  "2021-07-28T12:58:53.287Z"
// 1:  "2021-07-29T12:58:53.287Z"
// 2:  "2021-07-30T12:58:53.287Z"
// 3:  "2021-07-31T12:58:53.287Z"
// 4:  "2021-08-01T12:58:53.287Z"
// 5:  "2021-08-02T12:58:53.287Z"
// 6:  "2021-08-03T12:58:53.287Z"
// 7:  "2021-08-04T12:58:53.287Z"
// ]
console.log(baseDate.toISOString())
// "2021-07-28T12:58:53.287Z" <- 起点日は変わっていない

検証: Temporal

 Temporalでの日時の加減計算はインスタンスメソッドのadd()subtract()で行えるが、加減値はDuration APIで初期化する必要がある。また、起点日時としてインスタンス化した書式がそのまま戻り値となる。ちょっとクセが強いが、起点日時のイミュータブル性は確保されている。

const baseDate = Temporal.Now.zonedDateTimeISO()
let weekDates = []
for (let add = 0; add <= 7; add++) {
  weekDates.push(baseDate.add(Temporal.Duration.from({days: add})))
}
console.log(weekDates.map(date => date.toString()))
// 結果: [
// 0:  "2021-07-28T22:00:30.124230122+09:00[Asia/Tokyo]"
// 1:  "2021-07-29T22:00:30.124230122+09:00[Asia/Tokyo]"
// 2:  "2021-07-30T22:00:30.124230122+09:00[Asia/Tokyo]"
// 3:  "2021-07-31T22:00:30.124230122+09:00[Asia/Tokyo]"
// 4:  "2021-08-01T22:00:30.124230122+09:00[Asia/Tokyo]"
// 5:  "2021-08-02T22:00:30.124230122+09:00[Asia/Tokyo]"
// 6:  "2021-08-03T22:00:30.124230122+09:00[Asia/Tokyo]"
// 7:  "2021-08-04T22:00:30.124230122+09:00[Asia/Tokyo]"
// ]
console.log(baseDate.toString())
// "2021-07-28T22:00:30.124230122+09:00[Asia/Tokyo]" <- 起点日時は変わっていない

 ミュータブルなDateオブジェクトを使っていて一番やっかいなのが、起点日時を格納したbaseDate定数は「いじらない!」というコーダー側の強い覚悟を常に持っていないと、どこかで日時が書き換わってしまっている不安にさいなまれることになることだ……w
 それに比べて他のライブラリはインスタンス化した日時はイミュータブルであることが担保されているので、心穏やかに日付を取り扱うことができる。

2桁年の自動マッピング

 Dateオブジェクトの最もヒドイ仕様は、与えられた年の数値引数が2桁以下(0~99)だった場合に、1900~1999年に自動でマッピングしてしまうことだ。さらに、年のみの指定ではインスタンス化できない点も地味に使いづらい。例えば、下記のコードを参照してみて欲しい。

for (let y = 0; y <= 100; y++) {
  // コンストラクタ引数が1つだけの数値の場合、UNIX時間が与えられたとしてインスタンス化される
  console.log(new Date(y).toISOString())// -> 1970-01-01T00:00:00.000Z ~ 1970-01-01T00:00:00.100Z
  // ローカルタイムゾーンでのインスタンス化の場合、オフセットによるUTC日時がズレるのであえて月の2日目を指定している
  console.log(new Date(y, 0, 2).getFullYear())// -> 1900 ~ 1999 最後は 100
  // UTCメソッドは年のみの指定でUNIX時間が得られるので、それを元にインスタンス化できる
  console.log(new Date(Date.UTC(y)).getUTCFullYear())// -> 1900 ~ 1999 最後は 100
}

 もし2桁以下の年を取得したい場合、このように書くことになる。

let twoDigitYearDate = new Date()
for (let y = 0; y < 100; y++) {
  twoDigitYearDate = new Date(twoDigitYearDate.setFullYear(y))
  console.log(twoDigitYearDate.getFullYear())// 0 ~ 99
  console.log(twoDigitYearDate.toISOString())// 0000-07-22T01:10:10.057Z ~ 0099-07-22T01:10:10.057Z
}

 一度テンポラリーなインスタンスを生成しておいて、Setterでプリミティブ値を上書きするという方法だ。美しくないうえに、無駄に処理コストが高くて、どうも好きになれない。理想的というか直感的に、2桁以下の年は、そのまま0~99年(BCE.1~CE.99)でインスタンス化されて欲しいんだよね。

 ちゅーわけで、これに対応できているライブラリを調査してみた。

検証: Luxon

 Luxonでの結果は下記の通り。理想的なマッピングが行われている。完璧だ!

const DateTime = luxon.DateTime

for (let y = 0; y <= 100; y++) {
  const date = DateTime.local(y)
  console.log(date.toString())
  // 結果(システムタイムゾーンは Asia/Tokyo)
  // 0000-01-01T00:00:00.000+09:18 ~ 0100-01-01T00:00:00.000+09:18
}

検証: DAYJS

 DAYJSでの結果は下記の通り。自動マッピングの処理に規則性がなく散々な結果だった。実際のところ、2桁年でのインスタンス化には非対応と云った方がよいだろう。

// 配列引数での日時インスタンス化にはプラグインが必要
dayjs.extend(window.dayjs_plugin_arraySupport)
for (let y = 0; y <= 100; y++) {
  let date = dayjs([y])
  console.log(date.format())
  // 結果(システムタイムゾーンは Asia/Tokyo)
  // y = 0: 2000-01-01T00:00:00+09:00
  // y = 1 ~ 12: 2001-01-01T00:00:00+09:00 ~ 2001-12-01T00:00:00+09:00 (月だけ変わる)
  // y = 13 ~ 31: Invalid Date (`year()`メソッドでは`NaN`となる)
  // y = 32 ~ 49: 2032-01-01T00:00:00+09:00 ~ 2049-01-01T00:00:00+09:00
  // y = 50 ~ 99: 1950-01-01T00:00:00+09:00 ~ 1999-01-01T00:00:00+09:00
  // y = 100: 100-01-01T00:00:00+09:15
}

検証: Temporal

 Temporalでの結果は下記の通り。2桁年に対しての自動マッピングは行われないものの、年のみでのインスタンス化が出来ない。また、インスタンスメソッドのfrom()は親となるPlain系メソッドに依存した日付データとなってしまい、ISO-8601形式等他の形式の日付が取得はできないためちょっと使い勝手が悪い。

for (let y = 0; y <= 100; y++) {
  // Temporalには年指定のみで日付をインスタンス化できるメソッドがないので、最低でも月指定が必要
  const date = Temporal.PlainYearMonth.from({ year: y, month: 1 })
  console.log(date.year, date.toString())
  // 結果
  // 0 ~ 100, "+000000-01" ~ "+000100-01"
}

 以上、この項目について私的な期待値を満たしてくれるライブラリはLuxonだけだった。
 まぁ、UNIX元期(1970年1月1日0時0分0秒)より前の時刻を取り扱うようなケースは稀なのかもしれないが、私的にはここがかなり重要なポイントでもある。

「月」の指定レンジが 0~11

 Dateオブジェクトでは、1月は0、12月は11と云うように配列の数値添え字の感覚で月を指定しないといけない。これが直感的でなく、コードの可読性を著しく阻害している要因でもある。一応12進数として繰り上がりが行われるので、月に12と指定してもエラーにはならず、ただ繰り上がって翌年の1月になってしまいバグの温床にもなり易いのだ。
 ちゅーわけで、直感的で、開発者に優しい1~12の数値で月が指定できるライブラリを調査してみた。

検証: Luxon

const date = DateTime.local(2021, 7, 22)
console.log(date.month, date.monthLong, date.monthShort)// 7 "July" "Jul"

 さすがはLuxon! 渡した月の数値をそのまま月としてインスタンス化してくれる。

検証: DAYJS

const date = dayjs([2021, 7, 22])
console.log(date.toISOString(), date.format(), date.month(), date.get('month'))
// 結果
// date.toISOString(): "2021-08-21T15:00:00.000Z"
// date.format():      "2021-08-22T00:00:00+09:00"
// date.month():       7
// date.get('month'):  7

 DAYJSはちょっと微妙……というかむしろ危険だ。インスタンス化した日付を日付文字列やフォーマッターを介して出力すると、ネイティブJSと同じように+1の月となるが、個別に日付要素を取得するインスタンスメソッドで取得すると引数で渡した月が返る。これはどちらかに統一しないとバグの温床になり得ないか!?
 特に、get('month')メソッドについては公式ドキュメントに0開始の月が返ると書いてあることもあって、ちょっと混乱してしまったw

検証: Temporal

const yearMonth = Temporal.PlainYearMonth.from({ year: 2021, month: 7 }),
      monthDay  = Temporal.PlainMonthDay.from({ month: 7, day: 22 }),
      date      = monthDay.toPlainDate({ year: 2021 })
console.log( yearMonth.month, monthDay.monthCode, date.toString() )
// 結果
// yearMonth.month:    7
// monthDay.monthCode: "M07"
// date.toString():    "2021-07-22"

 Temporalも与えられた月の数値をそのまま月として日付をインスタンス化してくれる。ただ、Temporalはインスタンスを生成する際に使われたメソッドによって生成される日付のプリミティブ値が異なる。「年と月」から生成した場合と「月と日」から生成した場合とでは全く別の断片的な日付インスタンスが生成されるのだ。そして、生成したインスタンスの欠けている日時要素を補完することで断片データから完全な日時データを作成していくことができる。
 従来のJavaScriptのDateオブジェクトと全く異なる概念性を持っているので、特性に馴れないと逆に使いづらいかもしれない。

タイムゾーンオフセットのゆらぎ

 あまり知られていないが、JavaScriptのタイムゾーンオフセットは固定値ではない。歴史的経緯や年代によって同じタイムゾーンでも時差が異なるのだ。以前気になって独自に調べてみたところ、下記の例のように結構な頻度でブレている。

America/New_York (UTC-05:00) America/Danmarkshavn (Coordinated Universal Time) Europe/London (GMT Standard Time) UTC Africa/Monrovia (Greenwich Standard Time) Asia/Kathmandu Asia/Tokyo (Japan Standard time) Pacific/Tongatapu (UTC+13:00)
2021 -05:00 +00:00 +00:00 +00:00 +00:00 +05:45 +09:00 +13:00
2000 ~ 2002, 2017 +14:00
1917 ~ 1995 -03:00
1920 ~ 1985 +5:30
1920 ~ 1972 -00:44:30
1943 ~ 1945 -04:00 -00:44:30
~ 1942 -05:00 -00:44:30
~ 1919 -00:43:08 +05:41:16
~ 1916 -01:14:40
1901 ~ 1940 +12:20
~ 1900 +12:19:20
~ 1888 +09:18:59
~ 1883 -04:56:02
~ 1847 -00:01:15

 システム時制として一般的なUNIXエポック元期(1970/1/1)以降でも結構なブレが発生している。同一タイムゾーンにおいて時差が異なる二点間のUTC日時のインターバルを取得する場合、その差分はどのように取り扱われているのかが気になるところだろう。
 以前、GMT(グリニッジ標準時)においてこのタイムゾーンのキャズム(溝、境界)間のDateオブジェクトのプリミティブ値がどうなっているか調べたことがあるが、その結果、キャズムポイントである1847年12月1日0:0:0~0:1:15において時間が消失していることが分かった。つまり、消失時間中の日時は取得できないのだが、消失することで前後の差分が吸収されて、結果として丸く収まる具合だ(これってあまりにもやっつけな仕様だと思うが……)。一方で、日本標準時の場合は時間消失は起きなかった(つまりは差分吸収がされないために丸く収まらないということだ)。
 これらのタイムゾーンオフセットの揺らぎとキャズム間の挙動は一見するとバグに近しいのだが、あまり表立って問題にもならないので、深く突っ込むのは止めておこうかと思うw
 差し当って、同一タイムゾーン間で時差が異なる二点間のUTC日時の期間をそれぞれのライブラリで取得してみた。調査ポイントとしては、差分吸収が行われていない日本標準時のキャズム日時を利用する。

検証: Dateオブジェクト

 まず、ネイティブJSのDateオブジェクトでの挙動を確認しておく。日時比較はそれぞれの日時のミリ秒を取得して差分を取ることになる。

const date1  = new Date(1888, 0, 1, 0, 18, 58, 999),// Sun Jan 01 1888 00:18:58 GMT+0918 (JST)
      date2  = new Date(1888, 0, 1, 0, 18, 59, 0),// Sun Jan 01 1888 00:18:59 GMT+0900 (JST)
      diffMs = date2.getTime() - date1.getTime()
console.log(diffMs)// 1139001

 重要な点はタイムゾーンオフセットのキャズム時刻が 1888-01-01T00:18:58.999 になることと、論理的には二点間日時の時差が1ミリ秒のはずなのに、1,139,001ミリ秒(18分59秒1ミリ秒)の差が出てしまうことだ。これが、GMTと異なり時差吸収が行われないタイムゾーンオフセットのキャズム挙動である。

検証: Luxon

 Luxonでは二点間の日時の差分を取得できるインスタンスメソッドdiff()が実装されているので、これを使う。
 そして、重要なのはタイムゾーンオフセットのキャズム時刻が 1888-01-01T00:00:00.000 になることだ。

const end    = DateTime.local(1888, 1, 1, 0, 0, 0, 0),// 1888-01-01T00:00:00.000+09:00 (UTC+09:00)
      start  = DateTime.local(1887, 12, 31, 23, 59, 59, 999),// 1887-12-31T23:59:59.999+09:18 (UTC+09:18)
      diff   = end.diff(start),
      diffMs = diff.toObject().milliseconds
console.log(diffMs)// 1080001

 日時として日本標準時の 1887-12-31T23:59:59.999 と 1888-01-01T00:00:00.000 の差は1ミリ秒なのだが、この二点間日時の間にはタイムゾーンオフセットに1,080,001ミリ秒(18分1ミリ秒) の時差が発生しているため、差分計算を行うとその時差が吸収されずに算出される。

検証: DAYJS

 DAYJSで日時比較するにはインスタンスメソッドdiff()が使える。このメソッドはプラグインなしでも利用可能だ。
 そして、重要なのがタイムゾーンオフセットのキャズム時刻が 1888-01-01T00:18:58.999 になることだ。これはネイティブJSのDateオブジェクトと同じである。

dayjs.extend(window.dayjs_plugin_arraySupport)
const date1  = dayjs([1888, 0, 1, 0, 18, 58, 999]),// Sun Jan 01 1888 00:18:58 GMT+0918 (JST) 
      date2  = dayjs([1888, 0, 1, 0, 18, 59, 0]),// Sun Jan 01 1888 00:18:59 GMT+0900 (JST)
      diffMs = date2.diff(date1)
console.log(diffMs)// 1139001

 日時として日本標準時の 1888-01-01T00:18:58.999 と 1888-01-01T00:18:59.000 の差は1ミリ秒だが、この二点間日時の間にはタイムゾーンオフセットに1,139,001ミリ秒(18分59秒1ミリ秒)の時差が発生している。時差の値についてもネイティブJSと同じなので、DAYJSのコア処理はかなりDateオブジェクトに依存していることがわかる。

検証: Temporal

 Temporalではインスタンスメソッドのuntil()を使うことで2点間日時の差分を取得できる。
 重要なタイムゾーンオフセットのキャズム時刻は 1888-01-01T00:18:58.999999999 である。Temporalではナノ秒までの精度で日時をインスタンス化できるので高精度な値になっているが、キャズム時刻はネイティブJSやDAYJSと同じポイントになっている。

const date1 = Temporal.ZonedDateTime.from({
        // 1888-01-01T00:18:58.999999999+09:18:59
        timeZone: 'Asia/Tokyo',
        year: 1888, month: 1, day: 1,
        hour: 0, minute: 18, second: 58,
        millisecond: 999, microsecond: 999, nanosecond: 999 }),
      date2 = Temporal.ZonedDateTime.from({
        // 1888-01-01T00:18:59.000000000+09:00
        timeZone: 'Asia/Tokyo',
        year: 1888, month: 1, day: 1,
        hour: 0, minute: 18, second: 59,
        millisecond: 0, microsecond: 0, nanosecond: 0 }),
      diffTimes = date1.until(date2, {largestUnit: 'minute', smallestUnit: 'nanosecond'})
console.log(diffTimes.toLocaleString())// PT18M59.000000001S

 日時として日本標準時の 1888-01-01T00:18:58.999999999 と 1888-01-01T00:18:59.000000000 の差は1ナノ秒だが、この二点間日時の間にはタイムゾーンオフセットに18分59秒1ナノ秒の時差が発生している。精度の差こそあるが、この差分値はネイティブJSやDAYJSと同じである。


 この問題、本来バグっぽい仕様なこともあって、どの仕様を正とするべきかが判断できないのだが、あえて総括するならば、多数決でDateオブジェクト準拠のDAYJSとTemporalの挙動が正しいのかもしれない。Luxonだけオフセットのキャズム時刻のタイミング自体がズレているのは、なかなか整合性を取りづらいのだ。とはいえ、Luxonのキャズム時刻はちょうど年が変わる時点になっていてわかりやすいので、個人的にはこっちの方が扱いやすくて好ましい。

 ちゅーか、今回この調査していて、ローカルタイムゾーンやDST(夏時間)といったローカルルールに影響を受けるような時制システムを構築しては駄目だな…と痛感した。システム内で取り扱う日時は一貫してUTCで統一しておくのが最も無難で安心できる。一般的にUTCと同一と云われているGMTですら時差が発生するようでは、タイムゾーンで信じられるのは唯一UTCだけなのだ。もはや、ITシステムの時制にローカルタイムゾーンなんて要らないぐらいだw

UNIX時間の単位

 これについてはそこまで不満はないのだが、まぁ可用性があると嬉しいな的な感じだ。サーバーサイドや外部との連携時にUNIX時間(UNIXタイムスタンプ)の単位を揃えられると便利じゃないかなぁ……と思った次第。
 まず、主要な各高級言語で取り扱われる日時データのUNIX時間として、取得できる値の単位を一覧化してみると、下記のようになった。

言語 メソッドなど 取得できるUNIXエポックの単位(最小)
PHP time() , date_format('U')
PHP microtime() マイクロ秒
Ruby Time.now.usec マイクロ秒
Ruby Time.now.strftime('%N') ナノ秒
Python time.time(), datetime.timestamp()
Python datetime.strftime('%f') マイクロ秒
Java System.currentTimeMillis() ミリ秒
Java System.nanoTime() ナノ秒

 うーん……思ってた以上にバラバラだねぇ……。
 現状のJavaScriptのDateオブジェクトでもミリ秒まで取り扱っているし、特に問題は出なさそうだ。
 結局、JavaScript側ができる最良の互換性としては、サーバーサイド側で取り扱われ得る最小単位のナノ秒まで対応しておくことぐらいなのかもしれない。そう考えると、ナノ秒まで取り扱われるTemporalはその辺の互換性まで考慮して策定しているってことになる。

まとめ

 現状、JavaScriptにおける日時処理をスマートに開発したいのであれば、「Luxon」を選択するのが最適解のような気がする。他にも、今回比較対象に含めなかったdate-fnsなどもJS界隈では人気があるので、あくまでLuxon推しは今回の比較対象の内でということになるんだが……。
 確実に云えることは、Temporalだけはまだ採用しない方がいいということだ。ただし、将来的にネイティブJSにTemporalがビルトインされた場合、他のライブラリは不要になって淘汰されてしまう可能性はある。今回Temporalを実際に触ってみて、その性能とパフォーマンスに改めて期待が膨らんだのも確かなのだ。


 蛇足だが、今回この調査をすることにした切っ掛けは、私が今開発しているjQuery.TimelineプラグインのネイティブJavaScriptバージョンのコアライブラリとして、どの日付処理ライブラリを採用しようか迷ったからである。
 で、結局何を選んだのかというと、どれも自分的にイマイチなところがあって、採用は見送った次第……(おぃw)

 色々検討した末、今回の調査で得られた各ライブラリのイイところを吸収し、さらに個人的なDateオブジェクトの不満点も解消できる、新たな日時処理ライブラリを開発することにしたのだ(こうやって本筋であるTimelineプラグインの開発は遅れていく……w)。
 ちなみに、開発中のライブラリはこちらである。

 まぁ何はともあれ、今回の調査でネイティブJavaScript用のライブラリのソースコードを色々読んだのが、最大の収穫だった。ソースコード的には、DAYJSのコードが自分好みで非常に読みやすく、インスピレーションが刺激された。