Edited at

ソースマップの自作は割とコワくない。

More than 1 year has passed since last update.

「めんどくさそう」先入観で敬遠していたのですが、仕様を見たらそんなでもなかったので書き残しておきます。手っ取り早くコードを見たい方はこちらをどうぞ。

1489821845-94F3558D-871C-46DA-9EE7-560E45EB45E6.png

ユーザとして、ソースマップに接する機会は多いですが、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などのバンドラを使う場合など、元ファイルは複数になる場合があります。なので、sourcessourcesContentは配列です。


なんちゃってコンパイラを作る

超絶簡単な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をどうぞ。


さらに調べるには

このあたりをどうぞ。