点击上方 程序员成长指北,关注公众号
回复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
,这些规则都适用。<