← 返回博客2026-08-12

CSS 静态检查的边界:别在阻断闸门里重造浏览器

为了证明一个 CSS custom property「只会用于字体」,locale 闸门差点在内部重造一套 cascade engine。真正稳的边界不是继续补 selector case,而是承认静态证据到哪里结束,再把合法写法收窄到局部、显式、可测试。

一条会同时撒两种谎的闸门

我们想做一条阻断式 locale 检查,规则很简单:生产 UI 文案必须来自中英文字典,web 客户端源码里一旦出现硬编码汉字,就让 QA 闸门直接判红。

逐行 grep 根本守不住这条规则。中文注释、与语言无关的 CJK 标点都会被当成界面文案,粗略算下来接近 900 个误报。后来手写了 comment stripper,情况反而更危险:展示给用户的 URL 里有 //,真实字符串可能被整段抹掉;template expression 里的类注释语法,又可能凭空制造违规。同一把闸门开始同时撒两种谎——该红的不红,不该红的乱红。

第一步结构性修复并不神秘:按真正发布的语言来 parse。TypeScript 与 JSX 交给 TypeScript AST;Vue 文件先用 SFC compiler 拆块;template 走 Vue template AST;CSS declaration 交给 PostCSS 与 value AST。comment 不再是「我们猜着删掉的一段文字」,而是 parser 本来就认识的语法节点。

CSS custom property 把证明边界推远了

看一个目前只喂给字体栈的 token:

:root { --brand-font: "苹方"; }
.title { font-family: var(--brand-font), sans-serif; }

最顺手的豁免是:只要所有可见 consumer 都是 font-family,这里的汉字就是字体名,不是用户文案。问题藏在「所有」两个字里——scanner 得先证明它。

只靠源码文本,证明不了。另一个文件可以重定义同名变量;ancestor 可以把它继承下来;runtime class set、cascade 顺序、at-rule、Vue scoped-style transform 都会改变最终归属。同一个 token 以后还可能流进 generated content:

.card::after { content: var(--brand-font); }

走到这里,检查器已经不是在判断一段 CSS value,而是在没有最终 DOM、active media condition 和 runtime class 的情况下,模拟浏览器 cascade。

证明越写越大,本身就是警报

有一版实现试图保住 custom-property 豁免,办法是证明 cascade ownership。它逐渐长出了 selector shape、specificity、source order、inheritance、conditional scope、!important、Vue style-block identity 和 whole-tree consumer tracking。那次 merge 动了 scanner 277 行(新增 262、删除 15),regression 又新增了 217 行。

这些测试并非白费。它们真的揪出了 sibling combinator、functional pseudo、escaped identifier、selector list、重叠 class、以 root 开头的 descendant,以及同一行多个 Vue style block 等歧义。但每修一个角落,locale checker 里就多长出一点不完整的 CSS engine。

这就是可以复用的警报:当一条例外要求你复刻另一个系统的 runtime semantics,这条例外已经越过了当前工具的证据边界。继续补 case,或许能把近似做得更好,却不会自动把近似变成证明。

最后落地的规则

我们保留 syntax-aware scanner,把 policy 收窄:

  • 汉字直接写在 font、font-family 或 src declaration 里,可以豁免——这条 declaration 自己就证明了用途。
  • 只要 custom property 的 value 含汉字,一律进入审计。scanner 手里的输入无法证明它未来只会被谁消费。
  • CSS comment 仍作为 AST node 被忽略;escape 编码的汉字、continued string、未加引号的 counter symbol,以及大小写不敏感的 url() value data,仍然逃不过检查。
  • 不支持的 script/style language 与空扫描都直接失败,不允许拿一张真空绿灯。

真正的字体 token,要修也很朴素:

.title { font-family: "苹方", sans-serif; }

我们用一个局部、显式的 declaration,换掉了一层方便的抽象。这里值得这么做:改写成本很低,而一次静默的 locale 泄漏,比这点局部误报贵得多。

给静态闸门的一条决策方法

任何 blocking analyzer 要新增「聪明豁免」之前,先把三件事写清楚:

  1. 工具手里到底有什么证据?AST 能证明 syntax,source tree 也许能证明 reference;两者都不会自动证明 runtime ownership。
  2. 歧义落在哪?别让「大概只用于字体」「现在走不到」「通常会被覆盖」悄悄变成 PASS。
  3. 哪种纠正更便宜?如果作者能在局部把意图写明,就优先采用显式写法;如果合法案例很多、改写又昂贵,就把检查搬到拥有 runtime evidence 的层,而不是教静态闸门继续猜。

fail-closed 并不是放之四海皆准。误报无穷的闸门,最后一定会被绕开。真正有用的模式更窄:只在工具缺少「批准例外所需证据」的位置 fail closed,同时把合法逃生口做得小、显式、可测试。

总结

  1. 语法问题交给 parser,别不断给 regex 打补丁、养出一个影子 parser。
  2. 给 analyzer 发豁免之前,先定义它的证据边界。
  3. 如果证明一条例外需要重造浏览器、compiler 或 policy engine,就停下来收窄规则。
  4. 把安全写法摆明:这里允许直接 font declaration,不允许 custom property 携带汉字。
  5. regression 两边都要钉住——必须失败的 value,以及必须保持绿色的合法 syntax。