moment.js・day.js よりも速くて軽い cdate ライブラリ

[moment.js](https://www.npmjs.com/package/moment) や [day.js](https://www.npmjs.com/package/dayjs) と似たインターフェースを実装した高速・軽量の JavaScript ライブラリ『cdate』をリリースしました。→ https://www.npmjs.com/package/cdate
cdate の主な特徴：

- moment.js や day.js、Luxon よりも高速
- moment.js と同じ `.format("YYYY-MM-DD HH:mm:ss")` 出力フォーマットに対応
- strftime と同じ `.text("%Y-%m-%d %H:%M:%S")` 出力フォーマットに対応
- moment.js と同様に `.add(1, "month").startOf("week").endOf("day")` のような計算に対応
- `.tz("Asia/Tokyo")` あるいは `.utcOffset("+09:00")` のようなタイムゾーン（時間帯）指定に対応。
- I18N 国際化対応: `.locale("ja").text("%c")` → `2023年1月4日(水) 00:00:00`
- ブラウザ向けの [cdate.min.js](https://cdn.jsdelivr.net/npm/cdate/dist/cdate.min.js) は 9KB と軽量。通信時は gzip 圧縮されてわずか 4KB。
- Immutable。（moment.js とは違って）メソッドチェーン呼び出しの都度に、新しいオブジェクトを返します。非破壊。
- Pure JavaScript。ESM・CommonJS 両対応です。

## 競合ライブラリとの比較

moment.js はかつて人気がありましたが、すでに開発が終了しています。また、[Intl.DateTimeFormat](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) に対応しないため、`Asia/Tokyo` のようなタイムゾーン（時間帯）を使いたい場合には、容量の大きな [moment-timezone](https://www.npmjs.com/package/moment-timezone) ライブラリを追加で読み込む必要がありました。

この moment.js の後継として、day.js も人気が高まっていますが、タイムゾーン指定時の動作が moment.js よりも 2ケタ遅いので同等には利用しづらいです。また、cdate のテスト実装中に、day.js 側のタイムゾーン周りに複数のバグが見つかりました。→ Issue [#2152](https://github.com/iamkun/dayjs/issues/2152)、[#2162](https://github.com/iamkun/dayjs/issues/2162)、[#2164](https://github.com/iamkun/dayjs/issues/2164)

[Luxon](https://www.npmjs.com/package/luxon) は、moment.js と同じメンバが開発しているようですが、なぜか moment.js とは API が異なります。また、タイムゾーン指定時も、ローカル時間帯利用時も、いずれの場合も動作が 1ケタ遅いので、とくに採用するメリットがありません。

[date-fns](https://www.npmjs.com/package/date-fns) は、１関数ごとにファイルが分かれているので tree shaking が効きまくるメリットがあるものの、API 設計思想が全く異なります。複数の関数を組み合わせて使う場合に、moment.js のように処理の順序通りに左から右にメソッドを並べるメソッドチェーンでなくて、date-fns は右から左に処理の逆順で関数を並べて記述する必要があります。賢いプログラマ向け。

## ベンチマーク結果

メソッドチェーンで似たように書ける4ライブラリの処理速度を比較すると、cdate が最速となります。

| ライブラリ | バージョン | .min.js 容量 | ローカル時間 | タイムゾーン指定時 | 備考   | 
|---------|---------|--------------:|-----------------:|----------------:|------------------|
| cdate   | 0.0.4   |          9 KB |    7,936 ops/sec |   6,380 ops/sec | もっとも速い 🍺      |
| moment  | 2.29.4  |       100 KB+ |    5,702 ops/sec |   3,573 ops/sec | 巨大な tz database |
| dayjs   | 1.11.7  |         11 KB |    3,841 ops/sec |      88 ops/sec | 夏時間周りにバグあり |
| luxon   | 3.2.0   |         74 KB |      914 ops/sec |     156 ops/sec | API が異なる   |

## （事例１） 月間カレンダーを描画する

cdate では下記のような読みやすいコードで、カレンダーを作成できます。

```js
const {cdate} = require("cdate");
const today = cdate(); // 今日
const start = today.startOf("month").startOf("week"); // 今月の第１週の日曜日
const end = today.endOf("month").endOf("week"); // 今月の最終週の土曜日

for (let day = start; +day < +end;) {
    const week = [];
    for (let i = 0; i < 7; i++) {
        week.push(day.get("date")); // 日付を取り出す
        day = day.next("day"); // 翌日
    }
    console.log(week.join(" "));
}
```

ちなみに、moment は破壊型なので `.clone()` を併用して下記のようなコードになります。

```js
const moment = require("moment");
const today = moment(); // 今日
const start = today.clone().startOf("month").startOf("week"); // 今月の第１週の日曜日
const end = today.clone().endOf("month").endOf("week"); // 今月の最終週の土曜日

for (let day = start.clone(); +day < +end;) {
    const week = [];
    for (let i = 0; i < 7; i++) {
        week.push(day.get("date")); // 日付を取り出す
        day.add(1, "day"); // 翌日
    }
    console.log(week.join(" "));
}
```

## （事例2） 日本時間帯 JST 専用モード

さて、cdate は、デフォルトではローカルの時間帯を使います。とはいえ、日本国内専用のアプリ（ウェブサイト）であれば、あえてローカル時間帯を使わずに、日本時間帯 JST 専用にしたい用途も多いかと思います。

環境変数 `process.env.TZ` を使わずに、タイムゾーンを固定する場合は、以下のように JST 専用版関数 `cdateJST()` を生成してください。

```js
const {cdate} = require("cdate");
const cdateJST = cdate().tz("Asia/Tokyo").cdateFn();

cdateJST("2023-01-01 00:00:00").text("%Y/%m/%d %H:%M:%S %:z");
// => '2023/01/01 00:00:00 +09:00'

cdateJST("2023-01-01 00:00:00").format("YYYY/MM/DD HH:mm:ss Z");
// => '2023/01/01 00:00:00 +09:00'
```

上記の `cdateJST` では、日時パーサー・出力フォーマットどちらも JST 固定になります。MySQL の DATETIME 型のデータがタイムゾーン情報を含まない `2023-01-01 00:00:00` 形式の日時を返していても、ローカルの時計によらず、日本時間で計算できるので安心です。

ちなみに、日本時間に限定すれば次回オリンピック開催時とかに夏時間が導入されない限りどうせ GMT+9 で固定なので、↑は↓でも同じ結果になります。（より速い）

```js
const cdateJST = cdate().utcOffset(9).cdateFn();
```

日本国内専用であれば、後者 `.utcOffset(9)` の方がシンプルで高速で良いです。

## （事例３） 曜日だけ日本語対応したい

曜日を返す `ddd` や `%a` はデフォルトでは英語表記になります。

```js
const {cdate} = require("cdate");

cdate().format(`YYYY年M月D月(ddd)`)
// => '2023年1月4月(Wed)'

cdate().text(`%Y年%-m月%-d月(%a)`)
// => '2023年1月4月(Wed)'
```

日本語表記の `水` を使いたい場合は、`.locale()` メソッドを利用します。

```js
cdate().locale("ja").format(`YYYY年M月D月(ddd)`);
// => '2023年1月4月(水)'
```

あるいは、Intl を使いたくない場合は [cdate-locale](https://www.npmjs.com/package/cdate-locale) ライブラリを使う方法もあります。

```js
const {cdate} = require("cdate");
const {locale_ja} = require("cdate-locale");
cdate().handler(locale_ja).format(`YYYY年M月D月(ddd)`);
// => '2023年1月4月(水)'
```

とはいえ、『なにも全体を日本語化したいわけではなくて、曜日だけ `水` 表記に対応したいのだ』（自分でチューニングしたい!）という用途も、よくある話かと思います。その場合は、`.handler()` メソッドでフォーマットを実装できます。

```js
const handler = {
    ddd: (dt) => ("日月火水木金土")[dt.getDay()],
};

cdate().handler(handler).format(`YYYY年M月D月(ddd)`);
// => '2023年1月4月(水)'
```

なお、引数 dt は、Date オブジェクトまたは [DateLike](https://github.com/kawanet/cdate/blob/main/index.d.ts) オブジェクトです。

strftime が好きな方は、`%a` を定義してください。

```js
const handler = {
    "%a": (dt) => ("日月火水木金土")[dt.getDay()],
};

cdate().handler(handler).text(`%Y年%-m月%-d月(%a)`);
// => '2023年1月4月(水)'
```

フォーマット出力のたびに `.handler()` を呼ぶのが面倒な場合は、`cdateFn()` を使います。

```js
const cdateJP = cdate().utcOffset(9).handler({
    "ddd": "%a",
    "LL": "%Y年%-m月%-d月(%a)",
    "%a": (dt) => ("日月火水木金土")[dt.getDay()],
}).cdateFn();

cdateJP().format(`YYYY年M月D月(ddd)`);
cdateJP().format(`LL`);
cdateJP().text(`%Y年%-m月%-d月(%a)`);
// => いずれも '2023年1月4月(水)'
```

上記のように strftime 形式の `%` で始まる `.text()` 用ハンドラは、他のハンドラから再帰的に呼び出すことができます。

## （事例４） 独自のフォーマット指定子

`.handler()` メソッドでは、独自のハンドラを追加定義できます。

```js
const cdateR = cdate().handler({
    "%EY": (dt) => `令和${dt.getFullYear() - 2018}年`,
}).cdateFn();

cdateR().text("%EY")
// => '令和5年'
```

実用的には、条件分岐して `平成` 対応を検討しても良いかも。

## （事例5） moment 互換性を高めたい

ミニマム方針のため、cdate に `.add()` メソッドはあるけど、`.subtract()` メソッドが存在しません。既存アプリの互換性のために同メソッドが欲しい場合は、プラグインの仕組みを利用して、独自に `.subtract()` メソッドを追加で生やすことができます。

```js
const cdateS = cdate().plugin(P => class extends P {
    subtract(diff, unit) {
        return this.add(-diff, unit);
    }
}).cdateFn();

cdateS("2023-01-01").subtract(1, "day").format("YYYY-MM-DD");
// => '2022-12-31'
```

上記 `class extends P` のコードは初見では読みにくいかもしれませんが、ES6 で親クラスを継承して `subtract` メソッドを生やした無名クラスを作っています。

（備考）cdate は、プラグイン・システムすらも immutable なので、上記の `subtract` メソッドは `cdateS()` 配下では使えるが、従来の　`cdate()` 配下では使えない点に注意してください。どんなプラグインを読み込んでも、グローバルには影響を与えません。

なお、上記の事例は、プラグイン・システムの説明用のサンプルです。実用のアプリでは、わざわざ `subtract` メソッドを生やすよりも、自分でマイナスの値を加算した方が読みやすいかもしれません：

```js
cdate("2023-01-01").add(-1, "day").format("YYYY-MM-DD");
// => '2022-12-31'
```

書きかけですが、[cdate-moment](https://github.com/kawanet/cdate-moment/blob/main/index.js) も参考になるかもしれません。これは、moment.js 互換メソッドをいくつか追加するプラグインです。（まだ私の必要がないために、特に npm ではリリースしていません）

## おわりに

日時を扱うアプリケーションを書くとき、「今日の深夜0時0分を返す関数」とか、「今月1日を返す関数」「今月の最終日を返す関数」は、何度も実装したりコピペしたかと思います。

また、日時文字列のパーサーの挙動はブラウザ依存なので、IE や iOS Safari で結果が異なり `Invalid Date` が発生することも。「事前に文字列を書き換えてから Date に投入するフィルタ関数」もよくありそう。

moment.js を使えば、よくテストされ、信頼できる実装を利用できます。
しかし、moment.js は開発が停止しています。タイムゾーン対応も重量級です。
moment.js よりもモダンで、immutable かつタイムゾーン対応しつつ軽量・高速な実装として、cdate が誕生しました。

cdate のテストコードは、既存ライブラリ moment.js、day.js および [strftime](https://www.npmjs.com/package/strftime) との互換性をチェックするテストが多くあります。基本的な機能は、moment.js や day.js と同様に利用できます。

既存アプリも移行しやすいようにインターフェースも寄せていますが、前述のように完全互換ではありません。まだ機能が足りないかもしれません。必要な機能は自前で追加してください。もし、「これは絶対に標準で必要だろう」というメソッドがあれば、Qiita のコメントか [github issue](https://github.com/kawanet/cdate/issues) でお知らせ下さい。