第一章:在 React 之前,界面为什么越来越难改
2010 年前后的 Facebook 已经不是一个由少量表单组成的网站。广告产品需要在同一屏里维护投放对象、预算、出价、素材、预览、验证结果和异步保存状态。每一块都可能影响其他部分,而且页面不能在用户改一个数字后整页刷新。
当时常见的解决方式,是让模型和视图彼此监听:字段变化后发出事件,依赖它的计算属性继续变化,相关 DOM 节点再被精确更新。小页面里,这种方式直观而高效;当依赖图变大,它会出现一个难以观察的特征——一次用户操作不再对应一条清楚的控制流,而会触发一串分散在不同对象里的监听器。
史料事实:React 团队后来回顾,广告组织当时使用内部 MVC 框架 BoltJS。Bolt 已包含后来进入 React 的若干名称和能力,例如
render、createClass与refs;随着应用复杂度上升,Bolt 代码库也越来越难维护。参见《Our First 50,000 Stars》。
Bolt 的问题不是“没有组件”
Bolt 已经有 view class、属性、子 view 和 ref。它的问题也不是完全缺少抽象,而是更新仍然围绕可变对象和定点操作展开。一个典型组件会先声明若干子节点,再在 setter 里找到某个 ref,命令它替换内容。组件越复杂,开发者越需要回答:这个 setter 会间接触发谁?某个子 view 的内容究竟由谁拥有?同一份状态是不是还被别的对象缓存?
这类系统倾向于把“最小 DOM 更新”当成开发者职责。看起来它避免了无关工作,却把整个应用的依赖图分散到大量回调中。局部代码每一段都合理,组合起来却很难预测。
参与者回忆:Jordan Walke 在 2017 年的 Reactiflux 访谈中说,他很早就不喜欢依赖 mutation 和 data binding 的 MVC 写法。后来学习 Standard ML,才获得了“不变性”“函数式编程”等术语,用来描述自己一直想要的 UI 模型。
这个回忆很重要,因为 React 的起点不是“怎样做一个更快的模板引擎”,而是“能不能把界面写成普通函数”。函数天然表达从输入到输出的转换。如果一次状态变化最终会让大量 computed binding 重新计算,那么与其维护一张精密的监听网络,不如直接重新调用描述 UI 的函数——前提是这件事足够快。
XHP 提供了另一块拼图
Facebook 从约 2010 年开始使用 XHP:一种把 XML literal 放进 PHP 的扩展。XHP 最初的重要目标之一是减少拼接字符串造成的 XSS 风险,但它也展示了另一件事:标记不一定非要被隔离在模板文件里,它可以是编程语言中的表达式;自定义标签也可以对应真正的组件。
XHP 并不是 React 的 JavaScript 实现,也没有提供浏览器内的 reconciliation。但它改变了团队对“标记与逻辑必须分离”的直觉。传统模板把可用抽象限制在模板语言里;XHP 让组件、条件、循环和数据结构回到完整语言之中。后来 JSX 受到的反对,某种程度上正说明这种经验还没有成为行业共识。
本文判断:XHP 给 React 的影响,不应被简化成“JSX 长得像 XML”。更深的继承是:UI 描述可以成为语言中的普通值,组件边界可以同时承载结构和行为,而不是让模板只负责静态外壳。
FaxJS:把整棵树再算一次
Jordan Walke 在业余实验 FaxJS 时,开始把这些想法合并起来。根据 React 团队的源码考古,FaxJS 已具备后来 React 的大部分基础轮廓:props、state、组件、服务端渲染,以及在状态变化后重新计算较大范围 UI、再比较结果的做法。
FaxJS 的代码今天看起来既熟悉又陌生。组件返回由函数构造的树,this.props 提供外部输入;但 API 名称、DOM 工厂和内部结构仍在快速变化。它更像一项用于证明模型可行的实验,而不是已经稳定的库。
Walke 还回忆,他最初曾用 Standard ML 粗略实现 reconciler 和 component model。由于当时 compile-to-JavaScript 工具链不成熟,他把探索移到 JavaScript。这个选择让 React 得以进入浏览器和 Facebook 现有工程体系,但函数式语言留下的影响没有消失:render 应尽量像纯函数,状态变化产生新描述,运行时负责把描述落实为宿主变化。
服务器渲染也不是多年后为 SEO 临时加上的附件。Walke 说,当时 JavaScript 构建完整应用仍会遭到强烈质疑;保留服务器输出能力,既回应首屏性能和兼容性约束,也帮助团队在组织内推销这个模型。React 后来一次次重写 SSR,源头却在最初的生存条件里。
FBolt:新模型靠增量接入活下来
2012 年 3 月,FaxJS 被导入 Facebook 代码库并改名为 FBolt。这个名字强调它仍可与 Bolt 共存。团队不是一次性把广告系统全部重写,而是先挑最适合函数式描述的组件替换,保留与旧 view 的互操作。
这一步决定了 React 很长时间里的工程气质:理念可以激进,迁移必须渐进。后来的 warning、codemod、双根 API、Concurrent Feature 渐进启用和 Compiler 分批 rollout,都能看到同一种组织经验——大规模产品不会因为架构更优就允许“停下来重写”。
FBolt 还保留了 refs。从纯函数角度看,ref 是不够优雅的逃生口;从迁移角度看,它让新组件能操作旧 DOM 或旧框架对象。React 从一开始就没有假装所有 UI 都能完全纯化。它更常见的策略是建立一条推荐的声明式主路,同时保留受控的命令式出口。
“React”这个名字说出了差异
当 Jordan Walke 与 Tom Occhino 把 FBolt 改名为 React 时,Walke 在内部 diff 中解释:许多系统也宣称 reactive,但通常要求建立点对点 listener,而且难以处理任意深度的结构化数据;这套 API 则会对 state 或 property 的变化重新产生描述,因此“React”是合适的名字。
这里的“react”并不是今天细粒度响应式系统所说的“自动追踪哪个字段被读取”。早期 React 选择的是粗粒度反应:组件收到新输入后重新执行。它用额外计算换取简单控制流,再依赖 reconciliation 避免把全部计算变成全部 DOM 重建。
这也是后续所有架构压力的起点:
- 重新执行必须足够便宜,所以 element 要轻量,render 要可重复。
- 对比必须可预测,所以组件类型、位置和 key 要参与身份判断。
- DOM 提交必须集中,所以需要 transaction、批处理和明确的生命周期阶段。
- 当树变大,连续重算会阻塞主线程,所以最终需要 Fiber。
- 当逻辑越来越多,class 实例和生命周期不利于复用,所以最终需要 Hooks。
- 当服务端与客户端共同生成树,描述与模块引用还要能跨网络,所以最终需要 Flight。
本文判断:React 后来的复杂度,不是背叛了最初的简单想法,而是为了在越来越大的时间、平台和网络尺度上继续维护那个想法——让开发者描述“现在 UI 应该是什么”,而不是手写“从上一个 UI 走到下一个 UI 的每一步”。
到 2012 年底,这个模型已经在内部找到落脚点,但还没有面对公开世界。下一章会看到,真正让社区震动的并不是 diff 算法,而是 React 公然否定了当时两条常识:View 应该由模板描述,更新应该尽量精确地绑定到变化点。