将React作为UI运行

大多数教程都将 React 作为 UI 库来引入。这逻辑上是对的,因为 React 就是一个 UI 库,标语中就是这么表述的!

React homepage screenshot

我之前写过关于创建用户界面的挑战。但这篇文章却是以不同的方式讨论 React——更像是运行时系统(programming runtime).

这篇文章不会教你如何创建用户界面。但是它可能会帮助你更深入地理解 React 编程模式。


提示:如果你正在学习 React,请查看文档

⚠️

这是个深度讨论帖 — 内容对初学者很不友好。在这篇文章中,我从第一原则描述了大部分 React 编程模型,但并不会解释如何使用它——他是如何工作的。

它面向有经验的程序猿和从事其他 UI 库的人,他们询问了在 React 中选择的一些权衡。我希望你们会觉得它很有用!

很多人成功使用 React 多年但没有考虑大多数这些话题。与其说这是以设计师为中心,这显然更会是以程序猿为中心的 React 视角。尽管我认为同时提供双方的资源并不会造成很大的负荷。

在免责声明之后,让我们开始进入正题吧!


主树 Host Tree

有些程序输出数字,有些输出诗歌。不同的语言及其运行时通常针对特定的一组用例进行优化,而 React 也不例外。

React 程序通常输出一个可能随时间变化的树。它可能是个DOM 树,一个iOS 层次结构,一个PDF 原语树,甚至是JSON 对象。但是通常我们希望用它表示一些 UI。我们称它为“host tree”,因为它是 React Host 环境外显的一部分——就像是 DOM 或 iOS 一样。通常 Host Tree它有 自己的命令式 API。React 是在它上面的一层。

那么 React 有什么用呢?非常抽象的说,它帮助你编写一个程序,这个程序可以预测操作复杂的 Host Tree 以响应外部事件,如交互、网络响应、计时器等。

当专用工具可以施加特定的约束并从中受益时,它比通用工具做得更好。React 依托于两个原则:

  • **稳定性:**Host Tree 相对稳定且大多更新都不会彻底改变其整体结构,如果一个应用程序每秒钟都将其所有交互元素重新组合成完全不同的组合,就很难使用。那个按钮去哪儿了?为什么屏幕在变化?
  • **规则性:**Host Tree 可以分解为外观和行为一致的 UI 模式(如按钮、列表、虚拟人物),而不是随机形状。

这些原则恰好适用于大多数 UI。但是,如果输出中没有稳定的“模式”,那么 React 就不合适了。例如:React 可以帮助你编写一个 Twitter 的客户端,但是对于3D 管道屏保程序就不是那么便利了。

主实例 Host Instances

Host Tree 由节点组成,我们将它们称为“Host 实例”。

在 DOM 环境中,Host 实例是常规的 DOM 节点——就像调用document.createElement('div')时得到的对象一样。在 iOS 上,Host 实例可以是从 javascript 唯一标识本机视图的值。

Host 实例有自己的属性(eg:domNode.classNameview.tintColor)。它们还可以包含其他作为子元素的 Host 实例。

(这与 React 无关——我描述的是 Host 实例)

通常有一个 API 来操作 Host 实例。例如,DOM 提供了诸如appendChildremoveChildsetAttribute等 API。在 React 应用程序中,通常不调用这些 API。这是 React 的工作。

渲染器 Renderers

一个渲染器教导 React 与特定的 Host 环境对话并管理其 Host 实例。React DOM、React Native 甚至Ink都是 React 渲染器。也可以创建自己的 React 渲染器

React 渲染器可以在两种模式之一下工作。

绝对多数渲染器都是使用“突变”模式编写的。这个模式就是 DOM 的工作方式:我们可以创建一个节点,设置它的属性,然后从中添加或删除子节点。Host 实例是完全可变的。

React 也可以在“持久”模式下工作。此模式适用于不提供appendChild()等方法,而是克隆父树并始终替换顶级子级的主机环境。Host Tree 级别的不可变性使多线程更容易实现。React Fabric利用了这一点。

作为一个 React 用户并不需要考虑这些模式,我只想强调 React 不仅仅是从一种模式到另一种模式的适配器,其效能与目标低级视图 API 范式是正交的。

React Elements

在 Host 环境中,一个 Host 实例(如 DOM 节点)是最小的构建块。在 React 中,最小的构建块是一个React element.

React element 是一个普通的 JavaScript 对象,它可以描述Host 实例。

1
2
3
4
5
6
// JSX is a syntax sugar for these objects.
// <button className="blue" />
{
type: 'button',
props: { className: 'blue' }
}

一个 React element 是轻量级的,没有绑定到他的 Host 实例,同样它也只是你期望在屏幕上可见内容的描述

与 Host 实例一样,React elements 也可以形成树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// JSX是这些对象的语法糖。
// <dialog>
// <button className="blue" />
// <button className="red" />
// </dialog>
{
type: 'dialog',
props: {
children: [{
type: 'button',
props: { className: 'blue' }
}, {
type: 'button',
props: { className: 'red' }
}]
}
}

(提示:我省略了一些对这个解释不重要的属性。)

但是请记住:React elements 没有自己的持久标识。它们理当被重建直到丢弃。

React elements 是不变的。例如不能改变子元素、属性或 React element。如果以后要呈现不同的内容,则应当从头使用新建的 React element 结构来描述

我喜欢吧 React elements 理解成电影中的画面,它们捕获用户界面在特定时间的外观,但它们不会变。

入口 Entry Point

每个 React 渲染器都有一个“入口”。正是 API 让我们告诉 React 在容器的 Host 实例中呈现特定的 React 元素树

例如,React DOM 入口是ReactDOM.render

1
2
3
4
5
ReactDOM.render(
// { type: 'button', props: { className: 'blue' } }
<button className="blue" />,
document.getElementById('container'),
);

当我们说ReactDOM.render(reactElement, domContainer)是指:“亲爱的 React,使 domContainer 的 Host 树与我的 reactElement 匹配吧。”

React 将查看reactElement.type(在我们的示例中为'button'),并要求 React DOM 渲染器为其创建 Host 实例并设置属性:

1
2
3
4
5
6
// Somewhere in the ReactDOM renderer (simplified)
function createHostInstance(reactElement) {
let domNode = document.createElement(reactElement.type);
domNode.className = reactElement.props.className;
return domNode;
}

在示例中,有效的 React 将做到如下:

1
2
3
let domNode = document.createElement('button');
domNode.className = 'blue';
domContainer.appendChild(domNode);

如果 React 元素在reactElement.props.children中有子元素,React 也将在第一次呈现时为它们递归创建 Host 实例。

调停 Reconciliation

如果我们用同一容器两次调用ReactDOM.render()会如何呢?

1
2
3
4
5
6
7
8
9
10
11
12
ReactDOM.render(
<button className="blue" />,
document.getElementById('container'),
);

// ... later ...

// 这应该*替换*按钮的Host实例么,或者只更新现有属性上的值?
ReactDOM.render(
<button className="red" />,
document.getElementById('container'),
);

同样,React 的工作就是使 Host 树与提供的 React 元素树匹配。为了响应新的信息而弄清楚 Host 实例树要做什么的过程,被称为调停

有两个方法可以解决这个问题,简化版的 React 可以讲现有的树清理并重建:

1
2
3
4
5
6
7
let domContainer = document.getElementById('container');
// 清理树
domContainer.innerHTML = '';
// 新建Host实例树
let domNode = document.createElement('button');
domNode.className = 'red';
domContainer.appendChild(domNode);

但这在 DOM 中会很慢,并且会丢失很重要的信息,如焦点、选中、滚动状态等。而我们希望 React 可以这样:

1
2
3
let domNode = domContainer.firstChild;
// 更新现有的Host实例
domNode.className = 'red';

换句话说,React 需要决定何时对现有 Host 实例进行更新以匹配新的 React 元素,以及何时创建新的元素。

这引发了一个关于身份识别的问题。React 元素每次都可能不同,但它什么时候在概念上引用同一个 Host 实例呢?

在简单示例中,我们曾将一个<button>呈现为第一个(也是唯一)子元素,我们希望再次在同位上呈现一个<button>。现在已经有了一个<button>的 Host 实例,那么为什么要重建它呢?我们应该复用它才对。

这很接近人们对 React 的想法。

如果树中同位的元素类型在前后渲染之间“匹配”,那么 React 将重用现有的 Host 实例

以下是一个示例,其中的注释大致显示了 React 的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// let domNode = document.createElement('button');
// domNode.className = 'blue';
// domContainer.appendChild(domNode);
ReactDOM.render(
<button className="blue" />,
document.getElementById('container')
);

// 可以重用Host实例吗? Yes! (button → button)// domNode.className = 'red';ReactDOM.render(
<button className="red" />,
document.getElementById('container')
);

// 可以重用Host实例吗? No! (button → p)// domContainer.removeChild(domNode);
// domNode = document.createElement('p');
// domNode.textContent = 'Hello';
// domContainer.appendChild(domNode);
ReactDOM.render(
<p>Hello</p>,
document.getElementById('container')
);

// 可以重用Host实例吗? Yes! (p → p)// domNode.textContent = 'Goodbye';ReactDOM.render(
<p>Goodbye</p>,
document.getElementById('container')
);

可以用同样的方式探索子树。例如,当我们用两个<button>更新一个<dialog>时,React 首先决定是否重新使用<dialog>,然后为每个子元素重复次决策过程。

条件 Conditions

如果 React 仅在更新之间的元素类型“匹配”时重用 Host 实例,那么会如何呈现条件内容?

假设我们只是想首先显示一个输入,但稍后在它之前呈现一条消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// First render
ReactDOM.render(
<dialog>
<input />
</dialog>,
domContainer,
);

// Next render
ReactDOM.render(
<dialog>
<p>I was just added here!</p> <input />
</dialog>,
domContainer,
);

示例中,<input>的 Host 实例将被重建,React 将遍历元素树,与其上一版本进行比较:

  • dialog → dialog:可以重用 Host 实例么?是的——类型匹配
    • input → p:可以重用 Host 实例么?**不是,类型已改变!**需要删除现有的input并创建新的pHost 实例。
    • (nothing) → input:需要创建一个新的inputHost 实例。

因此,React 执行的更新代码实际上是:

1
2
3
4
5
6
7
8
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 应用程序往往被分解成如下功能:

1
2
3
4
5
6
7
8
9
10
11
12
function Form({ showMessage }) {
let message = null;
if (showMessage) {
message = <p>I was just added here!</p>;
}
return (
<dialog>
{message}
<input />
</dialog>
);
}

这个例子不受我们刚才描述的问题的影响,如果我们使用对象表示法而不是 JSX,可能更容易理解为什么会这样。以下是dialog的子元素树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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的值是truefalse<input>是第二个子元素,在渲染之间不会更改其树的位置

如果showMessage的值从false变为true,那么 React 将遍历元素树,并将其与以前的版本进行比较:

  • dialog → dialog:可以重用 Host 实例么?是的——类型匹配
    • (null) → p:需要插入新的pHost 实例。
    • input → input:可以重用 Host 实例么?是的——类型匹配

由 React 执行的代码类似于:

1
2
3
4
let inputNode = dialogNode.firstChild;
let pNode = document.createElement('p');
pNode.textContent = 'I was just added here!';
dialogNode.insertBefore(pNode, inputNode);

这就没有输入状态的丢失。

列表 Lists

比较树中同位的元素类型通常足以决定是否重用或重建相应的 Host 实例。

但是只有当子节点的位置是静态的,不会重新排序时才有效。在上面的示例中,即使message可能是一个“hole”,我们仍然知道在输入消息后并没有其他的子级。

对于动态列表,我们无法确定顺序是否始终相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 将看到其中的所有的pinput元素具有相同的类型,并且不知道移动它们。(从 React 的角度来看,每一个子元素都发生了变化,而不是顺序。)

React 执行的代码像下面这样重新添加了十个子元素:

1
2
3
4
5
for (let i = 0; i < 10; i++) {
let pNode = formNode.childNodes[i];
let textNode = pNode.firstChild;
textNode.textContent = 'You bought ' + items[i].name;
}

因此,React 不会对它们进行重新排序,而是有效地对它们每个进行更新。这会造成性能问题和可能的错误。例如,第一个输入的内容将在排序的第一个输入中保持不变——即使在概念上它们可能指代list中的不同对象。

这就是为什么每次在输出中包含元素数组时,React 都会要求你指定一个名为 key 的特殊属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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">时,它将检查上一个渲染是否也包含<p key="42">在同一个<form>中。即使子节点改变了顺序,这也将起作用。React 将重用具有相同密钥的前一个 Host 实例(如果存在),并相应地在同级重新排序。

请注意,key只与特定的父级 React 元素相关,例如<form>。React 不会尝试在不同的父元素之间使用相同的键“匹配”元素。(React 不支持在不同的父元素之间移动 Host 实例而不重建它。)

什么事好的key值呢?回答这个问题的一个简单方法是问:**即使订单改变,对于你来说什么一定是“相同的”?**例如,在我们的购物清单中,产品 ID 在其间是一个唯一的标识。

组件 Components

这是返回 React 元素的函数:

1
2
3
4
5
6
7
8
9
10
11
12
function Form({ showMessage }) {
let message = null;
if (showMessage) {
message = <p>I was just added here!</p>;
}
return (
<dialog>
{message}
<input />
</dialog>
);
}

它们被称为组件。它们让我们创建自己的“组件库”,包括按钮、头像、评论等。组件对于 React 来说就像是面包之于黄油——是不可或缺的。

组件接受一个参数——一个 hash 的对象,它包括“props”(属性“properties”的缩写)。showMessage就是这样一个prop,它们像是参数一样的命名。

纯度 Purity

假设 React 的组件相对于它们的props是纯粹的。

1
2
3
4
function Button(props) {
// 🔴 Doesn't work
props.isActive = true;
}

一般来说,变化不是 React 擅长的。(稍候我们将进一步讨论更新 UI 以响应事件的常用方法。)

不过,内部变化显然是个不错的选项:

1
2
3
4
5
6
7
8
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没有其他组件会“看到”它,因此我们可以在将其作为渲染结果的一部分在传递之前根据自己的需要对其进行更改。没有必要为了避免内部变化而更改代码,让代码变得扭曲。

同样,尽管没有完全“纯粹的”初始化,但是延迟初始化也是可以的:

1
2
3
4
5
6
function ExpenseForm() {
// Fine if it doesn't affect other components:
SuperCalculator.initializeIfNotReady();

// Continue rendering...
}

只要多次调用一个组件是安全的并不影响其他组件的呈现,React 就不关心它是否在 strict FP Sense 上是 100%纯粹的。等幂Idempotence对 React 比是否纯粹更重要。

也就是说,React 组件中不允许有用户直接可见的副作用。换句话说就是仅仅调用一个组件函数本身就不该在屏幕上发生变化。

递归 Recursion

我们如何使用其他组件的组件?组件本质是函数,所以我们可以调用它们:

1
2
let reactElement = Form({ showMessage: true });
ReactDOM.render(reactElement, domContainer);

但是这不是在 React 运行时使用组件的常用方法。

相反,使用组件的常用方法与我们以前见过的机制相同——React 元素。这意味着你不用直接调用组件函数,而是让 React 稍后为你执行

1
2
3
// { type: Form, props: { showMessage: true } }
let reactElement = <Form showMessage={true} />;
ReactDOM.render(reactElement, domContainer);

在 React 某处,你的组件会被称为:

1
2
3
4
// Somewhere inside React
let type = reactElement.type; // Form
let props = reactElement.props; // { showMessage: true }
let result = type(props); // Whatever Form returns

组件函数名称按约定应当大写,当 JSX 转换时看到<Form>而不是<form>时,它使对象本身成为标识符而不是字符串:

1
2
console.log(<form />.type); // 'form' string
console.log(<Form />.type); // Form function

没有全局注册机制——键入<Form />时我们按名称逐字地引用Form,如果本地作用域中不存在Form,你将看到一个 JavaScript 错误,就像通常使用错误变量名时一样。

好了,那么当一个元素类型是一个函数时,React 会干什么呢?它调用组件并询问组件希望呈现什么元素

这个过程将以递归的方式继续,并在此处。简而言之它看起来会是这样:

  • You: ReactDOM.render(<App />, domContainer)
  • React: Hey App,你要呈现什么?
    • App:我想呈现<Layout>且包含<Content>子元素。
  • React: Hey Layout,你要呈现什么?
    • Layout:我想在<div>中呈现我的子元素。我的子元素是<Content>,所以我才需要进入<div>
  • React: Hey <Content>,你要呈现什么?
    • Content:我想呈现<article>包含Some text和一个<Footer>子元素。
  • React: Hey <Footer>,你要呈现什么?
    • Footer:我想呈现一个<footer>some more text
  • React: 好的现在就去:
1
2
3
4
5
6
7
// Resulting DOM structure
<div>
<article>
Some text
<footer>some more text</footer>
</article>
</div>

这就是为什么我们说调停是递归的,当 React 遍历元素树时,它可能会遇到type是组件的元素,调用它并继续沿着返回的 React 元素树下降。最终穷尽所有的组件,React 也会知道 Host 树中要改变什么。

我们已经讨论过相同调停规则也适用于这里。如果同位的type(由索引和可选的key)发生更改,React 将丢弃其中的 Host 实例,并重建它们。

控制反转 Inversion of Control

你可能会想:我们为什么不直接调用组件?为什么要写<Form />而不是Form()

如果 React 知道你的组件,而不是在递归调用后只看到 React 元素树,那么它可以做得更好

1
2
3
4
5
6
7
8
9
10
11
12
// 🔴 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 不会尝试复用其中的 Host 实例——就像你将<button>替换为<p>一样,所有的状态都将消失——当呈现概念上不同的视图时,这通常很棒,即使树中的<input>位置意外的在它们之间“排队”,你也不希望在<PasswordForm><MessengerChat>上保留输入状态。
  • React 可以延迟调停。如果 React 控制调用我们的组件,它可以做许多有趣的事情。例如,它可以让浏览器在组件调用之间做一些工作,以便我们重新呈现一个大型组件树时不阻塞主线程。在不重新实现大部分 React 的时候,手动协调达成这是很困难的。
  • 更好地调试。如果组件是库可以识别的(类型),我们可以构建富开发工具一边在开发中自查。

响应组件函数的最后一个好处是lazy evaluation,让我们看看这是什么意思。

懒测评 Lazy Evaluation

当我们在 JavaScript 中调用函数时,参数会在调用之前进行计算:

1
2
3
4
5
// (2) This gets computed second
eat(
// (1) This gets computed first
prepareMeal(),
);

这通常是 JavaScript 开发人员所期望的,因为 JavaScript 函数可能有隐含的副作用,如果我们调用一个函数,它的结果在 JavaScript 某种方法被“使用”之后才出现,就太过出乎人的意料了。

但是,React 组件是[相对][purity]纯粹的。如果我们知道它的结果不会在屏幕上呈现,就完全没有必要执行它。

考虑将<Comments>放在<Page>的组件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Story({ currentUser }) {
// return {
// type: Page,
// props: {
// user: currentUser,
// children: { type: Comments, props: {} }
// }
// }
return (
<Page user={currentUser}>
<Comments />{' '}
</Page>
);
}

Page组件可以在某些Layout中呈现给他的子组件:

1
2
3
function Page({ currentUser, children }) {
return <Layout>{children} </Layout>;
}

(在 JSX 中等同于<A children={} />.)

但如果它有条件提前退出呢?

1
2
3
4
5
6
function Page({ currentUser, children }) {
if (!currentUser.isLoggedIn) {
return <h1>Please login</h1>;
}
return <Layout>{children}</Layout>;
}

如果我们将Comments()作为函数使用,它将立即执行而不理会Page是否要呈现给它们:

1
2
3
4
5
6
// {
// type: Page,
// props: {
// children: Comments() // Always runs!// }
// }
<Page>{Comments()}</Page>

但是,如果我们传递一个 React 元素,而根本就不执行Comments

1
2
3
4
5
6
7
8
// {
// type: Page,
// props: {
// children: { type: Comments }// }
// }
<Page>
<Comments />
</Page>

这让 React 决定何时和是否调用它,如果我们的Page组件忽略了它的children属性并呈现了<h1>Please login</h1>相反,React 甚至不会调用Comments函数,有什么意义么?

这很好,因为这两种方法都可以避免不必要的渲染,从而减少代码的脆弱性。(当用户注销时,我们不在乎Comments是否引发,它不会被调用。)

状态 State

我们之前 说过关于表示以及元素在树中的概念“位置”如何告诉 React 是复用 Host 状态还是创建新的实例。Host 实例可以具有各种内部状态:焦点、选中、输入等。我们希望在概念上呈现相同 UI 的更新之间保留此状态。我们还希望在呈现概念上不同的东西时(例如从<SignupForm>移动到<MessengerChat>),可以有预见的破坏它。

内部状态非常有用,因此 React 让你自己的组件也拥有这个功能。组件仍然是函数,但 React 会使用对 UI 有用的功能来增强它们,与树中的位置相关联的内部状态就是这样的特性之一。

我们称这些特性为Hooks。例如,useState就是一个 Hook.

1
2
3
4
5
6
7
8
9
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>{' '}
<button onClick={() => setCount(count + 1)}> Click me</button>
</div>
);
}

它返回一对值:当前状态和更新它的函数。

解构数组语法允许我们为状态变量指定任意名称。例如,我称这对为countsetCount,但它可能是bananasetBanana。在下面的文本中,我将使用setState引用第二个值,而不管在特定示例中它的实际名称是什么。

(你可以在这里了解更多关于 useState 和 React 提供的其他的 Hook 的信息)

一致性 Consistency

即使我们想将调停过程本身拆分为非阻塞工作块,我们仍然应该在一个同步解析(swoop)中执行实际的 Host 树操作。这样我们就可以确保用户不会看到半更新的用户界面,并且浏览器不会对用户不应该看到的中间状态执行不必要的布局和样式重新计算。

这就是 React 将所有的工作拆分为“渲染阶段”和“提交阶段”的原因。渲染阶段是当 React 调用组件并执行协调时,中断是安全的,并且在未来将是异步的。提交阶段是当 React 接触到 Host 树时,它一直会是同步的。

记忆化 Memoization

当父组件通过调用setState来调度更新时,默认情况下,React 会协调其整个子树。这是因为 React 不知道父组件中的更新是否会影响整个子树,默认情况下 React 选择一致,这听起来代价很昂贵,但实际上对于中小型子树来说,这并不是问题。

当树变得层级过深或者太宽时,你可以告诉 React 去记忆子树,并在稍微相等的属性更改期间重用以前的呈现结果:

1
2
3
4
5
function Row({ item }) {
// ...
}

export default memo(Row);

现在,父级<Table>组件中的setState将跳过调停item与上次呈现相等的itemRow

你可以使用useMemo()这个 Hook在单个表达式级别上获得细颗粒度的记忆。缓存是组件树位置的内部缓存,将于其内部状态一同销毁,它只保存最后一个项目。

默认情况下,React 会故意不记忆组件,许多组件总是受到不同的props,因此记忆它们是一个净损失。

原始模型 Raw Models

更具有讽刺意味的是,React 不使用“反应性(reactivity)”系统进行细粒度的更新,换句话说就是顶部的任何更新都会触发调节,而不只是更新受更改影响的组件。

这是一个意向性设计决策。交互时间是面向用户 Web 应用程序中的一个重要指标,遍历模型已设置细粒度侦听器将花费宝贵的时间。此外在许多应用程序中,交互往往会导致小的(按钮悬停)或大的(页面跳转)更新,在这种情况下,细粒度订阅浪费了内存资源。

React 的核心设计原则之一就是它与原始数据一同工作,如果从网络中接收到大量的 JavaScript 对象,则可以直接将它们推入组件中,而不需要进行预处理。对于你可以访问那些属性,或者当结构发生发生轻微变化时出现意外的性能断崖,目前尚不明确,React 渲染复杂度是 O(视图大小)而不是 O(模型大小),你可以使用窗口化显著地减少视图大小

有些应用程序的细粒度订阅是有益的,比如股票行情。这是个罕见的例子,“所有的东西都在同时更新”。尽管命令式转义图案填充可以帮助优化此类代码,但 React 可能并不适合此用例。不过,你可以在 React 上实现自己的细粒度订阅系统。

请注意,有一些常见的性能问题,即使是细粒度订阅和“反应性”系统也无法解决。例如,在不阻塞浏览器的情况下呈现一个新的深树(发生在每个页面转换中)。更改跟踪并不能使其更快——这只会拖慢它,因为我们必须做更多来设置订阅。另一个问题是,在开始呈现视图之前,我们必须等待数据。在 React 中,我们的目标是通过并发渲染来解决这两个问题。

配料 Batching

多个组件可能希望更新状态以响应同一事件。这个例子很复杂,但它说明了一个常见的模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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调用,则最终将呈现子组件两次:

1
2
3
4
5
6
7
*** Entering React's browser click event handler ***
Child (onClick)
- setState
- re-render Child // 😞 unnecessaryParent (onClick)
- setState
- re-render Parent
- re-render Child*** Exiting React's browser click event handler ***

第一个Child渲染将被浪费,我们不能让 React 跳过第二次呈现Child,因为Parent可能会根据它的更新状态向它传递一些不同的数据。

这就是为什么在事件处理程序中进行 React 的批次处理更新:

1
2
3
4
5
6
7
8
9
*** Entering React's browser click event handler ***
Child (onClick)
- setState
Parent (onClick)
- setState
*** Processing state updates ***
- re-render Parent
- re-render Child
*** Exiting React's browser click event handler ***

组件中的setState调用不会立即导致重新呈现。相反,React 会将首先执行所有时间处理程序,然后触发一个重新渲染批处理所有这些更新。

批处理有助于提高性能,但如果编写以下代码,你会感到惊讶:

1
2
3
4
5
6
7
8
9
10
11
const [count, setCounter] = useState(0);

function increment() {
setCounter(count + 1);
}

function handleClick() {
increment();
increment();
increment();
}

如果我们以count设置为0开头,那么这将是三个setCount(1)调用。要解决这个问题,setState提供接受“updater”函数的重载:

1
2
3
4
5
6
7
8
9
10
11
const [count, setCounter] = useState(0);

function increment() {
setCounter((c) => c + 1);
}

function handleClick() {
increment();
increment();
increment();
}

React 会将更新程序函数放在队列中,然后按顺序运行它们,从而导致count设置为3的重新呈现。

当状态逻辑比几个setState调用更复杂时,我建议使用useReducer这个 Hook将其表示为本地状态还原程序。这就像是这种“更新程序”模式的演变,每个更新都有一个名称:

1
2
3
4
5
6
7
8
9
10
11
const [counter, dispatch] = useReducer((state, action) => {
if (action === 'increment') {
return state + 1;
}
}, 0);

function handleClick() {
dispatch('increment');
dispatch('increment');
dispatch('increment');
}

尽管对象是常见的选择,action参数却可以是任何东西。

调用树 Call Tree

编程语言运行时通常有一个调用堆栈。当函数a()调用b(),它本身调用c(),在 JavaScript 引擎的某个地方有一个类似于[a, b, c]的数据结构,它“跟踪”你所在的位置以及下一步要执行的代码。一旦退出c,它的调用堆栈帧就消失了——噗!它不再需要了。我们跳回到b中。当我们退出a时,调用堆栈就已经空了。

当然,React 本身在 JavaScript 中运行并遵守 JavaScript 规则,但我们可以想象,React 内部有自己的调用堆栈来记住我们当前正在呈现的组件,例如:[App, Page, Layout, Article /* we're here */]

React 与通用语言运行时不同,因为它旨在呈现 UI 树,这些树需要“保持活性”,这样我们才能与它们互动。在第一次调用ReactDOM.render()后,DOM 不会消失。

这可能是一个延伸的比喻,但我喜欢把 React 组件看作是在一个“调用树”而不是“调用堆栈”。当我们“走出”Article组件的时候,React 的“调用树”框架不会被破坏。我们需要保持内部状态和对 Host 实例某处的引用。

这些“调用树”帧连同它们的内部状态和 Host 实例一同被销毁但仅当调停规则认为这是必要的时候。如果你读过 React 的源码,你可能会看到这些帧被称为纤维 Fibers

纤维是内部状态实际生存的地方,当状态更新的时候,React 将下面的纤维标记为需要协调,并调用这些组件。

上下文 Context

在 React 中,我们将东西作为 prop 传递给其他组件。有时,大多数组件需要相同的东西——例如,当前选择的视觉主题。通过每一层传递就很麻烦。

在 React 中,这由上下文解决。它本质上类似于组件的动态范围。就好像一个虫洞,让你把东西放在顶端,让底部的每个子节点都可以阅读它,并且在它改变时重新渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Theme = React.createContext(
'light' // Default value as a fallback
);

function DarkApp() {
return (
<Theme.Provider value="dark">
<MyComponents />
<Theme.Provider>
);
}

function SomeDeeplyNestedChild() {
// Depends on where the child is rendered
const theme = useContext(Theme);
// ...
}

SomeDeeplyNestedChild呈现时,useContext(Theme)将在树中查找其上方最近的<ThemeContext.Provider>,并使用其value

(实际上,React 在渲染时维护上下文堆栈。)

如果上面没有ThemeContext.ProvideruseState(ThemeContext)调用的结果将是createContext()调用中指定的默认值,在我们的示例中,它是'light'

作用 Effects

我们之前提到过的,在渲染过程中,React 组件不应该有明显的副作用,但是副作用是有时是必要的。我们可能需要管理焦点、绘制画布、订阅数据源等等。

在 React 中,这是通过声明一个效果来实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 会延迟执行作用,直到浏览器重绘屏幕。这很好,因为像数据源订阅这样的代码不应损害交互时间首次绘图时间。(有一个很少使用的 Hook 可以让你选择退出这种行为,并同步执行,请尽可能避开它。)

作用不止运行一次,它们是在第一次向用户显示组件之后以及在组件更新之后运行。作用可以关闭当前的 props 和 state,如上面的示例中的count

作用可能需要清除,例如在订阅情况下,要在自身之后清楚,作用可以返回函数:

1
2
3
4
useEffect(() => {
DataSource.addSubscription(handleChange);
return () => DataSource.removeSubscription(handleChange);
});

React 将在下次应用此作用之前执行返回的函数,也将在销毁组件之前执行。

有事,在每个渲染上重新运行作用可能实不可取的,如果某些变量没有改变,你可以告诉 React 去跳过应用作用:

1
2
3
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);

但是,这通常是一个早期的优化,如果你不熟悉 JavaScript 的闭包工作方式,可能会导致问题。

例如此代码是错误的::

1
2
3
4
useEffect(() => {
DataSource.addSubscription(handleChange);
return () => DataSource.removeSubscription(handleChange);
}, []);

这个错误是因为[]表示“永远不要在执行这个作用”,但这个作用结束于外部定义的handleChange,和handleChange可以引用任何 props 或 state:

1
2
3
function handleChange() {
console.log(count);
}

如果不让作用重新运行,handleChange将继续指向第一次呈现的版本,count将始终位于其中0

要解决这个问题,请确保如果你指定依赖数组,它包括所有可以更改的内容,包括函数:

1
2
3
4
useEffect(() => {
DataSource.addSubscription(handleChange);
return () => DataSource.removeSubscription(handleChange);
}, [handleChange]);

根据代码的不同,你可能任然会看到不必要的重新订阅,因为handleChange本身在每个渲染中都是不同的。useCallback Hook 可以帮助你实现这一点,或者你可以让它重新订阅。例如,浏览器的addEventListenerAPI 速度非常快,为了避免调用它而跳过循环,可能会导致比它导致更多的问题。

(你可以在这里了解更多关于 useEffect 和 React 提供的其他 Hooks 的信息。)

自定义 Hooks

由于像useStateuseEffect这样的 Hook 是函数调用的,我们可以将它们组合成自己的 Hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function MyResponsiveComponent() {
const width = useWindowWidth(); // Our custom 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;
}

自定义 Hooks 允许不同的组件共享可复用的状态逻辑。注意,state 本身不是共享的,对 Hook 的每个调用都声明自己的独立 state。

(你可以在这里学到更多关于自定义 Hooks 的知识。)

静态使用顺序

你可以将useState视为定义“React state 变量”的语法。当然,这里不是一种真正的语法,我们还在写 JavaScript,但是我们将 React 视为一个运行时环境,因为 React 将 JavaScript 裁剪为描述 UI 树,所以它的特性有时更接近语言空间。

如果use一种语法,那么它处于顶层是有意义的:

1
2
3
4
5
6
7
8
9
10
11
12
// 😉 Note: not a real syntax
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>
);
}

将其放入调节、回调或组件外部意味着什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 😉 Note: not a real syntax

// This is local state... of what?
const [count, setCount] = use State(0);

component Example() {
if (condition) {
// What happens to it when condition is false?
const [count, setCount] = use State(0);
}

function handleClick() {
// What happens to it when we leave a function?
// How is this different from a variable?
const [count, setCount] = use State(0);
}

React 的 state 是组件及其在树中的表示的内部状态。如果use是一个真正的语法,那么将其范围也限定到组件的顶层是有意义的:

1
2
3
4
5
6
7
8
9
// 😉 Note: not a real syntax
component Example(props) {
// Only valid here
const [count, setCount] = use State(0);

if (condition) {
// This would be a syntax error
const [count, setCount] = use State(0);
}

这类似于import只在模块顶层工作。

当然,使用实际上不是一种语法。 (它不会带来太大的好处,也不造成很多损耗。)

但是,React确实期望所有的钩子的调用都只在组件的顶层无条件地发生。这些Hooks 规则可以通过一个 lint 插件强制执行。关于这一设计选择,一直有激烈的争论,但在实践中,我并未发现有何不妥,同样我还写了为什么通常建议的替代方案不起作用

在内部,Hooks 被实现为链表。当你调用useState时,我们将指针移动到下一个节点上,当我们退出组件的“调用”帧,我们将结果列表保存在哪里,知道下一次呈现。

本文提供了 Hooks 如何在内部工作的简化解释,数组可能比链表更容易成为一个构想模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Pseudocode
let hooks, i;
function useState() {
i++;
if (hooks[i]) {
// Next renders
return hooks[i];
}
// First render
hooks.push(...);
}

// Prepare to render
i = -1;
hooks = fiber.hooks || [];
// Call the component
YourComponent();
// Remember the state of Hooks
fiber.hooks = hooks;

(如果你好奇,解码就在这里。If you’re curious, the real code is here.)

这大致就是每个useState()调用获得正确 state 的方式。正如我们之前所提到的,“匹配事物”并非 React 的新特性——调停依赖于类似匹配元素渲染这样的方法。

后续

我们已经讨论了 React 运行时环境几乎所有重要的方面,如果你读完应该会知道 React 中 90%以上的细节,这你可以放心!

我遗留了一些部分——连我们都不太清晰。React 目前对于多路径渲染没有一个很好的阐述,例如当父级渲染需要有关子级的信息时。此外,错误处理 API还没有 Hooks 版本。这两个问题有可能之后被一同解决。并发模式下还不稳定,有一些有趣的问题类似 Suspense 是怎么嵌入这个路线图的。也许我们会在后续使其功能更加丰富,Suspense 已经准备好进行懒加载了。

我认为这说明了 React 的 API 的成功,你可以在不考虑大多数主题的情况下取得很大的进展。大多数情况下,良好的默认值(如调停启发式算法)都是正确的。当你使用危险的方案时,key这类警告也会对你敲打。

如果你是一个 UI 库的维护者,我希望这篇文章会有作用,并且更深入地阐明 React 的工作方式,或者你觉得 React 太过复杂决心剔除。不管哪种情况我都很期待在 Twitter 上了解你的见解!谢谢您的阅读!

Discuss on TwitterEdit on GitHub

原文链接: React as a UI Runtime