编写一个 Chrome 开发者工具的扩展程序
April 25, 2020
在工作中的一个项目里,我们的前端框架建立在 Web Components 技术之上,每个界面都由数个到数十个 Custom Elements 组成。它们基于 Web 标准技术,因此开发者可以直接使用浏览器原生的开发者工具来辅助开发和调试。例如利用 Chrome 的开发者工具,我们可以直接审查指定的 Custom Elements,也可以在控制台中打印出它们的属性等等。
这些自定义元素通常都有与业务相关的自定义属性、方法和事件,虽然我们可以在控制台中打印出这个元素,但是要从大量的继承自 HTMLElement
的属性中分辨出想要的业务属性并不容易。另一方面,框架为静态声明式的配置文件添加了运行时处理动态数据的能力,开发者有时希望能审查这些数据转换的过程。
浏览器原生的开发者工具已经不能满足需求,因此我们决定扩展一下它的能力。
在讲述如何编写这个扩展程序前,先看两张成品图片,以便理解我们想要拓展的能力。我们为开发者工具新建了一个 “Bricks” 面板,它可以列出当前审查的页面由自定义元素组成的渲染树,鼠标经过时可以实时显示对应元素的轮廓,点击选中一个元素则可以显示它的业务属性和事件配置等。
切换到 “Evaluations” 则可以实时显示框架中被称为 “Evaluate” 的数据处理的过程、结果及其上下文信息。
本文不会从头讲述如何一步一步地编写扩展程序,对于这些我们应该去阅读官方开发手册。本文主要侧重讲述其中不易理解、或相对困难的部分,如扩展程序中各模块的通讯方式、限制、以及如何突破这些限制等。
接下来进入正题,首先阅读官方的几篇文档《Getting Started Tutorial》、《Extending DevTools》,其中下面这张图很好地解释了一个 Chrome 开发者工具扩展程序所涉及的几个主要模块及其之间的关系:
Content scripts:
- 以当前审查的页面为上下文访问完整的 DOM 接口;
- 在相对的孤立世界中执行,它的执行被设计为不与页面或其它 content scripts 冲突;
- 可以与 Background page 通信;
Background page:
- 可以访问完整的扩展程序 API;
- 可以分别与 Content scripts、 DevTools page 通信;
DevTools page:
- 可以访问
chrome.devtools.*
API; - 为开发者工具创建新的面板;
其中 chrome.devtools.inspectedWindow.eval
:
- 可以在 DevTools page 及其创建的面板页面中使用它;
- 以当前审查的页面为上下文执行指定 JavaScript 代码;
- 该上下文还额外包括了开发者工具控制台 API;
- 可以设置一个选项以使用 content scripts 上下文来执行代码。
我们的扩展程序的主要业务逻辑和界面都在 DevTools page,它不能直接访问 DOM,而我们需要展示的信息几乎都来自 DOM,因此只能借助其它模块来实现间接访问。
首先看最接近 DOM 的 content scripts,它有个关键词—“孤立世界”,虽然官方文档对此有说明,但依然有些令人费解。从手工测试结果来看,content scripts 中访问标准的 DOM 接口可以按预期工作,除此之外的操作将撞上孤立世界的边界,即:除了标准 DOM 接口以外的操作只能影响该脚本自身。
例如以下 content script 可以按预期工作:
/* @file: your-content-script.js */window.addEventListener("DOMContentLoaded", () => {document.body.style.background = "red";});
而以下脚本无法按预期工作:
/* @file: your-content-script.js */window.addEventListener("DOMContentLoaded", () => {// 假设审查的页面给 `body` 添加了自定义属性 `yourAddon`,// 但由于它不是标准 DOM 接口,content scripts 无法访问它,// 因此这里只会打印 `undefined`。console.log(document.body.yourAddon);// 同样适用于全局变量。console.log(window.YOUR_METADATA);// 为元素赋值 `yourAddon` 将只影响该脚本自身。document.body.yourAddon = "hello world";// 将打印 `"hello world"`。但在审查的页面中,该属性不受影响。console.log(document.body.yourAddon);});
我们想要展示的、由自定义元素组成的渲染树及其业务属性等信息,正是标准 DOM 接口以外的数据,content scripts 无法直接访问,需要另辟蹊径。
再看 inspectedWindow.eval
,它也能以当前审查的页面为上下文执行代码,它接收一段字符串作为代码执行,并且将执行结果传递给回调函数,例如:
/* @file: your-devtools-page.js */chrome.devtools.inspectedWindow.eval("document.body.yourAddon", (result) => {// 假设审查的页面给 `body` 添加了自定义属性 `yourAddon` 值为 `"hello world"`,// 将打印 `"hello world"`。console.log(result);});
这看起来可以让我们拿到想要的渲染树信息了。但是官方文档也说明了它对代码执行结果的要求:合法的 JSON 对象,即可以序列化为 JSON 的对象,它只能包含 JavaScript 原始类型的值、并且不包含循环引用。这意味着它的能力受到了限制,例如无法直接传递原生事件对象 Event
、CustomEvent
或者内部包含循环引用的属性,不过我们先暂时放下这个问题。
渲染树信息可以由框架封装接口提供给 inspectedWindow.eval
调用,但这意味着框架需要额外捆绑扩展程序专用的代码逻辑,并且这些逻辑与扩展程序紧密相关,这增加了代码耦合度。
为了尽量减少对框架的侵入、降低耦合,还需要进一步探索。在参考了 React Developer Tools 的源码后,我发现了能让代码在真正的审查页面的上下文中执行的方法。在 content scripts 中动态插入 <script>
标签,插入 <script>
的过程是标准的 DOM 操作,仍然在 content scripts 的孤立世界中,而它指向的代码文件则跳脱了出来!
/* @file: your-content-script.js */const script = document.createElement("script");// `hook.js` 将在真正的审查页面的上下文中执行。script.src = chrome.runtime.getURL("hook.js");document.documentElement.appendChild(script);script.parentNode.removeChild(script);
由此,获取渲染树信息的代码逻辑可以放在 hook.js
中,并在 DevTools page 中使用 Eval 接口调用它。
/* @file: hook.js */window.getRenderTree = () => { ... }/* @file: your-devtools-page.js */chrome.devtools.inspectedWindow.eval("window.getRenderTree()", (result) => {// Display render tree.});
渲染树可以显示了,那么如何获取单个自定义元素呢?Eval 接口接收的是一串字符串代码,无法为它传递引用,但我们可以想办法在 hook.js
中存储一个映射表来存放自增 ID 与自定义元素的关系,Eval 调用时只传递 ID。
/* @file: hook.js */// 简易自增 ID 工厂let uniqueIdCounter = 0;function uniqueId() {return (uniqueIdCounter += 1);}// 映射表const uidToElement = new Map();window.getRenderTree = () => {// 记录映射表uidToElement.set(uniqueId(), element)};// 获取元素属性信息window.getElement = (uid) => uidToElement.get(uid);/* @file: your-devtools-page.js */chrome.devtools.inspectedWindow.eval(`window.getElement(${uid}).props`,(result) => {// Display element props.});
之前提到过 Eval 接口还可以访问开发者工具控制台 API,例如我们可以调用 inspect()
来审查元素或函数:
/* @file: your-devtools-page.js */// `inspect(element)` 将打开 Element 面板并选中该元素。chrome.devtools.inspectedWindow.eval(`inspect(window.getElement(${uid}));`);// `inspect(function)` 将打开 Source 面板并指向该函数对应的源码。chrome.devtools.inspectedWindow.eval(`inspect(window.getElement(${uid}).constructor);`);
迄今为止我们都是在 DevTools page 主动调用 Eval 接口获取信息,但有时候我们需要响应来自审查的页面的消息,例如当页面重新渲染后,希望 DevTools page 能够自动重新获取渲染树,又或者框架完成一次 Evaluate 数据处理后,希望在 DevTools page 实时显示。
上文介绍过,DevTools page 无法直接和审查的页面通信,但我们可以通过 content script 和 background page 作为代理管道转发消息完成通信。
Hook↖↘Content script↖↘Background page↖↘DevTools page
其中需要注意的是,对于每个扩展程序,只有一个 background page 实例,而 content script 和 DevTools page 则是每个 tab 页对应一个实例,因此在 background page 中实现双向管道时需要识别连接的 tab.id
。
在这个通信过程中,我们再次遇到了和 Eval 接口一样的问题:只能传递 JSON 序列化的数据。序列化,或者说更广义的编码/解码,是很多通讯方式的基础,遥远如书信,信息被编码为文字,近如网络,信息被编码为数字信号。编码再解码后的得到的信息其实不是真正的原始信息,而是一份有损的拷贝,正如电话里听到的是手机扬声器还原的对方的声音,网络视频里看到的是屏幕上绘制的对方镜头的影像拷贝。
我们的扩展程序需要展示一些数据结构信息,其中难免包含复杂对象类型或循环引用的数据,那如何解决 JSON 序列化的问题呢?再次参考 React Developer Tools 的源码,它使用了一对命名为 dehydrate
和 hydrate
(脱水和补水)的函数,发送消息前,dehydrate
将原始信息进行脱水,将复杂对象类型转换为特定的可以被 JSON 序列化的结构信息,接收消息时,对数据进行补水,还原得到原始的对象类型。
这对函数的命名有点意思,让我想到了科幻小说《三体》里的情节,三体人为了在乱纪元中生存,会自我脱水进入休眠状态,而到了恒纪元则完成补水重新苏醒。这或许正如复杂数据想要在通信中保持相对完整也需要进行脱水和补水一样。
回到主题,一个简单的脱水效果大约如下:
// 脱水前const event = new CustomEvent("something-happened", {detail: "oops"});const input = {firedEvent: event};// 执行脱水const output = dehydrate(input);// 脱水后expect(output).toEqual({// 普通对象如 `{ firedEvent: ... }` 保留原结构firedEvent: {// 复杂对象被转换为特定结构,并附带一些跟该类型相关的信息// `__dehydrated_use_only__` 是一个用于识别脱水数据的特殊 key__dehydrated_use_only__: {type: "object",constructorName: "CustomEvent",children: {type: "something-happened",detail: "oops"}}}});
脱水后的数据不再包含循环引用,并且在补水后可以还原得到原始对象类型的相关信息,虽然是有损的,但已经能满足界面展示数据类型的需求。
但是,React Developer Tools 的实现中没有包含对循环引用的处理,它通过限制递归层级绕开了这个问题,只在用户需要访问更深层级的数据时再去实时获取。
我尝试采取了另一种方案,在返回的数据中额外包含一个映射表,存储所有被多次引用的对象,同时对 dehydrate
的数据扩展一种映射类型,表示数据存放在映射表中。优化后的脱水效果大约如下:
// 脱水前const person = {name: "Good Man"};person.loves = person;// 执行脱水const dehydratedPerson = dehydrate(person);// 脱水后// `value` 固定为 `dehydrate()` 结果的主体数据expect(dehydratedPerson.value).toEqual({name: "Good Man",loves: {__dehydrated_use_only__: {type: "ref",ref: 0}}});// `repo` 固定为 `dehydrate()` 结果的引用映射表expect(dehydratedPerson.repo).toEqual([{name: "Good Man",loves: {__dehydrated_use_only__: {type: "ref",ref: 0}}}]);
补水后则可以还原循环引用关系:
// 补水const person = hydrate(dehydratedPerson);expect(person.name).toBe("Good Man");expect(person.loves).toBe(person);
至此,当下的主要困难都已经被解决。Keep exploring.
注:本文代码均只作示意用途,非实际代码。该扩展程序已发布:Brick Next Developer Tools,源码托管在 GitHub:brick-next-devtools。