Source maps 是現代網頁開發中不可或缺的一部分。如今,我們擁有官方標準、一個龐大的成員群體,以及許多令人興奮的開發中功能!但情況並非一直如此。
你可能會感到驚訝,過去多年竟然沒有官方標準來描述 source map 格式。一方面,令人難以置信的是,十年來打包工具、瀏覽器和開發工具竟然只靠一份共享的 Google 文件協同合作!另一方面,這也使得新增功能、淘汰舊功能,以及建立支持多種差異的開發工具變得不可能。
過去的網頁開發非常簡單!你只需寫一點 JavaScript,放入 <script> 標籤,然後發送給使用者。這也讓除錯變得非常簡單:網站上執行的程式碼就是你撰寫的原始程式碼。
隨著網頁開發變得越來越複雜,開始出現優化大型 JavaScript 應用的工具。2009 年,Google 發布了 Closure Tools,一套旨在解決 Google Maps、Google Docs 和 Gmail 等應用日益複雜性的工具組。
Closure Tools 包含四個獨立工具:優化編譯器、模板語言、JavaScript 框架和開發者工具。編譯器後來成為 Google 的 Closure Compiler,而開發者工具則深刻影響了 Chrome DevTools 的未來發展。
編譯器與開發工具一直密不可分,並形成至今仍存在的重要關係。如果開發者使用編譯器來優化、縮小和修改原始碼,他們就需要開發工具能將編譯器產生的輸出映射回原始碼!這正是 source maps 所解決的問題。
了解 source maps 的用途後,讓我們深入探討 source map 檔案中包含的資訊。
source map 其實就是一個 JSON 檔案。雖然部分欄位包含編碼資料,但如果你在喜愛的 IDE 中打開 .map 檔案,大部分內容仍可讀取。例如,在 out.js.map 中,你可能會看到類似以下內容:
我們可以透過閱讀大部分檔案內容來理解它!它包含產生的 JavaScript 檔案名稱、用於生成該檔案的一個或多個原始檔案、每個檔案中的程式碼,以及標記為第三方或函式庫程式碼的檔案清單!唯一無法直接閱讀的是 mappings 欄位。
讓我們思考 mappings。要精確追蹤開發者在 bundle.js 這類生成檔案中設定斷點的位置,並回溯到原始檔案 App.ts 中的確切檔案與位置,我們需要哪些資訊?
概念上,我們可以想像用數字陣列來表示這些資訊,前提是我們也有一個檔案名稱陣列。我們可以設計類似的結構:
但現在我們需要一種方式,將生成程式碼中的位置映射到這些 mappings 中的某一項。因此,我們可能新增一個欄位,包含生成檔案每行的映射,指向 mappings 陣列中的一個條目!類似這樣:
你可以透過省略 lineMaps 陣列中的重複項來減少 source map 的大小。因此,同樣的資訊也可以表示為:
這樣做很不錯!唯一的問題是,對於大型檔案,lineMaps 會非常龐大。即使使用稀疏陣列,你仍會為每個字元有一個陣列項目!即使是中等規模的網頁應用,經過縮小後也會包含 20 萬到 200 萬字元。
這種每字元映射的模型,實際上曾在早期的 source map 修訂版(1 和 2)中使用,後來在修訂版 3 中被取代。
隨著 JavaScript 打包檔案成長到數十萬甚至數百萬字元,縮小 source maps 的大小成為首要任務。
修訂版 3(於 2011 年制定,至今仍是我們使用的格式)做出了四項關鍵架構改變,大幅縮小了地圖大小:
使用修訂版 3,上述 source map 可以表示為:
這些改變讓 source maps 變得更小(多虧了 VLQ 編碼)且更容易編碼與解碼。
修訂版 3 於 2011 年推出,效果良好!效果好到整個網路生態系決定多年不再更動它。新打包工具開始產生修訂版 3 的 source maps,瀏覽器也持續建構能消費修訂版 3 source maps 的開發工具。
在這種情況下,斷點總能「大致」落在正確位置,情況良好。但這種狀態使得新增功能、淘汰舊行為或修正模糊性變得不可能。
儘管如此,一個新功能仍成功穿越模糊性,進入打包工具與開發工具!
隨著 JavaScript 框架日益普及,開發者面臨的一個常見問題是,需要在包含數十個框架檔案但只有少數自撰檔案的呼叫堆疊中逐步調試。2014 年,Google 的 Chrome DevTools 允許開發者建立忽略清單,指定希望從堆疊追蹤和逐步調試中排除的檔案或模式。
這很好,但如果打包工具能讓開發工具知道哪些檔案應被忽略,而不必讓開發者手動列出檔案,那會更好。為了解決這個問題,2022 年 Google 開始在 source maps 中尋找 x_google_ignoreList 陣列。若找到,便會自動將陣列中的檔案加入 DevTools 的 ignoreList。
這項功能非常受歡迎,2023 年 Firefox 也開始支援 x_google_ignoreList!這是開源工具、網頁框架與瀏覽器在無正式組織或標準下合作的成功故事。但同時也清楚顯示,我們需要更簡便的溝通方式。
雖然加入 ignoreList 功能成功,但多數大型公司希望能解碼堆疊追蹤中的函式名稱,這在沒有標準的情況下極難協調。
如果你不進行轉譯、優化或打包,堆疊追蹤會非常易讀!但若有這些處理,你很可能會看到單字母函式名稱的堆疊,無法追溯回原始碼。
為解決此問題,Bloomberg 創建了 pasta-sourcemaps。Pasta 利用相同的 x_ 前綴,新增 x_com_bloomberg_sourcesFunctionMappings 到 source maps 中。接著,我們可以在開發工具中解析此列表,將原始函式名稱還原到堆疊追蹤中。
雖然此功能受到所有瀏覽器和大多數開發工具的需求,但因其規模龐大且複雜,無法輕易推送。Chrome 雖在旗標下支援 pasta-sourcemaps,但我們清楚需要一個官方組織來討論此功能的細節。
2023 年,Bloomberg 開始推動 source maps 的標準化過程。我們從慕尼黑 Google 辦公室開始,召集 Bloomberg、Google、Mozilla、Vercel、Igalia 和 JetBrains 的工程師,整理多年來生態系統中關於 source maps 的問題與疑惑。
有了來自瀏覽器、開發工具、編譯器和開源的成員,我們尋找 source map 標準的「主辦單位」,最終選擇了 Ecma International,這也是 TC39 使用的產業協會。
2023 年 10 月,我們成為 TC39 旗下的官方工作小組。
我們的工作小組(TC39-TG4)在 2024 年處理了大量針對 source maps 的議題,討論主題包括提升正確性、正式化非官方功能、撰寫規範文本、建立驗證與測試工具,以及成為官方標準後將新增的功能。
2024 年底,我們正式成為 ECMA-426 標準。
2025 年,我們開始大膽構想新功能,提出五項構想,並著手實作其中兩項:Scopes 與 Range Mappings。
Scopes 提案基於 Bloomberg 的 pasta-sourcemaps,但將概念大幅擴展,不僅限於函式名稱。目標是讓 source maps 能反映現代 JavaScript 編譯的實際情況,直接在地圖中嵌入作用域與綁定資訊(而非讓開發工具重新解析原始碼並「猜測」)。
這讓打包工具與開發工具有共同語言,能溝通例如:
Range Mappings 則是較小但非常實用的想法:有時我們希望映射能套用到整段文字範圍,而非單一點。
目前,映射基本上是生成檔案中的「釘點」。如果工具詢問「第137欄映射到哪裡?」且該欄無精確釘點,消費者通常會退回到該行之前最近的映射。這種精度損失在合成 source maps(如 TypeScript 到 JS,再從 JS 到縮小版 JS)時尤其痛苦,因為只能可靠合成兩張地圖中精確對應的映射。
Range mappings 透過讓產生器標記特定映射為「範圍映射」來解決此問題,意即:
假設從此映射開始(直到下一個映射)之後的每個字元,都以相同偏移映射回原始碼。
換句話說,你得到「每個字元都映射」的精度,但不需為每個字元產生映射。這對於像「剝除型別」這類轉換特別強大,因為大量執行時程式碼是相同的。
為避免破壞 mappings 欄位,提案新增 rangeMappings 欄位,編碼(每生成行)哪些映射段應視為範圍映射。
想像你有一個 JavaScript 檔案,頂端有一段你打算剝除的註解。
編譯後,你得到這樣的結果:
若沒有 Range Mappings,以下操作的 source map 會是:
這樣運作良好,但會失去所有欄位精度。source map 只表示「我知道第1行原本是第2行」,僅此而已。
有了 Range Mappings,我們可以明確表示第1行原本是第2行,且是範圍映射,因此保留欄位資訊!
此方法讓瀏覽器能在控制台和逐步調試中提供大幅提升的體驗,且不增加使用者負擔。
多年來,source maps 基於瀏覽器與工具間的共識「自動運作」。ECMA-426 為我們帶來真正的規範與演進平台。
這個過程非常精彩,也凸顯了開源的最佳面向。許多願意分享知識的人投入心力,造福整個 JavaScript 生態系。
如果你是打包工具作者、開發工具工程師,或維護任何產生或消費 source maps 的工具(錯誤監控、回放除錯器等),TG4 正是我們希望你參與的地方。提案工作公開進行,我們非常歡迎你的意見!
如果你是 source maps 的使用者,請持續關注!Scopes 與 Range Mappings 很快就會在瀏覽器開發工具中出現!