Chrome DevTools 可以說是前端開發(fā)最常用的工具,無論是普通頁面、移動端 webview、小程序、甚至 node 應用,都可以用它來調(diào)試。
Chrome DevTools 提供的功能非常豐富,包含 DOM、debugger、網(wǎng)絡、性能等許多能力。
為什么 Chrome DevTools 能夠適用這么多場景?如何把 Chrome DevTools 移植到新的應用場景?Chrome DevTools 提供的功能我們能不能拆解出模塊單獨使用?今天我們來嘗試探索這些問題。
Chrome DevTools 組成 Chrome DevTools 包括四個部分:
調(diào)試器協(xié)議:devtools-protocol [1] ,基于 json rpc 2.0。
調(diào)試器后端:實現(xiàn)了調(diào)試協(xié)議的可調(diào)試實體,例如 chrome、node.js。
調(diào)試器前端:通常指內(nèi)嵌在 chrome 中的調(diào)試面板,通過調(diào)試器協(xié)議和調(diào)試器后端交互,除此之外還有 Puppeteer [2] ,ndb [3] 等。
消息通道:前后端通信方式,例如 websocket、usb、adb 等,本質(zhì)都是 socket 通信。
Chrome DevTools 我們可以看到,Chrome DevTools 的核心是調(diào)試器協(xié)議。
Chrome DevTools Protocol 協(xié)議按域「Domain」劃分能力,每個域下有 Method、Event 和 Types。
Method 對應 socket 通信的請求/響應模式,Events 對應 socket 通信的發(fā)布/訂閱模式,Types 為交互中使用到的實體。
例如:
# https://chromedevtools./devtools-protocol/1-3/Log Log Domain Provides access to log entries. Methods Log.clear Log.disable Log.enable Log.startViolationsReport Log.stopViolationsReport Events Log.entryAdded Types LogEntry ViolationSetting
一個調(diào)試器后端,應當實現(xiàn)對 Method 的響應,并在適當?shù)臅r候發(fā)布 Event。
一個調(diào)試器前端,應當使用 Method 請求需要的數(shù)據(jù),訂閱需要的 Event。
browser_protocol & js_protocol 協(xié)議分為 browser_protocol [4] 和 js_protocol [5] 兩種。
browser_protocol 是瀏覽器后端使用,js_protocol 是 node 后端使用。除此之外,還有對應的 Typescript 類型定義 [6] 。
js_protocol 只有以下四個域「Console、Schema 已廢棄」:
能力比 browser_protocol 少很多,這是因為頁面有相對固定的工作模式,node 應用卻千差萬別。
browser_protocol 主要有以下幾個域:
涉及了頁面開發(fā)的方方面面。
Chrome DevTools Frontend devtools-frontend 即調(diào)試器前端,我們平常使用的調(diào)試面板,其源碼可以從 ChromeDevTools/devtools-frontend [7] 獲得。我們先來看一下它是怎么工作的。
項目結(jié)構(gòu) 從 ChromeDevTools/devtools-frontend [8] 下載源碼后,我們進入 front_end 目錄,可以看到如下結(jié)構(gòu):
# tree -L 1 . ├── accessibility ├── accessibility_test_runner │ ├── AccessibilityPaneTestRunner.js │ └── module.json ├── animation ├── application_test_runner ├── axe_core_test_runner ... ├── input ├── inspector.html ├── inspector.js ├── inspector.json ├── network ├── network_test_runner ├── node_app.html ├── node_app.js ├── node_app.json ├── worker_app.html ├── worker_app.js └── worker_app.json
front_end 目錄下的每一個 json 文件會有一個同名的 js 文件,有的還會有一個同名的 html 文件。
它們都代表一個應用,如 inspector.json 是其配置文件。如果此應用有界面,則帶有 html,可以在瀏覽器中打開 html 運行應用。
我們可以看到熟悉的應用,inspector、node、devtools、ndb 等等。
devtools_app 即我們常用的調(diào)試面板,如圖所示:
devtools inspector 在 devtools_app 基礎(chǔ)上增加了頁面快照,可以實時看到頁面的變化,并且可以在頁面快照上交互,如圖所示:
inspector 以 devtools_app 為例,我們來看配置文件的語義:
// devtools_frontend/front_end/devtools_app.json { 'modules' : [ { 'name' : 'emulation' , 'type' : 'autostart' }, { 'name' : 'inspector_main' , 'type' : 'autostart' }, { 'name' : 'mobile_throttling' , 'type' : 'autostart' }, ... { 'name' : 'timeline' }, { 'name' : 'timeline_model' }, { 'name' : 'web_audio' }, { 'name' : 'media' } ], 'extends' : 'shell' , 'has_html' : true }
modules 表示此應用包含的模塊,每個模塊都對應 front_end 目錄下的一個目錄。 extends 表示此應用是否繼承自另外一個應用,devtools_app 繼承自 shell 應用,我們可以在 front_end 目錄下看到 shell.js、shell.json。 has_html 表示此應用有 html 界面,即同名的 devtools_app.json。 我們再來看一下模塊,所有的模塊都平級放在 front_end 目錄下,不存在嵌套,每個模塊都有一個 module.json 文件,表示此模塊的配置。
{ 'extensions' : [ { 'type' : 'view' , 'location' : 'drawer-view' } ], 'dependencies' : [ 'elements' ], 'scripts' : [], 'modules' : [ 'animation.js' , 'animation-legacy.js' , 'AnimationUI.js' ], 'resources' : [ 'animationScreenshotPopover.css' , 'animationTimeline.css' ] }
resources 表示此模塊包括的靜態(tài)資源,主要是 css。 之所以有這些配置,是因為,front_end 有自己的一套模塊加載邏輯,和通常的 node 應用和前端應用都不一樣。
初始化 front_end 各個應用初始化的過程類似,基本如下:
從對應的 json 文件中加載配置,并根據(jù)配置加載需要的模塊 // devtools-frontend/front_end/RuntimeInstantiator.js export async function startApplication (appName ) { console .timeStamp('Root.Runtime.startApplication' ); const allDescriptorsByName = {}; for (let i = 0 ; i < Root.allDescriptors.length; ++i) { const d = Root.allDescriptors[i]; allDescriptorsByName[d['name' ]] = d; } if (!Root.applicationDescriptor) { // 加載應用配置 <appName>.json let data = await RootModule.Runtime.loadResourcePromise(appName + '.json' ); Root.applicationDescriptor = JSON .parse(data); let descriptor = Root.applicationDescriptor; while (descriptor.extends) { // 加載父級配置直到?jīng)]有父級 data = await RootModule.Runtime.loadResourcePromise(descriptor.extends + '.json' ); descriptor = JSON .parse(data); Root.applicationDescriptor.modules = descriptor.modules.concat(Root.applicationDescriptor.modules); } } const configuration = Root.applicationDescriptor.modules; const moduleJSONPromises = []; const coreModuleNames = []; for (let i = 0 ; i < configuration.length; ++i) { const descriptor = configuration[i]; const name = descriptor['name' ]; const moduleJSON = allDescriptorsByName[name]; // 根據(jù)每個模塊的 module.json 加載模塊 if (moduleJSON) { moduleJSONPromises.push(Promise .resolve(moduleJSON)); } else { moduleJSONPromises.push( RootModule.Runtime.loadResourcePromise(name + '/module.json' ).then(JSON .parse.bind(JSON ))); } } // ... }
雖然 js 代碼都是通過 import 來引用依賴,但是 front_end 并非使用 import 來加載模塊,而是自己寫了一個模塊加載邏輯,先請求模塊文件,然后在根據(jù)依賴關(guān)系把代碼 eval。
// devtools-frontend/front_end/root/Runtime.js function evaluateScript (sourceURL, scriptSource ) { loadedScripts[sourceURL] = true ; if (!scriptSource) { // Do not reject, as this is normal in the hosted mode. console .error('Empty response arrived for script \'' + sourceURL + '\'' ); return ; } self.eval(scriptSource + '\n//# sourceURL=' + sourceURL); }
作為調(diào)試器前端,socket 通信是不可或缺的,初始化的主要工作就是對調(diào)試器后端建立 socket 連接,準備好調(diào)試協(xié)議。
對于頁面應用來說,還需要初始化 UI,front_end 未使用任何渲染框架,全部都是原生 DOM 操作。
// devtools-frontend/front_end/main/MainImpl.js new MainImpl(); // 初始化SDK(協(xié)議),初始化socket連接,初始化通信
應用 遠程調(diào)試 我們可以用 front_end 來實現(xiàn)遠程調(diào)試頁面,例如:用戶在自己的 PC、APP 上操作頁面,開發(fā)人員在另外一臺電腦上觀察頁面、網(wǎng)絡、控制臺里發(fā)生的變化,甚至通過協(xié)議控制頁面。
開啟調(diào)試端口 不同后端打開調(diào)試端口的方式不同,以 chrome 為例:
chrome 和內(nèi)嵌的調(diào)試面板使用 Embedder channel 通信,這個消息通道不能被用來做遠程調(diào)試,遠程調(diào)試我們需要使用 websocket channel。
使用 websocket channel 我們還需要打開 chrome 的遠程調(diào)試端口,以命令行參數(shù) remote-debugging-port 打開 chrome。
[path]/chrome.exe --remote-debugging-port=9222
或者使用腳本 devtools-frontend/scripts/hosted_mode/launch_chrome.js
。
調(diào)試端口打開后,chrome 會啟動一個內(nèi)置的 http 服務,我們可以從中獲取 chrome 的基本信息,其中最重要的是各個 tab 頁的 websocket 通信地址。
chrome 提供的 http 接口如下,訪問方式全部為 GET:
/json/protocol 獲取當前 chrome 支持的協(xié)議,協(xié)議為 json 格式。
/json/list 獲取可調(diào)試的目標列表,一般每個 tab 就是一個可調(diào)試目標,可調(diào)試目標的 webSocketDebuggerUrl 屬性就是我們需要的 websocket 通信地址。例如:
[{ 'description' : '' , 'devtoolsFrontendUrl' : '/devtools/inspector.html?ws=localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02' , 'faviconUrl' : 'https://github./favicon.ico' , 'id' : '8ED9DABCE2A6BD36952657AEBAA0DE02' , 'title' : 'GitHub - Unitech/pm2: Node.js Production Process Manager with a built-in Load Balancer.' , 'type' : 'page' , 'url' : 'https://github.com/Unitech/pm2' , 'webSocketDebuggerUrl' : 'ws://localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02' }]
/json/new 創(chuàng)建新的 tab 頁
/json/activate/:id 根據(jù) id 激活 tab 頁
/json/close/:id 根據(jù) id 關(guān)閉 tab 頁
/json/version 獲取瀏覽器/協(xié)議/v8/webkit 版本,例如:
{ 'Browser' : 'Chrome/80.0.3987.149' , 'Protocol-Version' : '1.3' , 'User-Agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36' , 'V8-Version' : '8.0.426.27' , 'WebKit-Version' : '537.36 (@5f4eb224680e5d7dca88504586e9fd951840cac6)' , 'webSocketDebuggerUrl' : 'ws://localhost:9222/devtools/browser/ad007235-aa36-4465-beb1-70864067ea49' }
注意:這些接口都不能跨域,可以通過服務器訪問,或者直接在瀏覽器中打開,但是不能使用 ajax 訪問。
連接 獲取到 webSocketDebuggerUrl 后,我們就可以用此連接來調(diào)試頁面。front_end 下的 devtool、inspector 等應用均可使用。
觀察 初始化 socket 鏈接的代碼可以得知,我們需要把 webSocketDebuggerUrl 以 url 參數(shù)的形式傳給應用,參數(shù)名為 ws。
// devtools-frontend/front_end/sdk/Connections.js export function _createMainConnection (websocketConnectionLost ) { const wsParam = Root.Runtime.queryParam('ws' ); const wssParam = Root.Runtime.queryParam('wss' ); if (wsParam || wssParam) { const ws = wsParam ? `ws://${wsParam} ` : `wss://${wssParam} ` ; return new WebSocketConnection(ws, websocketConnectionLost); } if (Host.InspectorFrontendHost.InspectorFrontendHostInstance.isHostedMode()) { return new StubConnection(); } return new MainConnection(); }
我們在 front_end 目錄下啟動靜態(tài)服務器。
serve -p 8002
然后訪問 http://localhost:8002/inspector?ws=localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02
我們可以看到頁面上的一切變化都會出現(xiàn)在 inspector 的界面中。
跨域 如果前端和后端都在同一網(wǎng)段,我們使用以上方式就可以進行調(diào)試了,但是如果前后端在不同的內(nèi)網(wǎng)內(nèi),我們?nèi)绾螌崿F(xiàn)遠程調(diào)試?
只要我們有一臺放在公網(wǎng)的服務器就可以調(diào)試。
前端和后端都在各自的內(nèi)網(wǎng)內(nèi),因此相互之間肯定無法直接訪問。但是它們都可以訪問公網(wǎng)的服務器,并且,websocket 是可以跨域的。
因此我們可以通過兩次轉(zhuǎn)發(fā),讓不同內(nèi)網(wǎng)的前端和后端交互,具體步驟如下:
創(chuàng)建一個轉(zhuǎn)發(fā)用的 websocket 服務,放在公網(wǎng)。
我們在被調(diào)試的頁面中增加一個自定義的 launcher.js,對公網(wǎng)的 websocket 服務建立連接,把頁面的基本信息傳遞給服務器,同時通過 json/list 接口找出自身的 webSocketDebuggerUrl 建立連接。
注意:因為 json/list 是 http 接口,無法跨域,這一步必須手動獲取,然后把 webSocketDebuggerUrl 放在 url 參數(shù)上傳給 launcher.js
手動獲取 webSocketDebuggerUrl 把 front_end 頁面 url 的 ws 參數(shù)改為公網(wǎng)的 websocket 服務。 這樣,我們的 socket 鏈路上有了四個節(jié)點,分別是:
server 和 laucher 完全作為轉(zhuǎn)發(fā)器,轉(zhuǎn)發(fā)兩邊傳來的信息,即可實現(xiàn) front_end 到 debugger 的交互。
注意:如果 front_end 請求了 Network.enable, 就不能把 laucher.js 所在的頁面作為調(diào)試頁面,因為 laucher.js 收到 debugger 傳來的數(shù)據(jù)會觸發(fā) Network.webSocketFrameReceived 推送,這個推送本身又會觸發(fā) Network.webSocketFrameReceived ,造成無限循環(huán)。處理方式有兩種,一是攔截掉 Network.enable 請求,這樣會取消掉所有的 Network 的推送。二是不把 laucher.js 所在的頁面作為調(diào)試頁面,僅作數(shù)據(jù)中轉(zhuǎn)用。
遠程調(diào)試 websocket 服務代碼示例:
// server.js var WebSocketServer = require ('websocket' ).server;var http = require ('http' );var server = http.createServer(function (request, response ) { response.writeHead(404 ); response.end(); }); server.listen(3232 , function ( ) { console .log((new Date ()) + ' Server is listening on port 3232' ); }); wsServer = new WebSocketServer({ httpServer : server });var frontendConnection;var debugConnection; wsServer.on('request' , async function (request ) { var requestedProtocols = request.requestedProtocols; if (requestedProtocols.indexOf('frontend' ) != -1 ){ // 處理來自調(diào)試器前端的請求 frontendConnection = request.accept('frontend' , request.origin); frontendConnection.on('message' , function (message ) { if (message.type === 'utf8' ) { // 把調(diào)試器前端的請求直接轉(zhuǎn)發(fā)給被調(diào)試頁面 if (debugConnection){ debugConnection.sendUTF(message.utf8Data) }else { frontendConnection.sendUTF(JSON .stringify({msg :'調(diào)試器后端未準備好,先打開被調(diào)試的頁面' })) } } }) frontendConnection.on('close' , function (reasonCode, description ) { console .log('frontendConnection disconnected.' ); }); } if (requestedProtocols.indexOf('remote-debug' ) != -1 ){ // 處理來自被調(diào)試頁面的請求 debugConnection = request.accept('remote-debug' , request.origin); debugConnection.on('message' , function (message ) { if (message.type === 'utf8' ) { var feed = JSON .parse(message.utf8Data); if (feed.type == 'remote_debug_page' ){ // 確認連接 debugConnection.sendUTF(JSON .stringify({'type' :'start_debug' })); }else if (feed.type == 'start_debug_ready' ){ // 被調(diào)試頁面已連接好 } else { // 把被調(diào)試頁面的數(shù)據(jù)全部轉(zhuǎn)發(fā)給調(diào)試器前端 if (frontendConnection){ frontendConnection.sendUTF(message.utf8Data) }else { console .log('無法轉(zhuǎn)發(fā)給frontend,沒有建立連接' ) } } } }); debugConnection.on('close' , function (reasonCode, description ) { console .log((new Date ()) + ' Peer remote' + debugConnection.remoteAddress + ' disconnected.' ); }); } });
laucher.js 代碼示例:
var host = 'localhost:3232' var ws = new WebSocket(`ws://${host} ` ,'remote-debug' ); var search = location.search.slice(1 );var urlParams = {}; search.split('&' ).forEach(s => { var pair = s.split('=' ); if (pair.length == 2 ){ urlParams[pair[0 ]] = pair[1 ] } }) ws.onopen = function ( ) { ws.send(JSON .stringify({type :'remote_debug_page' ,url :location.href})) }; ws.onmessage = function (evt ) { var feed = JSON .parse(received_msg); if (feed.type == 'start_debug' ) { // 連接到 webSocketDebuggerUrl var debugWS = new WebSocket(`ws://${urlParams.ws} ` ); debugWS.onopen = function ( ) { ws.send(JSON .stringify({type :'start_debug_ready' })); // 確認可以開始調(diào)試 ws.onmessage = function (evt ) { // 轉(zhuǎn)發(fā)到 debugger debugWS.send(evt.data); } ws.onclose = function (evt ) { debugWS.close() } } debugWS.onmessage = function (evt ) { ws.send(evt.data); // 轉(zhuǎn)發(fā)到 server } debugWS.onclose = function ( ) { ws.send(JSON .stringify({type :'remote_page_lost' ,url :location.href})) }; } }; ws.onclose = function ( ) { console .log('連接已關(guān)閉...' ); };
回放 使用 inspector 時我們可以發(fā)現(xiàn),只要開啟了 Page.enable 和 Network.enable,就可以一直接收到調(diào)試器后端推送的頁面快照和網(wǎng)絡請求數(shù)據(jù)。
我們可以略微改造一下 server.js 的代碼,把所有收到的推送數(shù)據(jù)打時間戳后保存到一個文件,持久化存儲起來。
if (message.type === 'utf8' ) { var feed = JSON .parse(message.utf8Data); if (feed.type == 'remote_debug_page' ){ debugConnection.sendUTF(JSON .stringify({'type' :'start_debug' })); }else if (feed.type == 'start_debug_ready' ){ writeStream = fs.createWriteStream(saveFilePath,{flags :'as' ,encoding : 'utf8' }); } else { // 全部轉(zhuǎn)發(fā)給 frontendConnection if (frontendConnection){ frontendConnection.sendUTF(message.utf8Data) }else { console .log('無法轉(zhuǎn)發(fā)給frontend,沒有建立連接' ) } // 保存數(shù)據(jù)到文件 if (feed.method)writeStream.write(message.utf8Data+'\n' ) } }
然后我們給 websocket 服務增加一個協(xié)議類型,和 inspector 建立連接后,讀取文件中保存的數(shù)據(jù),按照時間戳上的時間間隔推送數(shù)據(jù)。
這樣就實現(xiàn)了回放功能,把之前調(diào)試時的現(xiàn)場重現(xiàn)一遍。
if (requestedProtocols.indexOf('feedback' ) != -1 ){ feedbackConnection = request.accept('feedback' , request.origin); feedbackConnection.on('message' , function (message ) { // 忽略來的消息 }) const fileStream = fs.createReadStream(saveFilePath); const rl = readline.createInterface({ input : fileStream, crlfDelay : Infinity }); for await (const line of rl) { // 逐行讀取數(shù)據(jù) feedbackConnection.sendUTF(line) rl.pause(); setTimeout(_ => {rl.resume()},1000 ) } feedbackConnection.on('close' , function (reasonCode, description ) { console .log('feedbackConnection disconnected.' ); }); }
甚至可以更進一步,創(chuàng)建一個 websocket 服務作為調(diào)試器前端,模擬 inspector 發(fā)送請求的邏輯并保存推送數(shù)據(jù)到文件,這樣就實現(xiàn)了一個錄制服務器,可以隨時錄制調(diào)試現(xiàn)場,然后在需要的時候播放,因為記錄了時間戳,pause、seek、resume、stop 都可以實現(xiàn)。
devtools-frontend 的調(diào)用方式 一般來說,我們習慣用 require/import 的方式調(diào)用模塊,devtools-frontend 雖然也是個 npm 包 ,chrome-devtools-frontend [9] ,但是卻不方便用 require/import 的方式直接引用。
主要是因為之前所述的 front_end 應用有自己的一套模塊加載邏輯,應用的 js、json 配置文件必須在同一個目錄下,模塊也必須在同一個目錄下,否則就會出現(xiàn)路徑錯誤。
如果僅使用 front_end 的某個模塊,還可以用 require/import 來引用。
如果想創(chuàng)建一個新的應用,最好是把整個 front_end 復制過來修改。
Chrome DevTools Extensions 如果想在 chrome 內(nèi)嵌的調(diào)試面板中增加自定義的能力,可以用 chrome 插件的方式實現(xiàn),例如vue-devtools [10] 。
參考資料 ChromeDevTools/awesome-chrome-devtools [11]
ChromeDevTools/devtools-protocol [12]
參考資料 [1] devtools-protocol: https://github.com/chromedevtools/devtools-protocol
[2] Puppeteer: https://github.com/GoogleChrome/puppeteer/
[3] ndb: https://github.com/GoogleChromeLabs/ndb
[4] browser_protocol: https://github.com/ChromeDevTools/devtools-protocol/blob/master/json/browser_protocol.json
[5] js_protocol: https://github.com/ChromeDevTools/devtools-protocol/blob/master/json/js_protocol.json
[6] Typescript 類型定義: https://github.com/ChromeDevTools/devtools-protocol/tree/master/types
[7] ChromeDevTools/devtools-frontend: https://github.com/ChromeDevTools/devtools-frontend
[8] ChromeDevTools/devtools-frontend: https://github.com/ChromeDevTools/devtools-frontend
[9] chrome-devtools-frontend: https://www./package/chrome-devtools-frontend
[10] vue-devtools: https://github.com/vuejs/vue-devtools
[11] ChromeDevTools/awesome-chrome-devtools: https://github.com/ChromeDevTools/awesome-chrome-devtools
[12] ChromeDevTools/devtools-protocol: https://github.com/chromedevtools/devtools-protocol