JavaScript Dateオブジェクトに書式指定メソッドを追加

[以前作成](https://qiita.com/akebi_mh/items/b9e62d66d70555f37b43)したdate()関数をDateオブジェクト用のメソッドとして書き直したものです。
PHPのDateTime/DateTimeImmutableクラスの[formatメソッド](https://www.php.net/manual/ja/datetime.format.php)に近い使い方がしたかったのでメソッド名も``format()``で合わせました。

formatパラメータ文字列は[前回](https://qiita.com/akebi_mh/items/b9e62d66d70555f37b43)と同様、基本的にPHPのものに合わせていますが、**e** タイムゾーン識別子、**T** タイムゾーンの略称、**I** サマータイム等、未対応のものもあります。

###script###

```js:date_format.js
'use strict';
Date.prototype.format = function(format, offset) {
    if(isNaN(this)) return this;
    if(typeof format !== 'string') return '';
    if(offset === undefined) {
        offset = this.getTimezoneOffset();
    }
    else {
        const r = offset;
        if(typeof offset === 'string') {
            const _ = offset.match(/^Z|([+-])(\d\d):(\d\d)$/i);
            if(_ !== null) {
                if(_[0].toUpperCase() === 'Z') {
                    offset = 0;
                }
                else {
                    if(_[2] > 23) _[2] = 23;
                    if(_[3] > 59) _[3] = 59;
                    offset = (+_[2] * 60 + +_[3]) * (_[1] === '-' ? -1 : 1);
                }
            }
        }
        offset = -parseInt(offset, 10);
        if(isNaN(offset)) {
            throw new Error('Invalid value "' + r + '" for time zone offset argument.');
        }
        offset = offset > 1439 ? 1439 : offset < -1439 ? -1439 : offset;
    }

    const time =
        Math.abs(this.getTime() - Date.now()) < 100 &&
        typeof performance !== 'undefined' &&
        performance.timeOrigin !== undefined ?
        performance.timeOrigin + performance.now() : this.getTime();

    const date = new Date(time);
    date.setMinutes(date.getMinutes() - offset);

    const p = date.toISOString().match(/\d+/g);
    const d = {
        year       : p[0],
        month      : p[1],
        day        : p[2],
        hour       : p[3],
        minute     : p[4],
        second     : p[5],
        millisecond: p[6],
    };

    const
        spellingMonth = [
            'January', 'February', 'March', 'April', 'May',
            'June', 'July', 'August', 'September', 'October',
            'November', 'December'][d.month - 1],
        spellingWeek = [
            'Sunday', 'Monday', 'Tuesday', 'Wednesday',
            'Thursday', 'Friday', 'Saturday'][date.getUTCDay()],
        adj = +this - offset * 6e4,
        sDate = new Date(Math.floor((adj + 2592e5) / 6048e5) * 6048e5),
        sYear = sDate.getUTCFullYear(),
        tzOffset = offset < 0 ? (1440 - offset) % 1440 : offset,
        offsetTime = '+-'[offset > 0 ? 1 : 0] + 
            ('0' + Math.floor(tzOffset / 60)).slice(-2) + ':' +
            ('0' + (tzOffset % 60)).slice(-2),
        ap = d.hour < 12 ? 'am' : 'pm',
        sTime = this.getTime() < 0 ?
            this.getTime() % 864e5 + 864e5 : this.getTime();

    const formatTable = {
        // 年 4桁
        Y: d.year,
        // 月 2桁
        m: d.month,
        // 日 2桁
        d: d.day,
        // 時 24時間単位 2桁
        H: d.hour,
        // 分 2桁
        i: d.minute,
        // 秒 2桁
        s: d.second,

        // 年 2桁
        y: d.year.slice(-2),
        // 月 0埋め無し
        n: +d.month,
        // 日 0埋め無し
        j: +d.day,
        // 時 24時間単位 0埋め無し
        G: +d.hour,
        // 時 12時間単位 2桁
        h: ('0' + ((+d.hour + 11) % 12 + 1)).slice(-2),
        // 時 12時間単位 0埋め無し
        g: (+d.hour + 11) % 12 + 1,
        // 閏年か 0:閏年ではない 1:閏年
        L: new Date(d.year, 1, 29).getDate() !== 29 ? 0 : 1,
        // 日に対する英語形式序数サフィックス
        S: ['th', 'st', 'nd', 'rd']
                [Math.floor(d.day / 10) % 10 !== 1 && d.day % 10 < 4 ? d.day % 10 : 0],
        // 曜日 0:日～6:土
        w: date.getUTCDay(),
        // ISO8601 曜日 1:月～7:日
        N: (date.getUTCDay() + 6) % 7 + 1,
        // 年間通算日 0～365
        z: Math.floor(date.getTime() / 864e5 -
                new Date(d.year + '-01-01T00:00:00Z').getTime() / 864e5),
        // 月の日数
        t: new Date(d.year, d.month, 0).getDate(),
        // ISO8601 週番号による年
        o: sYear,
        // ISO8601 月曜日に始まる年単位の週番号
        W: ('0' + (1 + Math.floor((
                sDate - new Date(sYear + '-01-01T00:00Z')) / 6048e5))).slice(-2),
        // 曜日 3文字
        D: spellingWeek.slice(0, 3),
        // 月 3文字
        M: spellingMonth.slice(0, 3),
        // 曜日 フルスペル
        l: spellingWeek,
        // 月 フルスペル
        F: spellingMonth,

        // am/pm
        a: ap,
        // AM/PM
        A: ap.toUpperCase(),

        // GMTとの時差 ±HH:MM
        P: offsetTime,
        // GMTとの時差 Pと同じ 但し+00:00はZ
        p: offsetTime !== '+00:00' ? offsetTime : 'Z',
        // GMTとの時差 ±HHMM
        O: offsetTime.slice(0, 3) + offsetTime.slice(-2),
        // タイムゾーンオフセット秒数
        Z: -offset * 60,

        // UNIXエポックからの秒数
        U: Math.floor(time / 1000),
        // ミリ秒
        v: d.millisecond,
        // マイクロ秒
        u: ('00000' + Math.floor((time % 1000) * 1000)).slice(-6),
        // Swatchインターネットタイム
        B: ('00' + Math.floor((sTime + 36e5) % 864e5 / 86400)).slice(-3),
    };

    const re = RegExp('[' + Object.keys(formatTable).join('') + ']', 'g');
    return String(format)
        .replace(/\\(\w)/g, function(_, m) { return '&#' + m.charCodeAt() + ';';})
        // RFC2822フォーマットされた日付
        .replace(/r/g, 'D, d M Y H:i:s O')
        // ISO8601 日付
        .replace(/c/g, 'Y-m-d&#84;H:i:sP')
        .replace(re, function(m) { return formatTable[m];})
        .replace(/&#(\d+);/g, function(_,m){ return String.fromCharCode(m);});
}
```

formatパラメータ文字テーブルが大きめですが、不要なものは削ってしまっても大丈夫です。

###書式###

``dateオブジェクト.format(フォーマット文字列 [, 時差])``

####引数####

**フォーマット文字列**
PHPと同様の[format文字](https://www.php.net/manual/ja/datetime.format.php#refsect1-datetime.format-parameters)が使用できます。
(一部未対応の文字あり)

**時差**(オプション)
分単位で時差を指定できます。
指定できる範囲は **-1439** ～ **1439**です。
(-23:59 ～ +23:59に相当します)
指定しない場合は実行環境のOSの時差設定に従います。

###使用法###

```html:
<script src='date_format.js'></script>
<script>
const date = new Date();
console.log(date.format('Y-m-d H:i:s')); // 2021-04-27 17:54:53
</script>
```

###sample###

```html:
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<script src='date_format.js'></script>
</head>
<body>
<div id='v'></div>
<script>
d = document.getElementById('v');
function test() {
    d.innerText = new Date().format('Y-m-d H:i:s.u\nr\nc\nU\nB');
    requestAnimationFrame(test);
}
test();
</script>
</body>
</html>
```

[**動作デモ**](https://akebi.jp/temp/date_format/)

###sample2###

```js:
const date = new Date('2021/06/07 01:23:45');
console.log(date.format('r / c'));
console.log(date.format('Y-m-d H:i:s'));
console.log(date.format('y-n-j G:i:s h g'));
console.log(date.format('D M l F a A Z U P p'));
console.log(date.format('曜日:w 曜日(1～7):N 年間通算日(0～365):z 月の日数:t'));
console.log(date.format('閏年か:L 日に対するサフィックス:S'));
console.log(date.format('\\i\\s\\o8901年/週 o-W'));
console.log(date.format('\\S\\w\\a\\t\\c\\hインターネットタイム:B'));
console.log(date.format('ミリ秒:v マイクロ秒:u'));
console.log(new Date('2000-01-01T00:00:00.123456+09:00').format('ミリ秒:v マイクロ秒:u'));
console.log(new Date().format('ミリ秒:v マイクロ秒:u'));

// Mon, 07 Jun 2021 01:23:45 +0900 / 2021-06-07T01:23:45+09:00
// 2021-06-07 01:23:45
// 21-6-7 1:23:45 01 1
// Mon Jun Monday June am AM 32400 1622996625 +09:00 +09:00
// 曜日:1 曜日(1～7):1 年間通算日(0～365):157 月の日数:30
// 閏年か:0 日に対するサフィックス:th
// iso8901年/週 2021-23
// Swatchインターネットタイム:724
// ミリ秒:000 マイクロ秒:000000
// ミリ秒:123 マイクロ秒:123000
// ミリ秒:275 マイクロ秒:275893
```

マイクロ秒の下3桁はDateオブジェクトが現在日時以外の場合は000になります。また、現在日時であっても環境によってはマイクロ秒まで取得できず000になる場合があります。

###sample3###

時差設定の使用例

```js:
const date = new Date('2021-05-01T00:00:00Z');
console.log(date.toString());
    // Sat May 01 2021 09:00:00 GMT+0900 (日本標準時)

console.log(date.format('c / r', -390)); // -06:30
    // 2021-04-30T17:30:00-06:30 / Fri, 30 Apr 2021 17:30:00 -0630
console.log(date.format('c / r', -60));  // -01:00
    // 2021-04-30T23:00:00-01:00 / Fri, 30 Apr 2021 23:00:00 -0100
console.log(date.format('c / r', 540));  // +09:00 (日本時間と同等)
    // 2021-05-01T09:00:00+09:00 / Sat, 01 May 2021 09:00:00 +0900
console.log(date.format('c / r', 0));    // +00:00 (UTCと同等)
    // 2021-05-01T00:00:00+00:00 / Sat, 01 May 2021 00:00:00 +0000
console.log(date.toISOString());
    // 2021-05-01T00:00:00.000Z
```

時差を設定をしても、表示上時差による影響を受ける値が変化するだけで、UNIXタイム等の時差に関係ない情報は変化しません。

**現在日時を表示させつつ一方の時差設定を任意に変更するサンプル**

```html:
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<script src='date_format.js'></script>
<style>
#offset {
    width: 300px;
}
</style>
</head>
<body>
<div id='v'></div>
<hr>
<div id='v2'></div>
<input type='range' id='offset' min='-1439' max='1439' value='540'>
<br>
<select id='s'>
    <option value='-600'>-10:00(ハワイ・アリューシャン標準時)</option>
    <option value='-360'>-06:00(アメリカ中部標準時)</option>
    <option value='-300'>-05:00(アメリカ東部標準時)</option>
    <option value='-240'>-04:00(チリ標準時)</option>
    <option value='-180'>-03:00(アルゼンチン標準時)</option>
    <option value='0'>+00:00(UTC/グリニッジ標準時)</option>
    <option value='60'>+01:00(西アフリカ標準時)</option>
    <option value='120'>+02:00(南アフリカ標準時)</option>
    <option value='180'>+03:00(モスクワ標準時)</option>
    <option value='330'>+05:30(インド標準時)</option>
    <option value='480'>+08:00(シンガポール標準時)</option>
    <option value='525'>+08:45(オーストラリア中西部標準時)</option>
    <option value='540' selected>+09:00(日本標準時)</option>
    <option value='570'>+09:30(オーストラリア中部標準時)</option>
    <option value='840'>+14:00(ライン諸島)</option>
</select>
<script>
const d = document.getElementById('v');
const d2 = document.getElementById('v2');

const slider = document.getElementById('offset');
const select = document.getElementById('s');
let offset = slider.value;

slider.addEventListener('input', function(){
    offset = this.value;
    select.value = offset;
});
select.addEventListener('change', function(){
    offset = slider.value = this.value;
});

function test() {
    const date = new Date();
    d.innerText =
        date.toString() + '\n' +
        date.toISOString() + '\n\n' +
        date.format('Y-m-d H:i:s.u\nr\nc\nU');

    d2.innerText =
        date.format('Y-m-d H:i:s.u\nr\nc\nU', offset);
    requestAnimationFrame(test);
}
test();
</script>
```

[**動作デモ**](https://akebi.jp/temp/date_format/offset_test.html)


----