大型前端項(xiàng)目的斷點(diǎn)調(diào)試共享化和復(fù)用化實(shí)踐
背景
隨著我們項(xiàng)目越來越大,我們有可能需要維護(hù)很多的模塊,我們騰訊文檔 Excel 項(xiàng)目大模塊有 10 幾個,而每個大模塊分別有 N 個小模塊,每個大模塊下的小模塊都有主要的負(fù)責(zé)人在跟進(jìn)模塊問題。
這就會導(dǎo)致一個很大的問題是,模塊負(fù)責(zé)人大部分情況只會關(guān)注自己模塊的問題,而不甚了解其他負(fù)責(zé)人手上模塊的具體問題。
比如:當(dāng)我們有用戶反饋使用復(fù)制粘貼有問題的時候,我們想要快速去定位這個問題,就只能找復(fù)制粘貼對應(yīng)的模塊負(fù)責(zé)人處理,如果復(fù)制粘貼模塊負(fù)責(zé)人請假了,那么其他負(fù)責(zé)人去處理這個問題的時候,解決成本就會非常大,因?yàn)槠渌?fù)責(zé)人可能根本對這個模塊不熟悉。
又比如:我們新來了幾個同學(xué),想讓他快速去排查用戶反饋的問題的時候,我們只能手把手把我們該模塊調(diào)試的經(jīng)驗(yàn)傳授他,和所熟知的各個坑點(diǎn)告訴他,或者整理好對應(yīng)的 iwiki 給他看(一般效率低也沒人看!),讓他去慢慢定位問題,這樣的每個新同學(xué)對模塊的熟悉,學(xué)習(xí)和維護(hù)的成本就會變得越來越大,項(xiàng)目越大這種情況就會越嚴(yán)重!
所以我們思考了很多,該怎么去解決這些問題,至少要讓模塊維護(hù)成本變低,變得更好去維護(hù)和定位問題。
方案
由于上面的問題真的很痛,我們在爬滾中逐漸摸索了一套方案,我們暫且叫它為基于斷點(diǎn)調(diào)試的共享化和復(fù)用化的實(shí)踐方案吧,這里有個關(guān)鍵詞是斷點(diǎn),相比作為每一個開發(fā)者都不陌生,在我們前端,模塊定位問題的時候,我們少不了去使用斷點(diǎn)去斷住一些代碼運(yùn)行關(guān)鍵的地方。下面舉一個例子:
- class CopyPaste {
- // 內(nèi)部粘貼
- pasteFromInter(){ ...}
- // 外部粘貼
- pasteFromOuter(){ debugger; ...}
- // 外部圖文粘貼
- isShapePasteFromOuter(){ ... }
- // 外部圖片粘貼
- isImgPasteFromOuter(){ ... }
- // 外部文本粘貼
- isTextFromOuter(){ ... }
- }
上面這段代碼是當(dāng)用戶反饋一個復(fù)制粘貼問題的時候,熟悉該模塊的負(fù)責(zé)人根據(jù)用戶的反饋,知道用戶是外部粘貼出現(xiàn)了問題,由于他對該模塊熟悉,他會快速的在瀏覽器的控制臺打斷點(diǎn),或者手動在源代碼注入 debugger 關(guān)鍵詞去一步一步定位用戶的問題,他會先檢查內(nèi)部粘貼 pasteFromOuter 是否觸發(fā)了,然后檢查函數(shù) isShapePasteFromOuter 是否運(yùn)行成功,出參和入?yún)⑹欠裾_,是否代碼走歪了,去了 isImgPasteFromOuter。
然后在問題排查修復(fù)完后,長舒一口氣,等遇到下一個問題的時候,再把瀏覽器或者代碼中當(dāng)前的這些調(diào)試的痕跡清理干凈,再周而復(fù)始的重復(fù)上面的一系列動作,我相信大部分的同學(xué)每天排查問題甚至做需求都是重復(fù)著上面的類似動作,我們是否可以考慮一下把這些珍貴的調(diào)試痕跡給保存下來,等自己或者其他同學(xué)遇到類似模塊問題的時候,我們把這些凝聚著我們血與淚的心路歷程再自動復(fù)現(xiàn)一次?
代碼片段 | 記錄 debugger 位置 |
---|---|
pasteFromInter |
2 行 4 列 |
isShapePasteFromOuter |
256 行 89 列 |
isImgPasteFromOuter |
867 行 12 列 |
對于大型項(xiàng)目來說,每一個小 Bug 的調(diào)試鏈路的時間成本都是無比巨大的,也是難以復(fù)刻和重現(xiàn)的,我們能做的就是當(dāng)再次遇到相似問題的時候,復(fù)用相似的調(diào)試經(jīng)驗(yàn)。有過受傷的痕跡和經(jīng)歷,當(dāng)問題再次相遇,我們應(yīng)該會更自信和從容。
所以我們首要任務(wù)其實(shí)就變成了是保留珍貴的調(diào)試鏈路,也就是保留無數(shù)個日夜,那些深扎并刺痛我們內(nèi)心深處的每個斷點(diǎn)。
插件化
在實(shí)踐的過程中我們嘗試過無數(shù)的方法,第一個方案就是基于瀏覽器插件,實(shí)現(xiàn)斷點(diǎn)留存,基于谷歌瀏覽器插件開發(fā)提供的接口 chrome.debugger,它是 Chrome 遠(yuǎn)程調(diào)試協(xié)議的一種消息傳輸方式。chrome.debugger 可以附加到一個或多個標(biāo)簽頁調(diào)試 JavaScript。并使用調(diào)試對象基于 sendCommand 和 onEvent 來做插件通信。它可以讓我們在插件去調(diào)試頁面,很多插件和工具是基于這個協(xié)議來跟瀏覽器的控制臺去做通信,這種方案現(xiàn)只能實(shí)現(xiàn)一個遠(yuǎn)程的調(diào)試面板,這個面板類似瀏覽器本身的調(diào)試界面可以加載代碼然后記錄斷點(diǎn),最后可以把這些斷點(diǎn)分享出去。
這種方案體驗(yàn)會比較糟糕,首先插件自己實(shí)現(xiàn)的調(diào)試面板無法像谷歌瀏覽器那么好的體驗(yàn),其次是插件需要開發(fā)主動去安裝,分享的前提是雙方都需要安裝好對應(yīng)的插件,開發(fā)和推廣成本都比較高,所以個人不是很建議,但是這不代表這個方案走不通,因?yàn)檫@個基于插件還可以有另外一種實(shí)現(xiàn),就是下面的 debug 函數(shù)方案。
debug 函數(shù)
具體是利用函數(shù)斷點(diǎn) debug(functionName) 和 undebug(functionName) 方法,其中 functionName 是要調(diào)試的函數(shù)。我們可以將 debug() 插入到的代碼中(這個方法和 console.log() 語句相似),也可以從 DevTools 控制臺中進(jìn)行調(diào)用。debug() 相當(dāng)于在第一行函數(shù)中設(shè)置代碼行斷點(diǎn)。
一般情況是在控制臺中使用,這個方法配合插件會有比較好的體驗(yàn),因?yàn)椴寮褂?chrome.devtools.inspectedWindow.eval 方法配合瀏覽器的接口可以把代碼注入到控制臺中執(zhí)行,從而實(shí)現(xiàn)幫你自動下發(fā)斷點(diǎn)的功能。
- chrome.devtools.inspectedWindow.eval(
- `debug(window.xxxApi);`,
- (value) => {
- callback && callback(value);
- }
- );
但是細(xì)心的同學(xué)發(fā)現(xiàn)我使用 debug 函數(shù)監(jiān)聽的是一個全局的函數(shù) window.xxxApi,所以這里也總結(jié)一下經(jīng)驗(yàn),這個方法的缺陷就是如果你在控制臺使用,它會在你的上下文尋找該函數(shù),所以它一般只能用于全局的函數(shù)打點(diǎn),如果需要打點(diǎn)的函數(shù)不在上下文,還需要手動斷點(diǎn)到目標(biāo)函數(shù)的范圍,然后使用函數(shù)打點(diǎn)來觸發(fā),如果是閉包函數(shù)那就毫無辦法了,但是瑕不掩瑜,這個方法能幫我們快速定位任何的全局函數(shù),就算代碼被混淆了,它還是能快讀把函數(shù)斷點(diǎn)給你加上,所以這個方案我建議可以作為一個備選方案,在某些情況下能發(fā)揮奇效!
AST 注入
經(jīng)歷過上面的各種坑之后,下面我們簡單介紹我們實(shí)現(xiàn)的一套方案吧:
我們的方案其實(shí)是在之前函數(shù)調(diào)用鏈方案基礎(chǔ)上做的一種改進(jìn),既然我們開發(fā)可以自己在代碼中輸入 debugger 關(guān)鍵詞去斷住任何地方的代碼,我們何不把這個工作交給工具?
首先我們可以用使用狀態(tài)機(jī)去告訴工具我們需要分發(fā)的打點(diǎn)的位置在哪里,類似我們常用 whistle 的配置表:
- Module 'CopyPaste'
- index.ts -f pasteFromInter -s !(()=>{ console.log(window.Worker) })()
- index.ts -f pasteFromOuter -s console.log('success') -check messagecenter1
- index.ts -f isShapePasteFromOuter
- End Module
- Module <-- state --> End Module 這里描述一個狀態(tài),是一個分發(fā)斷點(diǎn)的行為,用來需要監(jiān)聽那類模塊的,例如:復(fù)制粘貼模,數(shù)據(jù)層模塊還是數(shù)據(jù)層模塊
- -f functionname -s code 這里可以描述該狀態(tài)的具體行為特征,例如:在 pasteFromInter 函數(shù)中分發(fā)斷點(diǎn),并注入 debugger 代碼。
在 webpack 中我們可以在 loader 或者 plugin 這兩個過程中去解析這份配置文件,這里你也可以使用第三方庫或者正則來解析上面這些狀態(tài)文本。我是在 loader 中去解析這份狀態(tài)表的,我在全局目錄下或者局部模塊內(nèi)定義一份 .debug.json 來寫入上述的狀態(tài),然后解析出一份 map 對象出來:
- args = argument({
- "--class": String, // 類
- "--function": String, // 函數(shù)
- "--code": String, // 函數(shù)
- "-c": "--class", // 轉(zhuǎn)義替換
- "-f": "--function",
- "-s": "--code",
- },{ argv: debugConfigValue, }
- );
如果不想用狀態(tài)機(jī)的方式去寫配置文件的話,其實(shí)也可以使用一份 debug.json 文件來描述斷點(diǎn)的位置,這種方式更簡單,解析 json 文件的成本比狀態(tài)機(jī)的配置文件低不少,json 文件在這里涉及的主要字段分別是需要檢測代碼的路徑,這個方便工具去定位文件,然后是需要檢測的類或者函數(shù)的名字,這個方便工具去定位代碼的位置,還有檢測項(xiàng)的名字和需要檢測的代碼,和一個關(guān)鍵的鍵值:
- {
- "MessageCenter": {
- "function": [
- {
- "path": "src/core/network/message-center/SendMessageCenter.ts",
- "name": "_sendUserChanges",
- "title": "數(shù)據(jù)層斷點(diǎn)測試2",
- "code": "__console.log('數(shù)據(jù)層斷點(diǎn)測試2')",
- "key": "MessageCenter|function|1"
- }
- ]
- }
- }
這里鍵值的涉及可以定義的清晰點(diǎn),比如 MessageCenter|function|1 指的是對 MessageCenter 模塊的文件里面的某一個函數(shù)打點(diǎn),以后還可以繼續(xù)改進(jìn)這樣寫 MessageCenter|class|1:12,意思是 MessageCenter 模塊的文件里面某一個類的具體位置打點(diǎn),如果這個 key 的語義越豐富,后續(xù)分發(fā)的打點(diǎn)也會更精確,定位問題也會更高效,具體這個可以根據(jù)業(yè)務(wù)場景去定義。
- class CopyPaste {
- // 內(nèi)部粘貼
- pasteFromInter(){
- debugger
- ...
- }
- }
當(dāng)我們有了配置文件,我們就得思考怎么無入侵的在代碼里面加入調(diào)試和檢測代碼了,我們首選通過 AST 去注入,它可以幫我們把代碼關(guān)鍵部分給梳理成一顆樹出來,比如抹掉冒號、括號、分號等,能讓我們把精力放在重要的節(jié)點(diǎn)上,上面的代碼經(jīng)過解析會得到下面這棵 AST 語法樹:
- {
- "program": {
- "type": "Program",
- "body": [{
- "type": "ClassDeclaration",
- "id": {{ "type": "Identifier", "identifierName": "CopyPaste" }, "name": "CopyPaste" },
- "body": {
- "type": "ClassBody",
- "body": [{
- "type": "ClassMethod",
- "key": { "type": "Identifier", "name": "pasteFromInter" },
- "body": { "type": "BlockStatement", "body": [{ "type": "DebuggerStatement" }]},
- "leadingComments": [{ "type": "CommentLine", "value": " 內(nèi)部粘貼" }],
- }]
- }
- }]
- }
- }
而具體步驟大概如下:解析 MessageCenter|function|1 這段參數(shù)配置的字符串,得到函數(shù)名,模塊名,位置信息等,然后對代碼進(jìn)行掃描并進(jìn)行詞法和語法分析,并得到 AST 語法樹,根據(jù)剛才解析得到的函數(shù)名,模塊名,位置信息來匹配 AST 樹節(jié)點(diǎn),在上面進(jìn)行加入我們的調(diào)試和檢測代碼,最后再輸出經(jīng)過我們加工的代碼。
那上面這個原理我們都懂,具體怎么實(shí)現(xiàn)呢,我們可以在 webpack 工具使用 plugins 來實(shí)現(xiàn),在 plugins 中我們經(jīng)常會用到訪問者模式,就是說在訪問到某一個路徑的時候進(jìn)行匹配,然后在對這個節(jié)點(diǎn)進(jìn)行修改,比如上面這個 pasteFromInter 函數(shù),它是一個 ClassMethod,plugins 就會對代碼生成的 AST 樹進(jìn)行訪問,訪問者可以匹配任何對應(yīng)的詞法特性,我們就可以在這里匹配所有的 ClassMethod 然后根據(jù)路徑去拿到節(jié)點(diǎn)對應(yīng)的信息,比如函數(shù)名,函數(shù)參數(shù)和函數(shù)位置等,拿到這些關(guān)鍵的信息,我們就可以對這個函數(shù)節(jié)點(diǎn)進(jìn)行加工,也就是注入我們的調(diào)試和檢測代碼或者直接注入一個 debugger 去打斷點(diǎn)。
- plugins = {
- // 訪問器
- Visitor = {
- 'ClassMethod'(path) {
- // 檢點(diǎn)
- path.node
- }
- }
- }
當(dāng)然注入檢測代碼也是需要構(gòu)造成 ClassMethod 的類似結(jié)構(gòu),所有我們可以配合 @babel/types 工具去快速注入一段代碼,比如最簡單的是注入一個 debugger:
- types.expressionStatement(types.identifier(`debugger`))
這樣就會在你匹配的路徑的特定位置放入一個 debugger,而你的代碼源文件本身其實(shí)是沒有任何改動的,只是通過 AST 樹配合配置文件成功融合了一段代碼到指定的位置,當(dāng)然實(shí)際情況會比預(yù)想中的復(fù)雜,因?yàn)橛锌赡芟掳l(fā)的位置不是函數(shù)中的某個位置,可能是類函數(shù)中的某個位置,閉包函數(shù)中的某個位置,所以要兼容各種的語法結(jié)構(gòu),需要在 AST 中匹配這些函數(shù)的所有特征才能準(zhǔn)確無誤的下發(fā)代碼,還是以函數(shù)作為例子,列出部分需要考慮的情況:
- FunctionExpression
需要滿足到這兩種寫法,不然 debugger 會下發(fā)錯位置。
- this.xxx = function() { debugger }
- const xxx = function() { debugger }
- ClassMethod
這個一般情況按下面的方式就能定位到了,但是如果要更精確比如是私有函數(shù)等,那就需要寫更精確的訪問器了。
- class xxx { xxx:(){ debugger } }
- FunctionDeclaration
除了要處理上面函數(shù)表達(dá)式的寫法,不要忘了函數(shù)還有聲明定義的寫法,所以這個也得滿上。
- function xxx() { debugger }
- ArrowFunctionExpression
最后還要考慮下箭頭函數(shù)的寫法
- const xxx = () => { debugger }
- this.xxx = () => { debugger }
- class xxx { xxx = () => { debugger } }
雖然大部分情況匹配函數(shù)對項(xiàng)目下發(fā)的調(diào)試代碼能覆蓋大部分的場景,但總會有漏網(wǎng)之魚,比如有的同學(xué)想在類定義之前注入檢測代碼,那就需要繼續(xù)寫對應(yīng)的訪問器去獲取路徑,然后對該位置去分發(fā)對應(yīng)的檢測代碼,所以需要對各種語法和對應(yīng)的訪問器類型很熟悉才能順利實(shí)現(xiàn)。
經(jīng)過上面的改造,我們會在最終代碼中會得到新代碼(已注入了所有檢測代碼),但是這樣會引發(fā)一個新的,當(dāng)我們運(yùn)行這份新代碼,我們上面所有的檢測代碼都會跑一遍,這樣就會斷住很多別的模塊負(fù)責(zé)人不想斷住的代碼區(qū)域,所以實(shí)際情況我們需要分發(fā)一個帶開關(guān)的檢測代碼,當(dāng)然這個開關(guān)的涉及其實(shí)可以很簡單,如下:
- // 基于 AST 在模塊中分發(fā)的調(diào)試開關(guān)
- if(require('@tencent/vdebugger').call(this, key)){ debugger }
- // 或者這樣,雖然好看點(diǎn),但這樣 debugger 在閉包里面拿不到上下文
- require('@tencent/vdebugger').call(this, key) || (() => { debugger })()
- // 注意這種下面類似這種寫法是不行的↓
- require('@tencent/vdebugger') || debugger
我們可以使用 require('@tencent/vdebugger') 打包一個函數(shù),這個函數(shù)可以設(shè)計為在全局變量或者 localstorage 等地方讀取配置,然后返回一個布爾值,用于判斷是否執(zhí)行該位置的 debugger,這里為了調(diào)試方便有幾個小細(xì)節(jié)需要注意,debugger 這個關(guān)鍵詞自己要獨(dú)立一個作用域,所以你不能寫成類似這個樣子 false || debugger,還有 require('@tencent/vdebugger') 這個函數(shù)里面在讀取配置之后里面可以包一個 eval 方法來執(zhí)行檢測代碼,所以可以用 call 把當(dāng)前作用域代理過來,更方便去做調(diào)試。
當(dāng)然實(shí)際情況可能還要比想象中復(fù)雜,舉個簡單的例子:因?yàn)榉职l(fā)的開關(guān)有可能會注入到一些被打包到 worker 的代碼里面,worker 在大型項(xiàng)目中運(yùn)用的很多,但是 worker 里面無法讀取 document、window 這些對象,雖然可以使用 navigator,location 和 XMLHttpRequest 等對象,但無法通過 localstorage 讀取配置等手段去控制調(diào)試開關(guān)了,所以你需要考慮一下是否需要讓調(diào)試開關(guān)分發(fā)到 worker 代碼中,如果分發(fā)了又要怎么去通信對應(yīng)的開關(guān)等問題。
最簡單粗暴就是打包 worker 代碼的時候進(jìn)行過濾。
- !isWorker && new DebuggerPlugin({
- debugConfig: path.resolve(dirName, '../debug.json'),
- }),
當(dāng)然如果需要分發(fā)的開關(guān)在 worker 中生效,就需要去實(shí)現(xiàn)一個讀取開關(guān)配置的通信手段,最常見的就是基于 postMessage 的通信手段,讓 require('@tencent/vdebugger') 函數(shù),即開關(guān)模塊接受主線程的配置去向 worker 的運(yùn)行代碼下達(dá)是否執(zhí)行檢測代碼和啟動斷點(diǎn)的命令。
- myWorker.postMessage(xx);
- myWorker.onmessage = () => {
- console.log('Message received from worker');
- }
思考
實(shí)現(xiàn)了上面的基本功能之后,我們還可以繼續(xù)優(yōu)化很多體驗(yàn),比如我們還可以使用 webpack 的 plugin 來實(shí)現(xiàn)本地編譯時候的增量更新,這就能做到當(dāng)我們更改本地配置文件的時候,自動分發(fā)斷點(diǎn)和調(diào)試代碼,邏輯也是比較簡單的,在 plugin 的 apply 周期使用內(nèi)置的庫 chokidar 去監(jiān)聽配置文件的變更,然后觸發(fā)編譯,重新走 AST 去編譯生成帶調(diào)試代碼合斷點(diǎn)的代碼:
- const chokidar = require('chokidar');
- this.watcher = chokidar.watch(["../src/**/.debug.json"], {
- usePolling: true,
- ignored: this.options.ignored
- });
總結(jié)
關(guān)于這方面的調(diào)試相關(guān)文章不多,一路走來跳了不少的坑,感謝團(tuán)隊(duì)成員的支持,并讓這個方案最終成功落地,也希望有更多志同道合的人加入我們騰訊文檔團(tuán)隊(duì),一起去探索和遨游,最后也希望這篇文章能給到你們一些啟發(fā)吧 。
【本文為51CTO專欄作者“騰訊技術(shù)工程”原創(chuàng)稿件,轉(zhuǎn)載請聯(lián)系原作者(微信號:Tencent_TEG)】