LoginSignup
104
78

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-03-18

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

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

さらに調べるには

このあたりをどうぞ。

104
78
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
104
78