moment.js を temporal に移植するのはどのくらいラクか or 大変か
JS で新しく提案されている日付計算の API である temporal。
プロポーザルとしてはまだ stage 2 だが、先日の TC39 のスライドですでに仕様やポリフィルは stable と言っていい状況(?)という文言が見えたので、使ってみる
実は筆者はすでに一度(2020年8月) temporal にフィードバックのアンケートを送ったことがあるが、そのときは新規プロジェクトでの使用を想定したもので、既存のを移行するとどのくらいつらいかはあまり考えていなかった。
また、今 temporal-polyfill を見たら当時からさらに API が変わっていた。
具体的には、現在時刻をタイムゾーン指定で生成するためのメソッドである Temporal.now.zonedDateTimeISO()
は私がアンケートを送った頃にはなかったし、Duration に負の値を取れるというのも当時は確定してなかった。
結論、当然のことではあるが、moment.js からの移行コストだけを考えるのであれば day.js の方が圧倒的に楽でおすすめできる。
moment → day.js と違い、moment → temporal は単なる API の変更以上に圧倒的に思想の変更を余儀なくされる。
ちなみに、以下の検証をする前は私は temporal に全面移行する気満々だった( 数ヶ月前の自分は、「どうせすぐ temporal 来るんだから急いで date-fns や day.js に移行したってもう一回捨てるだけだよ」とか言ってた )
例) フォーマット。item.createdAt
は ISO8601 文字列とする( 2020-11-29T12:00:00
)
// moment.js
<div>
{moment(item.createdAt).format('YYYY年MM月DD日')}
</div>
// day.js
<div>
{dayjs(item.createdAt).format('YYYY年MM月DD日')}
</div>
// temporal
<div>
{Temporal.PlainDate.from(item.createdAt).toLocaleString('ja-JP', {
year: 'numeric',
month: 'long',
day: '2-digit',
})}
</div>
temporal 長い……長いが i18n 対応を明確に意識できる点は良い( option は Intl.DateTimeFormat
と同じものが渡せる )
たぶん、toLocaleString を毎回呼ぶのがダルさの原因で、Intl.DateTimeFormat
のインスタンスを使い回すほうがマシかも。
export const yyyyMdDd = new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: 'long',
day: '2-digit',
})
const createdAt = Temporal.PlainDate.from(item.createdAt)
yyyyMdDd.format(createdAt as any)
が、Intl.DateTimeFormat['format'] の型定義はそのままでは temporal のインスタンスを受け付けないので型定義をいじっておく必要がある
そもそも現状の Intl.DateTimeFormat は temporal のオブジェクトを受け取れるのか?
Temporal.Instance#toLocaleString は Intl.DateTimeFormat のラッパーなので実装に入ったらいけそう。
internal slot を見る関係で polyfill だと Intl.DateTimeFormat 自体を上書きしないといけなそう(未検証)。
temporal 使った時の感想として、そもそも暦やタイムゾーンを意識しないと手が進まないタイプの API だなーというのがあった。
これは慣れのせいもあるかもしれない。
デフォルトではタイムゾーンを意識しなくても使える moment.js / day.js と比較したとき、強制的に意識させられたほうが健全なケースも多いはず。
が、元々意識せずに書かれたコードをリファクタリングして temporal に移行しようと思うと脳の負荷がすごい
(「えーとここは unix ミリ秒なので Temporal.Instant
だが、こっちは Temporal.PlainDate
で作って良いんだっけ」)
たとえば Storybook の addon で date()
knob を使い、ISO8601 文字列を受け取るコンポーネントの Story を書いているとしよう。
// moment.js
export const 通常時 = () => {
const createdAt = date('作成日時', new Date())
return <TargetComponent createdAt={moment(createdAt).toISOString()} />
}
// temporal
export const 通常時 = () => {
const createdAt = date('作成日時', new Date())
const createdAtIso8601 = Temporal.Instant.fromEpochMilliseconds(createdAt)
.toZonedDateTimeISO({ timeZone: Temporal.now.timeZone() })
.toString()
return <TargetComponent createdAt={createdAtIso8601} />
}
だるすぎるので、temporal を使うなら自分で date()
をラップして専用の knob を作るだろう
export const iso8601 = (label: string, legacyDate = new Date()) => {
return Temporal.Instant.fromEpochMilliseconds(date(label, legacyDate))
.toZonedDateTimeISO({ timeZone: Temporal.now.timeZone() })
.toString() as ISO8601
}
export const 通常時 = () => {
const createdAt = iso8601('作成日時', new Date())
Storybook 6 組み込みの Controls でも同じ問題が発生するだろうか?こっちは検証していない
それだと文字列に Zone が含まれてしまうので
Temporal.Instant.fromEpochMilliseconds(date(label, legacyDate))
.toString({ timeZone: Temporal.now.timeZone() }) as ISO8601
かと。
おお、なるほど。ありがとうございます!!
総じて、「元のコードがどういうタイムゾーンや i18n などを意識して書かれてたかを読み解きながら temporal に置き換える」のはとてもつらい。
一方新規で書く時に temporal ファーストで設計するとすごく健全なコードがかけそう。
プロジェクト内の moment.js をサクッと捨てることが目的ならおとなしく day.js に逃げたほうが良いのでは…
逆に、temporal だからできる圧倒的に素晴らしい点として、型の豊富さがある。
たとえば temporal には PlainYearMonth 型( 「2020年の11月全体」を表すインスタンスが作れる )や PlainMonthDay 型( 「どの年にも紐付かない11月30日」を表すインスタンスが作れる )がある。
単に「今月」という情報が欲しかっただけの際に、現在時刻を M
で format と書かなくていいというのはセマンティクスとして美しい。