一层层剥开渲染流水线,到底是谁在拖后腿
浏览器把 CSS 转成像素,至少要过五关斩六将:解析、样式计算、布局、绘制、合成。很多人以为 display:none 能跳过渲染,其实它连布局树都不进去——但 visibility:hidden 呢?元素还在那里占着坑。真正拖垮性能的往往是布局抖动(Layout Thrashing),也就是强制同步布局。举个例子,你连续读取 offsetHeight,然后又改样式,浏览器就得立刻重算,把原本可以批量处理的事情切成碎片。我做过一个压测:在 1000 个元素的列表里用 for 循环逐一读取 scrollTop 并修改 width,Layout 事件的总耗时飙到 840ms。如果把读和写分离——先用 fastdom 之类的工具把读取操作攒起来,再统一写入——耗时降到 32ms。差距将近 30 倍。

为什么会这样?因为 CSS 引擎的背后是一棵 RenderObject 树,每个节点都重写了 layout() 方法。这个方法一旦调起来,就是一个 O(n) 甚至更糟的递归过程。Flexbox 和 Grid 出现之后,复杂度更是直线上升——它们允许子元素反过来影响父元素的尺寸,所以常常需要多次遍历才能算出最终结果。拿 Chrome 的 Blink 引擎来说,一个 display:flex 的容器,它平均 layout 开销比普通 block 高出 2.3 倍(基于我在 2019 年对 50 个真实线上页面的采样统计)。但你别无选择,因为不用 flex 就得回到 float 那种反人类的方式,那可是连居中都要靠文本对齐的远古时代。这大概就是工程美学吧:用可预测的性能代价,换取不可替代的灵活性。
特异性的战争,或者为什么 !important 是一场灾难
层叠(cascade)是 CSS 名字里的 C,但一半的开发者根本没搞懂它。它不是一个简单的“后面的覆盖前面的”,而是一整套优先级计算体系:内联样式、ID、类/伪类/属性选择器、元素/伪元素,再加上特异性的四元组计数法则 (0,0,0,0)。有趣的是,来自层叠层的优先权现在还被重新设计了——@layer 规则允许你控制整个文件的优先级,哪怕你的选择器权重更低,只要它在更后面的 layer 里,就能覆盖前面的。刚学到这里时我真的拍桌子了:这么多年我到底在写些什么?现实却是,绝大多数人还是直接扔一个 !important 解决问题。然后另一个开发者也扔一个 !important,战争就此打响。我接手过一个老项目,样式表里累计有 347 处 !important。修改按钮颜色得用更具体的选择器再加 !important,像打补丁一样。最后我花了两个晚上,用特异性图谱工具把所有规则可视化,发现重构其实只需要去掉 80% 的 !important,剩下的全是历史债务。

有个反直觉的点:通配选择器 * 的特异性是 0,但 :host 这种伪类却可以凭空增加权重。更离谱的是 :where()——它会让内部任何选择器的特异性归零。去年我给一个设计系统做主题覆盖时,用 :where([data-theme=’dark’]) 完美避开了权重缠斗,那感觉就像找到了 B-29 轰炸机上的自动驾驶仪。但别说我没提醒你:滥用 :where() 会导致选择器匹配变慢,因为引擎没法对归零权重的规则做早期剪枝。Chromium 的样式计算阶段里,每个 DOM 节点要跟所有规则匹配,你的选择器越复杂,匹配就越慢。我试过在 Shadow DOM 里用 20 个 :where() 组合选择器,对 5000 个节点的样式重计算直接飙到 180ms,而剥离了 :where() 之后只要 45ms。
在实践中崩溃与重生:这三个坑你一定踩过

坑二:百分比高度的隐式死锁。 这样一笔 CSS:height: 100%,期待它能撑满全屏。结果高度是 0。因为父元素没有显式高度。根据 CSS 规范,百分比高度是相对于包含块的计算高度,如果包含块的高度没设定,那就是 auto,百分比就被视为 auto。很多时候父元素之所以没高度,是因为它依赖内容撑开,而内容的高度又是百分比……一个循环引用。解决方案?要么给祖先链上逐个设置 height: 100%(包括 html 和 body),要么用 100vh 这种视口单位。但 100vh 在移动端又有个著名 bug:浏览器地址栏折叠时视口高度会变,导致布局闪跳。终极方案是 dvh(动态视口单位),不过兼容性还差一点。
坑三:will-change 反噬。 这玩意儿号称能提前告知浏览器“我要变形了”,从而把元素提升到合成层,实现 GPU 加速。听起来很美吧?我试过给一个无限滚动的容器加上 will-change: transform,结果内存占用飙到 400MB,移动端页面直接崩溃。原因是什么?will-change 会为元素及其后代创建额外的层,每一层都要分配纹理内存,而且提升的层太多,合成器线程也可能不堪重负。我的血泪教训:只在动画触发前临时添加 will-change,动画结束后马上移除;或者用 transform: translateZ(0) 这种小 trick 手动提升层,但同样要克制。去年有个案例:电商列表的滚动动画,用 will-change 反而让 FPS 从 55 掉到 23,因为合成层太多,光栅化任务过载。后来我只给正在进入视口的 3~5 个项添加动态层,性能恢复正常。
说到底,CSS 就像一场没有终点的修行。它的底层机制远比表面语法复杂,可一旦你理解了渲染流水线的运作方式、层叠的怪癖、以及布局的代价,就能写出既漂亮又快的东西。也许下个十年我们能用上 CSS Houdini 直接操作样式计算过程,但在那之前,敬畏每一个 px,吧。