DevTools 實現原理與性能分析實戰
一、引言
從 2008 年 Google 釋放出第一版的 Chrome 後,整個 Web 開發領域彷彿被注入了一股新鮮血液,漸漸打破了 IE 一家獨大的時代。Chrome 和 Firefox 是 W3C Web 標準的堅定支援者,隨著這兩款開源瀏覽器市場份額逐漸加大,迎來了開發者的春天。這就迎來了一個新的職業分工——前端工程師 frontend-engineer,前端工程師促進了 Web 應用的繁榮,功能強大的調試工具必不可少。Google 基於開源的基礎上順勢推出了 DevTools,廣受網頁開發者的好評,隨即也推動了 Chrome 的在商業的成功。
本文通過分析 Chrome 的 DevTools 的技術實現,特別是在瀏覽器內核中的實現部分,來展示這款被萬千開發者所喜愛的開發工具背後的秘密。本文適合閱讀對象主要有前端開發者、有志於開發 Hybrid 應用調試工具或重寫 webdriver 實現對 Chrome 或 WebView 控制的應用工程師。
註:本文所有程式碼分析,基於 Android Chromium 87.0.4280.141 版本分析而成。由於筆者所在團隊主要從事 Android 平台的 Blink 內核開發,所以分析過程主要集中在移動端,其他平台只是數據通路的區別,實現原理差別不大。
二、網頁調試工具發展史
2006 年之前,這屬於 IE 時代,在 IE 時代編寫 JavaScript 程式碼時的調試手段,主要靠 window.alert() 或將調試資訊輸出到網頁上來分析邏輯 bug。這種硬 debug 的手段,不亞於系統底層開發,往往一個小問題要花費掉一整天時間,開發效率極低。
2006 年 1 月份,Apple 的 WebKit 團隊釋放出第一版本的 Web Inspector,此版本功能還比較簡樸,僅可以查看 DOM 節點的繼承關係,節點所應用了哪些 CSS 的規則。但此版本已經奠定了今後多年的網頁調試工具的原型,具有劃時代意義。
WebKit 團隊的迭代速度非常快,2006 年 6 月發布了一個重量級功能,JavaScript 的斷點調試功能,此時已經具備開發者神器的雛形。
同時開源陣營出現一款 Firefox 的插件 Firebug,專註於 Web 開發的調試,奠定了現代 DevTools 的 Web UI 的布局。早期版本就支援了 JavaScript 的調試,CSS Box 模型可視化展示,支援 HTTP Archive 的性能分析等優秀特性,後來的 DevTools 參考了此插件的功能和產品定位。2016 年 Firebug 整合到 Firefox 內置調試工具,2017 年 Firebug 停止更新,一代神器就此謝幕。
此時迎來了一個開源界的狠角色 Google 團隊,基於 WebKit 加入瀏覽器研發,推出的 Chrome 以「安全、極速、更穩定」吸引了不少 IT 極客的關注,同時開發者工具這方面, Google 吸收多款調試工具的優秀功能,推出了今天的主角 DevTools。
早期版本現在看起來這個布局有點簡陋,但這可是十幾年前的作品。支援 DOM + CSS 查看,查看資源載入分析,腳本調試以及性能調試。現在開發中常用 DevTools 的功能,基本也就這幾個功能。
那個年代的 DevTools,基本是在跟隨 Firebug 的功能,只是交互方式上的差異。2007 年 Steve Jobs 發布了第一代 iPhone 手機,Google 相繼推出了 Android 手機,互聯網的發展來到移動互聯網時代。DevTools 此時開始超越同類工具,支援了遠程真機調試。Chrome 是多進程架構,DOM 和 JavaScript 是運行在子進程中的,所以 DevTools 的底層實現,已與同類產品完全不同。Chrome 的架構師將 DevTools 實現架構調成在 client-server 模式,這個架構讓遠程真機調試成為可能。為了方便網路數據傳輸,Chrome 設計出了一套數據封裝協議 Chrome DevTools Protocal(CDP),接下來的幾年,這個架構的調整在開源世界大放異彩。
yan Dahl 基於 Chromium 的 JavaScript 虛擬機 V8 設計了 Node.js,Node.js 的面世讓 JavaScript 這款 Web 腳本語言走出了瀏覽器,打開了服務端編程、桌面編程可以使用 JavaScript 語言的新局面。依託於 DevTools 的 client-server 架構以及 Node.js 的開發者的數量不斷增加,DevTools 也迅速出圈,Chrome 團隊於 2016 年開始支援 Node.js 的調試。DevTools 已從一款 Web 調試工具,演變成 JavaScript 生態中重要一員,助力更多的開發者開發更多優秀程式碼。Node.js 的生態都離不開 DevTools ,比如桌面開發框架 Electron、開發者喜愛的編輯器 Visual Studio Code 、前端架構 Vue.js、Facebook 開源 Android 性能分析工具 Stetho等。
三、DevTools 架構
DevTools 是 client-server 架構,client 就是用戶操作的 Web UI 介面,負責接收用戶操作指令,然後將操作指令發往瀏覽器內核或 Node.js 中進行處理,並將處理結果數據展示在 Web UI 上。server 啟動了兩類服務,一種 HTTP 服務;另一種 WebSocket 服務。
HTTP 服務提供內核資訊查詢能力。比如獲取內核版本、獲取調試頁的列表、啟動或關閉調試。
WebSocket 服務提供與內核進行真實數據通訊的能力,負責 Web UI 傳遞過來的所有操作指令的分發和處理,並將結果送回 Web UI 進行展示。
下圖展示出了 Android DevTools 的整體架構圖,從左側開發者通過 Web UI 的發起的操作命令,是怎麼一步一步地將操作命令,傳遞到手機中的 Browser Core(Browser Core 運行 Chrome 瀏覽器內核的應用,比如 Chrome 瀏覽器、Android WebView、NodeJs 應用等)中執行的過程。
Android 平台巧妙地使用 ADB forward 能力,解決了 PC 上的 WebUI 與 Android 手機中的 Chrome 內核的連接問題。輕鬆了實現了遠程調試的能力,不要小瞧這一實現,這對前端開發者效率提升是極大的。因為前端開發者的工作環境,目前來看基本是在 PC (Windows、Mac、Linux 統稱為 PC)下,通過遠程調試能力的實現,讓移動端的開發實現了所見即所得。
正是 Chrome 團隊基於網路通訊方式,作為 DevTools 底層通訊框架,才為後來的 Web 開發團隊百花齊放奠定了基礎。TCP/IP 是互聯網的基礎,沒有哪種語言或平台不支援 TCP/IP 的。DevTools 選型 TCP/IP 方式直接抹平了不同平台或系統框架之間的差異。
Chrome DevTools Protocol(簡稱CDP) 這組開放協議的推出,再一次將 DevTools 的實現,真正做到了跨平台。CDP 本質就是一組 JSON 格式的數據封裝協議,JSON 是輕量的文本交換協議,可以被任何平台任何語言進行解析。正因為此,官方推薦的支援 CDP 的語言庫多達近十種。Google 官方推薦了 Node.js 版本 Puppeteer ,通過 Puppeteer 完整地實現了 CDP 協議,為 Chrome 內核通訊的方式打了一個樣,接著開源世界陸續推出了多個語言版本的 CDP 的使用庫。關於 CDP 協議,在稍後的章節會詳細介紹。
Chrome 的架構師通過高度抽象能力,將 DevTools 的底層架構抽象成 TCP/IP 和 CDP 兩個部分,奠定了 DevTools 的跨平台跨終端的能力。當年 WebSocket 的實現方案還處在草案階段,Chrome 架構師就大膽地採用 WebSocket 實現了調試協議中的主協議部分。現在看來,開發者日常使用的頁面的實時截圖能力,可以實時觀察到遠程網頁中所展示的介面,這個實時性就是基於 WebSocket 來提供的。筆者還很佩服 Chrome 架構師的眼光和設計氣場,正是他們優秀的能力,將網頁開發者工具帶到新高度。
四、DevTools 通訊協議
Chrome DevTools Protocol(簡稱CDP)此協議包含兩部分 HTTP 和 WebSocket,DevTools 的 Web UI 將控制命令發往瀏覽器內核,其中的控制命令、參數以及返回值,都是通過 CDP 來進行封裝。命令的發送時,由 Web UI 進行封裝後,通過 WebSocket 發往瀏覽器內核。接收到瀏覽器內核回饋回結果後,再按協議進行解包,分發給Web UI。
為了分析 Web UI 與 Android 瀏覽器內核通訊過程,需要做一下環境準備。
4.1 環境準備
為了能訪問到內核中數據,瀏覽器內核需要開啟 DevTools Server ,PC Chrome 和 Android Chrome / WebView 的開啟方式略有不同。
PC Chrome 啟動時,增加一個啟動參數 -remote-debugging-port=9222 , 這樣 DevTools Server 就會偵聽本地的埠,可以向 //localhost:9222 發起 HTTP / WebSocket 請求,即可獲取 DevTools 中的數據。
對於 Android Chrome 與 WebView 略有差異,由於 WebView 默認是不開啟調試功能的,需要在客戶端手動開啟,才能啟動 Server。
// Android 4.4 以上 WebView 才真正使用 Blink 內核,所以需要在此版本及以上系統。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
WebView.setWebContentsDebuggingEnabled(true);
}
此時 Android Chrome / WebView 在手機內已啟動了 Server,但為了在 PC 上能夠訪問到,需要使用 ADB工具的埠轉發能力。
ADB 埠轉發
您可以使用 forward 命令設置任意埠轉發,將特定主機埠上的請求轉發到設備上的其他埠。以下示例設置了主機埠 6100 到設備埠 7100 的轉發:
adb forward tcp:6100 tcp:7100
通過 forward 可以打通 PC 與 Android 設備之間的網路相互訪問
Android Chrome / WebView 使用 unix domain socket 建立的 Server 端,此 socket 的連接符為:
chrome_devtools_remote和 webview_devtools_remote_分別為 chrome 和 WebView 的連接符。WebView 的連接由於可能不同應用都使用了 WebView,所以採用了進程 ID(PID)作為後綴來區分。
adb shell cat /proc/net/unix | grep "devtools_remote"
0000000000000000: 00000002 00000000 00010000 0001 01 528176 @chrome_devtools_remote
0000000000000000: 00000002 00000000 00010000 0001 01 276394 @webview_devtools_remote_23119
通過 ADB forward ,將 PC 與 Android 設備訪問打通,執行如下命令:
# 在 PC 上偵聽 9222 埠,對 localhost:9222 的請求將會轉發到 android 設備上的 webview_devtools_remote_23119 上
adb forward tcp:9222 localabstract:webview_devtools_remote_23119
至此,就可以在 PC 上通過 9222 來訪問 Android 設備中的調試頁面了。
4.2 HTTP 協議分析
4.2.1 獲取內核版本資訊
# 使用 curl 工具,GET //localhost:9222/json/version
curl //localhost:9222/json/version
{
"Android-Package": "com.vivo.browser",
"Browser": "Chrome/87.0.4280.141",
"Protocol-Version": "1.3",
"User-Agent": "Mozilla/5.0 (Linux; Android 8.1.0; vivo X20Plus A Build/OPM1.171019.011; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/87.0.4280.141 Mobile Safari/537.36",
"V8-Version": "8.7.220.31",
"WebKit-Version": "537.36 (@9f05d1d9ee7483a73e9fe91ddcb8274ebcec9d7f)",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser"
}
從上面返回值,可以得到如下幾個資訊:
-
Android-Package,使用 WebView 應用的包名。
-
Browser,內核的版本號。
-
Protocol-Version,為 CDP 的協議版本,當前版本為 1.3,從 1.0 開始,還有 1.1、1.2 等。
-
User-Agent,瀏覽器的 UA 資訊。
-
V8-Version,所使用的 JavaScript 引擎版本號。
-
WebKit-Version,由於 Blink 內核是基於 WebKit 537.36 版本開發,所以會有此版本資訊。
-
webSocketDebuggerUrl,這是 WebSocket 的調試 URL。
4.2.2 獲取可調試頁面列表
# 使用 curl 工具,GET //localhost:9222/json/list
curl //localhost:9222/json/list
[ {
"description": "{\"attached\":true,\"empty\":false,\"height\":1812,\"never_attached\":false,\"screenX\":0,\"screenY\":72,\"visible\":true,\"width\":1080}",
"devtoolsFrontendUrl": "//chrome-devtools-frontend.appspot.com/serve_rev/@9f05d1d9ee7483a73e9fe91ddcb8274ebcec9d7f/inspector.html?ws=localhost:9222/devtools/page/B86E67DEA526D5EEE83A170B1F62A72C",
"faviconUrl": "//mat1.gtimg.com/www/mobi/2017/image/logo/v0/192.png",
"id": "B86E67DEA526D5EEE83A170B1F62A72C",
"title": "騰訊網-QQ.COM",
"type": "page",
"url": "//xw.qq.com/#news",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/B86E67DEA526D5EEE83A170B1F62A72C"
}, {
"description": "{\"attached\":false,\"empty\":true,\"never_attached\":true,\"screenX\":0,\"screenY\":0,\"visible\":true}",
"devtoolsFrontendUrl": "//chrome-devtools-frontend.appspot.com/serve_rev/@9f05d1d9ee7483a73e9fe91ddcb8274ebcec9d7f/inspector.html?ws=localhost:9222/devtools/page/3F9E05905F1919D563DF01BAEC64D2E4",
"id": "3F9E05905F1919D563DF01BAEC64D2E4",
"title": "about:blank",
"type": "page",
"url": "about:blank",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/3F9E05905F1919D563DF01BAEC64D2E4"
} ]
返回了一個 JSON 的數組,每一個調試頁佔用一個數據元素,上面的返回值可以看出,筆者環境下 vivo 瀏覽器打開了兩個頁面,一個 //xw.qq.com/#news 和 about:blank。
-
description,是個 JSON 對象,展示當前頁面的狀態資訊。比如頁面寬、高、在螢幕上的偏移,WebView 是否已經 attached 到 view 上了,只有 attach 上的頁面,才會被展示出來,能否被調試。
-
devtoolsFrontendUrl,此值為一個 URL,就是日常使用 DevTools 的 WebUI 控制面板地址,這是個 Web APP 當訪問過一次後,會就快取一份在瀏覽器下。此頁面託管在某個在中國無法正常訪問地址,所以經常會出現打不開面板,而顯示白屏的情況。Chrome 瀏覽器在打包時會內置一份與當前內核匹配的 WebUI 版本,所以 Chrome 可以直接調試自己的頁面。
-
id,這是每個打開頁面隨機生成的 GUID 值,用於生成 WebSocket 鏈接,以區分不同頁面。
-
title,打開網頁的標題,對應網頁 head 中的 title 標籤內容。
-
type,頁面的類型,主要有以下幾類 page、iframe、worker 以及 service_worker 等。
-
URL,當前打開的頁面 URL。
-
webSocketDebuggerUrl,此參數為 WebSocket 連接的 URL。
HTTP 協議還有其他幾個子命令,比如 protocol、new、activate 等,主要是頁面控制類的,就不一一介紹了。
4.2.3 WebSocket 協議分析
WebSocket 協議由四部分組成: Domain 、Method 、 Event 和 Type 。
1)Domain,命名空間,類似 C++/Java 中的命名空間或包名,用於分割不同的命令。用於將眾多子命令按類劃分,方便使用者調用,以及防止 Method 同名衝突。以 1.3 版本的 CDP 協議,一共劃分出 15 個Domain。
-
Browser: 用於管理瀏覽器對象。
-
Debugger: 用於調試 JavaScript 的分類,比如斷點、調用棧等。
-
DOM: 所有 DOM 節點操作都在此 Domain 下,DOM 節點的修改,遍歷等。
-
DOMDebugger: 管理 DOM 節點調試的 Domain,DevTools 中節點修改斷點,就是通過這組 Domain 中提供的 Method 完成的。
-
Emulation: 此是一組環境模擬器集合,DevTools 中的修改設備尺寸、UserAgent 等是由這個 Domain 實現。
-
Input: 事件分發方法的集合。
-
IO: I/O 流操作集合。
-
Log: Log 控制 Method 集合。
-
Network: 瀏覽器網路通訊數據,可能通過此 Domain 進行捕獲。
-
Page: 基於 Blink 中的 Page 操作 Method 集合,比如刷新,打開 URL。
-
Performance: 集成了性能分析 Method。
-
Profiler: 取樣分析器的 Method 集成在此 Domain 下。
-
Runtime: 與 JavaScript 通訊的 Method 被集成此 Domain 下,比如執行 JavaScript 程式碼。
-
Security: 安全類操作,比如證書錯誤。
-
Target: DevTools 連接的一些控制類 Method 在此 Domain 下。
2)Method,方法名稱,每個 Domain 下都會有一組 Method,指明了具體操作瀏覽器內核的功能。有三部分組成:名稱 、 參數 和 返回值 。與 C++/Java 中方法描述一致。
-
名稱:Debugger.setBreakpointByUrl;
-
參數:lineNumber integer [,url string,urlRegex string,scriptHash string,columnNumber integer,condition string ];
-
返回值:breakpointId BreakpointId,actualLocation Location。
// Debugger.setBreakpointByUrl 到內核,帶上如下參數
{
"lineNumber":1,
"url":"snippet:///Script%20snippet%20%231",
"columnNumber":0,
"condition":""
}
// 將會收到內核的返回值,返回斷點成功資訊
{
"breakpointId":"1:1:0:snippet:///Script%20snippet%20%231",
"locations":[]
}
3)Event,通知事件,網頁會有很多狀態通知,需要同步到 WebUI 或其他控制端上來。Event 就是用於通知這些事件的。比如 DOM 屬性發生了變化時,將會收到 Dom.attributeModified 事件;將 JavaScript 傳遞到內核去執行時,將會收到內核發回來的 Debugger.scriptParsed 事件和參數,參數如下:
{
"scriptId":"238",
"url":"",
"startLine":0,
"startColumn":0,
"endLine":0,
"embedderName":"",
"endColumn":7,
"endLine":0,
"executionContextAuxData":{
"isDefault":true,
"type":"default",
"frameId":"2059AA1A2C1A535CF4C480DC01E7FDEC"
},
"frameId":"2059AA1A2C1A535CF4C480DC01E7FDEC",
"isDefault":true,
"type":"default",
"executionContextId":5,
"hasSourceURL":false,
"hash":"035a9e1738252e22523ed8f1c52d9dbf81abe278",
"isLiveEdit":false,
"isModule":false,
"length":7,
"scriptId":"238",
"scriptLanguage":"JavaScript",
"sourceMapURL":"",
"startColumn":0,
"startLine":0,
"url":""
}
4)Type,是 Method 或 Event 傳遞參數的複雜數據類型,這些類型與內核的對象相對應。比如 DOM.Node 類型就對應著 Blink 中的 DOM 節點。主要屬性如下:
-
nodeId: NodeId 也是 Type,節點 id,根據此值可以在內核找到對應的節點。
-
parentId: NodeId 也是 Type,父節點 id 。
-
nodeType: integer,節點類型。
-
nodeName: string,節點名稱。
-
nodeValue:string, 節點內容。
-
children: array,子節點數組。
-
attributes: array, 節點屬性數組 通過 Node 上這些屬性,就可以將 DOM 樹的節點在記憶體佔用描述出來。DevTools 的 Web UI 中 Element 面板,就是通過 DOM.getDocument Method 將一棵 DOM 樹展現出來。
通過 CDP 的這種數據組織方式,既可以傳遞控制命令來操作內核,也可以接收內核狀態通知(Event)。通過 CDP 可以讓瀏覽器做任何事情,而且得到的資訊遠比使用 Chrome 圖形介面還要多。因此, Google 推出 Chrome Headless 版本,被廣泛應用於 web 自動化測試、網頁爬蟲以及網頁沙箱等領域。
當調試移動端瀏覽器時,可以實時看到移動設備上的所瀏覽的螢幕,這是怎麼做到的呢?
其實,就是一張一張截圖通過 Page.screencastFrame 事件將 base64 後的圖片發回到 Web UI 中展示的。
從 Page.screencastFrame 通知事件帶回了圖片和描述資訊(Meta data):
{
"data":"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBw...",
"metadata":{
"deviceHeight":604,
"deviceWidth":360,
"offsetTop":60,
"pageScaleFactor":1,
"scrollOffsetX":0,
"scrollOffsetY":832.6666870117188,
"timestamp":1631018056.565802
},
"sessionId":2
}
通過描述資訊,即可將此圖片的資訊展示在 WebUI 上。一張截圖近 1M 的大小,由於 DevTools 利用了 WebSocket 的雙向長鏈接的特性,所以展示出來無比平滑和清晰。
4.3 DevTools 內核實現
以上章節,介紹了從 Web 開發者的角度出發,將操作命令傳遞到移動端 Browser Core 的一個整體流程,以及 CDP 通訊協議相關內容。本節重點介紹在 Browser Core 中的實現過程,先介紹 DevTools 在瀏覽器內核中實現,後面筆者會挑選 JavaScript 如何從字元串傳遞到 V8 中執行過程,展開來進行詳細介紹,這一行為的實現方案。
4.3.1 內核架構介紹
DevTools 以啟動一個 Web Server 為起點,然後將調用命令發到相應處理模組,整體架構圖如下:
DevTools 在內核中大體上分為四層:
-
Server 層,用於接收外部網路發過來的操作請求。
-
Agent 層,對於 Server 層發過來的請求,進行拆解,根據操作的類型不同,再分發給不同的 Agent 來處理。
-
Session 層,Session 是對不同的業務模組進行了一層抽象。過了 Session 層後,將會進入不同的業務模組,可以到達 V8, Blink 等。
-
業務層,就是具體的功能模組,比如 V8 模組,主要負責 JavaScript 的調試相關能力的支撐。
Server 層由 DevToolsManager 這個單例對象來管理,由於是單例所以一個進程只會存在一個 Manger 對象,從而防止被重複創建出多個,導致狀態錯亂。
4.3.2 Web Server 數據接收入口
Server 收到的請求都會分發給 DevToolsHttpHandler 類,此類負責網路 Client 發過來的數據請求響應和將處理結果發送回網路 Client, 此類有兩個重要方法 OnJsonRequest 和 OnWebSocketMessage ,分別用來處理 HTTP 協議和 WebSocket 協議。
void DevToolsHttpHandler::OnJsonRequest(
int connection_id,
const net::HttpServerRequestInfo& info) {
// 查詢內核版本資訊
if (command == "version") {
base::DictionaryValue version;
version.SetString("Protocol-Version",
DevToolsAgentHost::GetProtocolVersion());
// ...
SendJson(connection_id, net::HTTP_OK, &version, std::string());
return;
}
// 獲取內核所支援的協議
if (command == "protocol") {
DecompressAndSendJsonProtocol(connection_id);
return;
}
// 獲取可調試頁
if (command == "list") {
DevToolsManager* manager = DevToolsManager::GetInstance();
DevToolsAgentHost::List list =
manager->delegate() ? manager->delegate()->RemoteDebuggingTargets()
: DevToolsAgentHost::GetOrCreateAll();
RespondToJsonList(connection_id, info.GetHeaderValue("host"),
std::move(list));
return;
}
// 啟動一個新調試
if (command == "new") {
// ...
std::string host = info.GetHeaderValue("host");
std::unique_ptr<base::DictionaryValue> dictionary(
SerializeDescriptor(agent_host, host));
SendJson(connection_id, net::HTTP_OK, dictionary.get(), std::string());
return;
}
// 激活或關閉一個調試
if (command == "activate" || command == "close") {
// ...
SendJson(connection_id, net::HTTP_NOT_FOUND, nullptr,
"Unknown command: " + command);
}
void DevToolsHttpHandler::OnWebSocketRequest(
int connection_id,
const net::HttpServerRequestInfo& request) {
// 創建調試的 Agent
if (base::StartsWith(request.path, browser_guid_,
base::CompareCase::SENSITIVE)) {
scoped_refptr<DevToolsAgentHost> browser_agent =
DevToolsAgentHost::CreateForBrowser(
thread_->task_runner(),
base::BindRepeating(&DevToolsSocketFactory::CreateForTethering,
base::Unretained(socket_factory_.get())));
connection_to_client_[connection_id] =
std::make_unique<DevToolsAgentHostClientImpl>(
thread_->task_runner(), server_wrapper_.get(), connection_id,
browser_agent);
AcceptWebSocket(connection_id, request);
return;
}
connection_to_client_[connection_id] =
std::make_unique<DevToolsAgentHostClientImpl>(
thread_->task_runner(), server_wrapper_.get(), connection_id, agent);
// Accept websocket
AcceptWebSocket(connection_id, request);
}
// WebSocket 數據接收介面,所有 WebUI 的請求都通過此介面分發
void DevToolsHttpHandler::OnWebSocketMessage(int connection_id,
std::string data) {
auto it = connection_to_client_.find(connection_id);
if (it != connection_to_client_.end()) {
it->second->OnMessage(base::as_bytes(base::make_span(data)));
}
}
-
DevToolsHttpHandler::OnJsonRequest 用於響應 HTTP 請求,用於查詢內核狀態,比如內核版本、當前支援協議,將返回完整協議內容,方便開發者適配對應的支援。
-
DevToolsHttpHandler::OnWebSocketRequest 用於接收 WebSocket 的連接,根據此方法對不同的 Agent 對象進行了創建。
-
DevToolsHttpHandler::OnWebSocketMessage 所有調試請求數據,都經過此介面通過 Client 分發到不同的 Agent 上去。
Server 層數據響應時通過上面的三個介面來達到數據接收和分發的能力。
4.3.3 JavaScript 執行過程
V8 JavaScript 引擎用於解釋執行網頁中的 JavaScript 腳本,同時也可以通過 DevTools 接收外部傳遞過來的腳本,腳本在當前網頁的 Context 下執行,所以可以通過 JavaScript 來操作網頁行為,比如修改 DOM 節點屬性。CDP 中設計了執行 JavaScript 介面 Runtime.evaluate ,引方法的參數如下:
{
allowUnsafeEvalBlockedByCSP: false,
awaitPromise: false,
contextId: 14,
expression: "alert('hi');",
generatePreview: true,
includeCommandLineAPI: true,
objectGroup: "console",
replMode: true,
returnByValue: false,
silent: false
}
其中,最重要的一個參數就是 expression ,此為一個 string 類型的參數,用於存放需要執行的腳本內容。上例將會在網頁中彈出一個內容為 hi 的 alert 確認框。
V8 中有個專門的模組,V8RuntimeAgentImpl 用於支援 CDP 中 Runtime 的這個 Domain,當然也有 V8DebuggerAgentImpl 是用來支援 Debug 這個 Domain 的具體實現。V8RuntimeAgentImpl 中 evaluate 方法,就是用於負責接收 DevTools 發過來的執行請求。
void V8RuntimeAgentImpl::evaluate(
const String16& expression, Maybe<String16> objectGroup,
Maybe<bool> includeCommandLineAPI, Maybe<bool> silent,
Maybe<int> executionContextId, Maybe<bool> returnByValue,
Maybe<bool> generatePreview, Maybe<bool> userGesture,
Maybe<bool> maybeAwaitPromise, Maybe<bool> throwOnSideEffect,
Maybe<double> timeout, Maybe<bool> disableBreaks, Maybe<bool> maybeReplMode,
Maybe<bool> allowUnsafeEvalBlockedByCSP,
std::unique_ptr<EvaluateCallback> callback);
V8RuntimeAgentImpl::evaluate 會啟動一個 microtasks 來執行腳本,最終會走到 v8::internal::Execution::Call 中,Execution 模組會負責將腳本進行語法解析和編譯成位元組碼,最終調度到虛擬機器中運行。
執行流程如上圖所示,Web UI 發出執行腳本的字元串,WebSocket 的 OnWebSocketMessage 將會收到此命令,然後通過 DevToolsSession 逐層向 V8 分發。由於 Chrome 是多進程架構,分為Browser 進程和 Render 進程,之間通過 IPC 進行通訊。上圖左側在 Browser 端執行流程,右側為 Render 端執行流程。
Render 端的DevToolsSession::DispatchProtocolCommand 是一個重要的分發介面,所以發到 V8 或 Blink 的控制命令,都會經過此介面。接著就會將控制命令發送到 V8RuntimeAgentImpl,根據命令功能的不同,調度到不同功能模組進行處理。
4.4 網頁性能調優
4.4.1 性能分析面板介紹
DevTools 提供一組功能強大的性能分析工具,網路、JavaScript 調試、渲染、記憶體以及標準支援度檢測等。下面介紹 Performance 面板中一些性能分析時的一些功能。主介面被劃分為這幾塊:
1)幀率(FPS):線性展示了做 Performance 期間,網頁渲染的幀率。
2)CPU 使用率:CPU 佔用走勢圖
3)載入過程中截屏:定時採集了網頁截屏性能
4)網路載入時序:展示網路資源載入次序及耗時情況
5)幀耗時(Frames):展示了渲染每幀耗時情況,紅色表示存在耗時較長的幀。
6)Web Vitals 指標:Google 推薦一套性能體驗指標,下面會詳細介紹。
7)內核中主要執行緒:瀏覽器內核中存在多個執行緒各有分工,當出現耗時較長幀時,需要在這些執行緒中排查,具體哪個執行緒在耗時。主要分為這幾個:
-
Main,這是 Blink 主執行緒,負責網頁的排版、解析、JavaScript 執行等。
-
Raster,光柵化執行緒,用於將渲染對象轉化成 Bitmap。
-
GPU,硬體加速渲染執行緒,將 Texture 繪製到螢幕上。
-
Chrome_ChildIOThread,負責網路資源,文件操作。
-
Compositor,合成執行緒,負責將渲染時各個層,合成在一起然後進行光柵化。
-
ThreadPoolForegoundWorker,Worker 的工作執行緒池。
8)資訊面板:用於展示選擇模組詳細資訊,幾個指標含義:
-
Loading:網路請求和 HTML 解析耗時。
-
Scripting:JavaSript 解析、編譯、在虛擬機中執行,以及 GC 耗時。
-
Rendering:Blink 排版渲染耗時。
-
Painting:繪製耗時,主要包含繪製、合成、圖片解碼以及上屏。
-
System 和 Idle:是系統調度和空閑耗時。
4.4.2 性能分析常規思路
性能分析基本思路從問題入手,網頁常見性能問題,筆者遇到的主要有這幾種情形。
-
需要的資源沒有及時被請求回來。排除伺服器問題,資源請求發起太晚?資源太大?
-
網頁分層太多,導致 Rendering 和 Painting 時間過長。
-
記憶體佔用過多,頁面過於複雜、資源多且大、JavaScript 大塊資源持有生命周期太長。
-
動畫多且消失後未移除。JavaScript 的輪播動畫、CSS 的動畫、帶有動畫的圖片資源,比如 GIF, SVG、WebP 等。
-
事件偵聽不合理。事件偵聽過多且可能被高頻觸達,比如節點變化、Move 事件等。
總的來說,不論是網頁性能優化還是 Native 程式優化,只要協調好這兩個資源佔用即可:CPU + 記憶體。只要挖掘出問題點,性能問題都會迎刃而解,問題點的挖掘除了源碼級別的審查,DevTools 可以助一臂之力。
針對上面總結的常規場景,利用 DevTools 性能分析能力,先整體上審視 Profile 圖。
網路請求次序和時長是否合理;
Main Thread 的長任務是否合理。
從 Network 板塊觀察資源請求發起的順序,是否存在長耗時任務,阻塞著首屏展示資源載入,如果不保證需要的及時載入,就會長時間白屏。
資源問題就緒後,就需要排查哪些長耗時任務執行。先查看 Main Thread 中的 Long task,比如,上圖的 Long task 就是 Scripting 的佔了較長時間。通過 Bottom-Up / CallTree 查看具體的耗時點,相應地優化掉。
在排查具體優化點時,有個小技巧。通常開發環境都是在 PC 上進行模擬,當版本出去後,才能暴露出問題。由於移動設備的碎片化,很多用戶的設備,性能可能並不好。那如何在開發環境優化這類低配置機器上的表現呢?DevTools 提供了限流的模擬,可以限制網路制式為 2G/3G,CPU 降速。
在右上角有個「設置」,展開配置項目,可以看到 Network 和 CPU 的限流選項,選擇後重新錄製一下 Profile。
上面提到,網頁層數太多,極大地影響到網頁渲染性能。「網頁層數」 是什麼意思呢?目前,瀏覽器渲染引擎為了提升網頁繪製性能,繪製時會對網頁進行分層。這樣的好處就是,僅重繪修改過的層,其他層內容如果沒有變化,就不需要重新繪製,直接取上次繪製結果,從而提升繪製效率。不同的 WEB 引擎分層的策略不同,通常會將普通網頁、CSS 動畫、Canvas、WebGL、Fix 標籤等各分為一層。分層會帶來渲染效率的提升,但也會帶來記憶體的開銷,從而會影響到性能。DevTools 能否分析網頁層數嗎?可以,在上面的「設置」中有一個選項 「Enable advanced paint instrumentation(slow)」 啟用它,重新做一次性能錄製。
在 「資訊面板」 多了一個 「Layers」 標籤,選擇後將會看到網頁分層情況。如果存在不合理的分層,可以嘗試調整方式,將分層進行合併,從而達到提升性能。
4.4.3 Web Vitals
Web Vitals 是 Google 推出的一套 Web 性能與體驗兼顧的衡量標準。原先的衡量策略基本是基於 「首字」 和 「首屏」 來衡量,但從用戶角度和技術優化角度,這兩指標都存在這樣那樣的問題。所以, Google 推出了 Web Vitals 標準,並與 DevTools 進行配合,方便開發者在開發階段,就識別出 Web 的性能問題。由於標準一直隨著時代的發展,不斷變化,開發者一直追著指標的變化有點吃不消,好在 Google 明確表示,目前推出的三個指標,短時間內不會變,筆者就不清楚這個短時間是多長時間。
第一個指標:Largest Contentful Paint (LCP),大面積鋪滿時間點,2.5 秒以內算優秀。主要是指有大面積的文字、圖片被展示出來,就算達到了 LCP。
第二個指標:First Input Delay(FID),首次可響應外部輸入事件的時間點,100 ms 內算優秀。這個指標是從用戶使用角度出發,達到 FID 的時間點,意味著用戶可以操作網頁了。
第三個指標:Cumulative Layout Shift(CLS),排版跳躍指標,0.1 為優秀。在網頁載入過程中,如果出現排好版的元素,發現大面積的移動的話,這個指標就會很高。比如網頁中 img 標籤不設置寬和高,當圖片載入完畢後,按圖片實際大小來排版本。這樣的就會觸髮網頁重新排版,從用戶角度網頁被整體向下推了一個圖片高度,Google 認為這個體驗不好。
LCP / FID / CLS 這三個指標,本質上是從用戶視角看網頁的性能衡量指標,開發者可以看看自己作品這三個指標屬於什麼水平。
五、工具在生態構建中的重要性
(數據來自 statcounter.com)
Chrome 憑藉著自己優秀的產品特性,安全、快速以及穩定性,贏得了大批用戶青睞。從上圖 StatCounter 統計數據,可以看出 Chrome 已成為絕對的瀏覽器界的一哥,理所當然地取得商業上的成功。但是 Chrome 在開源以及生態的建立,DevTools 可謂首功一件。Google 通過 DevTools 的超越競品的特性,吸引了大批前端開發者,轉到 Chrome 下開發自己的產品。早期生態產品是 Chrome 插件,Chrome Store 中的插件數量就可以看出它的成功。
當 Node.js 的問世,DevTools 首款支援 Node.js 的調試工具,推動了 Node.js 的普及。然後 DevTools 依託 Node.js 迅速出圈。另一方面,開源世界也開始反哺了 DevTools 項目,目前支援 CDP 協議的開源方案多達 10 幾種語言,常用的語言基本都支援上了。這個領域目前還在飛速發展中,期待這個領域可以有更好的發展。
DevTools Web UI 已經從 Chromium 倉庫中獨立出來,可以單獨 Clone 下來進行二次開發,Web UI 本次限於篇幅,未做實現原理分析。其實,Web UI 也是個非常優秀的 Web APP,很適合前端開發者深度研究一下。
我們從優秀開源項目中學習到的不僅是程式碼實現與架構,也可以學習到更高維度的東西,比如產品思維以及工具思維,並落地到自己項目中。回顧一下網頁調試領域發展過程,從一款 JavaScript 插件,是如何演變成今天的前端開發生態,其中有很多點值得學習。
六、結束語
筆者所在團隊長期致力於 Chromium 內核的研究與學習,基於其衍生出來的產品,服務我們生態用戶,為其提供優質的上網體驗。同時,我們孵化出的 Web 瀏覽服務,也為生態內應用提供強大、快速、穩定的 Web 服務能力。如果您有興趣於 Web 底層技術研究,歡迎加入我們,與一群志同道合的小夥伴共同成長,同時也能服務好億級用戶。
七、參考文獻
[1] Google Chrome
[3] 10 years of Speed in Chrome
[4] Chrome DevTools
[5] Chrome DevTools Protocol protocol
[6] Web Vitals
作者:vivo 互聯網瀏覽器內核團隊-Li Qingmei