第二章:2013,一套看起来倒退的 UI 写法
2013 年 5 月,React 在 JSConf US 对外发布;5 月 29 日,仓库出现提交 75897c2,提交信息只有一句:Initial public release。
对外叙事非常克制。发布后一周的官方文章《Why did we build React?》先说 React 不是 MVC framework,再说它不使用模板。这两句几乎是在主动放弃当时最容易被理解的分类。
React 的提案是:把界面拆成组件;组件的 render 使用完整 JavaScript 返回一份轻量描述;数据改变时再次调用 render;运行时比较前后两份描述,只把必要变化应用到 DOM。官方还特别强调,render 返回的既不是字符串,也不是 DOM 节点,而是“DOM 应该长什么样”的描述。
社区首先看到的是 JSX
JSX 把类似 HTML 的标记放进 JavaScript。对于习惯“关注点分离”的开发者,这看起来像回到 PHP 时代:逻辑和标记又混在一个文件,甚至需要编译步骤。React 团队后来回顾,JSX 像一个早期过滤器——愿意越过这种陌生感的人,往往更能理解 React 真正想改变的编程模型;更多人则在看到语法时就停止继续听。
但 JSX 一直是可选语法。初始仓库中已有 XJS/JSX transform,而运行时真正接收的是普通对象和函数调用。JSX 的价值不是发明另一套模板语言,而是让树形描述在 JavaScript 中足够可读,同时保持表达式、函数、数组和抽象能力。
传统“结构、样式、行为分离”按文件类型组织关注点;React 更倾向按组件组织:同一个业务概念的结构和交互放在一起,不同业务概念彼此隔离。它不是取消分离,而是改变分离轴。
本文判断:JSX 的争议替 React 遮住了一层更激进的设计。真正的变化不是“HTML 写进 JS”,而是 markup 不再是最终输出,也不再由专用模板引擎掌控;它只是创建 element description 的一种语法。
初始代码已经写下“纯 render”契约
打开初始版本的 ReactCompositeComponent.js,可以看到 render 协议旁的注释:不保证它何时、以什么频率被调用,因此不得有副作用。与此同时,同一接口里已经有 componentWillMount、componentDidMount、componentWillReceiveProps、shouldComponentUpdate、componentWillUpdate、componentDidUpdate 和 componentWillUnmount。
这形成了一个此后多年持续拉扯的边界:
render负责计算,理论上可以重做。- lifecycle 负责与外界同步,但不同 lifecycle 发生在计算前、提交后或卸载前。
state允许组件拥有局部记忆。refs允许跳出声明式模型。- mixin 允许多个行为向同一实例注入方法。
初始 API 因而既函数式又面向对象。React 不是把学术纯函数系统直接搬到浏览器,而是在当时 JavaScript、旧代码和产品交付约束下做的折中。后来的 Fiber 会暴露“pre-commit lifecycle 可能重复执行”的问题,Hooks 会重新组织 state 与副作用,但矛盾从第一版就存在。
不是“每次删掉 DOM 重建”
“重新 render”很容易被误解成重新创建全部 DOM。React 实际重建的是轻量描述,再由 reconciliation 生成最小化的宿主操作。2013 年文章称 TodoMVC 的这类 re-render 约为 1ms,并据此主张开发者不需要显式声明每条 data binding。
这个数字只是一项同期示例,不应被当作普遍性能保证;更重要的是论证方式:React 不要求“零冗余计算”,而是要求冗余计算可控,并用更简单的程序模型换取它。只要 JavaScript 中生成与比较描述的成本低于手工依赖管理带来的复杂度,交换就成立。
这种策略后来反复出现。Concurrent Rendering 会允许一次 render 被中断和重做;Strict Mode 会在开发环境故意重复部分调用;Compiler 会自动缓存某些计算。它们都建立在同一前提上:计算阶段可以被重放,真正不可逆的副作用必须延后或隔离。
初始架构远不只是一个 diff 函数
首个公开快照已经包含多个相互配合的子系统:
ReactCompositeComponent管理用户组件实例与 lifecycle。ReactMultiChild协调一组子节点。ReactReconcileTransaction把一次协调过程包装成事务。Transaction提供初始化与关闭 wrapper。EventPluginHub与SimpleEventPlugin组成事件抽取、排队和分发管线。- XJS transform 把 JSX 转成运行时调用。
- server rendering、测试工具、示例和文档与核心一起发布。
这解释了为什么把 React 概括成“Virtual DOM diff”会遗漏很多。描述树只是中间表示。一个可用于真实产品的 UI 系统还必须决定:何时关闭事件、何时恢复 selection、mount callback 何时运行、浏览器事件如何标准化、组件实例怎样保存 state、子节点怎样获得稳定身份。
史料事实:2013 年官方介绍已经列出 canvas chart、Instagram 与 Backbone Router 组合、Web Worker/原生 iOS 原型、server rendering,以及自动使用事件委托的跨浏览器事件层。多 renderer 并不是 React 成功后才附加的野心,而是轻量描述带来的早期可能性。
“不是 MVC”带来的自由与空缺
React 只声明自己是构建可组合 UI 的 library。它没有规定数据层、路由、网络请求或目录结构。这降低了增量接入门槛:团队可以在 Backbone、现有服务端应用或任意数据模型上替换一个局部 view,不必先接受整套框架。
代价是,新用户很快会问:状态放哪里?多个 store 如何同步?路由和数据获取怎么做?构建 JSX 又该选什么工具?React 的核心回答常常是“这不属于 React”。这种克制催生了 Flux、Redux、Relay、React Router、Create React App 和后来的全栈框架,也制造了长时间的选择疲劳。
开源第一年的怀疑
官方周年回顾记录,团队内部和公开社区都存在强烈怀疑。JSX、全量重新执行组件、把模板放进 JavaScript,都与主流经验相冲突。但一年内,项目累积了约 2,300 个提交、1,500 个 issue/PR,并接近 7,000 stars。数字不能证明架构正确,却说明它解决了足够真实的问题。
React 得以扩散,还有一个常被低估的原因:它能局部采用。开发者不必先重写整个应用;只要给 React 一个 DOM container,就能让它管理其中一棵子树。旧系统与新系统之间可以通过 props、callbacks、refs 或共享模型连接。这与 FBolt 在 Bolt 中逐组件迁移的路径一致。
本文判断:React 早期的传播不是因为社区立即接受了函数式 UI 哲学,而是因为它把激进模型包装成了一个可局部试用的 library。架构上的“全量描述”与组织上的“增量采用”恰好互补。
初始发布证明了开发体验,但还没有解释最关键的技术问题:组件每次都返回新对象,React 如何知道哪个旧节点仍是“同一个”?下一章进入 reconciliation、key、transaction 和合成事件,看看所谓 Virtual DOM 背后真正稳定下来的契约。