通过代码看 React 的历史

点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群

前言

讲述了 React 框架从诞生到现在的历史演变,以及它如何通过一系列的特性和改进,逐步成为一个性能优异且易于使用的前端库。今日文章由 @Corbin Crutchley 分享,前端早读课@飘飘编译。

译文从这开始~~

React 是一个有些 “奇特” 的 Web 开发框架。我发现他们的很多 API 都需要特定的思维模式才能正确使用;这是为什么呢?

多年来,我在使用 React 的过程中也一直有同样的疑问,直到某一天,突然恍然大悟。多年来聆听 React 核心团队的交流,再加上对这个工具演变过程的观察,终于让我彻底理解了它的设计逻辑。

今天我想分享一下让我终于理解 React 库背后的故事。这个故事同时从两个方向展开:一个是历史性的,另一个纯粹源自代码本身。

为什么要分开讲?因为对大多数人来说,很容易以为 React 的 API 是零散发展出来的,没有统一的思路。但如果我们从它诞生的起点出发,梳理 React 团队一路走来的理念,你会发现,事实并非如此。

我希望通过这个故事提出一个核心观点:React 自诞生以来,在 API 设计上的一致性令人惊讶。而正是这种一致性,造就了一种特有的思维模型 —— 只要你掌握了它,就能迅速成为 React 高手。

接下来我们会聊到:

  • React 的基础:历史、JSX 和虚拟 DOM

  • 早期的组件写法:类组件及其当时的用法

  • Hooks 的引入及其带来的变革

  • React 重写后的巨大转变

  • 为什么 React 的数据获取方式是今天这个样子

  • React 如何 “转向” 服务器端

  • React API 的短期发展方向

那我们就开始吧!

React 的起点

时间回到 2011 年,Facebook 遇到了一个问题。他们在内部广告团队用一个自研框架叫 “BoltJS”。虽然大部分(大概 90%)广告相关的功能都可以用 Bolt 实现,但在项目中仍有一些情况,团队不得不脱离自己的框架,采用不太声明式的解决方案。

虽然这不是一个需要立即解决的问题,但它给 Facebook 迅速壮大的团队带来了一系列新的问题;在庞大的团队中,10% 的比例很快就会在一致性、培训以及整体开发者体验方面成为问题。如果不加以控制,这必然会影响他们以期望的速度推出产品的能力。

这些问题让广告团队的一位成员 Jordan Walke 感到不舒服。其实,很少有编程范式让他满意。他曾在一次社区访谈中说:

“刚开始学编程时,我就觉得那种基于数据绑定和状态变更的传统 MVC 模式不太对劲。虽然当时我还没掌握像 ‘状态变异’、‘函数式编程’ 这些术语,但我知道那种方式不适合我。”

“我的代码经常被别人说奇怪。我曾一度觉得‘大概我就是个奇怪的程序员’。后来我终于上了一门编程语言原理课,才学会用术语表达我想构建应用的方式。”

于是,Jordan 开始尝试着解决他认为 Bolt 和其他框架存在的问题。他最初做了一个叫 “FaxJS” 的个人项目,后来改名为 “FBolt(Functional Bolt)”,再后来,它就成了 “React”。一个小团队也开始围绕这个新工具展开开发。

时间来到 2012 年,Facebook 状态很好 —— 好到刚花 10 亿美元收购了 Instagram。

当时 Instagram 有安卓和 iOS 的移动应用,但没有网页版。新加入 Facebook 的团队需要构建这个网页版本,但他们被要求使用 Facebook 自家的技术栈。

评估了 Bolt 和 React 之后,这个团队做了个决定:他们将成为第一个在生产环境中使用 React 的团队。

很快,他们意识到手上的这个工具不一般:开发速度很快,性能表现不错,开发体验也很好。于是有人开始提出把这个项目开源。

但这时候新问题来了:Facebook 内部已经有两个用于浏览器渲染的方案 ——Bolt 和刚起步的 React。

两个团队坐下来认真讨论,发现问题的复杂度超出了他们的权限范围。此时 Facebook 正值 IPO 低谷期,而广告产品是主要收入来源,这支广告团队才刚把一个大项目迁移到 Bolt。如果换成 React,可能要花上四个月重写,中间不能加任何新功能。

眼看着 React 的内部推广要泡汤了,CTO 出面了:“做正确的技术决策,着眼长远。如果这会带来短期代价,我支持你们。哪怕重写要花几个月,也去做。”

于是广告平台也迁移到了 React,并像 Instagram 那样取得了成功。

到 2013 年,推动开源 React 的团队逐渐占据主导,最终赢得内部共识。React 准备开源了:在 JSConf US 2013 上,Tom Occhino 和 Jordan Walke 正式发布了 React 的源码和文档。

模板语法的痛点

React 早期就提出一个理念:用 JavaScript 来写 HTML。

这带来了极大的灵活性。它不仅避免了需要定制模板标签来实现条件渲染或循环的麻烦,还让 UI 代码的迭代变得更加有趣和快速。

比如原来可能需要这样写模板(假设用的是某个模板框架):

<div>
   <some-tag data-if="someVar"></some-tag>
   <some-item-tag data-for="let someItem of someList"></some-item-tag>
 </div>

现在可以这样用 JSX 写:

const data = (
   <div>
     {someVar && <some-tag />}
     {someList.map((someItem) => (
       <some-item-tag />
     ))}
   </div>
 );

这样的方式有几个关键优势:

  • 模板可以在运行前就编译,开发时更早发现错误

  • JSX 不是字符串,天生具有更好的 XSS 安全性

  • 可以重用 JavaScript 的控制逻辑,避免在模板语言中重复造轮子

JSX 的语法也让 “模板转 JS” 这一步变得非常轻量。比如:

function App() {
   return (
     <ul role="list">
       <li>Test</li>
     </ul>
   );
 }

会被转换成:

function App() {
   return React.createElement(
     "ul",
     { role: "list" },
     [React.createElement("li", {}, ["Test"])]
   );
 }

这样一来,哪怕代码经过了转换,出错时的定位依然清晰,调试起来也更方便。

“关注点分离” 其实不是你以为的那个意思

JSX 经常被批评 “打破了关注点分离原则”。早期很多项目按语言类型来组织代码:

src/
   html/
     button.html
     card.html
   css/
     button.css
     card.css
   js/
     button.js
     card.js

但这其实是个很武断的分类方式。你很难快速找到某个功能相关的所有代码。

React 团队提倡的方式(也被很多现代项目采纳)是按功能模块组织代码:

src/
   button/
     button.html
     button.css
     button.js
   card/
     card.html
     card.css
     card.js

这种结构更有利于理解和维护代码,也更符合 React 的开发理念。

以 “任意时刻的状态” 来描述 UI

在 React 之前,像 Backbone.js 这样的框架通常是这样管理 UI 的:

<!-- index.html, shortened for brevity -->
 <div id="counter-app"></div>

 <!-- index.html 中的模板 -->
 <script type="text/template" id="counter-template">
   <p>Count: <%= count %></p>
   <button>Add 1</button>
 </script>
// app.js
 var CounterModel = Backbone.Model.extend({ defaults: { count: 0 } });
 var CounterView = Backbone.View.extend({
   el: "#counter-app",
   template: _.template($("#counter-template").html()),
   events: { "click button": "increment" },
   initialize() {
     this.listenTo(this.model, "change", this.render);
     this.render();
   },
   render() {
     var html = this.template(this.model.toJSON());
     this.$el.html(html);
   },
   increment() {
     this.model.set("count", this.model.get("count") + 1);
   },
 });

 var counterModel = new CounterModel();
 new CounterView({ model: counterModel });

在这里,我们正在做很多事情:

  • 从包含表示我们模板的字符串的脚本标签中读取初始模板

  • 定义模板中要使用的组件的数据模型

  • 手动绑定事件并在请求时将模板重新构建为 HTML

这里虽然也能实现功能,但数据和视图同步过程是手动管理的,一不小心就容易出问题。

而 React 的同类代码长这样:

<div id="root"></div>
 <script type="text/babel">
   var Counter = React.createClass({
     getInitialState: () => ({ count: 0 }),
     increment() {
       this.setState({ count: this.state.count + 1 });
     },
     render() {
       return (
         <div>
           <p>Count: {this.state.count}</p>
           <button onClick={this.increment}>Add 1</button>
         </div>
       );
     },
   });
   ReactDOM.render(<Counter />, document.getElementById("root"));
 </script>

虽然用 this.setState 显式更新了模板,但与 Backbone 最大的区别在于:

React 中的 render 方法不是 “初始模板”,而是 “每一次状态变化都用的模板”。

也就是说,我们不再关心 UI 是怎么一步步 “变成” 现在这个样子的,而是用状态直接描述 “现在这个样子”。这种思维方式来源于 Jordan 对函数式编程的学习:数据应该是不可变的,视图是状态的纯函数。

更棒的是,这种响应式的方式也非常高效:点击按钮、更新状态、自动重新渲染 —— 一切都很自然。

让模板具备响应性

JSX 提供了极大的灵活性,但它也意味着每次状态变化时,模板中的所有节点都需要重新执行,才能用新数据构建 DOM。

没有虚拟 DOM 的话,React 会重新渲染所有组件

对于小型应用,这种方式问题不大;但如果是大型 DOM 树,频繁的整体重渲染会带来明显的性能问题。

为了解决这个问题,React 团队引入了一个概念:虚拟 DOM(VDOM)。这个虚拟 DOM 是浏览器 DOM 在 JavaScript 中的一个副本。当 React 创建 DOM 节点时,它也会将副本记录在自己的 VDOM 中。

这样,当某个组件需要更新 DOM 时,React 就会先与 VDOM 做对比,仅对需要更新的部分进行重新渲染。

有了 VDOM,React 就可以把重渲染限制在受影响的组件子树中

这是一项巨大的性能优化,使得 React 应用可以大规模扩展而不牺牲性能。

从内部实现角度看,React 在 “协调(reconciliation)” 阶段引入了差异比较(diff)步骤。值得一提的是,React 在早期版本中就对 VDOM 的 diff 算法做了不少优化。

React 处理状态变化 → 触发 VDOM diff → 预更新 VDOM → 最终提交真实 DOM

React 早期的开发体验

React 在 2013 年推出时采用的是类组件(class-based components);Hooks 直到 2019 年才发布。类组件的出现很棒,它让我们可以将代码模块化,但也带来了一些问题。

比如组件的核心理念是 “组合性”:可以通过已有组件构建新组件:

// 已有组件
 class Button extends React.Component { /* ... */ }
 class Title extends React.Component { /* ... */ }
 class Surface extends React.Component { /* ... */ }

 // 新的组合组件
 class Card extends React.Component {
     render() {
         return (
             <Surface>
                 <Title />
                 <Button />
             </Surface>
         );
     }
 }

没有这样的组合能力,React 根本无法扩展到大型项目中。但问题在于:类组件的内部逻辑是不可组合的。

来看这个例子:

class WindowSize extends React.Component {
     state = {
         width: window.innerWidth,
         height: window.innerHeight,
     };
     handleResize = () => {
         this.setState({
             width: window.innerWidth,
             height: window.innerHeight,
         });
     };
     componentDidMount() {
         window.addEventListener("resize", this.handleResize);
     }
     componentWillUnmount() {
         window.removeEventListener("resize", this.handleResize);
     }
     render() {
         // ...
     }
 }

这个 WindowSize 组件获取了浏览器窗口的宽高,将其存储在 state 中,并在变化时触发组件的更新。

假设我们想在多个组件中重用这套逻辑。根据面向对象编程的思路,可以使用类继承(class inheritance)。

直观但短视的继承方案

不修改 WindowSize 组件的代码,我们可以用 extends 来让新组件继承它的方法和状态:

class MyComponent extends WindowSize {
     render() {
         const { windowWidth, windowHeight } = this.state;
         return (
             <div>
                 The window width is: {windowWidth}
                 <br />
                 The window height is: {windowHeight}
             </div>
         );
     }
 }

这个简单示例可以运行,但它的问题也很明显:一旦 MyComponent 变复杂,就得小心处理继承逻辑,比如必须调用 super 方法:

class MyComponent extends WindowSize {
     state = {
         ...this.state, // 继承父类状态
         counter: 0,
     };
     intervalId = null;

     componentDidMount() {
         super.componentDidMount(); // 继承父类生命周期方法
         this.intervalId = setInterval(() => {
             this.setState((prevState) => ({ counter: prevState.counter + 1 }));
         }, 1000);
     }

     componentWillUnmount() {
         super.componentWillUnmount(); // 清理事件监听
         clearInterval(this.intervalId);
     }

     render() {
         const { windowWidth, windowHeight, counter } = this.state;
         return (
             <div>
                 The window width is: {windowWidth}
                 <br />
                 The window height is: {windowHeight}
                 <br />
                 The counter is: {counter}
             </div>
         );
     }
 }

但只要漏掉一个 super(),就可能引发意外行为,甚至内存泄漏。

于是,React 社区提出了一种更好的方案:高阶组件(Higher-Order Components, HoC)。

社区实践:高阶组件

借助高阶组件,能够避免要求用户在其代码库中进行 super 调用,而是从基类接收参数作为 props 传递给扩展类:

const withWindowSize = (WrappedComponent) => {
     return class WithWindowSize extends React.Component {
         state = {
             width: window.innerWidth,
             height: window.innerHeight,
         };
         handleResize = () => {
             this.setState({
                 width: window.innerWidth,
                 height: window.innerHeight,
             });
         };
         componentDidMount() {
             window.addEventListener("resize", this.handleResize);
         }
         componentWillUnmount() {
             window.removeEventListener("resize", this.handleResize);
         }
         render() {
             return (
                 <WrappedComponent
                     {...this.props}
                     windowWidth={this.state.width}
                     windowHeight={this.state.height}
                 />
             );
         }
     };
 };

 class MyComponentBase extends React.Component {
     render() {
         const { windowWidth, windowHeight } = this.props;
         return (
             <div>
                 The window width is: {windowWidth}
                 <br />
                 The window height is: {windowHeight}
             </div>
         );
     }
 }

 const MyComponent = withWindowSize(MyComponentBase);

在 Hooks 出现前,这是组件逻辑复用的主流方式。

不过,它也有缺点:开发者需要知道有哪些 props 是高阶组件提供的,不太适合 TypeScript 类型检查,而且总给人一种 “外挂工具” 的感觉,而不像是 React 的内置能力。

类组件的早期替代方案

2015 年,React 0.14 发布,带来了类组件的替代方案:函数组件(function components)。

React 团队曾说过,类组件其实就是 “一个带状态容器的 render 函数”。那如果把状态拿掉,只保留 render 呢?

于是下面这段类组件:

var Aquarium = React.createClass({
     render: function () {
         var fish = getFish(this.props.species);
         return <Tank>{fish}</Tank>;
     },
 });

可以简化成:

var Aquarium = (props) => {
     var fish = getFish(props.species);
     return <Tank>{fish}</Tank>;
 };

写法更简洁了,但也有个大问题:函数组件没有自己的状态(state)。

这限制了它在实际项目中的使用,因此许多人还是选择继续使用类组件。

开发体验的成熟期:Hooks 登场

React 16.8 发布时,引入了 Hooks,彻底解决了函数组件无法拥有状态的问题,也为后续的 API 奠定了基础。

原本需要用类组件 + 生命周期方法才能实现状态管理和副作用处理:

class WindowSize extends React.Component {
     state = {
         width: window.innerWidth,
         height: window.innerHeight,
     };
     handleResize = () => {
         this.setState({
             width: window.innerWidth,
             height: window.innerHeight,
         });
     };
     componentDidMount() {
         window.addEventListener("resize", this.handleResize);
     }
     componentWillUnmount() {
         window.removeEventListener("resize", this.handleResize);
     }
     render() {
         // ...
     }
 }

现在可以用纯函数 + Hooks 来实现:

function WindowSize() {
     const [size, setSize] = React.useState({
         width: window.innerWidth,
         height: window.innerHeight,
     });
     const { height, width } = size;

     useEffect(() => {
         const handleResize = () => {
             setSize({
                 width: window.innerWidth,
                 height: window.innerHeight,
             });
         };
         window.addEventListener("resize", handleResize);
         return () => window.removeEventListener("resize", handleResize);
     }, []);

     return (
         // ...
     );
 }

这种新 API 带来了多个好处,其中最关键的一点就是:提升了逻辑的组合能力。

在逻辑层应用组件的优势

在类组件中,组合逻辑的主流方式是高阶组件(HoC),而在 Hooks 体系中,组合逻辑的方法是…… 🥁

另一个 Hook。😐

这听起来可能理所当然,但正是这种 “理所当然”,让 Hook 拥有了强大的能力 —— 无论是现在还是将来。

来看一个自定义的 useWindowSize Hook:

function useWindowSize() {
     const [size, setSize] = React.useState({
         width: window.innerWidth,
         height: window.innerHeight,
     });
     const { height, width } = size;
     useEffect(() => {
         const handleResize = () => {
             setSize({
                 width: window.innerWidth,
                 height: window.innerHeight,
             });
         };
         window.addEventListener("resize", handleResize);
         return () => window.removeEventListener("resize", handleResize);
     }, []);
     return { height, width };
 }

📌 说明

你可能注意到了,我们几乎不需要改动原来组件中的逻辑。正是这种 “逻辑抽离后几乎无改动” 的特性,让 Hook 的组合能力非常强大。

然后这个自定义 Hook 就可以在任意函数组件中复用:

function MyComponent() {
     const { height, width } = useWindowSize();
     return (
         <div>
             The window width is: {width}
             <br />
             The window height is: {height}
         </div>
     );
 }
持续一致的 I/O 处理方式

我可以花几个小时聊编程中的副作用。这里只做个简单概括:

  • “副作用” 指的是从组件 / 函数外部改变状态的行为。

纯函数只能在自身作用域内修改状态,而副作用则是改变外部环境中的数据。

  • 所有 I/O(如事件监听、请求等)本质上都是副作用,因为用户和外部世界在函数之外。

大多数 I/O 操作都需要在某个时机进行清理:要么取消监听,要么重置设置的状态,否则就可能造成 bug 或内存泄漏。

React 的 useEffect Hook 正是为了解决这个问题,它鼓励开发者以更规范的方式处理副作用和清理逻辑。

来看类组件中副作用的处理方式:

class Listener extends React.Component {
     componentDidMount() {
         window.addEventListener("resize", this.handleResize);
     }
     componentWillUnmount() {
         window.removeEventListener("resize", this.handleResize);
     }
     handleResize = () => {
         // ...
     };
 }

📌 说明

你可能会疑惑:为什么 handleResize 必须是箭头函数?因为如果不是,this 会指向 window 而不是组件实例。

而在函数组件中,用 useEffect 来处理就简单直观多了:

function Listener() {
     useEffect(() => {
         const handleResize = () => {
             // ...
         };
         window.addEventListener("resize", handleResize);
         return () => window.removeEventListener("resize", handleResize);
     }, []);
 }

这也是 React 没有为函数组件设计 1:1 对应的类生命周期方法的原因之一:Hook 的方式更优雅,也更利于副作用的管理和清理。

解决 React 的一致性问题

React 18 发布时,很多人发现他们的应用在开发模式下突然 “出问题” 了。

实际发生的是:React 故意对开发模式下的 <StrictMode> 组件做了变更,而这个组件默认出现在大多数 React 应用模板中。

在此之前,<StrictMode> 主要用来提示开发者使用了过时的 API 或生命周期方法。

而现在,它更出名的原因是这个:

function App() {
     useEffect(() => {
         console.log("Mounted");
     }, []);
     return <>{/* ... */}</>;
 }

在开发环境中,这段代码会执行两次;在生产环境中只执行一次。

为什么会有这个改变?

简单说:React 团队希望你能正确清理副作用,避免内存泄漏和难以调试的问题。

更深层的原因是:他们希望组件的渲染行为是幂等的(idempotent)。

什么是幂等?举个例子说明:

假设你在工厂流水线上工作,有个按钮控制上方滑道掉落空箱子,箱子会被传送到包装机封装。


如果你在上一个箱子还没封装完就再次按按钮,新箱子就会卡住流水线,出问题了。

而如果这个按钮是 “幂等” 的,不管你按多少次,只会等上一个箱子包装完成后才掉落下一个。

React 的渲染行为也是一样的。

来看这段代码:

function BoxAddition() {
     useEffect(() => {
         window.addBox();
     }, []);
     return null;
 }

每次渲染这个组件,都会 “添加一个箱子”。但如果我们不断地挂载 / 卸载这个组件,系统里总箱子数会出问题,表现不一致。

比如这样写:

function CheckBoxAddsOnce() {
     const [bool, setBool] = useState(true);
     useEffect(() => {
         setInterval(() => setBool((v) => !v), 0);
         setInterval(() => setBool((v) => !v), 100);
         setInterval(() => setBool((v) => !v), 200);
     });
     if (bool) return null;
     return <BoxAddition />;
 }

这会导致 BoxAddition 不断被挂载卸载,window.addBox() 被调用了很多次,而没有对应的清理逻辑。

所以正确写法是这样的:

function BoxAddition() {
     useEffect(() => {
         window.addBox();
         return () => window.removeBox();
     }, []);
     return null;
 }

这就是为什么 React 18 改变了 <StrictMode> 的行为,用来提醒你注意这些 “非幂等” 问题。

而且,这并不是 React 18 新加的偶然想法。幂等性早在 Facebook 团队第二次公开讲解 React 时就被列为核心设计原则。

为保持一致性而制定的规则

不过这不代表你可以随意编写 Hook。React 明确规定了 Hook 的一套使用规则:

  • 所有 Hook 都必须是函数

  • 函数名称必须以 use 开头

  • 不允许在条件语句中调用 Hook

  • Hook 必须在组件最顶层调用

  • 不允许动态调用 Hook

  • 传给 Hook 的参数不能被修改

无论是官方的还是自定义的 Hook,从早期的 useState 到后来的 useActionState,这些规则都适用。<