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

快应用开发工具 Jul 6, 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。

Tags

vivo developer

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