こんにちは、LINEでフロントエンド開発を担当しているJunです。この記事はLINE Engineering Blog「夏休みの自由研究 -Summer Homework-」の4日目の記事になります。
この記事ではJavaScript ecosystemの一つ、module bundlerについて書きたいと思います。まずmodule bundlerという概念について簡単に紹介し、その後module bundlerが実際どう動くのかについて自作module bundlerを書いた経験と共に説明します。
Module bundler?
Moduleシステムは大きいコードベースをmoduleと呼ばれる単位に分割できるようにする構造のことです。JavaScriptは長い間moduleシステムを持っていませんでしたが、2009年Node.jsがCommonJSというmoduleシステムを使い始めて以来、JSでもmodule化したコードを書くのが普通になりました。そしてECMAScript 2015でJS言語仕様に新しいmoduleシステム、ES Modulesが含まれるようになり、もはやmodule無しではJSを書きづらくなったとさえいえます。
// ES Modulesの例
import "otherModule";
import { someFunc } from "otherModule";
export const x = 10;
しかし、JS言語仕様とブラウザ実装には時間差があります。まだ全てのブラウザでES Modulesを使えるとは言えません。そのためブラウザでもmoduleを使えるようにするためには、module依存関係を事前に解決し、それをブラウザで読めるようなJSに変換しておく必要があります。その変換をしてくれるのが今回紹介するmodule bundlerです。
Module bundlerがすること
上で説明したとおり、module bundlerはmoduleの依存関係を分析し、それをブラウザが読めるJSに変換します。もっと詳しく説明すると、あるmoduleを読み、それをブラウザでも認識できる普通の関数に変換します。その関数の引数として他のmoduleをimportするための関数と、moduleがexportする値を保存するためのobjectを渡します。
例えば以下のmoduleを、
import { x } from "otherModule";
export const y = x + 10;
以下のような関数に変換させます。
function (require, module, exports) {
var { x } = require("otherModule");
exports.y = x + 10;
}
もう気づいた方もいらっしゃると思いますが、関数の中身はCommonJS形式です。ES ModulesをCommonJSに変換するツールは色々ありますので、そのツールをそのまま使えるメリットがあるためです。
その後、変換されたmodule関数たちをmodule間でお互い参照できるように一つのファイルにまとめます。ここまでがmodule bundlingというプロセスで、module bundlerが行う主な仕事になります。
オレオレmodule bundlerを書いてみよう
What I cannot create, I do not understand
-- Richard Feynman
今までの説明でmodule bundlerの原理は大体理解できたと思います。しかし、それを自ら作れないと完全に理解したことにはなりません。ということで、Tinypackという簡単な自作module bundlerを書いてみました。実はTinypackはTypeScriptのbundlerですが、TypeScriptはJavaScriptのmoduleシステムをそのまま使っていますので、bundlerとしてやることは同じです。これから一つずつ説明していきます。
Semantic analysis
Module変換の前の段階で型チェックを行います。これはTinypackはTypeScript bundlerなためであって、JSのbundlerだと基本必要ありません。型チェックはTypeScript compiler APIを使います。
let errors = ts.getPreEmitDiagnostics(
ts.createProgram([entry], { ... })
)
Convert source files to module objects
TS(又はJS)のソースコードをentryから順番にmoduleに変換していきます。まずentryを処理し、そこで発見される依存関係をどんどん処理していく感じですね。Module変換のたびに各moduleにはユニークなmodule IDを付けます。Entryファイルはmodule IDとして0
を使います。Module IDを付けたentryファイルを変換queueにいれ、変換を始めます。
変換の1番目の作業として、ソースファイルを読み、ASTにパーシングします。これはASTからそのmoduleが持つ依存関係を分析するためです。パーシングのためにもTypeScript compiler APIを使います。
let ast = ts.createSourceFile(file, content, ...)
パーシングされたASTを巡回し、import
宣言をチェックします。各import
宣言のパスが新しく発見された依存関係になります。依存関係それぞれにユニークなmodule IDをつけて、そのパスとmodule IDを依存性mapに入れます。そして後でmoduleに変換されるよう変換queueにも入れておきます。
source.forEachChild(node => {
if (node.kind === ts.SyntaxKind.ImportDeclaration) {
let importDecl = node as ts.ImportDeclaration;
// module specifier should be a string literal
let moduleSpecifier = importDecl.moduleSpecifier.getText(source);
let dep = JSON.parse(moduleSpecifier) as string;
...
queue.push(depPath);
deps.set(dep, depID);
}
});
依存関係の分析が終わったら、TypeScript compiler APIを使いソースファイルをCommonJSにtranspileします。
let transpiled = ts.transpileModule(content, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
...
}
}).outputText;
ここまででmodule IDと依存性mapとtranspileした結果が用意できましたね。これらをobjectとして返すと、一つのファイルのmodule変換が終わります。
return { id, deps, transpiled };
この変換作業を変換queueが空になるまで続けて、依存性グラフ内の全てのファイルを変換します。
Code generation / bundling
上で作ったmodule objectたちを合わせ、ブラウザで実行されるbundleファイルを生成します。作った各module objectの内容をもう一回レビューすると以下のようになります。
- Module ID
- 依存性map (module name - module ID)
- Transpileされたコード
上の情報で各moduleのコードを生成します。例えば以下のような形になります。
var modules = {
0: [ // ← module ID
function(require, module, exports) {
... // ← transpiled code
},
{
"otherModule": 1, // ← dependency map
...
}
],
1: ...
}
「Module bundlerがすること」で紹介した関数と似たようなものができましたね。そして依存性mapから依存関係objectも生成されています。これは関数bodyのなかのmodule pathを実際のmodule IDにマッチングするために使います。
そして生成されたmoduleコードを実行するexecute関数を生成します。
function executeModule(id) {
var mod = modules[id];
var localRequire = function (path) {
return executeModule(mod[1][path]);
};
var module = { exports: {} };
mod[0](localRequire, module, module.exports);
return module.exports;
}
依存関係object(mod[1]
)を使ったrequire関数が確認できます。それを関数body(mod[0]
)に渡すところも実装されていますね。そして関数実行の結果、module.exports
にmoduleがexportした値が入るので、それをそのまま返します。
最後にentry moduleを実行するコードを生成すると終わりですね。
executeModule(0); // 0 is for entry module
Code generationまで終わるとmodule bundlerの基本的な実装が完了します。
更に優秀なbundlerになるために
ここまで実装するとmodule bundlerが動いてコードがブラウザで実行できるようになります。しかしwebpackみたいな優秀なmodule bundlerは上で説明した内容以外にも色々な機能を追加しています。その一部をTinypackにも実装してみました。
同じmoduleの重複を消す
上の実装だと、同じファイルでも違うmoduleからimportすると別module扱いになってしまいます。同じコードが重複してしまう問題もありますが、module contextが共有されないという問題にもなります。
解決のためにファイルのパスを絶対パス化して、その絶対パスとmodule IDをmapに保存します。
let depPath = path.resolve(file, dep);
let depID = fileModuleIdMap.get(depPath);
if (depID === undefined) {
depID = ++moduleID;
fileModuleIdMap.set(depPath, depID);
files.push(depPath);
}
絶対パスでmapからmodule IDをgetし、まだ存在していない時だけ追加処理を行います。
Circular dependencyを解決する
Circular dependencyとは依存性グラフが巡回してしまうことで、cyclic dependencyとも呼びます。例えばx
moduleがy
moduleをimportしていて、y
moduleがまたx
moduleをimportしている状態です。対応処理を入れないとqueueに無限にファイルが増え、変換処理が終わらなくなります。
その解決は実は重複対応と同じです。既に処理が済んでいるmoduleは処理をせず、module IDだけ使うということです。しかし追加条件があります。それは実際のmodule処理の前にそのmoduleのIDが決められなければいけないということです。例えば上のx
、y
の例で説明すると、xの依存性mapを作るためにはその時点でyのmodule IDを知る必要があります。上のコードでmodule IDを先に決めてmapにいれておくのはそのためです。
注意として、この処理は依存性のcircular dependencyを解決するだけであって、実行のcircular dependencyまでは解決できません。
npmからインストールしたmoduleをimportする
Node.jsのパッケージマネージャであるnpmは、フロントエンドJSのためにもよく使われています。というより、もはやnpmを使うユーザ層はフロントエンドの方が多いです。
なのでnpmでインストールしたパッケージをbundleしたい、というのは当然な要求かもしれません。
その対応として、依存性解決にNode.jsで使っているルールを適用する必要があります。
.
で始まると、local module- それ以外はnpmでインストールしたmodule
コードとして書くと以下のようになります。
let depPath: string;
if (dep.startsWith(".")) {
depPath = localModulePath(dep, file);
} else {
depPath = npmModulePath(dep, file);
}
そしてnpm moduleの場合、package.jsonの中のmodule
やmain
フィールドを確認する必要があります。
let packageJSONPath = resolve(pkgRoot, "package.json");
if (isFile(packageJSONPath)) {
let main: string =
require(packageJSONPath).module ||
require(packageJSONPath).main;
if (main) {
return resolve(pkgRoot, main);
}
}
その他、拡張がない時の処理とか、index.jsにfallbackする処理などがlocal/npm共通に入ります。
最後に
もちろん今のmodule bundlerがやることはこれだけではありません。Code splitting、Dead code eliminationなど、色々な機能がたくさん提供されています。しかし、module bundlingという一番基本的な機能を理解するために、自分でmodule bundlerを書いてみるのはすごく面白い経験でした。ぜひ皆さんも書いてみてください。
今回紹介したTinypackはGitHubに公開されていますので、もし良ければ見てください。(そして⭐️もください!)
リポジトリのREADMEに参照した資料のリンクもはっておきました。特にMinipackはTinypackを書いてみようと思ったきっかけでもあります。内容がすごく充実していてコードも分かりやすく書かれていますので、もし興味があればそっちも読んでみてください。
明日はPaul TraylorさんによるPromgenについての記事です。お楽しみに!