11月4日、Polar Signalsが「The Inner Workings of JavaScript Source Maps」と題した記事を公開した。この記事では、JavaScriptのソースマップ(Source Map)がどのようにしてミニファイド済みのコードと元のソースコードを対応づけているのか、その内部構造や仕組みについて詳しく紹介されている。以下に、その内容を紹介する。
ソースマップとは何か
ソースマップは、変換・圧縮後のJavaScriptファイル内の位置を、元のソースコード上の位置へ対応づけるための仕組みである。ブラウザのDevToolsでミニファイドされたコードをデバッグするときに、TypeScriptなどの元のソースと対応した変数名やフォーマットを見られるのはソースマップのおかげである。
たとえば、次のようなケースがある。
Production Bundle
bundle.min.js:1:27698
Original Source Location
src/index.ts:73:16
このように、bundle.min.jsの1行目・27698文字目で発生したエラーが、ソースマップを通じてsrc/index.tsの73行16文字目に対応していると判明する。
TypeScriptビルドパイプラインの各段階
記事では、TypeScriptのビルドパイプラインにおいてソースマップがどのように機能するかを3段階で説明している。
- Transpilation(トランスパイル): TypeScript → JavaScript
- Bundling(バンドル): 複数モジュールを単一ファイルに結合
- Minification(圧縮): ファイルサイズを削減するためのコード圧縮
これらのステップごとにソースマップは「元のコードとの対応関係」を維持していく。
Stage 0: 元のTypeScriptソース
例として、Fibonacci数列を計算する簡単なTypeScriptコードが示されている。
export function add(a: number, b: number): number {
return a + b;
}
import { add } from './add';
export function computeFibonacci(n: number): number {
if (n <= 1) return n;
return add(computeFibonacci(n - 1), computeFibonacci(n - 2));
}
import { computeFibonacci } from './fibonacci';
const result = computeFibonacci(10);
console.log(`Fibonacci(10) = ${result}`);
この段階ではまだソースマップは存在しない。
ソースマップのJSON構造
ソースマップファイル(拡張子 .js.map)はJSON形式で表現され、次のような構造を持つ。
{
"version": 3,
"file": "add.js",
"sourceRoot": "",
"sources": ["add.ts"],
"names": ["add", "a", "b"],
"mappings": "AAAA,OAAO,SAAS,IAAI,CAAC,EAAE;EACrB,OAAO,IAAI;AACb"
}
各フィールドの意味は以下のとおりである。
version: ソースマップのバージョン。現在は常に3。file: このマップが対応する生成済みファイル名。sourceRoot: すべてのソースURLに共通するプレフィックス。sources: 元のソースファイルパスの配列。sourcesContent: 実際のソースコードを含む配列(オプション)。names: 元の識別子(変数名・関数名など)のリスト。mappings: 圧縮されたマッピングデータ。ここがソースマップの中核部分であり、VLQエンコード方式を使っている。
VLQエンコードによる位置情報の圧縮
mappingsフィールドは、生成後のJavaScriptファイル内の各トークンと、元のソースコード上の位置との対応関係を保持している。しかし、これを単純にJSON配列で表すと膨大になるため、VLQ(Variable Length Quantity)エンコードという可変長のエンコード方式で圧縮されている。
マッピング構造
mappingsは、カンマとセミコロンで区切られた「セグメント列」として表現される。
"segment,segment,segment;segment,segment;segment"
- カンマ(,):同一行内の次の位置を示す。
- セミコロン(;):改行を示す。これにより「どの行の対応か」を判別できる。
セグメントには次の3種類の形式がある。
[generatedColumn]— ソースに対応しない列(例:webpack生成コード)[generatedColumn, sourceFileIndex, sourceLine, sourceColumn]— 通常の対応関係[generatedColumn, sourceFileIndex, sourceLine, sourceColumn, nameIndex]— 名前付き要素の対応(変数・関数名など)
セミコロンの位置で行番号を管理しているため、空行でもセミコロンは必要となる。
実際のデコード例
例として、add.js.mapのmappings文字列からのデコード結果が示されている。
AAAA,OAAO,SAASS,IAAI,CAAC,EAAE;EACrB,OAAO,IAAI;AACb
行ごとに分解すると、各値は前の値との差分(相対値)でエンコードされていることがわかる。これにより、巨大な列番号(例:27698など)を持つ圧縮済みコードでも、短い文字列で表現できるようになっている。
VLQエンコードの仕組み
VLQは数値をできるだけ少ないビットで表すための手法であり、ソースマップのように小さな差分を多数扱うケースに適している。
1. 符号ビットの追加
正負両方の値を表すため、最下位ビット(LSB)に符号情報を格納する。
5 → 101 → 1010(正)
-5 → 101 → 1011(負)
2. 5ビットごとの分割
Base64の1文字は6ビットだが、1ビットを「継続フラグ」として使用し、残り5ビットにデータを格納する。
[continuation bit][5 data bits]
継続ビットが1なら次の文字が続く。0なら終了。
3. Base64への変換
6ビット値をBase64文字に変換する。
A=0, B=1, ..., Z=25, a=26, ..., z=51, 0=52, ..., 9=61, +=62, /=63
例:数値7のエンコード
7→ 2進数で111- 符号ビット追加 →
1110(正数) - 継続ビット付加 →
001110 - Base64変換 →
O
したがって、7はOとしてエンコードされる。
まとめ
記事では、ソースマップの構造とVLQエンコードの詳細な仕組みを通じて、ブラウザのDevToolsがどのようにしてミニファイドされたコードを元のTypeScriptやES6コードに対応づけているかが解説されている。これにより、開発者が効率的にデバッグできる理由が理解できる。
詳細はThe Inner Workings of JavaScript Source Mapsを参照していただきたい。