「めんどくさそう」先入観で敬遠していたのですが、仕様を見たらそんなでもなかったので書き残しておきます。手っ取り早くコードを見たい方はこちらをどうぞ。
ユーザとして、ソースマップに接する機会は多いですが、AltJSを作るとかでもない限り、あまり提供側に回ることはないかもしれません。私自身、次のような疑問というか誤解を抱えたまま、深入りしなかったクチです...。
- 誤解1: ソースマップを作るにはASTを作る必要がある → 実際はもっとシンプル
-
誤解2: なんかすごいエンコード(
AAAA;AACA;AACA;AACA...
)がされてる → ただのBase64
よく見かけるソースマップの説明記事(というほど、記事自体ないけれど)では、「ソースを構文解析してASTを構築して...」という手順が出てくるのですが、 ソースマップにASTは必要ありません。
もちろん、実際にAltJSを作るならASTの作成は必須なのですが、ソースマップとは独立した話です。逆に、ソースマップでできるのはシンプルで、 次のふたつをマップすることだけ です。
- コンパイル前のコードの位置: ○○行・○○列
- コンパイル後のコードの位置: ○○行・○○列
本稿では、コードのコンパイル中にどのようにマッピングデータを構築するか、簡単に説明を試みたいと思います。
ソースマップってこういうやつ
もし、ソースマップのファイルを開いたことがなければ、ぜひ開いてみてください。.map
の拡張子で終わるJSONのファイルです。BabelやTypeScript、CoffeeScriptを利用していれば、途中で必ず生成しているはず...
以下、例。
{
"version": 3,
"sources": ["../fixture/HELLO.js"],
"names": [],
"mappings": "AAAA;AACA;AACA;AACA",
"file": "hello.js",
"sourcesContent": ["CONST NAME = \"WORLD\"\nCONST MESSAGE = `HELLO ${NAME}!`\nCONSOLE.LOG(MESSAGE)\n"]
}
ざっくり、次の3つの情報が記載されています。
- ファイル情報: 元ファイル(
sources
)、生成ファイル(file
) - マッピング情報 (
mappings
) - 元ソース (
sourcesContent
)
マッピング情報のところが「初見殺し」ですが、コードの位置情報をBase64(正確には可変長な「Base64 VLQ」)で保持しているだけ(マップファイルの容量圧縮のため)。詳しくはこちら。
メモ: ちなみに、version
はファイル自体のバージョンではなく、 ソースマップの仕様のバージョン を表しています。基本「3」を指定しておけばOK。
メモ: WebPackやRollupなどのバンドラを使う場合など、元ファイルは複数になる場合があります。なので、sources
とsourcesContent
は配列です。
なんちゃってコンパイラを作る
超絶簡単なAltJSを考えます。例えばこんなの。
CONST NAME = "WORLD"
CONST MESSAGE = `HELLO ${NAME}!`
CONSOLE.LOG(MESSAGE)
これをコンパイル(トランスパイル)すると、こうなります。
const name = "world"
const message = `hello ${name}!`
console.log(message)
そうですね、お察しの通り.toLowerCase()
しているだけです。
コンパイルしながらマッピング
コンパイルした後に、独立してマッピングするのは非効率かつ不可能な場合も多いです。パースして変換する際、同時にソースマップも付けていくのが楽です。
ソースマップなしであれば、先ほどのコンパイラ(?)は1行で実装可能です。
function compile (srcCode) {
return srcCode.toLowerCase()
}
一応、行ごとに処理するように書くならこうですね。
function compile (srcCode) {
return srcCode
.split('\n')
.map(line => line.toLowerCase())
.join('\n')
}
ここに、マッピングの処理を加えるとこんな感じに。詳しくは次項。
function compile (srcFile, srcCode, destFileName) {
// 出力ファイル名の指定
destFileName = destFileName || path.basename(srcFile).toLowerCase()
// SourceMapGeneratorの初期化
const gen = new SourceMapGenerator({ file: destFileName })
gen.setSourceContent(srcFile, srcCode)
// コンパイルしながらマッピング
const code = srcCode
.split('\n')
.map((line, idx) => {
// 一行ごと対応付け
gen.addMapping({
source: srcFile,
original: { line: idx + 1, column: 0 },
generated: { line: idx + 1, column: 0 }
})
// コンパイル...というか小文字に変換するだけ
return line.toLowerCase()
})
.join('\n')
// ソースマップを付属させるため、文字列ではなくオブジェクトで返す
return {
code: code + `\n//# sourceMappingURL=${destFileName}.map`,
map: gen.toString()
}
}
SourceMapGenerator
幸い、ソースマップを操作するためのライブラリがMozillaから提供されています。
いくつかクラスがあるのですが、ここではSourceMapGeneratorだけ使います。
マッピングデータは、普通に作ると重くなってしまうのでAAAA;AACA;AACA;AACA...
みたいな形にエンコードします。SourceMapGeneratorは、このあたりのことを自動でやってくれます。
インスタンスメソッドにaddMapping()
というそのものずばりのものがあり、元コード(original)と生成コード(genrated)の位置を対応づけます。たとえば、次のように書くと、3行目と3行目(のそれぞれ0列)がマップされます。
gen.addMapping({
source: 'hello.js',
original: { line: 3, column: 0 },
generated: { line: 3, column: 0 }
})
このマッピングが、1行にひとつずつ以上提供されていれば、デバッグの際に行を追うことができるわけです。
試してみる
実際にプログラムを動かしてみるのが、やはりわかりやすいと思います。GitHubにサンプルコードを用意したので、こちらをクローンしましょう。
以下は、デスクトップに展開して試す場合です。
$ cd ~/Desktop/
$ git clone https://github.com/cognitom/CAPITALS.git
$ cd CAPITALS
CLIから動かしてみます。
$ ./cli.js test/fixture/HELLO.js ../hello.js
デスクトップに次のファイルができていれば成功です。
-
hello.js
: 小文字に変換されたスクリプト -
hello.js.map
: ソースマップ
HTMLファイルから読み込んでブラウザで表示すると、本稿冒頭のスクリーンショットのようになります。正しく、元ファイル上でステップ実行できました。
コードの詳細は、index.jsをどうぞ。
さらに調べるには
このあたりをどうぞ。
- ソースマップ仕様 (v3)
- html5rockの記事「Introduction to JavaScript Source Maps」