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

快应用开发工具 Jun 28, 2020

在上一篇文章──《快应用 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 是否展示即可。

vivo developer

快应用引擎、工具开发者、快应用生态拓展达人(vivo)。

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.