快应用 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.jsstartApplication 启动应用,加载应用需要的模块、资源等,并启动核心模块。核心模块中有一个 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