快应用 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 是否展示即可。