JavaScriptでPHPのdate関数ライクな日時フォーマッター関数を作ってみた

かなり久しぶりなQiitaへの投稿になります。
いやぁ、実に9ヶ月ぶりっす…。
自ブログの方も半年ぐらい更新してなかったんですが、GA見たらPV落ちずに右肩上がりの微増傾向で、驚いて調べてみたら、Qiitaに転載した記事からのPV流入の恩恵を受けているようで、さすがは偉大なるQiitaさまっ！（笑）

閑話休題（それはさておき）、今回は現在自作しているjQueryプラグイン「[jquery.timeline](https://github.com/ka215/jquery.timeline)」の内部関数として作成した**日時フォーマッター関数**を紹介してみようかと思った次第であります。

まぁ、JavaScript界隈で日時フォーマッターの大御所としては「**[Moment.js](https://momentjs.com/)**」が有名でして、当初のプラグイン開発では私も大御所に依存しちゃおうかなぁ…と日和っていたものです。ただ、それだと自作jQueryプラグインが単体で動作できなくなってしまい、マニュアルとか書く時に手順が増えて面倒だなぁ…と言うのが、この日時フォーマッターを自作するきっかけでした。
──で、いっそ作るなら、Moment.jsとは取り扱うフォーマットの書式（「YYYY-MM-DD」とか「HH:MM」など）を違うものにした方が良いなと思ったわけです。Moment.jsのフォーマット書式は直感的でわかりやすいんですが、私の好みじゃないんで、慣れ親しんだPHPの`date()`関数のフォーマット書式に寄せてしまおう！と。
ついでにRubyの日時フォーマットにも対応できるように…とか欲をかいたら、色々と挫折しそうになって来たので、やめました…(※ その名残で、後述の[フォーマット一覧表](#対応フォーマット一覧)にRubyの表記が…苦笑)

なお、Node.js使える環境なら素直にnpmの「[phpdate-js](https://www.npmjs.com/package/phpdate-js)」使った方が良いです。私のように、自作ライブラリなどで**ちょろっと**日時フォーマッター組み込みたい人向けの関数になります。

fn.date.js
---

まずは、ソースを紹介:

```js:fn.date.js
function date( format, date ) {
  // Date format like PHP
  var baseDt  = Object.prototype.toString.call( date ) === '[object Date]' ? date : new Date( date ),
      month = { 'Jan': 'January', 'Feb': 'February', 'Mar': 'March', 'Apr': 'April', 'May': 'May', 'Jun': 'June', 'Jul': 'July', 'Aug': 'August', 'Sep': 'September', 'Oct': 'October', 'Nov': 'November', 'Dec': 'December' },
      day = { 'Sun': 'Sunday', 'Mon': 'Monday', 'Tue': 'Tuesday', 'Wed': 'Wednesday', 'Thu': 'Thurseday', 'Fri': 'Friday', 'Sat': 'Saturday' },
      formatStrings = format.split(''),
      converted = '',
      esc = false,
      lastDayOfMonth = function( dateObj ) {
        var _tmp = new Date( dateObj.getFullYear(), dateObj.getMonth() + 1, 1 );
        _tmp.setTime( _tmp.getTime() - 1 );
        return _tmp.getDate();
      },
      isLeapYear = function( dateObj ) {
        var _tmp = new Date( dateObj.getFullYear(), 0, 1 ),
            sum  = 0, i;
        for ( i = 0; i < 12; i++ ) {
          _tmp.setMonth(i);
          sum += lastDayOfMonth( _tmp );
        }
        return ( sum === 365 ) ? 0 : 1;
      },
      dateCount = function( dateObj ) {
        var _tmp = new Date( dateObj.getFullYear(), 0, 1 ),
            offset = arguments.length > 1 ? Number( arguments[1] ) : 0,
            sum = 0, i;
        for ( i=0; i<dateObj.getMonth(); i++ ) {
          _tmp.setMonth(i);
          sum += lastDayOfMonth( _tmp );
        }
        return sum + dateObj.getDate() + offset;
      },
      half_hours = function( dateObj ) {
        var h = dateObj.getHours();
        return h > 12 ? h - 12 : h;
      },
      ampm = function( dateObj ) {
        var h = dateObj.getHours();
        return h > 12 ? 'pm' : 'am';
      },
      object_values = function( obj ) {
        var r = [];
        for ( var k in obj ) {
          if ( obj.hasOwnProperty( k ) ) r.push( obj[k] );
        }
        return r;
      },
      object_keys = function( obj ) {
        var r = [];
        for ( var k in obj ) {
          if ( obj.hasOwnProperty( k ) ) r.push( k );
        }
        return r;
      },
      zerofill = function( num, digit ) {
        if ( String.prototype.padStart ) {
          return num.toString().padStart( digit, 0 );
        } else {
          return ( Array( digit ).join( "0" ) + num ).slice( -digit );
        }
      };

  if ( format === '' ) {
    return baseDt;
  }
  try {
    if ( baseDt.toString() === 'Invalid Date' ) {
      throw new Error( '"' + date.toString() + '" is invalid date.' );
    }
    formatStrings.forEach( function( str, i ) {
      var res, tmp, sign;
      if ( esc === false ) {
        switch( str ) {
          case 'Y': // Full year | ruby %Y
          case 'o': // Full year (ISO-8601)
            res = baseDt.getFullYear();
            break;
          case 'y': // Two digits year | ruby %y
            res = ('' + baseDt.getFullYear()).slice(-2);
            break;
          case 'm': // Zerofill month (01-12) | ruby %m
            res = ('0' + (baseDt.getMonth() + 1)).slice(-2);
            break;
          case 'n': // Month
            res = baseDt.getMonth() + 1;
            break;
          case 'F': // Full month name | ruby %B
            res = object_values( month )[baseDt.getMonth()];
            break;
          case 'M': // Short month name | ruby %b
            res = object_keys( month )[baseDt.getMonth()];
            break;
          case 'd': // Zerofill day (01-31) | ruby %d
            res = ('0' + baseDt.getDate()).slice(-2);
            break;
          case 'j': // Day
            res = baseDt.getDate();
            break;
          case 'S': // Day with suffix
            var suffix = [ 'st', 'nd', 'rd', 'th' ],
                suffix_index = function(){
                  var d = baseDt.getDate();
                  if ( d == 1 || d == 2 || d == 3 || d == 21 || d == 22 || d == 23 || d == 31 ) {
                    return Number( ('' + d).slice(-1) - 1 );
                  } else {
                    return 3;
                  }
                };
            res = suffix[suffix_index()];
            break;
          case 'w': // Day of the week (number) | ruby %w
          case 'W': // Day of the week (ISO-8601 number)
            res = baseDt.getDay();
            break;
          case 'l': // Day of the week (full) | ruby %A
            res = object_values( day )[baseDt.getDay()];
            break;
          case 'D': // Day of the week (short) | ruby %a
            res = object_keys( day )[baseDt.getDay()];
            break;
          case 'N': // Day of the week (ISO-8601 number)
            res = baseDt.getDay() === 0 ? 7 : baseDt.getDay();
            break;
          case 'a': // am or pm
            res = ampm(baseDt);
            break;
          case 'A': // AM or PM
            res = ampm(baseDt).toUpperCase();
            break;
          case 'g': // Half hours (1-12)
            res = half_hours( baseDt );
            break;
          case 'h': // Zerofill half hours (01-12) | ruby %I
            res = ('0' + half_hours(baseDt)).slice(-2);
            break;
          case 'G': // Full hours (0-23)
            res = baseDt.getHours();
            break;
          case 'H': // Zerofill full hours (00-23) | ruby %H
            res = ('0' + baseDt.getHours()).slice(-2);
            break;
          case 'i': // Zerofill minutes (00-59) | ruby %M
            res = ('0' + baseDt.getMinutes()).slice(-2);
            break;
          case 's': // Zerofill seconds (00-59) | ruby %S
            res = ('0' + baseDt.getSeconds()).slice(-2);
            break;
          case 'z': // Day of the year (0-365) | ruby %j
            res = String( dateCount( baseDt, -1 ) );
            break;
          case 't': // Days of specific month
            res = lastDayOfMonth( baseDt );
            break;
          case 'L': // Whether a leap year
            res = isLeapYear( baseDt );
            break;
          case 'c': // Date of ISO-8601
            tmp = baseDt.getTimezoneOffset();
            tzo = [ Math.floor( Math.abs( tmp ) / 60 ), Math.abs( tmp ) % 60 ];
            sign = tmp < 0 ? '+' : '-';
            res  = baseDt.getFullYear() +'-'+ zerofill( baseDt.getMonth() + 1, 2 ) +'-'+ zerofill( baseDt.getDate(), 2 ) +'T';
            res += zerofill( baseDt.getHours(), 2 ) +':'+ zerofill( baseDt.getMinutes(), 2 ) +':'+ zerofill( baseDt.getSeconds(), 2 );
            res += sign + zerofill( tzo[0], 2 ) +':'+ zerofill( tzo[1], 2 );
            break;
          case 'r': // Date of RFC-2822
            tmp = baseDt.getTimezoneOffset();
            tzo = [ Math.floor( Math.abs( tmp ) / 60 ), Math.abs( tmp ) % 60 ];
            sign = tmp < 0 ? '+' : '-';
            res  = object_keys( day )[baseDt.getDay()] +', '+ baseDt.getDate() +' '+ object_keys( month )[baseDt.getMonth()] +' '+ baseDt.getFullYear() +' ';
            res += zerofill( baseDt.getHours(), 2 ) +':'+ zerofill( baseDt.getMinutes(), 2 ) +':'+ zerofill( baseDt.getSeconds(), 2 ) +' ';
            res += sign + zerofill( tzo[0], 2 ) + zerofill( tzo[1], 2 );
            break;
          case 'u': // Millisecond
            res = baseDt.getTime();
            break;
          case 'U': // Unix Epoch seconds
            res = Date.parse( baseDt ) / 1000;
            break;
          case "\\": // escape
            esc = true;
            res = formatStrings[i + 1];
            break;
          default:
            res = str;
            break;
        }
        converted += res;
      } else {
        esc = false;
        return true;
      }
    });

    return converted;
  } catch (e) {
    console.error(e.message);
    return false;
  }
}
```

長ぇ～（汗）、オリジナルのソースはGistにアップしてあるので、

[https://gist.github.com/ka215/20cbab58e4f7d4e5508a07cff8d64b00](https://gist.github.com/ka215/20cbab58e4f7d4e5508a07cff8d64b00)

──のURLでも見れます。または、下記のエンベッドコードでどこにでも埋め込み可能なはずです。

```
<script src="https://gist.github.com/ka215/20cbab58e4f7d4e5508a07cff8d64b00.js"></script>
```

最初、underscore.jsとjQueryに依存した処理で書いていたのですが、素のJavaScriptだけで動くように修正しました。なので、外部JSライブラリとか考えないで使えるようになっています。ただ、ブラウザ依存までは調べていないので、古いブラウザだとJavaScriptの実装が古くて動かない可能性はあるかも。


使い方
---

JavaScript中で、`date( "フォーマット文字列", 日時的な文字列かDateオブジェクト )`として呼び出すと、フォーマットされた日時文字列が返ってくるという建付けです。

```js:
var formatterStrings = [
    'Y', 'o', 'y', 'm', 'n', 'F', 'M', 'd', 'j', 'S', 'w', 'W', 'l', 'D', 'N', 'a', 'A', 'g', 'h', 'G', 'H', 'i', 's', 'z', 't', 'L', 'c', 'r', 'u', 'U', '\\Y-\\m-\\d'
  ];
  formatterStrings.forEach(function( str, i ) {
    document.write( str + ' : ' + date( str, new Date() ) + '<br>' );
  });
```

第二引数にはDateオブジェクトでない日付っぽい文字列を入れても整形してくれます。

```js:
document.write( date( 'Y-m-d H:i:s', '1234-5-6' ) );
// "1234-05-06 00:00:00" と表示される
```


対応フォーマット一覧
---

「Ruby互換」などのRuby対応向けの表記は今は無視してください（苦笑）

| 対応書式 | Ruby互換 | 出力形式（PHP `date()` との比較等） |
|:--------------:|:--------:|:-----------------------------------|
| Y | %Y | 西暦を表す4桁の数 (e.g. 1970,2017) |
| o | - | PHPでは「ISO-8601 形式の週番号による年」らしいが、よくわからんかったのでYと同じ数値（笑） |
| y | %y | 西暦の下2桁の数 (00-99) |
| m | %m | 月を表す数字 (01-12) |
| n | - | 先頭に0が付かない月を表す数字 (1-12) |
| F | %B | 月の名称 (January, February ... ) |
| M | %b | 月の省略名 (Jan, Feb ... ) |
| d | %d | 日を表す数字 (01-31) |
| j | - | 先頭に0が付かない日を表す数字 (1-31) |
| S | - | 英語形式の序数を表すサフィックス (st,nd,rd,th) 通常はjと一緒に使う |
| w | %w | 曜日を表す数 (0-6) ※ 日曜日が0 |
| W | %W | 年単位の週番号 (0-53) ※ **現在、未実装でwと同じ値を返す** |
| l | %A | 曜日の名称 (Sunday, Monday ... ) |
| D | %a | 曜日の省略名 (Sun, Mon ... ) |
| N | - | ISO-8601形式の、曜日の数値表現 (1-7) ※ 月曜日が1 |
| a | - | 午前・午後を表す文字（小文字） (am,pm) |
| A | - | 午前・午後を表す文字（大文字） (AM,PM) |
| g | - | 先頭に0が付かない12時間制の時間 (1-12) |
| h | %I | 12時間制の時間 (01-12) |
| G | - | 先頭に0が付かない24時間制の時間 (0-23) |
| H | %H | 24時間制の時間 (00-23) |
| i | %M | 分を表す数 (00-59) |
| s | %S | 秒を表す数 (00-59) |
| z | %j | 年中の通算日 (0-365) ※ rubyだと 001-366 |
| t | - | 指定月の総日数 (28-31) |
| L | - | うるう年かどうか (0:うるう年でない, 1:うるう年) |
| c | - | ISO-8601形式の日付 (e.g. 2017-03-24T12:00:00+09:00) |
| r | - | RFC-2822形式の日付 (e.g. Fri, 24 Mar 2017 15:50:22 +0900) |
| u | - | PHPでは「マイクロ秒」だが、JavaScriptでは算出が難しいのでUNIXエポックタイムからのミリ秒を返す（独自仕様） |
| U | - | UNIXエポックタイム（1970-01-01 00:00:00）からの秒数（＝UNIXタイムスタンプ） |
| \\\ | - | バックスラッシュを二つ連続すると直後の文字のフォーマット処理をエスケープする（独自仕様） |

将来的に、第三引数に言語フラグを持たせてRubyの日時フォーマット書式にも対応させたいと思っています（Ruby建てのシステムで違和感なく使えるようにしたいので）が、今々はこれでローンチしてます。

既知の不具合
---

Dateオブジェクトの盲点である、二桁数値の年を2000年前後に丸めてしまうという不具合としか思えない仕様への対応をしていないので、西暦100年より前の日時を正常にフォーマットできません。
まぁ、その辺の日時を取り扱うことも少ないと思うので、必要に迫られない限り対応する予定はありません。

もし他にも不具合等あったら、教えてください。

そして、もし気に入ったら使ってやってください。

<div style="margin-bottom: 3em;"><br></div>

_P.S._
いやぁ、この記事書くのに関数の動作テストしてたら、色々と不具合見つかって…（汗）
でも、上手い事デバッグができたので、棚ぼた的に有意義な時間でした（笑）