16

快应用 IDE 定制 Devtools 元素面板系列三:通信方案

 3 years ago
source link: https://quickapp.vivo.com.cn/quickapp-ide-customize-devtools-element-panel-series-3/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

快应用 IDE 定制 Devtools 元素面板系列三:通信方案

快应用开发工具 • Jul 04, 2020

在上一篇快应用 IDE 定制 Devtools 元素面板系列二:新增面板,介绍了如何在 devtools-frontend 新增一个面板,接下来就需要考虑 element 面板的具体实现。Element 面板主要有下面 3 个功能:

  • 渲染元素树,编辑元素属性;
  • 渲染样式表,编辑样式;
  • 页面元素高亮,检查页面元素;
panel-1.png

渲染元素树、样式表,需要获取页面的信息;设置属性、样式、高亮,需要页面有相应的更新。所以,为了实现这些功能,需要先建立 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-protocol.png

在 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 的通信机制如下:

devtools-frontend-communication.png

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 的通信模式如下图所示:

react-devtools-1.png

devtools 和 page 都是通过 wall 和 bridge 发送和监听消息,通信模式大体一致。可以先看 page 部分,page 各部分的关系和方法如下图所示:

react-devtools-2.png
  • 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

0 条评论

来做第一个留言的人吧!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK