快应用 IDE 定制 Devtools 元素面板系列四:UI 方案与高亮功能

快应用开发工具 Jul 06, 2020

上一篇快应用 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 结构及其数据管理如下图所示:

elements-panel

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。

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.