玩转 Chrome DevTools,定制自己的调试工具

14,294 阅读10分钟

Chrome DevTools 是我们每天都用的工具,它可以查看元素、网络请求、断点调试 JS、分析性能问题等,是辅助开发的利器。

今天不讲怎么使用它,而是讲一个好玩的方向:定制自己的调试工具。

之前讲过,Chrome DevTools 和 Chrome 是分离的架构,两者之间通过 WebSocket 通信,通信协议是 Chrome DevTools Protocol,简称 CDP:

其实这不准确,具体原因后面揭秘。

上图中,UI 的部分叫做 frontend,解析网页、执行 JS 的部分叫做 backend。

backend 是集成在 Chrome 中的,但是 frontend 的部分是独立的。

我们可以从 npm 仓库下载 chrome-devtools-frontend 的代码,我这里用的是 1.0.672485 版本的:

npm install chrome-devtools-frontend@1.0.672485

下载下来的代码有个 front_end 目录,这个就是 Chrome DevTools 的前端代码:

它下面有几个 html:

我们 "npx http-server ." 起个静态服务看一下:

devtools_app.html 就是网页的那个调试页面:

node_app.html 就是 node 的那个调试页面:

这就是 Chrome DevTools 的 frontend 部分。

那怎么用这个独立的 frontend 呢?

给它配个 WebSocket 的 backend 不就行了?

用 node 创建个 WebSocket 服务端,打印下收到的消息:

const ws = require('ws');

const wss = new ws.Server({ port: 8080 });

wss.on('connection', function connection(ws) {
    ws.on('message', function message(data) {
        console.log('received: %s', data);  
    });
});

在 devtools_app.html 后面加上 ws=localhost:8080 的参数:

启动 ws 服务,你就会发现控制台打印了一系列收到的消息:

这就是 CDP 协议的数据。

那我们对接一下这个协议,返回相应格式的数据,能在 Chrome DevTools 里做显示么?

我们试一下。

打开 CDP 的文档 chromedevtools.github.io/devtools-pr…

CDP 是按照不同的 Domain 分隔的,比如 DOM、CSS、Debugger 等。

我们找个网络相关的:

可能你看到这些协议也不知道怎么用,这时候可以先打开 Chrome DevTools 的 Protocol Monitor 面板,找个网页测试下:

看看 NetWork 部分都是怎么通过 CDP 交互的:

然后你会发现每次发请求前,backend 都会给 frontend 传一个 Network.requestWillBeSent 的消息,带上这次请求的信息。

那我们能不能也发一个这样的消息呢?

我模拟构造了一个类似的 CDP 消息:

ws.send(JSON.stringify({
    method: "Network.requestWillBeSent",
    params: {
        requestId: `111`,
        frameId: '123.2',
        loaderId: '123.67',
        request: {
            url: 'www.guangguangguang.com',
            method: 'post',
            headers: {
                "Content-Type": "text/html"
            },
            initialPriority: 'High',
            mixedContentType: 'none',
            postData: {
                "guang": 1
            }
        },
        timestamp: Date.now(),
        wallTime: Date.now() - 10000,
        initiator: {
            type: 'other'
        },
        type: "Document"
    }
}));

然后在 frontend 的页面看一下:

你会发现 Network 面板显示了我们发过来的消息!

这就是 Chrome DevTools 的原理。

测试了下 Network 部分的协议之后,我们再来试下 DOM 的。

我用 Protocol Monitor 观察了下 DOM 部分的 CDP 交互:

首先通过 DOM.getDocument 获取 root 的信息,这一级返回的 node 只到 body。

然后后面再发 DOM.requestChildNodes 的消息,服务端会回一个 DOM.setChildNodes 的消息来返回子节点的信息。

我们也这样实现一下:

收到 DOM.getDocument 的消息的时候,我们返回 root 的信息,只到 body 那一级。

然后发送 DOM.setChildNotes 来返回子节点的信息。

还要处理下 DOM.requestChildNodes 的消息,返回空就行。

完整代码如下:

ws.on('message', function message(data) {
        console.log('received: %s', data);

        const message = JSON.parse(data);
        if (message.method === 'DOM.getDocument') {
            ws.send(JSON.stringify({
                id: message.id,
                result: {
                    root: {
                        nodeId: 1,
                        backendNodeId: 1,
                        nodeType: 9,
                        nodeName: "#document",
                        localName: "",
                        nodeValue: "",
                        childNodeCount: 2,
                        children: [
                            {
                                nodeId: 2,
                                parentId: 1,
                                backendNodeId: 2,
                                nodeType: 10,
                                nodeName: "html",
                                localName: "",
                                nodeValue: "",
                                publicId: "",
                                systemId: ""
                            },
                            {
                                nodeId: 3,
                                parentId: 1,
                                backendNodeId: 3,
                                nodeType: 1,
                                nodeName: "HTML",
                                localName: "html",
                                nodeValue: "",
                                childNodeCount: 2,
                                children: [
                                    {
                                        nodeId: 4,
                                        parentId: 3,
                                        backendNodeId: 4,
                                        nodeType: 1,
                                        nodeName: "HEAD",
                                        localName: "head",
                                        nodeValue: "",
                                        childNodeCount: 5,
                                        attributes: []
                                    },
                                    {
                                        nodeId: 5,
                                        parentId: 3,
                                        backendNodeId: 5,
                                        nodeType: 1,
                                        nodeName: "BODY",
                                        localName: "body",
                                        nodeValue: "",
                                        childNodeCount: 1,
                                        attributes: []
                                    }
                                ],
                                attributes: [
                                    "lang",
                                    "en"
                                ],
                                frameId: "3A70524AB6D85341B3B613D81FDC2DDE"
                            }
                        ],
                        documentURL: "http://127.0.0.1:8085/",
                        baseURL: "http://127.0.0.1:8085/",
                        xmlVersion: "",
                        compatibilityMode: "NoQuirksMode"
                    }
                }
            }));

            ws.send(JSON.stringify({
                method: "DOM.setChildNodes",
                params: {
                    nodes: [
                        {
                            attributes: [
                                "class",
                                "guang"
                            ],
                            backendNodeId: 6,
                            childNodeCount: 0,
                            children: [
                                {
                                    backendNodeId: 6,
                                    localName: "",
                                    nodeId: 7,
                                    nodeName: "#text",
                                    nodeType: 3,
                                    nodeValue: "光光光",
                                    parentId: 6,
                                }
                            ],
                            localName: "p",
                            nodeId: 6,
                            nodeName: "P",
                            nodeType: 1,
                            nodeValue: "",
                            parentId: 5
                        }
                    ],
                    parentId: 5
                }
            }));
        } else if (message.method === 'DOM.requestChildNodes') {
            ws.send(JSON.stringify({
                id: message.id,
                result: {}
            }));
        }
    });

返回的内容如上,我们返回了一个 P 标签,有 class 属性,还有一个文本节点。

重启下 backend 服务,在 frontend 里重连一下,你就会发现 frontend 显示了我们返回的 DOM 信息:

经过这两个案例,我们就搞明白了 Chrome DevTools frontend 是怎么和 backend 交互的。

看到自己模拟 DOM 信息这部分,不知道你是否会想到跨端引擎呢。

跨端引擎就是通过前端的技术来描述界面(比如也是通过 DOM),实际上用安卓和 IOS 的原生组件来做渲染。

它的调试工具也是需要显示 DOM 树的信息的,但是因为并不是网页,所以不能直接用 Chrome DevTools。

那如何用 Chrome DevTools 来调试跨端引擎呢?

看完上面两个案例,相信你就会有答案了。只要对接了 CDP,自己实现一个 backend,把 DOM 树的信息,通过 CDP 的格式传给 frontend 就可以了。

自定义的调试工具几本都是前端部分集成下 Chrome DevTools frontend,后端部分实现下对接 CDP 的 ws 服务来实现的。

跨端引擎的调试工具我们知道怎么实现了,那小程序引擎呢?

小程序引擎的调试工具更简单,因为它实际上渲染是用的网页,有 CDP 的 backend,可以直接和 frontend 对接,不用自己实现 CDP 交互。

我下载了 vivo 的快应用开发工具,它有编辑器、调试器、模拟器这几部分:

模拟器渲染的内容能够在调试器里调试,这也是通过 WebSocket 通信的么?

其实不是,Chrome DevTools 支持几种信道,WebSocket 是最常见的一种,还有就是嵌入的时候会通过全局函数通信,electron 会通过 ipc 的方式通信等等。

比如 WebSocket 时的通信实现是这样的:

而 electron 环境下是这样的:

嵌入到一个环境的时候是这样的:

这也是为什么文章最开始我说 Chrome DevTools 和 Chrome 通过 WebSocket 通信是不准确的,其实是通过全局函数的方式。

而且,像上面那种在一个窗口里渲染,在另一个窗口里调试的这种需求,electron 直接提供了 api 来支持。

使用 setDevToolsWebContents 的 api,就可以让 devtools 的 frontend 显示在任意的窗口里。

所以说,小程序的调试工具实现起来还是很简单的,不但 CDP 交互不用自己实现,而且一个窗口渲染,一个窗口显示Chrome DevTools frontend 这种功能 electron 都已经提供了。

上面我们都是自己实现的 backend,那能自己实现 frontend 么?

当然也是可以的。

我们通过命令行的方式把 chrome 跑起来,通过 remote-debugging-port 指定 backend 的端口:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

然后实现个 WebSocket 客户端连上就可以了。

当然自己实现 CDP 的交互还是挺麻烦的,chrome 给提供了一个工具包 chrome-remote-interface,可以用 api 的方式来组织代码。

const CDP = require('chrome-remote-interface');

async function test() {
    let client;
    try {
        client = await CDP();
        const { Page, DOM, Debugger } = client;
        //...
    } catch(err) {
        console.error(err);
    }
}
test();

我们测试一下 DOM 部分的协议:

const CDP = require('chrome-remote-interface');
const fs = require('fs');

async function test() {
    let client;
    try {
        client = await CDP();
        const { Page, DOM, Debugger } = client;

        await Page.enable();
        await Page.navigate({url: 'https://baidu.com'});

        await DOM.enable();

        const { root } = await DOM.getDocument({
            depth: -1
        });
        
    } catch(err) {
        console.error(err);
    }
}
test();

打个断点,看下 backend 返回的消息:

是不是很熟悉?

不过这次是真实的 DOM.getDocument 的消息。

我们自己实现了 frontend,对接了真实 backend,之前也自己实现了 backend,对接了真实 frontend。

那能不能自己实现 frontend,对接自己实现的 backend 呢?

当然可以,不过这样就没必要用 CDP 了,自己创建一套协议不香么?

其实 Vue DevTools 和 React DevTools 就是自己定制的一套协议。

它们都是以 Chrome 插件的方式存在的,我们要先了解下 Chrome 插件,准确的说是 Chrome DevTools 插件:

它包含三部分:content script、background page、 devtools page。

content script 是可以获取 DOM 的,但是不能访问用户的 JS。这很容易理解,获取 DOM 是插件需要的功能,但是为了安全,又限制了只能访问 DOM。

background page 随浏览器打开就启动,浏览器关闭才销毁,存在周期很长。这也很容易理解,插件是需要这么长的存在周期的,完成一些跨页面的功能。

devtools page 就是在 DevTools 的新 Tab 显示的页面了,它还可以向页面注入 JS。

content script 和 devtools page 都可以和 background page 通信。

那基于这些功能,怎么实现一个自定义调试工具呢?

调试工具主要是 frontend、backend,再就是通信协议。

很容易想到可以这样实现:

devtools page 像页面注入 backend.js,用来获取运行时的信息,然后传递给 devtools page。

devtools page 做 frontend 的显示。

两者之间的通信协议可以自定义。

vue devtools 就是这样实现的:

你可以看到它的代码分包:

backend 就是注入到页面的 js,frontend 部分就是 devtools page 的显示和交互的实现。

react devtools 也是差不多的原理。

只不过它还有 electron 的版本,用于 React Native 的调试:

至此,怎么基于 Chrome Devtools 自定义调试工具,如何基于 devtools extension 实现调试工具我们都了解了。

再回头看下 CDP:

调试工具我们知道怎么实现了,那 CDP 只能用来调试么?

也不是,其实也可以起到远程控制的作用。

puppeteer 就是基于 CDP 实现的自动化测试,它的原理是内置了一个 chromium,用调试模式启动,会有一个 ws 的 backend 的端口。然后用自己实现的 frontend 连接上,通过 CDP 来控制它。

这就是 puppeteer 自动化测试的原理,只不过它是在 node 环境下的。

浏览器环境能实现这种控制么?

也是可以的,Chrome 插件提供了 debugger 的 api,可以代替 frontend 来给 backend 发消息,从而控制浏览器:

其实这个和 puppeteer 的原理很像了,只不过是在浏览器里的。

有一个叫做 puppeteer IDE 的 chrome 插件,就是通过 debugger 来实现了 puppeteer 的 api,从而可以在控制台写 puppeteer 的自动化测试脚本,然后执行。

感兴趣可以去玩一下。

总结

Chrome DevTools 分为 frontend、backend,之间通过 Chrome DevTools Protocol 通信,通信的信道有很多种,常用的是 WebSocket。

我们可以集成 chrome devtools frontend 的代码,对接自己实现的 backend,从而实现调试的功能。跨端引擎的调试就是这样实现的。

小程序引擎调试工具的实现更简单,CDP 不用自己实现,electron 还提供了在一个窗口显示另一个窗口的 devtools frontend 的 api 可以直接用。

除了自己实现 backend,我们也可以自己实现 frontend,通过 chrome-remote-interface 这个包可以用 api 来操作 CDP。

当然,像 Vue DevTools、React DevTools 这种都是要自定义调试协议的,他们的实现原理是 devtools page 向页面注入了 background 代码,之间通过一定的协议通信,然后在 devtools 里面做渲染。

除了调试之外,CDP 还能实现远程控制, puppeteer 就是通过 CDP 实现的自动化测试。

chrome 插件的 debugger api 也可以发送 CDP 消息,可以实现和 puppeteer 类似的效果。

其实调试还是挺简单的,就是 frontend、backend、调试协议,然后可能有很多种信道,不管是 Chrome DevTools 还是自定义调试工具都是这样。

自己做一个调试工具的话,可以集成 Chrome DevTools frontend,然后对接 backend。可以通过 devtools extension 扩展,往页面注入 backend 代码。也可以基于 electron 实现一个完全独立的调试工具。