Intl.DateTimeFormatでロケールに応じて日付をフォーマットする

# ロケールごとの日付表記の違い

プログラムで取り扱う日付文字列は `2020-01-01` `2020/01/01` といった「ゼロパディング、ハイフン or スラッシュ区切り」などがありますが、ユーザーフレンドリーにするため以下のような形式で表記することがあるかと思います。

- 1月1日(水)
- Jan 1(Wed)

例えば[moment.js](https://momentjs.com/)を利用すると以下のようになります。

```javascript
moment.locale('ja');
moment('2020-01-01').format('M月D日(ddd)')
// > "1月1日(水)"

moment.locale('en');
moment('2020-01-01').format('MMM D(ddd)');
// > "Jan 1(Wed)"
```

自由な書式フォーマットの指定ができるので便利ですが、日付の計算や比較をするわけでもなく、ただフォーマットするだけであれば、標準APIである`Intl.DateTimeFormat`を使うという選択肢もあります。

# Intlとは

各言語に依存した日付や数値フォーマットを共通化するための国際化API（Internationalization API）です。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl

日付のフォーマットには`Intl.DateTimeFormat`を利用します。

# Intl.DateTimeFormat

## 基本的な使い方

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat

ロケールと各項目の書式を指定してフォーマットします。

```javascript
const options = {
  month: 'short',
  day: 'numeric',
  weekday: 'short',
};

new Intl.DateTimeFormat('ja-JP', options).format(new Date('2020-01-01'));
// > "1月1日(水)"

new Intl.DateTimeFormat('en-US', options).format(new Date('2020-01-01'));
// >> "Wed, Jan 1"
```

`moment.js`のように日付の表記順などの細かい指定はできませんが、ロケールごとの書式指定が不要になるという点ではメリットがあります。

## optionsの指定による表記一覧

何を指定するとどう表記されるか明示されているドキュメントが見当たらなかったので、一覧を出してみました。

例えば`ja-JP`の場合`month`は`long/short/narrow`ですべて同じ表記になりますが`en-US`の場合は表記が異なります。
指定方法によっては結局ロケールごとの書式指定が必要になってしまいます。

<details>
<summary>year, month, day, weekdayの表記一覧（Chrome）</summary><div>

options | ja-JP | en-US
--- | --- | ---
{ year: 'numeric' } | 2020年 | 2020
{ year: '2-digit' } | 20年 | 20
{ month: 'numeric' } | 1月 | 1
{ month: '2-digit' } | 01月 | 01
{ month: 'long' } | 1月 | January
{ month: 'short' } | 1月 | Jan
{ month: 'narrow' } | 1月 | J
{ day: 'numeric' } | 1日 | 1
{ day: '2-digit' } | 01日 | 01
{ weekday: 'long' } | 水曜日 | Wednesday
{ weekday: 'short' } | 水 | Wed
{ weekday: 'narrow' } | 水 | W
{ year: 'numeric', month: 'numeric' } | 2020/1 | 1/2020
{ year: 'numeric', month: '2-digit' } | 2020/01 | 01/2020
{ year: 'numeric', month: 'long' } | 2020年1月 | January 2020
{ year: 'numeric', month: 'short' } | 2020年1月 | Jan 2020
{ year: 'numeric', month: 'narrow' } | 2020年1月 | J 2020
{ year: '2-digit', month: 'numeric' } | 20/1 | 1/20
{ year: '2-digit', month: '2-digit' } | 20/01 | 01/20
{ year: '2-digit', month: 'long' } | 20年1月 | January 20
{ year: '2-digit', month: 'short' } | 20年1月 | Jan 20
{ year: '2-digit', month: 'narrow' } | 20年1月 | J 20
{ month: 'numeric', day: 'numeric' } | 1/1 | 1/1
{ month: 'numeric', day: '2-digit' } | 1/01 | 1/01
{ month: '2-digit', day: 'numeric' } | 01/1 | 01/1
{ month: '2-digit', day: '2-digit' } | 01/01 | 01/01
{ month: 'long', day: 'numeric' } | 1月1日 | January 1
{ month: 'long', day: '2-digit' } | 1月01日 | January 01
{ month: 'short', day: 'numeric' } | 1月1日 | Jan 1
{ month: 'short', day: '2-digit' } | 1月01日 | Jan 01
{ month: 'narrow', day: 'numeric' } | 1月1日 | J 1
{ month: 'narrow', day: '2-digit' } | 1月01日 | J 01
{ day: 'numeric', weekday: 'long' } | 1日水曜日 | 1 Wednesday
{ day: 'numeric', weekday: 'short' } | 1日(水) | 1 Wed
{ day: 'numeric', weekday: 'narrow' } | 1日(水) | 1 W
{ day: '2-digit', weekday: 'long' } | 01日水曜日 | 01 Wednesday
{ day: '2-digit', weekday: 'short' } | 01日(水) | 01 Wed
{ day: '2-digit', weekday: 'narrow' } | 01日(水) | 01 W
{ year: 'numeric', month: 'numeric', day: 'numeric' } | 2020/1/1 | 1/1/2020
{ year: 'numeric', month: 'numeric', day: '2-digit' } | 2020/1/01 | 1/01/2020
{ year: 'numeric', month: '2-digit', day: 'numeric' } | 2020/01/1 | 01/1/2020
{ year: 'numeric', month: '2-digit', day: '2-digit' } | 2020/01/01 | 01/01/2020
{ year: 'numeric', month: 'long', day: 'numeric' } | 2020年1月1日 | January 1, 2020
{ year: 'numeric', month: 'long', day: '2-digit' } | 2020年1月01日 | January 01, 2020
{ year: 'numeric', month: 'short', day: 'numeric' } | 2020年1月1日 | Jan 1, 2020
{ year: 'numeric', month: 'short', day: '2-digit' } | 2020年1月01日 | Jan 01, 2020
{ year: 'numeric', month: 'narrow', day: 'numeric' } | 2020年1月1日 | J 1, 2020
{ year: 'numeric', month: 'narrow', day: '2-digit' } | 2020年1月01日 | J 01, 2020
{ year: '2-digit', month: 'numeric', day: 'numeric' } | 20/1/1 | 1/1/20
{ year: '2-digit', month: 'numeric', day: '2-digit' } | 20/1/01 | 1/01/20
{ year: '2-digit', month: '2-digit', day: 'numeric' } | 20/01/1 | 01/1/20
{ year: '2-digit', month: '2-digit', day: '2-digit' } | 20/01/01 | 01/01/20
{ year: '2-digit', month: 'long', day: 'numeric' } | 20年1月1日 | January 1, 20
{ year: '2-digit', month: 'long', day: '2-digit' } | 20年1月01日 | January 01, 20
{ year: '2-digit', month: 'short', day: 'numeric' } | 20年1月1日 | Jan 1, 20
{ year: '2-digit', month: 'short', day: '2-digit' } | 20年1月01日 | Jan 01, 20
{ year: '2-digit', month: 'narrow', day: 'numeric' } | 20年1月1日 | J 1, 20
{ year: '2-digit', month: 'narrow', day: '2-digit' } | 20年1月01日 | J 01, 20
{ month: 'numeric', day: 'numeric', weekday: 'long' } | 1/1水曜日 | Wednesday, 1/1
{ month: 'numeric', day: 'numeric', weekday: 'short' } | 1/1(水) | Wed, 1/1
{ month: 'numeric', day: 'numeric', weekday: 'narrow' } | 1/1(水) | W, 1/1
{ month: 'numeric', day: '2-digit', weekday: 'long' } | 1/01水曜日 | Wednesday, 1/01
{ month: 'numeric', day: '2-digit', weekday: 'short' } | 1/01(水) | Wed, 1/01
{ month: 'numeric', day: '2-digit', weekday: 'narrow' } | 1/01(水) | W, 1/01
{ month: '2-digit', day: 'numeric', weekday: 'long' } | 01/1水曜日 | Wednesday, 01/1
{ month: '2-digit', day: 'numeric', weekday: 'short' } | 01/1(水) | Wed, 01/1
{ month: '2-digit', day: 'numeric', weekday: 'narrow' } | 01/1(水) | W, 01/1
{ month: '2-digit', day: '2-digit', weekday: 'long' } | 01/01水曜日 | Wednesday, 01/01
{ month: '2-digit', day: '2-digit', weekday: 'short' } | 01/01(水) | Wed, 01/01
{ month: '2-digit', day: '2-digit', weekday: 'narrow' } | 01/01(水) | W, 01/01
{ month: 'long', day: 'numeric', weekday: 'long' } | 1月1日水曜日 | Wednesday, January 1
{ month: 'long', day: 'numeric', weekday: 'short' } | 1月1日(水) | Wed, January 1
{ month: 'long', day: 'numeric', weekday: 'narrow' } | 1月1日(水) | W, January 1
{ month: 'long', day: '2-digit', weekday: 'long' } | 1月01日水曜日 | Wednesday, January 01
{ month: 'long', day: '2-digit', weekday: 'short' } | 1月01日(水) | Wed, January 01
{ month: 'long', day: '2-digit', weekday: 'narrow' } | 1月01日(水) | W, January 01
{ month: 'short', day: 'numeric', weekday: 'long' } | 1月1日水曜日 | Wednesday, Jan 1
{ month: 'short', day: 'numeric', weekday: 'short' } | 1月1日(水) | Wed, Jan 1
{ month: 'short', day: 'numeric', weekday: 'narrow' } | 1月1日(水) | W, Jan 1
{ month: 'short', day: '2-digit', weekday: 'long' } | 1月01日水曜日 | Wednesday, Jan 01
{ month: 'short', day: '2-digit', weekday: 'short' } | 1月01日(水) | Wed, Jan 01
{ month: 'short', day: '2-digit', weekday: 'narrow' } | 1月01日(水) | W, Jan 01
{ month: 'narrow', day: 'numeric', weekday: 'long' } | 1月1日水曜日 | Wednesday, J 1
{ month: 'narrow', day: 'numeric', weekday: 'short' } | 1月1日(水) | Wed, J 1
{ month: 'narrow', day: 'numeric', weekday: 'narrow' } | 1月1日(水) | W, J 1
{ month: 'narrow', day: '2-digit', weekday: 'long' } | 1月01日水曜日 | Wednesday, J 01
{ month: 'narrow', day: '2-digit', weekday: 'short' } | 1月01日(水) | Wed, J 01
{ month: 'narrow', day: '2-digit', weekday: 'narrow' } | 1月01日(水) | W, J 01
{ year: 'numeric', month: 'numeric', day: 'numeric', weekday: 'long' } | 2020/1/1水曜日 | Wednesday, 1/1/2020
{ year: 'numeric', month: 'numeric', day: 'numeric', weekday: 'short' } | 2020/1/1(水) | Wed, 1/1/2020
{ year: 'numeric', month: 'numeric', day: 'numeric', weekday: 'narrow' } | 2020/1/1(水) | W, 1/1/2020
{ year: 'numeric', month: 'numeric', day: '2-digit', weekday: 'long' } | 2020/1/01水曜日 | Wednesday, 1/01/2020
{ year: 'numeric', month: 'numeric', day: '2-digit', weekday: 'short' } | 2020/1/01(水) | Wed, 1/01/2020
{ year: 'numeric', month: 'numeric', day: '2-digit', weekday: 'narrow' } | 2020/1/01(水) | W, 1/01/2020
{ year: 'numeric', month: '2-digit', day: 'numeric', weekday: 'long' } | 2020/01/1水曜日 | Wednesday, 01/1/2020
{ year: 'numeric', month: '2-digit', day: 'numeric', weekday: 'short' } | 2020/01/1(水) | Wed, 01/1/2020
{ year: 'numeric', month: '2-digit', day: 'numeric', weekday: 'narrow' } | 2020/01/1(水) | W, 01/1/2020
{ year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long' } | 2020/01/01水曜日 | Wednesday, 01/01/2020
{ year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' } | 2020/01/01(水) | Wed, 01/01/2020
{ year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'narrow' } | 2020/01/01(水) | W, 01/01/2020
{ year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' } | 2020年1月1日水曜日 | Wednesday, January 1, 2020
{ year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' } | 2020年1月1日(水) | Wed, January 1, 2020
{ year: 'numeric', month: 'long', day: 'numeric', weekday: 'narrow' } | 2020年1月1日(水) | W, January 1, 2020
{ year: 'numeric', month: 'long', day: '2-digit', weekday: 'long' } | 2020年1月01日水曜日 | Wednesday, January 01, 2020
{ year: 'numeric', month: 'long', day: '2-digit', weekday: 'short' } | 2020年1月01日(水) | Wed, January 01, 2020
{ year: 'numeric', month: 'long', day: '2-digit', weekday: 'narrow' } | 2020年1月01日(水) | W, January 01, 2020
{ year: 'numeric', month: 'short', day: 'numeric', weekday: 'long' } | 2020年1月1日水曜日 | Wednesday, Jan 1, 2020
{ year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' } | 2020年1月1日(水) | Wed, Jan 1, 2020
{ year: 'numeric', month: 'short', day: 'numeric', weekday: 'narrow' } | 2020年1月1日(水) | W, Jan 1, 2020
{ year: 'numeric', month: 'short', day: '2-digit', weekday: 'long' } | 2020年1月01日水曜日 | Wednesday, Jan 01, 2020
{ year: 'numeric', month: 'short', day: '2-digit', weekday: 'short' } | 2020年1月01日(水) | Wed, Jan 01, 2020
{ year: 'numeric', month: 'short', day: '2-digit', weekday: 'narrow' } | 2020年1月01日(水) | W, Jan 01, 2020
{ year: 'numeric', month: 'narrow', day: 'numeric', weekday: 'long' } | 2020年1月1日水曜日 | Wednesday, J 1, 2020
{ year: 'numeric', month: 'narrow', day: 'numeric', weekday: 'short' } | 2020年1月1日(水) | Wed, J 1, 2020
{ year: 'numeric', month: 'narrow', day: 'numeric', weekday: 'narrow' } | 2020年1月1日(水) | W, J 1, 2020
{ year: 'numeric', month: 'narrow', day: '2-digit', weekday: 'long' } | 2020年1月01日水曜日 | Wednesday, J 01, 2020
{ year: 'numeric', month: 'narrow', day: '2-digit', weekday: 'short' } | 2020年1月01日(水) | Wed, J 01, 2020
{ year: 'numeric', month: 'narrow', day: '2-digit', weekday: 'narrow' } | 2020年1月01日(水) | W, J 01, 2020
{ year: '2-digit', month: 'numeric', day: 'numeric', weekday: 'long' } | 20/1/1水曜日 | Wednesday, 1/1/20
{ year: '2-digit', month: 'numeric', day: 'numeric', weekday: 'short' } | 20/1/1(水) | Wed, 1/1/20
{ year: '2-digit', month: 'numeric', day: 'numeric', weekday: 'narrow' } | 20/1/1(水) | W, 1/1/20
{ year: '2-digit', month: 'numeric', day: '2-digit', weekday: 'long' } | 20/1/01水曜日 | Wednesday, 1/01/20
{ year: '2-digit', month: 'numeric', day: '2-digit', weekday: 'short' } | 20/1/01(水) | Wed, 1/01/20
{ year: '2-digit', month: 'numeric', day: '2-digit', weekday: 'narrow' } | 20/1/01(水) | W, 1/01/20
{ year: '2-digit', month: '2-digit', day: 'numeric', weekday: 'long' } | 20/01/1水曜日 | Wednesday, 01/1/20
{ year: '2-digit', month: '2-digit', day: 'numeric', weekday: 'short' } | 20/01/1(水) | Wed, 01/1/20
{ year: '2-digit', month: '2-digit', day: 'numeric', weekday: 'narrow' } | 20/01/1(水) | W, 01/1/20
{ year: '2-digit', month: '2-digit', day: '2-digit', weekday: 'long' } | 20/01/01水曜日 | Wednesday, 01/01/20
{ year: '2-digit', month: '2-digit', day: '2-digit', weekday: 'short' } | 20/01/01(水) | Wed, 01/01/20
{ year: '2-digit', month: '2-digit', day: '2-digit', weekday: 'narrow' } | 20/01/01(水) | W, 01/01/20
{ year: '2-digit', month: 'long', day: 'numeric', weekday: 'long' } | 20年1月1日水曜日 | Wednesday, January 1, 20
{ year: '2-digit', month: 'long', day: 'numeric', weekday: 'short' } | 20年1月1日(水) | Wed, January 1, 20
{ year: '2-digit', month: 'long', day: 'numeric', weekday: 'narrow' } | 20年1月1日(水) | W, January 1, 20
{ year: '2-digit', month: 'long', day: '2-digit', weekday: 'long' } | 20年1月01日水曜日 | Wednesday, January 01, 20
{ year: '2-digit', month: 'long', day: '2-digit', weekday: 'short' } | 20年1月01日(水) | Wed, January 01, 20
{ year: '2-digit', month: 'long', day: '2-digit', weekday: 'narrow' } | 20年1月01日(水) | W, January 01, 20
{ year: '2-digit', month: 'short', day: 'numeric', weekday: 'long' } | 20年1月1日水曜日 | Wednesday, Jan 1, 20
{ year: '2-digit', month: 'short', day: 'numeric', weekday: 'short' } | 20年1月1日(水) | Wed, Jan 1, 20
{ year: '2-digit', month: 'short', day: 'numeric', weekday: 'narrow' } | 20年1月1日(水) | W, Jan 1, 20
{ year: '2-digit', month: 'short', day: '2-digit', weekday: 'long' } | 20年1月01日水曜日 | Wednesday, Jan 01, 20
{ year: '2-digit', month: 'short', day: '2-digit', weekday: 'short' } | 20年1月01日(水) | Wed, Jan 01, 20
{ year: '2-digit', month: 'short', day: '2-digit', weekday: 'narrow' } | 20年1月01日(水) | W, Jan 01, 20
{ year: '2-digit', month: 'narrow', day: 'numeric', weekday: 'long' } | 20年1月1日水曜日 | Wednesday, J 1, 20
{ year: '2-digit', month: 'narrow', day: 'numeric', weekday: 'short' } | 20年1月1日(水) | Wed, J 1, 20
{ year: '2-digit', month: 'narrow', day: 'numeric', weekday: 'narrow' } | 20年1月1日(水) | W, J 1, 20
{ year: '2-digit', month: 'narrow', day: '2-digit', weekday: 'long' } | 20年1月01日水曜日 | Wednesday, J 01, 20
{ year: '2-digit', month: 'narrow', day: '2-digit', weekday: 'short' } | 20年1月01日(水) | Wed, J 01, 20
{ year: '2-digit', month: 'narrow', day: '2-digit', weekday: 'narrow' } | 20年1月01日(水) | W, J 01, 20

</div></details>

## ブラウザによる差異

各ブラウザごとの実装に差異があり、指定したoptionによっては表記が異なる場合があります。

```javascript
const options = {
  month: 'long',
  day: 'numeric',
  weekday: 'long',
};

new Intl.DateTimeFormat('ja-JP', options).format(new Date('2020-01-01'));
// Chrome, Firefox > "1月1日水曜日"
// Safari > "1月1日 水曜日" 
```

# まとめ

自由な書式を定義したい場合は`moment.js`などのライブラリを利用した方が良いかもしれませんが、標準的な書式で十分なのであれば`Intl.DateTimeFormat`を利用するのもありかと思います。

書式の細かい指定ができないため出力してみないと分からない、ブラウザにより表記が異なるケースがある、という点には注意が必要です。