新聞中心
背景

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


咨詢
建站咨詢
