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 就會偵聽本地的端口,可以向 http://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 http://localhost:9222/json/version
curl http://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 http://localhost:9222/json/list
curl http://localhost:9222/json/list  
[ {
   "description": "{\"attached\":true,\"empty\":false,\"height\":1812,\"never_attached\":false,\"screenX\":0,\"screenY\":72,\"visible\":true,\"width\":1080}",
   "devtoolsFrontendUrl": "https://chrome-devtools-frontend.appspot.com/serve_rev/@9f05d1d9ee7483a73e9fe91ddcb8274ebcec9d7f/inspector.html?ws=localhost:9222/devtools/page/B86E67DEA526D5EEE83A170B1F62A72C",
   "faviconUrl": "https://mat1.gtimg.com/www/mobi/2017/image/logo/v0/192.png",
   "id": "B86E67DEA526D5EEE83A170B1F62A72C",
   "title": "騰訊網-QQ.COM",
   "type": "page",
   "url": "https://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": "https://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 瀏覽器打開了兩個頁面,一個 https://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

[2] 10 Years of Web Inspector

[3] 10 years of Speed in Chrome

[4] Chrome DevTools

[5] Chrome DevTools Protocol protocol

[6] Web Vitals

作者:vivo 互聯網瀏覽器內核團隊-Li Qingmei

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章