深入理解 Chrome DevTools

前言

在移动端 H5 开发过程中经常会使用 Chrome DevTools 工具进行代码调试,之前对于 Chrome DevTools 底层原理的理解一直比较碎片化也比较浅,对很多概念例如 Chrome 调试协议、远程调试、Chrome 拓展等本质理解不够深入,本文尝试从新手角度从 0 到 1 去探索 Chrome DevTools 的常用用法。

根据过去的使用经验,本文我尝试带着这几个问题去探索其中的答案:

  • 1.Chrome DevTools 及微信小程序开发工具是如何实现模拟器与开发者工具元素面板进行联动交互?
  • 2.Chrome Inspect 是如何实现 Android/iOS 移动设备 WebView 视图与到 Chrome 开发者工具面板联动交互?
  • 3.如何实现例如 vue-devtools 的 Chrome DevTools 的拓展,且和 Chrome 拓展有什么区别?
  • 4.Stetho 类似的调试工具的工作原理是什么,为什么能够将 Native App 中的网络、存储等信息在 Chrome 上展示?

冥冥之中总觉得这几个问题有某种联系,但是又不能直接说出其中的本质联系,或许当我们搞清楚其中的联系与区别,再回头看这几个问题就会有种“原来如此”的感觉。

Chrome DevTools

Chrome 开发者工具 (Chrome DevTools) 是一套内置于 Google Chrome 中的 Web 开发和调试工具,可用来对网站进行迭代、调试和分析。

Chrome DevTools 是辅助开发者进行 Web 开发的重要调试工具,DevTools 是 Chromium 的一部分,可以作为独立项目被 Electron 等容器集成。DevTools 主要分为四部分:

  • Frontend:调试器前端,默认由 Chromium 内核层集成
  • Backend:调试器后端,Chromium、V8 或 Node.js
  • Protocol:调试协议
  • Message Channels:消息通道,包括:Embedder Channel、WebSocket Channel、Chrome Extensions Channel、USB/ADB Channel

Chrome DevTools Frontend 是一个 Web 应用程序,通过 WebSocket 与 Blink 的 C++ 后端通信。

Chrome DevTools Frontend 部分的资料:

Chrome 开发者工具中有一个非常重要的概念是「面板」,主要有以下几个面板:

面板 作用
设备模式 使用设备模式可以模拟移动设备视口、限制网络流量、限制 CPU 占用率、模拟地理定位、设置屏幕方向备
元素面板 使用元素面板可以自由的操作 DOM 和 CSS 来迭代布局和设计页面
控制台面板 在开发期间,可以使用控制台面板记录诊断信息,或者使用它作为 shell在页面上与JavaScript交互
源代码面板 在源代码面板中设置断点来调试 JavaScript ,或者通过 Workspaces(工作区)连接本地文件来使用开发者工具的实时编辑器
网络面板 使用网络面板了解请求和下载的资源文件并优化网页加载性能
性能面板 使用性能面板可以通过记录和查看网站生命周期内发生的各种事件来提高页面的运行时性能
内存面板 如果需要比时间轴面板提供的更多信息,可以使用“配置”面板,例如跟踪内存泄漏
应用面板 使用资源面板检查加载的所有资源,包括IndexedDB与Web SQL数据库,本地和会话存储,cookie,应用程序缓存,图像,字体和样式表
安全面板 使用安全面板调试混合内容问题,证书问题等等

关于 Chrome 开发者工具的详细使用可以看官方文档,本文不做赘述。Chrome DevTools Extensions 基于 Javascript、CSS、HTML 技术构建可以让用户根据业务需要为 DevTools 增强扩展功能项。当 Chrome DevTools 不能满足我们需求的时候,我们可以写一个 Chrome DevTools Extension,类似于 vue-devtools。

目前主流小程序平台针对小程序特有的技术特征,基于 Chrome DevTools Extensions 进行扩展及改造,赋能小程序开发的各种调试能力。

Chrome Extension

官方文档及示例如下:

因为 Chrome DevTools Extension 属于 Chrome Extension 中的一种特殊的拓展程序。在了解 Chrome DevTools Extension 之前,我们先简单的了解一下 Chrome Extension(Chrome 拓展)的内容。

我们经常说的 Chrome “插件”,其实不是真正意义上的 Chrome Plug-in,一般是指 Chrome Extension(简称“拓展”)。

  • 扩展(Extension),指的是通过调用 Chrome 提供的 Chrome API 来扩展浏览器功能的一种组件,工作在浏览器层面,使用 HTML + Javascript 语言开发。
  • 插件(Plug-in),指的是通过调用 Webkit 内核 NPAPI/PPAPI 来扩展内核功能的一种组件,工作在内核层面,理论上可以用任何一种生成本地二进制程序的语言开发,比如 C/C++、Delphi 等。比如 Flash player 插件,就属于这种类型。一般在网页中用 <object> 或者 <embed> 标签声明的部分,就要靠插件来渲染。

Chrome 拓展一般包含如下几个组件:

  • Manifest
  • Background Script
  • UI Elements
  • Content Script
  • Options Page
  • DevTools

Chrome Extension 架构图:

Chrome 拓展的 JS 主要可以分为这 5 类:injected script、content-script、popup js、background js 和 devtools js,

JS种类 可访问的API DOM访问情况 JS访问情况 直接跨域
injected script 和普通 JS 无任何差别,不能访问任何扩展 API 可以访问 可以访问 不可以
content script 只能访问 extension、runtime 等部分API 可以访问 不可以 不可以
popup js 可访问绝大部分 API,除了 devtools 系列 不可直接访问 不可以 可以
background js 可访问绝大部分 API,除了 devtools 系列 不可直接访问 不可以 可以
devtools js 只能访问 devtools、extension、runtime 等部分API 可以 可以 不可以

Chrome DevTools Extensions

官方文档及示例如下:

Chrome DevTools 扩展程序的结构与 Chrome 其他任何的扩展程序一样:它可以具有背景页面(background),内容脚本(content-scripts)和其他选项,此外每个 Chrome DevTools 扩展都有一个 Chrome DevTools 页面,该页面可以访问 DevTools API。Chrome DevTools 扩展程序可以为 Chrome DevTools 添加新功能,可以添加新的 UI 面板和侧边栏,与检查的页面进行交互,获取有关网络请求的信息等。

Chrome DevTools Extension 架构图:

DevTools page 在 manifest.json 中注册,必须为一个 html 页面,对用户不可见,可调用以下 Chrome DevTools Extension 特有的 API:

Background Page 是常驻后台运行的 JS 脚本,拥有对 Extensions API 的全部调用权限,可以和 DevTools Page 进行 通信; Inspected Window 指当前DevTools 检测的 Web 页,可被 Background Page 注入内容脚本。

Chrome DevTools Protocol

Chrome DevTools Protocol 允许第三方对基于 Chrome 的 Web 应用程序进行调试、分析等,基于 WebSocket,利用 WebSocket 建立连接 DevTools 和浏览器内核的快速数据通道。基于 WebSocket 建立连接 DevTools 和浏览器内核的快速数据通道,DevTools Frontend 中的源代码(Connections.js):

/**
 * @param {function()} websocketConnectionLost
 * @return {!ProtocolModule.InspectorBackend.Connection}
 */
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();
}

该协议把操作划分为不同的域(domain),比如 DOM、Debugger、Network、Console 和 Timeline 等,可以理解为 DevTools 中的不同功能模块。

每个域(domain)定义了它所支持的 command 和它所产生的 event。每个 command 包含 request 和 response 两部分,request 部分指定所要进行的操作以及操作说要的参数,response 部分表明操作状态,成功或失败。command 和 event 中可能涉及到非基本数据类型,在 domain 中被归为 Type,比如:’frameId’: <FrameId>,其中 FrameId 为非基本数据类型。

基于 Chrome Debugging Protocol 的 Client 端有常见几种:

  • 基于 HTML5 标准的 WebSocket 或 Node ws
// 建立连接
var ws = new WebSocket('ws://localhost:9222/devtools/page/A12A4B08-E5AF-4A84-A86A-A1C86E731D7F"');
// 调用 Command
ws.onmessage = function(event) {
	console.log(event.data);
  // 获取数据:{"method": "Page.loadEventFired", "params": {"timestamp": 1402317772.874949}}
};
ws.send('{"id": 1, "method": "Page.navigate", "params": {"url": "http://www.github.com"}}');
const CDP = require("chrome-remote-interface");

const target = { port: '', ws: '' };

const client = await CDP({
  port: target.port,
  target: target.ws,
  local: true
});
client.on('event', (message) => {
  console.log(message);
});

const { Runtime, Page } = client;
Runtime.enable();
Page.enable();
const data = await Runtime.evaluate({
	expression: 'document.documentElement.outerHTML'
});
console.log(data.result.value);

Electron 集成 DevTools

setDevToolsWebContents

Electron 官方文档 contents.setDevToolsWebContents(devToolsWebContents),在 renderer 层建立两个 WebView,将 Simulator 和 DevTools 建立联系。

<webview id="simulator" src="https://zhaomenghuan.js.org"></webview>
<webview id="devtools"></webview>
const simulatorView = document.getElementById('simulator');
const devtoolsView = document.getElementById('devtools');
simulatorView.addEventListener('dom-ready', () => {
  const simulatorContents = simulatorView.getWebContents();
  const devtoolsContents = devtoolsView.getWebContents();
  simulatorContents.setDevToolsWebContents(devtoolsContents);
  simulatorContents.openDevTools();
});

这种方法 devtools WebView 加载的地址是:

chrome-devtools://devtools/bundled/inspector.html?remoteBase=https://chrome-devtools-frontend.appspot.com/serve_file/@7accc8730b0f99b5e7c0702ea89d1fa7c17bfe33/&can_dock=&toolbarColor=rgba(223,223,223,1)&textColor=rgba(0,0,0,1)&experiments=true

在 Electron 2.x 版本中上述的方法生效,通过 setDevToolsWebContents 方法,我们将两个 WebView 建立了联系,可以实现之间的交互,但是在 Electron 6.x 版本上测试发现居然不生效。通过 github issues (https://github.com/electron/electron/issues/17168#issuecomment-483224164 ) 找到一种新的解决办法,使用 BrowserView 代替 WebView 尝试了一下:

// main process
let mainWindow;
let devtoolsView;
// Workbench Window
  mainWindow = new BrowserWindow({
    width,
    height,
    useContentSize: false,
    titleBarStyle: 'hidden',
    webPreferences: {
      webSecurity: false,
      nodeIntegration: true,
      webviewTag: true
    }
  });
  mainWindow.maximize();
  mainWindow.loadURL(winURL);

  // Devtools View
  devtoolsView = new BrowserView();
  mainWindow.setBrowserView(devtoolsView);
  devtoolsView.setBounds({
    x: 330,
    y: 101,
    width: width - 330,
    height: height - 101
  });
  
  ipcMain.on('initialized', (event, message) => {
  const container = webContents.getAllWebContents().find((item) => {
    return item.getURL().includes(message);
  });
  if (container) {
    container.setDevToolsWebContents(devtoolsView.webContents);
    container.debugger.attach();
    container.openDevTools();
  }
});

// renderer process
const simulatorView = document.getElementById('simulator');
  simulatorView.addEventListener('dom-ready', () => {
  ipcRenderer.send('initialized', simulatorView.src);
});

再回头看了看官方 v8.0.2 最新文档 ⚠️警告提示不要使用 WebView 标签,建议使用 iframe 和 BrowserView 替代。

Electron的 webview 标签基于 Chromium webview </0>,后者正在经历巨大的架构变化。 这将影响 webview 的稳定性,包括呈现、导航和事件路由。 我们目前建议不使用 webview 标签,并考虑其他替代方案,如 iframe 、Electron的 BrowserView 或完全避免嵌入内容的体系结构。

这里的 BrowserView 相对 WebView、BrowserWindow 有什么区别呢?Electron 中 WebView 是 DOM 层级结构的一部分,BrowserView 位于操作系统窗口层次结构。BrowserView 与 Chrome 浏览器标签页类似,可以作为一个子窗口,它的位置是相对于父窗口。另外相对 WebView 而言,Chrome 浏览器标签页的错误修复很快,BrowserView 比 WebView 更容易解决一些错误,且 BrowserView 比 WebView 运行更快。

重要结论:通过 Electron setDevToolsWebContents 方法,可以使用任何 WebContents 在其中显示 devtools,包括 BrowserWindow,BrowserView 和 webview 标签,这种方式适合 IDE 进行本地 Web 化模拟调试,模拟器与调试器建立联系。

Remote Debugging

基本原理

什么是远程调试?远程调试可以让您从自己的开发计算机上检查 Android 设备上运行的页面,当然开发本地的页面也可以通过远程调试的方式实现。

远程调试的交互流程:

Electron 支持在 app 模块的 ready 事件触发之前使用 app.commandLine.appendSwitch 添加 Chrome 命令行参数:

// 主进程 main.js
const { app } = require('electron');
// 远程调试
const port = await getPort();
process.env.EMP_REMOTE_DEBUGGING_PORT = port;
app.commandLine.appendSwitch('remote-debugging-port', `${port}`);
app.commandLine.appendSwitch('remote-debugging-address', 'http://127.0.0.1');

app.on('ready', () => {
  // ...
})

可以通过  /json 或 /json/list 获取所有可用的 WebSocket 目标地址。

返回一个数组,里面是所有可以远程调试的页面,其中包含以下字段信息:

  • description:页面信息描述
  • devtoolsFrontendUrl:调试 URL 地址
  • id:页面 ID
  • webSocketDebuggerUrl:WebView Debug Server 的 WebSocket 地址

获取远程 WebView DevTools Frontend 地址:

function getTargetWebViewDevtoolsFrontendUrl(url) {
 	return fetch(`http://127.0.0.1:9222/json`)
    .then(res => res.json())
    .then(res => {
      const target = res.find(
        child => child.type === 'webview' && child.url === url
      );
      return target.devtoolsFrontendUrl;
    });
}

然后加上 remote-debugging-address 和 remote-debugging-port 就是 DevTools Frontend 完整的地址,然后可以使用 WebView、BrowserView 展示出来,如下:

http://127.0.0.1:9222/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/eac1573a-c713-4421-ad64-3e07fb20f034

这个地址相当于两部分组成,一个是 Inspector(调试器),由 Chromium 默认提供集成,也可以进行修改 Chrome DevTools Frontend 源代码进行自定义集成,ws 是 Chromium 内核层为目标 WebView 生成的唯一的 websocketDebuggerUrl。目前 Electron 限制,WebView 集成 DevTools Frontend 前端页面不能加载自定义拓展。

真机调试

真机调试整体流程如下:

Android 平台真机调试

对于 Android WebView 调试,打开 Chrome://inspect 可以显示每一个连接上的设备,以及它们打开了的浏览器标签和启用调试的 WebViews,如下图:

基于 ADB 调试 Android WebView 的原理如下:

$ adb shell cat /proc/net/unix | grep --text _devtools_remote
0000000000000000: 00000002 00000000 00010000 0001 01 28940245 @stetho_org.js.emp.engine.sample:emp_devtools_remote
0000000000000000: 00000002 00000000 00010000 0001 01 28942398 @webview_devtools_remote_14277
$ adb forward tcp:9223 localabstract:webview_devtools_remote_14277

webview_devtools_remote 后面带的 _14277 这种是对应的 pid,可以使用 adb forward 命令将本地 9223 端口映射到远程设备的 unix domain socket(webview_devtools_remote_8208),这样就可以在本地访问到 Android WebView Debug Server。

其中 devtoolsFrontendUrl 是 Chrome 云服务器提供,根据 Android WebView 集成的 Chromium 不同版本加载不同的前端地址,4cd8c034a5b41cc6da41e00e42d9aadfaa34932b 和 WebKit Version 对应。

iOS 平台真机调试

iOS 设备可以连接数据线借助 Safari 或者按安装 Safari Technology Preview 远程调试。如果想使用 Chrome 调试,可以使用 RemoteDebug iOS WebKit Adapter 进行代理。

关于 iOS 平台调试权限的问题,开发阶段,用开发者账号 build 出来的 app 可以很方便的调试, 但是苹果应用商店中的 app 使用 Distribution 签名,无法直接打开 webview 的远程调试。 因此我们通过替换 ipa 包签名方式改成开发者的签名实现远程调试。

定制 Inspector

Electron 支持 Chrome DevTools 扩展程序,可增强开发工具调试流行 web 框架的能力。

Electron 主进程通过 API 管理 DevTools Extension:

  • BrowserWindow.addDevToolsExtension(path)
  • BrowserWindow.removeDevToolsExtension(name)
  • BrowserWindow.getDevToolsExtensions()

经过测试发现 setDevToolsWebContents 模式下 DevTools 面板可以加载自定义 DevTools Extenson,但是远程调试模式下自定义 DevTools Extenson 无法加载。目前主流小程序平台调试器的自定义面板如 Wxml、Swan Element、AppData、Storage 等都是通过 Chrome DevTools Extension 实现,都是改造 devtools-frontend 项目。

可以通过 preload 或者注入 JS Script 可以实现对 DevTools 前端界面进行定制:

// 隐藏默认面板
const tabbedPane = window.UI.inspectorView._tabbedLocation._tabbedPane;
tabbedPane.closeTab('elements');
tabbedPane.closeTab('timeline');
tabbedPane.closeTab('resources');

// 新增 Connection
const capabilitiesForPageFrameTarget = () => {
  return window.SDK.Target.Capability.Browser | window.SDK.Target.Capability.DOM | window.SDK.Target.Capability.DeviceEmulation |
    window.SDK.Target.Capability.Emulation | window.SDK.Target.Capability.Input | window.SDK.Target.Capability.JS |
    window.SDK.Target.Capability.Log | window.SDK.Target.Capability.Network | window.SDK.Target.Capability.ScreenCapture |
    window.SDK.Target.Capability.Security | window.SDK.Target.Capability.Target | window.SDK.Target.Capability.Tracing |
    window.SDK.Target.Capability.Inspector;
};
const createPageFrameConnection = (params: any) => {
  const onDisconnect = (message: any) => {
    console.log('', '-onDisconnect-' + message);
  };
  let wsConnection = new window.SDK.WebSocketConnection(url, onDisconnect, {
    onMessage: params.onMessage,
    onDisconnect
  });
  wsConnection.isPageFrame = true;
  return wsConnection;
};
window.SDK.targetManager.createTarget('page-frame', 'Page', capabilitiesForPageFrameTarget(), createPageFrameConnection, null);

参考

写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!