CSS的解析和计算

通过上一篇《DOM 的解析》,我们已经知道当浏览器接收到 HTML 文本后,会将它解析成 Token ,再构建成 DOM 树。下面我们继续分析浏览器渲染流程的样式计算,即 CSS的解析和计算。

一、收集CSS规则

浏览器开始处理 CSS 的第一步就是收集 CSS 规则。CSS 的规则来源有三种:

  1. 内联规则
  2. 外联规则
  3. 行内规则

如果浏览器遇到了一个 <link> 标签,rel 属性是 stylesheet,那么它会触发资源加载,根据标签里的 href 属性下载该资源。这个加载是异步的,不会影响DOM树的构建。

当遇到一个<style> 标签,整个<style> 标签解析结束的时候,浏览器会收集<style> 标签里的规则,存到一个地方。行内样式不收集。

收集完 CSS 规则后,会解析 CSS 。具体过程还是先进行词法分析,生成 Token。和 HTML 解析不同的是,CSS 词法分析后,还需要进行语法分析。解析后会生成相应的规则对象,并push到规则数组中。对象的内容可以通过 document.styleSheets 查看。

二、解析CSS选择器

此时,CSS 规则已经被解析并放到一个规则数组里了。那么什么时候开始计算 CSS 呢?

通常情况下,当 HTML 解析完一个开始标签后就开始计算 CSS。原因是 CSS4 之前,不支持父选择器。所以在一个开始标签解析完后,所有的样式规则都已经收集完毕(一些特殊情况除外,如:style标签写在 body 里,这样会重新计算)。

CSS 规则的选择器是从右向左匹配。从右向左匹配时,如果当前元素不匹配,就可以直接跳过了。如果从左向右,要等到匹配最后一个选择器才能知道当前元素是否匹配。显然从右向左匹配效率会更高。

举个例子:

<html>
  <head>
    <style>
      body div img {
        width: 500px;
        height: 300px;
      }
    </style>
  </head>
  <body>
      <div>
        <img />
      </div>
  </body>
</html>

上面代码中的 <style> 里的内容,大体会被解析成如下格式:

rule: {
    type: 'rule',
  selectors: ['body div img'],
  declarations: [{
    type: 'declaration',
    property: 'width',
    value: '500px',
  }, {
    type: 'declaration',
    property: 'height',
    value: '300px',
  }],
}

selectors 是这条规则的选择器,declarations 是这条规则的内容。下面我们来看看,当解析完 <img/> 时,会如何计算 <img/> 的 CSS 规则。

三、拆分选择器

CSS 选择器由简单到复杂可以分成以下几种。

  • 简单选择器:针对某一特征判断是否选中元素。
  • 复合选择器:连续写在一起的简单选择器,针对元素自身特征选择单个元素。
  • 复杂选择器:由“(空格)”、“ >”、“ ~”、“ +”、“ ||”等符号连接的复合选择器,根据父元素或者前序元素检查单个元素。
  • 选择器列表:由逗号分隔的复杂选择器,表示“或”的关系。

示例代码中的 selectors: ['body div img'] 是由空格连接的复杂选择器。空格表示匹配子孙元素。复杂选择器显然是不能直接匹配标签的,所以要把这个复杂选择器拆分。我们可以把标签名拆分出来:

const selectorParts = ['body', 'div', 'img'];

四、选择器匹配

下一步就要匹配这些选择器了。当解析完 <img /> 后,浏览器会取 selectorParts 的最后一项:’img’,和当前元素 match。match 函数里实现了判断各种选择器是否和元素匹配。match 函数实现大体如下:

function match(element, selector) {
  // element: {
  //   type: 'element',
  //   tagName: 'img',
  //   children: [],
  //   attributes: [{
  //     name: 'id',
  //     value: 'myid'
  //   }, {
  //     name: 'class',
  //     value: 'myclass'
    //   }],
  // }
  //
  // selector: 'img'

  if (selector.charAt(0) === '#') {
        // id选择器
    const attr = element.attributes.filter(attr => attr.name === 'id')[0];
    if (attr && attr.value === selector.replace('#', '')) {
      return true;
    }
  } else if (selector.charAt(0) === '.') {
        // class选择器
    const attr = element.attributes.filter(attr => attr.name === 'class')[0];
    if (attr && typeof attr.value === 'string') {
      // 处理空格的多个class选择器
      const classList = attr.value.split(' ');
      if (classList.includes(selector.replace('.', ''))) {
        return true;
      }
    }
    } else if () {
     // 其他选择器
         ......
  } else if (element.tagName === selector) {
        // 标签选择器
    return true;
  }
  return false;
}

如果匹配。下一步就是取 selectorParts 的倒数第二项:’div’,看是否和 img 标签的父元素匹配。就这样依次匹配 selectorParts 中的元素,直到结束。如果一直匹配,则表示选择器匹配了当前元素。选择器下的 CSS 规则就会添加到这个元素中。

五、根据权重(Specificity)计算最终的 ComputedStyle

接下来就到了计算权重的时候了。在 HTML 标准中,对于样式权重,描述的比较模糊。标准是对于不同的选择器,使用不同数量级的数字来计算权重。在比较早的 IE 中是使用 10 倍或者更多倍数来计算权重。这样做的问题就是如果有足够数量的低权重选择器,就会覆盖高权重的选择器。

所以现代浏览器,会使用一个四位的数组来表示权重,第一位权重最高,逐渐递减。

一般数组的第一位表示行内样式,第二位表示 id 选择器,第三位表示 class 选择器,第四位表示 tagName 选择器。

这样在比较权重时是同级别与同级别比较,不会出现数量多而导致的权重覆盖问题。如果将来有新标准,调整数组的大小和位置就能满足要求。

下面我们来看一下代码如何实现:

if (matched) {
  const sp = specificity(rule.selectors[0]);
  const computedStyle = element.computedStyle;
  for (let declaration of rule.declarations) {
    if (!computedStyle[declaration.property]) {
      computedStyle[declaration.property] = {};
    }
    if (
      !computedStyle[declaration.property].specificity ||
      compara(computedStyle[declaration.property].specificity, sp) < 0
    ) {
      computedStyle[declaration.property].value = declaration.value;
      computedStyle[declaration.property].specificity = sp;
    }
  }
}

如果选择器匹配了当前规则那么,就要根据权重来计算 computedStyle。

我们来看一下 specificity 函数是如何实现的:

function specificity(selector) {
  // p[0]: inline-style
  // p[1]: id
  // p[2]: class
  // p[3]: tagName
  let p = [0, 0, 0, 0];
  const selectorParts = selector.split(' ');
  for (let part of selectorParts) {
    if (part.charAt(0) === '#') {
      p[1] += 1;
    } else if (part.charAt(0) === '.') {
      p[2] += 1;
    } else {
      p[3] += 1;
    }
  }
  return p;
}

首先,新建一个长度为 4 的数组 p。里面的每一位存放的就是上面提到的四个不同权重选择器的值。每当匹配到一个选择器,这个选择器的值就加一。本文只是实现最基础的选择器,在真实的浏览器中还要处理更复杂的复合选择器等逻辑。不过处理的方式都是一样的。

下一步就是遍历 cssRules 里的每条规则了。如果在 computedStyle 中这条规则没有 specificity 属性,说明这条规则还没计算过权重,要把这个规则加上权重和值。

如果有 specificity 属性,那么就要和当前的选择器权重进行比较,compara 函数的实现如下:

function compara(sp1, sp2) {
  if (sp1[0] - sp2[0]) {
    return sp1[0] - sp2[0];
  }
  if (sp1[1] - sp2[1]) {
    return sp1[1] - sp2[1];
  }
  if (sp1[2] - sp2[2]) {
    return sp1[2] - sp2[2];
  }
  return sp1[3] - sp2[3];
}

逻辑很简单,就是逐位对比。用相同级别的选择器去做比较。比较出权重的大小后,按权重大小判断是否应该添加该规则。

!important

另外关于权重还有一个很常见的语法就是 !important。!important 会直接覆盖上述的那几类选择器。但是 !important 不是 CSS 标准。一般来说,在业务开发中,严禁使用 !important,除非是修复紧急线上 bug,在正常的业务开发中,使用 !important 会给调试带来巨大的麻烦。那么如何覆盖 !important 呢?!important 同样可以写多个,数量多的会覆盖数量少的。

总结

通过遍历每条 CSS 规则,然后计算权重,再比较权重,从而算出最终的 ComputedStyle。到此,浏览器是如何将 CSS 从一条条规则变成后面布局及渲染所需要的 ComputedStyle 规则的原理揭示出来了。本文是以实现一个 ToyCSSParser 的形式来展示浏览器解析 CSS 规则的原理,现实中浏览器要处理的规则和情况会复杂的多。但整体原理不变,不过复杂度大大增加。本文源码如下:

huyinglin/ToyBrowser