← ursb.me
·
沉浸式Immersive 2026 春Spring 2026 31 章 · ~10k 行31 chapters · ~10k lines Chromium 130 · Blink

一段 CSS 的一生 — Chromium 样式引擎全景

The Life of a Stylesheet — Inside Chromium's CSS Engine

一份 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().

怎么读
How to read

本文跟 《字节码到像素的一生》《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.

9 道工序 · 从 .css 字节到 ComputedStyle 9 stages · from .css bytes to ComputedStyle live
tokenizeparseCSSOMmatchcascaderesolve varcomputeinvalidateanimate
完整目录Full contents
31 章 · 9 个 Act31 chapters · 9 acts
先讲为什么(Ch1-4),引入主线(Ch5),拆解 9 道工序(Ch6-28),最后回到性能与 References(Ch29-31)。
Why first (Ch1-4), main-line specimen (Ch5), 9 pipeline stages unpacked (Ch6-28), then perf + References (Ch29-31).
II
主线 · The Card
Main-line · The Card
specimen · the 4-line CSS
1
CHAPTER 01

三个公式 — CSS 到底是什么

Three formulas — what CSS really is

把整个 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 不是"样式表" — 是四种东西合一

CSS isn't a "stylesheet" — it's four things in one

读完本文你会发现"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 fileCh6-9
CSSOMJS 操作的运行时对象树the JS-facing runtime object treeCh7, Ch10
Cascade / matching"哪条规则赢"的算法the "who wins" algorithmCh11-19
ComputedStyle每个 DOM 节点上挂的最终结果the final result hanging off each DOM nodeCh19-22
最常见的误解 The most common misconception 很多人以为 CSS 就是".css 文件"。其实 .css 文件只是四件事中的一件——而且是最不重要的一件。真正的"CSS 引擎"不在 .css 文件里——它在 ~150 000 行 Blink 源码(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.
3 FORMULAS · WHAT THE WHOLE ARTICLE EXPANDS ① WHAT (data) Stylesheet = [Rule, Rule, Rule, ...] Each Rule = (Selectors, Declarations) → unpacked Ch6-10 ② RUN (algorithm) ComputedStyle(el) = argmax · matched · winner argmax over 8 priorities, per CSS property → unpacked Ch11-19 ③ KEEP FAST StyleRecalc = O(dirty) via InvalidationSet (not O(N · rules)) → unpacked Ch20-22 31-CHAPTER STRUCTURE MAPS TO 3 FORMULAS Act III (Ch6-10) expands ①: how bytes become a Rule tree (CSSOM) Act IV-V (Ch11-19) expands ②: how matched rules + 8-step cascade pick a winner per property Act VI (Ch20-22) expands ③: how Blink avoids re-running ② for every element on every DOM change Act VII-IX (Ch23-30) handle edge cases: animation as continuous ③, container queries as conditional ②, modern features as new declarations If you remember nothing else from this article: CSS = (data) → (algorithm) → (kept fast). Each formula owns ~10 chapters.
FIG 01·1 整篇文章的骨架。三个公式分别对应"什么(数据)" · "怎么算(算法)" · "怎么保持快(失效优化)"。Act III/IV-V/VI 各扩展一个公式;Act VII-IX 处理边缘情况。这张图回答了"为什么本文用 31 章"——它不是枚举特性,而是按照算法的物理结构分章。 Fig 01·1 · The article's skeleton. Three formulas correspond to "what (data)" · "how (algorithm)" · "how to stay fast (invalidation)". Acts III / IV-V / VI each expand one formula; Acts VII-IX handle the edges. This answers "why 31 chapters" — not by feature enumeration, but by the physical structure of the algorithm.
CHAPTER 02

家谱 — CSS 的 30 年

A family tree — 30 years of CSS

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.

CSS · 1996 → 2026 · KEY LANDMARKS & DEAD BRANCHES 1996 2003 2011 2017 2022 2026 CSS1 50 props CSS2 @media · float CSS2.1 stable baseline CSS3 modules · transitions Flexbox 2012 Grid 2017 Custom Props 2017 Container Q 2022 @layer 2022 :has() 2023 nesting 2023 anchor 2024 DEAD BRANCHES XBL Mozilla 2001 → replaced by Web Components CSS Regions 2014-2015 → shipped in Safari, removed Houdini paint() 2017 → Chrome only · stalled CSS-in-JS 2016-2022 → peaked, declining; not a spec ENGINE FORKS (CSS implementations) WebKit/KHTML 1998 (Konqueror) Gecko 1998 (Mozilla) Blink 2013 fork ← THIS ARTICLE Servo 2012 (Rust) Trident EOL 2022 Presto EOL 2013 本文以 Blink 为锚 · WebKit / Gecko / Servo 各章会做横向对比
FIG 02·1 CSS 1996-2026 30 年家谱。上方是活的 milestone(按颜色分代),下方是死掉的提案(XBL / CSS Regions / Houdini paint / CSS-in-JS),最下是实现引擎。本文以 Blink 为主线,会在各章对照 WebKit / Gecko / Servo 的不同实现。 Fig 02·1 · CSS family tree, 1996-2026. Above the line: shipping milestones (colour-coded by generation). Below: dead branches (XBL / CSS Regions / Houdini paint / CSS-in-JS). At the bottom: implementation engines. We anchor on Blink and contrast WebKit / Gecko / Servo throughout.

六代 CSS · 设计哲学的转向

Six generations · the philosophical pivots

gen代表特性flagship设计哲学philosophy
1996 · CSS1font / 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 · 可编程 CSSCustom 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
为什么 2022-2026 这一代特别重要 Why the 2022-2026 generation is especially important 在 2022 之前,CSS 一直是"声明式 + 静态选择器"——你只能说"选这个元素,设它的样式",不能反过来看上下文。一旦需要"如果某个 li 里包含 form,则其父 ul 加 padding"这种逻辑,就只能 JS。2023 :has() 的 ship 是 CSS 30 年里最大的一次能力跃迁——它让选择器具备子树存在性判断,等价于把一类 JS hack 直接抹掉。@container / @scope / anchor 共同形成"关系 CSS"代,本文 Ch14 + Ch26-28 全面拆解。 Before 2022, CSS was always "declarative + static selectors" — you could say "select this element, set its style", but couldn't look at context. Logic like "if a li contains a form, pad its parent ul" needed JS. :has() shipping in 2023 was the single biggest capability leap in CSS's 30 years — it gives selectors subtree existence queries, wiping out a whole class of JS hacks. Together with @container / @scope / anchor, these form the "relational CSS" generation, fully unpacked in Ch14 + Ch26-28.
CHAPTER 03

在渲染管线里的位置 — CSS 不是孤岛

Position in the pipeline — CSS isn't an island

从 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.

CHROMIUM RENDERING PIPELINE — WHERE CSS LIVES HTML parse DOM build CSS parse CSSOM build match + cascade Computed Style Layout tree Layout (NG) Pre-paint Paint (uses CS) Composite (anim) GPU raster CSS-OWNED (this article · Ch6-19) CSS-CONSUMED (used by other stages · Ch24/29) NOT-CSS (handled by sibling articles) CSS-OWNED REGION · EXPANDED INTO 9 SUB-STAGES (Ch6-19 of this article) ① tokenize CSSTokenizer.cc Ch06 ② parse CSSParser.cc Ch07 ③ CSSOM StyleSheetContents Ch07, 10 ④ selector SelectorChecker Ch11-14 ⑤ cascade StyleResolver Ch15-17 ⑥ resolve var CSSVariableData Ch18 ⑦ compute ComputedStyle Ch19 ⑧ invalidate InvalidationSet Ch20-22 第 ⑨ 段是动画(Ch23-25),它在帧间反复触发上述 ②-⑧ 中的某些段——不在固定位置,所以画成"跨段循环" Stage ⑨ is animation (Ch23-25), which re-triggers ②-⑧ subsets per frame — drawn as a "cross-stage loop", not a fixed position ⑨ anim loop
FIG 03·1 Chromium 渲染管线里 CSS 占哪几格。橙实底是 CSS 独占的 5 段(parse / CSSOM / match+cascade / ComputedStyle);橙虚边是 CSS 被消费的 3 段(Layout NG / Paint / Compositor);中性是其他系统主导(HTML parser / Pre-paint / GPU raster)。下方展开是 CSS-owned 部分的 9 个子阶段——本文 Ch6-22 各拆 1 段。第 ⑨ 段动画跨越多段,作为循环叠加。 Fig 03·1 · Where CSS sits in Chromium's pipeline. Solid orange: 5 CSS-owned boxes (parse / CSSOM / match+cascade / ComputedStyle). Dashed orange: 3 CSS-consumed boxes (Layout NG / Paint / Compositor). Neutral: handled elsewhere (HTML parser / Pre-paint / GPU raster). The expansion below details the CSS-owned 9 sub-stages — one chapter each in Ch6-22. Stage ⑨ animation overlays as a cross-stage loop.
CHAPTER 04

三层模型 — author / user / UA

Three layers — author / user / UA

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:

CSS · 3 SOURCES FEEDING CASCADE USER-AGENT 浏览器内置默认样式 html5.css view-source.css quirks.css media-controls.css ~20 KB · ~700 rules USER 用户自定样式 browser preferences (font-size, theme) Stylus / browser ext accessibility settings 实际很少 · 大多人不用 AUTHOR 站点开发者写的 CSS <link rel="stylesheet"> <style> inline blocks style="..." attribute CSSOM via JS 大部分网站 99% 在这一层 CASCADE · 8 STEPS · STYLE RESOLVER Ch15 unpacks the 8 tie-breakers · UA < User < Author < (!important reverses) → ComputedStyle on the DOM node
FIG 04·1 三个来源同时输入 cascade。UA(蓝): 浏览器内置默认 (~20 KB · ~700 条规则,如 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.

UA 样式 — 你以为是浏览器"自然"的那些

UA stylesheet — the "natural" browser behaviour

在 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;
  ...
}
"reset CSS" 在跟谁打架 What "reset CSS" is fighting 你写的 * { 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 样式 — 你可能从来没用过的那一层

User stylesheet — the layer you've probably never used

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:

浏览器偏好设置
Browser preferences
最小字号、默认字体族、最小行距 — Chrome 把这几个转成合成的 user 样式表注入 cascade。设置在 chrome://settings/appearance。无障碍设置同理。
Minimum font size, default font families, minimum line height — Chrome synthesises these into a user stylesheet and injects them into the cascade. Found at chrome://settings/appearance. Accessibility settings work the same way.
浏览器扩展 (Stylus / Tampermonkey CSS / 暗色模式插件)
Browser extensions (Stylus / Tampermonkey CSS / dark-mode extensions)
扩展通过 chrome.scripting.insertCSS() 注入 — 默认进 author origin,但带 origin: 'USER' 参数可以注入 user 层。Stylus 默认走 USER 层,所以它能"胜过站点的 default" 但"输给站点的 !important"。
Extensions inject via 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(已废弃)
Custom.css user script (deprecated)
Chrome 33 以前支持用户在 profile 目录放 Custom.css 文件做全局样式覆盖。2014 年废弃,原因: 维护成本 + 让恶意软件得手太容易。现在只有 Firefox 还支持(userChrome.css 但只能改 Firefox UI 不能改网页内容)。
Chrome < 33 supported a 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).

Author 样式 — 99% 的真实场景

Author stylesheet — the 99% case

本文 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怎么进 CSSOMhow it enters CSSOM
<link rel="stylesheet">外部 fetch,parser 跑一遍external fetch, parser runs once
<style> inlineHTML 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

html.css 真实内容 · 前 80 行

html.css real content · first 80 lines

// 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;
}
html.css 的结构分布 html.css structural breakdown 完整 html.css 是 800 行占比最大的三段: 表格(~300 行,所有 table / thead / tbody / tr / td 默认 + border-collapse 复杂规则)、表单控件(~150 行,每种 input type 都有 default 行为)、其余基本元素(~350 行,h1-h6 / p / ul / ol / blockquote / dl 等)。Blink 选择把所有 UA 默认都写在 一份 CSS 里 — 而不是埋在 C++ 里。这让 UA 样式跟普通 CSS 走同一条 cascade 路径(Ch15 step ①),也让每个 reset.css 知道自己在覆盖什么 Full html.css is 800 lines. Three largest sections: tables (~300 lines, all table / thead / tbody / tr / td defaults + border-collapse complications); form controls (~150 lines, every input type has its default behavior); other basic elements (~350 lines, h1-h6 / p / ul / ol / blockquote / dl). Blink decided to put all UA defaults in one CSS file — not buried in C++. This sends UA styles through the same cascade as regular CSS (Ch15 step ①), and lets every reset.css know exactly what it's overriding.
MAIN-LINE ✦

The Card — 一段贯穿全文的 CSS

The Card — the through-line stylesheet

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 行

Why these 4 lines

看起来平平无奇——其实没有比这更密的 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 triggeredAct
:root + custom property变量声明 + 值未解析variable declaration + unresolvedV (Ch18)
@layer base级联层 · 改变 cascade ordercascade layer · changes orderV (Ch17)
.cardclass 选择器 · 命中 RuleSet bloomclass selector · hits RuleSet bloomIV (Ch13)
:hover · pseudo-class动态匹配 · 进 InvalidationSetdynamic match · enters InvalidationSetVI (Ch20)
color-mix(in oklch, ...)2024 函数 · 色彩插值2024 function · color interpolationVIII (Ch28)
transition: 200ms动画 · 可 compositor offloadanimation · compositor-offloadableVII (Ch24)
@container (min-width)2022 关系查询 · style invalidation2022 relational query · style invalidationVIII (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.

The Card 的一生 · 9 道工序故事板

The Card's life · 9-stage storyboard

为了让 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.

THE CARD · 9-STAGE STORYBOARD · BYTES → PIXELS ① TOKENIZE Ch06 · CSSTokenizer.cc char stream → tokens [ident "card"] [colon] [ident "hover"] [ws] [{] [ident "padding"] ... ~120 tokens · ~40 µs ② PARSE Ch07 · CSSParser.cc tokens → AST → StyleRule's 4 rules · 1 @layer · 1 @container errors? drop → § Ch09 ~30 µs ③ CSSOM Ch07 · StyleSheetContents build tree of CSSRule's attach to Document JS surface ready · § Ch10 ~10 µs ④ SELECTOR MATCH Ch11-14 · SelectorChecker for each DOM element: <div class="card"> matches? bloom probe → real check ~5-50 µs · depends on rules · n ⑤ CASCADE Ch15-17 · StyleResolver 8-step argmax per property: @layer base.padding = 0.5rem @container.padding = 1rem (if >400) winner picked ⑥ RESOLVE VAR Ch18 · CSSVariableData var(--brand) → oklch(70% .15 250) color-mix(in oklch, ...) → oklch(76% .12 250) all functions evaluated ⑦ COMPUTE Ch19 · ComputedStyle attach result to fiber: padding-* = 16px (1rem · 16px/rem) background-color = (oklch → sRGB) ~600 B per node ⑧ INVALIDATE Ch20-22 · InvalidationSet hover begins: walk InvalidationSet for :hover mark only .card dirty (not siblings) ~0.5 µs · sub-tree level ⑨ ANIMATE Ch23-25 · CompositorAnim transition: 200ms triggers KeyframeEffect on background but bg ≠ composited → main thread 12 frames at 60 fps animation re-invalidates each frame ■ PARSING Ch6-10 · ■ MATCH/COMPUTE Ch11-19 · ■ CASCADE Ch15-17 · ■ RESOLVE/INVALIDATE Ch18,20-22 · ■ ANIMATE Ch23-25
FIG ✦ The Card 走完 9 道工序的故事板。每个格子标了对应章节 + 真实 Blink 源文件 + 实测时间量级。9 个格子按行从左到右、自上而下读: ①-③ 解析(),④⑦ 匹配/计算(绿),⑤ 级联(),⑥⑧ 解析变量/失效(品红),⑨ 动画()。⑨ 回到 ⑧ 的虚线箭头表示动画每帧重新失效计算。整套加起来 mount 时 ~85 µs,hover transition 每帧 ~50 µs。 Fig ✦ · The Card's 9-stage storyboard. Each cell labels its chapter, real Blink source file, and measured time order. Reading order: left to right, top to bottom. Stages ①-③ parsing (blue), ④⑦ match/compute (green), ⑤ cascade (orange), ⑥⑧ resolve/invalidate (magenta), ⑨ animate (purple). The dashed arrow ⑨→⑧ shows that animation re-invalidates each frame. Mount cost: ~85 µs total. Hover transition: ~50 µs per frame.

三个时间点 · The Card 的一生切成三个 ACT

Three moments — The Card's life cut into three acts

把流水线压缩成用户感知的三个时刻,你能更直观地感受成本:

Compressed into three user-perceptible moments, the cost becomes intuitive:

I
ACT I · 加载 (cold load)
ACT I · Cold load
<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)。
From <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).
II
ACT II · 首次 mount (initial style resolution)
ACT II · First mount
DOM 解析完后,Blink 给每个元素跑 ④-⑦。这里是 The Card 的"第一次 ComputedStyle"——RuleSet 索引建好,SelectorChecker 逐元素跑匹配,StyleResolver 跑 cascade,CSSVariableData 解 var(--brand),color-mix 算出 sRGB 值,最后写入 ComputedStyle 对象挂在每个 Layout 节点上。1 个 .card 元素 ~30 µs。整页 100 个元素 ~3 ms。
After DOM parse, Blink runs ④-⑦ for every element. This is The Card's "first ComputedStyle" — RuleSet index built, SelectorChecker matches per element, StyleResolver runs cascade, CSSVariableData resolves 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.
III
ACT III · 用户 hover (动态匹配 + transition)
ACT III · User hover
鼠标进入 .card需要 ①-③ (CSSOM 不变),从 ⑧ 开始: InvalidationSet 看到 :hover 触发,标 这一个 .card 元素 dirty (不波及兄弟),重跑 ④-⑦ 算出 background = color-mix(...)。⑨ transition 把 background 从旧 oklch 到新 oklch 拆成 12 帧 (200ms × 60fps)。每帧 ~50 µs 主线程开销。
Cursor enters .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.
为什么不切 4 个 ACT (像 React Counter 那篇) Why only 3 acts (unlike the React Counter's 4) CSS 没有"unmount"。一旦 stylesheet 进了 CSSOM,它不会单独被卸下——除非整页 navigate。元素被删除时,InvalidationSet 把那个元素从级联里摘掉,但样式表本身留着。所以 3 个 ACT (加载 / mount / 交互) 已经覆盖 CSS 的全生命周期。 CSS has no "unmount". Once a stylesheet enters the CSSOM, it isn't individually unloaded — only a full page navigation tears it down. When an element is removed, InvalidationSet drops the element from cascade lookups, but the stylesheet itself persists. So 3 acts (load / mount / interact) cover CSS's full life.

第一击 · ACT III 的纳秒级时间线

The first hover · ACT III at nanosecond resolution

用户鼠标移到 .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:

t=0 100 µs 200 µs 300 µs 500 µs 600 µs pointerenter hit-test InvalSet probe selector match cascade resolve var · color-mix ComputedStyle transition setup paint frame painted INVALIDATION STYLE RECALC ANIM SETUP PAINT 用户感知延迟: 鼠标到 background 开始过渡 ≈ 0.6 ms · 之后每 16.7 ms 一帧到 200 ms 完成 Perceived latency: cursor → background start fading ≈ 0.6 ms · then 12 frames @ 60 fps to complete in 200 ms
FIG ✦·2 用户 hover 触发的纳秒级时间线。鼠标 hit-test 到 第一帧 paint 出过渡起点,只要 ~0.6 ms。注意 InvalidationSet probe 在最前面——它不重算样式,只决定"要不要重算",Ch20 详谈。 Fig ✦·2 · Nanosecond timeline of a hover. Cursor hit-test to first frame painted with transition's start state takes ~0.6 ms. Notice InvalidationSet probe is first — it doesn't compute style, just decides "does style need recomputing". Ch20 details.
读到这里你已经能回答的几个问题 By here you can already answer ① "为什么 hover 不卡?"——因为 InvalidationSet 只标一个元素 dirty,不是重跑整页。② "color-mix 慢不慢?"——它跑在 ⑥ 阶段,4 行 CSS 里占 ~5 µs。③ "transition 走 GPU 吗?"——background-color transition 能 offload(Ch24),仍在主线程,但成本极低。④ "@container 怎么知道容器大小?"——它在 ⑤ cascade 时拿 layout 的 contentBoxSize · Ch26 详。后面 Ch6-25 把每一格里发生的事拆到源码行号级。 ① "Why doesn't hover stutter?" — InvalidationSet only flags one element dirty, no full-page recompute. ② "Is color-mix expensive?" — runs in stage ⑥; ~5 µs for these 4 lines. ③ "Does the transition use GPU?" — background-color transitions can't offload (Ch24); stay on the main thread but are cheap. ④ "How does @container know container size?" — reads the layout's contentBoxSize during stage ⑤ · see Ch26. The next 20 chapters dissect every cell down to source-line.
color-mix(in <color-space>, red, blue) · DIFFERENT SPACES, DIFFERENT MIDPOINTS srgb (gamma encoded): midpoint = rgb(128, 0, 128) · gamma-bent → muddy srgb-linear: linear-light midpoint · less muddy oklch (perceptual): perceptually uniform midpoint · clean lab (CIE): CIE perceptual · similar to oklch 同样是"50% red + 50% blue",不同 color space 给的中点差很多sRGB 是 gamma-encoded,直接 lerp 出灰暗污浊色;oklch / lab 是感知均匀,中点真的看起来"居中"。这就是 2024 加 color-mix 的目的: 让作者能在哪个空间插值。 实战: 几乎总是用 color-mix(in oklch, ...),除非有明确的 srgb 兼容性需求。Tailwind 4 / Material 3 已默认 oklch。 Color mixing in non-uniform spaces (sRGB) was the dominant approach pre-2024 — and is the dominant source of "why is my gradient muddy" complaints.
FIG 28·1 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.

实战 · 拿到 The Card 的全栈追踪

Practical · capturing The Card's full-stack trace

// 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)
复现这篇文章里的每一个 µs Reproduce every µs in this article 本文所有 µs 数字都来自 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.
CHAPTER 06

tokenizer — 字符流变 token 流

Tokenizer — characters into tokens

CSSTokenizer.cc · 17 种 token · 状态机

CSSTokenizer.cc · 17 token types · the state machine

主线
Main-line
STAGE 1 / 9
源码
Source
core/css/parser/css_tokenizer.cc
输入
Input
UTF-16 char stream
输出
Output
~17 种 CSSParserToken

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,
};
为什么没有"selector token"或"at-rule token" Why no "selector token" or "at-rule token" tokenizer不区分".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.

The Card 的前 12 个 token

The first 12 tokens of The Card

把主线第一行 :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:

TOKENIZE · ":root { --brand: oklch(70% 0.15 250); }" : r o o t  {  -- b r a n d :  o k l c h ( 7 0 %   0 . 1 5   2 5 0 ) ;  } char stream · 39 chars Colon ":" Ident "root" WS L{ "{" Ident "--brand" Colon ":" Function "oklch(" % "70%" Num "0.15" Num "250" R) ")" Semi ";" 注意 ⑤ "--brand"kIdentToken,不是某种"变量 token"——tokenizer 不知道它是自定义属性,只看到一个以 "--" 起首的 ident。"自定义属性"语义在 parser 里。 ⑦ "oklch("kFunctionToken(不是 ident + 左括号两个 token)—— spec 把 "ident(" 合成单独类型,因为它有自动错误恢复语义 (右括号匹配)。 ⑧ "70%"kPercentageToken,不是 kNumberToken — 百分比有独立类型因为它语义上不同于数字。 完整 14 个 token: + ⑬ ws ⑭ R} (这两个在右侧被截掉) · 总耗时 ~3 µs · ~250 ns / token Full 14-token output: also includes ⑬ ws and ⑭ R} (right-truncated). ~3 µs total · ~250 ns per token.
FIG 06·1 主线第一行的分词输出。三个关键反直觉点都已经在图里: "--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).

tokenizer 是个状态机 · 6 个核心状态

It's a state machine · 6 core states

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.

CSSTokenizer · 6-STATE FSM DATA peek + dispatch IDENT consume_ident() NUMERIC consume_numeric() STRING consume_string() URL consume_url() DELIM single char a-z A-Z _ -- U+0080+ 0-9 . -[0-9] " or ' url( : ; , @ # [ ] { } ( ) all states return to DATA after emitting one token EACH SUB-STATE CAN PROMOTE IDENT → "followed by (" promotes to FunctionToken · "followed by ws + ident" stays IdentToken NUMERIC → "followed by %" promotes to PercentageToken · "followed by ident" promotes to DimensionToken
FIG 06·2 CSSTokenizer 的 6 状态 FSM。DATA 是"peek 第一字符决定走哪条分支"的中央调度。5 个子状态消费完字符后回到 DATA 再决定下一个 token。子状态可以"升级": IdentToken 在立刻跟一个左括号时升级为 FunctionToken (上图 ⑦);NumericToken 在立刻跟 % 时升级为 PercentageToken (上图 ⑧),跟 ident 时升级为 DimensionToken (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.

tokenize 时不做的事

What tokenizing does not do

#事情job在哪一阶段做where it's done
区分 selector / property / valuedistinguish selector / property / valueparser · 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 fetchparser → fetcher · Ch8
报告 "parsing error" (除了 bad-string / bad-url)report "parsing errors" (except bad-string / bad-url)parser · Ch9
CSS tokenizer 的设计哲学 · 永不报错 CSS tokenizer's philosophy · never fails 这是跟 JS tokenizer 最大的差别。JS 看到 1 + + + + 2 会报 SyntaxError。CSS tokenizer 看到任何字符流都切出 token——哪怕是垃圾。它会发出 kBadStringTokenkBadUrlToken 这种"有问题但已经标好"的 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.

真实的 ConsumeQualifiedRule 主循环

Real ConsumeQualifiedRule loop

// 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;
    }
  }
}
parser 状态机的真实尺寸 The parser's real footprint 完整 css_parser_impl.cc3 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.
CHAPTER 07

parser + CSSOM — token 流变规则树

Parser + CSSOM — tokens into a rule tree

CSSParser · StyleSheetContents · CSSRule 家族

CSSParser · StyleSheetContents · the CSSRule family

主线
Main-line
STAGE 2-3 / 9
源码
Source
core/css/parser/css_parser_impl.cc
输入
Input
CSSParserTokenStream
输出
Output
StyleSheetContents (CSSOM)

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.

CSSOM 的 7 个核心类

CSSOM's 7 core classes

// 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
};

The Card 解析完是这棵树

The Card, parsed

CSSOM TREE · THE CARD AFTER PARSE StyleSheetContents child_rules_ : [4 entries] base_url, context [1] CSSStyleRule selectors : ":root" properties : --brand : oklch(...) ← unparsed [2] CSSLayerBlockRule name : "base" child_rules : [1 entry] ↓ holds 1 CSSStyleRule [3] CSSStyleRule selectors : ".card:hover" properties : { bg, trans } spec = (0, 0, 1, 1) → Ch16 [4] CSSContainerRule query : (min-width: 400px) child_rules : [1 entry] ↓ holds 1 CSSStyleRule CSSStyleRule (inner) selectors : ".card" properties : { pad: 0.5rem, bg } CSSStyleRule (inner) selectors : ".card" properties : { padding: 1rem } FOOTPRINT 6 个 CSSRule 对象 · 4 个顶层 + 2 个 @rule 内部嵌套 · 每个 CSSRule ~150 B · 总占用 ~900 B
FIG 07·1 The Card 解析后的 CSSOM 树。蓝色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.

parse 不是编译—— 它是形状识别

Parse isn't compilation — it's shape recognition

这一点对从 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 element
rem = 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 也延迟解析(2024 之后)
colors are now lazily parsed (post-2024)
早期 Blink 在 parse 时就把颜色 keyword 解析成 sRGB。2024 年后,因为 color-mix / oklch 等需要插值上下文,颜色解析延迟到 Ch19 的 used-value 阶段。这是 W3C "computed value time vs used value time" 区别(Ch19 详)的实际后果。
Early Blink parsed colors to sRGB during parse. Post-2024, because 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 也能改 CSSOM · 但只在一个口子

JS can mutate the CSSOM · through one window

// 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.push 不能用 Why sheet.cssRules.push doesn't work 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.
CHAPTER 08

@import 与加载顺序 — render-blocking 的根源

@import & load order — why CSS is render-blocking

stylesheet 加载链 · 串行陷阱

the import chain · the serialization trap

主线
Main-line
STAGE 1-3 / 9 · loader path
源码
Source
core/css/style_sheet_resource.cc
问题
Problem
@import 是同步
影响
Impact
render-blocking · FCP

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.

@import vs <link> · LOAD WATERFALL A · <link rel="stylesheet" href="a.css"> + <link rel="stylesheet" href="b.css"> 0 ms 100 ms 200 ms 300 ms fetch a.css 100ms fetch b.css 100ms parse parse FCP at 130ms B · <link href="a.css"> · a.css starts with @import "b.css" 0 ms 100 ms 200 ms 300 ms fetch a.css 100ms tok fetch b.css 100ms parse b parse a FCP at 240ms · 110ms slower A 用了 2 个 <link>: 两个请求并行发出,~130 ms 到 FCP。 B 用了 @import: parser 必须先解析 a.css 的开头,看到 @import 才知道有 b.css 要拉,这个等待无法并行化。+110 ms FCP。 This is why every web-perf guide says "don't use @import for external sheets" — at minimum 1 RTT extra.
FIG 08·1 @import 让 FCP 慢 1 个 RTT 以上的根本原因: parser 要先解析外层 stylesheet 才能发现内层有 @import,而解析必须等下载完。这是个无法绕过的串行依赖。建议: 用 <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 的真实定义

What "render-blocking" actually means

很多人以为 "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是否 blockblocks?
<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
JS 也被 CSS block · 第二个反直觉点 JS is also blocked by CSS · second counterintuitive point 如果 <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".
CHAPTER 09

错误恢复 — CSS 的"宽容"是怎么实现的

Error recovery — how CSS's "forgiveness" works

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.

4 种错误恢复策略 · 按粒度

4 recovery strategies · by granularity

#出错位置error site恢复 tokenresync 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   */
② 是为什么"多个 selector 共享 declarations" 风险大 Why "multiple selectors sharing declarations" is risky 看 ②: .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.

恢复算法的真实代码

The recovery algorithm

// 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
为什么 CSS 选择 forgiveness · 历史动机 Why CSS picked forgiveness · historical motive 1996 年 Web 上充满"给 IE 写的 hack"和"未来 CSS 版本的新属性"。如果 CSS 像 JS 那样 strict,任何一个不识别的属性会让整页失去样式——老浏览器看到 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".
ERROR RECOVERY FSM · WHEN TO STOP DROPPING TOKENS parsing OK accumulate tokens bad token where am I? switch on context in decl ① skip to next ";" drop one declaration · resume rule in selector ② skip to matching "}" drop the whole rule (selector + declarations) in @-rule ③ skip to ";" or matching "}" drop the whole at-rule unclosed string / paren ④ EOF — drop rest of file tokenizer emits BadStringToken / BadUrlToken resync — continue parsing rest of file
FIG 09·1 CSS 错误恢复的有限状态机。出错后,parser 看自己在哪决定丢什么: declaration 错丢一句到 ";" · selector 错丢整条规则到 "}" · @-rule 错丢整个 at-rule · 未闭合 (string / paren) 直接 EOF 丢剩余文件。关键: 恢复后 parser 不停 · 跳过坏位置继续读 — 这就是 1996 Lie 和 Bos 写入 spec 的"forgiveness"教条,让 CSS 跨 30 年兼容。 Fig 09·1 · The error-recovery FSM. On bad token, parser checks where it is: bad declaration → drop to ";"; bad selector → drop whole rule to "}"; bad at-rule → drop entire at-rule; unclosed (string / paren) → EOF drops rest of file. Critical: parser never stops · skips and continues — this is the "forgiveness" doctrine Lie & Bos wrote into spec in 1996, what makes CSS compatible across 30 years.
CHAPTER 10

CSSOM ↔ DOM — 两棵树怎么挂

CSSOM ↔ DOM — how two trees attach

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.

DOM ⊕ CSSOM · TWO TREES, ONE ENGINE DOM TREE Document <html> <body> div.card div.other CSSOM TREE Document .styleSheets[0]: CSSStyleSheet cssRules : CSSRuleList [1] :root --brand [2] .card @layer base [3] .card:hover StyleEngine core/css/style_engine.cc runs cascade · builds ComputedStyle ComputedStyle (attached to div.card) padding=1rem · background=oklch(...) ... ⊕ attached to fiber.computedStyle
FIG 10·1 DOM 树和 CSSOM 树平行存在,通过 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).

JS 看到 CSSOM 的三个口子

Three JS entry points into the CSSOM

#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 是同步重算触发器 getComputedStyle synchronously forces recalc 这是 web 性能里最经典的 jank 来源之一。任何 JS 调 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.

StyleEngine 的入口 · UpdateActiveStyleSheets

StyleEngine entry · UpdateActiveStyleSheets

// 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;
}
CHAPTER 11

选择器 parser — 字符串变 selector 链

Selector parser — string into a selector chain

CSSSelector · 链表 · 组合器

CSSSelector · linked list · combinators

主线
Main-line
STAGE 4 / 9
源码
Source
core/css/parser/css_selector_parser.cc
数据结构
Data
CSSSelector linked-list
关键 fn
Key fn
ConsumeCompoundSelector

".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.
为什么右到左? 不是反直觉吗 Why right-to-left? Isn't that backwards 这是 CSS 选择器实现里最反直觉但最关键的设计决策。从源码写法看,"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" · CSSSelector LINKED LIST SOURCE: div.main > .card:hover (reads left-to-right) CSSSELECTOR LIST (head = rightmost, walk via tag_history_): node[0] ← HEAD match=PseudoClass pseudo_type=PseudoHover relation=SubSelector tag_history_ → node[1] match=Class value="card" relation=Child (>) tag_history_ → node[2] match=Class value="main" relation=SubSelector tag_history_ → node[3] ← TAIL match=Tag value="div" relation=SubSelector tag_history_ = null SelectorChecker (Ch12) 从 head (右) 开始,沿 tag_history_ 走到 tail (左) · "右到左"匹配名字真正的来源 SelectorChecker (Ch12) starts at head (right) and walks tag_history_ to tail (left) · the literal origin of "right-to-left" matching relation field 表示 "从我到下一节点之间的组合器": SubSelector (compound 内) · Child (>) · Descendant (空格) · DirectAdjacent (+) · IndirectAdjacent (~)
FIG 11·1 "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).
CHAPTER 12

SelectorChecker — 右到左匹配的精髓

SelectorChecker — the right-to-left match

单元素 · O(选择器长度) · 早剪枝

single element · O(selector length) · early prune

主线
Main-line
STAGE 4 / 9
源码
Source
core/css/selector_checker.cc
关键 fn
Key fn
MatchSelector()
耗时
Cost
~50 ns per element per rule

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.

SelectorChecker · ".main article p" against <p> — RIGHT TO LEFT DOM (target = the <p>) <body> <div class="main"> <article> <p> ← target SelectorChecker enters here SELECTOR · ".main article p" [0] p · rightmost match? p tag? ✓ [1] article walk up — found ✓ [2] .main walk up — found ✓ ① start here · element matches rightmost simple selector? ② walk up DOM ③ walk up DOM WHY RIGHT-TO-LEFT IS FASTER 考虑反例 "div .card .btn .icon" 匹一个 <span>: 左到右要先找所有 div(数十个),再找每个 div 内的 .card(可能更多),... 最后才发现 span 根本不是 .icon。右到左从 span 开始: 不是 .icon → 立刻退出。 实测: 在 ~10k 元素的页面,右到左比左到右快 50-200×(取决于选择器复杂度)。 Bloom filter (Ch13) further prunes "which rules to even consider for this element" — another 10-100× speedup.
FIG 12·1 SelectorChecker 对一个 <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.

真实的 MatchSelector 源码

Real MatchSelector source

// 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;
  }
}
为什么有 kSelectorFailsAllSiblings Why kSelectorFailsAllSiblings exists 注意 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.

RuleSet 的真实 add 路径

RuleSet's real add path

// 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
}

跨引擎对照 · selector 匹配的三种实现

Cross-engine · three selector-matching implementations

enginematch 方向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
WebKit selector JIT · 鲜为人知的性能秘密 WebKit selector JIT · the lesser-known perf secret 2014 年 WebKit 给选择器加了 JIT 编译(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.
CHAPTER 13

Bloom filter / RuleSet — 决定"哪几条规则要检查"

Bloom filter / RuleSet — deciding "which rules to even check"

为什么 10k 规则也不慢

why 10k rules still aren't slow

主线
Main-line
STAGE 4 / 9 · pre-pass
源码
Source
core/css/rule_set.cc
数据
Data
multimap by rightmost selector + bloom
收益
Win
~99% rules skipped per element

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.

两层索引 · 元素 → 候选规则

Two-layer index · element → candidate rules

// 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);
}

Bloom filter 怎么工作 · 4-hash 32-bit

How the bloom filter works · 4-hash 32-bit

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.

ANCESTOR BLOOM · 32-bit · ".main article .btn" ELEMENT <a class="btn"> — its ancestor chain: <body> <div.main> <article> element bloom (after hashing all 3 ancestors): 0 0 0 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0 1 0 0 1 0 0 0 0 → 0x12110140 RULE ".main article .btn" — required ancestor bloom: 1 1 → 0x02100000 AND result: 0x12110140 & 0x02100000 = 0x02100000 · required bitselement bitsBLOOM PASS · proceed to SelectorChecker If AND would have lost any required bit → regulation reject immediately, no SelectorChecker call. ~99% of rules go this path. False positives are possible (bloom is approximate), but they only mean "maybe matches"; SelectorChecker still does the final check. False negatives are impossible.
FIG 13·1 Bloom filter 决定哪些规则要不要跑 SelectorChecker。元素的祖先链(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.
CHAPTER 14

:has() / :is() / :where() — 选择器的两次大跃迁

:has() / :is() / :where() — selector's two leaps

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语义semanticsspecificity
: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() 为什么花了 20 年才 ship Why :has() took 20 years to ship "父选择器"早在 CSS2 提案过,被毙了。原因有二: ① 性能——传统 right-to-left match 假设"选择器最终落在当前元素",: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 referencesa: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.
":has()" · BACKWARD INVALIDATION (the new dimension) RULE: form:has(input:invalid) { border: red; } form ★ ← will re-style if child is invalid label input ★ ← becomes :invalid btn walk UP find ancestor matching :has()'s outer compound TRADITIONAL (descendant) RULE: .parent .child { ... } parent class change → walk DOWN to find .child child change → only that child re-styles → DescendantInvalidationSet · existed since 2014 :has() (REVERSE) RULE: form:has(input:invalid) { ... } child changes :invalid state → walk UP to ancestor form then ALSO walk DOWN to find other matching input siblings → HasInvalidationSet · NEW in 2023 实测代价: 启用 :has() 的页面 invalidation 工作 +30-100%。Blink 用嵌套深度限制(:has() 内不能再 :has())防止 worst-case 爆炸。
FIG 14·1 :has() 给 CSS 引擎加了反向失效维度。传统选择器: 父元素变 → 找后代 (descendant invalidation)。:has(): 子元素变 → 走 UP 找匹配 :has() 外层的祖先,然后再 DOWN 看是否还有其他子树触发同一规则。这就是为什么 :has() "看上去像简单功能" 但等了 20 年才 ship——它要求 invalidation 引擎重写。 Fig 14·1 · :has() adds a reverse dimension to invalidation. Classic selectors: parent changes → walk down to descendants (descendant invalidation). :has(): child changes → walk UP to find an ancestor matching :has()'s outer compound, then DOWN again to find other subtrees that may still trigger the same rule. This is why :has() looks like a simple feature but took 20 years to ship — it required rewriting the invalidation engine.

跨引擎对照 · cascade 实现的三种风格

Cross-engine comparison · three cascade flavours

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
为什么 Blink 不并行 cascade? Why doesn't Blink parallelise cascade? Servo 证明并行 cascade 在多核上加速 4-8×。Blink 没移植: ① 主线程 cascade 通常 < 8 ms / frame,不是瓶颈;② 并行需要 immutable 数据结构,但 Blink 的 inheritance 是父子串行(parent ComputedStyle 算完才能算 child);③ 调度开销在 100-1000 元素的典型 SPA 下盖过收益。2024 Blink 团队态度: "等到 single-thread 真的不够再说"。 Servo proved 4-8× scaling on multi-core. Blink hasn't ported: ① main-thread cascade is usually < 8 ms / frame, not the bottleneck; ② parallelisation requires immutable data structures, but Blink's inheritance is parent → child serial (parent ComputedStyle must complete before child); ③ scheduling overhead outweighs benefit at typical SPA size (100-1000 elements). Blink team 2024 stance: "not until single-thread really fails".
CHAPTER 15

Cascade — 8 步决定谁赢

Cascade — 8 steps to a winner

"谁的属性值最终生效"

"whose property value wins"

主线
Main-line
STAGE 5 / 9
源码
Source
core/css/resolver/style_cascade.cc
输入
Input
所有匹中规则 + element 上下文
输出
Output
每属性 1 个 winner declaration

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.

CASCADE · 8-STEP TIE-BREAK PYRAMID (top wins) ① ORIGIN & IMPORTANCE transition > UA !important > user !important > author !important > animation > author > user > UA ② CONTEXT (encapsulation) scope · shadow boundaries · part() ③ STYLE ATTRIBUTE style="..." beats <style> / external ④ LAYER (cascade layer) @layer order (Ch17) · later layers win ⑤ SPECIFICITY (a, b, c) tuple — Ch16 · #id > .class > tag ⑥ ORDER OF APPEARANCE later in stylesheet wins; later stylesheet wins ⑦ INHERITANCE inherit from parent if property inherits ⑧ INITIAL VALUE spec-defined default (e.g. color: black) 怎么读这个金字塔 从 ① 顶层开始,按优先级从高到低逐层判: 若 ① 已分胜负就不用继续。如果两条规则在 ①-⑤ 都打平,就用 ⑥ "写得晚的赢" 决出。⑦/⑧ 是兜底——没有任何规则匹中时才走继承/初始值。 反直觉点: !important 不是顶层独立 tier,而是 ① 内部的子序!important 反转了 origin 顺序——UA !important > user !important > author !important > 普通 author 声明。 CSS Cascade Level 5 (2022) — promoted Layer to its own tier (④); pre-2022 only 6 steps existed.
FIG 15·1 Cascade 的 8 步金字塔。从上往下按优先级判,第一个能决出胜负的 tier 就结束。步骤 ① "Origin & Importance"最重要也最反直觉: !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.

StyleCascade.cc · 8 步代码层面是 12 个 priority 字段

StyleCascade.cc · 8 spec steps map to 12 priority fields

// 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 压成整数比较 Cascade compressed to integer compare spec 描述 cascade 是"8 步金字塔",实现里 Blink 把所有 tie-breaker 打包成一个 64-bit 整数 · 一次 < 比较就能决出胜负。这是性能关键: 一个页面跑 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.
CHAPTER 16

Specificity — (a, b, c) 真实算法

Specificity — the (a, b, c) tuple math

不是 (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).

真正的算法

The actual algorithm

// 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.

三个常见误判

Three common errors

#说法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
SPECIFICITY TUPLE LADDER · LEXICOGRAPHIC COMPARE * (0, 0, 0) · universal div (0, 0, 1) · type selector .card (0, 1, 0) · class .card:hover (0, 2, 0) · class + pseudo-class div.card (0, 1, 1) · class + type .a .b .c .d .e .f .g .h .i .j .k (0, 11, 0) · NO CARRY! #main .card (1, 1, 0) · id + class · BEATS 11 classes #header.main #footer (2, 1, 0) · two ids + one class SELECTOR (a, b, c) TUPLE · LEX-COMPARE 关键规则: lexicographic 比较 — 先比 a,a 平再比 b,b 平再比 c。(1,0,0) > (0,99,99),因为 a 先决出胜负。
FIG 16·1 specificity 真实的三元组比较。8 个选择器按 (a,b,c) tuple 高低排;蓝色行是 The Card 用到的几种。关键点: (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).

CSSSelector::GetSpecificity 真实代码

CSSSelector::GetSpecificity actual code

// 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;
}
specificity 不会真的溢出 Specificity (almost) doesn't overflow Blink 给 a 留了 8 位(255 个 id)、b 留 8 位(255 个 class)、c 留 16 位(65535 个 tag)。在正常 CSS 里到不了——但边界情况: 一个手写的 256-id 选择器 #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.
CHAPTER 17

@layer — 级联层 · 终结 specificity 战争

@layer — cascade layers · ending specificity wars

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. */
为什么 @layer 是设计变革,不只是新特性 Why @layer is a design shift, not just a feature 2010s 大型站点最大的样式痛是"specificity 军备竞赛": 库 A 用 .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.
@layer STACK · WHO BEATS WHOM (bottom wins · stack top = lowest priority) DECLARATION ORDER @layer reset, base, theme, utilities; @layer reset { .x { color: gray } } @layer base { .x { color: red } } .x { color: blue } ← unlayered @layer theme { .x { color: green } } @layer utilities { .x { color: orange } } CASCADE STACK (top = lowest priority) UA · browser defaults (Ch4) @layer reset · color: gray @layer base · color: red @layer theme · color: green @layer utilities · color: orange UNLAYERED · color: blue ★ WINS "naked" .x { color: blue } low high 关键反直觉: 把规则放进任何 named @layer 都让它变弱unlayered 永远是 author tier 里最高优先级(在 step ④ 阶段)。 Counterintuitive: putting a rule into any named @layer makes it weaker. Unlayered is always the highest-priority slot in the author tier (step ④).
FIG 17·1 @layer 栈的真实优先级图。从顶到底优先级递增: UA · 命名层(按声明顺序) · unlayered (赢)。这就是 The Card 例子里 @container 内的 .card(unlayered) @layer base 内的 .card 的原因。把 framework 代码放命名层,业务代码不进任何层 — 业务永远赢。 Fig 17·1 · The real @layer priority stack. Priority increases top-to-bottom: UA · named layers (in declared order) · unlayered (wins). This is why The Card's @container .card (unlayered) beats the @layer base .card. Library code into named layers, business code unlayered — business always wins.

CSSVariableData::ResolveTokenRange · 真实代码

CSSVariableData::ResolveTokenRange · real code

// 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;
}

@layer 实现 · CascadeLayer + LayerMap

@layer impl · CascadeLayer + LayerMap

// 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 嵌套的细节 @layer nesting details 你能写 @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".
CHAPTER 18

Custom Properties — 把 CSS 变成动态语言

Custom Properties — CSS becomes dynamic

CSSVariableData · 延迟解析 · 循环检测

CSSVariableData · lazy resolution · cycle detection

主线
Main-line
STAGE 6 / 9
源码
Source
core/css/css_variable_data.cc
入口
Entry
ResolveCSSWideKeyword
挑战
Challenge
cycle detection · undef fallback

--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                                 */

循环引用怎么处理

Cycles · how Blink handles them

: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() 是 token-substitution,不是值替换 var() is token substitution, not value replacement 这是 custom property 设计里最反直觉的点。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() CYCLE DETECTION · DFS with visit-mark :root { --a: var(--b); --b: var(--c); --c: var(--a); /* CYCLE! */ } --a --b --c refs refs CYCLE DETECTED ALGORITHM · CSSVariableData::Resolve() 1. mark current variable as visiting (push to in-progress set) 2. for each var(--x) in its value, recurse 3. if recursion encounters a variable already in the visiting set → mark all 3 as guaranteed-invalid 4. cycle-broken variables fall back to their var() default arg, or to initial value if none
FIG 18·1 var() 循环检测。Blink 用经典DFS + 访问标记: 解析变量时把它放进正在访问集合,递归 resolve 其值里的 var();如果递归遇到已经在访问中的变量,环成立 — 把链上所有变量标 guaranteed-invalid。这些变量在使用处会 fallback 到 var() 的 default 参数或 initial value。所以 var(--a, red) 在循环情况下变成 red。 Fig 18·1 · var() cycle detection. Blink uses classic DFS + visit-mark: when resolving a variable, push it into a visiting set; recurse to resolve nested var() refs; if recursion hits a variable already visiting, cycle confirmed — mark every variable on the chain as guaranteed-invalid. At use sites, these fall back to var()'s default arg or to initial value. So var(--a, red) under a cycle becomes red.
CHAPTER 19

四种值 — specified / computed / used / actual

Four values — specified / computed / used / actual

每个属性有四个不同状态的值

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)
specifiedspecifiedcascade 结束 · 还没解析cascade done · unresolved"50%"
computedcomputedvar() 解开 · 单位 normalize 但 % 保留var() resolved · units normalised · % kept"50%" (still)
usedusedlayout 完成后 · % 解成 pxafter layout · % resolved to px"100px"
actualactualdevice pixel snap 后after device-pixel snap"100px" (or 99.5 on @2x)
getComputedStyle 返回的是 used 不是 computed getComputedStyle returns used, not computed 虽然名字叫 "computed" Style,这个 API 实际返回 used value。所以 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%" · FOUR VALUE STAGES (parent width = 200px @1×) ① SPECIFIED "50%" cascade picked this declaration's value resolve var() etc. ② COMPUTED "50%" % still kept (Ch19) em → px ✓ layout ③ USED "100px" 200 × 0.5 = 100 layout-aware snap ④ ACTUAL "100px" no snap @ 1× 99.5 @ 2× / etc. JS APIS READ el.computedStyleMap().get('width') → reads ② computed · returns CSSUnitValue{50, '%'} getComputedStyle(el).width → reads ③ used · returns "100px" · despite the API name!
FIG 19·1 同一个 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.
CHAPTER 20

InvalidationSet — 一次 DOM 改动怎么触发全页重算

InvalidationSet — how one DOM change doesn't recalc the whole page

从 O(N · rules) 到 O(dirty)

from O(N · rules) to O(dirty)

主线
Main-line
STAGE 8 / 9
源码
Source
core/css/invalidation/style_invalidator.cc
问题
Problem
DOM 改 1 处 · 不重算全页
数据
Data
RuleFeatureSet + per-element

假设你在一个有 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

Worked example · the InvalidationSet for ".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() 反向 The expensive invalidation · :has() backward 2023 ship 的 :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.
InvalidationSet · DOM CHANGE → DIRTY NODES (NOT FULL PAGE) DOM TREE body .container ★ ← class added h1 p.note .card .btn class "container" added InvalidationSet["container"] type: Descendant classes: ["card", "btn"] whole_subtree_invalid: false precomputed at rule register time walk descendants match by class DIRTY NODES .card .btn untouched: h1, p.note (not in invalSet) body (ancestor not affected) 5000 other elements: untouched cost: 2 recalcs · 0.5 µs 没有 InvalidationSet: 加一个 class → 每个元素 × 每条规则 跑 SelectorChecker 有 InvalidationSet (Blink 自 2014): 查表 → 只 dirty 真正受影响的节点
FIG 20·1 InvalidationSet 把"有人改了 DOM"翻译成 "哪些节点 dirty"。当父元素加了 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.

StyleInvalidator.cc · 实际 walk 代码

StyleInvalidator.cc · the actual walk

// 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);
  }
}
CHAPTER 21

sibling / descendant 边界 — combinator 怎么影响失效

Sibling / descendant boundaries — how combinators affect invalidation

空格 · > · + · ~ · :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.

#组合器combinatorInvalidationSet 类型InvalidationSet typewalk 方向 / 成本walk direction / cost
A BdescendantA 的全部后代 · O(subtree)all descendants of A · O(subtree)
A > Bdescendant (depth 1)A 的直接子节点 · O(children)A's direct children · O(children)
A + Bsibling (next)A 的下一个兄弟 · O(1)A's next sibling · O(1)
A ~ Bsibling (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)
为什么 .a + .b 是 sibling 失效里最便宜的 Why .a + .b is the cheapest sibling invalidation +(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.
COMBINATOR WALK PATTERNS · 5 different shapes of invalidation work A B descendant A walk ALL descendants O(subtree) A > B direct child A walk DIRECT children O(children) A + B next sibling A B walk EXACTLY 1 sibling O(1) A ~ B subsequent sibling A B B walk ALL later siblings O(siblings) ⚠️ A:has(B) backward (Ch14) A B walk UP + DOWN O(ancestors · subtree) 读图: 黄色是变化的元素(class flipped),紫色是被 invalidation 走到的节点,虚框走到的。不同 combinator 决定 walk 形状,直接影响 invalidation 成本。 实战建议: + (next-sibling) 比 ~ (subsequent-sibling) 便宜量级 — 长列表里慎用 ~。:has() 是最重的,但能换掉一类 JS hack。 Diagram colour key: yellow = the element whose class changed; magenta-filled = nodes the invalidation walks; dashed boxes = untouched.
FIG 21·1 5 种 combinator 对应的 5 种walk 形状descendant(空格)走整棵后代子树;child(>)只走直接子;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.
CHAPTER 22

StyleRecalc 触发条件 — 真正会让样式重算的事件

StyleRecalc triggers — what really forces a recalc

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触发 recalctriggers 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)
⑩ 是性能噩梦的常见来源 ⑩ is a common perf nightmare 把 dark mode toggle 实现成 :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).
Web Animations · Animation + Effect + Timeline (one model, three syntaxes) Animation (controller) play() · pause() · reverse() · finished play_state, start_time, playback_rate KeyframeEffect (what) target_: Element* keyframes_: [{bg: red, off: 0}, {bg: blue, off: 1}] timing: {duration: 200ms, easing: ease} AnimationTimeline (when) DocumentTimeline · wall-clock ScrollTimeline · scroll position (Ch25) ViewTimeline · element entering vp 3 ways into this model: CSS transition · CSS @keyframes animation · JS new Animation()/element.animate() — all desugar identically
FIG 23·1 Web Animations 的三件套数据模型。Animation 是控制对象(play / pause / 时间);KeyframeEffect 是"对什么 element 做什么"(target + keyframes + timing);AnimationTimeline 是"时间从哪来"(wall-clock 或 ScrollTimeline 或 ViewTimeline)。CSS transition / CSS animation / JS element.animate() 是三种语法糖,desugar 到同一个 model — 所以 DevTools Animations 面板能同时控制三者。 Fig 23·1 · Web Animations' three-piece data model. Animation is the controller (play / pause / time); KeyframeEffect is "what to do on which element" (target + keyframes + timing); AnimationTimeline is "where time comes from" (wall-clock or ScrollTimeline or ViewTimeline). CSS transition / CSS animation / JS element.animate() are three syntactic sugars over the same model — which is why DevTools Animations panel controls all three uniformly.
STYLE RECALC TRIGGER · ACTION → SCOPE OF RECALC ACTION SCOPE COST FREQUENCY el.classList.add('x') invalSet for "x" only low very common mouse hover invalSet for :hover low extremely common :root style.--brand = '...' ALL using var(--brand) ⚠️ HIGH theme toggle window resize @media / @container affected medium user resize body.appendChild(el) new element + siblings medium react render el.style.padding = '1rem' this element only trivial animations document.title = '...' NONE 0 low window.scrollTo(...) NONE (unless scroll-driven anim) 0 extremely common getComputedStyle(el).width ⚠️ forces SYNC layout ⚠️ HIGH layout thrash trap 底两条(title / scroll) recalc · 最贵的是 :root var 改动(波及全页用 var 的元素)和 getComputedStyle 同步 layout — Ch29 详谈缓解
FIG 22·1 9 种常见操作触发的 recalc 范围 / 成本对照。最贵两条: :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).
CHAPTER 23

transition / animation 数据模型

Transition / animation data model

KeyframeEffect · Animation · timeline

KeyframeEffect · Animation · timeline

主线
Main-line
STAGE 9 / 9
源码
Source
core/animation/animation.cc
三对象
3 objects
Animation + Effect + Timeline
spec
spec
Web Animations Level 2

CSS transitionanimation 在 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(); }
};

CSS transition 怎么变 Animation

How CSS transition becomes an Animation

/* 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
为什么这种"统一模型"很重要 Why this "unified model" matters CSS transition / CSS animation / WAAPI 是三套语法一套实现。这意味着: ① 性能特征相同——transition 不会比 WAAPI 快或慢;② DevTools "Animations" 面板能同时暂停 / 跳转 / 编辑三者;③ 在 JS 里可以 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.
CHAPTER 24

Compositor offload — 哪些属性能 GPU 跑

Compositor offload — which properties go GPU

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能 offloadoffloadable为什么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
colortext raster · 主线程text raster · main thread
left / top / width / height触发 layout · main thread + layout tree rebuildtriggers layout · main thread + layout tree rebuild
border-radiuspaint · 改边界需要 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); }
Compositor offload 的两个隐藏成本 Two hidden costs of compositor offload 每个 offload 的元素需要单独 layer——~50-200 KB 显存。在低端 Android 上,几百个 offload 元素会撑爆显存,Chromium 退回到主线程合成。② "will-change: transform" 不是 free 提示——它强制创建 layer,即使元素没有动画。滥用会让 layer 数量爆炸(Ch29 性能模型详)。规则: 只在即将动画的元素上加 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.
COMPOSITOR LAYER TREE · WHICH ELEMENTS GET A LAYER DOM body header .card .modal ★ transform p.text COMPOSITED LAYER TREE Layer 1: ROOT (body + header + .card + p.text) main thread paints to this layer's bitmap Layer 2: .modal (separate) promoted by transform · GPU shader can animate PROMOTE TO OWN LAYER: transform / opacity / filter · 3D transform · will-change: transform · video / canvas · position: fixed (in certain cases)
FIG 24·1 Compositor 看到的不是 DOM 树 — 它看到的是Layer 树。绝大多数元素共享 ROOT layer(主线程画到这里);只有用了 transform / opacity / will-change 等的元素被promote独立 layer。GPU compositor 只对这些独立 layer 做动画 — 这就是为什么 transform: translateXleft 快几百倍。但 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.

Compositor 怎么决定谁该 promote

How the compositor decides who gets promoted

// 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).
CHAPTER 25

animation-timeline / scroll-driven — 2024 起 CSS 接管滚动联动

animation-timeline / scroll-driven — CSS owns scroll-bound motion since 2024

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 */
}
scroll-timeline · TIME SOURCE = SCROLL POSITION scroll container scroll height: 3000px viewport scrollY = 500px progress = 500/2000 = 25% ANIMATION (driven) 0% 100% 25% from { transform: translateY(0) } to { transform: translateY(-200) } current → translateY(-50) (25% × -200 = -50) 关键: animation 的 时间源 不再是 wall-clock,而是 scroll progress。compositor 直接读 scroll offset,不经过主线程 — 这就是为什么 CSS scroll-driven 比 JS scroll listener 快几个数量级。 Key: animation's time source is scroll progress, not wall-clock. Compositor reads scroll offset directly, bypassing main thread — orders of magnitude faster than JS scroll listeners.
FIG 25·1 scroll-driven animation 的时间映射。viewport 在 3000px 文档里滚到 500px 处 → progress = 500/2000 = 25% → 动画也对应跑到 25% → 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.
CHAPTER 26

Container Queries — 让组件知道容器多大

Container Queries — letting a component see its container

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 是必要的

Why container-type is required

循环依赖问题
The circular dependency
想象一下没有 container-type 的世界: container 大小取决于内容,内容大小取决于 @container 规则,@container 规则取决于 container 大小——圈了container-type: inline-size 强制 container 在 inline 方向上的大小独立于子内容(类似 contain: inline-size),打破环。代价: container 自己不会因子内容长大。
Picture a world without 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.
两遍布局
Two-pass layout
@container 规则不能在第一次 cascade 时跑——那时还不知道 container 多大。Blink 走两遍: pass 1 cascade 出"不依赖容器的"规则,跑 layout 决定容器大小;pass 2 重跑只依赖 @container 的子树。每个 @container 都加这一笔 layout 成本——Ch29 详。
@container rules can't be evaluated in the first cascade pass — container size isn't known yet. Blink does two passes: pass 1 cascades the non-container-dependent rules and runs layout to determine container size; pass 2 reruns only the container-dependent subtree. Each @container adds this layout cost — Ch29 details.
@container · TWO-PASS LAYOUT PASS 1 cascade · SKIP @container rules .card { padding: 0.5rem } (@layer base only) layout · compute container size container.inline-size = 600px (given DOM + parent constraint) trigger re-cascade on .card subtree PASS 2 cascade .card · @container ON 600 ≥ 400 ✓ · .card { padding: 1rem } WINS layout AGAIN · final size padding changed → reflow subtree → stable COST 每个 @container 加 ~+0.5 ms layout · 因为 layout 跑两遍。多个 @container 嵌套时,worst case 是 每层一次额外 layout避免循环: 父声明 container-type: inline-size 强制inline size 不依赖子内容——打破"子大小依赖容器,容器大小依赖子"的环。 Worst case: nested @containers may iterate multiple rounds before stabilising — see Chromium bug tracker for known pathological cases.
FIG 26·1 @container 的两遍布局。Pass 1: cascade 跳过 @container 规则,跑 layout 算出容器大小。Pass 2: 知道容器大小后跑 .card 的 cascade · @container 现在能匹中 · 触发 layout 第二次。每个 @container 大约加 0.5 ms 成本——但能真正解决组件级响应式问题,这笔账划算。 Fig 26·1 · @container's two-pass layout. Pass 1: cascade skips @container rules; layout determines container size. Pass 2: with size known, .card's cascade reruns · @container can now match · second layout. Each @container adds ~0.5 ms — but solves component-level responsiveness properly; worth it.

@container · Blink 实现细节

@container · Blink internals

// 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 不会震荡 Why @container doesn't oscillate 想象一种恶心的场景: @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.
CHAPTER 27

@scope / nesting — CSS 终于支持嵌套

@scope / nesting — CSS finally has nesting

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 */
}
关键差别 · CSS nesting 不是 Sass nesting Key difference · CSS nesting isn't Sass nesting CSS nesting 编译时就能确定语义,但 selectors 仍按 standard 解析。禁用不带 & 的嵌套简单选择器: .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.
CSS NESTING · SOURCE → FLAT EXPANSION (what parser actually stores) SOURCE (nested) .card { padding: 1rem; & .title { font-weight: bold; } &:hover { background: blue; } } FLAT (CSSOM) CSSStyleRule { selector: ".card", declarations: [padding: 1rem] } CSSStyleRule { selector: ".card .title", declarations: [font-weight: bold] } CSSStyleRule { selector: ".card:hover", declarations: [background: blue] } → 3 flat rules in CSSOM · cascade treats them independently
FIG 27·1 CSS Nesting 是语法糖—— parser 在 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.
CHAPTER 28

anchor positioning · view-transitions · color-mix — 2024 阵容

anchor positioning · view-transitions · color-mix — the 2024 lineup

三个把大量 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 positioningPopper.js / Floating UIposition-anchor: --foo + anchor() 函数position-anchor: --foo + anchor() function
view-transitionsFLIP / GSAPDOM 切换时浏览器自动渲染前后两帧并 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 POSITIONING · GEOMETRY (replaces Popper.js) SOURCE: .button { anchor-name: --my-btn; } .tooltip { position: absolute; position-anchor: --my-btn; position-area: top; } .button (anchor) anchor-name: --my-btn top-left top ★ top-right left right bottom-left bottom bottom-right .tooltip position-area: top CSS Anchor Position 给 absolute 定位的元素加了一个新参考系:另一个元素。配 9 区 position-area / anchor() 函数,大多数 popover/tooltip 完全不需 JS。
FIG 28·2 Anchor positioning 的几何。一个元素声明 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.

view-transitions · 浏览器自动 cross-fade 的真相

view-transitions · how the browser auto cross-fades

// 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 */
}
"声明式 FLIP" "Declarative FLIP" 2014 起前端有个 "FLIP" 技术(First / Last / Invert / Play): 测之前位置 → mutate DOM → 测之后位置 → 用 transform 把元素临时反向放回原位 → 跑 transition 让它"回到"新位置。这是整套 GSAP / Framer Motion 的核心。View Transitions 把这个模式声明化: 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.
CHAPTER 29

性能模型 — 一次 style update 多少 ms

Performance model — how many ms per style update

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).

stagecold loadfirst mounthover瓶颈风险bottleneck risk
① tokenize2 ms大 stylesheet 才显著only matters for huge sheets
② parse3 ms通常 okusually fine
③ CSSOM build1 ms通常 okusually fine
④ selector match5 ms0.05 ms⚠️ 长后代选择器⚠️ long descendant selectors
⑤ cascade2 ms0.02 ms通常 okusually fine
⑥ resolve var1 ms0.01 ms⚠️ :root 上太多 var()⚠️ too many vars on :root
⑦ compute2 ms0.02 mscolor-mix 在大量元素上color-mix on many elements
⑧ invalidate0.001 ms⚠️ :has() 反向⚠️ :has() backward
⑨ animate (per frame)0.5 ms⚠️ animate non-composited⚠️ animating non-composited
total per recalc6 ms10 ms~0.1 ms

5 个最常见性能陷阱

5 most common perf traps

在 :root 上扔太多 CSS 变量
Too many CSS variables on :root
Ch22 提过——任何 :root 上的 var 改动会让所有用它的元素重算。在 design system 里把所有 token 堆在 :root 是常见做法,但 token 切换时(暗色 toggle)整页 invalid。建议: 关键 token 用 @property 注册 typed,Blink 能更精细 invalidate。
As Ch22 showed — any change to a :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.
动画走 left/top 不走 transform
Animating left/top instead of transform
Ch24 提过——layout 触发 = main-thread 死循环。所有动画都应优先走 transform / opacity / filter
As Ch24 showed — triggers layout = main-thread death spiral. All animations should prefer transform / opacity / filter.
长后代选择器 (".a .b .c .d .e")
Long descendant selectors (".a .b .c .d .e")
即使有 bloom filter + right-to-left,选择器越长在不匹时剪枝越深。优先用 class naming convention(BEM、Tailwind)让选择器扁平。
Even with bloom + right-to-left, the longer the selector, the deeper the prune on a miss. Prefer flat selectors via class naming conventions (BEM, Tailwind).
用 ~ 选择器在长列表上
~ selectors on long lists
Ch21 提过——sibling subsequent invalidation 随兄弟数线性慎用
As Ch21 showed — subsequent-sibling invalidation scales linearly with sibling count. Use sparingly.
JS loop 里穿插读 + 写 (layout thrashing)
Interleaved read + write in JS loop (layout thrashing)
Ch10 提过——getComputedStyle 同步触发 layout。把所有读批量做,再批量写。requestAnimationFrame 内更好。
As Ch10 showed — getComputedStyle forces synchronous layout. Batch all reads, then batch writes. Best inside requestAnimationFrame.
CSS PIPELINE COST · 9 STAGES · M2 + CHROME 130 MEDIAN 0 2 µs 5 µs 10 µs 50 µs 3 µs tokenize 3 µs parse 1 µs CSSOM 0.15 µs match per el 0.2 µs cascade per el 1 µs resolve var per el 0.2 µs compute per el 0.5 µs invalidate per change 50 µs animate per frame stages ①-③ 是每个 stylesheet 一次性;④-⑦ 是每个元素;⑧ 是每次 DOM 改动;⑨ 是每帧(动画 paint 主导)
FIG 29·1 CSS 引擎 9 个阶段的实测开销 (M2 + Chrome 130 中位数)。解析(①-③)每个 stylesheet 一次,几 µs 量级。匹配/级联(④-⑦)每个元素,各 ~0.2-1 µs。失效(⑧)每次 DOM 改动,~0.5 µs。动画(⑨)每帧,paint 主导可到 50 µs(非 offload 时)。瓶颈始终是非 offload 动画——所以 Ch24 的 transform/opacity 建议特别重要。 Fig 29·1 · The 9 stages' measured cost (M2 + Chrome 130 median). Parsing (①-③): once per stylesheet, microseconds. Matching / cascade (④-⑦): per element, ~0.2-1 µs each. Invalidation (⑧): per DOM change, ~0.5 µs. Animation (⑨): per frame, paint-dominant up to 50 µs when not offloaded. The bottleneck is always non-offloaded animation — making Ch24's transform/opacity advice critical.

DevTools Performance 里的 trace event 名字

Trace event names you'll see in DevTools Performance

trace event对应阶段stage健康阈值healthy
ParseAuthorStyleSheetCh6-7 parse + CSSOMCh6-7 parse + CSSOM< 5 ms / 50 KB
RecalcStyleCh11-19 完整 style resolutionCh11-19 full style resolution< 8 ms / frame
UpdateLayoutTreeCh4 LayoutObject 重建Ch4 LayoutObject rebuild< 4 ms / frame
InvalidateStyleCh20-22 InvalidationSet walkCh20-22 InvalidationSet walk< 1 ms / change
Animation::UpdateCh23-25 main-thread anim tickCh23-25 main-thread anim tick< 2 ms / frame
cc::Animation::TickCh24 compositor offload animCh24 compositor offload anim< 0.1 ms / frame (GPU)
ContainerQueryEvaluatorCh26 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
关键阈值 · 60 fps 的 16.67 ms 帧预算 Critical threshold · the 16.67ms frame budget 60 fps = 16.67 ms/帧。CSS 占的部分大约是: ① 主线程 8 ms 可用(其余 ~8 ms 给 GPU 和 paint);② 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.
CHAPTER 30

DevTools — 三个面板覆盖 80% 调试场景

DevTools — 3 panels cover 80% of debugging

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
DevTools STYLES PANEL · ANATOMY (where each Ch maps) Styles Computed Layout Event Listeners element.style { padding : 24px; } → Ch10 inline style .card { background : rgb(106, 144, 199); padding : 0.5rem; } → Ch7 matched · Ch15 winner → Ch15 cascade · "overridden" @layer base { .card { padding: 0.5rem; } } → Ch17 named layer · lost html { /* UA */ } → Ch4 UA stylesheet Inherited from div.container → Ch15 step ⑦ inheritance DevTools Styles panel essentially visualizes Ch15's 8-step cascade · winning declaration on top · losers crossed out · grouped by origin and layer
FIG 30·1 DevTools Styles 面板的结构就是 Ch15 cascade 的可视化。顶上是 winner;划线的是被覆盖的 declaration(还要标明被哪一条赢了);底部是 UA 默认。每一行都对应本文一个或多个章节。这就是 "DevTools 是 CSS 引擎的镜子" — 它让你能看见 cascade 算法跑下来的样子。 Fig 30·1 · DevTools Styles panel structure is Ch15 cascade visualized. Top: winner. Strikethrough: overridden declarations (with which rule won shown alongside). Bottom: UA defaults. Every row maps to one or more chapters of this article. This is why "DevTools is the engine's mirror" — it lets you see the cascade algorithm running.
APPENDIX · REFERENCES

References — 每个论断的出处

References — sources for every claim

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.

A · W3C / CSS WG specs · 核心

A · W3C / CSS WG specs · core

Syntax 3
REC css-syntax-3 · tokenization & parsing · Ch6-9 全部基础。Foundation of Ch6-9.
Selectors 4
REC selectors-4 · selectors + matching · Ch11-14 + Ch16 specificity。Ch11-14 + Ch16 specificity.
Cascade 5
REC css-cascade-5 · cascade + @layer · Ch15-19 的 8 步级联规则。Ch15-19's 8-step cascade.
CSSOM 1
REC CSSOM 1 · JS-facing IDL · Ch7 + Ch10 JS API。Ch7 + Ch10 JS API.
Values 4
REC css-values-4 · four-value model · Ch19 spec/computed/used/actual。Ch19 four-value model.
Variables 2
REC css-variables-2 · Ch18 custom property。Ch18 custom properties.
Color 5
REC css-color-5 · color-mix() / oklch · Ch28 现代色彩。Ch28 modern color.

B · 2022-2026 新特性

B · 2022-2026 new features

Cascade Layers
REC @layer (Cascade 5 §5) · 2022 ship。Ch17。2022 ship · Ch17.
Container Queries
REC css-contain-3 · @container · 2022 ship。Ch26。2022 ship · Ch26.
:has()
REC selectors-4 §10 · :has() · 2023 ship。Ch14。2023 ship · Ch14.
Nesting
REC css-nesting-1 · 2023 ship。Ch27。2023 ship · Ch27.
@scope
REC css-cascade-6 · @scope · 2023 ship。Ch27。2023 ship · Ch27.
scroll-anim
REC scroll-animations-1 · 2024 ship。Ch25。2024 ship · Ch25.
anchor pos
REC CSS Anchor Position 1 · 2024 ship。Ch28。2024 ship · Ch28.
VT
REC CSS View Transitions 1 · 2024 ship。Ch28。2024 ship · Ch28.

C · Blink 源码 · 关键文件

C · Blink source · key files

third_party/blink/renderer/core/css/ · pin 到 Chromium 130third_party/blink/renderer/core/css/ · pinned to Chromium 130
tokenizer
SRC parser/css_tokenizer.cc · 460 行。Ch6。460 lines · Ch6.
parser
SRC parser/css_parser_impl.cc · Ch7 + Ch9 错误恢复。Ch7 + Ch9 error recovery.
rules
SRC css_rule.h · 19 个 CSSRule 类型。Ch7 + Ch10。19 CSSRule types · Ch7 + Ch10.
selector
SRC selector_checker.cc · 右到左匹配 · Ch12。Right-to-left matching · Ch12.
ruleset
SRC rule_set.cc · 两层索引 + bloom · Ch13。Two-layer index + bloom · Ch13.
cascade
SRC resolver/style_cascade.cc · 8 步 cascade · Ch15。8-step cascade · Ch15.
var data
SRC css_variable_data.cc · var() 解析 + 循环检测 · Ch18。var() resolution + cycle detect · Ch18.
invalidation
SRC core/css/invalidation/ · 10+ 文件 · Ch20-22。10+ files · Ch20-22.
style engine
SRC style_engine.cc · StyleEngine · 把 DOM 和 CSSOM 编织 · Ch10。StyleEngine · weaves DOM + CSSOM · Ch10.
animation
SRC core/animation/ · Web Animations 实现 · Ch23-25。Web Animations impl · Ch23-25.
UA css
SRC html.css · UA stylesheet · Ch4 · 浏览器内置的默认样式。Ch4 · browser's built-in defaults.

D · 设计文档 / blog

D · design docs / blog posts

InvalSet
BLOG Style Invalidation in Blink (Esprehn, 2014) · InvalidationSet 设计原文。Ch20 必读。The original InvalidationSet design. Required reading for Ch20.
:has()
BLOG Bramus · the rise of :has() · Ch14 历史背景。Ch14 historical context.
perf
BLOG High Perf Animations · Paul Lewis · Compositor offload 的圣经。Ch24。The Compositor offload bible. Ch24.
layers
BLOG Chrome team · cascade layers · Ch17 入门讲解。Ch17 intro explainer.
thrashing
BLOG paulirish · what forces layout · 每个会触发 sync layout 的 API。Ch10/Ch22。Every API that forces sync layout. Ch10/Ch22.
specifish
BLOG specifishity.com · Specificity 的可视化讲解。Ch16。Visual specificity explainer. Ch16.

E · 历史 / 文化

E · history / culture

Lie 1996
BLOG Lie & Bos · history of CSS · Ch1/Ch2/Ch9 · forgiveness 的根源。Ch1/Ch2/Ch9 · the root of forgiveness.
Blink fork
BLOG Blink project page (2013 fork) · Ch2 时间线。Ch2 timeline.
Baseline
DOC Web Baseline · 现代 CSS 跨浏览器支持矩阵。Modern CSS cross-browser support matrix.

F · 跨文章互联

F · sibling articles in this series

renderer
字节码到像素 · Chromium 渲染流水线全景 · Ch3 链回它的 13 段全图;Ch24 链回 compositor 章。Ch3 links back for full 13-stage pipeline; Ch24 links to compositor chapters.
v8
V8 是怎么把 JS 跑快的 · Hero 提到的姊妹篇之一。One of the sibling articles in the Hero.
react
React 内部 · 一次 setState 的一生 · CSS-in-JS 和 React 之间的接缝。The seam between CSS-in-JS and React.
jank
测量"流畅" · 从 FrameTime 到 Stutter · Ch22/Ch29 链回 style recalc 拖帧测量。Ch22/Ch29 cross-link for style-recalc jank diagnostics.

本节共 ~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 (本文末)