JS の Date で任意のタイムゾーンを表現するのは難しい
一言で
JS ビルトインの Date
はタイムゾーンが環境依存で不便な面がある。
dayjs
や date-fns
といったライブラリは任意のタイムゾーン指定をサポートしているが、あくまで Date
をベースに作られているため環境によっては期待通りに動かない。
JS Date
の使いづらい点
JavaScript の Date
は以下の理由で使いづらい:
- 不変じゃない。
setTime
などでインスタンスの日時を変更できる。 - 文字列パース (
new Date(string)
,Date.parse
) の挙動が実装依存 (MDN)。 - タイムゾーンを指定できない。常にローカル (ホストシステム) のタイムゾーンになる。
1,2 は Date
の使用方法を制限したり、 Date
のラッパーを使ったりすれば対処はできるが、 3 は少々厄介。
Node.js のようなサーバ環境ならローカルのタイムゾーンを適宜設定すれば良いが、ブラウザのようなユーザ環境ではそれもできない。
複数のタイムゾーンを扱いたい時や、常に特定のタイムゾーンで日時を表現したい時などに困りうる。
ローカルのタイムゾーンが何であれ任意のタイムゾーンを指定可能にする仕組みは作れなくはないが、 DST (サマータイム) を考慮すると厳密にやるのは難しい。
そのため Date
をベースに実装されている dayjs
や date-fns
といったライブラリを使う際には一応注意がいる。
どんな問題があるのかをここにメモしとく。
Date
の挙動おさらい
Date
のインスタンスは特定の UTC 日時を表し、 getDate
, getHours
などの各種メソッドはローカルのタイムゾーンを考慮した値を返す。
UTC における日時を取得したい時は getUTCDate
, getUTCHours
系のメソッドが使える。
process.env.TZ = "Asia/Tokyo"; // +09:00
// コンストラクタで渡す値はローカルの日時として解釈される (month は 0 始まり)。
const d = new Date(2020, 3, 1, 10, 0, 0);
console.log(d.toISOString()); //=> 2020-04-01T01:00:00.000Z
console.log(d.getUTCHours()); //=> 1
console.log(d.getHours()); //=> 10
process.env.TZ = "Asia/Dubai"; // +04:00
// タイムゾーンが変わっても d が保持する UTC 日時は変わらない。
console.log(d.toISOString()); //=> 2020-04-01T01:00:00.000Z
console.log(d.getUTCHours()); //=> 1
// getHours 系メソッドは新しいタイムゾーンに応じた値を返すようになる。
console.log(d.getHours()); //=> 5
(なお process.env.TZ
で Date
のタイムゾーンを変更できるのは Node.js v14 以降:
https://zenn.dev/dora1998/articles/node-process-env-tz)
見かけの日時を調整して別のタイムゾーンを表現してみる
このような挙動である Date
を使い、ローカルのタイムゾーンに関わらず任意のタイムゾーンにおける日時を表現できるかを考えてみる。
Date
の getTimezoneOffset
メソッドは、そのインスタンスが使う UTC へのオフセットを分単位で返す。
Asia/Tokyo
なら -540 になる。
これを使えば、せめて任意のオフセットを指定しての日時処理は出来るはず。
ローカルのオフセットと望むオフセットとの差分を取る事で、 getHours
などが返す値を調整すれば良い。
オフセットを調整する実装例:
// UTC からのオフセットと Date を受け取り、
// 「そのオフセットにおける originalDate の日時」を保持してるっぽく見える Date を返す。
const withUTCOffset = (desiredOffset, originalDate) => {
const utcTime = originalDate.getTime();
const date = new Date(utcTime);
const offsetDiff = desiredOffset + date.getTimezoneOffset();
date.setTime(utcTime + offsetDiff * 1000 * 60);
return date;
};
(ちなみに、 getTimezoneOffset
が常にローカルのタイムゾーンに基づくオフセットを返すなら、別にインスタンスメソッドである必要はなさそうに見えるが、 DST のあるタイムゾーンではオフセットが時期により変わる。つまりインスタンスが表す日時によって同じタイムゾーンでもオフセットは異なりうるため、インスタンスメソッドになっている)
上手くいく例
以下ではタイムゾーンが Asia/Dubai
である環境で Asia/Tokyo
のローカル日時を表現している:
const tokyoOffset = 540; // +09:00
const okPattern = () => {
process.env.TZ = "Asia/Dubai"; // +04:00
const d1 = new Date(2021, 3, 1, 10, 0, 0);
console.log(d1); //=> 2021-04-01T06:00:00.000Z
console.log(d1.getHours()); //=> 10
const d2 = withUTCOffset(tokyoOffset, d1);
// UTC 06:00 == Dubai 10:00 == Tokyo 15:00
console.log(d2.getHours()); //=> 15
};
okPattern();
d2
が指す実際の UTC 日時はデタラメだが、 getHours()
などのメソッドは「d1
が Asia/Dubai
で指していた日時の Asia/Tokyo
における値」を返すように調整されている。
この方法を使えば、ローカルのタイムゾーンに関わらずタイムゾーン (オフセット) を指定可能な日時クラスを作れそうに見える。
見かけ上の日時だけでなく実際の UTC 日時も必要なら、 d1.getTime()
が返すミリ秒を合わせて保持すれば良い (getTime
はタイムゾーン関係なく Unix 時刻からの経過時間を返す)。
実際これは割と上手くいくが、ローカルのタイムゾーンが DST を持つ場合、日時を正しく調整できないケースが存在する。
DST により上手くいかない例
例えば America/Chicago
には DST があり、2021 年は 3月14日 02:00 から DST が始まる。つまり 01:59 の次は 03:00 となる。
そのため America/Chicago
においては 2021-03-14 02:00 ~ 02:59 という日時が存在しない。
process.env.TZ = "America/Chicago";
const d1 = new Date(2021, 2, 14, 2, 0, 0);
console.log(d1.getHours()); //=> 3
JS の Date は存在しない月日や時刻を指定してもエラーにならず、超過分を繰り越す。
例えば new Date(2000, 15, 33)
は 2001-05-03 となる。
なので 02:00 を指定している上記の例もエラーにはならず、 03:00 となる。
こうなると、先程示した withUTCOffset
には問題があるとわかる。
ローカルのタイムゾーンが America/Chicago
の状態で Asia/Tokyo
における 2021-03-14 02:00 を表現しようとしても、 getHours
が 3 を返すため期待する日時と 1 時間ズレてしまう。
const impossiblePattern = () => {
process.env.TZ = "America/Chicago"; // -06:00, -05:00 in DST
const d1 = new Date(2021, 2, 13, 11, 0, 0);
console.log(d1); //=> 2021-03-13T17:00:00.000Z
console.log(d1.getHours()); //=> 11
const d2 = withUTCOffset(tokyoOffset, d1);
// Chicago 11:00 == UTC 17:00 == Tokyo 02:00 (next day)
// hours should be 2 but 3
console.log(d2.getHours()); //=> 3
};
impossiblePattern();
このように withUTCOffset
相当の実装は、 DST が絡むと正しく動かないケースがある。
getHours
系メソッドはローカルのタイムゾーンを自動で考慮するためこういう問題が起きるが、
UTC における日時を返す getUTCHours
系メソッドなら特定の時刻を返せない問題はない。ので UTC 日時の方を調整する手もあるが、結局は同じ問題にぶつかる。先程の例とは逆に America/Chicago
のタイムゾーンにおける日時を作りたい場合、 2021-03-14 02:00 ~ 02:59 は存在してはいけないのに、 getUTCHours
系を使うと 02:00 という時刻を返せてしまう。
というわけで、 JS の Date
を使って任意のタイムゾーンにおける日時を表現するのは難しい。
この問題は DST へ切り替わる日時において発生する。逆に言うとそれ以外の日時では (たぶん) 期待通りに動くので、 withUTCOffset
相当の実装でも問題ないユースケースはあるかも。どこまで厳密さを求めるかによる。
Date
を使うライブラリは同じ問題を持つ
dayjs
や date-fns
といったライブラリは、タイムゾーンを考慮した日時計算も提供している。
しかしこれらはビルトインの Date
を内部で使うため、試してみるといずれも前述の問題を抱えている事がわかる。
全く同じ処理でも、ローカルのタイムゾーン次第で結果が変わってしまう。
dayjs
(v1.10.4):
const dayjs = require("dayjs");
const utc = require("dayjs/plugin/utc");
const timezone = require("dayjs/plugin/timezone");
dayjs.extend(utc);
dayjs.extend(timezone);
const d = new Date(Date.UTC(2021, 2, 13, 17, 0, 0));
const format = (d) => dayjs(d).tz("Asia/Tokyo").format("YYYY-MM-DD HH:mm:ss");
process.env.TZ = "Asia/Dubai";
console.log(format(d));
//=> 2021-03-14 02:00:00
process.env.TZ = "America/Chicago";
console.log(format(d));
//=> 2021-03-14 03:00:00
date-fns
(v2.21.1):
const fns = require("date-fns");
const tz = require("date-fns-tz");
const d1 = new Date(Date.UTC(2021, 2, 13, 17, 0, 0));
const format = (d) => {
d = tz.utcToZonedTime(d, "Asia/Tokyo");
return fns.format(d, "yyyy-MM-dd HH:mm:ss");
};
process.env.TZ = "Asia/Dubai";
console.log(format(d1));
//=> 2021-03-14 02:00:00
process.env.TZ = "America/Chicago";
console.log(format(d1));
//=> 2021-03-14 03:00:00
一方で、 JS の Date
を使わずに独自の DateTime
クラスを提供する Luxon
にはこの問題がなかった (v1.26.0):
const { DateTime } = require("luxon");
process.env.TZ = "America/Chicago";
const d1 = DateTime.local(2021, 2, 13, 11, 0, 0);
console.log(d1.toISO());
//=> 2021-02-13T11:00:00.000-06:00
//(= 2021-02-13T17:00:00.000Z)
const d2 = d1.setZone("Asia/Tokyo");
console.log(d2.toISO());
//=> 2021-02-14T02:00:00.000+09:00
が、bundle size が 70kB とブラウザで気軽に使うには少し重め。
https://bundlephobia.com/[email protected]
ざっくり方針
というわけで、タイムゾーンの厳密な考慮を JS でやるなら Luxon のようなライブラリを必要がありそう。
- ローカルのタイムゾーンが不定な環境で特定のタイムゾーンを使いたい時
- 複数のタイムゾーンを扱いたい時
ただ実際には、ブラウザのようなクライアント環境で任意のタイムゾーンを使いたいケースはあまり多くないのかもしれない。 単なる日付の表示・入力であれば、ローカルのタイムゾーンに則る方が基本的に UI として自然なはず。 せいぜい日時の作成や比較時に、ローカル日時と UTC 日時を混同しないよう注意が必要なくらいでは。
また ECMAScript に新たに導入されそうな Temporal
という新しい日時 API が使えるようになれば、 Date
の辛みは解消しそう:
https://tc39.es/proposal-temporal/docs/