もなでぃっく

入出もなど >>= \blog ->

webpack で Chrome 拡張を作って得られた知見

文書校正ツール textlint の Chrome 拡張を作ったのですが、その開発の過程でハマった問題や対策などを記録として残しておきます。

なお、textlint 拡張のソースコードGitHub で公開しています。

github.com

1. textlint Chrome 拡張の仕組み

textlint とは azu さんが作成している文書校正ツールで、Node のパッケージマネージャである npm を通してインストールできるようになっています。中身は当然 Node の JavaScript 製であり、モジュールとして Node で読み込んで利用する事もできるため、textlint 拡張ではそれを利用しています。

Node のコードを Chrome 拡張として動かすにあたり、Chrome 拡張の JavaScript エンジンはブラウザとしての Chrome のものと同等なため、そのままでは動きません。また、ファイルアクセスやインターネットへのアクセスも通常のウェブページでやるように HTML5 の File APIAjax を利用する必要があります。

Node のコードをブラウザ上で動かすには Browserifywebpack といったビルドツールを使う事で対応できます。textlint 拡張では JS だけでなく付属する辞書ファイルなどのアセット類のため webpack を利用しています。

しかし webpack を使えば全て解決するかといえば、残念ながらそうではありません。やはりいくつか問題点が残ります。

この記事では、webpack で Chrome 拡張を作るにあたってハマった問題を一つ一つ紹介していきたいと思います。

2. ファイルアクセスの問題

textlint のルールモジュールのうちいくつかは Node の requirefs.readFile などを利用してファイルを読み込んでいます。こういったコードを webpack を通してブラウザ上で動かすには、一工夫が必要です。

2-1. fs.readFileSync 対策

textlint のルールの一部は外部の YAML ファイルを読み込んで動作します。ソースコードを確認したところ、その読み込みには fs.readFileSync を利用していました。

readFileSyncSync と名が付いているように、非同期ではなく同期で、読み込みが終わるまでブロックする必要があるのですが、Chrome 拡張で利用できるのは File API だけで、その読み込みは全て非同期的です。JavaScript ではブロックというのは基本的にできないので、ここは何とか差し替える必要があります。

textlint 拡張では webpack の string-replace-loader を使って該当部分を別の処理に置き換えています。

{
  test: /node_modules\/prh\/lib\/index\.js/,
  loader: 'string-replace',
  query: {
    search: 'fs.readFileSync',
    replace: `require("${scriptsDir}/shim/prh/fs-mock").readFileSync`
  }
}

string-replace-loader はファイル中の文字列を置き換えてくれる webpack のローダーです。やや強引に fs モジュールのモックに置き換えています。置き換えたモックの内容は以下のようになっています。

import path from "path";
import files from "./files";

export function readFileSync(file, options) {
  /* ...snip... */

  file = path.basename(file);
  if (files[file]) {
    if (encoding) {
      return files[file];
    } else {
      return new Buffer(files[file]);
    }
  } else {
    /* ...snip... */
  }
}

Babel を使って ES6 で書いていますが import は形を変えた require なので、内容は要約すれば ./files.js で export されている辞書からファイル名に対応する内容を取って返しているだけです。files.js の中身は以下の通りです。

export default {
  // for textlint-rule-preset-jtf-style
  "2.1.5.yml": require("raw!textlint-rule-preset-jtf-style/dict/2.1.5.yml"),
  "2.1.6.yml": require("raw!textlint-rule-preset-jtf-style/dict/2.1.6.yml"),
  "2.2.1.yml": require("raw!textlint-rule-preset-jtf-style/dict/2.2.1.yml"),
  "2.2.3.yml": require("raw!textlint-rule-preset-jtf-style/dict/2.2.3.yml"),
};

辞書の中身は YAML ファイルを require したものが入ってます。ここで注意する必要があるのは、require しているパスの前に raw! と付いていることです。これは webpack 特有の記法で、該当のファイルをローダー経由で読み込む事を示しています。ここでは、ファイルの中身をそのまま読み込みたいので raw-loader を利用しています。

webpack ではこのようにして、JavaScript ファイル以外も require で読み込む事ができます。しかも、この require は webpack でビルド際に置き換えられ、該当部分にファイルの内容が直接埋め込まれるようになります。(その分ビルド後のファイルが肥大化するので注意が必要です)

ちなみに、同様の事をやってくれる brfs と、それを適用できる transform-loader の組み合わせがあるのですが、残念ながら brfs は静的なファイルパスにしか対応していないため、今回のケースでは利用できませんでした。パスが直接書かれているようなケースでは、こちらを使うのが良さそうです。

2-2. 巨大な辞書ファイル読み込み

webpack の require を使った置き換えは便利である反面、読み込み先ファイルの内容が全てビルド後の JS ファイルに展開されるため、JS ファイルが肥大化するという問題があります。この問題に対する対処はいくつかあるのですが(後述)、単純に該当ファイルを Ajax で GET して読み込むという手があります。

kuromoji という日本語の形態素解析をするモジュールがあり、その解析には gzip 圧縮した状態で 17MB ほどの巨大な辞書ファイルセットが必要でした。Base64 のローダーなどで埋め込む手もあったのですが、ビルド後のサイズがでかすぎて JS ファイルのサイズとは思えなくなったので、いっその事外部ファイルのままにして Ajax 読み込みする事にしました。

{
  test: /node_modules\/kuromoji\/dist\/node\/TokenizerBuilder\.js/,
  loader: 'string-replace',
  query: {
    search: './loader/NodeDictionaryLoader.js',
    replace: `${scriptsDir}/shim/kuromoji/ChromeDictionaryLoader.js`
  }
},

やはり同様に string-replace-loader を使って、kuromoji 内部で使われている Loader クラスを置き換えています。こちらのモジュールでは単純に XMLHttpRequest でファイルを GET しているだけです。

さらに、当然ながら webpack 側で辞書ファイルをビルド後のディレクトリに含めてくれるわけはないので、自前の gulp タスクでコピーしています。ただ、問題は辞書ファイルが gzip 圧縮されているという点です。

元々の kuromoji の実装では Node の zlib モジュールで展開しているのですが、webpack ではこれを browserify-zlib に置き換えるようになっています。やはり Node 版に比べると制限が多いのか、実用に耐えないレベルで遅くてそのままでは採用が難しかったのです。

そこで textlint 拡張では思い切った事に gzip 圧縮されていた辞書ファイルを、展開した状態で同梱しています。辞書ファイルは展開した状態だと合計 96MB もあり、textlint 拡張は展開した状態だと恐ろしい事に 100MB 近いサイズなのです。

しかし、HDD の容量だって TB が当たり前になりつつある昨今、100MB 程度のサイズならお目こぼししてもらえるサイズかもしれません(してもらえるといいな)。さらに Chrome 拡張は zip 圧縮状態で配布されるので、ダウンロードは 17MB 程度で済みます。(それでも大きいですが)

HDD の容量を食うよりも、実用上のスピードに問題がある方が利用者のストレスが大きいと考え、このような選択を取ることにしました。もし問題があるようなら、辞書を使うルールは任意でインストールできる形式に変更する事も検討しています。

3. require を巡る問題

webpack にはローダーという機能があり、require はより便利になっているのですが、多種多様のモジュールを読み込むにはいくつかの問題があります。

3-1. 1ファイルにバンドルされてしまう問題

webpack を何も考えずに使うと、全てのファイルが1ファイルに収まるようになっています。

textlint はプラグイン方式の文書校正ツールであり、様々な校正ルールがプラグインとして作られていて、ユーザーが ON / OFF を設定で切り替えられるようになっています。これらプラグインも textlint 拡張に同梱しているわけですが、普通にやると全部のファイルが1つに収まってしまい、起動時の読み込みが無駄に大きくなってしまいます。

実はこの問題には webpack 公式の回答があります。そのものズバリ Code splitting という項で紹介されています。

ES6 の import 文はサポートされていないのですが、require を通常とは異なる非同期な形で使うと「分割ポイント」が定義できるのです。公式サイトでは CommonJS 形式と AMD 形式の require が紹介されていますが、textlint 拡張では AMD 形式を利用しています。

require(["module-a", "module-b"], function(a, b) {
    // ...
});

この方式で require されたモジュールは、その依存先も含めて全て別のファイルに収められます。これによって、プラグインそのものと、その依存するモジュールも含めて別ファイルに移動する事ができます。

textlint 拡張では、このようにプラグインごとに require するメソッドを定義しておき、必要に応じて呼び出しています。

loaders: {
  "textlint-rule-alex": (cb) => { require(["textlint-rule-alex"], cb) },
  "textlint-rule-common-misspellings": (cb) => { require(["textlint-rule-common-misspellings"], cb) },
  "textlint-rule-general-novel-style-ja": (cb) => { require(["textlint-rule-general-novel-style-ja"], cb) },
  /*... snip ...*/
},

ビルド後のスクリプト内でこの require がどうなるかというと、なんと <script> タグと JSONP による動的なロードに書き換えられます。

"textlint-rule-alex": function textlintRuleAlex(cb) {
  __webpack_require__.e/* require */(4, function(__webpack_require__) { var __WEBPACK_AMD_REQUIRE_ARRAY__ = [__webpack_require__(21)]; (cb.apply(null, __WEBPACK_AMD_REQUIRE_ARRAY__));}.bind(this));
},
__webpack_require__.e = function requireEnsure(chunkId, callback) {
    /* ... snip ... */
    // start chunk loading
    installedChunks[chunkId] = [callback];
    var head = document.getElementsByTagName('head')[0];
    var script = document.createElement('script');
    script.type = 'text/javascript';
    script.charset = 'utf-8';
    script.async = true;

    script.src = __webpack_require__.p + "" + chunkId + "." + ({/*... snip ...*/}[chunkId]||chunkId) + ".js";
    head.appendChild(script);
    /* ... snip ... */
};

つまり、この形式にするだけで何も考えずに動的ロードで分割できてしまうわけですね。webpack を使ってみて一番驚いたのはこの点でした。

分割されたファイルは特に指定していない限りは 5.5.js のような数字のファイル名になります。

3-2. 複数のページでページごとに使うモジュールが違う問題

Chrome 拡張では、Background と呼ばれる見えない裏で動き続けるページ、ツールバーのアイコンをクリックする事で表示されるページ、オプション画面のページ、Content Script と呼ばれる閲覧中のページへ注入されるスクリプトと、複数スクリプト実行画面があります。

それぞれの画面で利用するモジュールが違うため、ページごとにスクリプトファイルを分けたいです。特に Content Script は Chrome で閲覧中ページが変更する度に実行されるため、読み込まれるモジュール類は極力減らしたいです。かといって jQuery や lodash などの便利なライブラリが使えないのも困ります。

webpack にはやはりこの問題にも公式の回答があり Multiple Entry Points という項で紹介されています。

エントリーポイント、つまり起点となる箇所を複数指定でき、それぞれ依存するモジュールごと詰め込んでくれるのです。公式サイトで例示されている設定は以下の通りです。

{
    entry: {
        a: "./a",
        b: "./b",
        c: ["./c", "./d"]
    },
    output: {
        path: path.join(__dirname, "dist"),
        filename: "[name].entry.js"
    }
}

この場合、a b c の3つのエントリーポイントがあり、output の設定に従えば dist ディレクトリの中に a.entry.js b.entry.js c.entry.js の3ファイルが生成されます。

しかし、ここで1つの問題が浮上します。先ほど触れた jQuery や lodash のように、複数のページ間で共有したいライブラリがあるのです。この設定では、3ファイル内それぞれに jQuery と lodash のコードが含まれるため、無駄が大きいです。

この問題は CommonsChunkPlugin というプラグインを利用する事で解決できます。このプラグインは、複数のエントリーポイントで共通している Chunk (スクリプトファイル) を1つの共通ファイルにまとめてくれるというものです。

plugins: [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: Infinity
  }),
],

このように設定ファイルの plugins に追加します。name は後述する設定の共通ファイルの名前、minChunks は最小でいくつのエントリーポイントで共通していれば共通ファイルに移動するかという設定で、ここでは無制限にしています。

textlint 拡張で利用しているエントリーポイントの設定は以下のとおりです。

entry: {
  background: `${scriptsDir}/background.js`,
  contentscript: `${scriptsDir}/contentscript.js`,
  options: `${scriptsDir}/options.js`,
  popup: `${scriptsDir}/popup.js`,
  sandbox: `${scriptsDir}/sandbox.js`,

  vendor: ['jquery', 'lodash'],

  /* ... snip ... */
},

vendor というのが先ほど設定した共通ファイルの名前で、そこには jquerylodash が含まれているのがわかります。これにより vendor.js が生成されて、その中には jQuery と lodash が含まれるようになりました。

あとはこの共通ファイルを各ページでエントリーポイントのスクリプトファイルよりも先に読みこめば完了です。

<script src="../scripts/vendor.js"></script>
<script src="../scripts/background.js"></script>
<script src="../scripts/vendor.js"></script>
<script src="../scripts/sandbox.js"></script>

webpack は柔軟に色々できて便利ですね。

4. evalnew Function の問題

これは直接 webpack に関係があるわけではないですが、Chrome 拡張を作る上でハマったので書いておきます。

textlint のルールの中には lodashunderscore などのテンプレート機能を使っているものがありました。

var compiled = _.template('hello <%= user %>!');
compiled({ 'user': 'fred' });
// → 'hello fred!'

ここで問題となるのは、こういったテンプレートは大体が new Functioneval などで評価される動的コード生成で作られていることです。

実は Chrome 拡張にはセキュリティ制限があり、Chrome 拡張のコンテキスト、つまり chrome.storage などの chrome.* にアクセスできる状態での new Functioneval によるコード評価は禁止されています。

これに対応するにはサンドボックスと呼ばれる <iframe> 内のページを新たに用意して、その中で eval 等を行なうようにする必要があります。

Using eval in Chrome Extensions. Safely.

サンドボックスと background などの Chrome 拡張の JavaScript コンテキストは切り離されているので、サンドボックス内で chrome.* にはアクセスできません。また、background とのやりとりは全て iframe.contentWindow.postMessage を介して行なう必要があります。

textlint 拡張では textlint コアを含め、ルールやプラグインなど全てこのサンドボックス内で実行しています。絶対にありえない仮定ですが、仮に textlint のルールなどに悪意のある攻撃コードなどが紛れ込んでも安全というわけです。

webpack とは関係ありませんが、Node のライブラリを使うと結構この問題に行き当たるので注意が必要ということで書きました。あとからサンドボックスにしようとすると、結構面倒です。(実際にハマりました)

5. おわりに

だいぶ長い記事となってしまいましたが、以上になります。他にも色々と書きたい事があったのですが、webpack と関係ないものも多いので別記事にさせて頂きます。

ここまで読んで頂き、ありがとうございました。もし webpack を利用したり、Chrome 拡張を作る機会がありましたら、参考にして頂ければ幸いです。

それではまた!