スキップしてメイン コンテンツに移動

Closure Compiler による最適化を更に押し進める方法

$.css.hooks から $.css.patches にプロパティ名を変更。推敲と追記。(2024/04/16)

クロスブラウザを切り口に、アプリケーションが使用しないコードをコンパイル時に除去する方法を示します。

やや手間が増えますが、使用しないコードは確実に除去されるので、クロスブラウザ対策をモリモリに盛り込んで、ソースコードをブラウザの(黒)歴史博物館にしちゃいましょう。

ファイルを細かく分ける

この為に、tagName 毎にファイルを分けることにします。ファイル数は(爆発的に)増えますが、関心毎にファイルを分離できるメリットもあります。例えば、IFRAME はご存じのように黒魔術(patch)の宝庫ですね。

また patch の無さそうな DIV や SPAN は一つのファイルにまとめてしまって良いでしょう。とはいえアプリケーション側は将来の変化に強くするために、愚直に dom.tagName.DIV を使うこととします。例えば次のようなコードになります。

goog.require( 'dom.tagName.DIV' );

var div = dom.createElement( dom.tagName.DIV );

この記事では tagName ではなく、CSS のプロパティ毎にファイルを分割したサンプルを示し、コンパイル結果を比較してみます。パッと思いつくクロスブラウザの例が opacity だった由です。

opacity を設定するだけのサンプルアプリ

プロジェクトのファイル構成

+- src/
|  |
|  +- main1.js
|  +- main2.js
|  |
|  +- $/
|  |  |
|  |  +- $.js
|  |  +- $.css.patches.js
|  |  +- $.css.opacity.js
|  |
|  *- closure-primitives/
|     |
|     +- base.js <- closure-tools からコピーして配置
|
+- dist/
|
+- package.json
+- gulpfile.js

src/main1.js(dist/app1.js のソース)

次の通り、jQuery っぽい DOM 操作ライブラリ($)を使うアプリがあるとします。

goog.provide( 'app1' );

goog.require( '$' );

goog.scope(
  function(){
    var $elm = new $( 'app' );

    $elm[ 'setStyle' ]( 'opacity', 0.5 );
  }
);

src/main2.js(dist/app2.js のソース)

以上(main1.js)を一般的な書き方とするならば、今回検証する書き方は次になります。

goog.provide( 'app2' );

goog.require( '$' );
goog.require( '$.css.opacity' ); // <-

goog.scope(
  function(){
    var $elm = new $( 'app' );

    $elm[ 'setStyle' ]( $.css.opacity, 0.5 ); // <-
  }
);

goog.require( '$.css.opacity' ); が追加されていて、'opacity' とせずに、$.css.opacity を使用しています。$.css.opacity は文字列定数とし、多くの場合で == 'opacity' です。

$/ のコード

$ 以下の実装を次に示します。

  1. src / $ / $.js
  2. src / $ / $.css.patches.js
  3. src / $ / $.css.opacity.js

src / $ / $.js

setStyle というメソッドだけを持つ DOM I/F 抽象化クラスです。

メソッドの追加にドット記法ではなくブラケット記法(角括弧)を使うのは、本記事用にアグレッシブな最適化を一部で避けるためです。ドット記法に書き換えると、より最適化されて原型を留めないファイルを出力します。これは本解説記事には適しません。

goog.provide( '$' );
goog.provide( '$.css' );

/**
 * @constructor
 * @param {!Element|string} elmOrID 
 */
var $ = function( elmOrID ){
  if( typeof elmOrID === 'string' ){
    this.element = document.getElementById( elmOrID );
  } else {
    this.element = elmOrID;
  };
};

/**
 * @param {string} prop 
 * @param {string|number} value 
 */
$.prototype[ 'setStyle' ] = function( prop, value ){
  if( !prop ) return;

  if( $.css.patches ) var patch = $.css.patches[ prop ];

  if( patch ){
    patch( this.element, value );
  } else {
    this.element.style[ prop ] = '' + value;
  };
};

src / $ / $.css.patches.js

setStyle について、環境毎に異なる処理を格納する patches$.css に生やします。それだけですが最適化の為にファイルにします。

なお、css, patches といったオブジェクトは goog.provide() コール内で生成されていると考えてください。開発者が $.css = {}, $.css.patches = {} と書くことはありません。

goog.provide( '$.css.patches' );

goog.require( '$.css' );

/**
 * @package
 * @const
 */
var p_style = document.body.style;

src / $ / $.css.opacity.js

定数 $.css.opacity は実行時にブラウザ毎に設定されます。opacity に非対応の環境では if($.css.opacity) で処理を切り替える使い方もできますね。

更に filter で patch する IE8 以下の場合 $.css.patches.opacity に関数を登録します。

goog.provide( '$.css.opacity' );

goog.require( '$.css.patches' );

/** @const {string} */
$.css.opacity = void 0 !== p_style.opacity || document.documentMode < 9
                  ? 'opacity'
              : void 0 !== p_style.MozOpacity
                  ? 'MozOpacity'
              : void 0 !== p_style[ '-khtml-opacity' ]
                  ? '-khtml-opacity'
                  : '';

if( document.documentMode < 9 ){
  /**
   * IE ~8
   * @const {!function(!Element, (string|number))}
   */
  $.css.patches[ $.css.opacity ] = function( element, value ){
    element.style.filter = 'alpha(opacity=' + (+ value) * 100 + ')';
  };
};

ビルド用ファイル

次のコマンドで dist/ 以下に、2パターンのアプリを書き出します。

npm install
gulp app

package.json

{
  "devDependencies": {
    "google-closure-compiler": "^20240317.0.0",
    "gulp": "^4.0.2"
  }
}

gulpfile.js


const gulp            = require('gulp'),
      ClosureCompiler = require('google-closure-compiler').gulp(),

gulp.task('app', gulp.series(
  function () {
    return gulp
      .src(
        ['./src/closure-primitives/base.js', './src/**/*.js']
      ).pipe(
        ClosureCompiler(
          {
            dependency_mode   : 'PRUNE',
            entry_point       : 'goog:app1',
            compilation_level : 'ADVANCED',
            formatting        : 'PRETTY_PRINT',
            warning_level     : 'VERBOSE',
            language_in       : 'ECMASCRIPT3',
            language_out      : 'ECMASCRIPT3',
            js_output_file    : 'app1.js'
          }
        )
      ).pipe(
        gulp.dest('dist')
      );
  },
  function () {
    return gulp
      .src(
        ['./src/closure-primitives/base.js', './src/**/*.js']
      ).pipe(
        ClosureCompiler(
          {
            dependency_mode   : 'PRUNE',
            entry_point       : 'goog:app2',
            compilation_level : 'ADVANCED',
            formatting        : 'PRETTY_PRINT',
            warning_level     : 'VERBOSE',
            language_in       : 'ECMASCRIPT3',
            language_out      : 'ECMASCRIPT3',
            js_output_file    : 'app2.js'
          }
        )
      ).pipe(
        gulp.dest('dist')
      );
  }
));

コンパイル結果を確認する

dist / app2.js

IE8 以下用の patch も組み込まれているのが分かります。

7行で e(== $.css.patches) の存在確認をしているのが冗長ですが、残念ながら、これを最適化する方法は見つかりませんでした。

function d() {
  this.g = document.getElementById("app");
}
d.prototype.setStyle = function(a, b) {
  if (a) {
    var c;
    e && (c = e[a]);
    c ? c(this.g, b) : this.g.style[a] = "" + b;
  }
};
var e = {}, f = document.body.style;
var g = void 0 !== f.opacity || 9 > document.documentMode ? "opacity" : void 0 !== f.MozOpacity ? "MozOpacity" : void 0 !== f["-khtml-opacity"] ? "-khtml-opacity" : "";
9 > document.documentMode && (e[g] = function(a, b) {
  a.style.filter = "alpha(opacity=" + 100 * +b + ")";
});
(new d()).setStyle(g, 0.5);

dist / app1.js goog.require() しない場合のコンパイル結果を確認する

このコードは、opacity のクロスブラウザ対応を開発者が失念したものではなく、結果的に patch が不要な CSS プロパティだけで実現したアプリケーションと考えてください。

opacity の patch 関連が組み込まれず小さくなっていますが、c.h(= $.css.patches)undefined にも拘わらず、7~9行に patch 用の処理が残ってしまうのが気がかりです。しかし以降で、黒魔術的なコードを追加して解決しますのでご安心を。

var c = {};
function d() {
  this.g = document.getElementById("app");
}
d.prototype.setStyle = function(a, e) {
  if (a) {
    var b;
    c.h && (b = c.h[a]);
    b ? b(this.g, e) : this.g.style[a] = "" + e;
  }
};
(new d()).setStyle("opacity", 0.5);

$.css.patches 周りの更なる最適化をするには?

src / $ / $.js へ追記する

$ の宣言の後に $.css.patches に Falthy な値を代入してみました。@suppress は Closure Compiler の警告から解決できないものを非表示にする指示です。

var $ = function( elmOrID ){...};

/** @suppress {checkTypes} */
$.css.patches = 0;

それではコンパイル結果を見ていきます。

dist / app1.js($.js に追記してコンパイル)

狙い通り setStyle メソッドが最適化されています!

function a() {
  this.g = document.getElementById("app");
}
a.prototype.setStyle = function(b, c) {
  b && (this.g.style[b] = "" + c);
};
(new a()).setStyle("opacity", 0.5);

dist / app2.js($.js に追記してコンパイル)

4行(var e = 0;)と12行(e = {};)で冗長になってしまったのが気になりますが、上の結果が得られたので、この程度はいったん良しとします。

function b() {
  this.g = document.getElementById("app");
}
var e = 0;
b.prototype.setStyle = function(a, c) {
  if (a) {
    var d;
    e && (d = e[a]);
    d ? d(this.g, c) : this.g.style[a] = "" + c;
  }
};
e = {};
var f = document.body.style;
var g = void 0 !== f.opacity || 9 > document.documentMode ? "opacity" : void 0 !== f.MozOpacity ? "MozOpacity" : void 0 !== f["-khtml-opacity"] ? "-khtml-opacity" : "";
9 > document.documentMode && (e[g] = function(a, c) {
  a.style.filter = "alpha(opacity=" + 100 * +c + ")";
});
(new b()).setStyle(g, 0.5);

さいごに

正に Closure Library が goog.require('goog.dom.tagName.DIV') で取得した文字列定数で goog.dom.createDOM(goog.dom.tagName.DIV) する作法です。

ですので、てっきり Closure Library には、アプリケーションが使用する tagName の patch だけを組み込む仕組みがあるのかと思ったのですが、詳しく見ていくとそうでは無いようです。


今回の手口を、tagName、属性(属性名と列挙型の値)、スタイル(プロパティ名と列挙型の値)、イベントなどで実施すれば、createElement, setAttr, setStyle, on, fire 等のメソッド内からクロスブラウザ用のコードの多くを外に出し、使用するアプリケーションにだけ組み込むことが出来ます。

ということは、アプリケーションの無用な肥大化を気にせずに(適正には大きくなります)、クロスブラウザ対応のためのコードをモリモリとライブラリに追加していくことができます。

加えて、今回のように各 patch の実行時の有効化から、browser-compat-data 等を元にコンパイル(ビルド)時に死活できれば、いよいよ、ブラウザエンジンとバージョン別コンパイル(ビルド)が視野に入ります。