原文:Blogged Answers: A (Mostly) Complete Guide to React Rendering Behavior。
提要:关于 React 渲染行为的细节以及 Context 和 React-Redux 的使用如何影响渲染。
关于 React 何时、为何以及如何重新渲染组件,以及 Context 和 React-Redux 的使用将如何影响这些重新渲染的时间和范围,我看到了很多源源不断的困惑。在多次阐述关于这些内容的解释之后,有必要编写一个可以推荐给大家参考的综合解释。注意,所有这些信息都是可以网上查询到的,并且已经有许多其他优秀的博客文章进行了解释,其中一些我在 更多信息 提供了链接以供参考。但是,人们希望可以将各个部分拼凑起来以获得完整的理解,因此希望这篇文章能帮助某些人理解清楚这些内容。
注意:2022 年 10 月更新以涵盖 React 18 和未来的 React 更新
我还根据这篇文章为 React Advanced 2022 做了一次演讲:
React Advanced 2022 - A (Brief) Guide to React Rendering Behavior
什么是 “渲染”
渲染 是 React 要求您的组件根据当前的 props 和 state 来描述他们希望得到的 UI 部分是怎样的过程。
渲染过程概览
在渲染过程中,React 将从组件树的根部开始,向下遍历查找所有标记为需要更新的组件。对于每个被标记的组件,React 将调用 FunctionComponent(props)(对于函数组件)或 classComponentInstance.render()(对于类组件),并保存渲染输出以用于渲染过程的后续步骤。
组件的渲染输出通常用 JSX 语法编写,需要在编译为 JS 并且准备部署之前将其转换为 React.createElement() 调用。createElement 返回 React 元素,这些元素是描述 UI 预期结构的普通 JS 对象。例如:
// This JSX syntax:
return <MyComponent a={42} b="testing">Text here</MyComponent>
// is converted to this call:
return React.createElement(MyComponent, {a: 42, b: "testing"}, "Text Here")
// and that becomes this element object:
{type: MyComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
// And internally, React calls the actual function to render it:
let elements = MyComponent({...props, children})
// For "host components" like HTML:
return <button onClick={() => {}}>Click Me</button>
// becomes
React.createElement("button", {onClick}, "Click Me")
// and finally:
{type: "button", props: {onClick}, children: ["Click me"]}
在收集了整个组件树的渲染输出后,React 将比较(diff)新的对象树(通常称为“虚拟 DOM”),并收集一个包含所有需要应用的更改的列表,用于实现输出当前所需的真实的 DOM。比较(diff)和计算过程称为 reconciliation(协调)。
然后,React 在一个同步队列中应用所有计算出来的更改到 DOM。
注意:近年来,React 团队淡化了“虚拟 DOM”一词。Dan Abramov 说:
我希望我们可以淘汰“虚拟 DOM”这个术语。这在 2013 年是有道理的,因为那时候人们会以为 React 需要在每次渲染时创建 DOM 节点。但现在人们很少这么认为了。“虚拟 DOM”听起来像是用来解决某些 DOM 问题。但这不是 React 的本质。
React 是 “value UI”。它的核心是 UI 是一个值,就像字符串或者数组一样。你可以将它保存在一个变量中,传递它,使用 JavaScript 控制流,等等。这种表现力才是重点——而不是为了避免对 DOM 应用更改而进行的一些 diff 比较。(译者注:可以查看上面的推特链接,这里举了个例子,如果将来操作 DOM 的性能非常快,难道我们就不会使用React了嘛?)
它甚至并不总是代表 DOM,例如
<Message recipientId={10} />不是 DOM。从概念上讲,它表示惰性函数调用:Message.bind(null, { recipientId: 10 })。
渲染和提交阶段
React 团队将这项工作分为两个阶段,概念上:
- “渲染阶段”包含渲染组件和计算变化的所有工作
- “提交阶段”是将这些更改应用到 DOM 的过程
React 在提交阶段更新了 DOM 之后,它会更新所有 refs 以指向相应地的 DOM 节点和组件实例。然后它同步运行 componentDidMount 和 componentDidUpdate class 生命周期方法,以及 useLayoutEffect hook。
然后 React 设置一个短的超时时间,当超时时,运行所有的 useEffect hooks。此步骤也称为“Passive Effects”阶段。
React 18 添加了“并发渲染”功能,例如 useTransition。这使 React 能够暂停渲染阶段的工作,以允许浏览器处理事件。React 将在适当的时候恢复、丢弃或重新计算原来的工作。渲染阶段完成后,React 下一步将同步运行提交阶段。
理解这一点的关键部分是“渲染”与“更新 DOM”不同,组件可以在没有任何可见变化(译者注:即真实 DOM 变化)的情况下被渲染。当 React 渲染一个组件时:
- 该组件可能返回与上次相同的渲染输出,因此无需更改
- 在并发渲染中,React 可能会多次渲染一个组件,但如果其他更新使当前正在完成的工作无效,则每次都会丢弃渲染输出
这个出色的交互式 React hooks 时间线图有助于说明渲染、提交和执行 hooks 的顺序:
有关其他可视化,请参阅:
- React hooks flow diagram
- React hooks render/commit phase diagram
- React class lifecycle methods diagram
React 如何处理渲染?
排队渲染
初始渲染完成后,有几种不同的方式告诉 React 排队重新渲染:
- Function 组件:
useStatesettersuseReducerdispatches
- Class 组件:
this.setState()this.forceUpdate()
- 其它:
- 再次调用 ReactDOM 顶层
render(<App>)方法(相当于在根组件上调用forceUpdate()) - 从新出的
useSyncExternalStorehook 触发的更新
- 再次调用 ReactDOM 顶层
请注意,函数组件没有 forceUpdate 方法,但您可以通过使用 useReducer hook 实现递增计数器来获得相同的效果:
const [, forceRender] = useReducer((c) => c + 1, 0);
标准渲染行为
记住这一点非常重要:
React 的默认行为是当一个父组件渲染时,React 将递归渲染它所有子组件!
例如,假设我们有一个 A > B > C > D 的组件树,并且我们已经在页面上显示了它们。用户单击 B 中的一个按钮,该按钮会使计数器增加1:
- 我们在
B中调用setState(),B 会排队等待重新渲染。 - React 从树的顶部开始渲染过程
- React 看到
A没有被标记为需要更新,那么跳过它 - React 看到
B被标记为需要更新,然后渲染它。 B 像上次一样返回<C />。 C没有被标记为需要更新。然而,因为它的父组件B渲染了,React 现在向下递归渲染C。C再次返回<D />。D也没有被标记,不用渲染,但是由于它的父级C渲染了,React 向下递归渲染D。
换句话说:
默认情况下,渲染一个组件会导致它内部的所有组件也被渲染!
另外,还有一个关键点:
在正常渲染中,React 不关心“props 是否改变”——只要父组件渲染了,它就会无条件地渲染子组件!
这意味着在你的根 <App> 组件中调用 setState() 而没有其他改变该行为的变动(译者注:如 React.memo),将导致 React 重新渲染组件树中的每个组件。毕竟,React 最初的销售宣传之一是“就像我们在每次更新时重新绘制整个应用程序一样”。
现在,树中的大部分组件很可能会返回与上次完全相同的渲染输出,因此 React 不需要对 DOM 进行任何更改。但是,React 仍然需要完成组件渲染输出和比较(diff)渲染输出的工作。这两者都需要时间和精力。
请记住,渲染并不是一件坏事——它是 React 知道它是否需要实际对 DOM 进行任何更改的方式!
React 渲染规则
React 渲染的主要规则之一是渲染必须是“纯粹的”并且没有任何副作用!
这可能会很棘手并且令人困惑,因为许多副作用并不明显,也不会导致任何破坏。例如,严格来说 console.log() 语句是一种副作用,但它实际上不会破坏任何东西。改变 prop 绝对是一种副作用(译者注:这里应该指直接修改props 值,如 props.a = 'b'),但它可能不会破坏任何东西。在渲染过程中调用 AJAX 也绝对是一种副作用,并且根据请求的类型会导致意外的应用程序行为。
Sebastian Markbage 写了一篇很棒的文档,名为 The Rules of React。在其中,他定义了不同的 React 生命周期方法(包括 render)的预期行为,以及哪些类型的操作被认为是安全的“纯”操作,哪些是不安全的。值得完整阅读,但我将总结要点:
- 渲染逻辑一定不能:
- 不能改变现有的变量和对象
- 不能创建像
Math.random()或Date.now()这样的随机值 - 不能发出网络请求
- 不能排队 state 更新
- 渲染逻辑可能:
- 改变渲染时新创建的对象
- 抛出错误
- “延迟初始化”尚未创建的数据,例如缓存值
组件元数据和 Fibers
React 存储一个内部数据结构,用于跟踪应用程序中存在的所有当前组件实例。该数据结构的核心部分是一个称为“fiber”的对象,它包含描述以下内容的元数据字段:
- 应该在组件树中呈现什么组件类型
- 关联到当前组件的 props 和 state
- 指向父组件、兄弟组件和子组件的指针
- React 用于跟踪渲染过程的其他内部元数据
如果您听说过短语“React Fiber”来描述 React 版本或特性,那实际上是指 React 内部结构的重写,将渲染逻辑切换为依赖这些作为关键数据结构的“Fiber”对象。它作为 React 16.0 发布,因此从那以后的每个 React 版本都使用了这种方法。
Fiber 类型的简化版本如下所示:
export type Fiber = {
// Tag identifying the type of fiber.
tag: WorkTag,
// Unique identifier of this child.
key: null | string,
// The resolved function/class/ associated with this fiber.
type: any,
// Singly Linked List Tree Structure.
child: Fiber | null,
sibling: Fiber | null,
index: number,
// Input is the data coming into this fiber (arguments/props)
pendingProps: any,
memoizedProps: any, // The props used to create the output.
// A queue of state updates and callbacks.
updateQueue: Array<State | StateUpdaters>,
// The state used to create the output
memoizedState: any,
// Dependencies (contexts, events) for this fiber, if any
dependencies: Dependencies | null,
};
(你可以在此处查看 React 18 中 Fiber 类型的完整定义。)
在渲染过程中,React 将迭代这个 fiber 对象树,并在计算新的渲染结果时构建一个新树。
请注意,这些“fiber”对象存储组件真实的 props 和 state 值。当你在组件中使用 props 和 state 时,React 实际上让你可以访问存储在 fiber 对象上的值。事实上,特别是对于类组件,React 在渲染组件之前显式地将 componentInstance.props = newProps 复制到组件。因此,this.props 确实存在,但它存在只是因为 React 从其内部数据结构中复制了引用。从这个意义上说,组件有点像 React 的 fiber 对象的表现层。
类似地,React hooks 之所以起作用,是因为 React 将组件的所有 hooks 存储为链表并加到该组件对应的 fiber 对象上。当 React 渲染一个函数组件时,它从 fiber 中获取 hook 描述条目的链表,并且每次你调用另一个钩子时,它都会返回存储在 hook 描述对象中的适当值(比如 useReducer 的 state 和 dispatch 值)。
当父组件第一次渲染给定的子组件时,React 会创建一个 fiber 对象来跟踪组件的“实例”。对于类组件,它直接调用 const instance = new YourComponentType(props) 并将实际的组件实例保存到 fiber 对象上。对于函数组件,React 只是将 YourComponentType(props) 作为函数调用。
组件类型和 Reconciliation
正如“Reconciliation”文档页面中所述,React 试图通过尽可能多地重用现有组件树和 DOM 结构来提高重新渲染期间的效率。如果您要求 React 在树中的相同位置呈现相同类型的组件或 HTML 节点,React 将重用它并在适当时候应用更新,而不是从头开始重新创建它。这意味着如果您保持 React 在同一位置渲染某组件类型,React 就会使相应的组件实例保持活动状态。对于类组件,它实际上就是使用了组件的实例。函数组件不像类那样拥有真正的“实例”,但我们可以将 <MyFunctionComponent /> 视为表示“这种类型的组件在这里显示并保持活动状态”的“实例”。
那么,React 怎么知道输出什么时候以及怎样发生了变化?
React 的渲染逻辑首先根据元素的 type 字段比较元素,使用 === 引用比较。如果给定位置中的元素已更改为不同的类型,例如从 <div> 到 <span> 或从 <ComponentA> 到 <ComponentB>,React 会假设整个树已更改来加速比较过程。作为结果,React 将销毁整个现有的该组件树部分,包括所有 DOM 节点,并使用新的组件实例从头开始重新创建它。
这意味着您绝不能在渲染时创建新的组件类型!每当你创建一个新的组件类型时,它就是一个不同的引用,这将导致 React 反复销毁和重新创建子组件树。
换句话说,不要这样做:
// ❌ BAD!
// This creates a new `ChildComponent` reference every time!
function ParentComponent() {
function ChildComponent() {
return <div>Hi</div>;
}
return <ChildComponent />;
}
相反,始终单独定义组件:
// ✅ GOOD
// This only creates one component type reference
function ChildComponent() {
return <div>Hi</div>;
}
function ParentComponent() {
return <ChildComponent />;
}
Keys 和 Reconciliation
React 识别组件“实例”的另一种方式是通过 key 这个伪 prop。React 使用 key 作为唯一标识符,它可以用来区分同一组件类型的不同实例。
请注意, key 实际上并不是一个真正的 prop——它是 React 的指令。React 总是会把它去掉,它永远不会传递给实际的组件,所以你永远不会有 props.key——它永远是 undefined。
我们使用 key 的主要地方是渲染列表。如果您渲染的数据发生某些更改,例如重新排序、添加或删除列表条目,那么 key 在这里尤为重要。特别重要的是,尽可能让 key 是数据中的某种唯一 ID - 仅使用数组索引作为键作为最后的后备手段!
// ✅ Use a data object ID as the key for list items
todos.map((todo) => <TodoListItem key={todo.id} todo={todo} />);
这儿有一个为什么这很重要的例子。假设我渲染了一个包含 10 个 <TodoListItem> 组件的列表,使用数组索引作为 key。React 看到 10 个项目,key 为 0..9。现在,如果我们删除第 6 项和第 7 项,并在末尾添加三个新条目,我们最终会渲染键为 0..10 的项目。所以,在 React 看来,我真的只是在末尾添加了一个新条目,因为我们从 10 个列表项变成了 11 个。React 将愉快地重用现有的 DOM 节点和组件实例。但是,这意味着我们现在可能正在渲染的 <TodoListItem key={6}> 是传递给 #8 的待办事项。因此,组件实例仍然存在,但现在它获得了一个与以前不同的数据对象作为 prop。这可能有效,但也可能产生意外行为。此外,React 现在必须对多个列表项应用更新以更改文本和其他 DOM 内容,因为现有列表项现在必须显示与以前不同的数据。这些更新在这里是没有必要的,因为这些列表项都没有改变。
key 对于列表之外的组件实例标识也很有用。您可以随时向任何 React 组件添加一个 key 以指示其身份,更改该 key 将导致 React 销毁旧的组件实例和 DOM 并创建新的组件实例和 DOM。一个常见的用例是列表 + 详细信息表单组合,其中表单显示当前所选列表项的数据。通过 <DetailForm key={selectedItem.id}>,当所选项目更改时, React 会销毁并重新创建表单,从而避免表单内陈旧的 state 导致的任何问题。
渲染批处理和计时
默认情况下,每次调用 setState() 都会导致 React 开始一个新的渲染,同步执行它,然后返回。然而,React 也会自动应用一种优化,方式为渲染批处理。渲染批处理是指在单个渲染过程里多次调用 setState() 会排队执行,通常会有轻微延迟。
React 社区经常将此描述为“状态更新可能是异步的”。新的 React 文档也将其描述为“State is a Snapshot”。这可以作为理解渲染批处理行为的参考。
在 React 17 及更早版本中,React 仅在 React 事件处理程序(例如 onClick 回调)中进行批处理。在事件处理程序之外排队的更新,例如在 setTimeout 中、在 await 之后或在普通 JS 事件处理程序中,不会排队,并且每个都会导致单独的重新渲染。
然而,React 18 现在对在任何单个事件循环中排队的所有更新进行“自动批处理”。这有助于减少所需渲染的总数。
让我们看一个具体的例子。
const [counter, setCounter] = useState(0);
const onClick = async () => {
setCounter(0);
setCounter(1);
const data = await fetchSomeData();
setCounter(2);
setCounter(3);
};
使用 React 17,这执行了三次渲染过程。第一次将 setCounter(0) 和 setCounter(1) 一起批处理,因为它们都发生在原始事件处理程序调用堆栈期间,因此它们都在 unstable_batchedUpdates() 执行时被调用。
但是,对 setCounter(2) 的调用是在 await 之后发生的。这意味着原始的同步调用堆栈已经完成,函数的后半部分稍后会在一个完全独立的事件循环调用栈中执行。因此,React 将在执行 setCounter(2) 的最后一步同步执行整个渲染过程,完成渲染,然后从 setCounter(2) 返回。
setCounter(3) 也会发生同样的事情,因为它也在原始事件处理程序之外运行,因此在批处理之外。
但是,对于 React 18,只会执行两次渲染过程。前两次,setCounter(0) 和 setCounter(1),是一起批处理的,因为它们在一个事件循环中。在 await 之后,setCounter(2) 和 setCounter(3) 被一起批处理——尽管它们延迟执行,但也是在同一个事件循环中排队的两个状态更新,所以它们被批处理到第二个渲染中。
异步渲染、闭包和状态快照
我们经常看到的一个极其常见的错误是当用户设置了一个新值,然后尝试记录现有的变量名称。但是,记录的是原始值,而不是更新后的值。
function MyComponent() {
const [counter, setCounter] = useState(0);
const handleClick = () => {
setCounter(counter + 1);
// ❌ THIS WON'T WORK!
console.log(counter);
// Original value was logged - why is this not updated yet??????
};
}
为什么这不起作用?
如上所述,有经验的用户常说“React 状态更新是异步的”。这在某种程度上是正确的,但还有更多细微差别,实际上这里有几个不同的问题一起作用。
严格来说,React 渲染实际上是同步的——它将在事件循环结束时在一个“微任务”中执行。 (这固然是刻板的,但本文的目标是提供确切的细节和清晰度。)但是,从 handleClick 函数的角度来看,它是“异步的”,因为您无法立即看到结果,并且实际更新比 setCounter() 调用晚。
但是,还有一个更主要的原因导致它不起作用。handleClick 函数是一个“闭包”——它只能看到定义函数时存在的变量值。换句话说,这些 state 变量是在某个时间点的快照。
由于 handleClick 是在此函数组件的最近一次渲染期间定义的,因此它只能看到在该渲染过程中存在的 counter 值。当我们调用 setCounter() 时,它会排队等待未来的渲染过程,并且未来的渲染将有一个新的 counter 变量和一个新的 handleClick 函数......但是当前的 handleClick 永远无法看到那个新值。
新的 React 文档在 State as a Snapshot 部分对此进行了更详细的介绍,强烈推荐阅读。
回到我们的示例:在设置更新值后立即尝试使用变量几乎总是错误的方法,建议您需要重新考虑如何尝试使用该值。
渲染行为边缘案例
提交阶段生命周期
在提交阶段生命周期方法中还有一些额外的边缘情况:componentDidMount``、componentDidUpdate 和 useLayoutEffect。它们的存在主要是为了允许您在渲染之后但在浏览器绘制之前有机会执行额外的逻辑。特别是,一个常见的用例是:
- 第一次渲染组件使用了部分不完整的数据
- 在提交阶段生命周期中,使用 refs 来计算页面中实际 DOM 节点的真实大小
- 根据这些测量值在组件中设置一些 state
- 立即使用更新后的数据重新渲染
在此用例中,我们根本不希望用户看到初始的“部分”渲染出来的 UI——我们只希望显示“最终”UI。
浏览器将在修改 DOM 结构时重新计算它,但当 JS 脚本仍在执行并阻止事件循环时,它们实际上不会在屏幕上绘制任何内容。因此,您可以执行多个 DOM 更改,例如 div.innerHTML = "a"; div.innerHTML = b";,并且"a"永远不会出现。
因此,React 将始终在提交阶段生命周期中同步运行渲染。这样,如果您确实尝试执行类似“部分-> 最终”转换的更新,屏幕上只会显示“最终”内容。
据我所知,useEffect 回调中的状态更新是会排队等待,并且一旦所有 useEffect 回调完成后,在“Passive Effects”阶段结束时更新。
Reconciler 批处理方法
React Reconciler(ReactDOM、React Native)有改变渲染批处理的方法。
对于 React 17 及更早版本,您可以将事件处理程序之外的多个更新包装在 unstable_batchedUpdates() 中以将它们一起批处理。(请注意,尽管有 unstable_ 前缀,它在 Facebook 和公共图书馆的代码中仍然被大量使用和依赖 - React-Redux v7 在内部使用 unstable_batchedUpdates)
由于 React 18 默认自动批处理,React 18 有一个 flushSync() API,您可以使用它选择退出自动批处理并强制立即渲染。
请注意,由于这些是 reconciler-specific 的 API,因此 react-three-fiber 和 ink 等备用的 reconcilers 可能不会公开它们。检查 API 声明或实现细节以查看可用的内容。
<StrictMode>
React 将在开发模式中的 <StrictMode> 标签内俩次渲染组件。这意味着您的渲染逻辑运行的次数与提交渲染时不同,您不能在渲染时依赖 console.log() 语句来计算已发生的渲染次数。 相反,要么使用 React DevTools Profiler 来捕获跟踪并计算总体提交渲染的数量,要么在 useEffect hook 或 componentDidMount/Update 生命周期中添加日志记录。这样,日志只会在 React 实际完成渲染过程并提交时才会打印。
渲染时设置状态
在正常情况下,您不应该在实际渲染逻辑插入新的状态更新。换句话说,创建一个回调在点击发生时调用 setSomeState() 是可以的,但您不应将 setSomeState() 作为实际渲染过程中的一部分进行调用。
但是,有一个例外。函数组件可以在渲染时直接调用 setSomeState() ,只要它是有条件地完成,并且不会在每次该组件渲染时都执行。这相当于类组件中 getDerivedStateFromProps 的函数组件。如果一个函数组件在渲染时排队插入状态更新,在继续之前 React 将立即应用状态更新并同步重新渲染该组件。如果组件无限地保持排队插入状态更新并强制 React 重新渲染它,React 将在一定次数的重试后中断循环并抛出错误(目前尝试 50 次)。该技术可用于根据 props 更改立即强制更新 state 值,而无需重新渲染 + 调用 useEffect 中的 setSomeState()。
提升渲染性能
尽管渲染是 React 工作方式中的正常预期部分,但渲染工作有时也确实是“浪费”的工作。如果一个组件的渲染输出没有改变,那该部分 DOM 不需要更新,那么渲染那个组件的工作就是在浪费时间。
React 组件渲染输出应该始终完全基于当前 props 和当前组件 state。因此,如果我们提前知道一个组件的 props 和 state 没有改变,我们也应该知道渲染输出将是相同的,这个组件不需要改变,我们可以安全地跳过渲染它。
一般而言,在尝试提高软件性能时,有两种基本方法:1) 更快地完成相同的工作,以及 2) 减少工作量。优化 React 渲染主要是通过在适当的时候跳过渲染组件来减少工作量。
组件渲染优化技术
React 提供了三个主要的 API,使我们可以跳过渲染组件:
主要方法是 React.memo(),一种内置的“高阶组件”类型。它接受您自己的组件类型作为参数,并返回一个新的包装器组件。包装器组件的默认行为是检查是否有任何 props 发生了变化,如果没有,则阻止重新渲染。函数组件和类组件都可以使用 React.memo() 进行包装。 (可以传入自定义比较回调,但它实际上只能比较新旧 props,因此自定义比较回调的主要用例是只比较特定的 props 字段,而不是所有 props 字段。)
其他选项是:
- React.Component.shouldComponentUpdate:一个可选的类组件生命周期方法,将在渲染过程的早期调用。 如果它返回
false,React 将跳过渲染组件。 它可能包含任何你想用来计算布尔结果的逻辑,但最常见的方法是检查组件的 props 和状态自上次以来是否发生了变化,如果没有变化则返回false。 - React.PureComponent:因为 props 和 state 的比较是实现
shouldComponentUpdate的最常见方式,PureComponent基类默认实现了该行为,并且可以用来代替Component+shouldComponentUpdate。
所有这些方法都使用一种称为 “shallow equality” 的比较技术。这意味着检查两个不同对象中的每个单独字段,并查看对象的任何内容是否具有不同的值。换句话说,obj1.a === obj2.a && obj1.b === obj2.b && .........。这通常是一个快速的过程,因为 === 比较对于 JS 引擎来说非常简单。因此,这三种方法相当于 const shouldRender = !shallowEqual(newProps, prevProps)。
还有一个鲜为人知的技术:如果 React 组件在其渲染输出中返回与上次完全相同的元素引用,React 将跳过重新渲染该特定子组件。 至少有几种方法可以实现这种技术:
- 如果您在输出中包含
props.children,那么如果此组件执行状态更新,该元素保持不变(译者注:其实props.children只是被默认使用为传递元素,实际上只要 prop 中传递元素的字段都符合) - 如果你用
useMemo()包装一些元素,这些元素将保持不变,直到依赖项发生变化
// The `props.children` content won't re-render if we update state
function SomeProvider({ children }) {
const [counter, setCounter] = useState(0);
return (
<div>
<button onClick={() => setCounter(counter + 1)}>Count: {counter}</button>
<OtherChildComponent />
{children}
</div>
);
}
function OptimizedParent() {
const [counter1, setCounter1] = useState(0);
const [counter2, setCounter2] = useState(0);
const memoizedElement = useMemo(() => {
// This element stays the same reference if counter 2 is updated,
// so it won't re-render unless counter 1 changes
return <ExpensiveChildComponent />;
}, [counter1]);
return (
<div>
<button onClick={() => setCounter1(counter1 + 1)}>Counter 1: {counter1}</button>
<button onClick={() => setCounter1(counter2 + 1)}>Counter 2: {counter2}</button>
{memoizedElement}
</div>
);
}
从概念上讲,我们可以说这两种方法之间的区别是:
React.memo():由子组件控制- 同元素引用:由父组件控制
对于所有这些技术,跳过渲染一个组件意味着 React 也将跳过渲染整个子树,因为它设置了一个停止标志来停止默认的“递归渲染子组件”行为。
新 Props 引用如何影响渲染优化
我们已经看到,默认情况下,React 会重新渲染所有嵌套组件,即使它们的 props 没有改变。这也意味着将新引用作为 props 传递给子组件并不重要,因为无论您是否传递相同的 props,它都会渲染。所以,这样的事情完全没问题:
function ParentComponent() {
const onClick = () => {
console.log('Button clicked');
};
const data = { a: 1, b: 2 };
return <NormalChildComponent onClick={onClick} data={data} />;
}
每次 ParentComponent 渲染时,它都会创建一个新的 onClick 函数引用和一个新的 data 对象引用,然后将它们作为 props 传递给 NormalChildComponent。(请注意,无论我们是使用 function 关键字还是将 onClick 定义为箭头函数并不重要,它都是一个新的函数引用。)
这也意味着没有必要尝试通过将它们包装在 React.memo() 中来优化“host components”(如 <div> 或 <button>)的渲染。这些基本组件下面没有子组件,所以渲染过程无论如何都会停在那里。
但是,如果子组件试图通过检查 props 是否已更改来优化渲染,那么将新的引用作为 props 传递将导致子组件进行渲染。如果新的 prop 引用实际上是新数据,这很好。但是,如果父组件只是传递一个回调函数呢?
const MemoizedChildComponent = React.memo(ChildComponent);
function ParentComponent() {
const onClick = () => {
console.log('Button clicked');
};
const data = { a: 1, b: 2 };
return <MemoizedChildComponent onClick={onClick} data={data} />;
}
现在,每次 ParentComponent 渲染时,这些新引用将导致 MemoizedChildComponent 看到其 props 值已更改为新引用,并且它将继续并重新渲染......即使 onClick 函数和 data 对象每次都一样!
这意味着:
MemoizedChildComponent总是会重新渲染,即使我们想在大多数时候跳过渲染- 它正在做的比较新旧 props 的工作是浪费精力
同样,请注意渲染 <MemoizedChild><OtherComponent /></MemoizedChild> 也会强制孩子始终渲染,因为 props.children 始终是新引用。
优化 Props 引用
类组件不必担心意外创建新的回调函数引用,因为它们可以拥有始终是相同引用的实例方法。但是,他们可能需要为单独的子列表项生成唯一的回调,或者在匿名函数中捕获一个值并将其传递给子组件。这些将导致新的引用,因此将在渲染时创建新对象作为子组件的 props。 React 没有内置任何东西来帮助优化这些情况。
对于函数组件,React 确实提供了两个 hooks 来帮助您重用相同的引用:useMemo 用于创建对象或进行复杂计算等任何类型的通用数据,以及 useCallback 专门用于创建回调函数。
Memoize 一切?
如上所述,您不必为每个作为 prop 传递的函数或对象上都使用 useMemo 和 useCallback - 只有当它会对子组件的行为产生影响时。(但是说,子组件可能希望接收一致的 props 引用用于 useEffect 的依赖数组比较,这确实使事情变得更加复杂。)
另一个经常出现的问题是“为什么 React 默认不将所有内容包装在 React.memo() 中?”。
Dan Abramov 反复指出,memoization 仍然会产生比较 props 的成本,并且在很多情况下,memoization 检查永远无法阻止重新渲染,因为组件总是会收到新的 props。例如,来自 Dan 的 Twitter 帖子:
为什么 React 不默认将 memo() 放在每个组件周围?不是更快吗?我们应该做一个基准来检查吗?
问你自己:
你为什么不把 Lodash memoize() 放在每个函数周围?这不会使所有功能更快吗?我们需要一个基准吗?为什么不?
此外,虽然我没有关于它的特定链接,但由于人们正在改变数据而不是不可变地更新数据,因此尝试默认将其应用于所有组件可能会导致错误。
我已经在 Twitter 上与 Dan 就此进行了一些公开讨论。我个人认为,广泛使用 React.memo() 可能会在整体应用程序渲染性能方面获得净收益。正如我去年在一个扩展的 Twitter 线程中所说的那样:
整个 React 社区似乎过分沉迷于“性能”,但大部分讨论都围绕着通过 Medium 帖子和 Twitter 评论流传下来的过时的“tribal wisdom”展开,而不是基于具体用法。
对于“渲染”的概念和性能影响,肯定存在集体误解。是的,React 完全基于渲染——必须渲染才能做任何事情。不,大多数渲染并不过分昂贵。
“浪费”的重新渲染当然不是世界末日。 也不是从根重新渲染整个应用程序。 也就是说,没有 DOM 更新的“浪费”重新渲染只是不需要燃烧的 CPU 周期(译者注:我理解是不会大量浪费 CPU 计算)。 这对大多数应用程序来说是个问题吗? 也许不会。 这是可以改进的吗? 大概。
是否有默认的“全部重新渲染”方法不够用的应用程序?当然,这就是 sCU、PureComponent 和 memo() 存在的原因。
用户应该默认将所有内容包装在 memo() 中吗? 可能不会,因为您应该考虑应用程序的性能需求。 如果你这样做真的会受伤吗? 不,实际上我希望它确实有净收益(尽管 Dan 关于浪费比较的观点)
基准测试是否存在缺陷,结果是否因场景和应用程序而异? 当然。 也就是说,如果人们可以开始为这些讨论指出硬性数字而不是玩“我曾经看过一条评论……”的文字游戏,那将真的非常有帮助。
我很乐意看到来自 React 团队和社区的大量基准测试来衡量大量场景,这样我们就可以一劳永逸地停止争论这些东西。 函数创建、渲染成本、优化……请提供具体证据!
Dan 的标准答案是应用程序结构和更新模式千差万别,因此很难做出具有代表性的基准。
我仍然认为一些实际数字将有助于讨论
在 React 问题中还有一个关于“什么时候不应该使用 React.memo?”的扩展问题讨论。
请注意,新的 React 文档专门包含了“memo 一切?”的问题:
只有当您的组件经常使用完全相同的 props 重新渲染并且其重新渲染逻辑代价高昂时,使用
memo进行优化才有价值。 如果您的组件重新渲染时没有明显的延迟,则不需要memo。 请记住,如果传递给组件的 props 总是不同的,例如传递一个对象或在渲染期间定义的普通函数,那么memo是完全无用的。 这就是为什么您经常需要将useMemo和useCallback与memo一起使用。在其他情况下,将组件包装在
memo中没有任何好处。 这样做也没有什么大不了的,所以一些团队选择不考虑个别情况,并尽可能多地 memoize。 这种方法的缺点是代码变得不那么可读了。 此外,并不是所有的 memoization 都是有效的:一个“永远是新的”的单一值就足以破坏整个组件的 memoization。
有关避免不必要的 memoization 和提高性能的更多建议,请参阅该链接下的详细信息部分。
不变性和重新渲染
React 中的状态更新应该始终保持不变。主要原因有两个:
- 根据您突变的内容和位置,它可能会导致组件在您预期它们会渲染时不渲染
- 它会导致混淆数据实际更新的时间和原因
让我们看几个具体的例子。
正如我们所见,React.memo / PureComponent / shouldComponentUpdate 都依赖于当前 props 与之前 props 的浅比较检查。 因此,我们期望可以通过 props.someValue !== prevProps.someValue 知道一个 prop 是否是一个新值。
如果你突变(译者注:如 props.someValue.a = 'b'),那么 someValue 是相同的引用,那些组件将假定没有任何改变。
请注意,特别对于我们试图通过避免不必要的重新渲染来优化性能的情况。 如果 props 没有改变,渲染是“不必要的”或“浪费的”。 如果你突变,组件可能会错误地认为什么都没有改变,然后你想知道为什么组件没有重新渲染。
另一个问题是 useState 和 useReducer hooks。 每次我调用 setCounter() 或 dispatch() 时,React 都会排队重新渲染。 然而,React 要求任何 hook 状态更新必须传入/返回一个新的引用作为新的状态值,无论它是一个新的对象/数组引用,还是一个新的原始值(字符串/数字/等)。
React 在渲染阶段应用所有状态更新。 当 React 尝试从 hook 应用状态更新时,它会检查新值是否是相同的引用。 React 将始终完成渲染排队更新的组件。 但是,如果该值与之前的引用相同,并且没有其他原因继续渲染(例如父级已渲染),React 将丢弃组件的渲染结果并完全退出渲染过程。 所以,如果我像这样改变一个数组:
const [todos, setTodos] = useState(someTodosArray);
const onClick = () => {
todos[3].completed = true;
setTodos(todos);
};
那么组件将无法重新渲染。
(请注意,React 实际上有一个“快速路径”救助机制,在某些情况下会在状态更新排队之前尝试检查新值。由于这也依赖于直接引用检查,这是另一个需要进行不可变更新的示例 .)
从技术上讲,只有最外层的引用必须不可变地更新。如果我们将该示例更改为:
const onClick = () => {
const newTodos = todos.slice();
newTodos[3].completed = true;
setTodos(newTodos);
};
然后我们创建了一个新的数组引用并将其传入,组件将重新渲染。
请注意,类组件 this.setState() 与函数组件 useState 和 useReducer hooks 在突变和重新渲染方面的行为存在明显差异。 this.setState() 根本不在乎你是否突变——它总是完成重新渲染。 所以,这将重新渲染:
const { todos } = this.state;
todos[3].completed = true;
this.setState({ todos });
事实上,像 this.setState({}) 这样的空对象也会被传入。
除了所有实际的渲染行为之外,突变还给标准的 React 单向数据流带来了混乱。 当预期没有改变时,突变会导致其它代码看到不同的值。 这使得更难知道给定状态实际上应该更新的时间和原因,或者更改来自何处。
底线:React 和 React 生态系统的其余部分假定更新是不可变的。任何时候你突变,你都会冒着出现错误的风险。不要这样做。
测量 React 组件渲染性能
使用 React DevTools Profiler 查看每次提交中渲染的组件。 找到意外渲染的组件,使用 DevTools 找出它们渲染的原因,并修复问题(可能通过将它们包装在 React.memo() 中,或者让父组件 memoize 它传递下来的 props 。)
另外,请记住,React 在开发构建中运行得更慢。 您可以在开发模式下分析您的应用程序以查看正在渲染的组件以及原因,并对渲染组件所需的相对时间进行一些比较(“组件 B 在此提交中渲染的时间是组件 A 的 3 倍 “ )。 但是,永远不要使用 React 开发构建来测量绝对渲染时间——只使用生产构建来测量绝对时间! (否则 Dan Abramov 将不得不因为使用不准确的数字而对您大喊大叫)。 请注意,如果您想实际使用分析器从类似产品的构建中捕获耗时数据,则需要使用 React 的特殊“分析”构建。
Context 和渲染行为
React 的 Context API 是一种机制,用于使单个用户提供的值可用于组件的子树,给定 <MyContext.Provider> 内的任何组件都可以从该 context 实例中读取值,而不必显式地将该值作为 prop 通过每个中间组件进行传递。
Context 不是“状态管理”工具。您必须自己管理传递到 context 中的值。这通常是通过将数据保持在 React 组件 state 并基于该数据构建 context 值来完成的。
Context 基本认知
Context 提供者接收单个值 prop,例如 <MyContext.Provider value={42}>。子组件可以通过渲染 context 消费者组件并提供 render prop 来使用上下文,例如:
<MyContext.Consumer>{ (value) => <div>{value}</div>}</MyContext.Consumer>
或者通过在函数组件中调用 useContext 钩子:
const value = useContext(MyContext)
更新 Context 值
当周围组件渲染提供者时,React 检查 context 提供者是否已被赋予新值。 如果提供者的值是一个新的引用,那么 React 就知道该值已经改变,并且需要更新使用该上下文的组件。
请注意,将新对象传递给 context 提供者将导致它更新:
function GrandchildComponent() {
const value = useContext(MyContext);
return <div>{value.a}</div>;
}
function ChildComponent() {
return <GrandchildComponent />;
}
function ParentComponent() {
const [a, setA] = useState(0);
const [b, setB] = useState('text');
const contextValue = { a, b };
return (
<MyContext.Provider value={contextValue}>
<ChildComponent />
</MyContext.Provider>
);
}
在此示例中,每次 ParentComponent 渲染时,React 都会注意到 MyContext.Provider 已被赋予新值,并在继续向下循环时寻找使用 MyContext 的组件。 当 context 提供者有一个新值时,每个使用该 context 的嵌套组件都将被强制重新渲染。
请注意,从 React 的角度来看,每个 context 提供者只有一个值——不管它是一个对象、数组还是一个原始值,它只是一个 context 值。 目前,使用 context 的组件无法跳过由新 context 值引起的更新,即使它只关心新值的一部分。
如果组件只需要 value.a,但是为了产生新的 value.b 引用进行了更新......根据不可变更新和 context 渲染的规则要求 value 也是一个新引用,因此组件读取 value.a 也会渲染。
状态更新、Context 和重新渲染
是时候将这些部分放在一起了。我们知道:
- 调用
setState()将该组件排队渲染 - React 默认递归渲染嵌套组件
- Context 提供者由渲染它们的组件赋予一个值
- 该值通常来自该父组件的状态
这意味着默认情况下,渲染 context 提供程序的父组件的任何状态更新都会导致其所有后代重新渲染,无论它们是否读取 context 值!。
如果我们回顾上面的 Parent/Child/Grandchild 示例,我们可以看到 GrandchildComponent 将重新渲染,但不是因为 context 更新 - 它会重新渲染,是因为 ChildComponent 渲染了!。在这个例子中,没有试图优化掉“不必要”的渲染,所以 React 在 ParentComponent 渲染时默认渲染 ChildComponent 和 GrandchildComponent。 如果父级将新的 context 值放入 MyContext.Provider,GrandchildComponent 将在渲染并使用它时看到新值,但 context 更新不会导致 GrandchildComponent 渲染 - 它无论如何都会发生。
Context 更新和渲染优化
让我们修改该示例,使其真正尝试优化事物,但我们将通过在底部放置一个 GreatGrandchildComponent 来添加另一个变化:
function GreatGrandchildComponent() {
return <div>Hi</div>
}
function GrandchildComponent() {
const value = useContext(MyContext);
return (
<div>
{value.a}
<GreatGrandchildComponent />
</div>
}
function ChildComponent() {
return <GrandchildComponent />
}
const MemoizedChildComponent = React.memo(ChildComponent);
function ParentComponent() {
const [a, setA] = useState(0);
const [b, setB] = useState("text");
const contextValue = {a, b};
return (
<MyContext.Provider value={contextValue}>
<MemoizedChildComponent />
</MyContext.Provider>
)
}
现在,如果我们调用 setA(42):
ParentComponent将渲染- 创建了一个新的 contextValue 引用
- React 看到
MyContext.Provider有一个新的 context 值,因此需要更新MyContext的任何消费者 - React 将尝试渲染
MemoizedChildComponent,但看到它被包裹在React.memo()中。根本没有任何 props 被传递,所以 props 实际上并没有改变。 React 将完全跳过渲染ChildComponent。 - 但是,
MyContext.Provider有一个更新,因此可能有更深层次的组件需要了解它。 - React 继续向下,到达
GrandchildComponent。 它看到MyContext被GrandchildComponent读取,因此它应该重新渲染,因为有一个新的 context 值。 React 继续并重新渲染GrandchildComponent,特别是因为 context 更改。 - 因为
GrandchildComponent确实渲染了,所以 React 会继续进行并渲染其中的任何内容。所以,React 也会重新渲染GreatGrandchildComponent。
换句话说,正如 Sophie Alpert 所说:
Context 提供者下的 React 组件尽可能使用
React.memo
这样,父组件中的状态更新将不会强制每个组件重新渲染,只会强制渲染读取 context 的那部分。(您也可以通过让 ParentComponent 渲染 <MyContext.Provider>{props.children}</MyContext.Provider> 获得基本相同的结果,它利用“相同元素引用”技术来避免子组件重新渲染,然后上一层渲染 <ParentComponent><ChildComponent /></ParentComponent> 。)
但是请注意,一旦 GrandchildComponent 基于下一个 context 值进行渲染,React 就会立即按默认行为递归地重新渲染所有内容。 所以,GreatGrandchildComponent 被渲染了,下面其它的任何东西也会被渲染。
Context 和渲染器边界
通常,React 应用程序完全使用单个渲染器构建,例如 ReactDOM 或 React Native。 但是,核心渲染和 reconciling 逻辑作为一个名为 react-reconciler 的包发布,您可以使用它来构建您自己的针对其他环境的 React 版本。 很好的例子是 react-three-fiber,它使用 React 来驱动 Three.js 模型和 WebGL 渲染,以及 ink,它使用 React 绘制终端文本 UI。
一个长期存在的限制是,如果您在一个应用程序中有多个渲染器,例如在 ReactDOM 中显示 React-Three-Fiber 内容,context 提供程序将不会通过渲染器边界。所以,如果组件树看起来像这样:
function App() {
return (
<MyContext.Provider>
<DomComponent>
<ReactThreeFiberParent>
<ReactThreeFiberChild />
</ReactThreeFiberParent>
</DomComponent>
</MyContext.Provider>
);
}
其中 ReactFiberParent 创建并显示使用 React-Three-Fiber 呈现的内容,然后 <ReactThreeFiberChild> 将无法看到来自 <MyContext.Provider> 的值。
这是 React 的一个已知限制,目前没有正式的方法来解决这个问题。
也就是说,React-Three-Fiber 背后的 Poimandres 组织有一些使 context 桥接可行的内部 hacks,他们最近发布了一个名为 github.com/pmndrs/its-… 的库,其中包含一个 useContextBridge hook,它是 一个有效的解决方法。
React-Redux 和渲染行为
各种形式的“CONTEXT VS REDUX?!?!??!”这似乎是我现在在 React 社区中看到的最常被问到的问题。 (这个问题一开始就是错误的二分法,因为 Redux 和 Context 是做不同事情的不同工具。)
另外一个人们反复指出的事情是“React-Redux 仅重新渲染实际需要渲染的组件,因此它比 context 更好”。
这在某种程度上是正确的,但答案可不是仅仅如此。
React-Redux 订阅
我见过很多人重复“React-Redux 在内部使用 context ”这句话。 技术上的确如此,但 React-Redux 使用 context 来传递 Redux store 实例,而不是当前 state 值。 这意味着随着时间的推移,我们总是将相同的 context 值传递到我们的 <ReactReduxContext.Provider> 中。
请记住,每当 action 被 dispatched 时,Redux store 都会运行其所有订阅者通知回调。 需要使用 Redux 的 UI 层始终订阅 Redux store,在其订阅者回调中读取最新状态,比较值,并在相关数据发生变化时强制重新渲染。 订阅回调过程完全发生在 React 之外,只有当 React-Redux 知道特定 React 组件所需的数据发生变化(基于 mapState 或 useSelector 的返回值)时,React 才会介入。
这导致一组与 context 截然不同的性能特征。 Redux 很可能会渲染更少的组件,但是每次更新存储状态时,React-Redux 总是必须为整个组件树运行 mapState/useSelector 函数。 大多数时候,运行这些选择器的成本低于 React 执行另一个渲染过程的成本,因此通常是净赢,但这是必须完成的工作。 但是,如果这些选择器正在执行代价高昂的转换或在不应该返回新值时意外返回新值,则可能会减慢速度。
connect 和 useSelector 的区别
connect 是一个高阶组件。 你传入你自己的组件,然后 connect 返回一个包装器组件,它完成所有订阅 store 的工作,运行你的 mapState 和 mapDispatch,并将组合的 props 传递给你自己的组件。
connect 包装组件的作用通常等同于 PureComponent/React.memo()``,但侧重点略有不同:connect 只会在传递给您的组件的组合 props 发生变化时才会渲染您自己的组件。 通常,最终的组合 props 是 {...ownProps, ...stateProps, ...dispatchProps} 的组合,因此来自父级任何 props 的新引用会导致您的组件渲染,与 PureComponent 或 React.memo() 相同。除了父 props 之外,从 mapState 返回的任何新引用也会导致您的组件渲染。(由于您可以自定义 ownProps/stateProps/dispatchProps 的合并方式,因此也可以更改该行为。)
另一方面,useSelector 是您在自己的函数组件内部调用的一个 hook。因此,当父组件渲染时,useSelector 无法阻止您的组件渲染!
这是 connect 和 useSelector 之间关键的性能差异。 使用 connect,每个连接的组件都像 PureComponent 一样,因此充当防火墙以防止 React 的默认渲染行为向下级联整个组件树。 由于典型的 React-Redux 应用程序具有许多连接的组件,这意味着大多数重新渲染级联仅限于组件树的相当小的部分。 React-Redux 将根据数据更改强制连接的组件进行渲染,它下面的 2-3 个组件也可能渲染,然后 React 运行到一个不需要更新的连接组件并停止渲染级联。
此外,拥有更多连接组件意味着每个组件可能从 store 中读取更小的数据片段,因此不太可能在任意 action 后重新渲染。
如果你只使用函数组件和 useSelector,那么你的组件树的大部分可能会根据 Redux store 更新重新渲染(相较于connect),因为没有连接的组件可以阻止这些渲染级联向下遍历树。
如果这成为性能问题,那么答案是根据自己的需要将组件包装在 React.memo() 中,防止由父组件引起的不必要的重新渲染。
未来 React 的改进
“React Forget” Memoizing 编译器
自从 React hooks 首次出现并且我们开始处理像 useEffect 和 useMemo 这样的 hooks 的依赖数组时,React 团队就表示他们希望 hooks 依赖数组成为“足够先进的编译器可以自动生成的东西”。 换句话说,在钩子中使用一个名为 counter 的变量,编译器会在构建时自动为您插入 [counter] 依赖数组。
尽管出现在几次讨论中,但“足够先进的编译器”从未实现。社区尝试创建他们自己的自动记忆方法,如 Babel macro,但没有出现官方编译器......
直到 ReactConf 2021,当时 React 团队发表了题为“React Without Memo”的演讲。 在那次演讲中,他们演示了代号为“React Forget”的实验性编译器。 它旨在重写函数组件的主体,以自动添加记忆功能。
React Forget 真正令人兴奋的地方在于它不仅尝试记忆 hook 依赖数组,它还记忆 JSX 元素返回值。 并且在上面我们知道 React 有一个“相同元素引用”优化可以防止重新渲染子组件,这意味着 React Forget 可以有效地消除整个 React 组件树中不必要的渲染!
截至 2022 年 10 月,React Forget 编译器尚未发布,但从 React 团队传出的消息令人鼓舞。 据说有 3-4 名工程师全职致力于构建它,目标是在公开发布供社区试用之前让 Facebook.com 全面运行。 还有其他迹象表明工作进展顺利 - useEvent RFC 已关闭,理由是如果 React Forget 可行,它可能不是完全必要的,其他讨论通常建议“如果由于自动记忆化,太多渲染问题在未来消失了怎么办?”(译者注:感觉是调侃,不知道理解的对不对)。
虽然目前还不能保证,但是有理由对 React Forget 成功的可能性保持乐观。
Context 选择器
前面我们说过,Context API 最大的弱点是组件无法选择性地订阅 context 值的一部分,因此所有读取该 context 的组件都会在 context 值更新时重新渲染。
2019 年 7 月,一位社区成员编写了一份 RFC,提出了一个“context 选择器”API,这将允许组件有选择地订阅 context 的一部分。 该 RFC 搁置了一段时间之后终于出现了复活的迹象。 Andrew Clark 随后于 2021 年 1 月在 React 中实现了对 context 选择器的概念验证,新功能隐藏在内部特征标志下用于实验。
遗憾的是,从那时起,context 选择器功能就没有进一步的进展。 从讨论和 PR 来看,概念验证版本肯定需要对 API 设计进行更改和迭代才能定版。 如果 React Forget 编译器成功的话,这也可能是另一个半过时的特性。
如果这个特性真的被发布了,那么使用 Context + useReducer 组合会成为大多数 React 应用程序 state 的更好选择。
值得注意的是,来自 Daishi Kato(Zustand 和 Jotai 的维护者)的 useContextSelector 库用作 polyfill 是非常实用的。
总结
- React 默认总是递归渲染组件,所以当父组件渲染时,它的子组件也会渲染
- 渲染本身很好——这就是 React 知道需要进行哪些 DOM 更改的方式
- 但是,渲染需要时间,而且 UI 输出没有改变的“浪费渲染”会叠加
- 大多数时候传递回调函数和对象等新引用是没有问题的
- 如果 props 没有改变,像
React.memo()这样的 API 可以跳过不必要的渲染 - 但是如果你总是将新的引用作为 props 向下传递,
React.memo()永远不会跳过渲染,所以你可能需要 memoize 这些值 - Context 使深层嵌套组件可以访问任何感兴趣的值
- Context 提供者通过引用比较它们的值以了解它是否已更改
- 新的 context 值确实会强制所有嵌套的消费者重新渲染
- 但是,很多时候,由于正常的父级->子级的级联渲染过程,子级无论如何都会重新渲染
- 所以你可能会想在
React.memo()中包装一个 context 提供者的孩子,或者使用{props.children},这样当你更新 context 值时,整棵树不会总是渲染 - 当基于新的 context 值渲染子组件时,React 还是会从那里继续级联渲染
- React-Redux 使用对 Redux store 的订阅来检查更新,而不是通过 context 传递 store state 值
- 这些订阅在每次 Redux store 更新时运行,因此它们需要尽可能快
- React-Redux 做了很多工作来确保只有数据改变的组件才被强制重新渲染
connect的作用类似于React.memo(),因此拥有大量连接的组件可以最大限度地减少一次渲染过程中的组件总数 useSelector是一个钩子,所以它不能停止由父组件引起的渲染。 一个到处只有useSelector的应用程序可能应该将React.memo()添加到某些组件中,以帮助避免总是级联渲染。- “React Forget”自动记忆编译器如果发布的话可能会大大简化这一切。
最后的想法
显然,整个情况比“context 总是让所有组件渲染,Redux 不是这样,所以使用 Redux”要复杂得多。 不要误会我的意思,我希望人们使用 Redux,但我也希望人们清楚地了解不同工具所涉及的行为和权衡,以便他们可以就最适合自己的用例做出明智的决定。
由于每个人似乎总是在问“什么时候应该使用 Context,什么时候应该使用 (React-)Redux?”,让我继续回顾一些标准的经验法则:
- 在以下情况下使用 context :
- 您只需要传递一些不经常更改的简单值
- 应用程序的部分需要访问一些 state 或 functions,但你不想将它们作为 props 一直传递下去
- 你想坚持使用 React 内置的东西而不是添加额外的库
- 在以下情况下(React-)Redux :
- 您在应用程序的许多地方都需要大量的应用层级的 state
- 应用层级的 state 会随着时间的推移频繁更新
- 更新应用层级的 state 的逻辑可能很复杂
- 该应用程序具有中型或大型代码库,并且可能由许多人共同开发
请注意,这些并不是硬性的、排它性的规则——它们只是一些建议的指导方针,说明这些工具何时可能有意义! 通常情况下,请花一些时间自己决定什么是符合您情况的最佳工具。
总的来说,希望这篇阐述能帮助人们更全面地了解 React 的渲染行为在各种情况下的实际情况。
更多信息
我在 2022 年 10 月为 React Advanced 录制了这篇帖子的会议演讲版本:
我最近看过其他几篇专门介绍“React 渲染如何工作?”的好文章。 这是我想推荐的:
-
推荐的关于 React 渲染的文章:
除此之外,可以参阅以下其它资源:
- 通用
- Dave Ceddia: A Visual Guide to References in JavaScript
- React 渲染行为
- React docs: Reconciliation
- React class lifecycle methods diagram
- React hooks lifecycle diagram
- React issues: bailing out of context and hooks
- React issues: why setState is async
- Seb Markbage: "Context is good for low-frequency updates, not Flux-like state propagation"
- Ryan Florence: React, Inline Functions, and Performance
- James K Nelson: React context and performance
- Will It Render? A visualization for component rendering
- 优化渲染性能
- React docs: Optimizing Performance
- Kent C Dodds: Fix the slow render before you fix the re-render
- Kent C Dodds: When to useMemo and useCallback
- Kent C Dodds: One simple trick to optimize React re-renders
- React issues: When should you NOT use React.memo?
- 分析 React 组件
- React docs: Introducing the React DevTools Profiler
- React DevTools profiler interactive tutorial
- Kent C Dodds: Profile a React App for Performance
- Shawn Wang: Using the React DevTools Profiler to Diagnose React App Performance Issues
- Use the React Profiler for Performance
- React-Redux 性能
- Practical Redux, Part 6: Connected Lists and Performance
- Idiomatic Redux: The History and Implementation of React-Redux
- React-Redux docs: mapState Usage Guide - Performance
- High-Performance Redux
- React-Redux links: React/Redux Performance
这是 Blogged Answers 系列中的一篇文章。本系列其他文章:
译者注:请参考原文。