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

  • 35
    いいね
  • 0
    コメント

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

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をどうぞ。

さらに調べるには

このあたりをどうぞ。