深入前端調(diào)試原理
調(diào)試是開發(fā)者需要掌握的一項重要的技能, 它能夠幫助我們快速定位和修復(fù)代碼中的問題。本文主要介紹前端調(diào)試的基本原理。
“本文是筆者在學(xué)習(xí)了 前端調(diào)試通關(guān)秘籍[1] 后,并結(jié)合平時實踐過程中一些經(jīng)驗進(jìn)行的梳理和總結(jié)。主要以 Chrome,VSCode 作為調(diào)試工具,在其他編輯器中,配置雖有不同,但原理是相通的。
本文所使用的示例代碼均在 debug-dojo[2] 倉庫。
從一個簡單的示例入手
以上面代碼為例,簡單實現(xiàn)了按鈕在點擊后文字更新點擊次數(shù)的能力:
當(dāng)我們打開 Devtools 中的 Sources 目錄,并在 click 的回調(diào)設(shè)置斷點后,再次點擊按鈕,程序就會在此停住,并將內(nèi)部的運(yùn)行狀態(tài)暴露出來:
這背后到底是怎么實現(xiàn)的呢?Devtools 是如何將程序內(nèi)部的運(yùn)行狀態(tài)暴露出來,并且通過 UI 的形式呈現(xiàn)的呢?
Chrome Devtools 原理
Devtools 主要包含以下四個關(guān)鍵的組成部分:
-
后端:和 Js Runtime,內(nèi)部的布局、渲染器深度綁定,用于將內(nèi)部的狀態(tài)通過協(xié)議暴露出來。 -
前端:這里主要是 Devtools 的各個調(diào)試模塊,負(fù)責(zé)對接協(xié)議,做 UI 展示和交互。前端本質(zhì)上是獨(dú)立的,任何對接協(xié)議用于展示數(shù)據(jù)的項目都可以作為調(diào)試前端。 -
通信方式:前端后端通過 websocket 進(jìn)行通信。 -
Chrome Devtools Protocol(簡稱:CDP): 前后端的通信協(xié)議。
CDP 協(xié)議的具體內(nèi)容可以通過 官網(wǎng)文檔[3] 進(jìn)行查看,它按照不同的域進(jìn)行劃分,基本上包含我們平時所使用的 Devtools 的不同場景:
可以在 Chrome Devtools 設(shè)置中打開 Protocol Monitor,就可以查看前后端的協(xié)議通信了:
VSCode Debugger 原理
除了 Devtools 外,VSCode Debugger 也是常見的調(diào)試工具,在 VSCode 的項目中 .vscode/launch.json 中加入如下的配置即可調(diào)試:
VSCode Debugger 的原理大致相同,唯一特殊的是:VSCode 并不是 JS 語言的專屬編輯器,它可以用于多種語言的開發(fā),自然不能對某一種語言的調(diào)試協(xié)議進(jìn)行深度適配,所以它提供了 Debugger 適配層和適配器協(xié)議,不同的語言可以通過提供 Debugger 插件的形式,增加 VSCode 對不同語言的調(diào)試能力:
如此,VSCode Debugger 就能以唯一的 Debugger 前端調(diào)試各個不同的語言,插件市場中也提供了諸多不同語言的調(diào)試插件:
調(diào)試模式
Attach 模式
從上面調(diào)試的四個關(guān)鍵部分可以看出,前后端之間是通過 websocket 進(jìn)行通信的,所以確保前后端能正確的連接是調(diào)試成功的關(guān)鍵。
除了在需要調(diào)試的網(wǎng)頁中直接打開 Devtools 的方式外,我們使用第三方前端工具進(jìn)行調(diào)試時,都需要知道所需要調(diào)試的網(wǎng)頁的后端 ws 地址才行。因此我們需要讓 Chrome 以指定調(diào)試端口的形式跑起來,使用 --remote-debugging-port 參數(shù)指定調(diào)試端口:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
“Chrome 運(yùn)行參數(shù)非常多,可以通過 該文檔[4] 進(jìn)行查看。
在 Chrome 運(yùn)行起來后,隨意的打開幾個網(wǎng)頁,然后訪問 localhost:9222/json/list 網(wǎng)址,此時就能得到所有運(yùn)行的網(wǎng)頁后端 ws 地址:
在百度網(wǎng)頁中同時打開了 Devtools, 可以看到連 Devtools 的調(diào)試信息都一起打印出來了,因為 Devtools 本質(zhì)上也是一個網(wǎng)頁程序,所以甚至可以做到對 Devtools Debugger 進(jìn)行 Debug 這樣的套娃操作。
有了 ws 信息,我們就可以使用其他 Debugger 進(jìn)行連接了,比如使用下面的 VSCode Debug 配置:
Node.js 程序在運(yùn)行時則是通過 --inspect 或者 --inspect-brk 參數(shù)開啟調(diào)試模式:
Node.js 調(diào)試協(xié)議經(jīng)過了漫長的迭代,最終也是以 CDP 協(xié)議進(jìn)行調(diào)試,因此 Node.js 程序可以直接使用 Devtools 進(jìn)行調(diào)試,在 Chrome 中訪問 chrome://inspect/#devices 就可以看到調(diào)試目標(biāo)了:
“如果當(dāng)前的 Node 項目沒有被發(fā)現(xiàn),可能是檢測端口不是默認(rèn)的
9229,可以通過Configure進(jìn)行配置。
VSCode Debugger 同樣能夠連接上 Node.js 項目并進(jìn)行調(diào)試,

Launch 模式
前面講到的都是首先通過調(diào)試模式啟動一個項目,然后再手動進(jìn)行前端 ws 連接,最后再調(diào)試的模式。相對較繁瑣,VSCode Debugger 提供了 launch 模式,它相當(dāng)于是將上面的流程自動化了,以調(diào)試模式將 Chrome 或者 Node.js 程序運(yùn)行起來,然后 Debugger 自動 attach 到調(diào)試端口,然后直接調(diào)試。
“VSCode Debugger 的各種調(diào)試配置不是本文的重點,有興趣可以閱讀 官方文檔[5],已經(jīng)講得很詳細(xì)了,也提供常見使用場景的 Recipes[6]。
SourceMap
實際的項目往往不會如此簡單,要引入各種開源庫,然后經(jīng)過諸如 Webpack, Rollup 等等打包工具做編譯打包,才能運(yùn)行起來。編譯壓縮后的代碼是不具備可讀性的,在它上面進(jìn)行調(diào)試也沒有意義,所以我們需要一個技術(shù),將源碼和編譯后的代碼進(jìn)行映射,這就是 SourceMap。
以如下代碼為例:
SourceMap 文件規(guī)則
在經(jīng)過 Webpack 打包后,會生成壓縮文件,以及對應(yīng)的 SourceMap 文件:
SourceMap 文件內(nèi)容主要包含:
-
version: SourceaMap 的版本 -
file: 對應(yīng)編譯后的文件名 -
sourceRoot:源碼的根目錄 -
sources:對應(yīng)源碼文件路徑 -
sourcesContent:對應(yīng)的源碼文件內(nèi)容 -
names:源碼轉(zhuǎn)化前的變量,屬性名 -
mappings:源碼和編譯后代碼的對應(yīng)關(guān)系
mappings 使用了 BaseVLQ 編碼形式
-
使用 ,和;做分隔,一個;對應(yīng)轉(zhuǎn)換后代碼的一行,,代表一個位置的映射 -
每個 maping 通常使用 5 位長度表示映射關(guān)系(也會根據(jù)實際的打包規(guī)則進(jìn)行簡化),1 到 5 位分別表示: -
對應(yīng) 轉(zhuǎn)換后 代碼第幾列(行號已經(jīng)通過 ;確定) -
對應(yīng)轉(zhuǎn)換前哪個文件(對應(yīng) sources 里面下標(biāo)索引) -
對應(yīng)轉(zhuǎn)換前第幾行 -
對應(yīng)轉(zhuǎn)換前第幾列 -
對應(yīng)轉(zhuǎn)換前源碼哪個變量名(對應(yīng) names 里面的下標(biāo)索引)
說起來有點繞,好在可以通過 在線的 SouceMap 可視化工具[7] 進(jìn)行查看:
SourceMap 提供的是源碼和編譯后代碼的映射能力,無關(guān)乎代碼的類型,所以在不管是 js 代碼,還是 less 代碼,都可以為其提供映射:
Webpack SourceMap 配置
Webpack 提供的 SourceMap 配置能力應(yīng)該是最豐富,也是最復(fù)雜的,基本上掌握了 Webpack 的 SourceMap 配置,其他的打包工具就難不倒我們了。
在配置之前,首先說明幾個概念:
-
Original:源碼 -
Transformed:經(jīng)過各種 loader 轉(zhuǎn)化后的代碼,比如 babel-loader, less-loader 等等 -
Genrated: weback 對每一個模塊按照 Webpack 加載處理后的代碼 -
Bundled: 最中生成的代碼
devtool 配置參見 官方文檔[8],需要滿足 [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map 這樣的規(guī)則。
inline|hidden|eval 控制 SourceMap 文件的生成方式,當(dāng)不指定時,會單獨(dú)生成 SourceMap 文件,并在對應(yīng)的打包文件的尾部添加關(guān)聯(lián):
而 hidden 則表示單獨(dú)生成 SourceMap 但是不關(guān)聯(lián)。
inline 則表示將生成的 SourceMap 內(nèi)容以 Base64 形式直接內(nèi)嵌到打包文件中,打包的文件體積會顯著增加:
eval 相對而言比較復(fù)雜,JS 可以通過 eval api 動態(tài)執(zhí)行代碼:
我們可以通過后面映射的 VM242:1 虛擬文件查看源碼,設(shè)置斷點,注意:該文件不會出現(xiàn)在 Sources 面板的目錄中。
如果在 eval 代碼的尾部加上 //#sourceURL=xxx,那么 Devtools 就會以 xxx 的路徑將文件加入到 Sources 面板目錄中,就好像是直接運(yùn)行 xxx 路徑的文件代碼一樣:
Webpack 利用 eval 來優(yōu)化 SourceMap 生成的性能,是比較推薦的 development 模式下配置,它將每個模塊都用 eval 包裹,并搭配 sourceURL, 就能將也映射到每個源文件了,不需要從 Bundled 的代碼做映射。但現(xiàn)在映射到的只是 Generated 級別的代碼:
所以只是 sourceURL 還是不夠的,需要搭配其他 SourceMap 配置,比如 eval-source-map,就能進(jìn)一步將 SourceMap 關(guān)聯(lián)上:
此時的行為就和 inline 模式的差不多。
nosources 表示不將對應(yīng)源碼寫入 SourceMap 的 sourcesContent 字段中,這會顯著的減少 SourceMap 文件的體積。
cheap 表示映射只精確到行,而不到列,也可以有效的加快 SourceMap 的生成速度,畢竟精確到行已經(jīng)足夠我們排查問題了。
module 主要用來處理 loader 生成的 SourceMap。通常代碼要經(jīng)過多個 loader 處理轉(zhuǎn)換,比如 React 組件代碼必須得經(jīng)過 babel-loader 進(jìn)行轉(zhuǎn)換,然后在交由 Webpack 處理,而在 loader 的轉(zhuǎn)換過程也會生成 SourceMap,如果不指定 module,Webpack 只能生成從 Transformed 代碼到 Bundled 代碼的映射,即映射級別只能到 Transformed。在指定了 module 后,Webpack 會結(jié)合 loader 過程中生成的 SourceMap, 和自己從 Transformed 代碼到 Bundled 代碼的映射,就能生成 Original 級別的映射了。
需要注意的是:即使開啟了 module,Webpack 也只會處理經(jīng)由 loader 傳遞下來的 SourceMap,我們常常會在 babel-loader 中排除 node_modules 里面的文件處理,因為大多數(shù)情況下里面的開源包都是轉(zhuǎn)換后的產(chǎn)物,直接交由 Webpack 處理即可,但如果庫里面也包含 SourceMap,Webpack 是不會處理它的,所以此時只能映射到開源庫的打包產(chǎn)物級別。
以 @antv/s2 為例:
即使開啟了 module,也只能映射到 esm/index.js 這個級別:
為了能正確加上開源庫的 SourceMap 信息,需要搭配 source-map-loader[9] 處理開源庫的 SourceMap, 然后傳遞給 Webpack,Webpack 就能正確的處理經(jīng)由 loader 傳遞下來的 SourceMap 了,配置如下:


現(xiàn)在再來看官方文檔中不同配置之間的速度差異,以及 Production 級別,是不是就能清楚一點了。
SourceMap 加載
編譯后的代碼以及 SourceMap 都有了,現(xiàn)在來講講 SourceMap 是如何被 Devtools 加載解析的。
瀏覽器雖會加載 SourceMap 資源,但它們并不會出現(xiàn)在 Network 面板中,需要在 Developer Resources 面板中查看:
“不過目前 Developer Resources 面板比較的簡單,只能查看成功與否等簡單信息。
以單獨(dú)的 SourceMap 文件為例,當(dāng) Devtools 加載完代碼文件后,如果文件尾部包含 //#sourceMappingURL ,就會單獨(dú)去請求該鏈接,在拿到 SourceMap 文件后再開始做解析,壞處就是多了一個的網(wǎng)絡(luò)請求,解析速度就會慢一點。
而 inline 模式則是直接內(nèi)嵌的 SourceMap,無需單獨(dú)請求, 可以在代碼文件加載完成后,就直接解析了。inline 模式下 Developer Resources 里面展示的鏈接就是 Base64 的形式了:
上面說過 hidden 完全不關(guān)聯(lián) SourceMap, 所以 Devtools 是不會去加載 SourceMap 的。這種模式主要用于生產(chǎn)環(huán)境,我們并不希望直接在線上暴露源碼,所以不能直接關(guān)聯(lián)上。而是將生成的 SourceMap 上傳到監(jiān)控平臺,就可以結(jié)合線上的報錯信息順利找到報錯的源碼位置了。
當(dāng)然還可以借助 Sources 面板中的 Add source map... 臨時添加源碼映射,不過重新刷新后就沒了:
nosources 不將源碼寫入 SourceMap 文件中,既能減少 SourceMap 文件體積,也能到達(dá)隱藏源碼信息,比如我們在源碼中打印了些信息出來,雖然從 Console 面板中看到映射到了正確的文件路徑,但是點擊跳轉(zhuǎn)過去后,會發(fā)現(xiàn)無法查看源碼:
這種模式下,借助 VSCode Debugger 又有別樣的體驗。編輯器調(diào)試的好處在于:如果映射出來文件在當(dāng)前的工程項目中,編輯器就會直接打開該文件,不管有沒有源碼存在于 SourceMap 中。調(diào)試配置如下:
如果映射的文件路徑不在當(dāng)前項目中,那么打開的結(jié)果就和 Devtoos 一樣了。
多說一句,將源碼加入到 SourceMap 中后。如果映射到的文件在當(dāng)前項目中,那么跳轉(zhuǎn)過去后,是可以直接進(jìn)行編輯的;如果不在,則該文件就只讀。比如下面的 @antv/s2 源碼中的某一個文件顯然不在項目中,雖然可以查看源碼,但是無法編輯:
eval 模式的加載情況情況大致相同,只是多了將 //#sourceURL 映射到 Sources 目錄的布置而已,sourceMappingURL 處理就和 inline 模式一致。所以不再贅述。
SourceMap 加載到底發(fā)生在哪里?
SourceMap 的加載和解析完全是前端行為(Devtools,VSCode Debugger)等,后端并不涉及到任何 SourceMap 的處理。比如我們在源碼位置添加的斷點,通過 Protocol Monitor 可以看到傳給后端的是打包產(chǎn)物代碼的位置:
其實也能理解這樣的設(shè)計,本來源碼的調(diào)試和斷點就是前端行為,后端只是提供了運(yùn)行時暫停和狀態(tài)暴露的能力。如果后端來解析,會影響代碼運(yùn)行時的執(zhí)行效率。而且前端處理能更自由,不同 Debugger 工具可能對 SourceMap 進(jìn)行再映射。比如前面提到的 VSCode Debugger 在調(diào)試時,如果映射的路徑不在項目中就無法編輯,就可以通過 sourceMapPathOverrides 等配置再重新映射。
正因為前端負(fù)責(zé) SourceMap 的解析,所以我們打的斷點在 SourceMap 解析完成之前是沒法告訴后端正確的地址的。所以如果 SourceMap 加載比較慢,可能后端代碼已經(jīng)執(zhí)行就完了,前端才將斷點信息傳遞過去,就會出現(xiàn)打了斷點但是無效的情況。在 VSCode Debugger 中打斷點提示無效也大概是這個原因,沒有完成 SourceMap 的解析,就無法正確映射。
好在 Devtools 會將這些斷點信息進(jìn)行緩存,所以在刷新網(wǎng)頁后,能立馬將正確的斷點信息傳遞給后端。所以有些網(wǎng)頁在打了斷點后即使關(guān)閉了網(wǎng)頁后過一段時間再打開,依舊可以看到斷點信息。但 VSCode Debugger 則不會緩存這些信息,其實也不應(yīng)該去緩存。所以在調(diào)試斷開后,再重新調(diào)試,又需要重新進(jìn)行 SourceMap 的加載解析,雖然有 pauseForSourceMap 等配置讓程序等到 Debugger 加載完成 SourceMap 再執(zhí)行,但是目前 VSCode Debugger 整體的加載解析 SourceMap 的效率還是比較低,期待未來能做到更好。
“有興趣可以關(guān)注 vscode-js-debug[10],它就是 VSCode 所使用的 CDP Debugger。
Vite
Vite 是目前大火的構(gòu)建工具,相比于傳統(tǒng)的構(gòu)建工具如 Webpack 和 Rollup,Vite 的最大特點是“快”。這得益于 Vite 利用了瀏覽器原生的 ES modules 功能 。具體來說,Vite 會根據(jù)入口文件中的依賴關(guān)系,生成一棵依賴樹,并將各個模塊作為單獨(dú)的文件提供給瀏覽器。也無需單獨(dú)配置 SourceMap 就能映射到源碼。那它是怎么做到的呢?
Vite 會將每個文件進(jìn)行轉(zhuǎn)換,然后提供給瀏覽器,而轉(zhuǎn)換的文件中就已經(jīng) inline 了 SourceMap,所以我們可以直接對源碼進(jìn)行斷點調(diào)試了。
Jest
Jest 作為目前主流的單測工具,它又是怎么做到單測時斷點調(diào)試的呢?其實它和 vite 類似,在實際運(yùn)行代碼前,也會對代碼進(jìn)行轉(zhuǎn)換,并將 SourceMap inline 到轉(zhuǎn)換后的文件中,所以我們也可以直接對源碼進(jìn)行調(diào)試,以如下的 VSCode Debug 配置為例:
再聊聊 CDP
CDP 簡單講就是一組 API,用于與 Chrome DevTools 進(jìn)行通信。它允許開發(fā)人員以編程方式控制 Chrome,例如在 Chrome 中打開一個新的選項卡,加載網(wǎng)頁,設(shè)置網(wǎng)絡(luò)條件等。CDP 可以通過 WebSocket 進(jìn)行通信,也可以通過 HTTP 請求進(jìn)行通信。上文內(nèi)容更多的聚焦在代碼調(diào)試這一塊,但是 CDP 遠(yuǎn)不止于此,Chrome DevTools 的大部分功能都是基于 CDP 實現(xiàn)的。
Puppeteer 是一個著名的自動化庫,用于自動化控制 Chrome 或 Chromium 瀏覽器。本質(zhì)上就是使用 CDP 協(xié)議來與瀏覽器進(jìn)行通信,相當(dāng)于是對 CDP 的高級封裝版。
基于 CDP,我們可以做很多有趣的事,比如自己打造一個獨(dú)享版的 Devtools,可以使用 Chrome 提供的 chrome-remote-interface[11],它是對 CDP 的 Node.js 封裝,使用起來就像是 Pupeteer 一樣;也可以直接基于 Chrome Devtools 進(jìn)行修改,Chrome 也將 Devtools[12] 倉庫開源了,比如小程序的調(diào)試器就可以基于 Devtools 項目做二次封裝。
至此就是本文的全部內(nèi)容,希望能對你有所幫助,如有錯誤歡迎指正。
參考資料
前端調(diào)試通關(guān)秘籍: https://juejin.cn/book/7070324244772716556?utm_source=profile_book
[2]debug-dojo: https://github.com/wjgogogo/debug-dojo
[3]官網(wǎng)文檔: https://chromedevtools.github.io/devtools-protocol/
[4]該文檔: https://peter.sh/experiments/chromium-command-line-switches/
[5]官方文檔: https://code.visualstudio.com/docs/editor/debugging
[6]Recipes: https://code.visualstudio.com/docs/nodejs/debugging-recipes
[7]在線的 SouceMap 可視化工具: https://evanw.github.io/source-map-visualization/
[8]官方文檔: https://webpack.js.org/configuration/devtool/
[9]source-map-loader: https://webpack.js.org/loaders/source-map-loader/
[10]vscode-js-debug: https://github.com/microsoft/vscode-js-debug
[11]chrome-remote-interface: https://github.com/cyrus-and/chrome-remote-interface
[12]Devtools: https://github.com/ChromeDevTools/devtools-frontend
