Articles

Ⓜ️ Mini CSS Parser

我的项目使用 micro-app 框架作为微前端框架。使用 micro-app 时,由于开启了样式隔离功能,子应用加载的 CSS 会被解析器解析,在选择器前增加一个子应用属性选择器前缀。我碰到的问题是,如果 CSS 源码中带嵌套语法那么解析就会失败。今天借这个问题给大家简单介绍一下 CSS Parser 的基本流程。

前言

Vue 源码中包含 HTML Parser,随着大家在框架方向的深入学习(卷),不免要开始接触一些编译解析相关的东西。社区很多看 Vue 源码时总结的 HTML Parser 的文章,但是很少有介绍 CSS Parser 的文章。今天借着在开发时碰到的一个问题,给大家简单介绍一下 CSS Parser 的基本流程。

我的项目使用 micro-app 框架作为微前端框架。使用 micro-app 时,由于开启了样式隔离功能,子应用加载的 CSS 会被解析器解析,在选择器前增加一个子应用属性选择器前缀。代码示例见下。我碰到的问题是,如果 CSS 源码中带嵌套语法,那么解析就会失败。

/* css 源代码 */
.a {
  z-index: 1;
  .b {
    z-index: 2;
  }
}

/* 期待转化得到的代码 */
micro-app[name="test-app"] .a {
  z-index: 1;
  .b {
    z-index: 2;
  }
}

/* 实际得到的代码 */
micro-app[name="test-app"] .a {
  z-index: 1;
  .b {
    z-index: 2;
  } /* -> 到这里就停了 */

CSS 的组成部分

要理解 CSS 是怎么解析的,就要先清楚 CSS 是由哪几部分组成的。由我们平常见得最多的普通 CSS 代码开始说起,见下图,基本的 CSS 代码的组成部分包含规则集(rules)、选择器(selector)、选择器(declares)、属性(property)、值(value)这五个部分。规则是由属性组成的声明、声明与括号形成的声明块再加上选择器组成,而一条或多条规则组合成了规则集1

规则&规则集

图中没有包含 at-rule 的部分,哦,还少了注释部分~ 由于注释是 CSS 中语法最简单的部分,我们从解析注释开始代码部分的解读吧。

流程速览

解析注释

可能许多人不了解 CSS 的注释风格,这里先说明一下,原生 CSS 仅支持斜杠星号风格注释,不支持双斜杠注释,所以解析器并未处理双斜杠的情况。

解析注释的函数是一个 while 循环,从代码开头开始匹配,每次循环解析一条注释,直到代码开头没有注释为止。

class CSSParser {
  private matchComments() {
    while (this.matchComment());
  }
  private matchComment() {
    // 快速跳过非注释部分代码
    if (this.cssText.charAt(0) !== "/" || this.cssText.charAt(1) !== "*") {
      return false;
    }
    // ...
    return true;
  }
}

在选择解析注释内容的方法上,micro-app 没有使用正则,而是用了下标递增的办法。暂不清楚原因,感兴趣的小伙伴可以试试几种相关办法的性能对比。

let i = 2;
while (
  this.cssText.charAt(i) !== "" &&
  (this.cssText.charAt(i) !== "*" || this.cssText.charAt(i + 1) !== "/")
) {
  ++i;
}
i += 2;

let commentText = this.cssText.slice(2, i - 2);
/* ... */
this.cssText = this.cssText.slice(i);

注释的正文结果保存在了变量 commentText 中,这样可以判断 commentText 的内容来实现通过注释禁用某个选择器、禁用某行的样式隔离之类的功能。比如想让 .test2 选择器不被 scoped 规则覆盖,我们可以在业务代码中这样写。

/*! scopecss-disable-next-line */
.test2 {
  background: url(/test.png);
}

输入输出

“解析注释”中“解析”实际指对输入的 CSS 源文件待处理部分以及处理好的输出部分做出改变。这里引申出解析器里两个关键状态,cssTextresult,分别代表待解析的内容和最终输出结果。

cssText 随着循环应当逐渐减小直到为空,此时代表解析完成。result 能被内部方法 recordResult 改变,比如如果要把注释的正文内容记录到输出结果,可以这样使用:

this.recordResult(`/*${commentText}*/`);
// 相当于:this.result += `/*${commentText}*/`

解析规则集

规则集的解析是整个解析的核心过程。原理与解析注释类似,也是一个 while 循环。每一轮循环都会尝试匹配一条规则,并且将多余的注释和空白字符匹配出来。

private matchRules (): void {
  this.matchLeadingSpaces()
  this.matchComments()
  while (
    this.cssText.length &&
    this.cssText.charAt(0) !== '}' &&
    (this.matchAtRule() || this.matchStyleRule())
  ) {
    this.matchComments()
    this.matchLeadingSpaces()
  }
}

匹配注释我们知道它是使用了下标递增 + Slice 的方法,匹配空白这里则是用的一个非常简单的正则。见下 matchLeadingSpaces 函数。

private matchLeadingSpaces (): void {
  this.commonMatch(/^\s*/, false)
}

可以想象,commonMatch 应当会对状态 cssTextresult 做一些操作。实际上,commonMatch 两个参数分别是用于匹配 cssText 的正则,及跳过标记。如果在待匹配内容中匹配到了字符串,便把匹配的内容从中移除;跳过标记则意味着当确认跳过时,匹配的内容将不会被记录到输出结果中。以上面的 matchLeadingSpaces 函数举例,如果在待匹配内容中匹配到空白字符,那么会将其丢弃不做处理。

commonMatch 可谓是解析器匹配逻辑的核心,只要理解了它,那么我们回到上面提到的样式规则匹配 matchStyleRule,会发现其过程非常简单。简单来说只有三步,匹配选择器、记录解析后的选择器、解析(并记录)声明。结束条件只有成功解析以及选择器解析报错或声明解析报错。

private matchStyleRule (): boolean | void {
  const selectors = this.formatSelector(true)
  this.scopecssDisableNextLine = false
  if (!selectors) return parseError('selector missing', this.linkPath)
  this.recordResult(selectors)
  this.matchComments()
  this.styleDeclarations()
  this.matchLeadingSpaces()
  return true
}

首先是匹配选择器的部分。以 .a,.b{color:red} 为例,首先匹配出{字符前的部分即.a,.b作为选择器的正文,然后使用,分割选择器,并给选择器加上 prefix,最终返回micro-app[name="subapp"] .a, micro-app[name="subapp"] .b。刚才提到可以在业务代码中使用注释给特定选择器禁用 prefix 前缀,相关的代码的实现也在这个函数中。

const selectors = this.formatSelector()

private formatSelector (skip: boolean): false | string {
  // 匹配选择器正文
  const m = this.commonMatch(/^[^{]+/, skip)
  return m[0].replace(/(^|,[\n\s]*)([^,]+)/g, (_, separator, selector) => {
    // 如果检测到选择器被禁用则跳过添加前缀步骤
    if (...) {
      const prefix = this.prefix // micro-app[name="subapp"]
      selector = prefix + ' ' + selector
    }
    return separator + selector
  })
}

匹配完选择器后,声明便是剩下的包含在左右花括号的内容。匹配过程是一个递归,以遇到}作为结束条件。如果遇到斜杠/,则尝试停止声明匹配并开始注释匹配。总之,}之前的内容都作为当前选择器的声明,添加到cssText中。micro-app 框架会对 CSS 资源中的 URL 补全为主应用的完整路径这个功能也是在这个函数中实现,有兴趣的话可以查看其完整代码

private matchAllDeclarations (): void {
  let cssValue = (this.commonMatch(/^[^}/])*/, true) as RegExpExecArray)[0]
  this.recordResult(cssValue)

  // 递归退出条件
  if (!this.cssText) return
  if (this.cssText.charAt(0) === '}') return

  // extract comments in declarations
  if (this.cssText.charAt(0) === '/' && this.cssText.charAt(1) === '*') {
    this.matchComments()
  } else {
    // 如果不是注释则忽略中断,继续匹配声明
    this.commonMatch(/\/+/)
  }

  return this.matchAllDeclarations()
}

解析at-rule

流程中最繁琐的部分,是匹配 @media@import@charsetat-rules

每一种规则的语法都不同,要怎么处理才合适呢?

这里,micro-app 把 at-rule 分为了三组:

  1. 类似 @charset "utf-8"; 为一组,包括 @import@charset@namespace 等。
  2. 类似 @media (w < 100px) { .selector { xxx: xxx } } 为一组,包括 @supports@font-face@document 等。
  3. 类似 @font-face { unicode-range: 'xxx' } 为一组,包括 @font-face@page@key-frames 等。

第一组,@charset 所在的第一组规则的规则正文内容从 @ 开始到 ; 结束,比如 @charset "utf-8"; 就是一条完整的声明;

第二组,@media () {} 的花括号部分可能包含选择器,所以其正文内容需要重新进入 matchRules 匹配选择器以及声明。

第三组,@font-face {} 的花括号部分仅包含声明,不包含选择器,所以只需要进入 matchAllDeclarations 匹配声明即可。

解决问题

回到文章开头的问题,如果要给这个 CSS Parser 增加原生的嵌套功能,见下代码,可以仅用十行简单实现。

+ private matchAllDeclarations (nesting = 0): void {
  let cssValue = (this.commonMatch(/^[^}/])*/, true) as RegExpExecArray)[0]
  this.recordResult(cssValue)

  // 递归退出条件
  if (!this.cssText) return
-  if (this.cssText.charAt(0) === '}') return
+  if (this.cssText.charAt(0) === '}') {
+    if (!nesting) return
+    this.commonMatch(/}+\s*/)
+    return this.matchAllDeclarations(nesting - 1)
+  }

  // extract comments in declarations
  if (this.cssText.charAt(0) === '/' && this.cssText.charAt(1) === '*') {
    this.matchComments()
  } else {
    // 如果不是注释则忽略中断,继续匹配声明
    this.commonMatch(/\/+/)
  }

+ if (this.cssText.charAt(0) === '{') {
+   this.commonMatch(/{+\s*/)
+   nesting++
+ }

- return this.matchAllDeclarations()
+ return this.matchAllDeclarations(nesting)
}

最终 CSS Nesting 解析成功。

result

结语

整个 CSS Parser 的代码简单,功能强悍,耐人寻味。不过有许多边界条件没有处理好,尤其是引入了使用注释禁用样式隔离功能后,匹配注释变得更复杂且易错了。

  1. 注释不能和选择器等混用(以下 result 文件代表解析结果)。

comment in selectors

  1. CSS 字符串值可能意外启用编译器的特殊功能。

special comment in string value

  1. 错误的 @host 规则匹配。猜测是想匹配 :host 但是手滑写成了 @host
private hostRule =
  this.createMatcherForRuleWithChildRule(
    /^@host\s*/,
    '@host'
  )

error @host rule

在实际项目中,考虑到性能和可维护性的平衡,为了使代码不变得过于复杂,没有必要去完善“边界功能”。运行时的解析应当尽量简单且运行快速,并保持一定的容错:不规范的 CSS 代码在浏览器中本身就能运行,再者,解析器是一个容易带来代码性能和体积瓶颈的地方,要不然 VueJS 也不会分全局构建和运行时两个版本的编译产物

鉴于 CSS 在部分语法上的灵活性,如果从降低维护难度的角度考虑优化这个 CSS Parser,可能从以下方向:

  • 在规则匹配部分使用黑名单机制而不是白名单匹配。就 at-rule 等灵活语法而言,如果浏览器支持新的 @xxx 语法,黑名单机制编写的代码不需要对 @xxx 做额外处理;而白名单机制需要手动处理
  • 在复杂的解析器部分使用外部依赖,如使用类似 lightingcss-wasm 等方案。

关于样式隔离,作者两年前专门写了文章介绍,不过时效性稍有不足,没有涉及解析器的具体流程,写得更多是解析器在整个微前端框架中的作用,也可作为补充阅读。

最后,微前端框架中的 CSS 的有很多中实施办法,采用一些办法可以抛弃运行时解析。em... 也许以后有机会聊一聊(咕一咕)。

增订 | 2024 年 1 月 28 日

相关实现和测试用例见这个 Issue

Footnotes

  1. 比较完整的 CSS 语法参见:https://developer.mozilla.org/zh-CN/docs/Web/CSS/Syntax

Copyright © 2024 Lionad - CC-BY-NC-CD-4.0