一份 4 行的 .css 文件要穿过 9 道工序、命中 3 棵索引树、参与 8 步级联,才能让屏幕上某个像素变成 oklch 蓝。把每一步对应的 Blink 源码行号、CSSOM 字段布局、计算/继承/级联规则、容器查询/@layer/:has() 的实现细节,全部摊开。
A 4-line stylesheet has to traverse 9 stages, hit 3 index trees, and participate in an 8-step cascade before one pixel on screen turns oklch blue. We unfold each step: Blink source-line citations, CSSOM field layouts, computed/inherited/cascaded value rules, and the real implementation of container queries / @layer / :has().
本文跟 《字节码到像素的一生》、《V8 是怎么把 JS 跑快的》 同一棵 Chromium 源码树。前两篇问"HTML 怎么变像素"和"JS 怎么变快",这一篇问的是中间最容易被忽略的那一段:样式表怎么从 4 行字符串变成每个 DOM 节点的 ComputedStyle。读到 Ch24 时你会知道,为什么 color-mix(in oklch, ...) 这种 2026 才标准的语法能在 16 ms 内算完。
This article shares a source tree with "Bytecode to Pixels" and "How V8 Makes JS Fast". Those ask how HTML turns into pixels and how JS gets fast; this one tackles the most overlooked stretch in between — how a stylesheet becomes a ComputedStyle on every DOM node. By Ch24 you'll see why color-mix(in oklch, ...) — a 2026-standard syntax — resolves in under 16 ms.
把整个 CSS 引擎压缩到三行
The whole engine in three lines
每一篇沉浸式都从三个公式开始。CSS 也不例外。下面三行写完之后,后面 30 章只是把每个箭头展开。
Every immersive starts with three formulas. CSS is no exception. The next 30 chapters are just each arrow, unpacked.
// FORMULA ① — what a stylesheet IS Stylesheet = ordered list of (Selector, Declaration+) rules + an outer at-rule envelope (@media, @layer, @container, ...) // FORMULA ② — what cascade DOES ComputedStyle(element) = cascade( for each property P: argmaxspecificity, order, importance ( declarations of P matching element ) ) + inheritance + initial defaults // FORMULA ③ — what the engine OPTIMIZES StyleRecalc(node) = ∩r ∈ rules (r touches node) → O(matched · log dom) guarded by InvalidationSet ← Ch20 makes this O(dirty)
① 一个 stylesheet 就是有序的"选择器 + 声明列表"对。顺序很重要——这是 cascade 决定胜负的依据之一。
② ComputedStyle 是对每个 CSS 属性独立跑一遍 argmax: 在所有匹配这个元素的声明里,按 specificity → 顺序 → !important 的优先级挑一个最高的;然后从父节点继承没显式声明的属性;最后补 initial value。
③ DOM 改了一点东西(class 变了、子节点加了),引擎要回答"谁要重算"。朴素答案是每个元素都跑一遍 cascade(O(N × rules))。Blink 用 InvalidationSet 把它压到只跑真正脏的节点——这是 Ch20 的主题。
① A stylesheet is an ordered list of "selector + declarations" pairs. Order matters — it's one of cascade's tie-breakers.
② ComputedStyle runs an argmax per CSS property: from all declarations that match this element, pick the highest one by specificity → order → !important; then inherit unset properties from the parent; finally fill in initial values.
③ The DOM changes — a class flips, a node is inserted. The engine must answer "who needs a recalc". The naive answer is every element re-runs cascade (O(N × rules)). Blink shrinks this to only truly dirty nodes using InvalidationSet — the subject of Ch20.
读完本文你会发现"CSS"这个词其实指代四件本质不同的事:
By the end you'll see "CSS" actually denotes four distinct things:
| # | name | 是什么 | what | 本文章节 | Chapter |
|---|---|---|---|---|---|
| ① | CSS — the syntax | .css 文件里那些字符the characters in a .css file | Ch6-9 | ||
| ② | CSSOM | JS 操作的运行时对象树the JS-facing runtime object tree | Ch7, Ch10 | ||
| ③ | Cascade / matching | "哪条规则赢"的算法the "who wins" algorithm | Ch11-19 | ||
| ④ | ComputedStyle | 每个 DOM 节点上挂的最终结果the final result hanging off each DOM node | Ch19-22 |
third_party/blink/renderer/core/css/)里实现 cascade、specificity、inheritance、value resolution、invalidation、animation 等等。.css 文件只是给这个引擎喂的输入。
"CSS" is often thought to mean "the .css file". The .css file is just one of the four things above — and the least interesting one. The real "CSS engine" lives outside any .css file — in ~150 000 lines of Blink C++ (third_party/blink/renderer/core/css/) implementing cascade, specificity, inheritance, value resolution, invalidation, animation, and so on. The .css file is just the engine's input.
1996 CSS1 · 2026 nesting · 中间踩过的坑
1996 CSS1 · 2026 nesting · the dead-ends in between
CSS 是Web 上最古老但最活跃的标准之一。1996 年 Håkon Wium Lie 在 W3C 发布 CSS1,初版只有 50 个属性。今天 Chromium 实现的 CSS 属性 / at-rule / 函数加起来已经超过 650 个。下面这张图把 1996-2026 的关键节点拉直,把已死的提案也画进去——你会看到 CSS 跟任何活语言一样,踩过很多坑。
CSS is one of the oldest Web standards still actively evolving. Håkon Wium Lie shipped CSS1 in 1996 with 50 properties. Today Chromium implements over 650 CSS properties + at-rules + functions. The figure below maps 1996-2026's key landmarks — including the dead branches. Like any living language, CSS has stepped on plenty of rakes.
| gen | 代表特性 | flagship | 设计哲学 | philosophy |
|---|---|---|---|---|
| 1996 · CSS1 | font / color / marginfont / color / margin | "给 HTML 加点样式""add some style to HTML" | ||
| 1998 · CSS2 | @media · positioning · float@media · positioning · float | "排版引擎" · 但 float 是 hack 的代价"typographic engine" · but float is the cost of hacks | ||
| 2011 · CSS3 (modular) | transitions · selectors · backgroundstransitions · selectors · backgrounds | "拆成 30 个独立模块,各自演进"——标准化方式的根本变革"split into 30 independent modules" — a fundamental shift in how the standard evolves | ||
| 2012-2017 · Layout 革命 | Flexbox · Grid | "正面回答布局问题",不再靠 float / table hack"actually solving layout" instead of float / table hacks | ||
| 2017-2022 · 可编程 CSS | Custom Props · math · color-mixCustom Props · math · color-mix | "CSS 自己变成动态语言""CSS becomes a dynamic language itself" | ||
| 2022-2026 · 关系 CSS | :has · @container · @scope · anchor | "选择器能往上看 / 跨上下文 / 看锚点",一举抹掉一批 JS 用法"selectors can look up / cross-context / see anchors" — wipes out a category of JS hacks |
从 HTML parser 到 Compositor · CSS 占哪几段
from HTML parser to Compositor · CSS owns which segments
CSS 引擎不是独立服务——它嵌在 Chromium 的整套渲染管线里。下面这张图把姊妹篇《字节码到像素》的 13 段流水线拉出来,标出 CSS 占的哪几格。读完之后你会知道:CSS 不只是 "Style" 那一段,它的触手伸到 Layout、Paint、Compositor。
The CSS engine isn't a standalone service — it's embedded in Chromium's full rendering pipeline. The figure below pulls in the 13-stage pipeline from its sibling, "Bytecode to Pixels", and highlights which boxes are CSS. By the end you'll see: CSS isn't only the "Style" box — its tentacles reach into Layout, Paint, Compositor too.
cascade 输入的三个来源 · 谁优先于谁
three sources feeding cascade · who beats whom
浏览器拿到的 CSS 不只一份。当 Blink 给一个 DOM 节点算 ComputedStyle 时,同时 在看三套样式表:
The browser never sees just one stylesheet. When Blink computes ComputedStyle for a DOM node, it's looking at three sets of rules at once:
h1 { display: block; margin: 0.67em 0 });User(绿): 用户自定 (浏览器偏好、扩展);Author(橙): 站点开发者写的样式——99% 的真实样式来自这里。cascade 默认按 UA → User → Author 优先,但 !important 会反转这个顺序——这是 Ch15 cascade 章里讲的关键反直觉点。
Fig 04·1 · Three sources feeding cascade simultaneously. UA (blue): browser defaults (~20 KB · ~700 rules like h1 { display: block; margin: 0.67em 0 }). User (green): user-defined (browser prefs, extensions). Author (orange): site-developer styles — 99% of real styling comes from here. Cascade defaults to UA → User → Author priority, but !important reverses this ordering — a key counterintuitive point unpacked in Ch15.
在 Chromium 里,没有"自然"这种事——一切看起来"天生如此"的样式都写在某个文件里。比如:
In Chromium, there's no "natural" — anything that looks "just how things are" is written in a file. For example:
// third_party/blink/renderer/core/html/resources/html.css · 真实片段 // 你以为 h1 字大 / 加粗 / 自动加 margin 是"浏览器默认行为" // 不,它就是下面这几行 CSS h1 { display: block; font-size: 2em; margin-block: 0.67em; font-weight: bold; unicode-bidi: isolate; } // <button> 看起来是"原生按钮",其实也只是 ~15 行 CSS: button { appearance: auto; // ← 这一句把绘制委托给 host OS theme padding: 1px 6px; background-color: buttonface; // system color keyword · Ch28 color: buttontext; cursor: default; box-sizing: border-box; ... }
* { margin: 0 } / Tailwind preflight / normalize.css,本质都是在覆盖 UA 样式表。UA 样式跑在 author 样式之前,通过 cascade 的"origin tier" 让位 author。所以你能用 0 specificity 的 * 选择器覆盖一个看起来"浏览器天生"的 h1 margin——cascade 规则就是这么定的。但如果 UA 样式带了 !important(罕见,但 user-agent shadow DOM 里有),那 author 怎么写都没用——除非 author 也用 !important,而且 specificity 更高。
Your * { margin: 0 } / Tailwind preflight / normalize.css are all overriding the UA stylesheet. UA rules run before author rules; cascade's "origin tier" lets author win. That's why a 0-specificity * selector can override a seemingly "built-in" h1 margin — the cascade rules say so. But if a UA rule carries !important (rare, but present in user-agent shadow DOM), no author rule can override it — unless author also uses !important with higher specificity.
User 层在现代浏览器里几乎被遗忘——但它仍然存在,而且在级联里夹在 UA 和 Author 之间。你能怎么写 user 样式?三个途径:
The user layer is almost forgotten in modern browsers — but it's still there, sandwiched between UA and Author in the cascade. Three ways to write user CSS:
chrome://settings/appearance。无障碍设置同理。chrome://settings/appearance. Accessibility settings work the same way.chrome.scripting.insertCSS() 注入 — 默认进 author origin,但带 origin: 'USER' 参数可以注入 user 层。Stylus 默认走 USER 层,所以它能"胜过站点的 default" 但"输给站点的 !important"。chrome.scripting.insertCSS() — default goes to author origin, but origin: 'USER' parameter routes to the user layer. Stylus uses USER by default, which is why it "beats site defaults" but "loses to site !important".Custom.css 文件做全局样式覆盖。2014 年废弃,原因: 维护成本 + 让恶意软件得手太容易。现在只有 Firefox 还支持(userChrome.css 但只能改 Firefox UI 不能改网页内容)。Custom.css file in the profile directory for global style overrides. Deprecated in 2014 — maintenance burden plus too-easy malware vector. Only Firefox still has it (userChrome.css, but it can only restyle Firefox UI, not page content).本文 99% 的内容都是关于 Author 样式怎么工作。所以这一章不再细讲它——你写的每一句 CSS,从 Ch6 起会被全部展开。这里只列出 author 样式的四个入口:
99% of this article is about how Author styles work, so we don't dwell here — every CSS line you write gets unpacked from Ch6 onward. Just listing the four entry points:
| # | 入口 | entry | 怎么进 CSSOM | how it enters CSSOM |
|---|---|---|---|---|
| ① | <link rel="stylesheet"> | 外部 fetch,parser 跑一遍external fetch, parser runs once | ||
| ② | <style> inline | HTML parser 同步触发 CSS parserHTML parser triggers CSS parser inline | ||
| ③ | style="..." | 元素自带,以最高 specificity 进 cascadeattached to element, enters cascade with top specificity | ||
| ④ | JS · sheet.insertRule | 运行时通过 CSSOM API 增删runtime mutation via CSSOM API |
// third_party/blink/renderer/core/html/resources/html.css · Chromium 130 verbatim opener @namespace "http://www.w3.org/1999/xhtml"; /* Document defaults */ html { display: block; -webkit-text-size-adjust: auto; -webkit-touch-callout: default; } /* The body always has a background — even when transparent. */ body { display: block; margin: 8px; } /* Headings */ h1 { display: block; font-size: 2em; margin-block: 0.67em; font-weight: bold; unicode-bidi: isolate; } :is(article, aside, nav, section) h1 { font-size: 1.5em; /* h1 inside sectioning content gets smaller — html5 spec */ margin-block: 0.83em; } /* The famous default-margin chain */ h2 { display: block; font-size: 1.5em; margin-block: 0.83em; font-weight: bold; } h3 { display: block; font-size: 1.17em; margin-block: 1em; font-weight: bold; } h4 { display: block; margin-block: 1.33em; font-weight: bold; } h5 { display: block; font-size: 0.83em; margin-block: 1.67em; font-weight: bold; } h6 { display: block; font-size: 0.67em; margin-block: 2.33em; font-weight: bold; } /* The infamous <p> default margin */ p { display: block; margin-block: 1em; /* THIS is what reset CSS targets first */ } /* Block containers — many use display: block + margin defaults */ address, article, aside, div, footer, header, hgroup, layer, main, nav, section { display: block; } /* Tables — the LARGEST single block of UA CSS (~300 lines) */ table { display: table; border-collapse: separate; border-spacing: 2px; border-color: gray; /* the only place "gray" still appears as default */ box-sizing: border-box; } /* Buttons — appearance: auto delegates rendering to host OS theme */ button { appearance: auto; padding: 1px 6px; background-color: ButtonFace; /* system color keyword · CSS Color 4 */ color: ButtonText; cursor: default; box-sizing: border-box; } /* Form controls — delegated to OS via appearance: auto */ input, select, textarea { appearance: auto; font: -webkit-small-control; color: FieldText; background-color: Field; letter-spacing: normal; word-spacing: normal; text-transform: none; }
4 行 CSS · 1 次鼠标悬停 · 9 道工序
4 lines · 1 hover · 9 stages
接下来 17 章每一章都会挂一个 STAGE 标签——以同一段 CSS 为标本,逐帧记录它在那一段流水线里发生了什么。读完一遍,你就跟着这段 CSS 走完了它从字节流到 ComputedStyle 再到 GPU 的一生。下面是它的全部代码:
The next 17 chapters each carry a STAGE banner — using the same CSS specimen, recording its state at that stage of the pipeline. By the end you'll have followed this stylesheet through its whole life: from byte stream to ComputedStyle to GPU. Here it is in full:
/* card.css · the specimen we'll follow for the next 17 chapters */ :root { --brand: oklch(70% 0.15 250); } @layer base { .card { padding: 0.5rem; background: var(--brand); } } .card:hover { background: color-mix(in oklch, var(--brand), white 20%); transition: 200ms; } @container (min-width: 400px) { .card { padding: 1rem; } }
看起来平平无奇——其实没有比这更密的 4 行。它同时激活 7 个现代特性,每个特性对应文章的一个 Act:
Looks unassuming. In fact no four lines pack more. It simultaneously activates 7 modern features — one per Act of the article:
| # | 特性 | feature | 触发哪一段流水线 | stage triggered | Act |
|---|---|---|---|---|---|
| ① | :root + custom property | 变量声明 + 值未解析variable declaration + unresolved | V (Ch18) | ||
| ② | @layer base | 级联层 · 改变 cascade ordercascade layer · changes order | V (Ch17) | ||
| ③ | .card | class 选择器 · 命中 RuleSet bloomclass selector · hits RuleSet bloom | IV (Ch13) | ||
| ④ | :hover · pseudo-class | 动态匹配 · 进 InvalidationSetdynamic match · enters InvalidationSet | VI (Ch20) | ||
| ⑤ | color-mix(in oklch, ...) | 2024 函数 · 色彩插值2024 function · color interpolation | VIII (Ch28) | ||
| ⑥ | transition: 200ms | 动画 · 可 compositor offloadanimation · compositor-offloadable | VII (Ch24) | ||
| ⑦ | @container (min-width) | 2022 关系查询 · style invalidation2022 relational query · style invalidation | VIII (Ch26) |
几乎覆盖了 2017 后的所有核心新特性。把这 4 行讲透,等于把 Chromium CSS 引擎讲透。
It hits nearly every major CSS feature shipped after 2017. Explain these 4 lines completely and you've explained Chromium's CSS engine completely.
为了让 Ch6-25 的 STAGE 卡片有同一条时间线,把 The Card 的"从字节到像素"切成 9 个时间点。每一章 STAGE 卡上会注明它讨论的是哪一格。
To give Ch6-25's STAGE banners a single shared timeline, The Card's "from bytes to pixels" journey is sliced into 9 moments. Each chapter labels which cell it captures.
把流水线压缩成用户感知的三个时刻,你能更直观地感受成本:
Compressed into three user-perceptible moments, the cost becomes intuitive:
<link rel="stylesheet" href="card.css"> 触发 fetch 开始,走完 ①-③ (tokenize / parse / CSSOM build)——一份新 stylesheet 进了 CSSOM。耗时 ~80 µs (4 行的情况;典型站点 ~50 KB 样式表约 5-15 ms)。同步,因为 stylesheet 是 render-blocking 的(Ch8)。<link rel="stylesheet" href="card.css"> triggering fetch through ①-③ (tokenize / parse / CSSOM build) — a new stylesheet enters the CSSOM. ~80 µs for 4 lines; ~5-15 ms for a typical 50 KB stylesheet. Synchronous, because stylesheets are render-blocking (Ch8).var(--brand),color-mix 算出 sRGB 值,最后写入 ComputedStyle 对象挂在每个 Layout 节点上。1 个 .card 元素 ~30 µs。整页 100 个元素 ~3 ms。var(--brand), color-mix evaluates to sRGB, and the final ComputedStyle attaches to each layout node. ~30 µs per .card; ~3 ms for a 100-element page..card。不需要 ①-③ (CSSOM 不变),从 ⑧ 开始: InvalidationSet 看到 :hover 触发,标 这一个 .card 元素 dirty (不波及兄弟),重跑 ④-⑦ 算出 background = color-mix(...)。⑨ transition 把 background 从旧 oklch 到新 oklch 拆成 12 帧 (200ms × 60fps)。每帧 ~50 µs 主线程开销。.card. ①-③ skipped (CSSOM unchanged); we jump to ⑧: InvalidationSet sees :hover, marks this one .card dirty (siblings unaffected), reruns ④-⑦ to compute background = color-mix(...). ⑨ transition lerps the background across 12 frames (200ms × 60fps). ~50 µs per frame main-thread cost.用户鼠标移到 .card 上的那一瞬间——Chromium 在约 0.6 ms内跑完整套交互流水线(M2 MacBook · Chrome 130 中位数)。下面这张图把每一步钉到时间轴上:
The instant the cursor enters .card — Chromium runs the interactive pipeline in ~0.6 ms (median, M2 MacBook + Chrome 130). The figure pins each step to the clock:
color-mix(in <space>, red, blue) 在 4 个色彩空间里的同一计算结果。sRGB gamma 编码非线性,中点是污浊紫;srgb-linear 在线性光空间插值,稍好但仍有色相偏移;oklch / lab 是感知均匀空间,中点真的看起来在中间。这就是 CSS Color 5 把 in <space> 设成必选参数的原因——直接用 sRGB 是错,但要让作者明确选择。
Fig 28·1 · color-mix(in <space>, red, blue) evaluated in 4 color spaces. sRGB is gamma-encoded → midpoint is muddy purple; srgb-linear mixes in linear-light space, slightly better but still has hue shift; oklch / lab are perceptually uniform — the midpoint actually looks centered. This is why CSS Color 5 made in <space> a required argument — sRGB mixing is wrong, but authors must make the choice explicitly.
// 1. Drop card.css into a fresh page <link rel="stylesheet" href="card.css"> <div class="container"> <div class="card">hello</div> </div> // 2. Open DevTools → Performance → record // Hover over the .card → stop recording // 3. Filter Performance flame chart by these events: await page.evaluate(() => { performance.getEntriesByType("resource") .filter(e => e.name.endsWith(".css")) .forEach(e => console.log(`fetch:`, e.duration)); // stage ①-② }); // 4. Read RecalcStyle / UpdateLayoutTree timings — that's stages ④-⑦ // 5. After hover, look at InvalidateStyle event — that's stage ⑧ // 6. Animation::Update + cc::Animation::Tick events — stage ⑨ (compositor vs main)
chrome.tracing + DevTools Performance。把 card.css 放进 fresh page,运行 4 个 console 测量,然后对比本文 Ch29 表格里的数字。差别 5× 之内是正常(不同硬件、Chrome 版本、页面其他元素);差 10× 以上,大概率是你测错了或者撞上 Ch29 列出的 5 个性能陷阱之一。
Every µs number in this article comes from chrome.tracing + DevTools Performance. Drop card.css into a fresh page, run the 4 console measurements, then compare against Ch29's table. Within 5× is normal (different hardware, Chrome versions, other page elements); off by 10× or more, you probably measured wrong or hit one of Ch29's 5 perf traps.
CSSTokenizer.cc · 17 种 token · 状态机
CSSTokenizer.cc · 17 token types · the state machine
CSS 引擎拿到的第一刀是 tokenizer——它不理解 selector、不知道 cascade、对 @rule 一无所知。它只做一件事: 把 UTF-16 字符流切成 token 流。然后 parser(Ch7)拿这些 token 拼成 AST。
The engine's first cut is the tokenizer — it doesn't understand selectors, doesn't know cascade, knows nothing about at-rules. It does one job: split a UTF-16 character stream into a token stream. The parser (Ch7) then assembles tokens into an AST.
// third_party/blink/renderer/core/css/parser/css_parser_token.h // CSS 的 17 种 token 类型 (Chromium 130) — 全部来自 W3C css-syntax-3 enum class CSSParserTokenType : uint8_t { kIdentToken, // 普通标识符 · "card" "hover" "oklch" kFunctionToken, // "var(" "oklch(" "color-mix(" kAtKeywordToken, // "@layer" "@container" "@media" kHashToken, // "#main" "#brand-color" (id selector / hex color) kStringToken, // "Helvetica, sans" (quoted) kBadStringToken, // 未闭合 string — 错误恢复模式 Ch9 kUrlToken, // url(./bg.png) kBadUrlToken, kDelimiterToken, // 单字符 · ">" "+" "~" "&" "*" kNumberToken, // 70 0.15 -5 kPercentageToken, // 70% -50% kDimensionToken, // 1rem 0.5em 200ms 90deg kWhitespaceToken, // 空格 / 换行 / tab kCDOToken, // "<!--" (HTML 注释 兼容遗物) kCDCToken, // "-->" kColonToken, // ":" kSemicolonToken, // ";" kCommaToken, // "," kLeftBracketToken, // "[" — attribute selector kRightBracketToken, // "]" kLeftParenthesisToken, // "(" kRightParenthesisToken, // ")" kLeftBraceToken, // "{" kRightBraceToken, // "}" kEOFToken, };
.card" 是 class 选择器还是 dimensionToken—— 它只看见 kDelimiterToken('.') + kIdentToken("card")。"选择器"是 parser(Ch7)的概念,不是 tokenizer 的概念。把分词和语义分开是编译器设计的经典原则: 让 tokenizer 简单到可以放进 250 行 C++ (实测 css_tokenizer.cc 460 行) ,让 parser 专心做 AST。
The tokenizer does not distinguish ".card" as a class selector vs a dimensionToken — it just sees kDelimiterToken('.') + kIdentToken("card"). The selector concept lives in the parser (Ch7), not in the tokenizer. Separating tokenization from semantics is a classic compiler-design principle: keep the tokenizer small enough to fit in 250 lines of C++ (real count: 460 lines in css_tokenizer.cc), let the parser focus on the AST.
把主线第一行 :root { --brand: oklch(70% 0.15 250); } 喂给 tokenizer,输出是这样:
Feed the main-line's first line :root { --brand: oklch(70% 0.15 250); } to the tokenizer and the output is:
--brand" 是普通 IdentToken(不是变量 token,变量身份要等到 parser); "oklch(" 是 FunctionToken(spec 把 ident + 左括号合成一种类型,因为函数有错误恢复语义——右括号匹配); "70%" 是 PercentageToken(不是 Number,百分比和数字语义不同,有独立类型)。
Fig 06·1 · The first line of the main-line, tokenized. Three counterintuitive bits already in view: "--brand" is a plain IdentToken (variable identity comes later in the parser); "oklch(" is a FunctionToken (the spec fuses ident + open-paren into one type because functions have error recovery semantics — matched close-paren); "70%" is a PercentageToken (semantically distinct from Number).
CSS tokenizer 在 spec 上叫"consume a token",在实现上是个 6 状态的有限状态机。每次调 NextToken(),它从当前位置开始按 css-syntax-3 §4.3 的规则跑一遍,返回一个 token。
The CSS tokenizer is called "consume a token" in the spec; in code it's a 6-state finite-state machine. Every call to NextToken() runs the css-syntax-3 §4.3 rules from the current position and returns one token.
1rem / 200ms)。这种 "peek 一格" 的升级是 CSS 整套 token 模型的精髓。
Fig 06·2 · CSSTokenizer's 6-state FSM. DATA peeks the first char and dispatches. The 5 sub-states each consume their characters then return to DATA to start the next token. Sub-states can promote: IdentToken becomes FunctionToken if immediately followed by an open-paren (Fig 06·1's ⑦); NumericToken becomes PercentageToken if followed by % (Fig 06·1's ⑧), or DimensionToken if followed by an ident (1rem / 200ms). The "peek-one" promotion model is the heart of CSS tokenization.
| # | 事情 | job | 在哪一阶段做 | where it's done |
|---|---|---|---|---|
| ① | 区分 selector / property / valuedistinguish selector / property / value | parser · Ch7 | ||
| ② | 验证 "属性名是否存在" (如 padding vs 拼错的 padding2)validate "does this property exist" (e.g. padding vs typo padding2) | parser · Ch7 | ||
| ③ | 解析数值 ("0.15" 文本 → 0.15 浮点)parse numeric (text "0.15" → 0.15 float) | tokenizer 自己做 · 一次性 strtod | ||
| ④ | 解 var()resolve var() | cascade · Ch18 | ||
| ⑤ | @import 拉取外部资源@import resource fetch | parser → fetcher · Ch8 | ||
| ⑥ | 报告 "parsing error" (除了 bad-string / bad-url)report "parsing errors" (except bad-string / bad-url) | parser · Ch9 |
1 + + + + 2 会报 SyntaxError。CSS tokenizer 看到任何字符流都能切出 token——哪怕是垃圾。它会发出 kBadStringToken 或 kBadUrlToken 这种"有问题但已经标好"的 token,把"怎么处理"的决定权完全交给 parser。这是 CSS 著名的"forgiveness"传统的根基——见 Ch9。
This is the biggest divergence from JS tokenizers. JS sees 1 + + + + 2 and throws SyntaxError. CSS tokenizer can split any character stream into tokens — even garbage. It emits kBadStringToken or kBadUrlToken as "marked bad but still a token" types, deferring the "what to do" decision to the parser. This is the foundation of CSS's famous "forgiveness" tradition — see Ch9.
// core/css/parser/css_parser_impl.cc · simplified, the heart of CSS parsing StyleRule* CSSParserImpl::ConsumeQualifiedRule(CSSParserTokenStream& stream, AllowedRulesType allowed) { // ① CONSUME THE PRELUDE (selector list, ending at "{" or ";") const wtf_size_t prelude_start = stream.Offset(); while (!stream.AtEnd() && stream.UncheckedPeek().GetType() != kLeftBraceToken) { stream.ConsumeIncludingWhitespace(); // build up selector tokens } if (stream.AtEnd()) return nullptr; // no "{" found — invalid rule, discard // ② PARSE THE PRELUDE INTO A SELECTOR LIST CSSSelectorList* selector_list = CSSSelectorParser::ParseSelector( stream.RangeUntilOffset(prelude_start, stream.Offset()), context_); if (!selector_list->IsValid()) { // invalid selector — drop the WHOLE rule incl. declarations · Ch9 forgiveness ConsumeBlockContents(stream, /*allow_nested=*/false); return nullptr; } // ③ CONSUME THE BLOCK "{ declarations }" CSSParserTokenStream::BlockGuard guard(stream); // auto-skip to matching "}" HeapVector<CSSPropertyValue> properties; ConsumeDeclarationList(stream, properties); // ④ BUILD THE CSSStyleRule return StyleRule::Create(*selector_list, CSSPropertyValueSet::Create(properties, context_->Mode())); } // ConsumeDeclarationList loops over "ident: value;" until "}": void CSSParserImpl::ConsumeDeclarationList(CSSParserTokenStream& stream, HeapVector<CSSPropertyValue>& out) { while (!stream.AtEnd()) { switch (stream.UncheckedPeek().GetType()) { case kWhitespaceToken: case kSemicolonToken: stream.UncheckedConsume(); break; case kIdentToken: ConsumeDeclaration(stream, out, /*important_allowed=*/true); break; case kAtKeywordToken: ConsumeNestedRule(stream, out); // CSS Nesting · Ch27 break; default: // drop bad token, resync at next ";" — Ch9 ConsumeErrorBlock(stream); break; } } }
css_parser_impl.cc 是 3 600 行 C++,跟 30 年 CSS spec 的演化轨迹完全平行。每个新特性(@layer / @scope / @container / nesting)都给 parser 加一个或几个 switch case。但核心循环(consume rule → consume declaration list)从 1996 年至今几乎没变——只是分支越来越多。
The full css_parser_impl.cc is 3 600 lines of C++, tracking 30 years of CSS spec evolution. Each new feature (@layer / @scope / @container / nesting) adds one or a few switch cases. But the core loop (consume rule → consume declaration list) has barely changed since 1996 — only the branches multiplied.
CSSParser · StyleSheetContents · CSSRule 家族
CSSParser · StyleSheetContents · the CSSRule family
parser 把 tokenizer 吐出来的 token 流拼成 规则树——也就是 W3C 叫的 "CSSOM"。它解决两件事: ① 把 token 序列识别成 selector / declaration / at-rule 的结构;② 把这棵树挂在 Document.styleSheets 上,让 JS 可以读改。
The parser stitches the tokenizer's stream into a rule tree — what W3C calls the "CSSOM". It does two jobs: ① identify token sequences as selector / declaration / at-rule structures; ② attach the tree to Document.styleSheets so JS can read and mutate.
// third_party/blink/renderer/core/css/css_rule.h · simplified hierarchy class CSSRule { ... }; // abstract base class CSSStyleRule : public CSSRule { // .card { padding: 0.5rem } CSSSelectorList selectors_; // the ".card" part Member<CSSPropertyValueSet> properties_; // the "padding: 0.5rem" part }; class CSSLayerBlockRule : public CSSRule { // @layer base { ... } String name_; // "base" HeapVector<Member<CSSRule>> child_rules_; // child rule list }; class CSSContainerRule : public CSSRule { // @container (...) { ... } CSSContainerQuery query_; // "(min-width: 400px)" HeapVector<Member<CSSRule>> child_rules_; }; class CSSMediaRule · CSSImportRule · CSSKeyframeRule ... // 19 total in Chromium 130 // the outer container, what Document.styleSheets[0] returns class StyleSheetContents { HeapVector<Member<CSSRule>> child_rules_; // top-level rules KURL base_url_; ParserContext context_; bool has_failed_parser_; // ← still false even with errors };
CSSStyleRule (普通规则),品红是 @ at-rule(在 Blink 里都继承自 CSSRule)。注意 @layer 和 @container 的结构: 它们持有 child_rules_ 数组,内部嵌套一个独立的 CSSStyleRule——所以一个 .card 在 CSSOM 里实际上对应 3 个 CSSStyleRule 实例(:root 那条不算),它们在 cascade 阶段会被合并。--brand 的值在这里还是字符串,要等到 Ch18 才解析。
Fig 07·1 · The Card after parse. Blue: CSSStyleRule (ordinary rules); magenta: @ at-rules (in Blink all inherit from CSSRule). Note the shape of @layer and @container: they hold a child_rules_ array nesting independent CSSStyleRules — so .card in CSSOM actually maps to 3 CSSStyleRule instances (:root aside), all merged later during cascade. --brand's value is still a string here; Ch18 parses it.
这一点对从 JS / Java 引擎过来的人最反直觉。CSS parser 跑完后,大多数 declaration 的值还是字符串。"oklch(70% 0.15 250)" 没有被算成 sRGB; "var(--brand)" 没有被替换;"0.5rem" 没有被乘以 16 变成 px。所有这些都要等到 cascade 阶段(Ch15-19)再做,因为:
This is the most counterintuitive bit for anyone coming from JS / Java engines. After CSS parsing, most declaration values are still strings. "oklch(70% 0.15 250)" isn't yet sRGB; "var(--brand)" isn't substituted; "0.5rem" isn't multiplied by 16 to px. All of that waits for cascade (Ch15-19), because:
1rem 取决于 哪个元素1rem depends on which elementrem = root font-size。但 root font-size 本身就是个 cascade 的产物——:root { font-size: ... } 也可能被另一条规则覆盖。parser 在 parse 时还不知道 root font-size,所以 1rem 必须延迟到 每个元素求 ComputedStyle 时再算。rem = root font-size. But the root font-size itself is a cascade outcome — :root { font-size: ... } might be overridden by another rule. The parser doesn't know root font-size yet, so 1rem must wait until per-element ComputedStyle resolution.var(--brand) 在 cascade 完成之后才有定义var(--brand) isn't defined until after cascade--brand 在 :root 上,但 :root 的 cascade 要先跑过。所以 var() 求值必须发生在 cascade 之后,Ch18 详谈。parse 阶段只能把 var(...) 整段原样存为字符串。--brand lives on :root, but :root's cascade must complete first. var() resolution therefore follows cascade — Ch18 covers this. Parse stores the entire var(...) verbatim as a string.color-mix / oklch 等需要插值上下文,颜色解析延迟到 Ch19 的 used-value 阶段。这是 W3C "computed value time vs used value time" 区别(Ch19 详)的实际后果。color-mix / oklch need an interpolation context, color resolution moves to Ch19's used-value step. This is a concrete consequence of W3C's "computed value time vs used value time" distinction (Ch19 details).// JS-facing surface — these are CSSStyleSheet IDL methods that bypass the .css fetch path const sheet = document.styleSheets[0]; // → CSSStyleSheet (wrapper around StyleSheetContents) sheet.insertRule('.danger { color: red }', 0); // → re-runs Ch6+Ch7 on this snippet sheet.cssRules[0].style.padding = '2rem'; // → property mutation, triggers Ch20 InvalidationSet sheet.deleteRule(0); // → remove rule, invalidate every element it matched // Constructable Stylesheets (2020) — bypass <link> / <style> entirely const sheet2 = new CSSStyleSheet(); sheet2.replaceSync('.box { color: blue }'); document.adoptedStyleSheets = [sheet2]; // shadow-DOM friendly
sheet.cssRules 看起来像数组,实际是只读的 CSSRuleList。要新增必须走 insertRule——因为 Blink 要重建索引(Ch13 的 RuleSet) 和触发失效(Ch20)。如果允许用户随便 push,这些副作用会漏掉。这是 W3C 故意做的"narrow API",让 mutation 永远经过一个中央口子。
sheet.cssRules looks like an array; it's a read-only CSSRuleList. Insertion must go through insertRule because Blink needs to rebuild the index (Ch13's RuleSet) and trigger invalidation (Ch20). Allowing a free push would skip those side effects. W3C deliberately designed a "narrow API" so every mutation funnels through one central hook.
stylesheet 加载链 · 串行陷阱
the import chain · the serialization trap
CSS 是render-blocking 的——所有 stylesheet 必须 加载并 parse 完,Blink 才允许第一次 paint。@import 让这个问题变得更糟,因为它在 stylesheet 里再触发另一个 stylesheet 的加载,形成串行瀑布。下面这张图把"@import 让 FCP 变慢"这件事讲清楚。
CSS is render-blocking — every stylesheet must be fetched and parsed before Blink permits the first paint. @import makes this worse by triggering another stylesheet fetch from inside a stylesheet — a serial waterfall. The figure below explains why @import slows down FCP.
<link rel="stylesheet"> 并列加载,或在 build 时把 @import 内联展开。
Fig 08·1 · Why @import costs at least one extra RTT. The parser must parse the outer stylesheet to discover the inner @import, and parsing requires the download to finish. That serial dependency can't be parallelised. Recommendation: parallel <link rel="stylesheet"> tags, or inline @import at build time.
很多人以为 "CSS render-blocking" 意思是 "CSS 没下载完整页白屏"。这不准确。准确说法是: Blink 不会触发第一次 paint,直到 所有当前已知的非 disabled stylesheet 都加载 + parse 完。这里有几个 非显然 的副作用:
A common belief: "CSS render-blocking" means "whole page is white until CSS arrives". Not quite. More precisely: Blink withholds the first paint until all currently known non-disabled stylesheets have loaded and parsed. Several non-obvious consequences:
| # | 情形 | case | 是否 block | blocks? |
|---|---|---|---|---|
| ① | <link rel="stylesheet" media="print"> | 非匹配 medianon-matching media | 否 | |
| ② | <link rel="stylesheet" disabled> | JS 可后续开启JS may enable later | 否 | |
| ③ | <link> in <head> | 普通情形normal case | 是 | |
| ④ | <link> in <body> | 部分阻塞 · BlockingAttributepartial blocking · BlockingAttribute | 是,但只阻塞后续 DOMyes — but only blocks subsequent DOM | |
| ⑤ | @import "..." | 嵌在另一个 stylesheet 里nested in another stylesheet | 是 | |
| ⑥ | <style>...</style> | 内联inline | 否(不用 fetch)但阻塞 parserno fetch, but blocks parser | |
| ⑦ | style="..." | 行内样式inline style attr | 否 |
<script> 出现在 <link rel="stylesheet"> 之后,这个 script 的执行会等 stylesheet 加载完。原因: 这个 script 可能读 getComputedStyle()。Blink 不知道它会不会读,所以保险起见都等。这就是 "CSSOM 阻塞 JS" 的来源。绕过办法: 在 <script> 上加 async / defer,告诉浏览器"这个 script 不读 CSSOM"。
If a <script> appears after a <link rel="stylesheet">, the script's execution waits until the stylesheet loads. Reason: the script might call getComputedStyle(). Blink can't know in advance, so it waits anyway. This is where "CSSOM blocks JS" comes from. Bypass: async / defer on the script tells the browser "this script won't read CSSOM".
drop bad tokens · resync at ; or }
drop bad tokens · resync at ; or }
CSS 跟 JS 最大的工程哲学差别: parse error 不停止解析。JS 看到一个语法错误整段 script 失效;CSS 看到一个错误的 declaration,丢掉它,继续读下一个。这种"forgiveness"不是设计偷懒——它是 Web 向后兼容性的核心,Tim Berners-Lee 和 Håkon Wium Lie 在 1996 年明确写入 spec。
The biggest philosophical divergence from JS: parse errors do not halt parsing. JS sees one syntax error and the whole script dies. CSS sees a bad declaration, drops it, and reads the next one. This "forgiveness" isn't laziness — it's the foundation of Web backward compatibility, written into the spec by Tim Berners-Lee and Håkon Wium Lie in 1996.
| # | 出错位置 | error site | 恢复 token | resync at | 丢什么 | drops |
|---|---|---|---|---|---|---|
| ① | 某个 value 错bad value | ; | 那一条 declarationjust that declaration | |||
| ② | selector 错bad selector | } | 整个 ruleentire rule | |||
| ③ | @-rule 名错bad @-rule name | ; 或 {...} | 整个 at-ruleentire at-rule | |||
| ④ | 未闭合 (string / paren / brace)unclosed (string / paren / brace) | EOFEOF | 余下整个文档rest of file |
/* CSS does not stop on errors. Each block here exhibits a recovery strategy. */ .card { color: hsl(0); /* ✗ ① bad value — only 1 arg */ padding: 1rem; /* ↑ drop "color: hsl(0);" → keep padding */ } .card::wxyz /* ✗ ② unknown pseudo-element */ .btn { color: blue; } /* ↑ drop whole rule incl. .btn part! */ @unknown-rule ...; /* ✗ ③ unknown at-rule → drop */ .ok { color: green; } /* ✓ parser resumes — this rule still applies */
.card::wxyz 写错了一个 pseudo-element,但因为它跟 .btn 用逗号选择器列表共享 { color: blue },整条规则被丢掉——.btn 也跟着倒霉。这是 W3C selectors-3 的 "fail-on-any-invalid" 规则。selectors-4 引入 :is() 后改变了这条: :is(.card::wxyz, .btn) 内部一个错不会污染另一个——这是 Ch14 的核心讨论。
Look at ②: .card::wxyz mistypes a pseudo-element, but because it shares { color: blue } with .btn via a comma-separated selector list, the whole rule is discarded — .btn loses its blue too. This is selectors-3's "fail-on-any-invalid" rule. Selectors-4's :is() changes this: :is(.card::wxyz, .btn) isolates each branch — see Ch14.
// core/css/parser/css_parser_impl.cc · simplified — 模拟错误恢复主循环 while (!stream.AtEnd()) { auto rule = ConsumeQualifiedRule(stream); if (rule) { style_sheet->AppendChildRule(rule); } else { // rule failed — skip past the next "}" or end of input while (!stream.AtEnd() && stream.PeekTokenType() != kRightBraceToken) { stream.ConsumeIncludingWhitespace(); } if (stream.PeekTokenType() == kRightBraceToken) stream.ConsumeIncludingWhitespace(); } } // ConsumeQualifiedRule itself contains similar resync inside — at ";" for declarations
border-radius: 4px 直接挂。CSS 团队选了"识别不了就跳过"——这就是为什么同一份样式表可以在 IE6 和 Chrome 130 里都跑出合理结果。每个 CSS 新特性的引入都依赖这个: 旧浏览器看到 color-mix(...) 不认识就跳过,fallback 到前一行 background: #6699cc(渐进增强)。CSS 的整条向前兼容性都建立在 "parse error never stops parsing" 上。
Web in 1996 was full of "hacks for IE" and "properties from a future CSS version". If CSS were strict like JS, one unrecognised property would kill the entire page's styling — old browsers seeing border-radius: 4px would crash. The CSS team chose "skip what you can't parse" — that's why the same stylesheet renders sensibly in both IE6 and Chrome 130. Every new CSS feature relies on this: old browsers see color-mix(...), don't recognise it, skip it, fall back to the prior background: #6699cc (progressive enhancement). All of CSS's forward compatibility rests on "parse error never stops parsing".
Document.styleSheets · element.computedStyleMap
Document.styleSheets · element.computedStyleMap
CSS 引擎处理的不是一棵树,是两棵: DOM 树(Document → Element → ...)和 CSSOM 树(Document.styleSheets → CSSStyleSheet → CSSRule → ...)。它们独立构造,然后在 cascade 阶段被编织到一起——每个 DOM 节点最终挂一个 ComputedStyle,这个 ComputedStyle 是 CSSOM 跑下来的结果。
The CSS engine doesn't deal with one tree — it deals with two: the DOM tree (Document → Element → ...) and the CSSOM tree (Document.styleSheets → CSSStyleSheet → CSSRule → ...). They build independently, then get woven together during cascade — each DOM node ends up with a ComputedStyle attached, the output of running CSSOM against it.
StyleEngine(绿框)编织: 它从 CSSOM 取所有规则,从 DOM 取目标元素,跑完 cascade,把结果作为 ComputedStyle(黄底)挂回 DOM 节点。关键洞察: DOM 节点上的 computedStyle 字段是 cascade output,不是 CSSOM input 的指针。即使你后续删除整张 stylesheet,已经计算出的 ComputedStyle 还在(直到 Ch20 触发的 invalidation 把它 dirty)。
Fig 10·1 · DOM and CSSOM exist in parallel, woven by the StyleEngine (green box): it pulls rules from CSSOM, target elements from DOM, runs cascade, and attaches the result as ComputedStyle (yellow) back onto the DOM node. Key insight: the computedStyle field on a DOM node is cascade's output, not a pointer to its CSSOM input. Even if you delete the entire stylesheet later, the already-computed ComputedStyle persists (until invalidation in Ch20 marks it dirty).
| # | API | 读什么 | reads | 侵入性 | invasiveness |
|---|---|---|---|---|---|
| ① | document.styleSheets | 所有 stylesheet 的规则定义rule definitions | 读: 廉价 · 写: 触发 InvalSetread: cheap · write: triggers InvalSet | ||
| ② | getComputedStyle(el) | cascade 跑完的最终值cascade's final values | ⚠️ 同步触发 style + layout 重算⚠️ synchronously triggers style + layout recalc | ||
| ③ | el.computedStyleMap() | 同 ② 但走 Typed OMsame as ② but Typed OM | 同 ②same as ② | ||
| ④ | el.style | 行内样式 style="..." attrinline style="..." attr | 读: 廉价 · 写: 触发本元素失效read: cheap · write: invalidates this element |
getComputedStyle(el).width 都会让 Blink 立刻同步跑完 style + layout——即使你刚刚改了 DOM。这就是著名的 "layout thrashing": 在 loop 里穿插 read/write DOM 会让每次迭代都重算一遍。规避: 先把所有读做完(requestAnimationFrame),再把所有写一起做。Ch29 详谈。
One of the classic jank sources on the web. Any JS call to getComputedStyle(el).width makes Blink synchronously run style + layout — even if you just mutated the DOM. Famously known as "layout thrashing": interleaving read/write DOM in a loop forces a recalc per iteration. Avoid: batch all reads (requestAnimationFrame) and then all writes. Ch29 details.
// core/css/style_engine.cc · simplified — runs on every style-affecting DOM change void StyleEngine::UpdateActiveStyleSheets() { TRACE_EVENT0("blink", "StyleEngine::UpdateActiveStyleSheets"); // ① Collect all stylesheets currently attached to the document HeapVector<Member<CSSStyleSheet>> new_sheets = CollectActiveStyleSheets(); // ② Diff against previously active set ActiveSheetsChange change = CompareWith(active_user_style_, new_sheets); switch (change) { case ActiveSheetsChange::kNoChange: return; case ActiveSheetsChange::kActiveSheetsAppended: // fast path: only NEW sheets added at the end → no invalidation walk needed AppendActiveSheets(new_sheets); break; case ActiveSheetsChange::kActiveSheetsChanged: // slow path: rebuild RuleSet, invalidate ALL elements that may match changed rules RebuildRuleSet(); InvalidateForRuleSetChange(*old_, *new_); break; } active_user_style_ = new_sheets; MarkStyleDirty(); // schedules StyleRecalc on the next frame } // Per-element style resolution — runs on every dirty element in the next frame ComputedStyle* StyleResolver::ResolveStyle(Element& element) { // ① Pull candidate rules from RuleSet — Ch13's bloom + bucket lookup MatchedPropertiesVector matched = rule_set_->CollectMatchingRulesForElement(element); // ② Run the 8-step cascade — Ch15 StyleCascade cascade; for (const auto& m : matched) cascade.AddMatchedProperties(m); cascade.Apply(); // ③ Resolve var() — Ch18 ComputedStyle* style = cascade.GetStyle(); ResolveCustomProperties(style); // ④ Compute final values (font-size / em → px, color-mix evaluation) — Ch19 FinalizeComputedStyle(style, element); // ⑤ Attach to element element.SetComputedStyle(style); return style; }
CSSSelector · 链表 · 组合器
CSSSelector · linked list · combinators
".card:hover" 在 CSSOM 里不是一个字符串——是一条链表。每个 CSSSelector 节点代表一个"简单选择器",通过 tag_history_ 指针串起来。组合器(空格、>、+、~)夹在简单选择器中间。
".card:hover" in CSSOM isn't a string — it's a linked list. Each CSSSelector node represents one simple selector, threaded by a tag_history_ pointer. Combinators (whitespace, >, +, ~) sit between simple selectors.
// core/css/css_selector.h · simplified class CSSSelector { MatchType match_; // Tag | Class | Id | AttributeExact | PseudoClass | PseudoElement | ... AtomicString value_; // "card" / "hover" / "div" PseudoType pseudo_type_; // PseudoHover / PseudoFocus / PseudoHas / PseudoIs / ... RelationType relation_; // SubSelector | Descendant | Child | DirectAdjacent | IndirectAdjacent Member<CSSSelector> tag_history_; // → next simple selector in the chain Member<CSSSelectorList> selector_list_; // for :is() / :has() / :where() — inner list };
.card:hover" 解析出来是 2 个节点.card:hover" decodes into 2 nodes// Parsing ".card:hover" gives a 2-node linked list: [node 0] match=PseudoClass · pseudo_type=PseudoHover · relation=SubSelector → [node 1] match=Class · value="card" · relation=SubSelector · tag_history=null // Note: parser stores in right-to-left order on disk, but reads left-to-right. // The "rightmost simple selector" is head of the linked list — Ch12 explains why.
div.card .btn:hover" 是左到右的;但 SelectorChecker(Ch12)是从右到左匹配的——先看最右的 :hover,再倒着检查父链。原因 Ch12 详谈,简言之: 大部分元素根本不匹配,从最具体的部分判起能最快剪枝。
This is the most counterintuitive but most important implementation choice in CSS selector matching. Source-code reads "div.card .btn:hover" left-to-right; SelectorChecker (Ch12) matches right-to-left — starts at the rightmost :hover, then walks up the parent chain. Reason in Ch12, briefly: most elements don't match anything, and starting at the most specific part prunes earliest.
div.main > .card:hover"在 CSSOM 里是一条4 节点链表,head 是最右的 :hover,tail 是最左的 div。这种反向存储是 right-to-left 匹配的物理来源: 从 head 起顺着 tag_history_ 走,自然就是"右到左"。每个节点的 relation 字段记它跟下一节点之间的组合器(child / descendant / sibling)。
Fig 11·1 · "div.main > .card:hover" lives as a 4-node linked list: head is the rightmost :hover; tail is the leftmost div. This reverse storage is the physical origin of right-to-left matching — start at head and walk tag_history_ forward, you're going right-to-left. Each node's relation records the combinator between it and the next node (child / descendant / sibling).
单元素 · O(选择器长度) · 早剪枝
single element · O(selector length) · early prune
SelectorChecker 回答一个问题: "这个 DOM 元素匹这条选择器吗?"。直觉是从左到右走——选择器写的是 div .card,那就先找 div,再看里面的 .card。实际上 Blink 反着来: 先看当前元素是不是 .card,匹配的话再向上走看祖先里有没有 div。这就是 "right-to-left matching",CSS 引擎的第一性原理。
SelectorChecker answers one question: "does this DOM element match this selector?". The intuition is left-to-right — the selector says div .card, so find the div, then look inside for a .card. In reality Blink goes backwards: check whether the current element is .card first; if yes, walk upward looking for a div ancestor. This is "right-to-left matching", the first principle of every CSS engine.
<p> 元素运行 ".main article p"。从 rightmost 开始(node[0]=p),检查当前元素是不是 p ✓。然后沿 DOM 向上走找 article(找到 ✓),再向上找 .main(找到 ✓)。一旦任意一步失败立刻返回 false——大多数选择器在 1-2 步内被剪掉。这就是为什么 CSS 引擎能在 10k DOM 节点 × 1k 规则上每帧跑完。
Fig 12·1 · SelectorChecker runs ".main article p" against a <p>. Starts at the rightmost (node[0]=p), checks if the current element is p ✓. Then walks up the DOM looking for article (found ✓), then for .main (found ✓). Any failure short-circuits — most selectors are pruned in 1-2 steps. This is why a CSS engine can handle 10k DOM × 1k rules every frame.
// third_party/blink/renderer/core/css/selector_checker.cc (~line 220) · simplified enum class MatchStatus { kSelectorMatches, kSelectorFailsLocally, kSelectorFailsAllSiblings }; MatchStatus SelectorChecker::MatchSelector( const SelectorCheckingContext& context, MatchResult& result) { // ① CHECK CURRENT COMPOUND (the rightmost simple-selector group) if (!CheckOne(context, result)) { return MatchStatus::kSelectorFailsLocally; } // ② IF NO MORE COMPOUNDS — DONE (this was the leftmost) if (context.selector->Relation() == CSSSelector::kSubSelector) return MatchStatus::kSelectorMatches; // ③ RECURSIVELY MATCH PARENT/SIBLING ACCORDING TO COMBINATOR SelectorCheckingContext next_context = context; next_context.selector = context.selector->TagHistory(); switch (context.selector->Relation()) { case CSSSelector::kDescendant: // walk up parent chain — bail when any ancestor matches the parent selector for (next_context.element = context.element->parentElement(); next_context.element; next_context.element = next_context.element->parentElement()) { if (MatchSelector(next_context, result) == kSelectorMatches) return kSelectorMatches; } return kSelectorFailsLocally; case CSSSelector::kChild: // only check direct parent next_context.element = context.element->parentElement(); return next_context.element ? MatchSelector(next_context, result) : kSelectorFailsLocally; case CSSSelector::kDirectAdjacent: next_context.element = ElementTraversal::PreviousSibling(*context.element); return next_context.element ? MatchSelector(next_context, result) : kSelectorFailsLocally; case CSSSelector::kIndirectAdjacent: // scan all preceding siblings for (next_context.element = ElementTraversal::PreviousSibling(*context.element); next_context.element; next_context.element = ElementTraversal::PreviousSibling(*next_context.element)) { if (MatchSelector(next_context, result) == kSelectorMatches) return kSelectorMatches; } return kSelectorFailsAllSiblings; case CSSSelector::kRelativeDescendant: // :has() forward case CSSSelector::kRelativeChild: case CSSSelector::kRelativeDirectAdjacent: case CSSSelector::kRelativeIndirectAdjacent: return MatchHasPseudoClass(context, result); // special — Ch14 default: return kSelectorFailsLocally; } }
kIndirectAdjacent (~) 失败时返回 kSelectorFailsAllSiblings,而不是 kSelectorFailsLocally。这是 Blink 的一个剪枝优化: 一旦 "X ~ Y" 在所有前面兄弟中都没找到 X,那么这条选择器对当前元素及其所有右侧兄弟都不会匹——caller 收到这个返回会跳过整条兄弟链。这一个 enum 值 = 几个数量级的 selector match 加速。
Notice that kIndirectAdjacent (~) failure returns kSelectorFailsAllSiblings, not kSelectorFailsLocally. This is a Blink pruning optimisation: once "X ~ Y" has failed to find X among all preceding siblings, the selector cannot match the current element or any of its later siblings — caller skips the entire sibling chain. One enum value = orders of magnitude in matching speedup.
// core/css/rule_set.cc · simplified — what runs when a new stylesheet enters CSSOM void RuleSet::AddRule(const RuleData& rule_data) { // ① IDENTIFY THE RIGHTMOST SIMPLE SELECTOR (the "key") const CSSSelector* selector = rule_data.Selector(); // head of linked list (= rightmost) switch (selector->Match()) { case CSSSelector::kId: id_rules_.Add(selector->Value(), rule_data); break; case CSSSelector::kClass: class_rules_.Add(selector->Value(), rule_data); break; case CSSSelector::kTag: // store by lowercased tag name; "*" goes to universal bucket if (selector->TagQName().LocalName() == g_star_atom) universal_rules_.push_back(rule_data); else tag_rules_.Add(selector->TagQName().LocalName().LowerASCII(), rule_data); break; case CSSSelector::kAttributeSet: case CSSSelector::kAttributeExact: attribute_rules_.Add(selector->Attribute().LocalName(), rule_data); break; case CSSSelector::kPseudoClass: // pseudo-class rules go into specialized buckets if (selector->GetPseudoType() == CSSSelector::kPseudoHover) hover_rules_.push_back(rule_data); else if (selector->GetPseudoType() == CSSSelector::kPseudoHas) has_rules_.push_back(rule_data); // Ch14 else universal_rules_.push_back(rule_data); // fall back break; default: universal_rules_.push_back(rule_data); } // ② BUILD THE ANCESTOR BLOOM (FIG 13·1) rule_data.descendant_invalidation_set->ComputeAncestorBloom(selector); // ③ REGISTER WITH INVALIDATION FEATURE SET (Ch20) features_->CollectFeaturesFromRuleData(rule_data); }
// Probe: matching an element against the RuleSet RuleSet::RuleSpan RuleSet::CandidatesForElement(const Element& e) { RuleSpan candidates; candidates += universal_rules_; if (e.HasID()) candidates += id_rules_.Get(e.IdForStyleResolution()); for (const AtomicString& cls : e.ClassNames()) candidates += class_rules_.Get(cls); candidates += tag_rules_.Get(e.LocalNameForSelectorMatching()); for (QName attr : e.PresenceAttributes()) candidates += attribute_rules_.Get(attr.LocalName()); if (e.HoverState()) candidates += hover_rules_; return candidates; // typically 5-20 entries out of 10 000 rules }
| engine | match 方向 | match direction | 索引数据结构 | index structure | 特点 | notable |
|---|---|---|---|---|---|---|
| Blink (Chromium) | 右到左 + bloom 预筛right-to-left + bloom pre-filter | RuleSet hashmap × 5 bucket | 本文主角subject of this article | |||
| WebKit (Safari) | 右到左 + JIT compiled matcherright-to-left + JIT compiled matcher | RuleSet + selector JIT | 把选择器编译成机器码(类似 V8 对 JS)— ~30% 快于 Blink 在 hot selector 上compiles selectors to machine code (like V8 does to JS) — ~30% faster than Blink on hot selectors | |||
| Gecko (Firefox · Stylo) | 右到左 + bloom + 并行right-to-left + bloom + parallel | Stylo SelectorMap (Rust) | 从 Servo 移植 · rayon 并行匹配多元素ported from Servo · rayon-parallel match across elements |
WebCore/css/SelectorCompiler.cpp)— 跟 V8 把 JS 编成 ARM/x86 机器码一个套路。对每条规则,SelectorChecker 不再走解释器循环,而是直接调用该规则专属的机器码 function。在 hover 状态切换这类高频路径,WebKit ~30% 快于 Blink。Blink 团队 2016 评估过,认为性价比不够(JIT 增加 binary 大小 + cold-start 成本),没移植。
In 2014 WebKit added JIT compilation for selectors (WebCore/css/SelectorCompiler.cpp) — same trick V8 uses to compile JS to ARM/x86. For each rule, SelectorChecker no longer runs an interpreter loop — it directly calls machine code generated for that specific rule. On high-frequency paths like hover state toggles, WebKit is ~30% faster than Blink. The Blink team evaluated this in 2016 and decided the cost-benefit didn't justify it (JIT adds binary size + cold-start cost). Hasn't been ported.
为什么 10k 规则也不慢
why 10k rules still aren't slow
Ch12 讲了"给定一条规则,如何匹一个元素"。但一个页面可能有10 000 条规则(典型 SPA),每帧给 5000 个元素跑 cascade 时,如果对每个元素 × 每条规则 都跑 SelectorChecker,那是 5 000 万次匹配。现实是 Blink 每帧只跑几千次 SelectorChecker——10 000× 的差距来自 RuleSet 索引 + Bloom filter。
Ch12 explained "given a rule, match it against an element". But a real page often has 10 000 rules (typical SPA), and each frame must cascade ~5000 elements — naively, element × rule = 50 million matches. Reality: Blink runs only a few thousand SelectorChecker invocations per frame — a 10 000× pruning win, courtesy of RuleSet indices + Bloom filter.
// core/css/rule_set.h · simplified class RuleSet { // Layer 1: hash maps keyed by the rightmost simple selector HashMap<AtomicString, RuleData[]> id_rules_; // "#main" → [rules] HashMap<AtomicString, RuleData[]> class_rules_; // "card" → [rules] HashMap<AtomicString, RuleData[]> tag_rules_; // "p" → [rules] HashMap<String, RuleData[]> attr_rules_; RuleData[] universal_rules_; // "*" rules — checked for everyone // Layer 2: per-rule ancestor bloom filter (32 bits) // For each rule, hash all its compound ancestors into 32 bits. // At match time, hash the element's ancestor chain too, AND them. // If zero → ancestor cannot match → skip rule. }; // MATCHING AN ELEMENT (simplified) RuleData[] candidates; for (String cls : element.classList()) candidates += rule_set.class_rules_.get(cls); for (String attr : element.attributeNames()) candidates += rule_set.attr_rules_.get(attr); candidates += rule_set.tag_rules_.get(element.tagName()); candidates += rule_set.id_rules_.get(element.id()); candidates += rule_set.universal_rules_; for (RuleData rule : candidates) { if (!BloomFilterAcceptsAncestors(rule, element)) continue; // fast prune if (SelectorChecker::Match(rule.selector, element)) // real check (Ch12) apply(rule); }
Blink 的 ancestor bloom 是一个每个元素一份的 32-bit 位图。元素的每个祖先(class / id / tag)按 4 个哈希函数定 4 个比特。匹配规则时,把规则的"祖先需求"(也是 32-bit bloom)和元素的 bloom AND 一下: 如果规则要的比特 ⊄ 元素有的比特,规则不可能匹——立刻跳过。
Blink's ancestor bloom is a 32-bit bitmap per element. Every ancestor (class / id / tag) hashes through 4 hash functions, setting 4 bits. To match a rule, AND the element's bloom against the rule's "ancestor requirement" bloom: if the rule's required bits aren't all in the element's bits, the rule cannot match — skip immediately.
body + .main + article)被哈希成 32-bit 位图(上)。规则".main article .btn"的祖先需求也是 32-bit 位图(下)。如果需求不是元素的子集,规则不可能匹——立刻跳过。Bloom 不会假阴(false negative),但有低概率假阳——所以 bloom 通过的规则仍要跑 SelectorChecker 最终确认。这种"廉价预筛 → 精确复核"的两层结构让 CSS 引擎能扛 10 000 条规则的页面。
Fig 13·1 · Bloom filter decides which rules even warrant a SelectorChecker call. The element's ancestor chain (body + .main + article) hashes into a 32-bit bitmap (top). The rule's required ancestor bloom for ".main article .btn" is also 32-bit (bottom). If the required bits aren't a subset of the element's bits, the rule cannot match — skipped. Bloom has no false negatives but a low rate of false positives — passing rules still run SelectorChecker for the final verdict. This "cheap pre-filter → exact recheck" structure lets the CSS engine survive pages with 10 000 rules.
selectors-4 · 2023 ship · 反向选择器
selectors-4 · 2023 ship · backward selectors
2023 年 :has() 在所有浏览器同时 ship 是 CSS 30 年里最重要的能力跃迁。它给选择器加了一个之前不存在的语义维度: 从子树往上看。:is() / :where() 是与之配套的错误恢复升级。
When :has() shipped in every browser in 2023, it was CSS's biggest capability leap in 30 years. It gave selectors a semantic dimension that didn't exist before: looking upward from a subtree. :is() / :where() are the matching error-recovery upgrades.
| # | 伪类 | pseudo | 语义 | semantics | specificity |
|---|---|---|---|---|---|
| ① | :is(a, b, c) | 匹配 a 或 b 或 cmatches a OR b OR c | 取列表里最高的takes the max in the list | ||
| ② | :where(a, b, c) | 同 :is, 但 specificity = 0like :is, but specificity = 0 | 总是 0,极佳 resetalways 0 — perfect for resets | ||
| ③ | :not(a, b, c) | 不匹配 a / b / cdoes not match any | 取列表里最高takes the max | ||
| ④ | :has(...) | ⭐ 包含匹配 ... 的子树⭐ contains a subtree matching ... | 取参数里最高takes the max in args |
/* :has() examples — all impossible before 2023 in pure CSS */ form:has(input:invalid) { border-color: red; } /* form 有非法 input → 给 form 红边 */ ul:has(> li.active) { padding: 1rem; } /* ul 有 active 子 li → padding */ article:has(h2 + p) { font-size: 1.1rem; } /* article 内有 h2+p 序列 */ /* The "upward" semantics. :has() lets selectors look down a subtree. */ /* This wipes out an entire category of JS use cases. */
:has() 把这个假设打破了,需要在 DOM 改变时双向追溯。② 循环引用——a:has(b) 和 b:has(a) 互相依赖会无限递归。Blink 在 2023 用新的 invalidation 模型(Ch20 详谈)+ 嵌套深度限制(:has() 内不能再 :has())解决了这两个问题。早 20 年的硬件不可能跑得起。
A "parent selector" was proposed for CSS2 and killed. Two reasons: ① perf — classic right-to-left matching assumes "the selector terminates at the current element"; :has() breaks that assumption and requires bi-directional invalidation when the DOM changes. ② circular references — a:has(b) + b:has(a) would loop. Blink solved both in 2023 with a new invalidation model (Ch20 details) and nesting depth limits (no :has() inside :has()). The hardware 20 years ago couldn't have handled it.
| engine | 数据结构 | data structure | 特点 | notable |
|---|---|---|---|---|
| Blink (Chromium) | CascadePriority (64-bit packed) | 最快;一次整数比较决出所有 tie-breakfastest; one integer compare decides all tie-breaks | ||
| WebKit (Safari) | PropertyCascade::Builder | 分桶 + vector of (priority, property);较 Blink 慢 ~30%bucketed + vector of (priority, property); ~30% slower than Blink | ||
| Gecko (Firefox · Stylo) | ServoStyleContext (Rust) | 从 Servo 移植;并行 cascade:rayon thread pool 上跑多元素ported from Servo; parallel cascade: rayon thread pool across elements |
"谁的属性值最终生效"
"whose property value wins"
SelectorChecker(Ch12)告诉你"这条规则匹中了"。但当 10 条规则同时匹中、且都声明了 color 时,哪条赢? 这就是 cascade 的工作: 跑一个 8 步的优先级算法,每个属性独立选出一个 winner。
SelectorChecker (Ch12) tells you "this rule matched". But when 10 rules all match and all declare color, which wins? Cascade's job: run an 8-step priority algorithm, per property, to pick a single winner.
!important 把整套 origin 顺序反转——UA !important > user !important > author !important > author 普通 > user 普通 > UA 普通。这就是为什么 reset.css 用 !important 仍可能被站点 author 的 !important 覆盖——同一 origin 的话回退到 ⑤ specificity 比较。
Fig 15·1 · The 8-step cascade pyramid. Walk top-to-bottom; the first tier that resolves the tie wins. Step ① "Origin & Importance" is the most important and most counterintuitive: !important reverses the entire origin order — UA !important > user !important > author !important > author normal > user normal > UA normal. This is why reset.css using !important can still be overridden by a site-author's !important — same origin sends you back to step ⑤ specificity.
// third_party/blink/renderer/core/css/resolver/cascade_priority.h · simplified // CascadePriority is a 64-bit struct that packs all the tie-breakers from // FIG 15·1 into one comparable integer. // Higher value = wins. struct CascadePriority { // Bits laid out big-endian to enable lexicographic compare via <: uint64_t important : 1; // !important reverses origin order uint64_t origin : 3; // UserAgent / User / Author / Animation / Transition uint64_t tree_order : 16; // shadow-tree depth · author scope uint64_t layer_order : 16; // @layer ordering · unlayered = max uint64_t specificity : 24; // (a,b,c) packed as 8+8+8 bits uint64_t position : 4; // order of appearance (fallback) bool operator<(const CascadePriority& o) const { return AsBits() < o.AsBits(); // single 64-bit compare } }; // StyleCascade::Apply() · simplified main loop void StyleCascade::Apply(CascadeFilter filter) { CascadeMap & map = matched_properties_; // declaration set per property for (CSSPropertyID id : map.Properties()) { CascadePriority best = CascadePriority::Min(); CSSPropertyValue winner; for (auto& entry : map.FindRevert(id)) { if (entry.priority > best) { best = entry.priority; winner = entry.value; // single argmax per property } } state_->StyleBuilder().SetProperty(id, winner); } }
< 比较就能决出胜负。这是性能关键: 一个页面跑 cascade 时,每个属性可能要比 5-10 个候选,每次 priority 比较就是一条 CPU 指令。没有这种压缩,cascade 会跑慢一个量级。
The spec describes cascade as an "8-step pyramid"; the implementation packs every tie-breaker into one 64-bit integer · a single < decides. This is critical for perf: each property may compare 5-10 candidates per cascade, and each priority compare is one CPU instruction. Without this packing, cascade would run an order of magnitude slower.
不是 (a, b, c, d),不是 256 进制
not (a, b, c, d), not base-256
特异性(specificity)的算法在网上有无数错版本。常见错误: ① 说是 "(a, b, c, d)" 元组(其实 selectors-3 起就是 (a, b, c) 三元组,inline style 是另一个 tier);② 说每位是 256 进制(不是,只是按位元组比较);③ 把 !important 算在 specificity 里(不对——!important 在 cascade step ①,Ch15 详)。
Specificity is the most-misexplained algorithm on the web. Common errors: ① calling it "(a, b, c, d)" tuple (actually since selectors-3 it's a (a, b, c) triple — inline style is a separate cascade tier); ② claiming each digit is base-256 (nope, just component-wise tuple comparison); ③ putting !important in specificity (wrong — !important lives in cascade step ① per Ch15).
// selectors-4 §16 · Specificity is a (a, b, c) triple: // a = count of ID selectors in the selector // b = count of class selectors + attribute selectors + pseudo-classes // c = count of type selectors + pseudo-elements // Comparison: lexicographic — compare a first, then b, then c. // Universal "*", combinators ">", "+", "~", " " all count 0. .card → (0, 1, 0) .card:hover → (0, 2, 0) ← :hover is a pseudo-class, counts in b div.card → (0, 1, 1) #main .card → (1, 1, 0) ← #id is one a, .class is one b .a .b .c .d .e .f .g .h .i .j .k → (0, 11, 0) ← NOT (0, 1, 1) — no overflow! ::before → (0, 0, 1) ← ::pseudo-element counts in c, NOT b // Tie-break in cascade step ⑤ (Ch15): (1,0,0) > (0,99,99) because a is compared first.
| # | 说法 | claim | 实际 | truth |
|---|---|---|---|---|
| ① | ":hover 不算 specificity"":hover doesn't count" | 伪类算 b,伪元素(::)算 c。:hover 是伪类pseudo-classes count in b; pseudo-elements (::) count in c. :hover is a pseudo-class | ||
| ② | "11 个 class = 1 个 id""11 classes = 1 id" | 不进位。(0, 11, 0) < (1, 0, 0) 因为 lexicographic 先比 aNo carry. (0, 11, 0) < (1, 0, 0) because lexicographic compares a first | ||
| ③ | ":is() 把 specificity 加 1"":is() adds 1 to specificity" | 不,:is(a, b) 等于列表里最高的。:where() 总是 0No — :is(a, b) equals the max in the list. :where() is always 0 |
(1,0,0) 永远 > (0,99,99) — lex 比较先看 a。"11 个 class = 1 个 id" 是错误都市传说,实际(0,11,0) < (1,0,0)。
Fig 16·1 · The actual specificity comparison. 8 selectors stacked by (a,b,c) tuple; green rows are tuples used by The Card. Key: (1,0,0) always beats (0,99,99) — lex compare hits a first. "11 classes = 1 id" is an urban legend; in reality (0,11,0) < (1,0,0).
// core/css/css_selector.cc · simplified GetSpecificity walk unsigned CSSSelector::Specificity() const { unsigned a = 0, b = 0, c = 0; // Walk the entire compound chain (head → tail via tag_history_) for (const CSSSelector* sel = this; sel; sel = sel->TagHistory()) { switch (sel->Match()) { case kId: ++a; break; case kClass: case kAttributeExact: case kAttributeSet: case kAttributeHyphen: case kAttributeList: case kAttributeContain: case kAttributeBegin: case kAttributeEnd: ++b; break; case kPseudoClass: // :is() / :where() / :has() — pick MAX inside switch (sel->GetPseudoType()) { case kPseudoIs: case kPseudoHas: case kPseudoNot: { unsigned max_a = 0, max_b = 0, max_c = 0; for (const CSSSelector* sub : sel->SelectorList()) { auto [sa, sb, sc] = sub->DecomposeSpecificity(); if (SpecificityTuple{sa, sb, sc} > SpecificityTuple{max_a, max_b, max_c}) { max_a = sa; max_b = sb; max_c = sc; } } a += max_a; b += max_b; c += max_c; break; } case kPseudoWhere: // :where() adds 0! break; default: ++b; // :hover, :focus, :checked etc — count as class } break; case kTag: if (sel->TagQName().LocalName() != g_star_atom) ++c; // "*" counts 0 break; case kPseudoElement: ++c; break; // ::before / ::after — pseudo-element counts in c } } // PACK into 32-bit: 8 bits a (cap 255) | 8 bits b | 16 bits c return (a << 24) | (b << 16) | c; }
#a#a#a#a...#a 会让 a 字段溢出,在 Blink 里实际是 saturating(留在 255)。Firefox 也类似。所以理论上 256 个 id 不等于 1 个 wraparound,实际就是 255 个 id 的上限。没人写过这种代码,但 spec 允许 — 算 Blink 的一个 1-byte 假设。
Blink reserves 8 bits for a (255 ids), 8 bits for b (255 classes), 16 bits for c (65535 tags). In normal CSS you can't hit them, but at the edge: a hand-written 256-id selector #a#a#a#a...#a would overflow a — Blink saturates at 255. Firefox is similar. So 256 ids don't wrap; they cap. No one writes this, but the spec allows it — counted as Blink's 1-byte assumption.
2022 ship · 把 specificity 装进盒子
2022 ship · specificity in a box
@layer 是 2022 年 ship 的结构化 cascade。它在 Ch15 cascade 第 ④ 步——比 specificity 高一级。意思: 不同 layer 里的规则不用比 specificity,layer 顺序就分了胜负。
@layer shipped in 2022 as structured cascade. It sits at Ch15's cascade step ④ — one tier above specificity. Meaning: rules in different layers don't bother comparing specificity; layer order already decided who wins.
/* Layers are declared, then ordered. */ @layer reset, base, theme, utilities; /* declares order — first = lowest priority */ @layer reset { .btn#submit { background: gray; } /* (1,1,0) — high specificity, but in low layer */ } @layer utilities { .btn { background: blue; } /* (0,1,0) — low specificity, but in HIGH layer */ } /* Result: button is BLUE. Even though .btn#submit has higher specificity (1,1,0) vs (0,1,0), @layer "utilities" wins over @layer "reset" by step ④ before step ⑤ even runs. */
.btn(specificity 0,1,0),你想覆盖只能 .my-app .btn(0,2,0);某天某人又加 .my-app .container .btn(0,3,0);最后大家都到 #root .a .b .c 才能赢。@layer 把 specificity 装进盒子里: 库放 layer "vendor",你放 layer "app",app 在后,你用任何 specificity 都赢。再也不用堆 class 也不用 !important。
The biggest styling pain in 2010s large sites was the "specificity arms race": library A uses .btn (specificity 0,1,0); to override you need .my-app .btn (0,2,0); someone adds .my-app .container .btn (0,3,0); eventually everyone reaches #root .a .b .c to win. @layer puts specificity in a box: library lives in layer "vendor", you live in layer "app", app comes after — any specificity wins. No more class stacking, no more !important.
// core/css/css_variable_data.cc · simplified var() resolution bool CSSVariableData::ResolveTokenRange( const StyleResolverState& state, CSSParserTokenRange range, Vector<CSSParserToken>& out_tokens, HashSet<AtomicString>& visiting) { // ← cycle-detection set while (!range.AtEnd()) { const CSSParserToken& token = range.Peek(); if (token.FunctionId() == kVarFunction) { range.ConsumeIncludingWhitespace(); // eat "var(" AtomicString var_name = range.ConsumeIdent(); // ① cycle detect if (!visiting.insert(var_name).is_new_entry) { // already visiting this variable → cycle! return false; } // ② resolve recursively CSSVariableData* var_data = state.CustomProperty(var_name); Vector<CSSParserToken> substituted; bool ok = false; if (var_data) { ok = ResolveTokenRange(state, var_data->TokenRange(), substituted, visiting); } // ③ pop from visit set (DFS unwind) visiting.erase(var_name); if (ok) { out_tokens.AppendVector(substituted); } else { // ④ failed — try fallback (var() second arg) if (range.Peek().GetType() == kCommaToken) { range.ConsumeIncludingWhitespace(); if (!ResolveTokenRange(state, range, out_tokens, visiting)) return false; } else { return false; // no fallback → guaranteed-invalid } } range.ConsumeIncludingWhitespace(); // eat ")" } else { out_tokens.push_back(range.Consume()); } } return true; }
// core/css/cascade_layer.h · simplified class CascadeLayer { String name_; // "base" / "theme" / "" (anonymous) uint32_t order_; // resolved order index — lower = earlier = lower priority HeapVector<Member<CascadeLayer>> sublayers_; // @layer theme { @layer dark { ... } } }; class LayerMap { HashMap<String, Member<CascadeLayer>> by_name_; HeapVector<Member<CascadeLayer>> in_order_; // determined by declaration order Member<CascadeLayer> implicit_unlayered_; // the "naked" bucket · always highest }; // When parser encounters @layer reset, base, theme; it CREATES (or finds) these layers // in the order they appear. Subsequent @layer reset { ... } blocks reopen the same layer. void LayerMap::DeclareOrder(const Vector<String>& names) { for (size_t i = 0; i < names.size(); ++i) { CascadeLayer* layer = EnsureLayer(names[i]); layer->order_ = i; // "reset" gets 0, "base" gets 1, "theme" gets 2 } // implicit_unlayered_->order_ = std::numeric_limits<uint32_t>::max() // (so it always wins layer comparison) } // Cascade step ④ — comparing two declarations' layers: bool LayerWins(const CascadeLayer* a, const CascadeLayer* b) { return a->order_ > b->order_; }
@layer framework { @layer base, components, utilities } — 嵌套 layer。Blink 把它实现为分层 order: parent.order * 1000 + child.order(或类似编码),确保整族的优先级跟其它顶层 layer 不交叉。这就是为什么 "嵌套 layer 永远在同名父 layer 内部排序,不会跨家族干扰"。
You can write @layer framework { @layer base, components, utilities } — nested layers. Blink implements this as hierarchical order: parent.order * 1000 + child.order (or similar encoding), ensuring the whole family's priorities don't cross other top-level layers. This is why "nested layers always sort within their parent layer family, never across families".
CSSVariableData · 延迟解析 · 循环检测
CSSVariableData · lazy resolution · cycle detection
--brand: oklch(70% 0.15 250) 在声明时不会被解析。Blink 把整段 oklch(...) 字符串塞进 CSSVariableData 对象,挂在 :root 的 ComputedStyle 上。等到某个元素用 background: var(--brand) 时,Blink 才查 --brand 的值、展开到 oklch(...)、再 parse成 sRGB。
--brand: oklch(70% 0.15 250) isn't resolved at declaration time. Blink stuffs the whole oklch(...) string into a CSSVariableData object hanging off :root's ComputedStyle. When an element uses background: var(--brand), Blink looks up --brand, substitutes the oklch(...), then parses it as sRGB.
// var() resolution — simplified :root { --brand: oklch(70% 0.15 250); } /* string stored verbatim */ .card { background: var(--brand); } /* "lookup + substitute" needed */ .card.dark { --brand: oklch(40% 0.15 250); } /* overrides at .card.dark — Blink walks up inheritance */ /* RESOLUTION ORDER for .card.dark element: */ /* 1. Look up --brand on .card.dark's ComputedStyle → "oklch(40% 0.15 250)" */ /* 2. Substitute into background → "oklch(40% 0.15 250)" */ /* 3. Parse as CSS color → sRGB / OKLCH */ /* 4. Store as actual ComputedStyle.background */
:root { --a: var(--b); --b: var(--a); /* ✗ cycle → CSS spec: treat both as guaranteed-invalid */ } .card { background: var(--a); /* result: initial value (transparent) */ background: var(--a, red); /* fallback! var() takes default arg */ }
var(--brand) 解析时,Blink不把 "解析过的 oklch 颜色对象"塞进 background;它把原始 token 流(oklch ( 70% 0.15 250 ))原文拷贝到使用处,然后在使用处重新 parse。这意味着 --brand: 70% 可以这样用:
The most counterintuitive bit of custom property design. When var(--brand) resolves, Blink doesn't stuff a "parsed oklch color object" into background; it copies the raw token stream (oklch ( 70% 0.15 250 )) verbatim into the use site, then re-parses there. Which means --brand: 70% can be used as:
:root { --w: 70%; } .a { width: var(--w); } /* width: 70% ✓ */ .b { background: hsl(var(--w), 50%, 50%); } /* hsl(70%, 50%, 50%) — invalid! 70% isn't a hue */同一个
--w 可以用在 width(合法)和 hsl 第一个参数(非法,因为 hue 必须是 number 或 angle 不是 %)。token 替换的灵活性带来这种位置敏感错误——Blink 在使用处 parse 时报错并 fallback 到 invalid。
The same --w works in width (valid) and fails in hsl's first arg (invalid — hue must be number or angle, not %). Token substitution's flexibility brings site-sensitive errors — Blink parses at use site and falls back to invalid.
var(--a, red) under a cycle becomes red.
每个属性有四个不同状态的值
every property has four stages of value
"width: 50%" 在 CSS 内部其实有四个不同的值——分别对应"用户写的"、"cascade 选出来的"、"跟 layout 配合算的"、"真正应用的"。理解这四种是理解所有"为什么 getComputedStyle 返回 px 不是 %"类问题的钥匙。
"width: 50%" actually has four different values inside CSS — corresponding to "what the user wrote", "what cascade picked", "what layout produced", and "what actually applies". Understanding these four is the key to every "why does getComputedStyle return px not %" question.
| # | 值 | value | 阶段 | stage | 例 (width: 50%, parent 200px) | example (width: 50%, parent 200px) |
|---|---|---|---|---|---|---|
| ① | specifiedspecified | cascade 结束 · 还没解析cascade done · unresolved | "50%" | |||
| ② | computedcomputed | var() 解开 · 单位 normalize 但 % 保留var() resolved · units normalised · % kept | "50%" (still) | |||
| ③ | usedused | layout 完成后 · % 解成 pxafter layout · % resolved to px | "100px" | |||
| ④ | actualactual | device pixel snap 后after device-pixel snap | "100px" (or 99.5 on @2x) |
width: 50% 的 getComputedStyle(el).width 给的是 "100px",不是 "50%"。这是历史命名包袱(API 1996 年定的,used value 概念 2004 年才进 spec),W3C 现在已经写明这一点。真正的 computed value 要走 Houdini Typed OM(el.computedStyleMap().get('width')),它会返回 CSSUnitValue{value: 50, unit: '%'}。
Despite its name, the "computed" Style API actually returns used value. So getComputedStyle(el).width on width: 50% gives "100px", not "50%". This is historical baggage — API named in 1996, used-value concept entered spec in 2004 — and W3C now documents the discrepancy. To get the true computed value, use Houdini Typed OM (el.computedStyleMap().get('width')) which returns CSSUnitValue{value: 50, unit: '%'}.
width: 50% 在 CSS 引擎里走过四个不同的值。最常被搞错的: getComputedStyle() 名字叫 "computed" 但返回的是 used(layout 后的 px 值);要真 computed 必须走 Houdini 的 computedStyleMap()。这就是 Ch10 警告 getComputedStyle() 会同步触发 layout 的根本原因——它读的是 layout 之后才存在的 used value。
Fig 19·1 · The same width: 50% passes through four distinct values in the engine. Most-misunderstood: getComputedStyle() is named "computed" but returns used (post-layout px). For true computed, use Houdini's computedStyleMap(). This is the root cause of Ch10's warning that getComputedStyle() synchronously triggers layout — it reads the used value which doesn't exist until layout finishes.
从 O(N · rules) 到 O(dirty)
from O(N · rules) to O(dirty)
假设你在一个有 10 000 个元素 + 1000 条规则的页面上,JS 给某个 div 加了 class "card"。朴素方案: 每个元素 × 每条规则跑一遍 selector match——10 000 000 次 SelectorChecker,显然不行。Blink 的解法叫 InvalidationSet: 在编译规则的时候就记下"如果这个 class / id / attribute 改了,会影响哪些祖先 / 兄弟 / 后代"。改 class 时,只查这张预算好的表,标真的需要重算的节点 dirty。
Imagine a page with 10 000 elements + 1000 rules. JS adds class "card" to one div. The naive approach: rerun selector match for every element × every rule — 10 million SelectorChecker calls. Obviously not viable. Blink's answer: InvalidationSet. At rule-compile time, precompute "if this class / id / attribute changes, which ancestors / siblings / descendants are affected". When a class flips, consult that precomputed table and mark only the truly-affected nodes dirty.
// core/css/invalidation/invalidation_set.h · simplified class InvalidationSet { InvalidationType type_; // Descendant | Sibling | Nth | SubtreeIfHover ... HashSet<AtomicString> classes_; // class names whose descendant must recalc HashSet<AtomicString> ids_; HashSet<AtomicString> tag_names_; HashSet<AtomicString> attributes_; bool whole_subtree_invalid_; // ← worst case: nuke subtree (e.g. with [attr~=*]) }; // RuleFeatureSet — built once when stylesheet enters CSSOM. // Keys are "features" (class / id / attr / pseudo). Values are InvalidationSets. class RuleFeatureSet { HashMap<AtomicString, Member<InvalidationSet>> class_invalidation_sets_; HashMap<AtomicString, Member<InvalidationSet>> id_invalidation_sets_; HashMap<AtomicString, Member<InvalidationSet>> attribute_invalidation_sets_; ... };
.card .btn" 的 InvalidationSet.card .btn"/* Source rule: */ .card .btn { color: red; } /* When this rule enters CSSOM, Blink builds two InvalidationSet entries: */ // "card" → InvalidationSet { type: Descendant, classes: ["btn"] } // "btn" → InvalidationSet { type: Descendant, classes: [] } // empty — leaf already known /* MEANING: */ /* - When element.classList toggles "card" → walk descendants, find .btn, */ /* mark them dirty. */ /* - When element.classList toggles "btn" → mark only THIS element dirty, */ /* no descendant walk needed. */ /* - Siblings, ancestors, unrelated subtrees: untouched. */
:has() 让 InvalidationSet 必须新增一个反向维度: 子节点变了,父节点可能要重算。li:has(form:invalid) 在某个 input 变 invalid 时,要 walk up 到最近的 li 然后下来找其他 form 子树。Blink 用 SiblingInvalidationSet + NthInvalidationSet 这种定向反向的 InvalidationSet 处理。代价: 启用 :has() 的页面 invalidation cost 大约 + 30-100%。但比"整页 recalc"还是便宜几个数量级。
2023's :has() forced an entirely new backward dimension: when a child changes, the parent may need recalc. li:has(form:invalid) on an input becoming invalid walks up to the nearest li, then back down to other form subtrees. Blink uses SiblingInvalidationSet + NthInvalidationSet for these targeted backward InvalidationSets. Cost: pages using :has() see ~30-100% more invalidation work. Still orders of magnitude cheaper than full-page recalc.
class="container",Blink 查 InvalidationSet["container"] 拿到 {classes: ["card", "btn"]},然后仅给后代里命中 .card / .btn 的标 dirty——其他 5000 个无关元素零代价。这就是为什么 Blink 改 1 个 class 不会触发全页重算。
Fig 20·1 · InvalidationSet translates "someone changed the DOM" into "which nodes are dirty". When a parent gains class="container", Blink queries InvalidationSet["container"] and gets {classes: ["card", "btn"]}, then only dirties descendants matching .card / .btn — the other 5000 unrelated elements pay zero. This is why one class change doesn't recalc the whole page.
// core/css/invalidation/style_invalidator.cc · ~simplified void StyleInvalidator::InvalidateForClassChange( Element& element, const AtomicString& changed_class) { // ① Look up which InvalidationSet was precomputed for this class name const InvalidationSet* set = rule_feature_set_->class_invalidation_sets_.at(changed_class); if (!set) return; // no rule uses ".foo" in any cascading way // ② Always dirty THIS element if class name matters at all element.SetNeedsStyleRecalc(kLocalStyleChange); // ③ Walk descendants if the invalidation set says "descendant" if (set->type() == InvalidationType::kInvalidateDescendants) { for (Element& descendant : ElementTraversal::DescendantsOf(element)) { if (set->InvalidatesElement(descendant)) { descendant.SetNeedsStyleRecalc(kLocalStyleChange); } } } // ④ Sibling invalidation — only walk the affected siblings if (set->type() == InvalidationType::kInvalidateSiblings) { Element* sibling = ElementTraversal::NextSibling(element); for (; sibling; sibling = ElementTraversal::NextSibling(*sibling)) { if (set->InvalidatesElement(*sibling)) { sibling->SetNeedsStyleRecalc(kLocalStyleChange); } if (!set->invalidates_subsequent_siblings()) break; // "+" stops here } } // ⑤ :has() — backward walk, see Ch14 if (const auto& has_set = set->SiblingInvalidationSet()) { InvalidateHasAncestor(element, has_set); } }
空格 · > · + · ~ · :has()
whitespace · > · + · ~ · :has()
选择器里的组合器决定 InvalidationSet 的类型。这是为什么 .card + .btn 比 .card .btn 在 invalidation 上更贵——后者只往后代看,前者还要往兄弟看。
A selector's combinator determines the InvalidationSet's type. This is why .card + .btn is more expensive to invalidate than .card .btn — the latter only inspects descendants, the former also inspects siblings.
| # | 组合器 | combinator | InvalidationSet 类型 | InvalidationSet type | walk 方向 / 成本 | walk direction / cost |
|---|---|---|---|---|---|---|
| ① | A B | descendant | A 的全部后代 · O(subtree)all descendants of A · O(subtree) | |||
| ② | A > B | descendant (depth 1) | A 的直接子节点 · O(children)A's direct children · O(children) | |||
| ③ | A + B | sibling (next) | A 的下一个兄弟 · O(1)A's next sibling · O(1) | |||
| ④ | A ~ B | sibling (subsequent) | A 之后的所有兄弟 · O(siblings)all siblings after A · O(siblings) | |||
| ⑤ | A:has(B) | backward (subtree) | B 变 → 走 up 找 A · O(ancestors · subtree)B changes → walk up to find A · O(ancestors · subtree) |
+(next-sibling)只看一个节点; ~(subsequent-sibling)要看所有后续兄弟。在循环渲染的列表里(li ~ li 这种),~ 的 invalidation cost 随列表长度线性增长——给一个 li 改 class 可能触发 1000 个兄弟重算。实战: 如果你需要"给所有后续兄弟加样式",优先考虑用 CSS counters 或 nth-child,它们的 InvalidationSet 更紧。
+ (next-sibling) only inspects one node; ~ (subsequent-sibling) inspects every following sibling. In a long rendered list (li ~ li patterns), ~'s invalidation cost grows linearly with list length — flipping one li's class can dirty 1000 siblings. Practical tip: if you need "style all subsequent siblings", prefer CSS counters or nth-child — their InvalidationSets are tighter.
>)只走直接子;next-sibling(+)只看 1 个;subsequent-sibling(~)走所有后续兄弟 — 长列表里这是个炸弹;:has()是反向,先 walk up 找祖先,再 down 找其他匹配子树。invalidation cost 几何形状一目了然。
Fig 21·1 · 5 combinators map to 5 walk shapes. descendant (whitespace) traverses the whole subtree; child (>) only direct children; next-sibling (+) checks 1; subsequent-sibling (~) walks all later siblings — a bomb in long lists; :has() is backward, walking up to ancestors then down for other matching subtrees. Geometric cost is visible at a glance.
DOM 改动 · 伪类切换 · viewport 变化
DOM mutations · pseudo-class flips · viewport changes
什么操作会让 Blink 重跑 cascade?这是页面性能优化里被最频繁问错的问题。看下面这张表:
What actions cause Blink to rerun cascade? This is the most-frequently-asked-wrong question in page perf optimisation. The table below:
| # | 操作 | action | 触发 recalc | triggers recalc | 范围 | scope |
|---|---|---|---|---|---|---|
| ① | el.classList.add('x') | 是 | 查 x 的 InvalidationSet 决定consult InvalidationSet for x | |||
| ② | el.id = 'main' | 是 | 查 #main 的 InvalidationSetconsult InvalidationSet for #main | |||
| ③ | el.setAttribute('data-x', '1') | 是 | 仅当某 rule 用了 [data-x]only if some rule uses [data-x] | |||
| ④ | el.style.padding = '1rem' | 是 | 只该元素just this element | |||
| ⑤ | 鼠标 hovermouse hover | 是 | 查 :hover InvalidationSetconsult :hover InvalidationSet | |||
| ⑥ | resize · viewport 变化resize · viewport change | 仅当用到 vw/vh/dvh、@media、@containeronly with vw/vh/dvh, @media, @container | 受影响的子树affected subtree | |||
| ⑦ | document.body.appendChild(el) | 是 | 新元素 + sibling 范围new element + sibling range | |||
| ⑧ | 改 document.titlechange document.title | 否 | — | |||
| ⑨ | 改 scrollTopchange scrollTop | 否(除非有 scroll-driven anim · Ch25)no (unless scroll-driven anim · Ch25) | — | |||
| ⑩ | 改 :root { --x: ... }change :root { --x: ... } | 是 | ⚠️ 所有用到 var(--x) 的元素⚠️ every element using var(--x) |
:root { --bg: black } 然后所有元素用 background: var(--bg)——切换瞬间整页 5000 个元素全部重算。缓解: 只让有限的几个"主题切换点"用 var(),具体颜色 fall-through 到那几个点;或者用 @property 注册 typed custom property,Blink 能更精细 invalidate(没用到的元素跳过)。
Implementing a dark-mode toggle as :root { --bg: black } with every element doing background: var(--bg) recalcs all 5000 elements the moment you flip. Mitigation: restrict var() to a handful of "theme switch points" and let concrete colors fall through; or register typed custom properties via @property so Blink can invalidate more selectively (skipping elements that don't read the property).
:root 上改 var()(波及所有用它的元素,典型 dark-mode toggle 噩梦)+ getComputedStyle()(同步触发 layout)。免费两条: document.title 改 + scroll(不触发 recalc,除非用 scroll-driven anim · Ch25)。
Fig 22·1 · 9 common actions and their recalc scope / cost. Two most expensive: changing var() on :root (cascades to every element using it — the classic dark-mode toggle nightmare) + getComputedStyle() (synchronously forces layout). Two free: document.title changes and scroll (no recalc, unless scroll-driven anim · Ch25).
KeyframeEffect · Animation · timeline
KeyframeEffect · Animation · timeline
CSS transition 和 animation 在 Blink 内部都被翻译成同一个数据模型: Web Animations API 的三件套——Animation(控制对象)、KeyframeEffect(关键帧序列)、AnimationTimeline(时间源)。transition 是 sugared form,animation 也是 sugared form——它们都被脱糖到 Web Animations 内核。
CSS transition and animation are both translated inside Blink into the same data model: the Web Animations API trio — Animation (controller), KeyframeEffect (frame sequence), and AnimationTimeline (time source). transition is sugar; animation is sugar; both desugar to the Web Animations kernel.
// core/animation/ · simplified class Animation { Member<AnimationEffect> effect_; // what to animate (one keyframe set) Member<AnimationTimeline> timeline_; // time source (DocumentTimeline / ScrollTimeline) PlayState play_state_; // idle | running | paused | finished Optional<double> start_time_; // when did it start (in timeline) double playback_rate_; // 1.0 = normal · -1.0 = reverse }; class KeyframeEffect : public AnimationEffect { Member<Element> target_; HeapVector<Keyframe> keyframes_; // e.g. [{ background: red }, { background: blue }] EffectTiming timing_; // duration, easing, delay, iteration count }; class DocumentTimeline : public AnimationTimeline { double CurrentTime() override { return doc_->RenderingTime(); } };
/* Source: */ .card { background: red; transition: background 200ms; } .card:hover { background: blue; } /* When .card transitions from no-hover to hover, Blink synthesises: */ const animation = new Animation( new KeyframeEffect( card_element, [ { background: 'red', offset: 0 }, { background: 'blue', offset: 1 } ], { duration: 200, easing: 'ease' } ), document.timeline ); animation.play(); // auto-cleanup when finished, identical to JS-created animations
element.getAnimations() 拿到所有正在跑的 animation,不管源头是 CSS 还是 JS。这是 Web Animations Level 2 的成功——把三块分裂功能装进一个 API。
CSS transition / CSS animation / WAAPI are three syntaxes sharing one implementation. Which means: ① perf parity — transition isn't faster or slower than WAAPI; ② DevTools "Animations" panel pauses / scrubs / edits all three the same way; ③ JS can element.getAnimations() to enumerate all running animations regardless of source. Web Animations Level 2's success — three fragmented features under one API.
transform · opacity · filter · ...
transform · opacity · filter · ...
Web 性能最常说的 "animate transform/opacity 而不是 left/top" 其实是 Compositor Offload 的简化版本。背后机制是: Blink 把 layer 树发给 Compositor 线程(不是 main thread),Compositor 在每帧自己跑 transform 和 opacity 插值,完全不用打扰 main thread。这就是为什么这两个属性能在 100% main-thread-busy 时仍能 60 fps 动画。
Web perf's classic advice "animate transform/opacity instead of left/top" is the dumbed-down version of Compositor Offload. The mechanism: Blink ships the layer tree to the Compositor thread (not main thread), and the Compositor interpolates transform and opacity each frame without ever bothering main. That's why these two properties can hit 60 fps even when main thread is at 100% busy.
| # | 属性 | property | 能 offload | offloadable | 为什么 | why |
|---|---|---|---|---|---|---|
| ① | transform | 是 | 仅是 4×4 矩阵 · GPU 顶点 shaderjust a 4×4 matrix · GPU vertex shader | |||
| ② | opacity | 是 | 逐像素 alpha · GPU fragment shaderper-pixel alpha · GPU fragment shader | |||
| ③ | filter | 是 | 大多 GPU shader · blur 慢但仍是 GPUmost GPU shaders · blur is slow but still GPU | |||
| ④ | background-color | 否 | ⚠️ 主线程 paint · 重新生成位图⚠️ main-thread paint · regenerates bitmap | |||
| ⑤ | color | 否 | text raster · 主线程text raster · main thread | |||
| ⑥ | left / top / width / height | 否 | 触发 layout · main thread + layout tree rebuildtriggers layout · main thread + layout tree rebuild | |||
| ⑦ | border-radius | 否 | paint · 改边界需要 rerasterpaint · changing radius requires re-raster |
/* ✗ TRIGGERS LAYOUT EVERY FRAME — main thread death spiral */ .bad { transition: left 300ms; } .bad.open { left: 200px; } /* ✓ COMPOSITOR-ONLY — 60 fps even under heavy main-thread load */ .good { transition: transform 300ms; } .good.open { transform: translateX(200px); }
will-change,动画完了去掉。
① Each offloaded element needs its own layer — ~50-200 KB GPU memory. On low-end Android, a few hundred offloads can OOM the GPU and force Chromium back to main-thread composite. ② "will-change: transform" isn't a free hint — it forces layer creation even when the element isn't animating. Overuse causes layer explosion (Ch29 perf model details). Rule: add will-change only when an animation is about to start, remove it when done.
transform / opacity / will-change 等的元素被promote 为独立 layer。GPU compositor 只对这些独立 layer 做动画 — 这就是为什么 transform: translateX 比 left 快几百倍。但 layer 不是 free: 每个 layer ~50-200 KB GPU 显存,几百个 layer 会撑爆低端 Android。
Fig 24·1 · Compositor sees not the DOM tree — it sees the Layer tree. Most elements share the ROOT layer (main thread paints into it); only elements using transform / opacity / will-change etc. get promoted to their own layer. GPU compositor animates only those independent layers — hence transform: translateX is hundreds of times faster than left. But layers aren't free: ~50-200 KB GPU memory each; hundreds can OOM low-end Android.
// core/paint/paint_layer.cc · simplified Layerizer decision CompositingReasons PaintLayer::DirectCompositingReasons() const { CompositingReasons reasons = CompositingReason::kNone; // ① TRANSFORM that animates or is 3D if (style_->HasTransform() && (style_->Has3DTransform() || HasActiveTransformAnimation())) { reasons |= CompositingReason::k3DTransform; } // ② OPACITY that animates if (HasActiveOpacityAnimation()) { reasons |= CompositingReason::kActiveOpacityAnimation; } // ③ FILTER (any filter forces a layer for offload) if (style_->HasNonInitialBackdropFilter() || HasActiveFilterAnimation()) { reasons |= CompositingReason::kBackdropFilter; } // ④ will-change hint — user said "this WILL animate" if (style_->HasWillChangeTransformHint() || style_->HasWillChangeOpacityHint()) { reasons |= CompositingReason::kWillChangeTransform; } // ⑤ position: fixed if scrolling matters if (style_->GetPosition() == EPosition::kFixed && DescendantsHaveScrollableAreas()) { reasons |= CompositingReason::kFixedPosition; } // ⑥ canvas / video / iframe / WebGL — always their own layer if (IsVideoElement() || IsCanvasElement() || IsIFrame()) { reasons |= CompositingReason::kVideo; } return reasons; } // "Layerizer" runs this for every PaintLayer and stitches together the layer tree. // A page with 5000 elements and 0 transforms/opacity/will-change typically has // JUST ONE compositor layer (the root). The Card adds ZERO additional layers // because its transition is on background (non-offloadable).
ScrollTimeline · ViewTimeline · animation-range
ScrollTimeline · ViewTimeline · animation-range
2024 之前,"滚动联动动画" 必须靠 JS 监听 scroll 事件然后改 transform——主线程消耗大,容易掉帧。CSS 把这种常用模式抽出来变成原生概念: scroll-timeline 让动画的时间源不再是 wall-clock 而是 scroll position,直接跑在 compositor 线程上。
Before 2024, "scroll-bound animations" required JS scroll listeners updating transform — heavy on the main thread, easily jank. CSS lifted this common pattern into a native concept: scroll-timeline swaps the animation's time source from wall-clock to scroll position, running entirely on the compositor.
/* Old way — JS scroll listener (jank-prone): */ window.addEventListener('scroll', () => { el.style.transform = `translateY(${window.scrollY * 0.5}px)`; }); /* New way — pure CSS, runs on compositor: */ .parallax { animation: parallax-move linear; animation-timeline: scroll(root block); } @keyframes parallax-move { from { transform: translateY(0); } to { transform: translateY(-200px); } } /* Or scoped to an element entering the viewport: */ .fade-in { animation: appear linear; animation-timeline: view(); animation-range: entry 0% cover 50%; /* play as it scrolls in */ }
transform: translateY(-50)(0% 起,100% 到 -200px 之间线性插值)。compositor 线程独自完成这一切 — JS scroll listener 那种"每帧一次主线程往返"的成本被消除。
Fig 25·1 · Time mapping of a scroll-driven animation. Viewport in a 3000px document scrolled to 500px → progress = 500/2000 = 25% → animation also runs to 25% → transform: translateY(-50) (lerp between 0 and -200px). The compositor thread does all of this — eliminating the "main-thread roundtrip per frame" cost of JS scroll listeners.
2022 ship · containment + size query
2022 ship · containment + size query
CSS 30 年来,响应式只能问 "viewport 多大"——但 web 组件的真实需求是问 "我所在的父容器多大"(同一个 Card 在 sidebar 里窄,在主区宽,该用不同布局)。@container 在 2022 解决了这个问题——但不是免费: 每个 container 必须先声明 containment(container-type: inline-size),Blink 才能在 layout 完成后再跑一次依赖该 container 大小的 cascade。
For 30 years CSS could only ask "how big is the viewport" — but web components actually need to ask "how big is my parent container" (same Card narrow in a sidebar, wide in main area, different layout each). @container answered this in 2022 — but it's not free: each container must declare containment (container-type: inline-size) first, so Blink can rerun cascade after layout for rules depending on that container's size.
/* Setup */ .container { container-type: inline-size; /* opt in · enables inline-size queries inside */ container-name: card-host; /* optional name · @container card-host (...) */ } /* Query */ @container card-host (min-width: 400px) { .card { flex-direction: row; } } @container (max-width: 399px) { .card { flex-direction: column; } }
container-type 的世界: container 大小取决于内容,内容大小取决于 @container 规则,@container 规则取决于 container 大小——圈了。container-type: inline-size 强制 container 在 inline 方向上的大小独立于子内容(类似 contain: inline-size),打破环。代价: container 自己不会因子内容长大。container-type: container size depends on content, content size depends on @container rules, @container rules depend on container size — cycle. container-type: inline-size forces the container's inline size to be independent of children (like contain: inline-size), breaking the cycle. Cost: the container can't auto-expand to fit content.// core/css/container_query.cc · simplified class ContainerQuery { MediaQueryExpNode* expression_; // "(min-width: 400px)" AtomicString container_name_; // optional named container }; bool ContainerQuery::Matches(const Element& element) const { // ① Walk UP the ancestor chain to find the nearest containing element const Element* container = element.parentElement(); while (container) { const ComputedStyle* style = container->EnsureComputedStyle(); if (style->ContainerType() == EContainerType::kNone) { container = container->parentElement(); continue; } // ② Check container-name match (or unnamed) if (!container_name_.IsEmpty() && !style->ContainerNames().Contains(container_name_)) { container = container->parentElement(); continue; } // ③ Found! Now check if size is queryable yet (layout must have run) if (!container->HasFinishedLayout()) { // Pass 1 of two-pass — defer evaluation style_engine_->MarkContainerForPostLayoutQuery(container, query: this); return false; // will re-evaluate in pass 2 } // ④ Evaluate the size query against container's layout box LayoutSize size = container->GetLayoutBox()->ContentBoxSize(); MediaQueryEvaluator eval(size, container->GetDocument()); return eval.Eval(*expression_); } // No matching container found — query fails silently return false; }
@container (min-width: 400px) { .card { padding: 50% } }。padding 50% 让 .card 撑爆容器,容器变大,padding 重算,内容变小,容器又缩 ... 无限循环?Blink 用containment(container-type: inline-size)切断: 容器的 inline-size 不能依赖子内容。这是为什么 container-type 是必须的—— 没它的话,@container 会真的无限循环。CSS WG 在 2022 ship 之前为这个问题讨论了 5 年。
Imagine a nasty case: @container (min-width: 400px) { .card { padding: 50% } }. 50% padding expands .card, container grows, padding recomputes, content shrinks, container shrinks ... infinite loop? Blink uses containment (container-type: inline-size) to cut it: container's inline-size cannot depend on children. That's why container-type is required — without it, @container would genuinely loop. The CSS WG debated this for 5 years before 2022 ship.
2023 ship · 终止 Sass 时代
2023 ship · ending the Sass era
2023 年 ship 的 CSS Nesting 解决了一个让 Sass / Less / Stylus 火了 15 年的痛点: 嵌套书写。@scope 进一步给了"限定作用域"的能力——一段样式只对某个子树生效,不溢出。这两个特性合起来让 Sass 类预处理器的必要性大幅下降。
CSS Nesting shipping in 2023 solved the pain that kept Sass / Less / Stylus alive for 15 years: nested syntax. @scope further added "scoped style block" — styles that apply only within a subtree, no leakage. Together these two cut the necessity of CSS preprocessors dramatically.
/* CSS Nesting · 2023 */ .card { padding: 1rem; & .title { /* & is the parent reference, like Sass */ font-weight: bold; } &:hover { /* & required even for pseudo-classes */ background: blue; } } /* @scope · 2023, scoped style block (avoids descendant leakage) */ @scope (.dialog) to (.dialog-footer) { .button { color: red; } /* only applies to .button INSIDE .dialog but NOT past .dialog-footer */ }
.card { div { ... } } 在 Sass 是合法的(展开成 .card div),但 CSS 不允许——因为 CSS parser 看到 div 会跟外层 .card 上下文混淆。必须写 .card { & div { ... } }。这是 CSS team 为了向前兼容做的折中。
CSS nesting's semantics are decided at parse time, but selectors still follow standard parsing rules. Bare-tag nesting is forbidden: .card { div { ... } } is valid Sass (expands to .card div), but illegal in CSS — the parser would confuse div as a property name in the outer .card context. Must write .card { & div { ... } }. A trade-off for forward compatibility.
ConsumeQualifiedRule 时展开嵌套的 & 引用,生成多条独立的 CSSStyleRule。CSSOM 看到的是扁平结构,Cascade(Ch15)对每条规则独立跑 — 它不知道 原 source 是嵌套的。这就是为什么嵌套不影响 specificity / cascade 行为,只影响书写体验。
Fig 27·1 · CSS Nesting is syntactic sugar — the parser in ConsumeQualifiedRule expands nested & references into multiple independent CSSStyleRules. CSSOM sees the flat structure; Cascade (Ch15) runs independently on each rule — it doesn't know the source was nested. That's why nesting doesn't affect specificity / cascade behavior, only authoring ergonomics.
三个把大量 JS 用法装回 CSS 的特性
three features taking JS use cases back into CSS
2024 年 CSS 同时 ship 了三个能力,各自杀掉一个 JS 库生态:
2024 was CSS's "kill the JS library" year — three features shipped, each replacing an entire JS ecosystem:
| # | 特性 | feature | 替代 | replaces | 机制 | mechanism |
|---|---|---|---|---|---|---|
| ① | anchor positioning | Popper.js / Floating UI | position-anchor: --foo + anchor() 函数position-anchor: --foo + anchor() function | |||
| ② | view-transitions | FLIP / GSAP | DOM 切换时浏览器自动渲染前后两帧并 cross-fadebrowser auto-renders before/after frames and cross-fades on DOM switch | |||
| ③ | color-mix(in oklch, ...) | chroma.js / Color | 原生色彩空间插值 · oklch / lab / display-p3native color-space interpolation · oklch / lab / display-p3 |
/* ① anchor positioning */ .button { anchor-name: --my-anchor; } .tooltip { position-anchor: --my-anchor; position-area: top; /* place tooltip above the anchor */ left: anchor(center); /* x = anchor's horizontal center */ } /* ② view-transitions · JS triggered, declarative styled */ ::view-transition-old(thumb) { animation: 300ms fade-out; } ::view-transition-new(thumb) { animation: 300ms fade-in; } /* JS side: document.startViewTransition(() => updateDom()) */ /* ③ color-mix · works for any blending */ .muted { color: color-mix(in oklch, var(--brand), white 40%); } .darker { background: color-mix(in srgb-linear, var(--brand), black 20%); }
anchor-name: --my-btn;另一个 absolute 定位的元素用 position-anchor: --my-btn 把另一个元素当参考系。position-area 给出 9 个标准方位(top / top-left / right / ... / bottom-right),tooltip 放进去就行。Popper.js 一类 1000+ 行的 JS 库变成 3 行 CSS。
Fig 28·2 · Anchor positioning geometry. One element declares anchor-name: --my-btn; an absolutely-positioned element uses position-anchor: --my-btn to make another element its reference frame. position-area gives 9 standard regions (top / top-left / right / ... / bottom-right). Popper.js et al. (1000+ lines of JS) reduce to 3 lines of CSS.
// JS triggers — browser captures BEFORE and AFTER frames document.startViewTransition(() => { updateDom(); // any mutation: replace nodes, change class, route navigation }); /* CSS controls the animation between BEFORE and AFTER */ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 300ms; animation-timing-function: ease-in-out; } ::view-transition-old(root) { animation-name: fade-out; } ::view-transition-new(root) { animation-name: fade-in; } /* Per-element transitions — capture each named region separately */ .thumbnail { view-transition-name: thumb; /* makes a "shared element" between old & new */ } ::view-transition-old(thumb), ::view-transition-new(thumb) { animation: 300ms ease morph; /* will lerp position+size automatically */ }
startViewTransition() 自动测 before + after,::view-transition-old/new 让你用 CSS 定义如何 lerp。1500 行 JS 库变成 10 行 CSS。
Since 2014, frontend has used "FLIP" (First / Last / Invert / Play): measure before position → mutate DOM → measure after position → apply transform to put element temporarily back → run transition to "arrive" at the new position. This is the core of all of GSAP / Framer Motion. View Transitions declarativises this: startViewTransition() auto-measures before+after, ::view-transition-old/new let you define how to lerp via CSS. 1500-line JS libs reduce to 10 lines of CSS.
9 个阶段的实测开销
measured cost per stage
读到这里你已经能看见 CSS 引擎的形状。最后这章把"一次 style update 多少 ms" 摊到 9 个阶段上,给你一张可用的性能心智图。所有数据来自典型企业级 SPA(5k DOM 节点 · 1k 规则 · M2 MacBook · Chrome 130 中位数)。
By here you can see the engine's shape. This final chapter breaks "how many ms per style update" across the 9 stages, giving you a usable perf mental model. Numbers from a typical enterprise SPA (5k DOM nodes · 1k rules · M2 MacBook · Chrome 130 median).
| stage | cold load | first mount | hover | 瓶颈风险 | bottleneck risk |
|---|---|---|---|---|---|
| ① tokenize | 2 ms | — | — | 大 stylesheet 才显著only matters for huge sheets | |
| ② parse | 3 ms | — | — | 通常 okusually fine | |
| ③ CSSOM build | 1 ms | — | — | 通常 okusually fine | |
| ④ selector match | — | 5 ms | 0.05 ms | ⚠️ 长后代选择器⚠️ long descendant selectors | |
| ⑤ cascade | — | 2 ms | 0.02 ms | 通常 okusually fine | |
| ⑥ resolve var | — | 1 ms | 0.01 ms | ⚠️ :root 上太多 var()⚠️ too many vars on :root | |
| ⑦ compute | — | 2 ms | 0.02 ms | color-mix 在大量元素上color-mix on many elements | |
| ⑧ invalidate | — | — | 0.001 ms | ⚠️ :has() 反向⚠️ :has() backward | |
| ⑨ animate (per frame) | — | — | 0.5 ms | ⚠️ animate non-composited⚠️ animating non-composited | |
| total per recalc | 6 ms | 10 ms | ~0.1 ms | — |
:root 上的 var 改动会让所有用它的元素重算。在 design system 里把所有 token 堆在 :root 是常见做法,但 token 切换时(暗色 toggle)整页 invalid。建议: 关键 token 用 @property 注册 typed,Blink 能更精细 invalidate。:root var dirties every element reading it. Piling all design-system tokens on :root is common but causes full-page invalidation on theme toggle. Recommendation: register critical tokens via @property as typed for tighter invalidation.transform / opacity / filter。transform / opacity / filter.requestAnimationFrame 内更好。requestAnimationFrame.| trace event | 对应阶段 | stage | 健康阈值 | healthy |
|---|---|---|---|---|
| ParseAuthorStyleSheet | Ch6-7 parse + CSSOMCh6-7 parse + CSSOM | < 5 ms / 50 KB | ||
| RecalcStyle | Ch11-19 完整 style resolutionCh11-19 full style resolution | < 8 ms / frame | ||
| UpdateLayoutTree | Ch4 LayoutObject 重建Ch4 LayoutObject rebuild | < 4 ms / frame | ||
| InvalidateStyle | Ch20-22 InvalidationSet walkCh20-22 InvalidationSet walk | < 1 ms / change | ||
| Animation::Update | Ch23-25 main-thread anim tickCh23-25 main-thread anim tick | < 2 ms / frame | ||
| cc::Animation::Tick | Ch24 compositor offload animCh24 compositor offload anim | < 0.1 ms / frame (GPU) | ||
| ContainerQueryEvaluator | Ch26 container 两遍布局Ch26 container two-pass | < 2 ms / @container |
// How to capture a trace programmatically (for CI): chrome --remote-debugging-port=9222 --headless --disable-gpu // then via DevTools Protocol: await page.tracing.start({ categories: ["blink.user_timing", "devtools.timeline"] }); await page.goto(url); await page.tracing.stop(); // → JSON trace, load in DevTools or chrome://tracing
RecalcStyle + UpdateLayoutTree 加起来 < 8 ms 才能稳 60 fps。120 fps 设备(2024+ iPhone Pro)则只有 4 ms。实战: DevTools Performance 录一段,看 RecalcStyle 的总耗时是不是接近 8 ms,接近就 jank — Ch29 那张图能帮你找瓶颈。
60 fps = 16.67 ms/frame. CSS gets roughly: ① ~8 ms main thread (the other ~8 ms goes to GPU and paint); ② RecalcStyle + UpdateLayoutTree together < 8 ms to stay locked at 60 fps. On 120 fps devices (2024+ iPhone Pro) it's 4 ms. Practical: record a DevTools Performance trace, watch RecalcStyle's total time — anything approaching 8 ms = jank. The bar chart above helps locate the bottleneck.
Elements · Performance · Animations
Elements · Performance · Animations
| panel | 解决什么 | solves what | 本文对应 | map to this article |
|---|---|---|---|---|
| Elements → Computed | "为什么这个属性是这个值?""why is this property this value?" | Ch15-19 | ||
| Elements → Styles | "哪些规则匹中?哪些被划掉?""which rules matched? which got crossed out?" | Ch11-17 | ||
| Performance → Style recalc | "这次 hover 触发了多少 recalc?""how much recalc did this hover trigger?" | Ch20-22, Ch29 | ||
| Animations panel | "哪条动画在跑?能 scrub 吗?""which animation is running? can I scrub?" | Ch23-25 | ||
| Rendering → Paint flashing | "哪些区域被重绘了?""which regions repaint?" | Ch24, /immersive/chromium-renderer/ | ||
| Rendering → Layer borders | "哪些元素被 composited?""which elements are composited?" | Ch24 |
W3C · Blink 源码 · 演讲 · 历史文档
W3C · Blink source · talks · historical docs
这一节把全文用到的所有外部出处归档。本文以 Chromium 130 (2026 春) 为基线,所有源码行号 pin 到该 commit。所有 URL 在 2026 年 5 月有效。
This section archives every external reference the article uses. Baseline: Chromium 130 (Spring 2026), all source line numbers pinned to that commit. All URLs valid as of May 2026.
本节共 ~50 条外部引用,覆盖每条非自明论断。如发现链接失效或事实漂移,issue 见 airingursb/airingursb.github.io。
This section carries ~50 external references, one for every non-self-evident claim. Broken link or factual drift → file an issue at airingursb/airingursb.github.io.
"读 CSS 引擎源码不是为了变成更好的CSS 用户——是为了变成"它的同事。" "Reading the CSS engine source isn't to become a better CSS user — it's to become its colleague." — Airing, 2026 (本文末)