原文地址:overreacted.io/react-as-a-…javascript
原文做者:Dan Abramovhtml
大多数教程把 React 称做是一个 UI 库。这是有道理的,由于 React 就是一个 UI 库。正如官网上的标语所说的那样。java
我曾经写过关于构建用户界面会遇到的难题一文。可是本篇文章将以一种不一样的方式来说述 React — 由于它更像是一种编程运行时。react
本篇文章不会教你任何有关如何建立用户界面的技巧。 可是它可能会帮助你更深刻地理解 React 编程模型。git
注意:若是你还在学习 React ,请移步到官方文档进行学习程序员
⚠️github
本篇文章将会很是深刻 — 因此并不适合初学者阅读。 在本篇文章中,我会从最佳原则的角度尽量地阐述 React 编程模型。我不会解释如何使用它 — 而是讲解它的原理。web
文章面向有经验的程序员和那些使用过其余 UI 库但在项目中权衡利弊后最终选择了 React 的人,我但愿它会对你有所帮助!npm
许多人成功使用了 React 多年却从未考虑过下面我将要讲述的主题。 这确定是从程序员的角度来看待 React ,而不是以设计者的角度。但我并不认为站在两个不一样的角度来从新认识 React 会有什么坏处。编程
话很少说,让咱们开始深刻理解 React 吧!
一些程序输出数字。另外一些程序输出诗词。不一样的语言和它们的运行时一般会对特定的一组用例进行优化,而 React 也不例外。
React 程序一般会输出一棵会随时间变化的树。 它有多是一棵 DOM 树 ,iOS 视图层 ,PDF 原语 ,又或是 JSON 对象 。然而,一般咱们但愿用它来展现 UI 。咱们称它为“宿主树”,由于它每每是 React 以外宿主环境中的一部分 — 就像 DOM 或 iOS 。宿主树一般有它本身的命令式 API 。而 React 就是它上面的那一层。
因此到底 React 有什么用呢?很是抽象地,它能够帮助你编写可预测的,而且可以操控复杂的宿主树进而响应像用户交互、网络响应、定时器等外部事件的应用程序。
当专业的工具能够施加特定的约束且能从中获益时,它比通常的工具要好。React 就是这样的典范,而且它坚持两个原则:
这些原则刚好适用于大多数 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 范例。
在宿主环境中,一个宿主实例(例如 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 渲染器都有一个“入口”。正是那个特定的 API 让咱们告诉 React ,将特定的 React 元素树渲染到真正的宿主实例中去。
例如,React DOM 的入口就是 ReactDOM.render
:
ReactDOM.render(
// { type: 'button', props: { className: 'blue' } }
<button className="blue" />,
document.getElementById('container')
);
复制代码
当咱们调用 ReactDOM.render(reactElement, domContainer)
时,咱们的意思是:“亲爱的 React ,将个人 reactElement
映射到 domContaienr
的宿主树上去吧。“
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 会在第一次渲染中递归地为它们建立宿主实例。
若是咱们用同一个 container 调用 ReactDOM.render()
两次会发生什么呢?
ReactDOM.render(
<button className="blue" />,
document.getElementById('container')
);
// ... 以后 ...
// 应该替换掉 button 宿主实例吗?
// 仍是在已有的 button 上更新属性?
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 环境下,这样的作法效率低下并且会丢失像 focus、selection、scroll 等许多状态。相反,咱们但愿 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')
);
复制代码
一样的启发式方法也适用于子树。例如,当咱们在 <dialog>
中新增两个 <button>
,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
宿主实例。(nothing) → 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 而丢失了 selection、focus 等状态以及其中的内容。
虽然这个问题很容易解决(在下面我会立刻讲到),但这个问题在 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>
);
}
复制代码
这个例子并不会遇到刚刚咱们所描述的问题。让咱们用对象注释而不是 JSX 也许能够更好地理解其中的缘由。来看一下 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>
)
}
复制代码
若是咱们的商品列表被从新排序了,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>
中的子元素们改变位置后,这个方法一样有效。在渲染先后当 key 仍然相同时,React 会重用先前的宿主实例,而后从新排序其兄弟元素。
须要注意的是 key
只与特定的父亲 React 元素相关联,好比 <form>
。React 并不会去匹配父元素不一样但 key 相同的子元素。(React 并无惯用的支持对在不从新建立元素的状况下让宿主实例在不一样的父元素之间移动。)
给 key
赋予什么值最好呢?最好的答案就是:何时你会说一个元素不会改变即便它在父元素中的顺序被改变? 例如,在咱们的商品列表中,商品自己的 ID 是区别于其余商品的惟一标识,那么它就最适合做为 key
。
咱们已经知道函数会返回 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 中不是惯用的。(咱们会在以后讲解如何用更惯用的方式来更新 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 并不关心你的代码是否像严格的函数式编程同样百分百纯净。在 React 中,幂等性比纯净性更加剧要。
也就是说,在 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)
App
,你想要渲染什么?
App
:我要渲染包含 <Content>
的 <Layout>
。<Layout>
,你要渲染什么?
Layout
:我要在 <div>
中渲染个人子元素。个人子元素是 <Content>
因此我猜它应该渲染到 <div>
中去。<Content>
,你要渲染什么?
<Content>
:我要在 <article>
中渲染一些文本和 <Footer>
。<Footer>
,你要渲染什么?
<Footer>
:我要渲染含有文本的 <footer>
。// 最终的 DOM 结构
<div>
<article>
Some text
<footer>some more text</footer>
</article>
</div>
复制代码
这就是为何咱们说协调是递归式的。当 React 遍历整个元素树时,可能会遇到元素的 type
是一个组件。React 会调用它而后继续沿着返回的 React 元素下行。最终咱们会调用完全部的组件,而后 React 就会知道该如何改变宿主树。
在以前已经讨论过的相同的协调准则,在这同样适用。若是在同一位置的 type
改变了(由索引和可选的 key
决定),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 调用咱们的组件,咱们会得到一些有趣的属性:
<Feed>
页面转到 Profile
页面,React 不会尝试重用其中的宿主实例 — 就像你用 <p>
替换掉 <button>
同样。全部的状态都会丢失 — 对于渲染彻底不一样的视图时,一般来讲这是一件好事。你不会想要在 <PasswordForm>
和 <MessengerChat>
之间保留输入框的状态尽管 <input>
的位置意外地“排列”在它们之间。让 React 调用你的组件函数还有最后一个好处就是惰性求值。让咱们看看它是什么意思。
当咱们在 JavaScript 中调用函数时,参数每每在函数调用以前被执行。
// (2) 它会做为第二个计算
eat(
// (1) 它会首先计算
prepareMeal()
);
复制代码
这一般是 JavaScript 开发者所指望的由于 JavaScript 函数可能有隐含的反作用。若是咱们调用了一个函数,但直到它的结果不知怎地被“使用”后该函数仍没有执行,这会让咱们感到十分诧异。
可是,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({ currentUser, children }) {
return (
<Layout>
{children}
</Layout>
);
}
复制代码
(在 JSX 中 <A><B /></A>
和 <A children={<B />} />
相同。)
可是要是存在提早返回的状况呢?
function Page({ currentUser, children }) {
if (!currentUser.isLoggedIn) {
return <h1>Please login</h1>;
}
return (
<Layout>
{children}
</Layout>
);
}
复制代码
若是咱们像函数同样调用 Commonts()
,无论 Page
是否想渲染它们都会被当即执行:
// {
// type: Page,
// props: {
// children: Comments() // 老是调用!
// }
// }
<Page>
{Comments()}
</Page>
复制代码
可是若是咱们传递的是一个 React 元素,咱们不须要本身执行 Comments
:
// {
// type: Page,
// props: {
// children: { type: Comments }
// }
// }
<Page>
<Comments />
</Page>
复制代码
让 React 来决定什么时候以及是否调用组件。若是咱们的的 Page
组件忽略自身的 children
prop 且相反地渲染了 <h1>Please login</h1>
,React 不会尝试去调用 Comments
函数。重点是什么?
这很好,由于它既可让咱们避免没必要要的渲染也能使咱们的代码变得不那么脆弱。(当用户退出登陆时,咱们并不在意 Comments
是否被丢弃 — 由于它从没有被调用过。)
咱们先前提到过关于协调和在树中元素概念上的“位置”是如何让 React 知晓是该重用宿主实例仍是该重建它。宿主实例可以拥有全部相关的局部状态:focus、selection、input 等等。咱们想要在渲染更新概念上相同的 UI 时保留这些状态。咱们也想可预测性地摧毁它们,当咱们在概念上渲染的是彻底不一样的东西时(例如从 <SignupForm>
转换到 <MessengerChat>
)。
局部状态是如此有用,以致于 React 让你的组件也能拥有它。 组件仍然是函数可是 React 用对构建 UI 有好处的许多特性加强了它。在树中每一个组件所绑定的局部状态就是这些特性之一。
咱们把这些特性叫作 Hooks 。例如,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
来替代第二个值不管它在具体的例子中被称做什么。
(你能在 React 文档 中学习到更多关于 useState
和 其余 Hooks 的知识。)
即便咱们想将协调过程自己分割成非阻塞的工做块,咱们仍然须要在同步的循环中对真实的宿主实例进行操做。这样咱们才能保证用户不会看见半更新状态的 UI ,浏览器也不会对用户不该看到的中间状态进行没必要要的布局和样式的从新计算。
这也是为何 React 将全部的工做分红了”渲染阶段“和”提交阶段“的缘由。渲染阶段 是当 React 调用你的组件而后进行协调的时段。在此阶段进行干涉是安全的且在将来这个阶段将会变成异步的。提交阶段 就是 React 操做宿主树的时候。而这个阶段永远是同步的。
当父组件经过 setState
准备更新时,React 默认会协调整个子树。由于 React 并不知道在父组件中的更新是否会影响到其子代,因此 React 默认保持一致性。这听起来会有很大的性能消耗但事实上对于小型和中型的子树来讲,这并非问题。
当树的深度和广度达到必定程度时,你可让 React 去缓存子树而且重用先前的渲染结果当 prop 在浅比较以后是相同时:
function Row({ item }) {
// ...
}
export default React.memo(Row);
复制代码
如今,在父组件 <Table>
中调用 setState
时若是 <Row>
中的 item
与先前渲染的结果是相同的,React 就会直接跳过协调的过程。
你能够经过 useMemo()
Hook 得到单个表达式级别的细粒度缓存。该缓存于其相关的组件紧密联系在一块儿,而且将与局部状态一块儿被销毁。它只会保留最后一次计算的结果。
默认状况下,React 不会故意缓存组件。许多组件在更新的过程当中老是会接收到不一样的 props ,因此对它们进行缓存只会形成净亏损。
使人讽刺地是,React 并无使用“反应式”的系统来支持细粒度的更新。换句话说,任何在顶层的更新只会触发协调而不是局部更新那些受影响的组件。
这样的设计是有意而为之的。对于 web 应用来讲交互时间是一个关键指标,而经过遍历整个模型去设置细粒度的监听器只会浪费宝贵的时间。此外,在不少应用中交互每每会致使或小(按钮悬停)或大(页面转换)的更新,所以细粒度的订阅只会浪费内存资源。
React 的设计原则之一就是它能够处理原始数据。若是你拥有从网络请求中得到的一组 JavaScript 对象,你能够将其直接交给组件而无需进行预处理。没有关于能够访问哪些属性的问题,或者当结构有所变化时形成的意外的性能缺损。React 渲染是 O(视图大小) 而不是 O(模型大小) ,而且你能够经过 windowing 显著地减小视图大小。
有那么一些应用细粒度订阅对它们来讲是有用的 — 例如股票代码。这是一个极少见的例子,由于“全部的东西都须要在同一时间内持续更新”。虽然命令式的方法可以优化此类代码,但 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 浏览器 click 事件处理过程 ***
Child (onClick)
- setState
- re-render Child // 😞 没必要要的重渲染
Parent (onClick)
- setState
- re-render Parent
- re-render Child
*** 结束 React 浏览器 click 事件处理过程 ***
复制代码
第一次 Child
组件渲染是浪费的。而且咱们也不会让 React 跳过 Child
的第二次渲染由于 Parent
可能会传递不一样的数据因为其自身的状态更新。
这就是为何 React 会在组件内全部事件触发完成后再进行批量更新的缘由:
*** 进入 React 浏览器 click 事件处理过程 ***
Child (onClick)
- setState
Parent (onClick)
- setState
*** Processing state updates ***
- re-render Parent
- re-render Child
*** 结束 React 浏览器 click 事件处理过程 ***
复制代码
组件内调用 setState
并不会当即执行重渲染。相反,React 会先触发全部的事件处理器,而后再触发一次重渲染以进行所谓的批量更新。
批量更新虽然有用但可能会让你感到惊讶若是你的代码这样写:
const [count, setCounter] = useState(0);
function increment() {
setCounter(count + 1);
}
function handleClick() {
increment();
increment();
increment();
}
复制代码
若是咱们将 count
初始值设为 0
,上面的代码只会表明三次 setCount(1)
调用。为了解决这个问题,咱们给 setState
提供了一个 “updater” 函数做为参数:
const [count, setCounter] = useState(0);
function increment() {
setCounter(c => c + 1);
}
function handleClick() {
increment();
increment();
increment();
}
复制代码
React 会将 updater 函数放入队列中,并在以后按顺序执行它们,最终 count
会被设置成 3
并做为一次重渲染的结果。
当状态逻辑变得更加复杂而不只仅只是少数的 setState
调用时,我建议你使用 useReducer
Hook 来描述你的局部状态。它就像 “updater” 的升级模式在这里你能够给每一次更新命名:
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()
时,在 JavaScript 引擎中会有像 [a, b, c]
这样的数据结构来“跟踪”当前的位置以及接下来要执行的代码。一旦 c
函数执行完毕,它的调用栈帧就消失了!由于它再也不被须要了。咱们返回到函数 b
中。当咱们结束函数 a
的执行时,调用栈就被清空。
固然,React 以 JavaScript 运行固然也遵循 JavaScript 的规则。可是咱们能够想象在 React 内部有本身的调用栈用来记忆咱们当前正在渲染的组件,例如 [App, Page, Layout, Article /* 此刻的位置 */]
。
React 与一般意义上的编程语言进行时不一样由于它针对于渲染 UI 树,这些树须要保持“活性”,这样才能使咱们与其进行交互。在第一次 ReactDOM.render()
出现以前,DOM 操做并不会执行。
这也许是对隐喻的延伸,但我喜欢把 React 组件看成 “调用树” 而不是 “调用栈” 。当咱们调用完 Article
组件,它的 React “调用树” 帧并无被摧毁。咱们须要将局部状态保存以便映射到宿主实例的某个地方。
这些“调用树”帧会随它们的局部状态和宿主实例一块儿被摧毁,可是只会在协调规则认为这是必要的时候执行。若是你曾经读过 React 源码,你就会知道这些帧其实就是 Fibers 。
Fibers 是局部状态真正存在的地方。当状态被更新后,React 将其下面的 Fibers 标记为须要进行协调,以后便会调用这些组件。
在 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 维护了一个上下文栈当其渲染时。)
若是没有 ThemeContext.Provider
存在,useContext(ThemeContext)
调用的结果就会被调用 createContext()
时传递的默认值所取代。在上面的例子中,这个值为 'light'
。
咱们在以前提到过 React 组件在渲染过程当中不该该有可观察到的反作用。可是有些时候反作用确实必要的。咱们也许须要进行管理 focus 状态、用 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 若是相应的变量不会改变则跳过这次调用:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
复制代码
可是,这每每会成为过早地优化并会形成一些问题若是你不熟悉 JavaScript 中的闭包是如何工做的话。
例如,下面的这段代码是有 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
可以帮你解决这个问题。或者,你能够直接让它重订阅。例如浏览器中的 addEventListener
API 很是快,但为了在组件中避免使用它可能会带来更多的问题而不是其真正的价值。
(你能在 React 文档 中学到更多关于 useEffect
和其余 Hooks 的知识。)
因为 useState
和 useEffect
是函数调用,所以咱们能够将其组合成本身的 Hooks :
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;
}
复制代码
自定义 Hooks 让不一样的组件共享可重用的状态逻辑。注意状态自己是不共享的。每次调用 Hook 都只声明了其自身的独立状态。
(你能在 React 文档 中学习更多关于构建本身的 Hooks 的内容。)
你能够把 useState
想象成一个能够定义“React 状态变量”的语法。它并非真正的语法,固然,咱们仍在用 JavaScript 编写应用。可是咱们将 React 做为一个运行时环境来看待,由于 React 用 JavaScript 来描绘整个 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 的确指望全部的 Hooks 调用只发生在组件的顶部而且不在条件语句中。这些 Hooks 的规则可以被 linter plugin 所规范。有不少关于这种设计选择的激烈争论,但在实践中我并无看到它让人困惑。我还写了关于为何一般提出的替代方案不起做用的文章。
Hooks 的内部实现实际上是链表 。当你调用 useState
的时候,咱们将指针移到下一项。当咱们退出组件的“调用树”帧时,会缓存该结果的列表直到下次渲染开始。
这篇文章简要介绍了 Hooks 内部是如何工做的。数组也许是比链表更好解释其原理的模型:
// 伪代码
let hooks, i;
function useState() {
i++;
if (hooks[i]) {
// 再次渲染时
return hooks[i];
}
// 第一次渲染
hooks.push(...);
}
// 准备渲染
i = -1;
hooks = fiber.hooks || [];
// 调用组件
YourComponent();
// 缓存 Hooks 的状态
fiber.hooks = hooks;
复制代码
(若是你对它感兴趣,真正的代码在这里 。)
这大体就是每一个 useState()
如何得到正确状态的方式。就像咱们以前所知道的,“匹配”对 React 来讲并非什么新的知识 — 这与协调依赖于在渲染先后元素是否匹配是一样的道理。
咱们已经触及到 React 运行时环境中几乎全部重要的方面。若是你读完了本篇文章,你可能已经比 90% 的开发者更了解 React !这一点也没有错!
固然有一些地方我并无说起到 — 主要是由于咱们对它们也不太清楚。React 目前对多道渲染并无太好的支持,即当父组件的渲染须要子组件提供信息时。错误处理 API 目前也尚未 Hooks 的版本。这两个问题可能会被一块儿解决。并发模式在目前看来并不稳定,也有不少关于 Suspense 该如何适应当前版本的有趣问题。也许我会在它们要完成的时候再来讨论,而且 Suspense 已经准备比如 lazy loading 可以作的更多。
我认为 React API 的成功之处在于,即便在没有考虑过上面这些大多数主题的状况下,你也能轻松使用它而且能够走的很远。 在大多数状况下,像协调这样好的默认特性启发式地为咱们作了正确的事情。在你忘记添加 key
这样的属性时,React 可以好心提醒你。
若是你是痴迷于 UI 库的书呆子,我但愿这篇文章对你来讲会颇有趣而且是深刻阐明了 React 是如何工做的。又或许你会以为 React 太过于复杂为此你不会再去深刻理解它。无论怎样,我都很乐意在 Twitter 上收到你的消息!谢谢你的阅读。