没有DevTools的前端就像基督教徒没有耶路撒冷。
前言
经过二十年的发展,我们的调试工具已经渐渐从最初的在IE时代的window.alert()
调试,这种调试方式,不可避免的就会有极低的调试效率。到2006 年Apple 的WebKit 团队发布第一代调试工具后面FireFox 发布早期调试神器FireBug 。直到互联网的发展逐渐来到了移动互联网时代,调试工具届出现了一个大爹那就是Chrome 的devTools,它能够支持远程真机调试在那以后调试工具的发展进程和devTools的发展进程几乎画上等号。
调试工具也确实给前端开发者带来了极大的好处,已经成为了目前不可缺少的工具。
DevTools剖析
💡 JavaScript实现 & 实际上是个网页
Google:"浏览器不就是跑JS的么?不用JS用什么?"
架构:CS(Client-Server)架构
数据封装协议:CDP(Chrome DevTools Protocol )
一般的DevTools过程
安卓端的过程
工具的四个组成部分:
- Fontend: 前端,用户操作的界面
- Backend: 后端,一般是 Chromium、V8 或 Node.js
- Protocol: 调试协议(JSON 格式的数据封装协议,包含了HTTP和WebSocket协议)
- Message Channels:调试消息通道
源码片段
值得一提的是,当初devTools决定使用WebSocket的时候,WebSocket还仅仅只在实验阶段,由此可见国际大厂的工程师的眼光确实长远~
如何在devTools中看到CDP传输的信息呢
开启工具
设置中打开工具
使用工具
此时此刻就可以看到请求的入参和出参等信息,前面有提到CDP是基于JSON的,这里我们就可以看到Response中的返回的就是JON格式
发出CDP命令
我们还可以使用 Protocol Monitor(版本 92.0.4497.0+)发出自己的命令。
如果该命令不需要任何参数,请在"协议监视器"面板底部的提示符中键入该命令,然后按 Enter 键,例如:
Page.captureScreenshot
。
如果命令需要参数,请以 JSON 形式提供,例如:
{"command":"Page.captureScreenshot","parameters":{"format": "jpeg"}}
.
简析CDP协议
什么是CDP?
Chrome DevTools Protocol的数据交互是通过WebSocket进行的。WebSocket是一种全双工通信协议,可以在单个TCP连接上进行双向通信。通过WebSocket,前端和后端之间可以进行实时通信,数据可以在两个方向上实时传输。
在Chrome DevTools Protocol中,前端通过WebSocket向后端发送命令和数据。后端接收到命令和数据后,会根据命令执行相应的操作,并将结果通过WebSocket返回给前端。前端接收到结果后,会将其显示在用户界面上,或者执行相应的操作。
在数据交互过程中,Protocol使用了JSON格式的数据进行传输。JSON是一种轻量级的数据交换格式,易于人类阅读和编写,也易于机器解析和生成。在前端和后端之间传输的数据都是JSON格式的字符串。
协议是如何定义的?
规范协议定义位于 Chromium 源代码树中:(browser_protocol.pdl 和js_protocol.pdl)。它们由 DevTools 工程团队手动维护。声明性协议定义跨工具使用;例如,在 Chromium 中创建了一个绑定层,供 Chrome DevTools 与之交互,并为 Chrome Headless 的 C++ 接口单独生成绑定。
CDP的数据是如何组成的?
之前有说到过CDP的数据是基于JSON的,所以从前端传到后端的数据格式是这样的应该没问题吧
json
{
"id":1,
"method":"Network.enable",
"params":{"maxPostDataSize":65536}
}
这个时候我们可以观察到到method
中的格式居然是用点来调用的,可是这是个字符串啊是不是有点不合理?
对就是不合理!所以是需要处理一下的。
这里的method是用.
来分割的,前面的部分是domain(域)
当然不是域名,可以理解为作用域。后面跟着的才是真正的调用的对应方法。
比如Network的方法们
这时候有的同学可能就会问了:"啊?那么土?用split('.')
来分割?"
对的,源码里也是这么干的。
开始实现
下载源码
下载并且打开源码
baseh
npm install chrome-devtools-frontend@1.0.672485
后端部分
Chrome中自带的后端
前面说了Chrome自带后端,但是我们如何才能够去感知到这个后端呢?
打开远程调试,指定端口
bash
sudo /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9527
这个时候启动的Chrome就是作为一个Server host的web app。
地址栏中输入:localhost:9527/json
or /json/list
我们可以看到当前浏览器打开的tab页,以及页面的id,inspect地址等等内容,由此我们可以感知到浏览器中后端的存在。(当然在后面自己实现前端的部分会更加明显的感知到)
inspector.html
和Chrome host之间通过webSocket建立连接,这个ws地址就是url中ws参数的值。其中55A4F84F6A66845F72388146E3B8F986
是page id,每个页面都有一个唯一的page id,chrome就是通过这个id确定哪个是目标页面。
我们进入到该调试页面中,打开调试页面即可看到ws链路。证明了该页面在与Chrome后端进行ws的数据交互。 并且chrome调试器实例和目标页面实例之间是进程通信,所以inspector.html可以通过chrome调试器实例加载目标页面的source文件,还可以操作目标页面,例如加断点、刷新、记录Network信息等。
HTTP端
ruby
// 关闭一个标签页,传入该页面的id
<http://localhost:9527/json/close/7FBA9CF445D4BF16990FEF94A6F32F76>
// 激活标签页
<http://localhost:9527/json/activate/7FBA9CF445D4BF16990FEF94A6F32F76>
// 查看chrome和协议的版本信息
<http://localhost:9527/json/version>
// 查看使用的协议内容
<http://localhost:9527/json/protocol>
// 浏览器自带的调试工具
/devtools/inspector.html
// 协议的ws端点
WebSocket: /devtools/page/{targetId}
悟了
笔者作为一个前端开发者,理解到这里的时候突然悟了。
💡 了解完这段我对浏览器有了新的理解,浏览器的实质是个巨型桌面应用。
那前端开发者的实质就是在浏览器游戏的开放规则下的玩家。
那浏览器本身是不是就是一个CS架构?
浏览器的后端是不是可以理解为是一个巨大的中间层?
那如果需要完全打通devtools的全部原理和流程的话还需要加入一些浏览器实现原理的部分。
手搓devTools后端
我们已经启了一个前端网页了,但是俗话说得好上阵父子兵,一个前端并不能达到调试的效果,并且我们需要去观察后端是怎么运作的,最好的方法就是------自己启一个node服务
(如何打开图片页面在后文打开fontend文件 的部分)
接下来在url的后面加上?ws=localhost:port
使该页面使用我们自己写的后端。
后端首先要安装WebSocket
javascript
const ws = require('ws');
const wss = new ws.Server({port: 8988});
console.log('启动node程序')
wss.on('connection',function connection(ws) {
ws.on('message', function message(data) {
console.log('收到数据: %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:8080/>",
baseURL: "<http://127.0.0.1:8080/>",
xmlVersion: "",
compatibilityMode: "NoQuirksMode"
}
}
}));
ws.send(JSON.stringify({
method: "DOM.setChildNodes",
params: {
nodes: [
{
attributes: [
"class",
"devToolsClass"
],
backendNodeId: 6,
childNodeCount: 0,
children: [
{
backendNodeId: 6,
localName: "",
nodeId: 7,
nodeName: "#span",
nodeType: 3,
nodeValue: "页面的文字",
parentId: 6,
}
],
localName: "div",
nodeId: 6,
nodeName: "DIV",
nodeType: 1,
nodeValue: "",
parentId: 5
}
],
parentId: 5
}
}));
} else if (message.method === 'DOM.requestChildNodes') {
ws.send(JSON.stringify({
id: message.id,
result: {}
}));
}
})
ws.send(JSON.stringify({
method: "Runtime.consoleAPICalled",
params: {
type: "log",
args: [
{
type: "string",
value: "被输出了"
}
],
executionContextId: 92,
timestamp: 1694608920078.64,
stackTrace: {
callFrames: [
{
functionName: "",
scriptId: "5500",
url: "",
lineNumber: 0,
columnNumber: 8
}
]
}
},
}))
ws.send(JSON.stringify({
method: "Network.requestWillBeSent",
params: {
requestId: `10088`,
frameId: '123',
loaderId: '12388',
request: {
url: 'www.zhangg0.com',
method: 'post',
headers: {
"Content-Type": "text/html"
},
initialPriority: 'High',
mixedContentType: 'none',
postData: {
"name": 1
}
},
timestamp: Date.now(),
wallTime: Date.now() - 10000,
initiator: {
type: 'other'
},
type: "Document"
}
}));
})
我们在上述操作中我们会收到前端传递的消息并且打印在控制台中,并且会在Element中显示DOM结构,在Network中会发送一条请求。我们验证一下是否和我们预想的一样。
刷新一下前端页面
在上述部分中我们可以看到inspect和控制台中出现的情况与我们预计的相同。
至此,手搓后端成功。
前端部分
打开fontend
文件
我们在fontend
文件夹下就可以看到devtools_app.html,node_app.html
,很好这个名字一看就是个主文件,所以我们起一个npx http-server .
静态服务看看情况。打开对应的页面就可以看到我们的调试工具了。
对的,它是个网页,我们甚至可以在调试工具里打开调试工具
打开之后我们就可以看到我们的Protocol Monitor一直在与后端信息交互,那么问题来了?后端在哪里?我不是只启了前端么??后端呢??后端已经集成在chromium中了,以此来形成CS架构。
其实这个网页也集成在Chrome了devtools://devtools/bundled/inspector.html
手搓前端
首先我们需要有一个后端,前文有提到过打开Chrome的后端,我们使用相同的方式
javascript
sudo /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9527
这个时候会跳出来页面。
因为我们使用Chrome的后端,所以我们就要遵守其协议,首先要安装一下这个包chrome-remote-interface
javascript
const CDP = require('chrome-remote-interface');
async function myDevToolsFE() {
let client;
try {
client = await CDP({
host: 'localhost',
port: 9527
});
const { Page, DOM, Debugger, Runtime, CSS, Profiler } = client;
console.log(Page, DOM, Debugger, Runtime, CSS, Profiler);
} catch(err) {
console.error(err);
}
}
myDevToolsFE();
把各个域的内容打印出来,如下。就是CDP协议中的域和方法。
我们在前端开始使用一些CDP的方法
javascript
const CDP = require('chrome-remote-interface');
async function myDevToolsFE() {
let client;
try {
client = await CDP({
host: 'localhost',
port: 9527
});
const { Page, DOM, Debugger, Runtime, CSS, Profiler,Network } = client;
console.log(Page, DOM, Debugger, Runtime, CSS, Profiler);
await Network.enable();
//网络 requestWillBeSent->当页面即将发送HTTP请求时触发。
Network.requestWillBeSent((params) => {
console.log('发起请求' + params.request.url)
});
await Page.enable();
await Debugger.enable();
await DOM.enable();
await CSS.enable();
CSS.on('styleSheetAdded', async (event) => {
debugger;
const styleSheetId = event.header.styleSheetId;
const content = await CSS.getStyleSheetText({ styleSheetId });
cssMap.set(styleSheetId, {
meta: event.header,
content: content.text
});
})
Debugger.on('scriptParsed', async (event) => {
debugger;
const scriptId = event.scriptId;
const content = await Debugger.getScriptSource({ scriptId });
jsMap.set(scriptId, {
meta: event,
content: content.scriptSource
});
})
await Page.enable()
await Page.navigate({url: '<http://localhost:9527/json/protocol>'})
const res = await Page.captureScreenshot()
} catch(err) {
console.error(err);
}
}
myDevToolsFE();
后端传给我们的CSS属性
页面也进行了跳转
至此,我们的前端就搓完了(授人以鱼不如授人以渔,已经教了怎么搓就是已经搓完了)。
到这里就发现了一个很有趣的冷知识,之前在console里面直接输入指令就能实现还以为控制台里集成了,了解了调试工具原理之后才发现原来他只是个传话筒罢了~
如何自己搓一个DevTools
前面已经给大家搓了前端和后端,把我们自己搓的前后端拼接在一起那就是我自己搓的DevTools了。
我们知道CDP它们由 DevTools 工程团队手动维护。
DevTools的主要组成实际上就是前端后端和CDP协议,那前后端我们都自己搓了,接下来只需要自己定义一套协议,并且完成里面的函数方法之后,你就拥有了一个完全由自己开发的devTools了。这边由于篇幅有限就不贴代码了(狗头)。
学这个有什么用??
devTools在那么多年的迭代下已经非常的完善,我们如果需要去手搓一个devTools明显是不够现实的,所以有的同学可以就会问我们学这个到底有什么用?
其实学的时候因为论坛上没有很多能比较完整的且能形成闭合且看起来容易入门理解的文章,导致我在这个过程中遇到了很多的瓶颈我也在问自己为什么?其实去了解这个的原因就是因为我自己多问了个为什么。。。。
- 了解了实现原理,可能自己某些当前devtools无法覆盖的情况下去实现自己的一个devtools或其中的某个功能,例如
vue
orreact
的调试插件。 - 了解浏览器的深层原理
结语
老师从小就教说要多问几个为什么,这些年来我发现一个问题,就是为什么问的越多就会越觉得自己菜哈哈哈
参考
- chromedevtools.github.io/devtools-pr...
- juejin.cn/post/684490...
- juejin.cn/post/684490...
- juejin.cn/post/713394...
以及掘金及国内外各大技术论坛的各种文章。。