UglifyJSで、できるだけライセンスコメントを残して圧縮する

  • 70
    Like
  • 0
    Comment
More than 1 year has passed since last update.
[2013/12/19 追記]

本記事で紹介している方法をモジュール化し、Node.jsで利用可能にしたuglify-save-licenseを公開しました。本記事のコードに改善を加えているので、利用する場合は記事中の方法ではなくそちらのモジュールを使用してください。

はじめに

クライアントサイドJavaScriptにおいて、(スタイルシートの読み込み直後での読み込みが推奨されているModernizrなどのライブラリ以外は)圧縮・結合してbody要素の末尾で読み込む、という手法は最早定番と言えますが、今回は圧縮ツールUglifyJSを用いる際に、ライセンスコメントをできる限り残しつつ圧縮する方法を紹介します。

……という予定だったのですが、アドベントカレンダーの担当日の数日前に、ライセンスコメントを抽出する非常に強力な方法としてgrunt-license-saverが登場し、ここで紹介するよりもわかりやすく、汎用的な方法でコメント抽出ができるようになりました。

話題を変えようかとも思ったのですが、今のところは、今回紹介する方法がgrunt-license-saverの完全な下位互換というわけではないようなので、予定通り、ライセンス保管の話題でいきたいと思います。

ライセンス抽出のための正規表現は、grunt-license-saverのコードを参考にさせて頂きました。

commentsオプションの利用

UglifyJSをNode.jsのプログラム中で使用する場合、commentsオプションには真偽値や正規表現のほかにも関数を指定でき(参考)、圧縮対象のファイルの中のコメントノードひとつに対し一度実行されます。関数がtruthyな値を返した場合、そのコメントは圧縮後にも消されずに残ります。
これを利用して、「ライセンステキストの一部であるかどうか」を判定し、ライセンスコメントを残して圧縮するための関数を作ります。

isLicenseComment関数

var isLicenseComment = (function() {

  // ライセンスコメントかどうか判定する正規表現
  var licenseRegexp = /^\!|^@preserve|^@cc_on|\bMIT\b|\bMPL\b|\bGPL\b|\(c\)|License|Copyright/mi;

  // 直前にライセンスコメントが現れた行を保存する内部変数
  var _prevCommentLine = 0;

  // 実際に`comment`オプションで実行されるのはこの関数
  return function(node, comment) {
    // コメントがライセンス文に含まれるかどうか判定
    if (licenseRegexp.test(comment.value) ||
        comment.line === 1 ||
        comment.line === _prevCommentLine + 1) {

      // コメントがライセンス文に含まれる場合、そのコメントの行番号を保存する
      _prevCommentLine = comment.line;

      return true;
    }

    // コメントがライセンス文に含まれない場合、行番号の保存をリセットする
    _prevCommentLine = 0;

    return false;
  };
})();

上記のisLicenseComment関数を、UglifyJSのcommentsオプションに指定して使用します。commentsオプションから呼び出される関数は、第一引数にそのコメントが含まれているコメントノードを表すオブジェクト、第二引数に当該コメントのAST tokenを受け取ります。

上記関数のif文では、以下の条件で、そのコメントがライセンス文の一部であるかを判定しています。

  1. 当該コメントがファイルの一行目にある
  2. 当該コメントが、「ライセンスコメントかどうか判定する正規表現」にマッチしている
  3. 当該コメントが 1. 2. に適合しなくとも、当該コメントの直前の行に現れたコメントが「ライセンスコメントである」と判定されている

この判定でも、ライセンスコメントを取り逃がしてしまう場合、あるいはライセンス以外のコメントを残してしまう場合は、多々あるかと思います。特に、3. の条件が余計なコメントをライセンス文と判定してしまう可能性は高いです。

使用例

Node.jsプログラム
"use strict";

var UglifyJS = require("uglify-js");

var licenseRegexp = /^\!|^@preserve|^@cc_on|\bMIT\b|\bMPL\b|\bGPL\b|\(c\)|License|Copyright/mi;

var isLicenseComment = (function() {
  var _prevCommentLine = 0;

  return function(node, comment) {
    if (licenseRegexp.test(comment.value) ||
        comment.line === 1 ||
        comment.line === _prevCommentLine + 1) {
      _prevCommentLine = comment.line;
      return true;
    }

    _prevCommentLine = 0;
    return false;
  };
})();

var minified = UglifyJS.minify("file.js", {
  output: {
    comments: isLicenseComment
  }
}).code;

console.log(minified);
圧縮対象のファイル file.js
// example.js

// (c) John Smith | MIT License
// http://examplelibrary.com/

// anonymous function
(function(win, doc){
  var string = 'Hello World! :' + doc.title;

  // output greeting message
  console.log(string);
}(window, document));
圧縮結果
// example.js
// (c) John Smith | MIT License
// http://examplelibrary.com/
!function(o,l){var n="Hello World! :"+l.title;console.log(n)}(window,document);

ライセンス文らしい部分のみ残して圧縮することができました。
上記の例では、

  • // example.jsはファイルの一行目なので真
  • // (c) John …は、正規表現にマッチする文字列(c)MIT Licenseが含まれているので真
  • // http://examplelibrary.com/は、直前の行が真なので、続くその行も真
  • それ以外のコメント(// output greeting messageなど)は偽

という風に、ライセンス文であるかどうか判定されています。

Grunt での利用例

Gruntfile.coffeegrunt-contrib-uglifyを使用する際のコード例です。preserveCommentsオプション(参考)にisLicenseComment関数を指定します。

'use strict'

module.exports = (grunt) ->
  grunt.loadNpmTasks 'grunt-contrib-uglify'
  grunt.loadNpmTasks 'grunt-contrib-concat'
  grunt.loadNpmTasks 'grunt-contrib-clean'

  licenseRegexp = /^\!|^@preserve|^@cc_on|\bMIT\b|\bMPL\b|\bGPL\b|\(c\)|License|Copyright/mi

  isLicenseComment = do ->
    _prevCommentLine = 0

    (node, comment) ->
      if licenseRegexp.test(comment.value) or
      comment.line is 1 or
      comment.line is _prevCommentLine + 1

        _prevCommentLine = comment.line
        return true

      _prevCommentLine = 0
      false

  grunt.initConfig
    uglify:
      target:
        options:
          preserveComments: isLicenseComment
        files: [
          expand: true
          flatten: true
          cwd: 'path/to/src'
          src: ["**/*.js"]
          dest: 'tmp/'
        ]

    concat:
      script:
        src: ['tmp/*.js']
        dest: 'path/to/build/app.js'

    clean:
      tmpfiles: ['tmp']

  grunt.registerTask 'default' [
    'uglify'
    'concat'
    'clean'
  ]

一度ひとつひとつのファイルをuglifyタスクで圧縮してから、最終的にconcatタスクで単一のファイルに纏めています。
uglifyタスクの出力を1ファイルだけとして、concatタスクを使用せずに単一ファイルとして書き出すこともできますが、上記の方法の方が、concatタスクのseparatorオプション(参考)により各ライブラリごとに改行されるので、一つのファイルの中で、

ファイル1のライセンス\n
圧縮されたファイル1のスクリプト\n
ファイル2のライセンス\n
圧縮されたファイル2のスクリプト\n
ファイル3のライセンス\n
圧縮されたファイル3のスクリプト\n


というように、ライセンスとスクリプトの1:1の対応が判る形で出力できます。
全てのライセンス文をファイルの先頭や別ファイルに纏める方法と異なり、「どのライセンスがどのスクリプトに適用されているか」という情報を保って圧縮できるのが、この方法の利点です。