LINE Engineering
Blog

Write you a webpack for great good

Jun 2018.08.06

LINEでフロントエンド開発を担当しています。プログラミング言語に興味を持っています。

こんにちは、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が決められなければいけないということです。例えば上のxyの例で説明すると、xの依存性mapを作るためにはその時点でyのmodule IDを知る必要があります。上のコードでmodule IDを先に決めてmapにいれておくのはそのためです。

注意として、この処理は依存性のcircular dependencyを解決するだけであって、実行のcircular dependencyまでは解決できません。

npmからインストールしたmoduleをimportする

Node.jsのパッケージマネージャであるnpmは、フロントエンドJSのためにもよく使われています。というより、もはやnpmを使うユーザ層はフロントエンドの方が多いです。

The npm Registry now distributes more front-end code than server-side JavaScript and is used to distribute every popular web development framework.

なので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の中のmodulemainフィールドを確認する必要があります。

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 splittingDead code eliminationなど、色々な機能がたくさん提供されています。しかし、module bundlingという一番基本的な機能を理解するために、自分でmodule bundlerを書いてみるのはすごく面白い経験でした。ぜひ皆さんも書いてみてください。

今回紹介したTinypackはGitHubに公開されていますので、もし良ければ見てください。(そして⭐️もください!)

リポジトリのREADMEに参照した資料のリンクもはっておきました。特にMinipackはTinypackを書いてみようと思ったきっかけでもあります。内容がすごく充実していてコードも分かりやすく書かれていますので、もし興味があればそっちも読んでみてください。

明日はPaul TraylorさんによるPromgenについての記事です。お楽しみに!

JavaScript module bundler summer homework

Jun 2018.08.06

LINEでフロントエンド開発を担当しています。プログラミング言語に興味を持っています。

Add this entry to Hatena bookmark

リストへ戻る