React 作为 UI 运行时
February 2, 2019
大多数教程都会把 React 介绍为一个 UI 库。这是有道理的,因为 React 确实是一个 UI 库,这正是它的标语所说的!
我之前写过关于创建用户界面的挑战。但这篇文章将以不同的方式来讨论 React —— 更像是一个编程运行时。
这篇文章不会教你如何创建用户界面。 但它或许能帮助你更深入地理解 React 的编程模型。
注意:如果你正在_学习_ React,请直接查阅官方文档。
⚠️
这是一篇深度剖析文章 —— 并不适合初学者。 在本文中,我将从第一性原理出发,描述 React 编程模型的大部分内容。我不会讲如何使用它 —— 只讲它是如何运作的。
本文面向有经验的程序员,以及那些在开发其他 UI 库时对 React 的某些权衡产生疑问的人。我希望你会觉得有用!
许多人多年使用 React 却从未思考过这些主题。 这绝对是程序员视角的 React,而不是比如说设计师视角。但我认为两种资源都很有价值。
废话不多说,让我们开始吧!
宿主树
有些程序输出数字,有些程序输出诗歌。不同的语言及其运行时通常会针对特定用例进行优化,React 也不例外。
React 程序通常输出一个随时间可能变化的树结构。它可能是一个 DOM 树、一个 iOS 层级、一棵 PDF 基元树,甚至是一棵 JSON 对象树。不过,通常我们希望用它来表达某种 UI。我们称之为“宿主树”,因为它属于 React 之外的宿主环境 —— 比如 DOM 或 iOS。宿主树通常有自己的命令式 API。React 是其上的一层抽象。
那么 React 有什么用?从抽象层面来说,它帮助你编写一个能够在外部事件(如交互、网络响应、定时器等)发生时,可靠地操作复杂宿主树的程序。
专用工具之所以优于通用工具,是因为它可以施加并利用特定的约束。React 基于两个原则:
-
稳定性。 宿主树相对稳定,大多数更新不会彻底改变其整体结构。如果一个应用每秒都把所有交互元素重新组合成完全不同的排列,用户会很难使用。按钮去哪了?为什么我的屏幕在跳舞?
-
规律性。 宿主树可以被拆分为外观和行为一致的 UI 模式(如按钮、列表、头像),而不是随机形状。
这两个原则恰好适用于大多数 UI。 但如果输出没有稳定的“模式”,React 就不适合。例如,React 可以帮你写一个 Twitter 客户端,但对于一个 3D 水管屏保就帮不上什么忙。
宿主实例
宿主树由节点组成。我们称这些节点为“宿主实例”。
在 DOM 环境下,宿主实例就是普通的 DOM 节点 —— 比如你调用 document.createElement('div')
得到的对象。在 iOS 上,宿主实例可能是 JavaScript 唯一标识某个原生视图的值。
宿主实例有自己的属性(如 domNode.className
或 view.tintColor
)。它们还可以包含其他宿主实例作为子节点。
(这和 React 无关 —— 我在描述宿主环境。)
通常会有 API 用于操作宿主实例。例如,DOM 提供了 appendChild
、removeChild
、setAttribute
等 API。在 React 应用中,你通常不会直接调用这些 API。这是 React 的工作。
渲染器
渲染器教会 React 如何与特定宿主环境通信并管理其宿主实例。React DOM、React Native,甚至 Ink 都是 React 渲染器。你也可以创建自己的 React 渲染器。
React 渲染器可以以两种模式之一工作。
绝大多数渲染器采用“可变”模式。这种模式类似于 DOM:我们可以创建节点、设置属性,之后还可以添加或移除子节点。宿主实例是完全可变的。
React 也可以在“持久化”模式下工作。这种模式适用于没有 appendChild()
等方法的宿主环境,而是通过克隆父树并始终替换顶层子节点来实现。宿主树层面的不可变性让多线程变得更容易。React Fabric 就利用了这一点。
作为 React 用户,你无需关心这些模式。我只是想强调,React 不只是某种模式之间的适配器。它的价值与底层视图 API 的范式无关。
React 元素
在宿主环境中,宿主实例(如 DOM 节点)是最小的构建块。而在 React 中,最小的构建块是 React 元素。
React 元素是一个普通的 JavaScript 对象。它可以描述一个宿主实例。
// JSX 是这些对象的语法糖。
// <button className="blue" />
{
type: 'button',
props: { className: 'blue' }
}
React 元素很轻量,并没有绑定任何宿主实例。它仅仅是你希望在屏幕上看到内容的描述。
和宿主实例一样,React 元素也可以组成一棵树:
// JSX 是这些对象的语法糖。
// <dialog>
// <button className="blue" />
// <button className="red" />
// </dialog>
{
type: 'dialog',
props: {
children: [{
type: 'button',
props: { className: 'blue' }
}, {
type: 'button',
props: { className: 'red' }
}]
}
}
(注意:我省略了一些属性,它们对本解释不重要。)
但要记住,React 元素没有持久的身份。 它们本来就应该被反复创建和丢弃。
React 元素是不可变的。例如,你不能更改 React 元素的子节点或属性。如果你想之后渲染不同的内容,你需要用全新的 React 元素树重新描述它。
我喜欢把 React 元素比作电影中的帧。它们捕捉了 UI 在某一时刻应该是什么样子。它们不会改变。
入口点
每个 React 渲染器都有一个“入口点”。这是让我们告诉 React 在某个容器宿主实例中渲染特定 React 元素树的 API。
例如,React DOM 的入口点是 ReactDOM.render
:
ReactDOM.render(
// { type: 'button', props: { className: 'blue' } }
<button className="blue" />,
document.getElementById('container')
);
当我们调用 ReactDOM.render(reactElement, domContainer)
时,意思是:“亲爱的 React,请让 domContainer
宿主树与我的 reactElement
保持一致。”
React 会查看 reactElement.type
(在本例中为 'button'
),并要求 React DOM 渲染器为其创建一个宿主实例并设置属性:
// ReactDOM 渲染器中的某处(简化版)
function createHostInstance(reactElement) {
let domNode = document.createElement(reactElement.type);
domNode.className = reactElement.props.className;
return domNode;
}
在我们的例子中,React 实际上会做如下操作:
let domNode = document.createElement('button');
domNode.className = 'blue';
domContainer.appendChild(domNode);
如果 React 元素的 reactElement.props.children
中有子元素,React 在首次渲染时也会递归为它们创建宿主实例。
协调(Reconciliation)
如果我们对同一个容器连续两次调用 ReactDOM.render()
会发生什么?
ReactDOM.render(
<button className="blue" />,
document.getElementById('container')
);
// ... 之后 ...
// 这应该*替换*按钮宿主实例,
// 还是仅仅更新已有实例的属性?
ReactDOM.render(
<button className="red" />,
document.getElementById('container')
);
再次强调,React 的工作是让宿主树与提供的 React 元素树保持一致。决定如何根据新信息操作宿主实例树的过程,有时被称为协调。
有两种方式可以实现。一个简化版的 React 可以直接清空现有树并重新创建:
let domContainer = document.getElementById('container');
// 清空树
domContainer.innerHTML = '';
// 创建新的宿主实例树
let domNode = document.createElement('button');
domNode.className = 'red';
domContainer.appendChild(domNode);
但在 DOM 中,这样做很慢,而且会丢失重要信息,比如焦点、选中状态、滚动位置等。我们希望 React 做的是:
let domNode = domContainer.firstChild;
// 更新已有宿主实例
domNode.className = 'red';
换句话说,React 需要决定何时更新已有宿主实例以匹配新的 React 元素,何时创建新的实例。
这就引出了身份的问题。React 元素每次都可能不同,但它在概念上何时指代同一个宿主实例?
在我们的例子中很简单。我们之前渲染了一个 <button>
作为第一个(也是唯一的)子节点,现在还想在同一个位置渲染 <button>
。我们已经有一个 <button>
宿主实例了,为什么要重新创建?直接复用即可。
这和 React 的思路很接近。
如果同一位置的元素类型在前后两次渲染中“匹配”,React 就会复用已有的宿主实例。
下面是一个带注释的示例,大致展示了 React 的行为:
// let domNode = document.createElement('button');
// domNode.className = 'blue';
// domContainer.appendChild(domNode);
ReactDOM.render(
<button className="blue" />,
document.getElementById('container')
);
// 可以复用宿主实例吗?可以!(button → button)
// domNode.className = 'red';
ReactDOM.render(
<button className="red" />,
document.getElementById('container')
);
// 可以复用宿主实例吗?不行!(button → p)
// domContainer.removeChild(domNode);
// domNode = document.createElement('p');
// domNode.textContent = 'Hello';
// domContainer.appendChild(domNode);
ReactDOM.render(
<p>Hello</p>,
document.getElementById('container')
);
// 可以复用宿主实例吗?可以!(p → p)
// domNode.textContent = 'Goodbye';
ReactDOM.render(
<p>Goodbye</p>,
document.getElementById('container')
);
同样的启发式规则也适用于子树。例如,当我们更新一个包含两个 <button>
的 <dialog>
时,React 首先决定是否复用 <dialog>
,然后对每个子节点重复这一过程。
条件渲染
如果 React 只在元素类型“匹配”时复用宿主实例,那我们如何实现条件内容渲染?
比如我们想先只显示一个输入框,之后再在它前面渲染一条消息:
// 首次渲染
ReactDOM.render(
<dialog>
<input />
</dialog>,
domContainer
);
// 下一次渲染
ReactDOM.render(
<dialog>
<p>I was just added here!</p>
<input />
</dialog>,
domContainer
);
在这个例子中,<input>
宿主实例会被重新创建。React 会遍历元素树,将其与之前的版本比较:
dialog → dialog
:可以复用宿主实例吗?可以 —— 类型匹配。input → p
:可以复用宿主实例吗?不行,类型变了! 需要移除现有input
并创建新的p
宿主实例。(无) → input
:需要创建新的input
宿主实例。
所以 React 实际执行的更新代码大致如下:
let oldInputNode = dialogNode.firstChild;
dialogNode.removeChild(oldInputNode);
let pNode = document.createElement('p');
pNode.textContent = 'I was just added here!';
dialogNode.appendChild(pNode);
let newInputNode = document.createElement('input');
dialogNode.appendChild(newInputNode);
这并不好,因为从概念上讲,<input>
并没有被 <p>
替换 —— 它只是移动了。我们不希望因为重新创建 DOM 而丢失其选中状态、焦点和内容。
虽然这个问题很容易修复(稍后会讲),但它在 React 应用中其实并不常见。原因很有趣。
实际上,你很少会直接调用 ReactDOM.render
。React 应用通常会拆分成如下函数:
function Form({ showMessage }) {
let message = null;
if (showMessage) {
message = <p>I was just added here!</p>;
}
return (
<dialog>
{message}
<input />
</dialog>
);
}
这个例子就不会出现我们刚才描述的问题。用对象表示法可能更容易理解。看看 dialog
的子元素树:
function Form({ showMessage }) {
let message = null;
if (showMessage) {
message = {
type: 'p',
props: { children: 'I was just added here!' }
};
}
return {
type: 'dialog',
props: {
children: [
message,
{ type: 'input', props: {} }
]
}
};
}
无论 showMessage
是 true
还是 false
,<input>
都是第二个子节点,其在树中的位置不会变化。
如果 showMessage
从 false
变为 true
,React 会遍历元素树,将其与之前的版本比较:
dialog → dialog
:可以复用宿主实例吗?可以 —— 类型匹配。(null) → p
:需要插入新的p
宿主实例。input → input
:可以复用宿主实例吗?可以 —— 类型匹配。
React 实际执行的代码类似于:
let inputNode = dialogNode.firstChild;
let pNode = document.createElement('p');
pNode.textContent = 'I was just added here!';
dialogNode.insertBefore(pNode, inputNode);
这样就不会丢失输入框的状态了。
列表
在树中同一位置比较元素类型,通常足以决定是复用还是重建对应的宿主实例。
但这只适用于子节点位置静态且不会重新排序的情况。在上面的例子中,虽然 message
可能是一个“空洞”,我们仍然知道输入框总是在消息之后,且没有其他子节点。
对于动态列表,我们无法保证顺序总是相同:
function ShoppingList({ list }) {
return (
<form>
{list.map(item => (
<p>
You bought {item.name}
<br />
Enter how many do you want: <input />
</p>
))}
</form>
)
}
如果购物项的 list
被重新排序,React 会发现所有 p
和 input
元素类型都一样,无法知道要移动它们。(从 React 的角度看,项目本身变了,而不是顺序变了。)
React 实际执行的代码类似于:
for (let i = 0; i < 10; i++) {
let pNode = formNode.childNodes[i];
let textNode = pNode.firstChild;
textNode.textContent = 'You bought ' + items[i].name;
}
所以 React 不会重新排序它们,而是更新每一个。这可能导致性能问题和 bug。例如,第一个输入框的内容会在排序后仍然显示在第一个输入框中 —— 即使它们在购物清单中可能已经对应不同的商品!
这就是为什么 React 每次你在输出中包含元素数组时都会提醒你指定一个特殊的 key
属性:
function ShoppingList({ list }) {
return (
<form>
{list.map(item => (
<p key={item.productId}>
You bought {item.name}
<br />
Enter how many do you want: <input />
</p>
))}
</form>
)
}
key
告诉 React,即使某项在父元素中的位置在不同渲染间发生变化,也应将其视为同一个概念项。
当 React 在 <form>
内看到 <p key="42">
时,会检查上一次渲染时 <form>
内是否也有 <p key="42">
。即使 <form>
的子节点顺序变了,这也能生效。React 会复用具有相同 key 的旧宿主实例,并相应地重新排序兄弟节点。
注意,key
只在特定父级 React 元素内有意义,比如 <form>
。React 不会尝试在不同父级之间“匹配”相同 key 的元素。(React 并不支持在不同父级之间移动宿主实例而不重新创建。)
什么样的值适合作为 key
?简单的判断方法是:你认为在顺序变化时,什么情况下某项还是“同一个”? 例如,在购物清单中,产品 ID 能唯一标识同级间的商品。
组件
我们已经见过返回 React 元素的函数:
function Form({ showMessage }) {
let message = null;
if (showMessage) {
message = <p>I was just added here!</p>;
}
return (
<dialog>
{message}
<input />
</dialog>
);
}
这些函数被称为组件。它们让我们可以创建自己的“工具箱”,比如按钮、头像、评论等。组件是 React 的核心。
组件只接受一个参数 —— 一个对象哈希。它包含“props”(属性的缩写)。这里,showMessage
就是一个 prop。它们类似于具名参数。
纯度
React 组件被假定为对其 props 是纯函数。
function Button(props) {
// 🔴 不可行
props.isActive = true;
}
一般来说,在 React 中不推荐对 props 进行变异。(稍后我们会讲响应事件时更新 UI 的惯用方式。)
不过,局部变异是完全没问题的:
function FriendList({ friends }) {
let items = [];
for (let i = 0; i < friends.length; i++) {
let friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
);
}
return <section>{items}</section>;
}
我们在渲染过程中创建了 items
,其他组件“看不到”它,所以在把它作为渲染结果传递出去之前,怎么变都行。没必要为了避免局部变异而让代码变得拧巴。
同样,惰性初始化也是可以的,尽管它不完全“纯”:
function ExpenseForm() {
// 只要不影响其他组件就没问题:
SuperCalculator.initializeIfNotReady();
// 继续渲染...
}
只要多次调用组件是安全的,并且不会影响其他组件的渲染,React 并不关心它是否 100% 纯粹于函数式编程意义。对 React 来说,幂等性比纯度更重要。
不过,组件中不允许产生用户可见的副作用。换句话说,仅仅调用组件函数本身不应导致屏幕内容发生变化。
递归
我们如何在组件中使用其他组件?组件是函数,所以我们可以直接调用它们:
let reactElement = Form({ showMessage: true });
ReactDOM.render(reactElement, domContainer);
但这不是在 React 运行时中使用组件的惯用方式。
惯用方式是用我们前面见过的机制 —— React 元素。也就是说,你不直接调用组件函数,而是让 React 之后帮你调用:
// { type: Form, props: { showMessage: true } }
let reactElement = <Form showMessage={true} />;
ReactDOM.render(reactElement, domContainer);
而在 React 内部的某处,你的组件会被调用:
// React 内部某处
let type = reactElement.type; // Form
let props = reactElement.props; // { showMessage: true }
let result = type(props); // Form 返回的内容
组件函数名按惯例首字母大写。当 JSX 转换器看到 <Form>
而不是 <form>
时,会让对象的 type
成为一个标识符而不是字符串:
console.log(<form />.type); // 'form' 字符串
console.log(<Form />.type); // Form 函数
没有全局注册机制 —— 我们在写 <Form />
时就是直接引用 Form
。如果 Form
不在本地作用域,会像普通变量名写错一样报 JavaScript 错误。
那么,当元素类型是函数时,React 会做什么?它会调用你的组件,并询问该组件想渲染什么元素。
这个过程会递归进行,详细描述见这里。简而言之,流程如下:
- 你:
ReactDOM.render(<App />, domContainer)
- React: 嘿,
App
,你渲染什么?App
:我渲染<Layout>
,里面有<Content>
。
- React: 嘿,
Layout
,你渲染什么?Layout
:我在<div>
里渲染我的子节点。我的子节点是<Content>
,所以放进去。
- React: 嘿,
<Content>
,你渲染什么?Content
:我渲染一个<article>
,里面有一些文本和一个<Footer>
。
- React: 嘿,
<Footer>
,你渲染什么?Footer
:我渲染一个<footer>
,里面有更多文本。
- React: 好的,给你最终结构:
// 最终 DOM 结构
<div>
<article>
Some text
<footer>some more text</footer>
</article>
</div>
这就是为什么我们说协调是递归的。当 React 遍历元素树时,遇到 type
是组件的元素,会调用它并继续往下遍历返回的 React 元素树。最终没有组件可调用时,React 就知道该如何操作宿主树了。
前面讨论过的协调规则在这里同样适用。如果同一位置(由索引及可选的 key
决定)的 type
发生变化,React 会丢弃其内部的宿主实例并重新创建。
控制反转
你可能会问:为什么我们不直接调用组件?为什么要写 <Form />
而不是 Form()
?
如果 React 能“知道”你的组件,而不是只看到递归调用后的 React 元素树,它就能更好地完成自己的工作。
// 🔴 React 完全不知道 Layout 和 Article 存在。
// 你自己在调用它们。
ReactDOM.render(
Layout({ children: Article() }),
domContainer
)
// ✅ React 知道 Layout 和 Article 存在。
// React 自己调用它们。
ReactDOM.render(
<Layout><Article /></Layout>,
domContainer
)
这就是典型的控制反转例子。让 React 控制组件调用有几个有趣的好处:
-
组件不止是函数。 React 可以为组件函数增强如本地状态等特性,这些特性与组件在树中的身份绑定。一个好的运行时会提供与问题匹配的基础抽象。正如前面提到,React 专为渲染 UI 树并响应交互而设计。如果你自己调用组件,就得自己实现这些特性。
-
组件类型参与协调。 让 React 调用你的组件,也让它更了解你的树的概念结构。例如,从渲染
<Feed>
切换到<Profile>
页面时,React 不会尝试复用它们内部的宿主实例 —— 就像用<p>
替换<button>
一样。所有状态都会消失 —— 这通常是好事,因为你不希望在<PasswordForm>
和<MessengerChat>
之间保留输入框状态,即使它们在树中的位置“碰巧”对齐。 -
React 可以延迟协调。 如果 React 控制组件调用,它可以做很多有趣的事。例如,它可以让浏览器在组件调用之间做些工作,这样重新渲染大型组件树时不会阻塞主线程。手动编排这些工作而不重写大半个 React 是很难的。
-
更好的调试体验。 如果组件是库可感知的一等公民,我们就能构建丰富的开发者工具用于开发时的调试。
React 调用你的组件函数的最后一个好处是惰性求值。让我们看看这意味着什么。
惰性求值
在 JavaScript 中调用函数时,参数会在调用前求值:
// (2) 这步第二执行
eat(
// (1) 这步先执行
prepareMeal()
);
这通常是 JavaScript 开发者的预期,因为 JS 函数可能有隐式副作用。如果我们调用一个函数,但它直到结果被“用到”才执行,会很奇怪。
然而,React 组件是相对纯的。如果我们知道其结果不会被渲染到屏幕上,完全没必要执行它。
比如这个组件把 <Comments>
放进 <Page>
:
function Story({ currentUser }) {
// return {
// type: Page,
// props: {
// user: currentUser,
// children: { type: Comments, props: {} }
// }
// }
return (
<Page user={currentUser}>
<Comments />
</Page>
);
}
Page
组件可以在某个 Layout
内渲染其子节点:
function Page({ user, children }) {
return (
<Layout>
{children}
</Layout>
);
}
(JSX 中 <A><B /></A>
等价于 <A children={<B />} />
。)
但如果它有提前返回的条件呢?
function Page({ user, children }) {
if (!user.isLoggedIn) {
return <h1>Please log in</h1>;
}
return (
<Layout>
{children}
</Layout>
);
}
如果我们把 Comments()
当函数调用,无论 Page
是否想渲染它们,都会立即执行:
// {
// type: Page,
// props: {
// children: Comments() // 总是执行!
// }
// }
<Page>
{Comments()}
</Page>
但如果我们传递的是 React 元素,就不会自己执行 Comments
:
// {
// type: Page,
// props: {
// children: { type: Comments }
// }
// }
<Page>
<Comments />
</Page>
这样 React 就能决定何时以及是否调用它。如果我们的 Page
组件忽略了 children
并渲染 <h1>Please log in</h1>
,React 根本不会尝试调用 Comments
。没必要嘛!
这很好,因为既避免了无用的渲染浪费,也让代码更健壮。(用户未登录时我们不关心 Comments
是否抛错 —— 它根本不会被调用。)
状态
我们之前在协调部分讲过身份,以及元素在树中的“概念位置”如何让 React 决定是复用还是新建宿主实例。宿主实例可以有各种本地状态:焦点、选中、输入等。我们希望在概念上渲染相同 UI 时保留这些状态,在渲染完全不同内容时(如从 <SignupForm>
切换到 <MessengerChat>
)能预测性地销毁它。
本地状态如此有用,以至于 React 也允许你自己的组件拥有它。 组件仍然是函数,但 React 为它们增强了对 UI 有用的特性。本地状态与树中的位置绑定就是其中之一。
我们称这些特性为Hook。比如,useState
就是一个 Hook。
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
它返回一对值:当前状态和一个更新它的函数。
数组解构语法让我们可以给状态变量任意命名。例如,我这里叫它们 count
和 setCount
,但也可以叫 banana
和 setBanana
。下文中我用 setState
泛指第二个值。
(你可以在这里了解更多关于 useState
及其他 Hook 的内容。)
一致性
即使我们想把协调过程本身拆分为非阻塞的小块工作,实际操作宿主树时仍应在一次同步操作中完成。这样可以确保用户不会看到半更新的 UI,浏览器也不会为用户不可见的中间状态多次计算布局和样式。
这就是为什么 React 把所有工作分为“渲染阶段”和“提交阶段”。渲染阶段是 React 调用你的组件并执行协调的时候。它可以被中断,未来会是异步的。提交阶段是 React 操作宿主树的时候,始终是同步的。
记忆化
当父组件通过调用 setState
触发更新时,默认情况下 React 会协调其整个子树。这是因为 React 无法知道父组件的更新是否会影响子组件,默认选择保持一致性。听起来很昂贵,但实际上对于小型和中等子树并不是问题。
当树太深或太宽时,你可以告诉 React 记忆化某个子树,在 props 浅层相等时复用上次渲染结果:
function Row({ item }) {
// ...
}
export default React.memo(Row);
现在,父 <Table>
组件中的 setState
会跳过那些 item
引用等于上次渲染的 Row
协调。
你还可以用 useMemo()
Hook 在表达式级别实现细粒度记忆化。缓存是组件树位置本地的,会随本地状态一起销毁。它只保存最后一项。
React 有意不默认记忆化组件。许多组件每次都接收不同的 props,记忆化反而得不偿失。
原始模型
讽刺的是,React 并不使用“响应式”系统做细粒度更新。换句话说,顶层的任何更新都会触发协调,而不是只更新受影响的组件。
这是有意为之。可交互时间是消费级 Web 应用的关键指标,遍历模型设置细粒度监听器会消耗宝贵时间。此外,在许多应用中,交互要么导致很小(按钮悬停),要么导致很大(页面切换)的更新,这种情况下细粒度订阅反而浪费内存。
React 的核心设计原则之一是它直接处理原始数据。如果你有一堆从网络获取的 JS 对象,可以直接传给组件,无需预处理。你可以随意访问属性,也不会因为结构微调而遇到性能陷阱。React 渲染的复杂度是 O(视图大小),而不是 O(模型大小),你还可以通过窗口化大幅减少视图大小。
有些应用确实适合细粒度订阅,比如股票行情。这是“所有东西同时不断更新”的罕见例子。虽然命令式逃逸可以优化此类代码,但 React 可能不是最佳选择。当然,你也可以在 React 之上实现自己的细粒度订阅系统。
注意,即使是细粒度订阅和“响应式”系统也无法解决一些常见性能问题。 例如,渲染全新的深层树(每次页面切换都会发生),无法避免阻塞浏览器。变更跟踪并不会让它更快 —— 反而更慢,因为要做更多订阅设置。另一个问题是必须等数据到齐才能开始渲染视图。在 React 中,我们希望用并发渲染来解决这两个问题。
批处理
多个组件可能会在同一个事件响应中更新状态。下面这个例子虽然刻意,但能说明常见模式:
function Parent() {
let [count, setCount] = useState(0);
return (
<div onClick={() => setCount(count + 1)}>
Parent clicked {count} times
<Child />
</div>
);
}
function Child() {
let [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Child clicked {count} times
</button>
);
}
当事件被分发时,子组件的 onClick
先触发(调用其 setState
),然后父组件在自己的 onClick
处理器中调用 setState
。
如果 React 在每次 setState
调用后立即重新渲染组件,子组件会被渲染两次:
*** 进入 React 的浏览器点击事件处理器 ***
Child (onClick)
- setState
- 重新渲染 Child // 😞 多余
Parent (onClick)
- setState
- 重新渲染 Parent
- 重新渲染 Child
*** 退出 React 的浏览器点击事件处理器 ***
第一次 Child
渲染是浪费的。而且我们不能让 React 跳过第二次渲染 Child,因为 Parent 可能会基于更新后的状态传递不同数据给它。
这就是为什么 React 会在事件处理器内批量处理更新:
*** 进入 React 的浏览器点击事件处理器 ***
Child (onClick)
- setState
Parent (onClick)
- setState
*** 处理所有状态更新 ***
- 重新渲染 Parent
- 重新渲染 Child
*** 退出 React 的浏览器点击事件处理器 ***
组件中的 setState
调用不会立即导致重新渲染。React 会先执行所有事件处理器,然后批量触发一次重新渲染。
批处理有助于性能,但如果你写如下代码可能会感到意外:
const [count, setCount] = useState(0);
function increment() {
setCount(count + 1);
}
function handleClick() {
increment();
increment();
increment();
}
如果初始 count
为 0
,这其实就是三次 setCount(1)
。为了解决这个问题,setState
提供了一个接受“更新器”函数的重载:
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
function handleClick() {
increment();
increment();
increment();
}
React 会把这些更新器函数放入队列,之后依次执行,最终 count
会变为 3
。
当状态逻辑比几个 setState
更复杂时,建议用 useReducer
Hook 表达为本地状态 reducer。这是“更新器”模式的进化,每次更新都有名字:
const [counter, dispatch] = useReducer((state, action) => {
if (action === 'increment') {
return state + 1;
} else {
return state;
}
}, 0);
function handleClick() {
dispatch('increment');
dispatch('increment');
dispatch('increment');
}
action
参数可以是任意类型,常用对象。
调用树
编程语言运行时通常有调用栈。当函数 a()
调用 b()
,b()
又调用 c()
时,JS 引擎内部会有类似 [a, b, c]
的数据结构,记录你当前在哪、下一步执行什么。退出 c
后,其调用栈帧就消失了 —— 不再需要。回到 b
。退出 a
后,调用栈为空。
当然,React 也是运行在 JS 里的,也遵循 JS 规则。但我们可以想象,React 内部有自己的调用栈,用于记住当前正在渲染哪个组件,比如 [App, Page, Layout, Article /* 当前在这里 */]
。
React 不同于通用语言运行时,因为它专注于渲染 UI 树。这些树需要“存活”以便我们与之交互。DOM 不会在第一次 ReactDOM.render()
后消失。
也许有点牵强,但我喜欢把 React 组件想象成“调用树”而不仅仅是“调用栈”。当我们“退出” Article
组件时,其 React“调用树”帧不会被销毁。我们需要在某处保存本地状态和宿主实例的引用。
这些“调用树”帧会在本地状态和宿主实例一起被销毁,但只有当协调规则认为有必要时。如果你看过 React 源码,可能见过这些帧被称为 Fiber。
Fiber 就是本地状态实际存储的地方。当状态被更新时,React 会标记下方的 Fiber 需要协调,并调用对应组件。
上下文
在 React 中,我们通过 props 向下传递数据。有时,大多数组件都需要同样的东西 —— 比如当前选中的视觉主题。每一层都传递会很繁琐。
React 用Context 解决了这个问题。本质上,它就像组件的动态作用域。就像虫洞一样,你可以在顶层放点东西,最底层的每个子组件都能读取并在变化时重新渲染。
const ThemeContext = React.createContext(
'light' // 默认值作为兜底
);
function DarkApp() {
return (
<ThemeContext.Provider value="dark">
<MyComponents />
</ThemeContext.Provider>
);
}
function SomeDeeplyNestedChild() {
// 取决于子组件被渲染在哪里
const theme = useContext(ThemeContext);
// ...
}
当 SomeDeeplyNestedChild
渲染时,useContext(ThemeContext)
会查找树中最近的 <ThemeContext.Provider>
并使用其 value
。
(实际上,React 在渲染时维护了一个 context 栈。)
如果上方没有 ThemeContext.Provider
,useContext(ThemeContext)
的结果就是 createContext()
时指定的默认值。在本例中是 'light'
。
副作用
我们之前提到,React 组件在渲染时不应有可观察的副作用。但副作用有时是必要的。比如我们可能需要管理焦点、绘制 canvas、订阅数据源等。
在 React 中,这通过声明 effect 实现:
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
在可能的情况下,React 会推迟 effect 的执行,直到浏览器重绘屏幕后。这很好,因为像订阅数据源这样的代码不应影响可交互时间和首次有意义绘制时间。(有一个很少用的 Hook 可以让你选择同步执行,尽量避免用。)
effect 不只会运行一次。它会在组件首次显示给用户时和之后每次更新后都运行。effect 可以闭包当前的 props 和 state,比如上例中的 count
。
effect 可能需要清理,比如订阅时。要清理,effect 可以返回一个函数:
useEffect(() => {
DataSource.addSubscription(handleChange);
return () => DataSource.removeSubscription(handleChange);
});
React 会在下次应用此 effect 前和组件销毁前执行返回的清理函数。
有时,每次渲染都重新运行 effect 并不理想。你可以告诉 React 跳过某些变量未变时不应用 effect:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
不过,这通常是过早优化,如果你不熟悉 JS 闭包,可能会出问题。
比如,下面的代码是有 bug 的:
useEffect(() => {
DataSource.addSubscription(handleChange);
return () => DataSource.removeSubscription(handleChange);
}, []);
它有 bug,因为 []
表示“永不重新执行此 effect”。但 effect 闭包了外部定义的 handleChange
,而 handleChange
可能引用了任何 props 或 state:
function handleChange() {
console.log(count);
}
如果我们从不让 effect 重新运行,handleChange
会一直指向首次渲染时的版本,count
永远是 0
。
为了解决这个问题,确保依赖数组中包含所有可能变化的东西,包括函数:
useEffect(() => {
DataSource.addSubscription(handleChange);
return () => DataSource.removeSubscription(handleChange);
}, [handleChange]);
根据你的代码,可能仍会出现不必要的重复订阅,因为 handleChange
每次渲染都不同。useCallback
Hook 可以帮你解决。或者你也可以直接让它重新订阅。比如浏览器的 addEventListener
API 非常快,为了避免调用它而大费周章可能得不偿失。
(你可以在这里了解更多关于 useEffect
及其他 Hook 的内容。)
自定义 Hook
由于像 useState
和 useEffect
这样的 Hook 是函数调用,我们可以将它们组合成自己的 Hook:
function MyResponsiveComponent() {
const width = useWindowWidth(); // 我们的自定义 Hook
return (
<p>Window width is {width}</p>
);
}
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
});
return width;
}
自定义 Hook 让不同组件可以共享可复用的有状态逻辑。注意,状态本身并不共享。每次调用 Hook 都会声明自己的独立状态。
(你可以在这里了解如何编写自己的 Hook。)
静态调用顺序
你可以把 useState
看作定义“React 状态变量”的语法。其实它并不是语法,我们还是在写 JS。但从 React 作为运行时环境的角度看,因为 React 让 JS 更适合描述 UI 树,所以它的某些特性更接近语言层面。
如果 use
真的是语法,那它应该在组件顶层:
// 😉 注意:这不是实际语法
component Example(props) {
const [count, setCount] = use State(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
如果把它放进条件、回调或组件外部会怎样?
// 😉 注意:这不是实际语法
// 这是谁的本地状态?
const [count, setCount] = use State(0);
component Example() {
if (condition) {
// condition 为 false 时怎么办?
const [count, setCount] = use State(0);
}
function handleClick() {
// 离开函数时怎么办?
// 和普通变量有何区别?
const [count, setCount] = use State(0);
}
React 状态是组件及其在树中身份的本地状态。如果 use
是真正的语法,也应该只在组件顶层作用域:
// 😉 注意:这不是实际语法
component Example(props) {
// 只在这里有效
const [count, setCount] = use State(0);
if (condition) {
// 这里会报语法错误
const [count, setCount] = use State(0);
}
这类似于 import
只能在模块顶层。
当然,use
实际上并不是语法。(它带来的好处有限,反而会增加摩擦。)
但 React 确实要求所有 Hook 调用只能在组件顶层且无条件执行。这些Hook 规则可以用eslint 插件强制执行。虽然这个设计有过激烈争论,但实际中我没见过有人因此困惑。我也写过为什么常见的替代方案行不通。
Hook 内部实现为链表。每次调用 useState
,我们就把指针移到下一个节点。退出组件的“调用树”帧时,把链表保存到那里,等下次渲染再用。
这篇文章用数组模型简化解释了 Hook 的内部原理:
// 伪代码
let hooks, i;
function useState() {
i++;
if (hooks[i]) {
// 后续渲染
return hooks[i];
}
// 首次渲染
hooks.push(...);
}
// 准备渲染
i = -1;
hooks = fiber.hooks || [];
// 调用组件
YourComponent();
// 记住 Hook 的状态
fiber.hooks = hooks;
(如果好奇,真实代码在这里。)
这就是每次 useState()
调用都能拿到正确状态的大致原理。正如我们在协调部分学到的,“匹配”并不是 React 的新鲜事 —— 协调本身就依赖于渲染间元素的匹配。
有哪些遗漏
我们已经涉及了 React 运行时环境几乎所有重要方面。如果你读完本页,可能比 90% 的用户更了解 React。这样也没什么不好!
有些部分我没讲 —— 主要是因为连我们自己都还没想清楚。React 目前对多遍渲染(即父组件渲染需要依赖子组件信息)还没有好的方案。错误处理 API 也还没有 Hook 版本。这两个问题或许可以一起解决。并发模式还不稳定,关于 Suspense 如何融入这个体系也有许多有趣的问题。等这些完善、Suspense 不仅仅用于懒加载时,也许我会写后续。
我认为 React API 能让你在不思考这些底层内容的情况下走得很远,这正是其成功之处。 像协调启发式这样的默认行为在大多数情况下都很合理。像 key
警告这样的提示则能在你“作死”时提醒你。
如果你是 UI 库爱好者,希望这篇文章对你有所启发,也让你更深入理解了 React 的工作原理。或者你觉得 React 太复杂,以后再也不想碰它。无论如何,欢迎你在 Twitter 上与我交流!感谢阅读。
Pay what you like