将React当做前端UI运行时环境来看待
大多数教程把 React 称作是一个 UI 库。这是有道理的,因为 React 就是一个 UI 库。正如官网上的标语所说的那样。

我曾经写过关于构建[用户界面][1]会遇到的难题一文。但是本篇文章将以一种不同的方式来讲述 React — 因为它更像是一种编程运行时。
本篇文章不会教你任何有关如何创建用户界面的技巧。 但是它可能会帮助你更深入地理解 React 编程模型。
注意:如果你还在学习 React ,请移步到官方文档进行学习
⚠️
本篇文章将会非常深入 — 所以并不适合初学者阅读。 在本篇文章中,我会从最佳原则的角度尽可能地阐述 React 编程模型。我不会解释如何使用它 — 而是讲解它的原理。
文章面向有经验的程序员和那些使用过其他 UI 库但在项目中权衡利弊后最终选择了 React 的人,我希望它会对你有所帮助!
许多人成功使用了 React 多年却从未考虑过下面我将要讲述的主题。 这肯定是从程序员的角度来看待 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 的 API 而是克隆双亲树并始终替换掉顶级子树的宿主环境。在宿主树级别上的不可变性使得多线程变得更加容易。React Fabric 就利用了这一模式。
作为 React 的使用者,你永远不需要考虑这些模式。我只想强调 React 不仅仅只是从一种模式转换到另一种模式的适配器。它的用处在于以一种更好的方式操控宿主实例而不用在意那些低级视图 API 范例。
React 元素
在宿主环境中,一个宿主实例(例如 DOM 节点)是最小的构建单元。而在 React 中,最小的构建单元是 React 元素。
React 元素是一个普通的 JavaScript 对象。它用来描述一个宿主实例。
React 元素是轻量级的因为没有宿主实例与它绑定在一起。同样的,它只是对你想要在屏幕上看到的内容的描述。
就像宿主实例一样,React 元素也能形成一棵树:
(注意:我省略了一些对此解释不重要的[属性][2])
但是,请记住 React 元素并不是永远存在的 。它们总是在重建和删除之间不断循环着。
React 元素具有不可变性。例如,你不能改变 React 元素中的子元素或者属性。如果你想要在稍后渲染一些不同的东西,你需要从头创建新的 React 元素树来描述它。
我喜欢将 React 元素比作电影中放映的每一帧。它们捕捉 UI 在特定的时间点应该是什么样子。它们永远不会再改变。
入口
每一个 React 渲染器都有一个“入口”。正是那个特定的 API 让我们告诉 React ,将特定的 React 元素树渲染到真正的宿主实例中去。
例如,React DOM 的入口就是 ReactDOM.render :
当我们调用 ReactDOM.render(reactElement, domContainer) 时,我们的意思是:“亲爱的 React ,将我的 reactElement 映射到 domContaienr 的宿主树上去吧。“
React 会查看 reactElement.type (在我们的例子中是 button )然后告诉 React DOM 渲染器创建对应的宿主实例并设置正确的属性:
在我们的例子中,React 会这样做:
如果 React 元素在 reactElement.props.children 中含有子元素,React 会在第一次渲染中递归地为它们创建宿主实例。
协调
如果我们用同一个 container 调用 ReactDOM.render() 两次会发生什么呢?
// … 之后 …
// 应该替换掉 button 宿主实例吗?
// 还是在已有的 button 上更新属性?
ReactDOM.render(
</div>
同样的,React 的工作是将 React 元素树映射到宿主树上去。确定该对宿主实例做什么来响应新的信息有时候叫做<a href="https://reactjs.org/docs/reconciliation.html" target="_blank" rel="nofollow noopener noreferrer">协调</a> 。
有两种方法可以解决它。简化版的 React 会丢弃已经存在的树然后从头开始创建它:
<div class="gatsby-highlight" data-language="jsx">
let domContainer = document.getElementById(‘container’); // 清除掉原来的树 domContainer.innerHTML = ‘’; // 创建新的宿主实例树 let domNode = document.createElement(‘button’); domNode.className = ‘red’; domContainer.appendChild(domNode);
</div>
但是在 DOM 环境下,这样的做法效率低下而且会丢失像 focus、selection、scroll 等许多状态。相反,[我们](https://www.a2doc.com)希望 React 这样做:
<div class="gatsby-highlight" data-language="jsx">
let domNode = domContainer.firstChild; // 更新已有的宿主实例 domNode.className = ‘red’;
</div>
换句话说,React 需要决定何时更新一个已有的宿主实例来匹配新的 React 元素,何时该重新创建新的宿主实例。
这就引出了一个识别问题。React 元素可能每次都不相同,到底什么时候才该从概念上引用同一个宿主实例呢?
在[我们](https://www.a2doc.com)的例子中,它很简单。[我们](https://www.a2doc.com)之前渲染了 <button> 作为第一个(也是唯一)的子元素,接下来[我们](https://www.a2doc.com)想要在同一个地方再次渲染 <button> 。在宿主实例中[我们](https://www.a2doc.com)已经有了一个 <button> 为什么还要重新创建呢?让[我们](https://www.a2doc.com)重用它。
这与 React 如何思考并解决这类问题已经很接近了。
**如果相同的元素类型在同一个地方先后出现两次,React 会重用已有的宿主实例。**
这里有一个例子,其中的注释大致解释了 React 是如何工作的:
<div class="gatsby-highlight" data-language="jsx">
// let domNode = document.createElement(‘button’);
// domNode.className = ‘blue’;
// domContainer.appendChild(domNode);
ReactDOM.render(
// 能重用宿主实例吗?能!(button → button)// domNode.className = ‘red’;ReactDOM.render(
// 能重用宿主实例吗?不能!(button → p)// domContainer.removeChild(domNode); // domNode = document.createElement(‘p’); // domNode.textContent = ‘Hello’; // domContainer.appendChild(domNode); ReactDOM.render(
Hello
, document.getElementById('container') );// 能重用宿主实例吗?能!(p → p)// domNode.textContent = ‘Goodbye’;ReactDOM.render(
Goodbye
, document.getElementById('container') ); ```同样的启发式方法也适用于子树。例如,当我们在
条件
如果 React 在渲染更新前后只重用那些元素类型匹配的宿主实例,那当遇到包含条件语句的内容时又该如何渲染呢?
假设我们只想首先展示一个输入框,但之后要在它之前渲染一条信息:
// 下一次渲染 ReactDOM.render(
I was just added here!
在这个例子中, 宿主实例会被重新创建。React 会遍历整个元素树,并将其与先前的版本进行比较:
- dialog → dialog :能重用宿主实例吗?能 — 因为类型是匹配的。
- input → p :能重用宿主实例吗?不能,类型改变了! 需要删除已有的 input 然后重新创建一个 p 宿主实例。
- (nothing) → input :需要重新创建一个 input 宿主实例。
因此,React 会像这样执行更新:
let newInputNode = document.createElement(‘input’);dialogNode.appendChild(newInputNode);
</div>
这样的做法并不科学因为事实上 <input> 并没有被 <p> 所替代 — 它只是移动了位置而已。[我们](https://www.a2doc.com)不希望因为重建 DOM 而丢失了 selection、focus 等状态以及其中的内容。
虽然这个问题很容易解决(在下面我会马上讲到),但这个问题在 React 应用中并不常见。而当[我们](https://www.a2doc.com)探讨为什么会这样时却很有意思。
事实上,你很少会直接调用 ReactDOM.render 。相反,在 React 应用中程序往往会被拆分成这样的函数:
<div class="gatsby-highlight" data-language="jsx">
function Form({ showMessage }) { let message = null; if (showMessage) { message =
I was just added here!
; } return (</div>
这个例子并不会遇到刚刚[我们](https://www.a2doc.com)所描述的问题。让[我们](https://www.a2doc.com)用对象注释而不是 JSX 也许可以更好地理解其中的原因。来看一下 dialog 中的子元素树:
<div class="gatsby-highlight" data-language="jsx">
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: {} } ] } }; }
</div>
**不管 showMessage 是 true 还是 false ,在渲染的过程中 <input> 总是在第二个孩子的位置且不会改变。**
如果 showMessage 从 false 改变为 true ,React 会遍历整个元素树,并与之前的版本进行比较:
* dialog → dialog :能够重用宿主实例吗?**能 — 因为类型匹配。**
* (null) → p :需要插入一个新的 p 宿主实例。
* input → input :能够重用宿主实例吗?**能 — 因为类型匹配。**
之后 React 大致会像这样执行代码:
<div class="gatsby-highlight" data-language="jsx">
let inputNode = dialogNode.firstChild; let pNode = document.createElement(‘p’); pNode.textContent = ‘I was just added here!’; dialogNode.insertBefore(pNode, inputNode);
</div>
这样一来输入框中的状态就不会丢失了。
## 列表 {#列表}
比较树中同一位置的元素类型对于是否该重用还是重建相应的宿主实例往往已经足够。
但这只适用于当子元素是静止的并且不会重排序的情况。在上面的例子中,即使 message 不存在,[我们](https://www.a2doc.com)仍然知道输入框在消息之后,并且再没有其他的子元素。
而当遇到动态列表时,[我们](https://www.a2doc.com)不能确定其中的顺序总是一成不变的。
<div class="gatsby-highlight" data-language="jsx">
function ShoppingList({ list }) { return (
) }</div>
如果[我们](https://www.a2doc.com)的商品列表被重新排序了,React 只会看到所有的 p 以及里面的 input 拥有相同的类型,并不知道该如何移动它们。(在 React 看来,虽然这些商品本身改变了,但是它们的顺序并没有改变。)
所以 React 会对这十个商品进行类似如下的重排序:
<div class="gatsby-highlight" data-language="jsx">
for (let i = 0; i < 10; i++) { let pNode = formNode.childNodes[i]; let textNode = pNode.firstChild; textNode.textContent = ‘You bought ’ + items[i].name; }
</div>
React 只会对其中的每个元素进行更新而不是将其重新排序。这样做会造成性能上的问题和潜在的 bug 。例如,当商品列表的顺序改变时,原本在第一个输入框的内容仍然会存在于现在的第一个输入框中 — 尽管事实上在商品列表里它应该代表着其他的商品!
**这就是为什么每次当输出中包含元素数组时,React 都会让你指定一个叫做 key 的属性:**
<div class="gatsby-highlight" data-language="jsx">
function ShoppingList({ list }) { return (
) }</div>
key 给予 React 判断子元素是否真正相同的能力,即使在渲染前后它在父元素中的位置不是相同的。
当 React 在 <form> 中发现 <p key="42"> ,它就会检查之前版本中的 <form> 是否同样含有 <p key="42"> 。即使 <form> 中的子元素们改变位置后,这个方法同样有效。在渲染前后当 key 仍然相同时,React 会重用先前的宿主实例,然后重新排序其兄弟元素。
需要注意的是 key 只与特定的父亲 React 元素相关联,比如 <form> 。React 并不会去匹配父元素不同但 key 相同的子元素。(React 并没有惯用的支持对在不重新创建元素的情况下让宿主实例在不同的父元素之间移动。)
给 key 赋予什么值最好呢?最好的答案就是:**什么时候你会说一个元素不会改变即使它在父元素中的顺序被改变?** 例如,在[我们](https://www.a2doc.com)的商品列表中,商品本身的 ID 是区别于其他商品的唯一标识,那么它就最适合作为 key 。
## 组件 {#组件}
[我们](https://www.a2doc.com)已经知道函数会返回 React 元素:
<div class="gatsby-highlight" data-language="jsx">
function Form({ showMessage }) { let message = null; if (showMessage) { message =
I was just added here!
; } return (</div>
这些函数被叫做组件。它们让[我们](https://www.a2doc.com)可以打造自己的“工具箱”,例如按钮、头像、评论框等等。组件就像 React 的面包和黄油。
组件接受一个参数 — 对象哈希。它包含“props”(“属性”的简称)。在这里 showMessage 就是一个 prop 。它们就像是具名参数一样。
## 纯净 {#纯净}
React 组件中对于 props 应该是纯净的。
<div class="gatsby-highlight" data-language="jsx">
function Button(props) { // 🔴 没有作用 props.isActive = true; }
</div>
通常来说,突变在 React 中不是惯用的。([我们](https://www.a2doc.com)会在之后讲解如何用更惯用的方式来更新 UI 以响应事件。)
不过,局部的突变是绝对允许的:
<div class="gatsby-highlight" data-language="jsx">
function FriendList({ friends }) {
let items = []; for (let i = 0; i < friends.length; i++) {
let friend = friends[i];
items.push(
</div>
当[我们](https://www.a2doc.com)在函数组件内部创建 items 时不管怎样改变它都行,只要这些突变发生在将其作为最后的渲染结果之前。所以并不需要重写你的代码来避免局部突变。
同样地,惰性初始化是被允许的即使它不是完全“纯净”的:
<div class="gatsby-highlight" data-language="jsx">
function ExpenseForm() { // 只要不影响其他组件这是被允许的: SuperCalculator.initializeIfNotReady();
// 继续渲染…… }
</div>
只要调用组件多次是安全的,并且不会影响其他组件的渲染,React 并不关心你的代码是否像严格的函数式编程一样百分百纯净。在 React 中,<a href="https://stackoverflow.com/questions/1077412/what-is-an-idempotent-operation" target="_blank" rel="nofollow noopener noreferrer">幂等性</a>比纯净性更加重要。
也就是说,在 React 组件中不允许有用户可以直接看到的副作用。换句话说,仅调用函数式组件时不应该在屏幕上产生任何变化。
## 递归 {#递归}
[我们](https://www.a2doc.com)该如何在组件中使用组件?组件属于函数因此[我们](https://www.a2doc.com)可以直接进行调用:
<div class="gatsby-highlight" data-language="jsx">
let reactElement = Form({ showMessage: true }); ReactDOM.render(reactElement, domContainer);
</div>
然而,在 React 运行时中这并不是惯用的使用组件的方式。
相反,使用组件惯用的方式与[我们](https://www.a2doc.com)已经了解的机制相同 — 即 React 元素。**这意味着不需要你直接调用组件函数,React 会在之后为你做这件事情:**
<div class="gatsby-highlight" data-language="jsx">
// { type: Form, props: { showMessage: true } } let reactElement =
这就是为什么我们说协调是递归式的。当 React 遍历整个元素树时,可能会遇到元素的 type 是一个组件。React 会调用它然后继续沿着返回的 React 元素下行。最终我们会调用完所有的组件,然后 React 就会知道该如何改变宿主树。
在之前已经讨论过的相同的协调准则,在这一样适用。如果在同一位置的 type 改变了(由索引和可选的 key 决定),React 会删除其中的宿主实例并将其重建。
控制反转
你也许会好奇:为什么我们不直接调用组件?为什么要编写