JavaScript
JSON
date
ECMAScript
シリアライズ

ECMAScript 2017 に準拠した方法で Date オブジェクトをシリアライズ(文字列化)する

初めに

この記事は、下記リンク先に投稿した serialize-date-object-to-json.md の複製版です。
記事後半で出てくる JSONForDate は下記リンク先からDL出来るライブラリの為、必要に応じて参照して下さい。

ECMAScript 2017

この記事では、ECMAScript 2017 に則った方法を紹介しています。
ECMAScript 2017 は JavaScript の根幹となる機能をまとめた標準仕様です。
この仕様に則れば、どの実装(ブラウザ)であっても同じように動作する事が期待できます。

ISO 8601 拡張形式 (RFC3339)

ECMAScript 2017 では、日付と認識可能な文字列として ISO 8601 拡張形式 を定義しています。
ISO 8601 拡張形式は曖昧な部分がある為、RFC3339 でも再定義しているようです。
下記にそれぞれのリンク先を示しますが、ECMAScript 2017 でも「ISO 8601 拡張形式」を再定義している為、どちらか一方を読むだけでも良いかもしれません。

ECMAScript 2017 では YYYY-MM-DDTHH:mm:ss.sssZ もしくは YYYY-MM-DDTHH:mm:ss.sss[+-]HH:mm の形式として「ISO 8601 拡張形式」を定義しています。

  • YYYY … グレゴリオ暦の0000〜9999桁の10進数。
  • MM … 01(1月)から12(12月)までの年の月。
  • DD … 01から31までの月の日。
  • T … 時間要素の始まりを示す文字(訳注: Time の "T" と思われます)
  • HH … 深夜0時から24侍を表した、00から24までの2桁の10進数(24時間法)
  • : 「時間:分:秒」を区切る為の区切り文字(セパレータ)
  • mm … 何時何分における何分を表す00から59までの2桁の10進数
  • ss … 0分から1分までの間に存在する2桁の10進数00から59からなる秒数
  • . … 「秒.ミリ秒」を区切る為の区切り文字(セパレータ)
  • sss 3桁の10進数に構成されるミリ秒
  • Z … UTC日時の場合、文字列の終端を表す(タイムゾーン指定子)
  • [+-]HH:mm … タイムゾーンとなるUTCからの時差を表す。"+09:00" はUTCから「+9時間」の時差を表し、"-01:00" はUTCから「-1時間」の時差を表す(タイムゾーン指定子)

他にも次のルールがあります。

  • ゼロパディングが必須です。"2017-01-01""2017-1-1" のようにゼロを切り詰めて表現する事は出来ません(MUST)。
  • タームゾーン識別子が省略された場合、UTC として扱われます。
  • 後方にある値は一部省略する事が可能です。省略された値は最も小さな値として扱われます。

省略規則はやや特殊な為、コード事例をあげます。

new Date("2017-08-23T12:00:00.000+09:00").toISOString();  // "2017-08-23T03:00:00.000Z"
new Date("2017-08-23T12:00:00.000").toISOString();        // "2017-08-23T03:00:00.000Z"
new Date("2017-08-23T12:00:00").toISOString();            // "2017-08-23T03:00:00.000Z"
new Date("2017-08-23T12:00").toISOString();               // "2017-08-23T03:00:00.000Z"
new Date("2017-08-23T12").toISOString();                  // RangeError: Invalid time value
new Date("2017-08-23").toISOString();                     // "2017-08-23T00:00:00.000Z"
new Date("2017-08").toISOString();                        // "2017-08-01T00:00:00.000Z"
new Date("2017").toISOString();                           // "2017-01-01T00:00:00.000Z"

HH:mm から HH に省略できない事を除いて、後ろの要素を省略可能な事が分かります。

new Date( value )

new Date() は最も基本となる日付文字列用のパーサ(構文解析器)であり、ISO 8061 拡張形式の文字列を Date オブジェクトに変換することが出来ます。

var dateString1 = '2017-08-23T03:00:00.000Z',
    dateString2 = '2017-08-23T12:00:00.000+09:00';

console.log(new Date(dateString1)); // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))
console.log(new Date(dateString2)); // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))

<<<<<<< HEAD

Date.parse( string )

=======

EDIT_REQUEST
- 20.3.3.2 Date.parse ( string ) - ECMAScript® 2017 Language Specification

Date.parse()new Date と同様、ISO 8061 拡張形式の文字列を、パース(構文解析)出来ますが、次の点が異なります。

  • Date.parse() は日付文字列しか引数にとれない(new Date には Number 型の値を引数にとる等、他の機能がある)
  • Date.parse() は協定世界時(UTC)からの経過ミリ秒数を返す (new DateDate オブジェクトを返す)

コードを書いてみましょう。

var dateString1 = '2017-08-23T03:00:00.000Z',
    dateString2 = '2017-08-23T12:00:00.000+09:00';

console.log(Date.parse(dateString1)); // 1503457200000
console.log(Date.parse(dateString2)); // 1503457200000

console.log(new Date(Date.parse(dateString1))); // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))
console.log(new Date(Date.parse(dateString2))); // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))

Date オブジェクトに変換する為には Date.parse でUTCからのミリ秒を得た後に new Date を更に実行しなければなりません。
new Date でも「ISO 8061 拡張形式」を扱える為、この場合は冗長なコードとなっています。

Date.prototype.toISOString

Date.prototype.toISOStringDate オブジェクトを「ISO 8061 拡張形式」のUTC文字列にシリアライズ(文字列化)します。

var date = new Date("2017-08-23T12:00:00.000+09:00");

console.log(date);                // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))
console.log(date.toISOString());  // 2017-08-23T03:00:00.000Z

Date.prototype.toJSON( key )

Date.prototype.toJSONthis 値を Number 型に変換した値が有限数であった場合、Date.prototype.toISOString を呼び出し、その返り値をそのまま返します。
つまり、Date.prototype.toISOString と同じ実行結果を返します。
(※このメソッドは JSON.stringify() で Date オブジェクトをシリアライズ(文字列化)する為に定義されており、通常は明示的に呼び出す事はありません。)

var date = new Date("2017-08-23T12:00:00.000+09:00");

console.log(date);           // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))
console.log(date.toJSON());  // 2017-08-23T03:00:00.000Z

JSON.stringify( value [ , replacer [ , space ] ] )

JSON.stringify() はJSONをシリアライズ(文字列化)する関数です。
JSON.stringify() は対象のオブジェクトに toJSON という名前のプロパティが存在し、それが関数であったならば、toJSON() を呼び出してシリアライズ(文字列化)します。

var date = new Date("2017-08-23T12:00:00.000+09:00");

console.log(date);                  // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))
console.log(JSON.stringify(date));  // "2017-08-23T03:00:00.000Z"

Date.prototype.toJSONDate.prototype.toISOString と同じ処理になるので、JSON.stringify() でシリアライズする事は「ISO 8061 拡張形式」にシリアライズする事と同義です。
ただし、JSON は文字列にシリアライズする際に文字列リテラルの形式にする為、前後に " (ダブルコーテーション)が付与される事になります。

JSON.parse( text [ , reviver ] )

JSON.parse()JSON.stringify() によってシリアライズされた文字列を元の形に戻す関数です。
しかし、残念ながら、JSON.parse() は「JSON.stringify() によって Date オブジェクトから変換された ISO 8061 拡張形式の文字列」を String 型のデータと判断する為、JSON.stringify() によるシリアライズは不可逆となります。

var date = new Date("2017-08-23T12:00:00.000+09:00"),
    json = JSON.stringify(date);

console.log(date);              // Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時))
console.log(json);              // "2017-08-23T03:00:00.000Z"
console.log(JSON.parse(json));  // 2017-08-23T03:00:00.000Z

JSON にシリアライズされた日付文字列を組み込む場合

不可逆性の問題

前述の通り、Date オブジェクトのシリアライズ/パースにおいて、JSONの活用は不可逆です。
そんな状況を考慮してか、JSON.stringify(), JSON.parse() にはコールバック関数を引数にとる事で出力値を変更する機能があります。

  • JSON.stringify() は第二引数に replacer となる関数を与える事で出力値を変更できる
  • JSON.parse() は第二引数に reviver となる関数を与える事で出力値を変更できる

まず、JSON.parse() から実装してみましょう。

'use strict';
function reviver (key, value) {
  return /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/.test(value) ? new Date(value) : value;
}

var date = new Date('2017-08-22T09:00:00+09:00'),
    array1 = [date, date.toISOString()],
    json = JSON.stringify(array1),
    array2 = JSON.parse(json, reviver);

console.log(array1);  // [Tue Aug 22 2017 09:00:00 GMT+0900 (東京 (標準時)), "2017-08-22T00:00:00.000Z"]
console.log(array2);  // [Tue Aug 22 2017 09:00:00 GMT+0900 (東京 (標準時)), Tue Aug 22 2017 09:00:00 GMT+0900 (東京 (標準時))]
console.log(json);    // ["2017-08-22T00:00:00.000Z","2017-08-22T00:00:00.000Z"]

期待通り、Date オブジェクトに戻す事が出来ました。
しかし、このままでは元々、文字列として存在した "2017-08-22T00:00:00.000Z"Date オブジェクトに変換されてしまうので、JSON.stringify() にもコールバック関数を与えてみましょう。

'use strict';
function reviver (key, value) {
  if(/date:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/.test(value)) { // new Date なら
    return new Date(value);
  }

  if(/string:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/.test(value)) {  // String 型なら
    return value.slice(7);
  }

  return value;
}

function replacer (key, value) {
  if (Object(value) === value && Object.getPrototypeOf(value) === Date.prototype) { // new Date なら
    return 'date:' + date.toISOString();
  }

  if (typeof value === 'string') {  // String 型なら
    return 'string:' + value;
  }

  return value;
}

var date = new Date('2017-08-22T09:00:00+09:00'),
    array1 = [date, date.toISOString()],
    json = JSON.stringify(array1, replacer),
    array2 = JSON.parse(json, reviver);

console.log(array1);  // [Tue Aug 22 2017 09:00:00 GMT+0900 (東京 (標準時)), "2017-08-22T00:00:00.000Z"]
console.log(array2);  // ["2017-08-22T00:00:00.000Z", "2017-08-22T00:00:00.000Z"]
console.log(json);    // ["string:2017-08-22T00:00:00.000Z","string:2017-08-22T00:00:00.000Z"]

期待に反して、両方とも「文字列」として扱われてしまいました。
なぜなら、replacer() を通した時点で Date オブジェクトは既に「ISO 8061拡張形式」の文字列に変換されてしまっているからです。

これでは replacer() による変換は諦めるしかなく、別の切り口でシリアライズする方法を考える必要があります。

(解決策) JSON.stringify 実行前に Date オブジェクトと文字列を衝突しない値に書き換える

やや強引ですが、事前に全ての Date オブジェクト、String 値に対して衝突しない値に書き換えてやれば、両者を区別することが出来ます。
Date オブジェクトは toJSON プロパティを書き換える事で "date:" の接頭辞付きで出力するものとし、String 値には "string:" の接頭辞を付けます。
パース処理には JSON.parse() の第二引数を利用します。

var date = new Date("2017-08-23T12:00:00.000+09:00"),
    array = [date, date.toISOString(), [1,2], {a: 'foo', b: 'bar'}],
    json = JSONForDate.stringify(array);

console.log(json);  // ["date:2017-08-23T03:00:00.000Z","string:2017-08-23T03:00:00.000Z",[1,2],{"a":"string:foo","b":"string:bar"}]
console.log(JSONForDate.parse(json)); // [Wed Aug 23 2017 12:00:00 GMT+0900 (東京 (標準時)),"2017-08-23T03:00:00.000Z",[1,2],{"a":"foo","b":"bar"}]

JSONForDate の基本的な使い方は JSON と同じです。
詳細は下記リンク先にある readme.md を参照して下さい。