快应用 IDE 定制 Devtools 元素面板系列二:新增面板

在上一篇文章──《快应用 IDE 定制 Devtools 元素面板系列一:背景需求及方案分析》,介绍了定制 Devtools Elements 面板的需求,预研了插件集成、修改 devtools-frontend 源码两种技术方案;最终选择修改源码来实现。从这篇文章开始,我们进入实现部分,首先是在 Devtools 中新增一个面板。

下面从 devtools-frontend UI 启动流程新增面板缓存面板三个主题进行介绍。

一、devtools-frontend UI 启动流程

1. 目录结构

devtools-frontend 包含 inspector、devtools_app、node_app 等多种应用,浏览器右键检查对应的是 inspector 应用。frontend 的目录结构如下:

front_end
  ├── ...
  ├── inspector.html    // 应用入口文件,加载 inspector.js
  ├── inspector.js      // 应用启动文件
  ├── inspector.json    // 应用配置文件
  ├── devtools_app.html // 应用入口文件,加载 devtools_app.js
  ├── devtools_app.js   // 应用启动文件
  ├── devtools_app.json // 应用配置文件
  ├── ...
  ├── elements
  │    ├── module.json        // elements 模块配置文件
  │    ├── elements-legacy.js // 定义全局变量 Elements 及其模块
  │    ├── elements.js        // 加载 elements 模块中的 js 文件
  │    ├── ElementsPanel.js   // UI 类
  │    ├── ...
  ├── ...

2. 应用启动流程

inspector 应用的入口文件是 inspector.html,inspector 的启动流程如下:

  • inspector.html

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta
          http-equiv="Content-Security-Policy"
          content="object-src 'none'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://chrome-devtools-frontend.appspot.com"
        />
        <meta name="referrer" content="no-referrer" />
        <script type="module" src="root.js"></script>
        <!-- 加载 inspector.js -->
        <script type="module" src="inspector.js"></script>
      </head>
      <body class="undocked" id="-blink-dev-tools"></body>
    </html>
    
  • inspector.js

    import "./devtools_app.js";
    import { startApplication } from "./RuntimeInstantiator.js";
    
    // 启动应用,加载 inspector.json 及其扩展中的模块
    startApplication("inspector");
    
  • inspector.json

    包含 screencast 模块,扩展自 devtools_app 应用

    {
      "modules": [{ "name": "screencast", "type": "autostart" }],
      "extends": "devtools_app",
      "has_html": true
    }
    
  • devtools_app.json

    包含 elements 等模块,扩展自 shell。

    {
      "modules" : [
        ...
        { "name": "elements" },
        ...
      ],
      "extends": "shell",
      "has_html": true
    }
    
  • shell.json

    main 是自启动模块

    {
      "modules" : [
        ...
        { "name": "main", "type": "autostart" },
        ...
      ]
    }
    
  • startApplication

    startApplication 收集了应用需要的模块,实例化 runtime,加载核心模块。

    // front_end/RuntimeInstantiator.js
    
    let appStartedPromiseCallback;
    Runtime.appStarted = new Promise(
      fulfil => (appStartedPromiseCallback = fulfil)
    );
    
    export async function startApplication(appName) {
      console.timeStamp("Root.Runtime.startApplication");
    
      /** @type {!Object<string, RootModule.Runtime.ModuleDescriptor>} */
      // 1、从 Root.allDescriptors 缓存数据中获取模块
      const allDescriptorsByName = {};
      for (let i = 0; i < Root.allDescriptors.length; ++i) {
        const d = Root.allDescriptors[i];
        allDescriptorsByName[d["name"]] = d;
      }
    
      // 2、如果没有缓存数据,按以下方法加载模块
      if (!Root.applicationDescriptor) {
        // 3、加载 inspector.json
        let data = await RootModule.Runtime.loadResourcePromise(
          appName + ".json"
        );
        Root.applicationDescriptor = JSON.parse(data);
        let descriptor = Root.applicationDescriptor;
        // 4、循环 json 文件中的 extends 字段,取出下一个 json(例如 inspector.json -> devtools_app.json)
        while (descriptor.extends) {
          data = await RootModule.Runtime.loadResourcePromise(
            descriptor.extends + ".json"
          );
          descriptor = JSON.parse(data);
          // 5、将 json 中的 modules 字段(数组)添加到 Root.applicationDescriptor.modules
          Root.applicationDescriptor.modules = descriptor.modules.concat(
            Root.applicationDescriptor.modules
          );
        }
      }
    
      // 6、configuration 放置 modules,比如 elements
      const configuration = Root.applicationDescriptor.modules;
      const moduleJSONPromises = [];
      const coreModuleNames = [];
      // 7、循环加载所有模块包,name/module.json。
      for (let i = 0; i < configuration.length; ++i) {
        const descriptor = configuration[i];
        // 取模块名,比如 elements,{ "name": "elements" }
        const name = descriptor["name"];
        // 根据模块名,取模块(allDescriptorsByName应该是一个缓存机制,第一次取到module.json后放到里面)
        const moduleJSON = allDescriptorsByName[name];
        if (moduleJSON) {
          moduleJSONPromises.push(Promise.resolve(moduleJSON));
        } else {
          // 取name/module.json,如 elements/module.json
          moduleJSONPromises.push(
            RootModule.Runtime.loadResourcePromise(name + "/module.json").then(
              JSON.parse.bind(JSON)
            )
          );
        }
        // 如果 type 为 autostart,加入核心模块数组
        if (descriptor["type"] === "autostart") {
          coreModuleNames.push(name);
        }
      }
    
      // 8、moduleDescriptors (解析的module.json数组)
      const moduleDescriptors = await Promise.all(moduleJSONPromises);
    
      // 9、将 configuration 中的 name,condition,type 赋值给 moduleDescriptors[i]
      for (let i = 0; i < moduleDescriptors.length; ++i) {
        moduleDescriptors[i].name = configuration[i]["name"];
        moduleDescriptors[i].condition = configuration[i]["condition"];
        moduleDescriptors[i].remote = configuration[i]["type"] === "remote";
      }
      // 10、实例化 runtime,传入 moudule.json 数组(注册模块,新建模块 extension)
      self.runtime = RootModule.Runtime.Runtime.instance({
        forceNew: true,
        moduleDescriptors
      });
      // 11、加载核心模块
      if (coreModuleNames) {
        await self.runtime.loadAutoStartModules(coreModuleNames);
      }
      // 12、调用启动后的回调函数,确认启动成功
      appStartedPromiseCallback();
    }
    

    runtime 实例化时,注册了收集的模块。

    // front_end/root/Runtime.js
    export class Runtime {
      constructor(descriptors) {
        this._modules = [];
        this._modulesMap = {};
        this._extensions = [];
        this._cachedTypeClasses = {};
        this._descriptorsMap = {};
    
        for (let i = 0; i < descriptors.length; ++i) {
          // 注册模块,descriptors[i] 对应解析的 module.json
          this._registerModule(descriptors[i]);
        }
      }
    
      // 1、实例化 runtime
      static instance(opts = { forceNew: null, moduleDescriptors: null }) {
        const { forceNew, moduleDescriptors } = opts;
        if (!moduleDescriptors || forceNew) {
          if (!moduleDescriptors) {
            throw new Error(
              `Unable to create settings: targetManager and workspace must be provided: ${
                new Error().stack
              }`
            );
          }
    
          runtimeInstance = new Runtime(moduleDescriptors);
        }
    
        return runtimeInstance;
      }
    
      // 2、新建模块,并保存到this._modules,this._modulesMap[descriptor['name']]
      _registerModule(descriptor) {
        const module = new Module(this, descriptor);
        this._modules.push(module);
        this._modulesMap[descriptor["name"]] = module;
      }
    }
    
    export class Module {
      constructor(manager, descriptor) {
        this._manager = manager; // 1、runtime 传过来的 this
        this._descriptor = descriptor; // 2、descriptor -> module.json
        this._name = descriptor.name; // 3、devtools_app.json 中 modules[i].name
        this._extensions = [];
    
        this._extensionsByClassName = new Map();
        const extensions = descriptor.extensions;
        // 4、遍历并新建模块中的 extension,保存到 runtime._extensions
        for (let i = 0; extensions && i < extensions.length; ++i) {
          const extension = new Extension(this, extensions[i]);
          this._manager._extensions.push(extension);
          this._extensions.push(extension);
        }
        this._loadedForTest = false;
      }
    }
    

    加载核心模块,这里以 shell.json 的 main 模块为例:

    export class Runtime {
      loadAutoStartModules(moduleNames) {
        const promises = [];
        for (let i = 0; i < moduleNames.length; ++i) {
          promises.push(this.loadModulePromise(moduleNames[i]));
        }
        return Promise.all(promises);
      }
    
      // 加载模块
      loadModulePromise(moduleName) {
        return this._modulesMap[moduleName]._loadPromise();
      }
    }
    
    export class Module {
      // 加载依赖,css,入口js,scripts
      _loadPromise() {
        if (!this.enabled()) {
          return Promise.reject(
            new Error("Module " + this._name + " is not enabled")
          );
        }
    
        if (this._pendingLoadPromise) {
          return this._pendingLoadPromise;
        }
    
        // 1、循环加载模块中的依赖
        const dependencies = this._descriptor.dependencies;
        const dependencyPromises = [];
        for (let i = 0; dependencies && i < dependencies.length; ++i) {
          dependencyPromises.push(
            this._manager._modulesMap[dependencies[i]]._loadPromise()
          );
        }
    
        this._pendingLoadPromise = Promise.all(dependencyPromises)
          // 2、加载css
          .then(this._loadResources.bind(this))
          // 3、import main-legacy.js
          .then(this._loadModules.bind(this))
          // 4、加载scripts
          .then(this._loadScripts.bind(this))
          .then(() => (this._loadedForTest = true));
    
        return this._pendingLoadPromise;
      }
    
      // 加载入口 js, main-legacy.js
      _loadModules() {
        if (!this._descriptor.modules || !this._descriptor.modules.length) {
          return Promise.resolve();
        }
    
        const namespace = this._computeNamespace();
        self[namespace] = self[namespace] || {};
    
        const legacyFileName = `${this._name}-legacy.js`;
        const fileName = this._descriptor.modules.includes(legacyFileName)
          ? legacyFileName
          : `${this._name}.js`;
    
        return eval(`import('../${this._name}/${fileName}')`);
      }
    }
    

3. UI 启动流程

从上面的应用启动流程,我们知道 startApplication 会加载核心模块。inspector 有一个自启动模块 main(inspector -> devtools_app -> shell),main 就是 UI 的启动入口。

main 模块的入口文件是 main-legacy.js(前面已经提过,加载 main 模块时 import main-legacy.js)。main-legacy.js 加载了 main.js,并定义 main 模块相关的全局变量。

// front_end/main/main-legacy.js

// 加载 main.js
import * as MainModule from './main.js';

// 定义全局变量 Main
self.Main = self.Main || {};
Main = Main || {};

// 引入的 mainModule 的类定义为全局变量 Main.xxx
Main.Main = MainModule.MainImpl.MainImpl;
...

main.js 加载了 ExecutionContextSelector.js,MainImpl.js 和 SimpleApp.js。

// front_end/main/main.js
import * as ExecutionContextSelector from "./ExecutionContextSelector.js";
import * as MainImpl from "./MainImpl.js";
import * as SimpleApp from "./SimpleApp.js";

export { ExecutionContextSelector, MainImpl, SimpleApp };

MainImpl.js 新建了 MainImpl,并在 window 加载后调用 this._loaded 新建 UI。

// front_end/main/MainImpl.js
new MainImpl();

export class MainImpl {
  /**
   * @suppressGlobalPropertiesCheck
   */
  constructor() {
    MainImpl._instanceForTest = this;
    // window 加载后,调用 this._loaded
    runOnWindowLoad(this._loaded.bind(this));
  }
}

runOnWindowLoad 在另一个自启动模块 platform 中定义,在页面加载完成后,执行传入的 callback。

front_end / platform / utilities.js;
self.runOnWindowLoad = function(callback) {
  /**
   * @suppressGlobalPropertiesCheck
   */
  function windowLoaded() {
    self.removeEventListener("DOMContentLoaded", windowLoaded, false);
    callback();
  }

  if (
    document.readyState === "complete" ||
    document.readyState === "interactive"
  ) {
    callback();
  } else {
    self.addEventListener("DOMContentLoaded", windowLoaded, false);
  }
};

我们继续看 MainImpl, _loaded 先等 app 启动完成,再新建 AppUI。

// front_end/main/MainImpl.js

export class MainImpl {
  async _loaded() {
    console.timeStamp("Main._loaded");
    // 1、等待 app 启动完成,即所有模块注册完成,自启动模块启动完成。(这里对应前面 startApplication 最后的回调函数 appStartedPromiseCallback)
    await Runtime.appStarted;
    // 设置 platform
    Root.Runtime.setPlatform(Host.Platform.platform());
    Root.Runtime.setL10nCallback(ls);
    // 获取首选项
    Host.InspectorFrontendHost.InspectorFrontendHostInstance.getPreferences(
      this._gotPreferences.bind(this)
    );
  }

  _gotPreferences(prefs) {
    console.timeStamp("Main._gotPreferences");
    if (Host.InspectorFrontendHost.isUnderTest(prefs)) {
      self.runtime.useTestBase();
    }
    // 新建配置项
    this._createSettings(prefs);
    // 2、新建 AppUI
    this._createAppUI();
  }
}

4. elements 面板启动流程

elements 等 UI 模块,不同于 main 等自启动模块,在应用启动(startApplication)时不会加载,只是注册。到了 main 模块 _createAppUI 时,才会加载。

// front_end/main/MainImpl.js

export class MainImpl {
  async _createAppUI() {
    // 新建视图管理器
    self.UI.viewManager = UI.ViewManager.ViewManager.instance();
    ...
    // 新建调试器视图
    self.UI.inspectorView = UI.InspectorView.InspectorView.instance();
  }
}

inspectorView 的 UI 结构如下图所示。它是一个 splitWidget,分为左右两边,左边是模拟器,右边是 tab 标签页,非手机模式模拟器不展示,这里直接忽略。其中,TabbedPane 从 dom 结构来说,也可以分为 tabbed-pane-header 和 tabbed-pane-content。

继续查看 inspectorView 添加 tab 的过程。

// front_end/ui/InspectorView.js
export class InspectorView extends VBox {
  constructor() {
    ...
    // Create main area tabbed pane.
    // 新建 tabbedLocation, location 为 'panel'(只取 location 为 'panel' 的插件)。
    this._tabbedLocation = ViewManager.instance().createTabbedLocation(
        Host.InspectorFrontendHost.InspectorFrontendHostInstance.bringToFront.bind(
            Host.InspectorFrontendHost.InspectorFrontendHostInstance),
        'panel', true, true, Root.Runtime.queryParam('panel'));

    this._tabbedPane = this._tabbedLocation.tabbedPane();
    ...
  }
}
// front_end/ui/ViewManager.js
export class ViewManager {
  constructor() {
    /** @type {!Map<string, !View>} */
    this._views = new Map();
    /** @type {!Map<string, string>} */
    this._locationNameByViewId = new Map();

    // 遍历类型为 'view' 的插件,新建 ProvidedView。elements 模块中的 ElementsPanel 就是 'view' 类型的。
    for (const extension of self.runtime.extensions('view')) {
      const descriptor = extension.descriptor();
      this._views.set(descriptor['id'], new ProvidedView(extension));
      this._locationNameByViewId.set(descriptor['id'], descriptor['location']);
    }
  }

  // 新建 tabbedLocation
  createTabbedLocation(revealCallback, location, restoreSelection, allowReorder, defaultTab) {
    return new _TabbedLocation(this, revealCallback, location, restoreSelection, allowReorder, defaultTab);
  }
}

export class _TabbedLocation extends _Location {
  constructor(manager, revealCallback, location, restoreSelection, allowReorder, defaultTab) {
    // 新建 tabbedPane
    const tabbedPane = new TabbedPane();
    ...
    if (location) {
      // 添加 tab
      this.appendApplicableItems(location);
    }
  }

  appendApplicableItems(locationName) {
    // 根据 location 获取 views,即取 location 为 'panel' 的视图
    const views = this._manager._viewsForLocation(locationName);
    ...

    for (const view of views) {
      const id = view.viewId();
      this._views.set(id, view);
      view[_Location.symbol] = this;
      if (view.isTransient()) {
        continue;
      }
      // 添加 tab
      if (!view.isCloseable()) {
        this._appendTab(view);
      } else if (this._closeableTabSetting.get()[id]) {
        this._appendTab(view);
      }
    }
    // 选中默认 tab
    if (this._defaultTab && this._tabbedPane.hasTab(this._defaultTab)) {
      this._tabbedPane.selectTab(this._defaultTab);
    } else if (this._lastSelectedTabSetting && this._tabbedPane.hasTab(this._lastSelectedTabSetting.get())) {
      this._tabbedPane.selectTab(this._lastSelectedTabSetting.get());
    }
  }

  _appendTab(view, index) {
    // 调用 _tabbedPane.appendTab 添加 tab
    this._tabbedPane.appendTab(
        // 新建 view 的 widget
        view.viewId(), view.title(), new ContainerWidget(view), undefined, false,
        view.isCloseable() || view.isTransient(), index);
  }

}

// front_end/ui/TabbedPane.js
export class TabbedPane extends VBox {
  constructor() {
    ...
    this._tabsElement = this._headerContentsElement.createChild('div', 'tabbed-pane-header-tabs');
    this._tabsElement.setAttribute('role', 'tablist');
    this._tabsElement.addEventListener('keydown', this._keyDown.bind(this), false);
    ...
  }

  appendTab(id, tabTitle, view, tabTooltip, userGesture, isCloseable, index) {
    isCloseable = typeof isCloseable === 'boolean' ? isCloseable : this._closeableTabs;
    // 1.新建 tabbedPaneTab
    const tab = new TabbedPaneTab(this, id, tabTitle, isCloseable, view, tabTooltip);
    ...
    // 2.更新 tab 元素
    this._updateTabElements();
  }

  // 更新 tab 元素
  _updateTabElements() {
    invokeOnceAfterBatchUpdate(this, this._innerUpdateTabElements);
  }

  // 内部更新 tab 元素
  _innerUpdateTabElements() {
    ...
    this._updateWidths();
    ...
  }

  _updateWidths() {
    const measuredWidths = this._measureWidths();
    const maxWidth =
        this._shrinkableTabs ? this._calculateMaxWidth(measuredWidths.slice(), this._totalWidth()) : Number.MAX_VALUE;

    let i = 0;
    for (const tab of this._tabs) {
      tab.setWidth(this._verticalTabLayout ? -1 : Math.min(maxWidth, measuredWidths[i++]));
    }
  }

  _measureWidths() {
    ...
    const measuringTabElements = [];
    for (const tab of this._tabs) {
      if (typeof tab._measuredWidth === 'number') {
        continue;
      }
      // 3.调用 tabbedPaneTab 的 _createTabElement
      const measuringTabElement = tab._createTabElement(true);
      measuringTabElement.__tab = tab;
      measuringTabElements.push(measuringTabElement);
      this._tabsElement.appendChild(measuringTabElement);
    }

    ...
  }
}

export class TabbedPaneTab {
  _createTabElement(measuring) {
    const tabElement = createElementWithClass('div', 'tabbed-pane-header-tab');
    tabElement.id = 'tab-' + this._id;
    ...
    // 1.监听 mousedown 事件
    tabElement.addEventListener('mousedown', this._tabMouseDown.bind(this), false);
  }

  _tabMouseDown(event) {
    if (event.target.classList.contains('tabbed-pane-close-button') || event.button === 1) {
      return;
    }
    // 2.选择对应 tab
    this._tabbedPane.selectTab(this.id, true);
  }
}

export class TabbedPane extends VBox {
  selectTab(id, userGesture, forceFocus) {
    ...
    // 1.隐藏当前 tab
    this._hideCurrentTab();
    // 2.展示 tab
    this._showTab(tab);
    ...
  }

  _hideCurrentTab() {
    if (!this._currentTab) {
      return;
    }

    this._hideTab(this._currentTab);
    delete this._currentTab;
  }

  _hideTab(tab) {
    tab.tabElement.removeAttribute('tabIndex');
    tab.tabElement.classList.remove('selected');
    tab.tabElement.setAttribute('aria-selected', 'false');
    // 3.调用 view.detach
    tab.view.detach();
  }

  _showTab(tab) {
    tab.tabElement.tabIndex = 0;
    tab.tabElement.classList.add('selected');
    ARIAUtils.setSelected(tab.tabElement, true);
    // 4. 调用 view.show
    tab.view.show(this.element);
    this._updateTabSlider();
  }
}

从上面流程中可以看出,点击 tab 时,隐藏之前 tab.view,显示现在的 tab.view。tab.view 是一个 Widget 实例,detach 方法调用时,将 dom 元素隐藏或删除;show 方法调用时,才将 dom 元素加到 document 中。
也就是说,除了默认的 panel (在 appendApplicableItems 方法中选中),其他 panel 一开始是不渲染的。只有选中 tab 之后将原来的 panel 删除,重新渲染新的 panel。

// front_end/ui/Widget.js
export class Widget extends Common.ObjectWrapper.ObjectWrapper {
  detach(overrideHideOnDetach) {
    if (!this._parentWidget && !this._isRoot) {
      return;
    }

    // removeFromDOM:是否从 dom 元素中删除。即确认 detach 是隐藏还是删除。panel 的 detach 取决于 this.shouldHideOnDetach
    const removeFromDOM = overrideHideOnDetach || !this.shouldHideOnDetach();
    ...
    this._hideWidget(removeFromDOM);
    ...
  }

  // 根据 widget 或其子 widget 的 _hideOnDetach判断是否隐藏,默认 false 即不隐藏直接删除。
  shouldHideOnDetach() {
    if (!this.element.parentElement) {
      return false;
    }
    if (this._hideOnDetach) {
      return true;
    }
    for (const child of this._children) {
      if (child.shouldHideOnDetach()) {
        return true;
      }
    }
    return false;
  }

  _hideWidget(removeFromDOM) {
    ...

    if (removeFromDOM) {
      // Force legal removal
      Widget._decrementWidgetCounter(parentElement, this.element);
      // 调用原生方法 removeChild 删除 dom 元素
      Widget._originalRemoveChild.call(parentElement, this.element);
    } else {
      // 添加 hidden 类,不删除 dom 元素
      this.element.classList.add('hidden');
    }

    ...
  }

  show(parentElement, insertBefore) {
    ...
    if (!this._isRoot) {
      ...
      // 添加 widget
      this._attach(currentParent.__widget);
    }

    // 显示 widget
    this._showWidget(parentElement, insertBefore);
  }

  _attach(parentWidget) {
    ...
    if (this._parentWidget) {
      // 先 detach 删除 dom,后面 _showWidget 重新添加 dom
      this.detach();
    }
    ...
  }

  _showWidget(parentElement, insertBefore) {
    ...

    if (this.element.parentElement !== parentElement) {
      if (!this._externallyManaged) {
        Widget._incrementWidgetCounter(parentElement, this.element);
      }
      // 调用 insertBefore 或 appChild,添加 dom 元素
      if (insertBefore) {
        Widget._originalInsertBefore.call(parentElement, this.element, insertBefore);
      } else {
        Widget._originalAppendChild.call(parentElement, this.element);
      }
    }

    if (!wasVisible && this._parentIsShowing()) {
      // 处理 wasShown
      this._processWasShown();
    }
    ...
  }
}

// dom 方法赋值给常量
export const _originalAppendChild = Element.prototype.appendChild;
export const _originalInsertBefore = Element.prototype.insertBefore;
export const _originalRemoveChild = Element.prototype.removeChild;
export const _originalRemoveChildren = Element.prototype.removeChildren;

// 重写 dom 原生方法,添加判断
Element.prototype.appendChild = function(child) {
  Widget.__assert(!child.__widget || child.parentElement === this, 'Attempt to add widget via regular DOM operation.');
  return Widget._originalAppendChild.call(this, child);
};
...

// 定义 widget 的 dom 方法
Widget._originalAppendChild = _originalAppendChild;
Widget._originalInsertBefore = _originalInsertBefore;
Widget._originalRemoveChild = _originalRemoveChild;
Widget._originalRemoveChildren = _originalRemoveChildren;

上面介绍了 tab 的渲染机制,接下来还需要分析 elements 模块的加载和 ElementsPanel 的新建。tab.view 对应 ContainerWidget,而 ContainerWidget 的 view 对应 ProvidedView。tab.view.show() 会调用 _showWidget 的方法,我们继续查看它的处理。

export class Widget extends Common.ObjectWrapper.ObjectWrapper {
  _showWidget(parentElement, insertBefore) {
    ...

    if (!wasVisible && this._parentIsShowing()) {
      // 处理 wasShown
      this._processWasShown();
    }
    ...
  }

  _processWasShown() {
    if (this._inNotification()) {
      return;
    }
    this.restoreScrollPositions();
    // 调用 this._notify
    this._notify(this.wasShown);
    this._callOnVisibleChildren(this._processWasShown);
  }

  _notify(notification) {
    ++this._notificationDepth;
    try {
      // 调用 this.wasShown()
      notification.call(this);
    } finally {
      --this._notificationDepth;
    }
  }

  // 具体在子类中实现
  wasShown() {
  }
}

export class ContainerWidget extends VBox {
  wasShown() {
    // 调用 _materialize
    this._materialize().then(() => {
      this._view[widgetSymbol].show(this.element);
      this._wasShownForTest();
    });
  }

  _materialize() {
    ...
    // 执行 this._view.widget()
    promises.push(this._view.widget().then(widget => {
      ...
    }));
    ...
  }
}

export class ProvidedView {
  async widget() {
    this._widgetRequested = true;
    // 实例化 _extension
    const widget = await this._extension.instance();
    if (!(widget instanceof Widget)) {
      throw new Error('view className should point to a UI.Widget');
    }
    widget[_symbol] = this;
    return (
        /** @type {!Widget} */ (widget));
  }
}

export class Extension {
  instance() {
    // 加载对应模块 elements,再实例化对应插件 ElementsPanel
    return this._module._loadPromise().then(this._createInstance.bind(this));
  }

  _createInstance() {
    const className = this._className || this._factoryName;
    if (!className) {
      throw new Error('Could not instantiate extension with no class');
    }
    const constructorFunction = self.eval(/** @type {string} */ (className));
    if (!(constructorFunction instanceof Function)) {
      throw new Error('Could not instantiate: ' + className);
    }
    if (this._className) {
      return this._module._manager.sharedInstance(constructorFunction);
    }
    // new ElementsPanel(this)
    return new constructorFunction(this);
  }
}

5.小结

UI 和 elements 面板的启动流程,如下图所示:

二、新增面板

理解了 UI 和 elements 面板的启动流程,要新增定制面板,就比较简单了。

1.增加 custom 模块

  • 新建对应的目录
front_end
  ├── ...
  ├── custom
  │    ├── module.json        // custom 模块配置文件
  │    ├── custom-legacy.js   // 定义全局变量 Custom 及其模块
  │    ├── custom.js          // 加载 custom 模块中的 js 文件
  │    ├── CustomPanel.js     // UI 类
  │    ├── ...
  ├── ...
  • 修改 devtools_app.json,增加 custom 模块
  {
    "modules" : [
      ...
      { "name": "elements" },
      { "name": "custom" },
      ...
    ]
    ...
  }

2.编写 custom 模块

  • 配置 module.json

参考 elements 模块:

extensions 配置 panel,dependencies 可以先直接用 elements 的配置,modules 是 js 文件,resources 是需要引入的 css 文件。

{
  "extensions": [
    {
      "type": "view",
      "location": "panel",
      "id": "custom",
      "title": "Custom",
      "order": 10,
      "className": "Custom.CustomPanel"
    }
  ],
  "dependencies": [
    "components",
    "extensions",
    "inline_editor",
    "color_picker",
    "event_listeners"
  ],
  "scripts": [],
  "modules": [
    "custom.js",
    "custom-legacy.js",
    "CustomPanel.js",
    ...
  ],
  "resources": []
}
  • custom.js 引入 js 模块
import './CustomPanel.js';
...

import * as CustomPanel from './CustomPanel.js';
...

export {
  CustomPanel,
  ...
};
  • custom-legacy.js 定义全局变量
import * as CustomModule from "./custom.js";

// 定义全局变量 Custom
self.Custom = self.Custom || {};
Custom = Custom || {};

// 定义全局变量 Custom.CustomPanel,对应 module.json 中 panel 插件的 className。在切换到 custom 模块时,调用 extension.instance() 新建 CustomPanel。
Custom.CustomPanel = CustomModule.CustomPanel.CustomPanel;
  • CustomPanel
import * as UI from '../ui/ui.js';

export class CustomPanel extends UI.Panel.Panel {
  constructor() {
    super('custom');

    let _contentElement = createElement("div");
    // 编写面板的具体内容
    ...

    this.element.appendChild(_contentElement);

  }

  static instance() {
    return (self.runtime.sharedInstance(CustomPanel));
  }
}

三、缓存面板

通过 elements 面板启动流程,我们知道切换 tab 时,会将原来的 view(panel 容器)删除,并重新渲染一个 view。

但是我们可能有缓存面板的需求:即面板新建后就不再删除,切换 tabs 只隐藏面板。

CustomPanel 扩展自 UI.Panel.Panel,UI.Panel.Panel 扩展自 VBox, VBox 扩展自 Widget。 Widget 可以设置在 detach 时是否删除 dom 元素。

// front_end/ui/Widget.js
export class Widget extends Common.ObjectWrapper.ObjectWrapper {

  constructor(isWebComponent, delegatesFocus) {
    ...
    // 默认 detach 时不隐藏,直接删除
    this._hideOnDetach = false;
    ...
  }

  setHideOnDetach() {
    // 设置 _hideOnDetach 为 true, detach 时只隐藏不删除
    this._hideOnDetach = true;
  }
}

所以,我们直接在 CustomPanel 中设置其 detach 模式,即可实现面板缓存。

export class CustomPanel extends UI.Panel.Panel {
  constructor() {
    super('custom');

    ...
    // 设置 detach 模式
    this.setHideOnDetach()

  }
}

当然,还有一种特殊情况:所有面板都是定制的,不需要用 devtools-frontend 原有的面板。这种情况下,我们可以直接修改面板的初始化流程。在 createAppUI 时,直接新建自定义的 tab 和 tabPanel,绕开 devtools-frontend 原来复杂的流程。切换 tab 时,直接用 css 控制 panel 是否展示即可。