快应用 IDE 定制 Devtools 元素面板系列三:通信方案
在上一篇快应用 IDE 定制 Devtools 元素面板系列二:新增面板,介绍了如何在 devtools-frontend 新增一个面板,接下来就需要考虑 element 面板的具体实现。Element 面板主要有下面 3 个功能:
- 渲染元素树,编辑元素属性;
- 渲染样式表,编辑样式;
- 页面元素高亮,检查页面元素;
渲染元素树、样式表,需要获取页面的信息;设置属性、样式、高亮,需要页面有相应的更新。所以,为了实现这些功能,需要先建立 devtools (frontend) 和页面 (backend) 之间的通信。
建立通信,有两种可能的方案:
- 基于 devtools 原本的通信协议开发,加上定制的内容。
- 自定义通信协议,这里参考 react-devtools 的方案。
Devtools 通信机制
1. 阅读 wiki,初步了解 devtools 通信协议
- devtools 包含前端和后端,前端是 devtools-frontend,后端是 chromium content(即 chromium 核心代码)。前后端通过远程调试协议 (Remote Debugging Protocol) 进行通信。
- chromium 的远程调试协议在 json rpc 2.0 上运行,协议分为多个域(代表被检查实体的语义层),每个域定义了类型、commands(前端发送给后端的消息)、events(后端发送给前端的消息)。
- chromium 有不同的消息通道:Embedder channel,Websocket channel,Chrome extension channel,USB/ADB channel。
2. devtools 和 page 的通信机制
devtools 和 page 通过 websocket 进行通信。调用 Electron 的 webContents.openDevTools()
方法,可以打开 devtools-frontend 页面的 devtools,在 Network 面板查看到其通信内容。
在 devtools-frontend 的源码中搜索 getResourceTree, 可以发现协议是在 browser_protocol.json
中定义的,在 InspectorBackendCommands.js
中注册后端命令,在 ResourceTreeModel.js
中调用。
// third_party/blink/public/devtools_protocol/browser_protocol.json
{
"domain": "Page", // 域
"description": "Actions and events related to the inspected page belong to the page domain.",
"dependencies": [
"Debugger",
"DOM",
"IO",
"Network",
"Runtime"
],
"types": [...],
"commands": [ // 前端发送给后端的消息
{
"name": "getResourceTree",
"description": "Returns present frame / resource tree structure.",
"experimental": true,
"returns": [{
"name": "frameTree",
"description": "Present frame / resource tree structure.",
"$ref": "FrameResourceTree"
}]
},
...
],
"events": [ // 后端发送给前端的消息
{
"name": "domContentEventFired",
"parameters": [{
"name": "timestamp",
"$ref": "Network.MonotonicTime"
}]
}
...
]
}
// front_end/generated/InspectorBackendCommands.js
inspectorBackend.registerCommand(
"Page.getResourceTree",
[],
["frameTree"],
false
);
// front_end/sdk/ResourceTreeModel.js
// 通过 agent 获取后端数据
this._agent.getResourceTree().then(this._processCachedResources.bind(this));
从 ResourceTreeModel 入手,初步了解通信方式。
// front_end/sdk/ResourceTreeModel.js
export class ResourceTreeModel extends SDKModel { // 1. 父类是 SDKModel
/**
* @param {!Target} target
*/
constructor(target) {
super(target);
// 2. 通过 target 可以获取到某个 model
const networkManager = target.model(NetworkManager);
...
// 3. 通过 target 可以获取到 agent
this._agent = target.pageAgent();
...
// 4. 通过 agent 可以发送消息给后端
this._agent.getResourceTree().then(this._processCachedResources.bind(this));
}
}
ResourceTreeModel 通过 Agent 发送消息给后端,而 Agent、Model 都可以通过 Target 获取。
devtools-frontend 的通信机制如下:
Model、Agent、Target 实现了 devtools 和页面的通信。
- Agent:代理,发送消息给后端。与前端同步的后端数据对象,按 domain 划分,比如 pageAgent,domAgent,cssAgent,workerAgent 等。
- Model:模型,监听后端消息。与后端同步的前端数据对象,比如 ResourceTreeModel,DomModel,CssModel 等。
- Target:目标页面对象,处理前后端通信,挂载 Model 和 Agent。
为了进一步理解通信的启动流程,我们需要继续阅读源码。
2.1 Target 启动流程
devtools 有 devtools_app,inspector, node_app,js_app,work_app 等多种应用。其中,浏览器右键检查对应的是 inspector 模块,其文件结构如下:
front_end
├── ...
├── inspector.html // 入口文件,加载 inspector.js
├── inspector.js // 启动文件
├── inspector.json // 配置文件
├── ...
inspector.js 通过 front_end/RuntimeInstantiator.js
的 startApplication
启动应用,加载应用需要的模块、资源等,并启动核心模块。核心模块中有一个 main
模块,是页面初始化的入口,用于创建应用 UI、初始化 Target 等。这里只关注 Target 的初始化。
- _initializeTarget: 加载 early-initialization 类型的插件。
// front_end/main/MainImpl.js
async _initializeTarget() {
MainImpl.time('Main._initializeTarget');
// 1. 实例化 'early-initialization' 类型的插件
const instances =
await Promise.all(self.runtime.extensions('early-initialization').map(extension => extension.instance()));
// 2.运行 'early-initialization' 类型的插件
for (const instance of instances) {
await /** @type {!Common.Runnable.Runnable} */ (instance).run();
}
...
}
- 搜索 early-initialization,可以发现它们是各应用的主模块的主插件。对于 inspector 应用,加载的是 inspector_main 模块的 InspectorMain 类。
// front_end/inspector_main/module.json
{
"extensions": [
{
"type": "early-initialization",
"className": "InspectorMain.InspectorMain"
},
...
],
...
}
- 运行 InspectorMain,建立主连接,创建 Target。
// front_end/inspector_main/InspectorMain.js
export class InspectorMainImpl extends Common.ObjectWrapper.ObjectWrapper {
async run() {
let firstCall = true;
// 1. 建立主连接
await SDK.Connections.initMainConnection(async () => {
// 2. Target 的类型:node 或 frame
const type = Root.Runtime.queryParam('v8only') ? SDK.SDKModel.Type.Node : SDK.SDKModel.Type.Frame;
const waitForDebuggerInPage = type === SDK.SDKModel.Type.Frame && Root.Runtime.queryParam('panel') === 'sources';
// 3. 用 TargetManager 实例创建 Target
const target = SDK.SDKModel.TargetManager.instance().createTarget(
'main', Common.UIString.UIString('Main'), type, null, undefined, waitForDebuggerInPage);
...
}
}
在 inspector 中 Target 是 frame 类型,也就是说 Target 对应我们调试的页面 或 iframe。
- 新建 Target
// front_end/sdk/SDKModel.js
export class TargetManager extends Common.ObjectWrapper.ObjectWrapper {
createTarget(
id,
name,
type,
parentTarget,
sessionId,
waitForDebuggerInPage,
connection
) {
// 1. 新建 Target
const target = new Target(
this,
id,
name,
type,
parentTarget,
sessionId || "",
this._isSuspended,
connection || null
);
if (waitForDebuggerInPage) {
// 2. agent 等待调试,agent 来自于 TargetBase
// @ts-ignore TODO(1063322): Find out where pageAgent() is set on Target/TargetBase.
target.pageAgent().waitForDebugger();
}
// 3. 根据 _modelObservers 新建 Model
target.createModels(new Set(this._modelObservers.keysArray()));
// 4. Target 保存到 TargetManager 的 _targets 中
this._targets.add(target);
// 5. Target 添加到 Target 观察集合中
// Iterate over a copy. _observers might be modified during iteration.
for (const observer of [...this._observers]) {
observer.targetAdded(target);
}
// 6. Model 添加到 Model 观察集合中
for (const modelClass of target.models().keys()) {
const model = /** @type {!SDKModel} */ (target.models().get(modelClass));
this.modelAdded(target, modelClass, model);
}
// 7. 绑定 Model 的监听事件
for (const key of this._modelListeners.keysArray()) {
for (const info of this._modelListeners.get(key)) {
const model = target.model(info.modelClass);
if (model) {
model.addEventListener(key, info.listener, info.thisObject);
}
}
}
return target;
}
}
新建 Target 时,也新建了它的 model。而 agent 与 Target 的父类 TargetBase 有关。
- 新建 Model
// front_end/sdk/SDKModel.js
export class Target extends ProtocolClient.InspectorBackend.TargetBase {
createModels(required) {
...
const registered = Array.from(SDKModel.registeredModels.keys());
for (const modelClass of registered) {
const info = (SDKModel.registeredModels.get(modelClass));
if (info.autostart || required.has(modelClass)) {
// 1.调用 this.model() 新建 model
this.model(modelClass);
}
}
this._creatingModels = false;
}
model(modelClass) {
if (!this._modelByConstructor.get(modelClass)) {
// 2. 确认 modelClass 已注册,从 SDKModel.registeredModels 获取 modelClass
const info = SDKModel.registeredModels.get(modelClass);
if (info === undefined) {
throw 'Model class is not registered @' + new Error().stack;
}
if ((this._capabilitiesMask & info.capabilities) === info.capabilities) {
// 3. 新建 model 实例
const model = new modelClass(this);
// 4. 保存 model 到 _modelByConstructor
this._modelByConstructor.set(modelClass, model);
if (!this._creatingModels) {
this._targetManager.modelAdded(this, modelClass, model);
}
}
}
// 5. 返回 model 实例
return this._modelByConstructor.get(modelClass) || null;
}
}
从以上代码可以看出,新建 model 时需要确认 modelClass 已经注册。也就是说新建 model 之前,需要先注册 modelClass;新建时,再从 SDKModel.registeredModels 中获取 modelClass。以 ResourceTreeModel 为例:
// front_end/sdk/ResourceTreeModel.js
// 1. 注册 modelClass
SDKModel.register(ResourceTreeModel, Capability.DOM, true);
// front_end/sdk/SDKModel.js
export class SDKModel extends Common.ObjectWrapper.ObjectWrapper {
// 2. 保存 modelClass 到 _registeredModels
static register(modelClass, capabilities, autostart) {
_registeredModels.set(modelClass, { capabilities, autostart });
}
// 3. 获取 _registeredModels
static get registeredModels() {
return _registeredModels;
}
}
- 新建 Agent
前面已经知道 agent 与 TargetBase 有关,查看 TargetBase 的代码,可以发现 agent 在其构造函数中新建。但是这里没有 pageAgent() 等获取 agent 的方法。
// front_end/protocol_client/InspectorBackend.js
export class TargetBase {
constructor(needsNodeJSPatching, parentTarget, sessionId, connection) {
...
this._agents = {};
// 1. 遍历 inspectorBackend._agentPrototypes,新建 agent,按 domain 添加到 this._agents
for (const [domain, agentPrototype] of inspectorBackend._agentPrototypes) {
this._agents[domain] = Object.create(/** @type {!_AgentPrototype} */ (agentPrototype));
this._agents[domain]._target = this;
}
}
}
继续查看 inspectorBackend._agentPrototypes,可以发现是在注册命令时,新建 agent 原型,注册命令,同时给 target 添加 {domain}Agent 方法,比如 pageAgent() 获取 page 域对应的 agent。
// front_end/generated/InspectorBackendCommands.js
// 调用注册命令:Page 是域,getResourceTree 是 方法
inspectorBackend.registerCommand('Page.getResourceTree', [], ['frameTree'], false);
// front_end/protocol_client/InspectorBackend.js
export class InspectorBackend {
// 1. 注册命令(前端发送给后端的消息):调用 this._agentPrototype 添加 agent 原型
registerCommand(method, signature, replyArgs, hasErrorData) {
// 取出 domain 和方法名
const domainAndMethod = method.split('.');
// 新建 agent 原型,注册命令
this._agentPrototype(domainAndMethod[0]).registerCommand(domainAndMethod[1], signature, replyArgs, hasErrorData);
this._initialized = true;
}
// 2. 新建 agent 原型
_agentPrototype(domain) {
if (!this._agentPrototypes.has(domain)) {
// 根据 domain 新建 agent 原型
this._agentPrototypes.set(domain, new _AgentPrototype(domain));
this._addAgentGetterMethodToProtocolTargetPrototype(domain);
}
return /** @type {!_AgentPrototype} */ (this._agentPrototypes.get(domain));
}
// 3. 给 target 原型添加 agent 的 getter 方法
_addAgentGetterMethodToProtocolTargetPrototype(domain) {
...
// 方法名:比如 Page -> pageAgent
const methodName = domain.substr(0, upperCaseLength).toLowerCase() + domain.slice(upperCaseLength) + 'Agent';
function agentGetter() {
return this._agents[domain];
}
// {domain}Agent 方法挂载到 TargetBase 原型,比如 target.pageAgent() = target._agents.page
TargetBase.prototype[methodName] = agentGetter;
}
}
class _AgentPrototype {
// 1. 注册命令
registerCommand(methodName, signature, replyArgs, hasErrorData) {
const domainAndMethod = this._domain + '.' + methodName;
function sendMessagePromise(vararg) {
const params = Array.prototype.slice.call(arguments);
// 发送消息给后端
return _AgentPrototype.prototype._sendMessageToBackendPromise.call(this, domainAndMethod, signature, params);
}
// @ts-ignore Method code generation
this[methodName] = sendMessagePromise;
...
}
// 2. 发送消息给后端
_sendMessageToBackendPromise(method, signature, args) {
...
return new Promise((resolve, reject) => {
...
// 用 TargetBase 中的 router 发送消息
this._target._router.sendMessage(this._target._sessionId, this._domain, method, params, callback);
});
}
}
2.2 初始化主连接
agent 发送消息给后端,是通过 target._router 发送的,而 _router 来自 TargetBase。在新建 Target 时,如果没有传入 connection,使用的是主连接。
// front_end/protocol_client/InspectorBackend.js
export class TargetBase {
constructor(needsNodeJSPatching, parentTarget, sessionId, connection) {
...
// 1. 新建 router,用于通信
/** @type {!SessionRouter} */
let router;
if (sessionId && parentTarget && parentTarget._router) {
router = parentTarget._router;
} else if (connection) {
router = new SessionRouter(connection);
} else {
// 2. 没有传入 connection 时,使用 _factory,也就是主连接
router = new SessionRouter(_factory());
}
/** @type {?SessionRouter} */
this._router = router;
router.registerSession(this, this._sessionId);
...
}
}
在 2.1 启动流程中,通过 initMainConnection 初始化主连接。
// front_end/sdk/Connections.js
// 初始化主连接
export async function initMainConnection(
createMainTarget,
websocketConnectionLost
) {
// 1. 设置 factory
ProtocolClient.InspectorBackend.Connection.setFactory(
_createMainConnection.bind(null, websocketConnectionLost)
);
await createMainTarget();
Host.InspectorFrontendHost.InspectorFrontendHostInstance.connectionReady();
Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener(
Host.InspectorFrontendHostAPI.Events.ReattachMainTarget,
() => {
TargetManager.instance()
.mainTarget()
.router()
.connection()
.disconnect();
createMainTarget();
}
);
return Promise.resolve();
}
// 新建主连接
export function _createMainConnection(websocketConnectionLost) {
const wsParam = Root.Runtime.queryParam("ws");
const wssParam = Root.Runtime.queryParam("wss");
if (wsParam || wssParam) {
// 2. 获取 websocket 参数,新建 websocket 连接
const ws = wsParam ? `ws://${wsParam}` : `wss://${wssParam}`;
return new WebSocketConnection(ws, websocketConnectionLost);
}
if (Host.InspectorFrontendHost.InspectorFrontendHostInstance.isHostedMode()) {
return new StubConnection();
}
return new MainConnection();
}
2.3 Model 监听 event
通过 2.1 和 2.2 的解析,可以知道 command 的实现如下:
- TargetBase 用 router 管理前后端通信,新建 agent。
- inspectorBarkend 注册 command,新建 agent 原型,给 TargetBase 挂载 {domain}Agent 方法。
- Model 调用 target.{domain}Agent().{command}() 发送消息给后端。
对于 event 来说,也是一样的方式:
- TargetBase 用 router 管理前后端通信,新建 dispatcher。
// front_end/protocol_client/InspectorBackend.js
export class TargetBase {
constructor(needsNodeJSPatching, parentTarget, sessionId, connection) {
...
// 1. 新建 router,用于通信
// 没有传入 connection 时,使用 _factory,也就是主连接
router = new SessionRouter(_factory());
this._dispatchers = {};
// 2. 遍历 inspectorBackend._dispatcherPrototypes,新建 dispatcherPrototype,按 domain 添加到 this._dispatchers
for (const [domain, dispatcherPrototype] of inspectorBackend._dispatcherPrototypes) {
this._dispatchers[domain] = Object.create(/** @type {!_DispatcherPrototype} */ (dispatcherPrototype));
this._dispatchers[domain]._dispatchers = [];
}
}
// 注册 dispatcher
registerDispatcher(domain, dispatcher) {
if (!this._dispatchers[domain]) {
return;
}
this._dispatchers[domain].addDomainDispatcher(dispatcher);
}
}
export class SessionRouter {
constructor(connection) {
...
this._connection.setOnMessage(this._onMessage.bind(this));
}
_onMessage(message) {
// 监听到后端消息时,触发对应域发送事件
session.target._dispatchers[domainName].dispatch(
method[1], /** @type {{method: string, params: ?Array<string>}} */ (messageObject));
}
}
- inspectorBackend 注册 event,新建 dispatcher 原型,给 TargetBase 挂载 register{domain}Dispatcher 方法。
// front_end/generated/InspectorBackendCommands.js
// 调用注册事件:Page 是域,domContentEventFired 是 事件
inspectorBackend.registerEvent('Page.domContentEventFired', ['timestamp']);
// front_end/protocol_client/InspectorBackend.js
export class InspectorBackend {
// 1. 注册事件(后端发送给前端的消息)
registerEvent(eventName, params) {
const domain = eventName.split('.')[0];
// 新建监听器原型,注册事件
this._dispatcherPrototype(domain).registerEvent(eventName, params);
this._initialized = true;
}
// 2. 新建监听器原型
_dispatcherPrototype(domain) {
if (!this._dispatcherPrototypes.has(domain)) {
// 按 domain 添加到 _dispatcherPrototypes
this._dispatcherPrototypes.set(domain, new _DispatcherPrototype());
}
return /** @type {!_DispatcherPrototype} */ (this._dispatcherPrototypes.get(domain));
}
// 3. 新建 agent 原型时,也给 target 添加 register{domain}Dispather 方法
_addAgentGetterMethodToProtocolTargetPrototype(domain) {
...
// register{domain}Dispatcher 方法挂载到 TargetBase 原型,比如 target.rigisterPageDispatcher(dispatcher) = target.registerDispatcher('page', dispatcher)
TargetBase.prototype['register' + domain + 'Dispatcher'] = registerDispatcher;
}
}
class _DispatcherPrototype {
constructor() {
this._eventArgs = {};
this._dispatchers;
}
// 注册事件
registerEvent(eventName, params) {
this._eventArgs[eventName] = params;
}
// 添加域监听器
addDomainDispatcher(dispatcher) {
this._dispatchers.push(dispatcher);
}
// 发送事件
dispatch(functionName, messageObject) {
...
// 遍历监听器,执行对应的方法
for (let index = 0; index < this._dispatchers.length; ++index) {
const dispatcher = this._dispatchers[index];
if (functionName in dispatcher) {
dispatcher[functionName].apply(dispatcher, params);
}
}
}
}
- Model 调用 target.register{domain}Dispatcher 注册监听器,监听后端消息。
export class ResourceTreeModel extends SDKModel {
constructor(target) {
...
// 注册监听器
target.registerPageDispatcher(new PageDispatcher(this));
}
}
export class PageDispatcher {
constructor(resourceTreeModel) {
this._resourceTreeModel = resourceTreeModel;
}
// 监听后端发送过来的消息 'domContentEventFired'
domContentEventFired(time) {
// model 发送事件 'Events.DOMContentLoaded',在其他地方(比如 ui)监听事件
this._resourceTreeModel.dispatchEventToListeners(Events.DOMContentLoaded, time);
}
}
2.4 总结
- Agent:代理,与前端同步的后端数据对象,按 domain 划分(即调试协议中的 domain),比如 pageAgent,domAgent,cssAgent,workerAgent 等。
- 在
InspectorBackendCommands.js
中注册 commands,再通过 agent 发送命令给后端。
- 在
- Model:模型,与后端同步的前端数据对象,比如 ResourceTreeModel,DomModel,CssModel 等。
- target():获取对应的 target,target 在 model 实例化时传入。
- 在
InspectorBackendCommands.js
中注册 events,model 监听后端事件,并通过 dispatchEventToListeners 广播消息。
- Target:目标页面对象,处理前后端通信,挂载 Model 和 Agent。
- model(modelClass):新建并返回 model,如果已有直接返回
- models():获取所有 models
- {domain}Agent():获取对应域的 agent
- register{domain}Dispatcher:注册对应域的监听器
- router():获取管理通信的 SessionRouter
- TargetManager:目标页面管理器,用于管理 Target,比如 比如新增/删除/获取 Target,管理 Model 的监听器等。
获取 Target 有以下方法:- targets():获取所有 target
- mainTarget():获取主 target
- targetById(id):根据 id 获取 target
react-devtools 通信方案
react-devtools 通过 websocket 进行通信,有 extension 和 standalone(electron 应用)两种实现方式,这里只分析 standalone 这种方案。
1. 启动入口
devtools 的入口是 standalone.js,通过 startServer 启动一个 http 服务和对应的 websocket 服务。
page 的 html 通过 <script src="http://localhost:${port}"></script>
,向 devtools 启动的 http 服务请求 backend.js。在 backend.js 中,page 新建 websocket 客户端,和 devtools 建立通信;并注入 hook 转发页面原生事件,比如 mountComponent。
2. 通信方案
react-devtools 的通信模式如下图所示:
devtools 和 page 都是通过 wall 和 bridge 发送和监听消息,通信模式大体一致。可以先看 page 部分,page 各部分的关系和方法如下图所示:
-
hook:用于转发页面原始事件,比如 mountComponent,unmountComponent 等。
注:由于新版本 hook 的代码太多,这里用旧版本代码进行解释。
- 注入全局钩子,设置 window.REACT_DEVTOOLS_GLOBAL_HOOK = hook。hook 主要作用是订阅事件,发送事件。
// backend.js // window 注入 hook installGlobalHook(); // hook.js // 注入 hook function installGlobalHook(window: Object) { if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__) { return; } const hook = ({ _renderers: {}, helpers: {}, // 注入原始事件的对象,比如 hook.inject(oldReact) inject: function(renderer) { var id = Math.random().toString(16).slice(2); hook._renderers[id] = renderer; ... // 触发 renderer 事件 hook.emit('renderer', {id, renderer, reactBuildType}); return id; }, _listeners: {}, // 订阅事件,给对应的事件添加监听函数 sub: function (evt, fn) { hook.on(evt, fn); return () => hook.off(evt, fn); }, // 添加监听函数 on: function (evt, fn) { if (!hook._listeners[evt]) { hook._listeners[evt] = []; } hook._listeners[evt].push(fn); }, // 删除监听函数 off: function (evt, fn) { if (!hook._listeners[evt]) { return; } var ix = hook._listeners[evt].indexOf(fn); if (ix !== -1) { hook._listeners[evt].splice(ix, 1); } if (!hook._listeners[evt].length) { hook._listeners[evt] = null; } }, // 触发事件,执行监听函数 emit: function (evt, data) { if (hook._listeners[evt]) { hook._listeners[evt].map(fn => fn(data)); } } }); // 设置 window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook Object.defineProperty(window, '__DEVTOOLS_GLOBAL_HOOK__', { value: hook, }); return hook; }
- 装饰页面原始事件,在页面原始事件触发时,会触发 hook 事件,并执行监听函数:
// backend.js // 1. 注入原始事件的对象,触发 hook 'renderer' 事件 hook.inject(oldReact); ... // 2. 监听 hook 'renderer' 事件,执行 attachRenderer hook.on('renderer', ({id, renderer}) => { hook.helpers[id] = attachRenderer(hook, id, renderer); hook.emit('renderer-attached', {id, renderer, helpers: hook.helpers[id]}); }); // attachRenderer.js function attachRenderer(hook: Hook, rid: string, renderer: ReactRenderer): Helpers { ... // 3. 装饰原始方法 oldMethods = decorateMany(renderer.Reconciler, { mountComponent(internalInstance, rootID, transaction, context) { var data = getData(internalInstance); rootNodeIDMap.set(internalInstance._rootNodeID, internalInstance); // 4. 触发 hook 事件(页面原始事件),执行监听函数 hook.emit('mount', {internalInstance, data, renderer: rid}); }, ... }); } // inject.js // 5. 订阅 hook 事件(页面原始事件),设置监听函数 agent.xxx hook.sub('mount', ({renderer, internalInstance, data}) => agent.onMounted(renderer, internalInstance, data))
- 装饰方法的实现如下:
// attachRenderer.js // 装饰一个函数 function decorate(obj, attr, fn) { var old = obj[attr]; obj[attr] = function(instance: NodeLike) { // 执行原始函数 var res = old.apply(this, arguments); // 执行传入的函数,即 hook.emit,触发事件 fn.apply(this, arguments); return res; }; return old; } // 装饰多个函数 function decorateMany(source, fns) { var olds = {}; for (var name in fns) { olds[name] = decorate(source, name, fns[name]); } return olds; }
-
agent:将 hook 事件转发给 bridge。
- page 的消息发送、监听,是调用 agent 的方法,agent 再转发给 brige 处理。
-
bridge:用于缓冲发送事件、暂时发送事件、恢复发送事件、取消发送事件、处理接收的消息、转换消息格式(react 自定义的消息格式)等。
- bridge 调用 wall 的方法发送消息或添加消息监听器。
- 缓冲发送事件,主要是为了避免短时间内有太多的事件触发,发送过多消息,引发通信性能问题。具体实现如下:
// 1. 把消息放入缓冲消息队列,设置 timeout send<EventName: $Keys<OutgoingEvents>>( event: EventName, ...payload: $ElementType<OutgoingEvents, EventName> ) { if (this._isShutdown) { console.warn( `Cannot send message "${event}" through a Bridge that has been shutdown.`, ); return; } // When we receive a message: // - we add it to our queue of messages to be sent // - if there hasn't been a message recently, we set a timer for 0 ms in // the future, allowing all messages created in the same tick to be sent // together // - if there *has* been a message flushed in the last BATCH_DURATION ms // (or we're waiting for our setTimeout-0 to fire), then _timeoutID will // be set, and we'll simply add to the queue and wait for that this._messageQueue.push(event, payload); if (!this._timeoutID) { this._timeoutID = setTimeout(this._flush, 0); } } // 2. 根据 timeout,每隔一段时间,批量发送消息给 page _flush = () => { // This method is used after the bridge is marked as destroyed in shutdown sequence, // so we do not bail out if the bridge marked as destroyed. // It is a private method that the bridge ensures is only called at the right times. if (this._timeoutID !== null) { clearTimeout(this._timeoutID); this._timeoutID = null; } if (this._messageQueue.length) { for (let i = 0; i < this._messageQueue.length; i += 2) { this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]); } this._messageQueue.length = 0; // Check again for queued messages in BATCH_DURATION ms. This will keep // flushing in a loop as long as messages continue to be added. Once no // more are, the timer expires. this._timeoutID = setTimeout(this._flush, BATCH_DURATION); } };
- 暂停和恢复发送事件,devtools 关闭 react panel 时,暂停消息发送;devtools 打开 react panel 时,恢复消息发送。
-
wall:socket 对外的方法,调用 socket 发送消息,监听消息。
devtools 和 page 基本一致,只是没有 hook(因为不需要转发事件),agent 变成 store。
store 的作用和 agent 基本一致,用于发送和接收消息:react-devtools panel 触发元素选择、高亮等操作时,把消息放入 bridge 缓冲消息队列,每隔一段时间,批量发送消息给 page。devtools 接收到添加/删除/更新元素等消息时,更新 panel 的 UI。
最终采取方案
基于 devtools 原有的通信协议开发,需要修改 chromium content 部分,意味着需要修改 C++ 代码,对于前端来说,开发成本太高。
自定义通信协议,react-devtools 已经有一套成熟方案,考虑了消息发送的缓冲,可避免通信崩溃。
所以,我们选择在 react-devtools 的通信方案基础上自定义通信协议,但由于应用场景不完全一致,存在以下差异:
1. ws 和 wss
和 react-devtools 相反,IDE 在 page 新建 websocket 服务,在 devtools 新建 websocket 客户端。
这是因为,react-devtools 是先运行 devtools 应用(新建 wss),再刷新页面建立通信(新建 ws)。而 IDE 是先加载预览页面,再根据预览的 url 获取 devtools 的链接,加载 devtools。所以 IDE 需要在预览页面新建 wss,在 devtools 新建 ws。
2. 页面注入 js 的方法不同
react-devtools 是页面通过 http 请求,从 devtools 端的 http server 获取 js。但 IDE 是先加载页面,再加载 devtools,无法按 react-devtools 的方法给页面注入 js。
方案一:IDE 的预览页面是通过 webview 加载的,webview 可以通过 preload 设置预加载文件,通过预加载文件可以给页面注入 js。
<webview src="" preload="./inject.js"></webview>
方案二:electron 支持自定义文件协议,可以在 IDE 注册文件协议,页面通过文件协议加载 js。
IDE 注册文件协议:
protocol.registerFileProtocol("customProtocol", (request, callback) => {
// 返回文件地址
callback({ filePath });
});
页面加载 js:
<scripts src="customProtocol://inject.js"></scripts>
3. 增加端口的确定和通信
react-devtools 的 websocket 通信端口取 process.env.PORT 或默认值 8097。直接用这种方案,可能造成端口冲突,所以我们增加了端口的确定和通信:在 page 获取空闲的端口,再通过 ipcRenderer 发送消息给 IDE。devtools 启动时,通过 ipcRenderer 发送消息给 IDE 获取 端口。
electron 的 ipcRenderer 通信方法请查阅官方文档 ipcRenderer。