JavaScriptで日付をフォーマットする（和暦対応）

# はじめに

JavaScriptで日付を操作するライブラリとして「[Moment.js](http://momentjs.com/)」や「[XDate](http://arshaw.com/xdate/)」等ありますが、和暦の取得や祝祭日の取得等には別のロジックが必要となることが不満で「[UltraDate.js](https://github.com/hrdaya/UltraDate.js)」というものを作ってみました。

Date.prototypeを汚さない形で日付の操作を行いたかったので

* ライブラリの中にDateオブジェクトを保持
* Date.prototypeには無い関数を作成
* Date.prototypeの関数をapplyで呼び出す


といった形で作成しています。内部でDate.prototypeの関数をapplyで呼び出していますので、単純に「new Date()」としている所を「new UltraDate()」と置き換えるだけで現在のプロジェクトにも適用できるようになっています。

その中からいくつかDateオブジェクトのみでも使用できるように関数を切り出して、簡易版として作成したものを説明していきたいと思います。

※「[UltraDate.js](https://github.com/hrdaya/UltraDate.js)」の中のコードとは一部異なる部分があります。

# Javascriptで日付をフォーマットする（和暦対応）

「[UltraDate.js](https://github.com/hrdaya/UltraDate.js)」と「[UltraDate.js](https://github.com/hrdaya/UltraDate.js)」に日本の祝祭日、和暦の設定をする「UltraDate.ja.js」を参考に和暦に対応した日付をフォーマットする関数を作成してみます。

## フォーマットの書式について

* 「"」（ダブルクォート）で囲まれた範囲内は変換対象外とします。
* 「"」（ダブルクォート）のエスケープは「""」（連続したダブルクォート）とします。
* 元号の計算は厳密には行わないものとします。（元年が含まれる年はすべて元年の元号）
* フォーマットに用いる書式は下記の表のとおりとします。

|文字列|変換内容|
|:-----------|:-----------|
|yyyy|4桁の西暦[パディングあり]（0000～9999）|
|yy|2桁の西暦[パディングあり]（00～99）単純に右から2文字の切り出し|
|ee|和暦[パディングあり]（00～99）2桁以上になる場合はそのまま表示|
|e|和暦[パディングなし]|
|ggg|長い形式の元号（「平成」等）|
|gg|短い形式の元号（「平」等）|
|g|長い形式の元号（「H」等）|
|MMM|「睦月」等の日本の月名|
|MM|月[パディングあり]（01～12）|
|M|月[パディングなし]（1～12）|
|dd|日[パディングあり]（01～31）|
|d|日[パディングなし]（1～31）|
|HH|24時間表記の時間[パディングあり]（00～23）|
|H|24時間表記の時間[パディングなし]（0～23）|
|hh|12時間表記の時間[パディングあり]（00～12）|
|h|12時間表記の時間[パディングなし]（0～12）|
|mm|分[パディングあり]（00～59）|
|m|分[パディングなし]（0～59）|
|ss|秒[パディングあり]（00～59）|
|s|秒[パディングなし]（0～59）|
|fff|ミリ秒[パディングあり]（000～999）|
|f|ミリ秒[パディングなし]（0～999）|
|TT|午前・午後|
|tt|AM・PM|
|DDD|長い形式の曜日（「日曜日」等）|
|DD|短い形式の曜日（「日」等）|

## dateFormatJp関数

まずはじめにフォーマットの書式に基づき文字列を変換する「dateFormatJp」関数を定義します。

```js
/**
 * フォーマットの書式に基づき文字列を変換する関数
 *
 * @param {Date} date フォーマットに使用するDateオブジェクト
 * @param {String} format フォーマットする文字列
 * @return {String} 書式に基づき変換した文字列
 */
var dateFormatJp = function(date, format) {
    "use strict";
    // 引数dateが日付オブジェクトでない場合は例外をスロー
    if (isNaN(date.getTime()) ||
        Object.prototype.toString.call(date) !== "[object Date]") {
        throw new Error("引数「date」を認識できません");
    }

    // 引数formatが文字列でない場合は例外をスロー
    if (typeof format !== "string") {
        throw new Error("引数「format」が文字列ではありません");
    }

    // ここにこれから説明するソースが入る

    // 変換した文字列を返す
    return format;
};
```

### 和暦取得用のオブジェクトを作成する

日本用の変換オブジェクトを作成します

```js
var jp = {
    // MMMで使用する日本の月名の配列
    month: ["睦月", "如月", "弥生", "卯月", "皐月", "水無月",
        "文月", "葉月", "長月", "神無月", "霜月", "師走"],

    // DDD、DDで使用する曜日に使用する配列
    day: ["日", "月", "火", "水", "木", "金", "土"],

    // TTで使用する配列
    noonJp: ["午前", "午後"],

    // ttで使用する配列
    noonEn: ["AM", "PM"],

    /**
     * ggg、gg、g、ee、eで使用する和暦と元号を取得する関数
     *
     * @param {Date} date 和暦を取得するためのDateオブジェクト
     * @return {Object} 和暦用のデータを格納したオブジェクト
     */
    era: function(date) {
        var year = date.getFullYear();
        // 戻り値の初期値
        var ret = {
            longName: "西暦",
            shortName: "西",
            alphaName: "AD",
            year: year
        };

        // 明治以降の和暦を取得する
        if (year > 2018) {
           // 2019年以降は令和
           ret = {
                longName: "令和",
                shortName: "令",
                alphaName: "R",
                year: year - 2018
            };
        } else if (year > 1988) {
           // 1989年以降は平成
           ret = {
                longName: "平成",
                shortName: "平",
                alphaName: "H",
                year: year - 1988
            };
        } else if (year > 1925) {
            // 1926年以降は昭和
            ret = {
                longName: "昭和",
                shortName: "昭",
                alphaName: "S",
                year: year - 1925
            };
        } else if (year > 1911) {
            // 1912年以降は大正
            ret = {
                longName: "大正",
                shortName: "大",
                alphaName: "T",
                year: year - 1911
            };
        } else if (year > 1867) {
            // 1868年以降は明治
            ret = {
                longName: "明治",
                shortName: "明",
                alphaName: "M",
                year: year - 1867
            };
        }
        return ret;
    }
};
```

### ヘルパー関数

0埋めに使用する関数を設定します

```js
var func = {
    /**
     * 任意の桁数に切り取った文字列を返す（桁数に足りない場合は0埋め）
     *
     * @param {Number} val 0埋めする数字
     * @param {Number} num 桁数
     * @return {String} 0埋めした文字列
     */
    padSlice: function(val, num) {
        // 桁数が指定されていない時は2桁
        if (num === undefined) {
            num = 2;
        }

        // 要素数numの配列を区切り文字"0"で結合しvalを結合
        // 出来た文字列の右側からnum文字を切り出す
        return (new Array(num).join("0") + val).slice(num * -1);
    },

    /**
     * 任意の桁数に0埋めした文字列を返す（桁数を超える場合はそのまま）
     *
     * @param {Number} val 0埋めする数字
     * @param {Number} num 桁数
     * @return {String} 0埋めした文字列
     */
    padLoop: function(val, num) {
        // 桁数が指定されていない時は2桁
        if (num === undefined) {
            num = 2;
        }

        // valを文字列に変換
        val = val.toString();

        // 桁数に達するまで左に0を足す
        while (val.length < num) {
            val = "0" + val;
        }

        return val;
    }
};
```

### 午前・午後の取得
```js
// 午前を0、午後を1として値を取得します
var noon = date.getHours() < 12 ? 0 : 1;
```

### 和暦の元号部分の取得
```js
// 和暦の元号等のオブジェクトを取得します
var era = jp.era(date);
```

### フォーマット用の設定

フォーマットの設定用オブジェクトを作成します。
変換する文字列をキーとし、変換に使用する関数等を値とします。

```js
/**
 * キー：変換する文字列
 * 値：変換に使用する関数等
 */
var formatting = {
    yyyy: func.padSlice(date.getFullYear(), 4),
    yy: func.padSlice(date.getFullYear()),
    ee: func.padLoop(era.year),
    e: era.year,
    ggg: era.longName,
    gg: era.shortName,
    g: era.alphaName,
    MMM: jp.month[date.getMonth()],
    MM: func.padSlice(date.getMonth() + 1),
    M: date.getMonth() + 1,
    dd: func.padSlice(date.getDate()),
    d: date.getDate(),
    HH: func.padSlice(date.getHours()),
    H: date.getHours(),
    hh: func.padSlice((date.getHours() - 12 * noon)),
    h: date.getHours() - 12 * noon,
    mm: func.padSlice(date.getMinutes()),
    m: date.getMinutes(),
    ss: func.padSlice(date.getSeconds()),
    s: date.getSeconds(),
    fff: func.padSlice(date.getMilliseconds(), 3),
    f: date.getMilliseconds(),
    TT: jp.noonJp[noon],
    tt: jp.noonEn[noon],
    DDD: jp.day[date.getDay()] + "曜日",
    DD: jp.day[date.getDay()]
};
```

### フォーマットの実行

```js
// 「""」連続したダブルクォートを一旦エスケープしておくための文字列
// こんな文字列指定しないだろうという文字列
var esc = "_____-----_____-----_____-----_____-----_____-----_____";

// 「""」連続したダブルクォートのエスケープ
format = format.replace(/("")/g, esc);

// 「"」ダブルクォートで文字列を分割
var split = format.split('"');

// 変換に使用する正規表現文字列の作成
// 後で文字列を連結するために一旦フォーマット設定のキーを配列に収める
var regs = [];
for (var key in formatting) {
    regs.push(key);
}

// キャプチャする括弧内に「または」で連結
regs = "(" + regs.join("|") + ")";

// 正規表現オブジェクトの作成
var reg = new RegExp(regs, "g");

// ダブルクォートで囲まれた範囲は変換しないので、
// 分割してできた配列の0から2おきに変換を実施する
for (var i = 0, len = split.length; i < len; i += 2) {
    split[i] = split[i].replace(reg, function (match) {
        return formatting[match];
    });
}

// 変換し終わった配列を区切り文字なしで結合する
format = split.join("");

// 最初にエスケープしておいた「""」連続したダブルクォートの文字列を
// 「"」ダブルクォートの文字列として変換する
format = format.replace(new RegExp("(" + esc + ")", "g"), '"');
```

## まとめ

以上をまとめたソースが下記のものになります。

> 今回の関数で肝になるのは正規表現の「|」で変換する文字列をつなぎ、一気に変換しているところです。
> 「formatting」のキーをforで回し、ひとつひとつ変換していると順番によっては意図しない変換が行われることがあります。たとえば、「tt」が先に変換されていると「AM・PM」の「M」の文字が月の数字に変換されたりしますが、「|」で変換する文字列をつなぎ、一気に変換することでこれを回避することができます。

下記のものをファイルに保存してそのまま使用すると「グローバル変数」を汚すことになるのでお勧めしません。
使用する場合はローカル関数として使用することをお勧めします。

```js
/**
 * フォーマットの書式に基づき文字列を変換する関数
 *
 * @param {Date} date フォーマットに使用するDateオブジェクト
 * @param {String} format フォーマットする文字列
 * @return {String} 書式に基づき変換した文字列
 */
var dateFormatJp = function(date, format) {
    "use strict";
    // 引数dateが日付オブジェクトでない場合は例外をスロー
    if (isNaN(date.getTime()) ||
        Object.prototype.toString.call(date) !== "[object Date]") {
        throw new Error("引数「date」を認識できません");
    }

    // 引数formatが文字列でない場合は例外をスロー
    if (typeof format !== "string") {
        throw new Error("引数「format」が文字列ではありません");
    }


    // 和暦用オブジェクト
    var jp = {
        // MMMで使用する日本の月名の配列
        month: ["睦月", "如月", "弥生", "卯月", "皐月", "水無月",
            "文月", "葉月", "長月", "神無月", "霜月", "師走"],

        // DDD、DDで使用する曜日に使用する配列
        day: ["日", "月", "火", "水", "木", "金", "土"],

        // TTで使用する配列
        noonJp: ["午前", "午後"],

        // ttで使用する配列
        noonEn: ["AM", "PM"],

        /**
         * ggg、gg、g、ee、eで使用する和暦と元号を取得する関数
         *
         * @param {Date} date 和暦を取得するためのDateオブジェクト
         * @return {Object} 和暦用のデータを格納したオブジェクト
         */
        era: function(date) {
            var year = date.getFullYear();
            // 戻り値の初期値
            var ret = {
                longName: "西暦",
                shortName: "西",
                alphaName: "AD",
                year: year
            };

            // 明治以降の和暦を取得する
            if (year > 2018) {
               // 2019年以降は令和
               ret = {
                    longName: "令和",
                    shortName: "令",
                    alphaName: "R",
                    year: year - 2018
                };
            } else if (year > 1988) {
               // 1989年以降は平成
               ret = {
                    longName: "平成",
                    shortName: "平",
                    alphaName: "H",
                    year: year - 1988
                };
            } else if (year > 1925) {
                // 1926年以降は昭和
                ret = {
                    longName: "昭和",
                    shortName: "昭",
                    alphaName: "S",
                    year: year - 1925
                };
            } else if (year > 1911) {
                // 1912年以降は大正
                ret = {
                    longName: "大正",
                    shortName: "大",
                    alphaName: "T",
                    year: year - 1911
                };
            } else if (year > 1867) {
                // 1868年以降は明治
                ret = {
                    longName: "明治",
                    shortName: "明",
                    alphaName: "M",
                    year: year - 1867
                };
            }
            return ret;
        }
    };

    // ヘルパー関数
    var func = {
        /**
         * 任意の桁数に切り取った文字列を返す（桁数に足りない場合は0埋め）
         *
         * @param {Number} val 0埋めする数字
         * @param {Number} num 桁数
         * @return {String} 0埋めした文字列
         */
        padSlice: function(val, num) {
            // 桁数が指定されていない時は2桁
            if (num === undefined) {
                num = 2;
            }

            // 要素数numの配列を区切り文字"0"で結合しvalを結合
            // 出来た文字列の右側からnum文字を切り出す
            return (new Array(num).join("0") + val).slice(num * -1);
        },

        /**
         * 任意の桁数に0埋めした文字列を返す（桁数を超える場合はそのまま）
         *
         * @param {Number} val 0埋めする数字
         * @param {Number} num 桁数
         * @return {String} 0埋めした文字列
         */
        padLoop: function(val, num) {
            // 桁数が指定されていない時は2桁
            if (num === undefined) {
                num = 2;
            }

            // valを文字列に変換
            val = val.toString();

            // 桁数に達するまで左に0を足す
            while (val.length < num) {
                val = "0" + val;
            }

            return val;
        }
    };


    // 午前を0、午後を1として値を取得します
    var noon = date.getHours() < 12 ? 0 : 1;

    // 和暦の元号等のオブジェクトを取得します
    var era = jp.era(date);

    /**
     * フォーマットの設定
     * 
     * キー：変換する文字列
     * 値：変換に使用する関数等
     */
    var formatting = {
        yyyy: func.padSlice(date.getFullYear(), 4),
        yy: func.padSlice(date.getFullYear()),
        ee: func.padLoop(era.year),
        e: era.year,
        ggg: era.longName,
        gg: era.shortName,
        g: era.alphaName,
        MMM: jp.month[date.getMonth()],
        MM: func.padSlice(date.getMonth() + 1),
        M: date.getMonth() + 1,
        dd: func.padSlice(date.getDate()),
        d: date.getDate(),
        HH: func.padSlice(date.getHours()),
        H: date.getHours(),
        hh: func.padSlice(date.getHours() - 12 * noon),
        h: date.getHours() - 12 * noon,
        mm: func.padSlice(date.getMinutes()),
        m: date.getMinutes(),
        ss: func.padSlice(date.getSeconds()),
        s: date.getSeconds(),
        fff: func.padSlice(date.getMilliseconds(), 3),
        f: date.getMilliseconds(),
        TT: jp.noonJp[noon],
        tt: jp.noonEn[noon],
        DDD: jp.day[date.getDay()] + "曜日",
        DD: jp.day[date.getDay()]
    };

    // フォーマットの実行

    // 「""」連続したダブルクォートを一旦エスケープしておくための文字列
    // こんな文字列指定しないだろうという文字列
    var esc = "_____-----_____-----_____-----_____-----_____-----_____";
    
    // 「""」連続したダブルクォートのエスケープ
    format = format.replace(/("")/g, esc);
    
    // 「"」ダブルクォートで文字列を分割
    var split = format.split('"');

    // 変換に使用する正規表現文字列の作成
    // 後で文字列を連結するために一旦フォーマット設定のキーを配列に収める
    var regs = [];
    for (var key in formatting) {
        regs.push(key);
    }

    // キャプチャする括弧内に「または」で連結
    regs = "(" + regs.join("|") + ")";

    // 正規表現オブジェクトの作成
    var reg = new RegExp(regs, "g");

    // ダブルクォートで囲まれた範囲は変換しないので、
    // 分割してできた配列の0から2おきに変換を実施する
    for (var i = 0, len = split.length; i < len; i += 2) {
        split[i] = split[i].replace(reg, function (match) {
            return formatting[match];
        });
    }

    // 変換し終わった配列を区切り文字なしで結合する
    format = split.join("");

    // 最初にエスケープしておいた「""」連続したダブルクォートの文字列を
    // 「"」ダブルクォートの文字列として変換する
    format = format.replace(new RegExp("(" + esc + ")", "g"), '"');

    // 変換した文字列を返す
    return format;
};
```