【超シンプル】 バンドルツール作ってみた
- カテゴリ:
- JavaScript
- つくった
- コメント数:
- Comments: 0
◆ 解析はしない (構文エラーやモジュールのパス間違いなどは実行時にわかる)
◆ require 関数と module/exports
◆ コードは 50 行弱
◆ require 関数と module/exports
◆ コードは 50 行弱
JavaScript ファイルをまとめたいのですが諸事情で webpack や parcel みたいな高機能なものは使いたくないときがあります
ツール自体も重くインストールも面倒ですし
これだけなら自分で作れそうなので作ってみました
50 行も無い短いものです
解析は一切行いません
構文エラーやインポートするモジュールのパスにファイルがあるかも実行するまでわかりません
解析をしない以上 ES2015 の EsModules 構文を使うのは無理そうなので昔ながらの Node.js の require 関数と module/exports を使います
バンドル対象のファイルも自動で判断できないのでバンドル時に使う側で指定します
指定したものが全部含まれるので 指定の仕方によっては使われないコードもバンドルファイルに含まれる可能性はあります
バンドル対象のファイルを元に次のようなコードを作ります
"file:///tmp/file.js" みたいな URL 形式のパスがキーになります
値の方は require/module/exports を引数とした関数で 関数の本体がファイルの中身になります
これを渡された全部のファイル分まとめたオブジェクトを作ります
引数として渡される require 関数が実行時に処理されるメイン部分です
ファイルの中身に require をする処理があると その引数を元に sources から関数を取り出して実行します
一度実行したら cache に結果を保存して 2 回目以降は cache の値を返します
こうすることで同じファイルが 2 度実行されなくしています
エントリポイントになるファイルは バンドル対象のファイルの中の一番最初に渡されたファイルです
そのファイルを自動的に require して実行する処理を追加します
これらの処理を書いたコードをファイルに書き出せばバンドルファイルの出来上がりです
それぞれの中身はこうなります
[a.js]
[b.js]
[d/x.js]
[y.js]
require の順をまとめるとこうなります
バンドルしてみると
フォルダが深くなったり浅くなったりしても問題なしです
b が一度しか表示されていないので 2 回実行もされていません
目的の動きはできています
このサンプルをバンドルして出力された JavaScript ファイルはこうなります
もちろんこのファイルはブラウザでも動きます
ところで b.js の最後の行をコメントアウトしていますが これを実行すると a.js が b.js を必要としていて b.js が a.js を必要としていることになって循環参照になります
循環参照があるとエラーになるようにしているので実行時エラーになります
ツール自体も重くインストールも面倒ですし
つくった
必要なのは本当に単純に JavaScript ファイルをまとめるだけで十分ですこれだけなら自分で作れそうなので作ってみました
50 行も無い短いものです
const fs = require("fs")
const path = require("path")
const jsfiles = [...new Set(process.argv.slice(2))]
if(jsfiles.length === 0){
throw new Error("no files")
} else {
console.log(jsfiles)
}
const modules = []
for(const file of jsfiles){
const abspath = path.resolve(file)
const content = fs.readFileSync(abspath).toString()
const key = new URL(abspath, "file:///")
modules.push(`"${key}": ({require, module, exports}) => {${content}},`)
}
const entrypoint = new URL(path.resolve(jsfiles[0]), "file:///")
const output = `
const sources = {
${modules.join("\n")}
}
const cache = {}
const sym = Symbol()
const createRequire = file => reqpath => {
const key = new URL(reqpath, file)
if(key in cache){
if(cache[key] === sym) throw new Error("Cyclic require is detected.")
return cache[key]
}
cache[key] = sym
const exports = {}
const module = {exports}
sources[key]({
require: createRequire(key),
module,
exports,
})
cache[key] = module.exports
return module.exports
}
const entrypoint = "${entrypoint}"
createRequire("file:///")(entrypoint)
`
fs.writeFileSync("./output.js", output)
解析は一切行いません
構文エラーやインポートするモジュールのパスにファイルがあるかも実行するまでわかりません
解析をしない以上 ES2015 の EsModules 構文を使うのは無理そうなので昔ながらの Node.js の require 関数と module/exports を使います
バンドル対象のファイルも自動で判断できないのでバンドル時に使う側で指定します
指定したものが全部含まれるので 指定の仕方によっては使われないコードもバンドルファイルに含まれる可能性はあります
仕組み
仕組みも簡単なものですバンドル対象のファイルを元に次のようなコードを作ります
const sources = {
"ファイルURL": ({require, module, exports}) => {/*ファイルの中身*/},
}
"file:///tmp/file.js" みたいな URL 形式のパスがキーになります
値の方は require/module/exports を引数とした関数で 関数の本体がファイルの中身になります
これを渡された全部のファイル分まとめたオブジェクトを作ります
引数として渡される require 関数が実行時に処理されるメイン部分です
ファイルの中身に require をする処理があると その引数を元に sources から関数を取り出して実行します
一度実行したら cache に結果を保存して 2 回目以降は cache の値を返します
こうすることで同じファイルが 2 度実行されなくしています
エントリポイントになるファイルは バンドル対象のファイルの中の一番最初に渡されたファイルです
そのファイルを自動的に require して実行する処理を追加します
これらの処理を書いたコードをファイルに書き出せばバンドルファイルの出来上がりです
サンプル
次のようなフォルダ構成でファイルを置きますsrc/
a.js
b.js
d/
x.js
y.js
それぞれの中身はこうなります
[a.js]
console.log("start")
const b = require("b.js")
const b2 = require("b.js")
console.log(b === b2)
console.log(b.m(2))
require("d/x.js")
[b.js]
module.exports = {m(x){return x + 1}}
console.log("b")
// require("a.js")
[d/x.js]
require("../y.js")
[y.js]
console.log("y")
require の順をまとめるとこうなります
a.js
→ b.js
→ d/x.js
→ y.js
バンドルしてみると
user@localhost /t/bdl> node bundle.js src/a.js src/**.js
[ 'src/a.js', 'src/b.js', 'src/d/x.js', 'src/y.js' ]
user@localhost /t/bdl> node output.js
start
b
true
3
y
フォルダが深くなったり浅くなったりしても問題なしです
b が一度しか表示されていないので 2 回実行もされていません
目的の動きはできています
このサンプルをバンドルして出力された JavaScript ファイルはこうなります
const sources = {
"file:///tmp/bdl/src/a.js": ({require, module, exports}) => {console.log("start")
const b = require("b.js")
const b2 = require("b.js")
console.log(b === b2)
console.log(b.m(2))
require("d/x.js")
},
"file:///tmp/bdl/src/b.js": ({require, module, exports}) => {module.exports = {m(x){return x + 1}}
console.log("b")
// require("a.js")
},
"file:///tmp/bdl/src/d/x.js": ({require, module, exports}) => {require("../y.js")
},
"file:///tmp/bdl/src/y.js": ({require, module, exports}) => {console.log("y")},
}
const cache = {}
const sym = Symbol()
const createRequire = file => reqpath => {
const key = new URL(reqpath, file)
if(key in cache){
if(cache[key] === sym) throw new Error("Cyclic require is detected.")
return cache[key]
}
cache[key] = sym
const exports = {}
const module = {exports}
sources[key]({
require: createRequire(key),
module,
exports,
})
cache[key] = module.exports
return module.exports
}
const entrypoint = "file:///tmp/bdl/src/a.js"
createRequire("file:///")(entrypoint)
もちろんこのファイルはブラウザでも動きます
ところで b.js の最後の行をコメントアウトしていますが これを実行すると a.js が b.js を必要としていて b.js が a.js を必要としていることになって循環参照になります
循環参照があるとエラーになるようにしているので実行時エラーになります