基于浏览器环境的css元数据解析方案研究

avatar
FE @字节跳动

序言

目前市面上有很多页面搭建方案,其中一种是基于运行时的lowcode/nocode搭建平台,主要是面向运营、产品及部分开发人员;另外一种则是基于DSL或代码,将可视化能力作为代码编写的辅助能力集成进现有项目中,主要是面向开发人员。AUX辅助工具(下文简称AUX)属于后者。

AUX是架构前端内部研发的一套可视化开发工具,旨在为前后端研发同学提供页面开发的可视化交互和代码生成能力。它侵入性弱,直接对代码进行修改,因此在兼顾易用性的同时产生的代码具有很强的可维护性和二次开发能力。同时,也由于完全基于代码本身,和其他低码平台不同,除了代码没有更加详细的元数据,所以AUX的很大一部分工作,在于努力实现基础的从运行时到编译时的反向处理能力,这样才能保证用户在浏览器中的各种操作能够被分解,并还原到真正的源代码中。

AUX工具提供了很多有趣的功能,其中一个是关于在浏览器中对css样式进行可视化编辑。可视化样式编辑的目的是让开发者能够在开发环境的浏览器端通过编辑器修改页面中的css样式,并实时更新页面中的样式渲染结果,最终在完成编辑后能够直接生成代码并写入用户的项目中。其中,怎么样在浏览器中获取到css代码的元数据,就是首先需要解决的问题,本文将主要围绕这个问题进行讨论。

在浏览器中向用户展现 css 数据

我们首先明确这个问题的产生原因。

在AUX中,css编辑能力的使用逻辑如下:

  1. 用户在前端页面中点击想要编辑的元素,点击目标元素后显示选中框;
  2. 右侧弹出 aux ****css 属性编辑面板,并展示当前元素的样式信息,在面板中可以修改 css 属性;
  3. 通过预览功能,页面中元素或组件实时应用样式修改并重新渲染;
  4. 提交,样式写入到源代码。

image.png

获取样式信息是这个功能的第一步。

如何实现页面中css样式信息的提取?为了达到这个目的,方案经历了几次变动,下面说明主要的几种。

使用浏览器在js环境中提供的现有api

首先作为前端的开发同学,大家都能很容易想到的最直接能获取css规则的方法,就是使用浏览器暴露的api:

  • 使用api window.getComputedStyle(element, [pseudoElt])获取元素的计算样式;
  • 使用apiHTMLElement.style获取元素的行内样式信息。

结合这些数据 ,就可以从计算样式的角度还原出一个元素的css元数据了!

image.png

这个方案的特点:

  • 优点:简单方便,并且从计算样式的角度非常准确;
  • 缺点:拿到的元数据是计算结果

?

看到这里,上面的方案好像没什么问题,很好的解决了之前提出的问题,实现起来也比较简便,本文也应该可以结尾了。

image.png

但是仔细一想,这个方案得到的是计算后的css样式。

计算后的样式存在什么问题?

问题在于,在样式编辑器开始的设计中,需要提供的能力是让开发者基于代码对 css 样式进行编辑

什么是计算样式?浏览器会根据页面上加载到所有css代码规则,计算出对应元素的渲染样式数据,这就是计算样式。

但是计算样式 !== css样式代码,如果使用计算样式为样式编辑器提供初始信息,这个差异会导致很大到问题。举个例子:在代码中的width: 100%这样的 css 样式,经过浏览器的计算后,100% 会被计算为真正的像素宽度,如 "98px" 。如果在编辑这个元素时css样式编辑器给用户显示出这个值,会给开发者一个误导——源码里就是硬编码了98px,并且在这个值的基础上修改。然而这是有问题的。

因此,不解决这个问题,后面的一切都无从谈起,样式编辑器的需求意味着必须要找到一个方案来提供某个元素对应的css样式源码,而不是计算样式。

使用浏览器提供的另外一组api

在对这个问题进行更多思考之后,可以想到第二个方案。

这个方案依然是通过浏览器的api去尝试得到css元数据。不同于上个方案,这次的目标是获得计算前的css代码。

简单描述一下思路:

通过浏览器 api document.stylesheets可以获取到整个页面文档的所有 CSSStyleSheet 实例。其中每个实例对应着浏览器解析出一个css样式表对象,每个样式表中包含多个css rule。

这些信息代表着开发者通过link等或style元素引入的内联或外部样式表。根据层叠样式表的规范处理这些样式表规则(分析选择器,分析属性等),可以最终计算出应用在某个元素上的属性。并且由于这些属性都是通过原始的样式表规则计算而来,也就可以非常准确的对应到计算前的属性上。

image.png

但是相应的这种方式还是存在一些问题,比如:

  • CSSStyleSheetcssRulesrules属性受到浏览器CORS策略的限制,不能访问第三方链接来源的css样式表。在开发的场景下的前端工程中,写在用户工程中的样式代码一般都会放在localhost下,但是引用第三方的样式代码在某些情况下也是不可避免的。
  • 考虑某个元素在计算样式时,来源一般分为:
  1. 通过link或者style标签声明的样式,通过选择器作用在元素上;
  2. 在脚本中通过html元素中"style"属性设置上的样式;
  3. 来自于浏览器的默认样式。很明显,通过本方案,前两者来源都可以获得到,但是对于浏览器的样式就无能为力了。这个问题会导致最终计算出的样式不准确。也就是某些样式属性可能被设置了值,但是没有被解析出来。

通过Chrome DevTools Protocol来获取 css 元数据

最后一个方案是通过 Chrome DevTools Protocol(下文简称CDP)来获取css元数据。那么 Chrome DevTools Protocol 是什么?

The Chrome DevTools Protocol allows for tools to instrument, inspect, debug and profile Chromium, Chrome and other Blink-based browsers. Many existing projects currently use the protocol. The Chrome DevTools uses this protocol and the team maintains its API.

image.png

简单来说,Chrome DevTools Protocol可以用来控制、调试、检查基于chromium内核的浏览器,通常用于对浏览器进行调试,或者制作自动化工具。chrome 浏览器内置的 devtool 以及包括 puppeteer 在内的很多工具都是在这个协议基础上实现。

通过 CDP 可以更获取到在脚本运行环境中无法访问的浏览器内核数据,并操作浏览器的行为。我们可以利用CSS.forcePseudoState来给元素施加伪类,通过 CSS.getMatchedStylesForNode 获取相应 Dom node 在浏览器中经过选择器解析和计算后的匹配样式表内容。这些内容不仅包括浏览器解析出的来自第三方源(通过link等方式远程引入或者通过style样式表引入等),同时也包含了浏览器本身自带的默认样式。这些都正好解决了前述方案的几个问题。

实现细节

编辑器框架

AUX最终使用了上述的 CDP 方案来解决css的解析问题。在CDP具体使用方式上,又大概考虑了两种:

  1. 通过 server 使用 CDP 去控制 浏览器;
  2. 通过 Chrome extension 使用 CDP 控制浏览器。

最终实现上选择了第二种方式,原因是第一种方式需要在浏览器启动时打开远程debugger端口,对于用户体验不佳。

下图就是最终css样式编辑器的框架:

image.png

在整个 css 编辑器中,用户的选择会触发 aux devtool 的选择器,选择器通过 bridge 通知 chrome extension 获取某个 dom 元素的 css 信息, chrome extension 通过 CDP 获取到 css 元数据并通过bridge返回给 aux devool,对元数据根据层叠样式表的规则进行运算,样式编辑器就得到了样式代码默认值等更精准的元数据。

样式的具体解析和处理

CSS.getMatchedStylesForNode这个 api 是css样式解析的核心,下面稍作展开。

首先在CDP的文档中,可以得知,这个api 完成的功能是:

Returns requested styles for a DOM node identified by nodeId.

image.png

也就是获取一个DOM node的样式信息,通过接口描述可以得知这些都是chrome内核稍加处理过的 css 相关信息。返回值包含几个属性,包括:inlineStyle (内联样式)、 attributesStyle ****(属性设置样式)、 matchedCSSRules ****(样式表匹配样式)、 pseudoElements ****(为元素)、inherited (继承样式)等。

在获得这些原始信息并提供给样式编辑器之前,还需要对这些样式进行处理,目的根据css(层叠样式)的规则来运算出诸如:“哪些样式目前处于active的状态”、“某个active的css属性来源是什么”这样的信息。

这部分不做展开,有兴趣的同学可以做深入研究。下面给出部分buildCascade的伪代码:

class CssStyle {
  constructor() {}
  ...
  /**
 *
 * @param {CSS.CSSStyle} inlinePayload 来自于CDP解析的内联样式
 * @param {CSS.CSSStyle} attributesPayload 来自于CDP解析的属性样式
 * @param {Array.<CSS.RuleMatch>} matchedPayload 来自于CDP的匹配样式规则
 * @param {Array.<CSS.InheritedStyleEntry>} inheritedPayload 来自于CDP的继承样式
 * @return
 * @memberof CssStyle
 */
  _buildCascade(
    inlinePayload,
    attributesPayload,
    matchedPayload,
    inheritedPayload,
  ) {
    const nodeCascades = [];
    const nodeStyles = [];

    // 内联样式拥有最高优先级
    if (inlinePayload) {
      const style = new CSSStyleDeclaration(
        inlinePayload,
        Type.Inline,
      );
      nodeStyles.push(style);
    }

    // 以相反的顺序加入rule,满足css定义
    let addedAttributesStyle;
    for (let i = matchedPayload.length - 1; i >= 0; --i) {
      const rule = new CSSStyleRule(matchedPayload[i].rule);
      // 在插件注入样式和浏览器样式前插入 attributesStyle
      if ((rule.isInjected() || rule.isUserAgent()) && !addedAttributesStyle) {
        addedAttributesStyle = true;
        addAttributesStyle.call(this);
      }
      nodeStyles.push(rule.style);
      ...
    }

    if (!addedAttributesStyle) {
      addAttributesStyle.call(this);
    }
    nodeCascades.push(
      new NodeCascade(this, nodeStyles, false /* isInherited */ ),
    );

    // 顺着node树向上查找并识别继承属性
    for (let i = 0; inheritedPayload && i < inheritedPayload.length; ++i) {
      // 计算继承的内联属性
      const inheritedStyles = [];
      const entry = inheritedPayload[i];
      const inheritedInlineStyle = entry.inlineStyle
        ? new CSSStyleDeclaration(
            entry.inlineStyle,
            Type.Inline,
          )
        : null;

      if (
        inheritedInlineStyle &&
        this._containsInherited(inheritedInlineStyle)
      ) {
        inheritedStyles.push(inheritedInlineStyle);
      }

      // 计算每一个父元素匹配的样式规则
      const inheritedRules = entry.matchedCSSRules || [];
      for (let j = inheritedRules.length - 1; j >= 0; --j) {
        const inheritedRule = new CSSStyleRule(
          inheritedRules[j].rule,
        );
        if (!this._containsInherited(inheritedRule.style)) {
          continue;
        }

        if (
          containsStyle(nodeStyles, inheritedRule.style) ||
          containsStyle(this._inheritedStyles, inheritedRule.style)
        ) {
          continue;
        }
        inheritedStyles.push(inheritedRule.style);
        this._inheritedStyles.add(inheritedRule.style);
      }
      nodeCascades.push(
        new NodeCascade(this, inheritedStyles, true /* isInherited */ ),
      );
    }

    return nodeCascades;

    function addAttributesStyle() {
      if (!attributesPayload) {
        return;
      }
      const style = new CSSStyleDeclaration(
        attributesPayload,
      );
      nodeStyles.push(style);
    }

    function containsStyle(styles, query) {
      if (!query.styleSheetId || !query.range) {
        return false;
      }
      for (const style of styles) {
        if (
          query.styleSheetId === style.styleSheetId &&
          style.range &&
          query.range.equal(style.range)
        ) {
          return true;
        }
      }
      return false;
    }
  }
}

上述伪代码描述了计算集联(cascade)的过程。通过合并获取到内联样式属性样式匹配样式最终得到一个综合后的样式层叠结构。

一个小问题

在实际的功能实现过程中,遇到了很多小问题,这里说一个:

样式编辑器必须考虑一种情况:在编写代码时,有的元素(如按钮),在不同的状态下(hover,active)会需要不同的css样式。

就像前文说的,应用CDP的 CSS.forcePseudoStateapi,可以对页面上的指定元素施加一个伪类。结合这个api,再获取样式信息,就可以实现对一个元素不同状态下样式的元数据获取。

然而这种情况在实际应用中会遇到一些问题。在 aux 的交互逻辑中,点击某个元素的同时,就会去获取元素的 style 信息。

问题在于点击时,鼠标是hover在元素上的。这个时候鼠标的hover状态怎么处理?怎么获取元素没有hover伪类状态下的样式?

这个问题就留给读者思考和探索。