一行代码的事,Web 为什么做了三十年还没做到

你大概率曾经注意到,在手机上用原生 app 刷信息流的时候,滚动通常很顺滑。但在浏览器里打开一个内容密集的网页,快速滑动时经常会看到内容跳动、布局闪烁、空白区域先出现再被填上内容。这不是网速的问题。很大一部分原因是,浏览器在计算"每段内容排完版占多大空间"这件事上,比原生平台慢了一个数量级。

Pretext 最近在技术社区很火,做的就是这件事:预测一段文字放进容器后会占多少空间。一位叫马工的读者看完后问了一个很好的问题:这不应该是最基本的功能吗?为什么到 2026 年了还需要一个第三方库来解决?

我顺着这个问题往下挖,发现它触及的东西远比前端技术选型深。Facebook 在 2012 年因为没理解它背后的 trade-off,付出了重写整个移动端的代价。这不只关乎前端工程师。它是一个关乎系统设计哲学的判断:你在做抽象的时候,该把什么藏起来,该把什么留在外面。

在其他平台上,这真的就是一行代码

先验证马工的直觉。在 iOS 上,你想知道一段文字在给定宽度下排完版占多高,调用 sizeThatFits 就行。一行代码,立刻返回,甚至不需要把这段文字放进任何界面。Android 上用 StaticLayout,Qt 用 QFontMetrics,Flutter 用 TextPainter,都是同一个模式:排版引擎是一个独立的计算模块,你给它输入,它给你输出,不触发任何全局操作。

这就是为什么原生 app 滚动长列表的时候通常很流畅:系统可以提前算好每条内容的高度,精确地知道哪些内容即将进入屏幕、该为它们预留多少空间。

Web 上做不到这件事。在浏览器里想知道一段内容排完版多高,你得把它真的放进页面,然后触发一次 Reflow,也就是浏览器重新计算页面中所有受影响元素的位置和大小。这个计算是同步阻塞的,而且范围不是只算你问的那个元素,而是可能波及整棵布局树。一个内容密集的信息流页面,窗口大小一变,每条内容都要重算一遍,每次重算都牵动整个页面。这就是为什么 Web 版的信息流、聊天界面、电商列表在滚动和窗口缩放时,体验通常比原生 app 更差。

马工说得对:这确实应该是最基本的功能。而且在 iOS、Android、Qt、Flutter 上,它真的就是最基本的功能。Web 是主流 UI 平台中唯一一个做不到这件事的。

但 Web 工程师不可能三十年都没想到。这里一定有一个 trade-off。而且理解这个 trade-off 很重要。

CSS 的选择不是因为蠢

构建界面有两种方式。

一种是你告诉系统每个东西放哪里。早期 iOS 开发就是这样,你手动计算每个元素的坐标和尺寸。系统严格执行,你对每一步都有精确理解。在这种方式下,"排完版了多高"是一个自然的中间数据,因为你本来就要用它来决定下一个元素放在哪。

另一种是你描述你想要什么效果,让系统自己算。CSS 就是这样。你写 display: flex; flex-wrap: wrap; gap: 16px,不管屏幕是 320px 还是 1920px,浏览器自己决定每行放几个元素、怎么分配空间。你不控制过程,你描述意图。

后者的天花板更高。LaTeX 是更极端的例子。它的断行算法把整个段落当作一个优化问题来求解,考虑所有可能的断行方案,选一个让全段行间松紧度最均匀的。它可能会让前面几行排得稍松,来避免后面某一行出现难看的大空隙。你用逐行手动排版写不出这种效果,因为排第三行的时候你不知道第七行会怎样。只有一个能看到全局的系统才能做这种优化。

CSS 的响应式布局也是同一个逻辑。有一位做过 WPF 和 XAML 的开发者在 Hacker News 上说,用 CSS Flexbox/Grid 做自适应布局比很多原生桌面框架的开发效率更高,因为你在描述意图而不是在编写实现。

但代价是什么?代价是你没法问系统"你算出来的结果是什么"。在 CSS 的世界里,一个元素的最终大小取决于它周围的所有元素。浮动、定位、行内格式化上下文、margin collapse,每一条规则都在强化同一个事实:局部结果是全局求解出来的,不存在脱离上下文的独立答案。1994 年 CSS 的第一份提案就确立了这个架构:信息单向流动,开发者声明规则,浏览器执行排版,但浏览器不反馈中间结果。

在文档排版的场景下,这完全合理。你不需要知道段落精确多高,你只要声明样式,浏览器负责呈现。LaTeX 用户也一样。你不控制图片出现在哪一页,你告诉 TeX 你的偏好,它自己决定最优位置。

不理解这个 trade-off 的代价可能高达上亿美元。2012 年,Facebook 用 HTML5 构建了整个移动端。iOS 和 Android 的 app 本质上是对 WebView 的封装。选择 HTML5 确实直观:write once, run everywhere,服务端推送更新不需要用户下载新版本。但最终大家发现,有个致命问题是性能扛不住。Facebook 工程师事后复盘的时候列举了一系列问题:滚动帧率不稳定、UI 线程卡顿、设备资源耗尽导致崩溃。DOM 的全局 Reflow 是核心瓶颈之一:每次内容更新都可能触发整棵布局树的重新计算,News Feed 那种长列表加大量图片的场景直接把它推到了极限。

最终 Facebook 花了9个月从头重写原生 iOS 应用。启动时间从约10秒降到约4秒,News Feed 加载速度提升一倍。Zuckerberg 在 TechCrunch Disrupt 上说:

The biggest mistake that we made as a company was betting too much on HTML5 as opposed to native.

后续的发展也很值得讨论。HTML5 失败直接催生了 React(2013),React 的声明式思路被验证后催生了 React Native(2015),React Native 的布局引擎 Yoga 在设计上明确优化了排版查询。Facebook 工程博客写道,Yoga 确保文本视图"只被测量尽可能少的次数,理想情况下只测量一次",并且把布局计算放到了独立线程上,彻底绕开了 DOM Reflow 中 JavaScript 和布局互相阻塞的问题。

这每一步都在逃离 CSS 布局架构的限制。因此,理解 CSS 为什么做了这个 trade-off,以及这个 trade-off 在什么场景下会崩溃,是一个价值数亿美元的判断。

但这个 trade-off 不是必须的

到这里,故事听起来像一个非此即彼的选择:要么选"系统替你决定"拿到更好的排版效果,代价是看不到中间结果;要么选"你自己控制一切"拿到精确可查询性,代价是失去全局优化。

但事实上,这个 trade-off 是可以打破的。

SwiftUI(Apple 2019 年推出)和 Jetpack Compose(Google 2021 年推出)都是声明式 UI 框架。你用 SwiftUI 写响应式布局的方式和用 CSS 写 Flexbox 在概念上非常接近:描述意图,系统自行决定布局。但它们都没有 CSS 的问题。

内里的原因在于架构分层。原生平台的设计是:底层有一个完全独立的排版引擎(iOS 的 Core Text,Android 的 StaticLayout),上层是声明式框架。声明式框架调用底层引擎来完成布局,但应用代码也可以随时穿透声明式抽象,直接调用底层引擎查询排版结果。Jetpack Compose 甚至提供了官方的 TextMeasurer API,可以在不触发任何实际渲染的情况下拿到完整的排版信息,包括宽高、行数、每个字符的位置。

这证明了一个重要的事情:声明式和可观测并不矛盾。你可以让系统替你做全局优化,同时保留一条独立的通道让你查询中间结果。关键是排版引擎要被设计成一个独立模块,而不是焊死在全局布局管线里。

CSS 的问题不是它选择了声明式。问题是它在做抽象的时候,把排版引擎也封进了全局布局流程,没有留一个独立的查询接口。(叠甲:canvas.measureText() 只能处理单行文本,不支持换行。处理换行的那套逻辑被锁在了布局引擎内部,从未作为独立接口暴露。)

1994 年设计 CSS 的时候,Web 是一个学术文档交换系统,没人预见到它会成为应用平台。在文档场景下,你确实不需要独立查询排版结果,所以没人觉得需要把排版引擎设计成可以单独调用的模块。这个决策在当时完全合理。但三十年后,它成了一笔巨大的技术债。

W3C 也对这个问题心知肚明。CSS Houdini 的 Font Metrics API 就是为了解决它,但截至 2026 年仍停留在提案阶段,没有浏览器实现。Pretext 用4000行用户态代码补了这个缺口。它在 Web 上重建了一个 iOS 和 Android 早就作为基础设施提供的能力。它的存在本身就是 CSS 缺失了一层抽象的证据。

这个教训比前端大得多

回头看,CSS 和原生平台都选择了声明式布局,但原生平台保留了排版引擎作为独立的可查询层,CSS 没有。一个在 1990 年代做出的、当时看起来无关紧要的分层决策,在三十年后导致了完全不同的开发体验,完全不同的性能特征,和一家公司数亿美元的战略代价。

这个模式在前端之外同样存在。

传统 API 设计遵循的哲学和 CSS 一样:隐藏复杂性,保护用户不需要看到中间状态。捕获底层错误后抛出一个抽象的高层异常,把实现细节封装在干净的接口后面。这在用户是人类的时候没问题。人类的认知带宽有限,好的抽象帮他们聚焦。

但当系统的用户变成需要做决策的代码或 AI 的时候,这种保护性抽象就成了障碍。AI 的有效性依赖于尝试-反馈-修正的循环。一个模糊的"操作失败,请稍后再试"会直接中断这个循环,和 CSS 不告诉你排版结果是同一个问题。AI 需要的是精细原始的反馈、细粒度的控制接口、足够详尽的中间状态。前端排版三十年的挣扎和 AI 工程今天遇到的问题,根源是同一个设计哲学在不同时代的碰壁。

解法也是类似的。Pretext 在 CSS 的声明式渲染旁边建了一条独立的可查询通道,不替代浏览器的排版,只是让你能观测到排版结果。Agentic loop 对 AI 工作流做了同样的事:不规定 AI 每一步怎么做,但让它能观测到自己行动的结果,然后自主决定下一步。两者都没有拆掉抽象,而是在抽象上开了观测窗口。

马工说"前端很多问题都是因为缺乏合适的架构造成的"。这个方向是对的,但可以更具体精确:问题不在于 CSS 选择了声明式,声明式的天花板确实更高。问题在于 CSS 在做抽象的时候,把不该藏的东西也藏了。好的抽象让你选择在哪一层工作,坏的抽象把所有层粘在一起让你没得选。

下次你设计一个系统、做一层抽象的时候,值得问自己一个问题:你有没有把使用者未来需要观测的中间状态封进了黑盒?这个决策今天可能看起来干净优雅。但 CSS 用三十年证明了,这可能是一笔到期时间很长、利息很高的技术债。

Comments