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