Skip to content
微信公众号

远程调试原理

DevTools 的核心是基于 Chrome DevTools Protocol (CDP), 工作原理可以简单概括为:前端界面通过 CDP 协议与浏览器内核通信,发送调试命令并接收调试信息。浏览器内核根据接收到的命令执行相应的操作,并将结果返回给前端界面。

Chrome DevTools 是 client server 的架构,比如 chrome 会使用 CDP 协议来传输数据。

CDP 协议详解

什么是 CDP 协议?

CDP(Chrome DevTools Protocol)是 Chrome DevTools 与浏览器内核通信的协议。它基于 WebSocket,允许开发者通过发送 JSON 格式的命令来控制浏览器行为,并获取调试信息。 通过 CDP,DevTools 能够实时与页面交互,实现断点调试、性能分析等功能。

CDP 协议的核心特点

  • 基于 JSON-RPC:CDP 协议使用 JSON 格式传输数据,简单易读。
  • 双向通信:不仅调试器可以发送命令,浏览器也会主动推送事件(比如断点触发、网络请求完成)。
  • 模块化设计:CDP 协议分为多个模块(如 DOM、Network、Runtime 等),每个模块负责不同的功能。

CDP 协议的主要功能

  • DOM 操作和 CSS:获取、修改 DOM 结构和 CSS 样式。
  • 网络监控:监控网络请求和响应。
  • JavaScript 调试:设置断点、单步执行、查看调用栈等。
  • 性能分析:分析页面加载性能、JavaScript 执行性能等。
  • 内存管理:检查和分析内存使用情况,查找内存泄漏等。

CDP 协议的工作流程

  1. 建立 WebSocket 连接:通过 WebSocket 与浏览器内核建立连接。
  2. 发送协议命令:前端界面发送 JSON 格式的命令。
  3. 执行协议命令:浏览器内核执行命令并返回结果。
  4. 接收结果:前端界面接收并显示结果。

了解 CDP 传输协议信息

传递的 CDP 数据可以通过 Protocol monitor 看到:

pc 端是这样,移动端也是这样,只不过传递协议数据的方式不大一样。

要想起一个有 CDP server 的浏览器,需要单独指定一些参数。

pc 端是跑 chrome 的时候带上 remote-debugging-port 参数,类似这样:

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

然后就可以连上这个端口进行调试了,不管你是用 chrome devtools 还是 vscode debugger 或者其余的 frontend UI。

那移动端呢?

自然也是一样的,要开启调试模式才有这个 CDP Server 可以连接。

andorid 的 app 里面的 weview 需要类似这样的方式开启调试:

java
// Android 4.4 以上 WebView 才真正使用 Blink 内核,所以需要在此版本及以上系统。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
  WebView.setWebContentsDebuggingEnabled(true);
}
// Android 4.4 以上 WebView 才真正使用 Blink 内核,所以需要在此版本及以上系统。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
  WebView.setWebContentsDebuggingEnabled(true);
}

所以你想调试 app 里网页的话,让移动端开发给你一个调试包即可。

有了 CDP Server 的标志就是你访问 9229 端口的 /json 是可以看到所有页面的 websocket 地址的:

每个页面就可以连接对应的 ws 服务端来进行 CDP 协议数据的交换,实现调试。

那问题来了,pc 端怎么访问移动端的页面呢?

有两种方式,第一种是通过 USB,连上 USB 之后就可以使用 android 的 adb 工具来转发端口:

shell
adb forward tcp:9229
adb forward tcp:9229

之后就可以进行通信,自然也就可以调试了。

ios 下也有类似的工具。

当然,也可以通过 wifi 的方式,只不过这种就需要单独起一个 ws 服务做转发了。

这个 ws 服务做的事情比较简单,就是原封不动转发了下 CDP 数据。

这样就能实现 wifi 调试,扫码调试等功能。

这就是远程调试移动端网页的原理。

最后,android 这个实时看到移动端网页的界面是怎么实现的呢?

其实就是一帧帧截图实现的。

CDP 协议里有这样一个传输 base64 的截图数据的事件:

我们可以通过 chrome、safari 调试移动端的网页,原理就是开启调试模式之后,可以通过 CDP server 和 client 进行通信,从而实现调试。

pc 端开启调试只要指定 remote-debugging-port 的启动参数即可,而移动端则需要指定 webview 的参数。

可以通过 USB 调试,是因为 adb 做了端口转发,也可以通过 wifi 调试,这种就需要自己实现一个 ws 服务做中转了。

原理

调试工具都包含 frontend、backend、调试协议、信道这四个部分,而在 Chrome DevTools 里这个调试协议就是 Chrome DevTools Protocol,简称 CDP。

打开 CDP 的文档,可以看到 CDP 协议分为了不同的 Domain:

比如 DOM、CSS、Debugger 等,这个很容易理解,各种工具的数据通信总不能混到一起吧,所以分成了不同的域来管理。

每个 Domain 下都包含了 Methods 和 Events:

Method 就是 frontend 向 backend 请求数据,backend 给它返回相应的数据 Event 就是 backend 推送给 frontend 的一些数据。

我们从 Protocol Monitor 面板可以看到 CDP 的数据交互如下:

双向箭头的就是 Method,单向箭头的就是 backend 推给 frontend 的 Event。

你可以在下面的 send a raw CDP method 的输入框里输入协议数据,Chrome DevTools 会把它发给 backend:

比如发送 DOM.getDocument 的 method:

shell
{"method": "DOM.getDocument","params": {}}
{"method": "DOM.getDocument","params": {}}

就会返回整个 DOM 的信息。

比如发送 CSS.getComputedStyleForNode 的 method,带上某个 nodeId:

shell
{"method": "CSS.getComputedStyleForNode","params": {"nodeId": 920}}
{"method": "CSS.getComputedStyleForNode","params": {"nodeId": 920}}

就可以拿到这个 node 的所有计算后的样式。

Chrome DevTools 里展示的所有内容都是从 backend 那里拿到的,他只是一个展示和交互的 UI 而已。

这个 UI 是可以换的,比如我们可以用 VSCode Debugger 对接 CDP 调试协议来调试网页。

Chrome DevTools frontend 也是一个独立的项目,我们可以从 npm 仓库下载 chrome-devtools-frontend 的代码,我这里用的是 1.0.672485 版本的:

shell
npm install chrome-devtools-frontend@1.0.672485
npm install chrome-devtools-frontend@1.0.672485

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

我们在 node_modules/chrome-devtools-frontend 下执行 "npx http-server ." 起个静态服务看一下:

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

这就是 Chrome DevTools 的 frontend 部分。

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

给它配个 WebSocket 的 backend 就行.

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

js
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);
  });
});
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 里做显示么?

我们试一下。

我们找个网络相关的协议:

现在 Protocol Monitor 里看看 NetWork 部分都是怎么通过 CDP 交互的:

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

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

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

然后在 frontend 的页面看一下:

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

这就是 Chrome DevTools 的原理。

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

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

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

我们也这样实现一下:

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

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

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

完整代码如下:

javascript
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: {},
      })
    );
  }
});
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 服务来实现的。

frontend 只是一个对接了 CDP 的独立的客户端 UI,自己实现 CDP 的 backend 就可以用它来调试各种东西。

对前端来说,常见的就是跨端引擎、小程序引擎的调试工具:

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

本站总访问量次,本站总访客数人次
Released under the MIT License.