玩轉(zhuǎn) Chrome DevTools,定制自己的調(diào)試工具
Chrome DevTools 是我們每天都用的工具,它可以查看元素、網(wǎng)絡(luò)請(qǐng)求、斷點(diǎn)調(diào)試 JS、分析性能問(wèn)題等,是輔助開(kāi)發(fā)的利器。
今天不講怎么使用它,而是講一個(gè)好玩的方向:定制自己的調(diào)試工具。
之前講過(guò),Chrome DevTools 和 Chrome 是分離的架構(gòu),兩者之間通過(guò) WebSocket 通信,通信協(xié)議是 Chrome DevTools Protocol,簡(jiǎn)稱(chēng) CDP:

其實(shí)這不準(zhǔn)確,具體原因后面揭秘。
上圖中,UI 的部分叫做 frontend,解析網(wǎng)頁(yè)、執(zhí)行 JS 的部分叫做 backend。
backend 是集成在 Chrome 中的,但是 frontend 的部分是獨(dú)立的。
我們可以從 npm 倉(cāng)庫(kù)下載 chrome-devtools-frontend 的代碼,我這里用的是 1.0.672485 版本的:
[email protected]
下載下來(lái)的代碼有個(gè) front_end 目錄,這個(gè)就是 Chrome DevTools 的前端代碼:

它下面有幾個(gè) html:

我們 "npx http-server ." 起個(gè)靜態(tài)服務(wù)看一下:
devtools_app.html 就是網(wǎng)頁(yè)的那個(gè)調(diào)試頁(yè)面:

node_app.html 就是 node 的那個(gè)調(diào)試頁(yè)面:

這就是 Chrome DevTools 的 frontend 部分。
那怎么用這個(gè)獨(dú)立的 frontend 呢?
給它配個(gè) WebSocket 的 backend 不就行了?
用 node 創(chuàng)建個(gè) WebSocket 服務(wù)端,打印下收到的消息:
const?ws?=?require('ws');
const?wss?=?new?ws.Server({?port:?8080?});
wss.on('connection',?function?connection(ws)?{
????ws.on('message',?function?message(data)?{
????????console.log('received:?%s',?data);??
????});
});
在 devtools_app.html 后面加上 ws=localhost:8080 的參數(shù):

啟動(dòng) ws 服務(wù),你就會(huì)發(fā)現(xiàn)控制臺(tái)打印了一系列收到的消息:

這就是 CDP 協(xié)議的數(shù)據(jù)。
那我們對(duì)接一下這個(gè)協(xié)議,返回相應(yīng)格式的數(shù)據(jù),能在 Chrome DevTools 里做顯示么?
我們?cè)囈幌隆?/p>
打開(kāi) CDP 的文檔 https://chromedevtools.github.io/devtools-protocol/

CDP 是按照不同的 Domain 分隔的,比如 DOM、CSS、Debugger 等。
我們找個(gè)網(wǎng)絡(luò)相關(guān)的:

可能你看到這些協(xié)議也不知道怎么用,這時(shí)候可以先打開(kāi) Chrome DevTools 的 Protocol Monitor 面板,找個(gè)網(wǎng)頁(yè)測(cè)試下:

看看 NetWork 部分都是怎么通過(guò) CDP 交互的:

然后你會(huì)發(fā)現(xiàn)每次發(fā)請(qǐng)求前,backend 都會(huì)給 frontend 傳一個(gè) Network.requestWillBeSent 的消息,帶上這次請(qǐng)求的信息。
那我們能不能也發(fā)一個(gè)這樣的消息呢?
我模擬構(gòu)造了一個(gè)類(lèi)似的 CDP 消息:

ws.send(JSON.stringify({
????method:?"Network.requestWillBeSent",
????params:?{
????????requestId:?`111`,
????????frameId:?'123.2',
????????loaderId:?'123.67',
????????request:?{
????????????url:?'www.guangguangguang.com',
????????????method:?'post',
????????????headers:?{
????????????????"Content-Type":?"text/html"
????????????},
????????????initialPriority:?'High',
????????????mixedContentType:?'none',
????????????postData:?{
????????????????"guang":?1
????????????}
????????},
????????timestamp:?Date.now(),
????????wallTime:?Date.now()?-?10000,
????????initiator:?{
????????????type:?'other'
????????},
????????type:?"Document"
????}
}));
然后在 frontend 的頁(yè)面看一下:

你會(huì)發(fā)現(xiàn) Network 面板顯示了我們發(fā)過(guò)來(lái)的消息!
這就是 Chrome DevTools 的原理。
測(cè)試了下 Network 部分的協(xié)議之后,我們?cè)賮?lái)試下 DOM 的。
我用 Protocol Monitor 觀察了下 DOM 部分的 CDP 交互:

首先通過(guò) DOM.getDocument 獲取 root 的信息,這一級(jí)返回的 node 只到 body。
然后后面再發(fā) DOM.requestChildNodes 的消息,服務(wù)端會(huì)回一個(gè) DOM.setChildNodes 的消息來(lái)返回子節(jié)點(diǎn)的信息。
我們也這樣實(shí)現(xiàn)一下:

收到 DOM.getDocument 的消息的時(shí)候,我們返回 root 的信息,只到 body 那一級(jí)。
然后發(fā)送 DOM.setChildNotes 來(lái)返回子節(jié)點(diǎn)的信息。
還要處理下 DOM.requestChildNodes 的消息,返回空就行。
完整代碼如下:
ws.on('message',?function?message(data)?{
????????console.log('received:?%s',?data);
????????const?message?=?JSON.parse(data);
????????if?(message.method?===?'DOM.getDocument')?{
????????????ws.send(JSON.stringify({
????????????????id:?message.id,
????????????????result:?{
????????????????????root:?{
????????????????????????nodeId:?1,
????????????????????????backendNodeId:?1,
????????????????????????nodeType:?9,
????????????????????????nodeName:?"#document",
????????????????????????localName:?"",
????????????????????????nodeValue:?"",
????????????????????????childNodeCount:?2,
????????????????????????children:?[
????????????????????????????{
????????????????????????????????nodeId:?2,
????????????????????????????????parentId:?1,
????????????????????????????????backendNodeId:?2,
????????????????????????????????nodeType:?10,
????????????????????????????????nodeName:?"html",
????????????????????????????????localName:?"",
????????????????????????????????nodeValue:?"",
????????????????????????????????publicId:?"",
????????????????????????????????systemId:?""
????????????????????????????},
????????????????????????????{
????????????????????????????????nodeId:?3,
????????????????????????????????parentId:?1,
????????????????????????????????backendNodeId:?3,
????????????????????????????????nodeType:?1,
????????????????????????????????nodeName:?"HTML",
????????????????????????????????localName:?"html",
????????????????????????????????nodeValue:?"",
????????????????????????????????childNodeCount:?2,
????????????????????????????????children:?[
????????????????????????????????????{
????????????????????????????????????????nodeId:?4,
????????????????????????????????????????parentId:?3,
????????????????????????????????????????backendNodeId:?4,
????????????????????????????????????????nodeType:?1,
????????????????????????????????????????nodeName:?"HEAD",
????????????????????????????????????????localName:?"head",
????????????????????????????????????????nodeValue:?"",
????????????????????????????????????????childNodeCount:?5,
????????????????????????????????????????attributes:?[]
????????????????????????????????????},
????????????????????????????????????{
????????????????????????????????????????nodeId:?5,
????????????????????????????????????????parentId:?3,
????????????????????????????????????????backendNodeId:?5,
????????????????????????????????????????nodeType:?1,
????????????????????????????????????????nodeName:?"BODY",
????????????????????????????????????????localName:?"body",
????????????????????????????????????????nodeValue:?"",
????????????????????????????????????????childNodeCount:?1,
????????????????????????????????????????attributes:?[]
????????????????????????????????????}
????????????????????????????????],
????????????????????????????????attributes:?[
????????????????????????????????????"lang",
????????????????????????????????????"en"
????????????????????????????????],
????????????????????????????????frameId:?"3A70524AB6D85341B3B613D81FDC2DDE"
????????????????????????????}
????????????????????????],
????????????????????????documentURL:?"http://127.0.0.1:8085/",
????????????????????????baseURL:?"http://127.0.0.1:8085/",
????????????????????????xmlVersion:?"",
????????????????????????compatibilityMode:?"NoQuirksMode"
????????????????????}
????????????????}
????????????}));
????????????ws.send(JSON.stringify({
????????????????method:?"DOM.setChildNodes",
????????????????params:?{
????????????????????nodes:?[
????????????????????????{
????????????????????????????attributes:?[
????????????????????????????????"class",
????????????????????????????????"guang"
????????????????????????????],
????????????????????????????backendNodeId:?6,
????????????????????????????childNodeCount:?0,
????????????????????????????children:?[
????????????????????????????????{
????????????????????????????????????backendNodeId:?6,
????????????????????????????????????localName:?"",
????????????????????????????????????nodeId:?7,
????????????????????????????????????nodeName:?"#text",
????????????????????????????????????nodeType:?3,
????????????????????????????????????nodeValue:?"光光光",
????????????????????????????????????parentId:?6,
????????????????????????????????}
????????????????????????????],
????????????????????????????localName:?"p",
????????????????????????????nodeId:?6,
????????????????????????????nodeName:?"P",
????????????????????????????nodeType:?1,
????????????????????????????nodeValue:?"",
????????????????????????????parentId:?5
????????????????????????}
????????????????????],
????????????????????parentId:?5
????????????????}
????????????}));
????????}?else?if?(message.method?===?'DOM.requestChildNodes')?{
????????????ws.send(JSON.stringify({
????????????????id:?message.id,
????????????????result:?{}
????????????}));
????????}
????});
返回的內(nèi)容如上,我們返回了一個(gè) P 標(biāo)簽,有 class 屬性,還有一個(gè)文本節(jié)點(diǎn)。
重啟下 backend 服務(wù),在 frontend 里重連一下,你就會(huì)發(fā)現(xiàn) frontend 顯示了我們返回的 DOM 信息:

經(jīng)過(guò)這兩個(gè)案例,我們就搞明白了 Chrome DevTools frontend 是怎么和 backend 交互的。
看到自己模擬 DOM 信息這部分,不知道你是否會(huì)想到跨端引擎呢。
跨端引擎就是通過(guò)前端的技術(shù)來(lái)描述界面(比如也是通過(guò) DOM),實(shí)際上用安卓和 IOS 的原生組件來(lái)做渲染。
它的調(diào)試工具也是需要顯示 DOM 樹(shù)的信息的,但是因?yàn)椴⒉皇蔷W(wǎng)頁(yè),所以不能直接用 Chrome DevTools。
那如何用 Chrome DevTools 來(lái)調(diào)試跨端引擎呢?
看完上面兩個(gè)案例,相信你就會(huì)有答案里。只要對(duì)接了 CDP,自己實(shí)現(xiàn)一個(gè) backend,把 DOM 樹(shù)的信息,通過(guò) CDP 的格式傳給 frontend 就可以了。
自定義的調(diào)試工具幾本都是前端部分集成下 Chrome DevTools frontend,后端部分實(shí)現(xiàn)下對(duì)接 CDP 的 ws 服務(wù)來(lái)實(shí)現(xiàn)的。
跨端引擎的調(diào)試工具我們知道怎么實(shí)現(xiàn)了,那小程序引擎呢?
小程序引擎的調(diào)試工具更簡(jiǎn)單,因?yàn)樗鼘?shí)際上渲染是用的網(wǎng)頁(yè),有 CDP 的 backend,可以直接和 frontend 對(duì)接,不用自己實(shí)現(xiàn) CDP 交互。
我下載了 vivo 的快應(yīng)用開(kāi)發(fā)工具,它有編輯器、調(diào)試器、模擬器這幾部分:

模擬器渲染的內(nèi)容能夠在調(diào)試器里調(diào)試,這也是通過(guò) WebSocket 通信的么?
其實(shí)不是,Chrome DevTools 支持幾種信道,WebSocket 是最常見(jiàn)的一種,還有就是嵌入的時(shí)候會(huì)通過(guò)全局函數(shù)通信,electron 會(huì)通過(guò) ipc 的方式通信等等。
比如 WebSocket 時(shí)的通信實(shí)現(xiàn)是這樣的:

而 electron 環(huán)境下是這樣的:

嵌入到一個(gè)環(huán)境的時(shí)候是這樣的:

這也是為什么文章最開(kāi)始我說(shuō) Chrome DevTools 和 Chrome 通過(guò) WebSocket 通信是不準(zhǔn)確的,其實(shí)是通過(guò)全局函數(shù)的方式。
而且,像上面那種在一個(gè)窗口里渲染,在另一個(gè)窗口里調(diào)試的這種需求,electron 直接提供了 api 來(lái)支持。

使用 setDevToolsWebContents 的 api,就可以讓 devtools 的 frontend 顯示在任意的窗口里。
所以說(shuō),小程序的調(diào)試工具實(shí)現(xiàn)起來(lái)還是很簡(jiǎn)單的,不但 CDP 交互不用自己實(shí)現(xiàn),而且一個(gè)窗口渲染,一個(gè)窗口顯示Chrome DevTools frontend 這種功能 electron 都已經(jīng)提供了。
上面我們都是自己實(shí)現(xiàn)的 backend,那能自己實(shí)現(xiàn) frontend 么?
當(dāng)然也是可以的。
我們通過(guò)命令行的方式把 chrome 跑起來(lái),通過(guò) remote-debugging-port 指定 backend 的端口:
/Applications/Google\?Chrome.app/Contents/MacOS/Google\?Chrome?--remote-debugging-port=9222
然后實(shí)現(xiàn)個(gè) WebSocket 客戶(hù)端連上就可以了。
當(dāng)然自己實(shí)現(xiàn) CDP 的交互還是挺麻煩的,chrome 給提供了一個(gè)工具包 chrome-remote-interface,可以用 api 的方式來(lái)組織代碼。
const?CDP?=?require('chrome-remote-interface');
async?function?test()?{
????let?client;
????try?{
????????client?=?await?CDP();
????????const?{?Page,?DOM,?Debugger?}?=?client;
????????//...
????}?catch(err)?{
????????console.error(err);
????}
}
test();
我們測(cè)試一下 DOM 部分的協(xié)議:
const?CDP?=?require('chrome-remote-interface');
const?fs?=?require('fs');
async?function?test()?{
????let?client;
????try?{
????????client?=?await?CDP();
????????const?{?Page,?DOM,?Debugger?}?=?client;
????????await?Page.enable();
????????await?Page.navigate({url:?'https://baidu.com'});
????????await?DOM.enable();
????????const?{?root?}?=?await?DOM.getDocument({
????????????depth:?-1
????????});
????????
????}?catch(err)?{
????????console.error(err);
????}
}
test();
打個(gè)斷點(diǎn),看下 backend 返回的消息:

是不是很熟悉?
不過(guò)這次是真實(shí)的 DOM.getDocument 的消息。
我們自己實(shí)現(xiàn)了 frontend,對(duì)接了真實(shí) backend,之前也自己實(shí)現(xiàn)了 backend,對(duì)接了真實(shí) frontend。
那能不能自己實(shí)現(xiàn) frontend,對(duì)接自己實(shí)現(xiàn)的 backend 呢?
當(dāng)然可以,不過(guò)這樣就沒(méi)必要用 CDP 了,自己創(chuàng)建一套協(xié)議不香么?
其實(shí) Vue DevTools 和 React DevTools 就是自己定制的一套協(xié)議。
它們都是以 Chrome 插件的方式存在的,我們要先了解下 Chrome 插件,準(zhǔn)確的說(shuō)是 Chrome DevTools 插件:

它包含三部分:content script、background page、 devtools page。
content script 是可以獲取 DOM 的,但是不能訪(fǎng)問(wèn)用戶(hù)的 JS。這很容易理解,獲取 DOM 是插件需要的功能,但是為了安全,又限制了只能訪(fǎng)問(wèn) DOM。
background page 隨瀏覽器打開(kāi)就啟動(dòng),瀏覽器關(guān)閉才銷(xiāo)毀,存在周期很長(zhǎng)。這也很容易理解,插件是需要這么長(zhǎng)的存在周期的,完成一些跨頁(yè)面的功能。
devtools page 就是在 DevTools 的新 Tab 顯示的頁(yè)面了,它還可以向頁(yè)面注入 JS。
content script 和 devtools page 都可以和 background page 通信。
那基于這些功能,怎么實(shí)現(xiàn)一個(gè)自定義調(diào)試工具呢?
調(diào)試工具主要是 frontend、backend,再就是通信協(xié)議。
很容易想到可以這樣實(shí)現(xiàn):
devtools page 像頁(yè)面注入 backend.js,用來(lái)獲取運(yùn)行時(shí)的信息,然后傳遞給 devtools page。
devtools page 做 frontend 的顯示。
兩者之間的通信協(xié)議可以自定義。
vue devtools 就是這樣實(shí)現(xiàn)的:

你可以看到它的代碼分包:

backend 就是注入到頁(yè)面的 js,frontend 部分就是 devtools page 的顯示和交互的實(shí)現(xiàn)。
react devtools 也是差不多的原理。

只不過(guò)它還有 electron 的版本,用于 React Native 的調(diào)試:

至此,怎么基于 Chrome Devtools 自定義調(diào)試工具,如何基于 devtools extension 實(shí)現(xiàn)調(diào)試工具我們都了解了。
再回頭看下 CDP:
調(diào)試工具我們知道怎么實(shí)現(xiàn)了,那 CDP 只能用來(lái)調(diào)試么?
也不是,其實(shí)也可以起到遠(yuǎn)程控制的作用。
puppeteer 就是基于 CDP 實(shí)現(xiàn)的自動(dòng)化測(cè)試,它的原理是內(nèi)置了一個(gè) chromium,用調(diào)試模式啟動(dòng),會(huì)有一個(gè) ws 的 backend 的端口。然后用自己實(shí)現(xiàn)的 frontend 連接上,通過(guò) CDP 來(lái)控制它。
這就是 puppeteer 自動(dòng)化測(cè)試的原理,只不過(guò)它是在 node 環(huán)境下的。
瀏覽器環(huán)境能實(shí)現(xiàn)這種控制么?
也是可以的,Chrome 插件提供了 debugger 的 api,可以代替 frontend 來(lái)給 backend 發(fā)消息,從而控制瀏覽器:

其實(shí)這個(gè)和 puppeteer 的原理很像了,只不過(guò)是在瀏覽器里的。
有一個(gè)叫做 puppeteer IDE 的 chrome 插件,就是通過(guò) debugger 來(lái)實(shí)現(xiàn)了 puppeteer 的 api,從而可以在控制臺(tái)寫(xiě) puppeteer 的自動(dòng)化測(cè)試腳本,然后執(zhí)行。

感興趣可以去玩一下。
總結(jié)
Chrome DevTools 分為 frontend、backend,之間通過(guò) Chrome DevTools Protocol 通信,通信的信道有很多種,常用的是 WebSocket。
我們可以集成 chrome devtools frontend 的代碼,對(duì)接自己實(shí)現(xiàn)的 backend,從而實(shí)現(xiàn)調(diào)試的功能??缍艘娴恼{(diào)試就是這樣實(shí)現(xiàn)的。
小程序引擎調(diào)試工具的實(shí)現(xiàn)更簡(jiǎn)單,CDP 不用自己實(shí)現(xiàn),electron 還提供了在一個(gè)窗口顯示另一個(gè)窗口的 devtools frontend 的 api 可以直接用。
除了自己實(shí)現(xiàn) backend,我們也可以自己實(shí)現(xiàn) frontend,通過(guò) chrome-remote-interface 這個(gè)包可以用 api 來(lái)操作 CDP。
當(dāng)然,像 Vue DevTools、React DevTools 這種都是要自定義調(diào)試協(xié)議的,他們的實(shí)現(xiàn)原理是 devtools page 向頁(yè)面注入了 background 代碼,之間通過(guò)一定的協(xié)議通信,然后在 devtools 里面做渲染。
除了調(diào)試之外,CDP 還能實(shí)現(xiàn)遠(yuǎn)程控制, puppeteer 就是通過(guò) CDP 實(shí)現(xiàn)的自動(dòng)化測(cè)試。
chrome 插件的 debugger api 也可以發(fā)送 CDP 消息,可以實(shí)現(xiàn)和 puppeteer 類(lèi)似的效果。
其實(shí)調(diào)試還是挺簡(jiǎn)單的,就是 frontend、backend、調(diào)試協(xié)議,然后可能有很多種信道,不管是 Chrome DevTools 還是自定義調(diào)試工具都是這樣。
自己做一個(gè)調(diào)試工具的話(huà),可以集成 Chrome DevTools frontend,然后對(duì)接 backend??梢酝ㄟ^(guò) devtools extension 擴(kuò)展,往頁(yè)面注入 backend 代碼。也可以基于 electron 實(shí)現(xiàn)一個(gè)完全獨(dú)立的調(diào)試工具。
如果覺(jué)得這篇文章還不錯(cuò),來(lái)個(gè)【轉(zhuǎn)發(fā)、收藏、在看】三連吧,讓更多的人也看到~
如果你想加入高質(zhì)量前端交流群,或者你有任何其他事情想和我交流也可以添加我的個(gè)人微信?huab119?。
如果你有任何想法,歡迎在留言區(qū)和我留言,如果這篇文章幫助到了你,歡迎點(diǎn)贊和關(guān)注。
