快应用 IDE 定制 Devtools 元素面板系列四:UI 方案与高亮功能
上一篇快应用 IDE 定制 Devtools 元素面板系列三:通信方案,分享了如何建立 devtools 和 page 之间的通信。这篇文章,我们继续解析 Elements 面板的具体实现。
一、面板的 UI 方案
实现自定义 elements 面板的 UI,有以下两种方案:
方案一
加载 iframe,自行实现面板 UI。
- 优点:可以完全自主地实现面板 UI,自由选择 vue、react 等技术栈,方便地选用组件库搭建自定义面板,灵活性高,容易维护。
- 缺点:elements 面板其实有很多细节的需求。比如,样式侧边栏,需要可以编辑样式(点击样式名/样式值需要从 div 切换为 input,enter 或 blur 时,完成编辑);颜色样式,需要展示颜色选择器;样式输入有自动补全机制;样式表有多个样式规则,样式规则又有多个样式属性,添加一个样式属性时,如果有重复的属性,需要更新它们的生效状态。从这个角度来看,需要完全实现一套 elements 的 ui,开发成本比较高。
方案二
参考 devtools 的 elements 面板,使用 TreeOutline, TreeElement, StylesSidebar,StylePropertiesSection,StylePropertyTreeElements 等 UI 类实现自定义面板。
- 优点:devtools 的 elements 面板,其实已经实现了我们所需的全部功能,只是需要将其中一部分代码替换为自定义的。
- 缺点:需要学习 elements 的代码,而这部分代码内容非常多,不同 UI 类之间的关系比较复杂,需要较高的学习成本。
选择方案
考虑到方案一的开发成本太高,我们最终选择方案二(其实两个方案各有千秋,自行选中即可)。
elements 面板的 UI 结构及其数据管理如下图所示:
UI 部分,都是用 UI 类组合而成的;UI 类主要用于操作 Dom、监听 Dom 事件等。比如 elements 面板由 ElementsTreeOutline 和 ElementsSidebarPane 组成,而 ElementsTreeOutline 又由 ElementsTreeElement 组成。
ElementsTreeElement 是树结构,主要用于创建和操作节点元素,通过 expand() 可以展开下一级的 element。每个 element 对应一个 DomNode,DomNode 是节点模型,在新建 element 时作为参数传入,允许 element 访问或设置节点的数据。同时,DomNode 再通过 model、target 和 agent 与后端进行通信。
UI 层和通信层的交互如下:
- UI 层监听 dom 原生事件(比如 click 等),通过 agent 发送 commands 给后端,获取数据。
- 通信层通过 model 监听后端 events,发送自定义的事件,UI 层监听自定义事件并进行更新。
自定义方案:
- UI 层:调用通信的代码需要替换为自定义的,修改其他不适合的地方。
- 通信层:需要替换为自定义的,可以参考 elements 面板的代码结构,也可以自行定义。只要和 page 建立起 web socket 连接,进行通信即可。
elements 面板的具体实现细节需要看一遍它的业务代码,内容太多,这里就不一一解析了。
二、页面高亮功能
在定制 Devtools 元素面板系列上一篇文章中,我们提到预览页面的高亮功能:
- 点击 elements 面板的元素节点,预览页面对应的元素显示高亮效果;
- 点击 devtools 的检查按键,切换到 inspect 状态。鼠标在预览页面移动时,对应的元素显示高亮效果,elements 面板选中对应的元素节点;
react-devtools 的 Highlight 模块实现了高亮功能,可以参考其方案进行开发。
1. 高亮页面元素
- devtools(frontend):监听 hover 事件,发送消息给 page。
class Store extends EventEmitter {
setHover(id: ElementID, isHovered: boolean, isBottomTag: boolean) {
if (isHovered) {
...
// hover 状态,高亮对应节点
this.highlight(id);
} else if (this.hovered === id) {
// 非 hover 状态,取消高亮
this.hideHighlight();
...
}
}
highlight(id: string): void {
...
this._bridge.send('highlight', id);
}
hideHighlight() {
...
this._bridge.send('hideHighlight');
...
}
}
- page(backend): 处理 devtools 的消息,在页面对应的元素上覆盖高亮元素。
// react-devtools/frontend/Highlighter/setup.js
'use strict';
var Highlighter = require('./Highlighter');
import type Agent from '../../agent/Agent';
module.exports = function setup(agent: Agent) {
// 新建 Highlighter
var hl = new Highlighter(window, node => {
agent.selectFromDOMNode(node);
});
// 监听 devtools 的消息,调用 Highlighter 方法
agent.on('highlight', data => hl.highlight(data.node, data.name));
agent.on('hideHighlight', () => hl.hideHighlight());
...
};
// react-devtools/frontend/Highlighter/Highlighter.js
class Highlighter {
// 高亮:新建 Overlay,展示高亮状态
highlight(node: DOMNode, name?: string) {
this.removeMultiOverlay();
if (node.nodeType !== Node.COMMENT_NODE) {
if (!this._overlay) {
this._overlay = new Overlay(this._win);
}
this._overlay.inspect(node, name);
}
}
// 取消高亮:删除 Overlay
hideHighlight() {
this._inspecting = false;
this.removeOverlay();
this.removeMultiOverlay();
}
}
// react-devtools/frontend/Highlighter/Overlay.js
class Overlay {
constructor(window: Object) {
var doc = window.document;
this.win = window;
// 1. 新建 dom
this.container = doc.createElement('div');
this.node = doc.createElement('div');
this.border = doc.createElement('div');
this.padding = doc.createElement('div');
this.content = doc.createElement('div');
// 2. 设置样式
// this.border, this.padding, this.content:对应节点的盒模型
this.border.style.borderColor = overlayStyles.border;
this.padding.style.borderColor = overlayStyles.padding;
this.content.style.backgroundColor = overlayStyles.background;
...
// this.node: 对应节点, 样式设置为固定定位
assign(this.node.style, {
borderColor: overlayStyles.margin,
pointerEvents: 'none',
position: 'fixed',
});
// this.tip: 节点的尺寸信息,样式设置为固定定位
this.tip = doc.createElement('div');
assign(this.tip.style, {
backgroundColor: '#333740',
borderRadius: '2px',
fontFamily: monospace.family,
fontWeight: 'bold',
padding: '3px 5px',
position: 'fixed',
fontSize: 0.5 + 'rem',
});
...
// 3. 添加到 docment.body
this.container.appendChild(this.node);
this.container.appendChild(this.tip);
this.node.appendChild(this.border);
this.border.appendChild(this.padding);
this.padding.appendChild(this.content);
doc.body.appendChild(this.container);
}
}
- 实现方案
参考 react-devtools 的实现方式,但 devtools 的处理需要和 elements 面板结合起来:监听 ElementsTreeOutline 和 ElementsTreeElement 的鼠标事件,调用通信方法。
2. 检查页面元素
2.1 devtools(frontend)
devtools:监听切换按键的点击事件,发送消息给 page。
如图所示,检查元素的切换按键不在面板上,而是在 devtools 的 Toolbar 上,是一个 ToolbarItem。为了增加这个按键,我们需要了解它的实现。
-
配置文件
elements 的检查元素按键,需要配置 UI 插件和对应的 action:
// module.json { "extensions": [ ... { "type": "action", // 配置 action "category": "Elements", "actionId": "elements.toggle-element-search", "toggleable": true, "className": "Elements.InspectElementModeController.ToggleSearchActionDelegate", "title": "Select an element in the page to inspect it", "iconClass": "largeicon-node-search", "bindings": [ // 配置快捷键 { "platform": "windows,linux", "shortcut": "Ctrl+Shift+C" }, { "platform": "mac", "shortcut": "Meta+Shift+C" } ] }, { "type": "@UI.ToolbarItem.Provider", // 配置 UI "actionId": "elements.toggle-element-search", "location": "main-toolbar-left", // 配置 toolbarItem 展示的位置,主工具栏的左侧 "order": 0 }, ], ... }
-
action 启动流程
devtools-frontend 在主入口注册 action 和 action 的快捷键。
// front_end/main/MainImpl.js export class MainImpl { async _createAppUI() { ... // 注册 action 和 action 的快捷键 self.UI.actionRegistry = new UI.ActionRegistry.ActionRegistry(); self.UI.shortcutRegistry = new UI.ShortcutRegistry.ShortcutRegistry(self.UI.actionRegistry); } } // front_end/ui/ActionRegistry.js export class ActionRegistry { constructor() { /** @type {!Map.<string, !Action>} */ this._actionsById = new Map(); this._registerActions(); } _registerActions() { // 1.查找 'action' 类型的插件,注册插件 self.runtime.extensions('action').forEach(registerExtension, this); /** * @param {!Root.Runtime.Extension} extension * @this {ActionRegistry} */ function registerExtension(extension) { const actionId = extension.descriptor()['actionId']; console.assert(actionId); console.assert(!this._actionsById.get(actionId)); // 2.新建 action const action = new Action(extension); if (!action.category() || action.title()) { this._actionsById.set(actionId, action); } else { console.error(`Category actions require a title for command menu: ${actionId}`); } if (!extension.canInstantiate()) { action.setEnabled(false); } } } }
-
toolbar 启动流程
devtools-frontend 通过主入口新建 toolbar 和 toolbarItem。toolbarItem 监听点击事件,执行 action.execute(),action 再调用 delegate.handleAction 进行处理。
export class MainImpl { async _createAppUI() { ... // 1. 新建 inspectorView self.UI.inspectorView = UI.InspectorView.InspectorView.instance(); ... } _showAppUI(appProvider) { ... // 2. inspectorView 新建 toolbars self.UI.inspectorView.createToolbars(); ... } } // front_end/ui/InspectorView.js export class InspectorView extends VBox { createToolbars() { // 1. 加载左边 toolbar 的 item this._tabbedPane.leftToolbar().appendItemsAtLocation('main-toolbar-left'); this._tabbedPane.rightToolbar().appendItemsAtLocation('main-toolbar-right'); } } // front_end/ui/Toolbar.js export class Toolbar { // 1. 加载对应位置的 toolbarItem async appendItemsAtLocation(location) { const extensions = self.runtime.extensions(Provider); // 根据 location 过滤插件 const filtered = extensions.filter(e => e.descriptor()['location'] === location); const items = await Promise.all(filtered.map(extension => { ... // 调用新建方法 return Toolbar.createActionButtonForId(descriptor['actionId'], descriptor['showLabel']); ... })); items.filter(item => item).forEach(item => this.appendToolbarItem(item)); } // 2.根据插件 id 新建 actionButton static createActionButtonForId(actionId, options = TOOLBAR_BUTTON_DEFAULT_OPTIONS) { const action = self.UI.actionRegistry.action(actionId); return Toolbar.createActionButton(/** @type {!Action} */ (action), options); } // 3.新建 actionButton static createActionButton(action, options = TOOLBAR_BUTTON_DEFAULT_OPTIONS) { const button = action.toggleable() ? makeToggle() : makeButton(); ... let handler = event => { action.execute(); }; ... // 4. 按键监听点击事件,执行 action.execute() button.addEventListener(ToolbarButton.Events.Click, handler, action); ... } } // front_end/ui/Action.js export class Action extends Common.ObjectWrapper.ObjectWrapper { // execute:调用 extension 的 handleAction 方法 async execute() { if (!this._extension.canInstantiate()) { return false; } // 获取 action 代理,调用 handleAction const delegate = /** @type {!ActionDelegate} */ (await this._extension.instance()); const actionId = this.id(); return delegate.handleAction(self.UI.context, actionId); } // 设置切换 setToggled(toggled) { console.assert(this.toggleable(), 'Shouldn\'t be toggling an untoggleable action', this.id()); if (this._toggled === toggled) { return; } this._toggled = toggled; // 发送自定义的消息,其他模块监听到消息可进行更新 this.dispatchEventToListeners(Events.Toggled, toggled); } ... }
-
action 的执行流程
delegate 是 action 的代理,controller 是 action 的控制器,按 action -> delegate -> controller 的流程,将点击事件交给 controller 处理。controller 做两个操作,一是调用 model.agent 发送消息给后端,二是设置 action 的状态。// front_end/elements/InspectElementModeController.js // delegate 是 action 的代理,类名在配置文件中设置 export class ToggleSearchActionDelegate { // 设置 action 的 handleAction handleAction(context, actionId) { if (!inspectElementModeController) { return false; } if (actionId === 'elements.toggle-element-search') { // 1.调用 controller 的方法,切换检查模式 inspectElementModeController._toggleInspectMode(); } else if (actionId === 'elements.capture-area-screenshot') { inspectElementModeController._captureScreenshotMode(); } return true; } } export class InspectElementModeController { constructor() { this._toggleSearchAction = self.UI.actionRegistry.action('elements.toggle-element-search'); ... } _toggleInspectMode() { ... // 设置检查模式 this._setMode(mode); } _setMode(mode) { if (SDK.SDKModel.TargetManager.instance().allTargetsSuspended()) { return; } this._mode = mode; for (const overlayModel of SDK.SDKModel.TargetManager.instance().models(SDK.OverlayModel.OverlayModel)) { // 1.调用 overlayModel 的方法 overlayModel.setInspectMode(mode, this._showDetailedInspectTooltipSetting.get()); } // 2.调用 action 的设置切换 this._toggleSearchAction.setToggled(this._isInInspectElementMode()); } } // front_end/sdk/OverlayModel.js export class OverlayModel extends SDKModel { async setInspectMode(mode, showStyles = true) { await this._domModel.requestDocument(); this._inspectModeEnabled = mode !== Protocol.Overlay.InspectMode.None; this.dispatchEventToListeners(Events.InspectModeWillBeToggled, this); this._highlighter.setInspectMode(mode, this._buildHighlightConfig('all', showStyles)); } setInspectMode(mode, config) { // 1.调用 agent 发送消息给后端,设置检查模式 return this._model._overlayAgent.setInspectMode(mode, config); } }
通过上面的分析,新增一个 inspecting 按键,步骤如下:
- 更新 module.json,增加 toolbarItem 和 action 的配置
- 增加 delegate 和 controller,处理按键的点击切换事件,发送消息给后端
2.2 page(backend)
处理 devtools 的消息,启动/取消 inspecting 状态。
启动 inspecting:鼠标移动,元素高亮;点击,取消元素高亮,发送消息给 devtools 选中对应节点。
取消 inspecting:取消元素高亮,删除事件监听器。
// react-devtools/frontend/Highlighter/setup.js
module.exports = function setup(agent: Agent) {
var hl = new Highlighter(window, node => {
// 设置 onSelect 方法
agent.selectFromDOMNode(node);
});
// 监听 devtools 的消息,调用 Highlighter 方法
agent.on('startInspecting', () => hl.startInspecting());
agent.on('stopInspecting', () => hl.stopInspecting());
...
};
// react-devtools/frontend/Highlighter/Highlighter.js
class Highlighter {
// 启动 inspecting
startInspecting() {
this._inspecting = true;
// window 添加事件监听器
this._subs = [
captureSubscription(this._win, 'mouseover', this.onHover.bind(this)),
captureSubscription(this._win, 'mousedown', this.onMouseDown.bind(this)),
captureSubscription(this._win, 'click', this.onClick.bind(this)),
];
}
// 取消 inspecting
stopInspecting() {
this._inspecting = false;
// 删除事件监听器
this._subs.forEach(unsub => unsub());
// 取消高亮
this.hideHighlight();
}
// hover:高亮事件目标对应的元素
onHover(evt: DOMEvent) {
if (!this._inspecting) {
return;
}
evt.preventDefault();
evt.stopPropagation();
evt.cancelBubble = true;
this.highlight(evt.target);
}
// mousedown: 调用 agent 发送消息, _onSelect 在 新建 Highlighter 时传入
onMouseDown(evt: DOMEvent) {
if (!this._inspecting) {
return;
}
evt.preventDefault();
evt.stopPropagation();
evt.cancelBubble = true;
this._onSelect(evt.target);
}
// click:取消元素高亮
onClick(evt: DOMEvent) {
if (!this._inspecting) {
return;
}
this._subs.forEach(unsub => unsub());
evt.preventDefault();
evt.stopPropagation();
evt.cancelBubble = true;
this.hideHighlight();
}
}
2.3 实现方案
我们的需求和 react-devtool 的基本一致,仅有一点不同:inspecting 状态,鼠标移动,页面元素高亮,devtools 对应的节点也需要选中。所以在 mouseover 时,也需要发送消息给 devtools。