超越正则表达式:在 Chrome 开发者工具中增强 CSS 值解析

Philip Pfaffe
Ergün Erdogmus
Ergün Erdogmus

您是否注意到,Chrome DevTools 的 Styles(样式)标签页中的 CSS 属性最近看起来更加精致了?我们在 Chrome 121 和 128 中推出这些更新,是为了大幅改进我们解析和呈现 CSS 值的方式。在本文中,我们将向您详细介绍这种转换的技术细节,即从正则表达式匹配系统到更强大的解析器。

我们来比较一下当前的 DevTools 与之前的版本:

顶部:最新版 Chrome,底部:Chrome 121。

差别很大,对吧?下面详细介绍了主要的增强功能:

  • color-mix - 直观表示 color-mix 函数中的两个颜色参数的便捷预览。
  • pink。名为 pink 的颜色的可点击颜色预览。点击该图标可打开颜色选择器,以便轻松进行调整。
  • var(--undefined, [fallback value]):改进了对未定义变量的处理方式,未定义变量灰显,并且有效的后备值(在本例中为 HSL 颜色)将与可点击的颜色预览一起显示。
  • hsl(…)hsl 颜色函数的另一个可点击的颜色预览,可快速访问颜色选择器。
  • 177deg:一个可点击的角度时钟,可让您以互动方式拖动和修改角度值。
  • var(--saturation, …):指向自定义属性定义的可点击链接,可轻松跳转到相关声明。

差异非常明显。为此,我们必须教会开发者工具比以前更好地理解 CSS 属性值。

这些预览不是已经推出了吗?

虽然这些预览图标看起来可能很熟悉,但它们的显示方式并不总是一致的,尤其是在复杂的 CSS 语法(如上例)中。即使这些模型确实有效,也往往需要付出大量努力才能正常运行。

原因在于,自 DevTools 问世之初,用于分析价值的系统一直在不断发展壮大。不过,它无法跟上 CSS 近期推出的令人惊叹的新功能,以及语言复杂性的相应增加。为了跟上时代的发展,该系统需要进行全面的重新设计,而我们正是这么做的!

CSS 属性值的处理方式

在开发者工具中,在样式标签页中渲染和修饰属性声明的过程分为两个不同的阶段:

  1. 结构分析。此初始阶段会解析媒体资源声明,以确定其底层组件及其关系。例如,在声明 border: 1px solid red 中,它会将 1px 识别为长度、solid 识别为字符串,并将 red 识别为颜色。
  2. 渲染。渲染阶段基于结构分析,将这些组成部分转换为 HTML 表示。这样,您就可以使用交互元素和视觉提示来丰富显示的房源文字。例如,颜色值 red 会使用可点击的颜色图标进行呈现,点击该图标即可显示颜色选择器,以便轻松进行修改。

正则表达式

以前,我们依赖于正则表达式 (regex) 来解析媒体资源值以进行结构分析。我们维护了一个正则表达式列表,用于匹配我们考虑装饰的属性值的位。例如,有些表达式会匹配 CSS 颜色、长度、角度,以及更复杂的子表达式(例如 var 函数调用)等。我们从左到右扫描文本以执行值分析,不断寻找列表中与文本的下一部分匹配的第一个表达式。

虽然这种方法在大多数情况下都能正常运行,但无法正常运行的情况却越来越多。这些年来,我们收到了大量错误报告,但发现匹配时并不完全准确。在修复这些问题时(有些修复简单,有些则相当复杂),我们不得不重新考虑自己的方法,以免积累技术债务。我们来看看其中的一些问题!

正在匹配“color-mix()

我们为 color-mix() 函数使用的正则表达式如下所示:

/color-mix\(.*,\s*(?<firstColor>.+)\s*,\s*(?<secondColor>.+)\s*\)/g

其语法如下所示:

color-mix(<color-interpolation-method>, [<color> && <percentage [0,100]>?]#{2})

请尝试运行以下示例来直观呈现匹配项。

const re = /color-mix\(.*,\s*(?<firstColor>.+)\s*,\s*(?<secondColor>.+)\s*\)/g;

// it works - simpler example
const simpler = re.exec('color-mix(in srgb, pink, hsl(127deg 100% 50%))');
console.table(simpler.groups);

re.exec('');

// it doesn't work - complex example
const complex = re.exec('color-mix(in srgb, pink, var(--undefined, hsl(127deg var(--saturation, 100%) 50%)))');
console.table(complex.groups);

color-mix 函数的匹配结果。

更简单的示例也能正常运行。不过,在更复杂的示例中,<firstColor> 匹配项为 hsl(177deg var(--saturation<secondColor> 匹配项为 100%) 50%)),这完全没有意义。

我们知道这个问题。毕竟,CSS 作为一种正式语言,并不是常规语言,因此我们已添加了特殊处理来处理更复杂的函数参数,例如 var 函数。但是,正如您在第一张屏幕截图中所看到的,上述操作并非在所有情况下都有效。

匹配 tan()

其中一个比较有趣的报告的 bug 与三角函数 tan() 有关。我们用于匹配颜色的正则表达式包含一个子表达式 \b[a-zA-Z]+\b(?!-),用于匹配命名颜色(例如 red 关键字)。然后,我们检查了匹配的部分是否实际上是命名颜色,结果发现 tan 也是一个命名颜色!因此,我们错误地将 tan() 表达式解读为颜色。

匹配 var()

我们再来看一个示例,var() 函数具有包含其他 var() 引用的回退:var(--non-existent, var(--margin-vertical))

我们为 var() 定义的正则表达式会与此值匹配。不过,它会在第一个右圆括号处停止匹配。因此,上述文本将匹配为 var(--non-existent, var(--margin-vertical)。这是正则表达式匹配的教科书限制。需要匹配圆括号的语言从根本上来说是非正规的。

改用 CSS 解析器

当使用正则表达式进行文本分析时,如果分析的语言不是正则语言,则可以采用标准的后续步骤:使用更高类型语法的解析器。对于 CSS,这意味着使用无上下文语言的解析器。实际上,开发者工具代码库中已经存在此类解析器系统:CodeMirror 的 Lezer,它是 CodeMirror 中语法突出显示(可在 Sources 面板中找到的编辑器)的基础。借助 Lezer 的 CSS 解析器,我们可以为 CSS 规则生成(非抽象)语法树,并随时可以使用。胜利。

属性值“hsl(177deg var(--saturation, 100%) 50%)”的语法树。这是 Lezer 解析器生成的结果的简化版本,省略了纯语法节点(例如逗号和圆括号)。

不过,我们发现无法直接从基于正则表达式的匹配迁移到基于解析器的匹配:这两种方法的运作方向相反。使用正则表达式匹配值片段时,DevTools 会从左到右扫描输入内容,反复尝试从有序模式列表中找到最早的匹配项。使用语法树时,匹配会从下往上开始,例如,先分析调用的参数,然后再尝试匹配函数调用。不妨将其视为评估算术表达式,其中您首先要考虑括号表达式,然后是乘法运算符,最后是加法运算符。在这种框架中,基于正则表达式的匹配相当于从左到右对算术表达式进行求值。我们真的不想从头重写整个匹配系统:有 15 种不同的匹配器和渲染程序对,其中包含数千行代码,因此我们不太可能在单个里程碑中完成该系统。

因此,我们提出了一项解决方案,以便我们逐步进行更改,具体详情将在下文中介绍。简而言之,我们保留了两阶段方法,但在第一阶段,我们尝试自下而上匹配子表达式(从而打破正则表达式流程),在第二阶段,我们自上而下进行渲染。在这两个阶段,我们都可以使用现有的基于正则表达式的匹配器和渲染(几乎没有变化),因此能够将它们逐个迁移。

第 1 阶段:自下而上匹配

第一阶段或多或少会完全按照封面上所说的内容执行操作。我们会按从下到上的顺序遍历树,并尝试匹配我们访问的每个语法树节点中的子表达式。如需匹配特定子表达式,匹配器可以使用正则表达式,就像在现有系统中一样。实际上,从版本 128 开始,在少数情况下(例如匹配长度时),我们仍会这样做。或者,匹配器也可以分析以当前节点为根的子树的结构。这样一来,它就可以同时捕获语法错误并记录结构信息。

考虑上面的语法树示例:

第 1 阶段:对语法树进行自底向上匹配。

对于此树,我们的匹配器将按以下顺序应用:

  1. hsl(177degvar(--saturation, 100%) 50%):首先,我们来了解 hsl 函数调用的第一个参数,即色相角。我们将其与角度匹配器进行匹配,以便使用角度图标装饰角度值。
  2. hsl(177degvar(--saturation, 100%)50%):第二步,我们使用 var 匹配器发现 var 函数调用。对于此类调用,我们主要想做以下两件事:
    • 查找变量的声明并计算其值,然后分别向变量名称添加链接和弹出式窗口以连接到它们。
    • 如果计算的值是颜色,请使用颜色图标装饰调用。实际上还有第三点,不过我们稍后会说说这一点。
  3. hsl(177deg var(--saturation, 100%) 50%):最后,我们匹配 hsl 函数的调用表达式,以便使用颜色图标对其进行装饰。

除了搜索要装饰的子表达式之外,我们实际上还会在匹配过程中运行第二个功能。请注意,在第 2 步中,我们说过要查找变量名称的计算值。事实上,我们会更进一步,将结果传播到树状结构。不仅是变量,还有后备值!我们可以保证,在访问 var 函数节点时,其子节点已被访问过,因此我们已经知道回退值中可能出现的任何 var 函数的结果。因此,我们能够轻松且经济高效地动态替换 var 函数及其结果,从而轻松回答诸如“此 var 调用的结果是否为颜色?”之类的问题,就像我们在第 2 步中所做的那样。

第 2 阶段:自上而下渲染

在第二阶段,我们会反转方向。采用第 1 阶段的匹配结果,我们会按从上到下的顺序遍历树,将其渲染为 HTML。对于每个访问的节点,我们检查它是否匹配,如果匹配,则调用匹配器的相应渲染程序。我们通过为文本节点添加默认的匹配器和渲染程序,避免对仅包含文本的节点(例如 NumberLiteral“50%”)进行特殊处理。渲染程序只会输出 HTML 节点,这些节点组合在一起后会生成属性值的表示形式,包括其装饰。

第 2 阶段:对语法树进行自上而下的渲染。

对于示例树,属性值的呈现顺序如下:

  1. 访问 hsl 函数调用。它匹配,因此调用颜色函数渲染程序。它会执行以下两项操作:
    • 使用任何 var 参数的即时替换机制计算实际颜色值,然后绘制颜色图标。
    • 以递归方式渲染 CallExpression 的子元素。这会自动处理函数名称、圆括号和英文逗号的渲染,这些内容只是文本。
  2. 访问 hsl 调用的第一个参数。它匹配,因此调用角度渲染程序,该程序会绘制角度图标和角度文本。
  3. 访问第二个参数,即 var 调用。匹配成功,因此请调用 var renderer,它会输出以下内容:
    • 开头的文本 var(
    • 变量名称,并使用指向变量定义的链接或灰色文本颜色对其进行装饰,以指示其未定义。它还会向变量添加一个弹出式窗口,以显示其值的相关信息。
    • 先添加英文逗号,然后以递归方式呈现后备值。
    • 右圆括号。
  4. 访问 hsl 调用的最后一个参数。它不匹配,因此只需输出其文本内容即可。

您是否注意到,在此算法中,渲染完全控制匹配节点的子节点的渲染方式?递归渲染子元素是主动的。正是通过这一技巧,我们才能够逐步从基于正则表达式的渲染迁移到基于语法树的渲染。对于与旧版正则表达式匹配器匹配的节点,可以使用原始形式的相应渲染程序。从语法树的角度来看,它负责渲染整个子树,并且其结果(HTML 节点)可以干净地插入到周围的渲染流程中。这样,我们就可以选择成对地移植匹配器和渲染程序,并逐个交换它们。

控制匹配节点子项渲染的渲染程序的另一个很酷的功能是,它使我们能够推理我们要添加的图标之间的依赖项。在上面的示例中,hsl 函数生成的颜色显然取决于其色相值。这意味着颜色图标显示的颜色取决于角度图标显示的角度。如果用户通过该图标打开角度编辑器并修改角度,我们现在能够实时更新颜色图标的颜色:

如上例所示,我们还将此机制用于其他图标配对,例如 color-mix() 及其两个颜色通道,或 var 函数从其回退返回颜色。

性能影响

在深入研究此问题以提高可靠性并解决长期存在的问题时,我们预计会出现一些性能回归,因为我们开始运行的是完整的解析器。为了对此进行测试,我们创建了一个基准测试,该基准可呈现大约 3, 500 个属性声明,并在 M1 计算机上使用 6 倍节流对基于正则表达式的版本和基于解析器的版本进行了分析。

正如我们预期的那样,在这种情况下,基于解析的方法比基于正则表达式的方法慢了 27%。基于正则表达式的方法需要 11 秒才能呈现,而基于解析器的方法需要 15 秒才能呈现。

考虑到新方法带来的成效,我们决定继续采用该方法。

致谢

我们深表谢意,Sofia Emelianova 和 Jecelyn Yeen 对本文的编辑提供了宝贵帮助!

下载预览渠道

不妨考虑将 Chrome Canary 版开发者版Beta 版用作默认开发浏览器。通过这些预览版渠道,您可以使用最新的 DevTools 功能、测试尖端的 Web 平台 API,并帮助您在用户发现问题之前发现网站上的问题!

与 Chrome DevTools 团队联系

您可以使用以下选项讨论与 DevTools 相关的新功能、更新或任何其他内容。