1947 年 9 月 9 日,第一代程序媛大佬 Hopper 正领着她的小组在一间一战时建造的老建筑机房里构造一个称为“Mark II”的艾肯中继器计算机。
那是一个炎热的夏天,房间没有空调,所有窗户都敞开散热。突然 Mark II 死机了,操作人员在电板编号为 70 的中继器触点旁发现了一只飞蛾。操作员把飞蛾贴在操作日志上,并写下了“First actual case of bug being found”,他们还提出了一个词:“debug(调试)”,表示他们已经从机器上移走了bug(调试了机器)。
于是,引入了一个新的术语“debugging a computer program(调试计算机程序)”。
在 2006 年前的 IE 时代,调试 JavaScript 代码主要靠 window.alert() 或者将调试信息输出到页面上,这种硬 debug 的手段,不亚于系统底层开发,效率极低。
2006 年 1 月份,Apple 的 WebKit 团队第一版本的 Web Inspector 问世,尽管最初版的调试工具很简陋(它甚至连 console 都没有),但是它为开发者展示了两个他们很难洞见的内容——DOM 树以和与之匹配的样式规则。
这奠定了今后多年的网页调试工具的原型。
同年 4 月,以最大的食虫植物命名的 Drosera 发布,它可以给任何的 WebKit 应用添加断点,调试 JavaScript——不仅限于是 Safari。
同时开源社区出现了一款 Firefox 的插件 Firebug,专注于 Web 开发的调试,它是在 Chrome 全世界最好的前端调试工具,同时也奠定了现代 DevTools 的 Web UI 的布局。
Firebug 早期版本就已经支持了 JavaScript 的调试,CSS Box 模型可视化展示,HTTP Archive 的性能分析等优秀特性,后来的 DevTools 参考了此插件的功能和产品定位。
2016 年 Firebug 整合到 Firefox 内置调试工具。
2017 年 Firebug 停止更新,一代神器就此谢幕。
此后开源界的狠角色 Google 团队基于 WebKit 加入浏览器研发,他们推出的 Chrome 以「安全、极速、更稳定」吸引了大部分开发者的关注,同时在开发者工具这方面, Google 吸收多款调试工具的优秀功能,推出了 DevTools。
虽然当时的界面相比如今,十分简陋,但此后 DevTools 的发展基本就与 Chrome DevTools 的发展史划等号了。
当然,不管是 Firebug 还是后来基于 Webkit(早期)、 Blink (现今) 内核的 Chrome ,再或者是 2016 年后的 node-inspector ,他们都离不开 Web Inspector,更多详细的 Web Inspector 发展史可以参考 10 Years of Web Inspector。
DevTools 是 client-server
架构:
client 端提供可视化 Web UI 界面供用户操作,它负责接收用户操作指令,然后将操作指令发往浏览器内核或 Node.js 中进行处理,并将处理结果数据展示在 Web UI 上。
server 端启动了两类服务:
以上具体化到 Chrome 开发者工具,你一定倍感亲切。
Chrome DevTools 提供了一套内置于 Google Chrome 中的 Web 开发和调试工具,可用来对网站进行迭代、调试和分析。
Chrome DevTools 主要由四部分组成:
总结来说,本质上 Chrome DevTools 就是一个 Web 应用程序,它通过使用 Chrome DevTools Protocol 与后端进行交互,达到调试目的。
关于 Chrome 开发者工具的详细使用可以看官方文档。
接下来聚焦 DevTools 的核心:Protocol 。
CDP 本质就是一组 JSON 格式的数据封装协议,JSON 是轻量的文本交换协议,可以被任何平台任何语言进行解析。
以 Tracing 的协议为例:
{
"domain": "Tracing",
"experimental": true,
"dependencies": ["IO"],
"types": [
{
"id": "TraceConfig",
"type": "object",
"properties": [
{
"name": "recordMode",
"description": "Controls how the trace buffer stores data.",
"optional": true,
"type": "string",
"enum": [
"recordUntilFull",
"recordContinuously",
"recordAsMuchAsPossible",
"echoToConsole"
]
},
...
]
},
...
],
"commands": [
{
"name": "start",
"description": "Start trace events collection.",
"parameters": [
{...}
]
},
{
"name": "end",
"description": "Stop trace events collection."
},
...
],
"events": [
{
"name": "tracingComplete",
"description": "Signals that tracing is stopped and there is no trace buffers pending flush, all data were\ndelivered via dataCollected events.",
"parameters": [
{
"name": "dataLossOccurred",
"description": "Indicates whether some trace data is known to have been lost, e.g. because the trace ring\nbuffer wrapped around.",
"type": "boolean"
},
...
]
}
]
}
如下图在 Chrome DevTools 中操作了 Performance 的录制,可以在 Chrome 中开启 Protocol monitor 查看具体的通讯信息。
每个 Method (${domain}.${conmand}
)包含 request 和 response 两部分,request 部分指定所要进行的操作以及操作说要的参数,response 部分表明操作状态,成功或失败。
除了使用 Protocol Monitor,还可以参考 https://stackoverflow.com/questions/12291138/how-do-you-inspect-the-web-inspector-in-chrome/12291163#12291163,开启对 Chrome DevTools 的调试。
官方推荐的支持 CDP 的 Libraries 多达近十种语言。
Google 官方推荐了 Node.js 版本 Puppeteer ,通过 Puppeteer 完整地实现了 CDP 协议,为 Chrome 内核通信的方式打了一个样,接着开源世界陆续推出了多个语言版本的 CDP 的使用库。
Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 CDP 协议控制 Chrome 或 Chromium。
Puppeteer 怎么用,我就不多写了(如果英文文档看不懂,咱就看中文文档)。
我可能更偏向于结合源码理解它是如何做到与 CDP 关联,并且如何发挥作用的。
const puppeteer = require('puppeteer');
puppeteer.launch({ headless: false }).then(async browser => {
const page = await browser.newPage();
await page.tracing.start({ path: './trace.json' });
await page.goto('' );
await page.tracing.stop();
await browser.close();
});
以上代码片段会将录制的 tracing 数据存储中 trace.json 中。
(是否记得之前 Tracing 协议的定义,与 start 配对的是 end,pupputeer 做了调整,具体在下面源码中体现)
再看看这个构建函数源码,其实非常简单:
// Page.ts
export class Page extends EventEmitter {
constructor(client,...) {
super()
...
this.#tracing = new Tracing(client);
}
get tracing(): Tracing {
return this.#tracing;
}
}
Tracing 是一个被标记为 Internal 的构造函数,意味着我们不能直接调用或扩展它的子类,如上代码片段,它挂载在 Page 上,随 Page 被实例化。
// Tracing.ts
import {assert} from './assert.js';
import {
getReadableAsBuffer,
getReadableFromProtocolStream,
isErrorLike,
} from './util.js';
import {CDPSession} from './Connection.js';
/**
* @public
*/
export interface TracingOptions {
path?: string;
screenshots?: boolean;
categories?: string[];
}
/**
* The Tracing class exposes the tracing audit interface.
* @remarks
* You can use `tracing.start` and `tracing.stop` to create a trace file
* which can be opened in Chrome DevTools or {@link | timeline viewer}.
*
* @example
* ```ts
* await page.tracing.start({path: 'trace.json'});
* await page.goto('');
* await page.tracing.stop();
* ```
*
* @public
*/
export class Tracing {
#client: CDPSession;
#recording = false;
#path?: string;
/**
* @internal
*/
constructor(client: CDPSession) {
this.#client = client;
}
/**
* Starts a trace for the current page.
* @remarks
* Only one trace can be active at a time per browser.
*
* @param options - Optional `TracingOptions`.
*/
async start(options: TracingOptions = {}): Promise<void> {
assert(
!this.#recording,
'Cannot start recording trace while already recording trace.'
);
const defaultCategories = [
'-*',
'devtools.timeline',
'v8.execute',
'disabled-by-default-devtools.timeline',
'disabled-by-default-devtools.timeline.frame',
'toplevel',
'blink.console',
'blink.user_timing',
'latencyInfo',
'disabled-by-default-devtools.timeline.stack',
'disabled-by-default-v8.cpu_profiler',
];
const {path, screenshots = false, categories = defaultCategories} = options;
if (screenshots) {
categories.push('disabled-by-default-devtools.screenshot');
}
const excludedCategories = categories
.filter(cat => {
return cat.startsWith('-');
})
.map(cat => {
return cat.slice(1);
});
const includedCategories = categories.filter(cat => {
return !cat.startsWith('-');
});
this.#path = path;
this.#recording = true;
await this.#client.send('Tracing.start', {
transferMode: 'ReturnAsStream',
traceConfig: {
excludedCategories,
includedCategories,
},
});
}
/**
* Stops a trace started with the `start` method.
* @returns Promise which resolves to buffer with trace data.
*/
async stop(): Promise<Buffer | undefined> {
let resolve: (value: Buffer | undefined) => void;
let reject: (err: Error) => void;
const contentPromise = new Promise<Buffer | undefined>((x, y) => {
resolve = x;
reject = y;
});
this.#client.once('Tracing.tracingComplete', async event => {
try {
const readable = await getReadableFromProtocolStream(
this.#client,
event.stream
);
const buffer = await getReadableAsBuffer(readable, this.#path);
resolve(buffer ?? undefined);
} catch (error) {
if (isErrorLike(error)) {
reject(error);
} else {
reject(new Error(`Unknown error: ${error}`));
}
}
});
await this.#client.send('Tracing.end');
this.#recording = false;
return contentPromise;
}
}
注意到 Puppeteer 提供的对 Tracing 的 config 有限,仅可自定义:
export interface TracingOptions {
path?: string;
screenshots?: boolean;
categories?: string[];
}
看到以上代码片段,你可能会有一些疑惑:
分享一下我的见解:
on
emit
)来更好的串联各个模块,并实现解耦。而在Nodejs 中,事件模型就是我们常见的订阅发布模式,所有可能触发事件的对象都应该是一个继承自 EventEmitter 类的子类实例对象。其实在 puppeteer 实现中,client 都承担着使用 CDP 与 server 通讯的责任,它其实就是 puppeteer launch 阶段与 server 通讯的 websocket transport。
// BrowserRunner.ts
async setupConnection(options: {
...
const transport = await WebSocketTransport.create(browserWSEndpoint);
this.connection = new Connection(browserWSEndpoint, transport, slowMo);
...
return this.connection;
}
}
CRI(简称)不同于 Puppeteer 附加的高级 API,它通过开放简单的 API 和事件通知,我们只需要使用简单的 JavaScript API 即可实现对 Chrome(或任何其他支持 Devtools Protocol 的实现)的控制。
它被 CDP 官方多次推荐。
以远程调试模式启动 Chrome (增加参数—remote-debugging-port=9222
),DevTools server 将监听本地的端口9222
。
# 退出 Chorme 后再命令行输入命令,打开新的 Chrome
open -a "Google Chrome" --args --remote-debugging-port=9222
访问 http://localhost:9222/json 可以看到可用调试页面数据信息(包括打开的 Tab 页和 Chrome 上添加的 Extensions):
访问 http://localhost:9222/ + 任意一个 Tab 的 devtoolsFrontendUrl
,将会打开对该页面调试页。
或者同移动端调试一般,打开about://inspect
界面,可以发现此时本地浏览器被作为一个 remote device 来调试,找到具体 Tab 页点击 inspect 即可。
这在移动端调试是十分有帮助的。
如下片段,我们可以通过 CRI 使用 CDP 的所有 API。
const fs = require('fs');
const CDP = require('chrome-remote-interface');
CDP(async (client) => {
try {
const {Page, Tracing} = client;
// enable Page domain events
await Page.enable();
// trace a page load
const events = [];
Tracing.dataCollected(({value}) => {
events.push(...value);
});
await Tracing.start();
await Page.navigate({url: '' });
await Page.loadEventFired();
await Tracing.end();
await Tracing.tracingComplete();
// save the tracing data
fs.writeFileSync('./trace.json', JSON.stringify(events));
} catch (err) {
console.error(err);
} finally {
await client.close();
}
}).on('error', (err) => {
console.error(err);
});
对于以上收集到的 Tracing 数据(存储在 trace.json),因为数据量大而且晦涩,一般直接在 Chrome DevTools 或其他 timeline viewer 打开,用来分析 Web 站点性能表现的文件。
或者可以参照 Trace Event Format,使用脚本过滤出期望格式的 event 数据再做进一步分析。
DevTools 实现原理与性能分析实战
Chrome DevTools Protocol 协议详解