React Hooks - 组件重新渲染原理


马库斯·斯皮斯克Markus Spiske)上传到 Unsplash照片

刚开始研究 React Hooks 时,我认为官方文档很好的阐释了其核心理念 —— 动机Hook API 索引Hook 规则Hooks FAQ。但开始使用后我发现我对组件生命周期的理解远远不够。有时我无法解释为什么我的组件会被重新渲染(一个常见问题),什么变化导致了无限渲染循环(另一个常见问题),有时我不确定使用 useCallback/useMemo 是否真的能提高性能。

与基于类的组件不同,使用钩子的功能组件会经历一个生命周期,但没有明确的生命周期方法。这种非显式特性可以让我们用较少的样板代码就能把可重用的行为提取到自定义钩子中 ,但我不理解钩子是怎么影响执行流程的。

所以,我认真学习了内置钩子,了解了内置钩子是怎么影响组件的重新渲染/生命周期的。我会在下文通过详细的例子来分享我的学习成果,同时也会给大家提示一些我踩过的坑。本文篇幅很长,涵盖了大量内置钩子的截图和示例。如果你只对某个特定的钩子感兴趣,可以直接点击对应的链接。

目录

术语

在较高层次上,React 在转换组件树并把结果缓冲到渲染环境时,会经历三个阶段:

(来自 Kent C. Dodds 的博客):

“渲染”阶段:创建 React 元素 React.createElement了解更多
“调和”阶段:将以前的元素与新的元素进行比较 (了解更多
“提交”阶段:更新 DOM (如果需要)。

那么,什么促使 React 触发了渲染阶段? 这篇 StackOverflow 帖子给出了精辟的答案。

  • 组件收到新的道具。
  • 状态被更新。
  • 上下文的值被更新(如果该组件使用 useContext 监听上下文的变化)。
  • 父组件由于上述任何原因而重新出现。

本文提到的所有“重新渲染”都指的是上面说的“渲染阶段”。

本文使用的例子:

  • 我们将执行一个简单的股票 ticker 组件,然后我们给这个组件添加功能来了解各种内置钩子的行为。这个组件显示 ticker 代码和价格,用户可以在下拉菜单中挑选不同的 ticker。

1_Wc8kWh4X78JzQoAHxCUhTg

  • 所有示例中的组件右边都有一个执行日志。每当 React 重新渲染该组件时,该组件四周都会出现一个虚线边框(作为视觉提示)。

  • 为了主要关注执行流程,我特意使用简单的代码样例(没有类型;没有后台调用;ticker 报价是模拟数据)。

  • React 发布并发模式时,一些样例的行为可能会改变。

useState

useState 钩子是主要的构建模块,它使功能组件在两次重新渲染之间保持状态。

我们通过下面的例子来理解 useState 的工作原理。我们会按照上面的简图来执行 ticker 组件,使用useState 钩子把显示在 UI 上的活跃 ticker 存储到本地状态,下面是该组件的相关部分(完整代码 点击这里)。

const [ticker, setTicker] = useState("AAPL");
...
const onChange = event => {
   setTicker(event.target.value);
}
...

我们来详细了解这个代码段:

  • useState 语句返回一个状态值(‘ticker’)和一个更新相应状态值的 setter 函数(‘setTicker’)。

  • 当组件第一次被渲染时,‘ticker’ 值是参数中指定的默认值(即’AAPL’)。

  • 当在下拉菜单中选中一个新的 ticker 时,就会调用 onChange 函数,这个函数使用 setter 函数更新状态值。 状态的变化又触发了一次重新渲染 ,就会再次调用 TickerComponent 函数来执行。但是这一次, "useState(‘AAPL’) "会返回之前 setter 函数设置的 ticker 值。这就好比是 React 将状态存储在一个绑定在我们的组件上的隐藏数据存储器中,最新的状态值被保留下来并在重新渲染时返回。如果该组件被卸载然后重新挂载,一切就会从头开始。

以上步骤都显示在下方截图中。当挂载时,Ticker 组件用默认的 ticker(‘AAPL’)进行渲染。从下拉菜单中选择的新 ticker 会更新 ticker 状态,从而触发重新渲染。请注意选择 ticker 后的高亮边框。这个步骤在执行日志里就是 “开始渲染”。

https://codesandbox.io/s/01a-usestate-single-61yn2

下图是执行日志的截图:

1_pgcKRF_R7mv9ALbp8qhnMQ
https://codesandbox.io/s/01a-usestate-single-61yn2

下面的动图通过代码的上下文中描述了组件的行为(步骤1~12)。

https://codesandbox.io/s/01a-usestate-single-61yn2

父组件状态更新的影响

我们已经了解了功能组件的本地状态变化产生的组件重新渲染周期,那么,如果父组件和子组件都有自己的本地状态(通过 useState 获得),父组件的状态发生了改变,会发生什么呢?

我们扩展 TickerComponent 来支持“主题”道具。它的父组件(‘App’)将主题值保存在状态中,并将状态通过道具传递给子组件。

(注意:在实际应用中,useContext 钩子更适合于支持主题这样的功能,而不是作为道具通过组件树向下传递值。)

App 组件的状态中含有“主题”变量,还有两个 Ticker 组件作为子组件。主题被作为道具传递给TickerComponents。TickerComponent 消耗 props.theme 并将其值渲染成 css 类(这是与之前示例里的 TickerComponent 的唯一不同之处)。


https://codesandbox.io/s/01b-usetstate-multiple-wq7yb

( code , sample execution run).


https://codesandbox.io/s/01b-usetstate-multiple-wq7yb

解析执行日志:

  • 第一组:当挂载 App 组件时,它被初始化为默认的‘深色’主题,ticker 组件被初始化为默认的 "AAPL " ticker。

  • 第二、三组:在两个 Ticker 组件里都选择了新的 ticker,状态的变化触发了本地的重新渲染(同前例)。

  • 第四组:父组件中的主题值发生改变,父(App)组件和它的子组件(Ticker 组件)都被重新渲染。

App 组件重新渲染时,无论子组件是否消耗主题值(作为道具),都会被重新渲染。请点击查看 已更新的示例,在这个例子中,第二个 ticker 组件不消耗主题状态,但主题改变时就会重新渲染,这是 React 的默认行为,我们可以通过使用 useMemo 钩子封装子组件来改变此行为,我们在下文会讲到 useMemo。

补充说明:

当一个状态变量使用一些道具值作为默认值(下面的代码段)进行初始化时,该道具值只在第一次创建状态时使用,道具的再次更新不会自动“同步”组件的本地状态。由此产生的故障请查看 [Dan] 的 播客,他给出了很好的解决办法,你还可以查看 React 文档中对这种反模式的详细解释和解决方案。

const [localState, setLocalState] = useState(props.theme);
  • 如果 setState 处理器被多次调用,当调用代码在基于React 的事件处理器内部时,React 会批处理这些调用并只触发一次重新渲染。如果这些调用不是来自基于 React 的处理器,比如说,来自setTimeout,那么每次调用都会触发一次重新渲染。

useEffect

文档中可以看出,突变、订阅、定时器、日志和其他副作用都不可以出现在函数组件(React 的渲染阶段)的主体内,否则,UI 会出现 bug 和不一致。

可以使用* useEffect 。传递给 useEffect 的函数会在渲染提交给屏幕后开始运行。我们可以把特效看作是从 React 的纯函数编程进入命令式编程的撤离门。

如果你熟悉 React 类的生命周期方法,可以把 useEffect 钩子看作是* componentDidMountcomponentDidUpdatecomponentWillUnmount 的结合体。

我们通过下面的几个例子来理解上面的内容。

例1:我们在 TickerComponent 上简单添加 useEffect 处理器和一些日志语句。我们的目标是了解这个函数在组件生命周期内的执行时机。

useEffect(() => {
  // logs 'useEffect inside handlers
  // logs the active ticker currently shown (by querying the DOM)
  return () => {
    // logs 'useEffect cleanup handler'
   };
});

下面是简化的代码(完整版本在这里)。执行日志是下列三个动作的结果:

  • 挂载组件。

  • 将 ticker 改为 “MSFT”。

  • 卸载组件。


https://codesandbox.io/s/02a-useeffect-basic-tjy8o

我们来仔细观察执行日志:

#1、2、3:挂载后,组件以默认的 ticker(‘AAPL’)呈现。

#4:useEffect 处理器在挂载后第一次运行——DOM 元素已经有默认状态(‘AAPL’),意味着当useEffect 处理器运行时,React 已经将组件状态同步到 DOM。 注意:清理处理器还没有运行(因为之前没有完成对 useEffect 处理器的调用)。

#5:从下拉菜单中选择新的 ticker (‘MSFT’)。

#6、7、8:状态改变。组件以新的状态(‘MSFT’)重新渲染。

#9从之前的 useEffect 调用中返回的清理处理器现在运行(注意:这里的 ticker 值是’AAPL’,因为这个闭包是在之前的 useEffect 运行时创建并返回的)。

#10:useEffect 处理器再次运行,与之前一样,DOM 已经有了新的 ticker 状态(‘MSFT’)。

#11、12:最后,组件被卸载后,React 一定会运行与末行效应(对于ticker ‘MSFT’)相关的清理处理器。

我们从上面这个例子学到了:

  • React 会在当前组件的状态与 DOM 完全同步时运行 useEffect 处理器,所以 React 是初始化副作用(后端调用、注册订阅、日志记录、计时器等)的理想场所。

  • useEffect 处理器返回的函数(清理处理器)正好在 useEffect 处理器下一次运行之前(或在卸载之后)运行。这是进行副作用相关的清理操作(取消订阅/拆卸事件处理器等)的最佳时机,同时也可以避免内存泄漏。由于“清理”处理器是一个闭包,它获取了函数创建和返回时的状态,即使该函数在下一次重新渲染时被执行,也会非常顺畅(查看日志中的#9 和#12——清理处理器中的状态值来自早期迭代)。

我们用一个具体的例子将这些概念联系起来。如果我们想更新 ticker 组件来不断刷新活跃 ticker 的最新报价,我们可以使用一个(模拟的)流式股票报价服务。该 API 可以为每个 ticker 注册一个回调,该回调每隔几秒钟就会被调用一次以返回当前的价格行情。如果它返回一个清理处理器,当该清理处理器被调用时,API 会停止执行该 ticker 的回调。

下面是精简的代码片段(完整版本在这里)。价格变化的每次通知都会在价格上显示一个 setState,从而触发一次重新渲染。活跃的 ticker 改变后,你可以看到清理处理器是针对以前的 ticker 运行的。


https://codesandbox.io/s/02c-useeffect-service-lvgnb

我简单介绍一下 useEffect 调用里的依赖数组(第二个参数)。当这个参数被省略时,React 会在每次重新渲染后执行 useEffect 处理器(就像 useEffect 的第一个示例),大多数情况下这样做效率都不高。当依赖关系被指定后,只在列表中的依赖关系发生变化时,React 才运行 useEffect 处理器。

大多数情况下,出现无限渲染是因为没有合理配置依赖列表。例如,如果你添加了一个函数引用作为依赖,如果该引用在每次重新渲染时都发生变化(函数被重新创建),那么 useEffect 也会在每次重新渲染时运行,处理器内部的状态变化会导致重新渲染,循环往复,造成无限的渲染循环。


https://codesandbox.io/s/02c-useeffect-service-lvgnb

对于上面的截图,我们需要注意以下内容:

  • #4、#16: useEffect 处理器(记录价格更新)只在 ticker 状态改变时运行。由于有依赖列表, useEffect(和清理)处理器没有在每次重新渲染时运行。

  • 每一个价格变化通知(#5, #8, #17, #20)都会触发一个重新渲染,与 ticker 变化事件(#11, #12)一样。这两个处理器都使用 useState 来更新相应的状态值。

  • 在新的 Ticker 状态 “MSFT” 的 useEffect 处理器运行之前,运行了默认股票 “AAPL” 的清理器(#15)(#16)。

useLayoutEffect

如果我们想应用的副作用是直接操作 DOM(基于渲染布局的 css 更新、拖放等),那么, ‘useLayoutEffect’ 处理器是进行这些突变的最佳场所。

文档中可以看出——其特征与 useEffect 相同,但是它会在所有 DOM 突变后同步触发。我们可以使用它可以从 DOM 中读取布局,并同步进行重新渲染。在浏览器有机会绘制页面之前, useLayoutEffect 中的更新会被同步刷新。

下面我们通过一个特定的例子来了解具体细节。我们建立一个报价组件——它有一个获取随机报价的按钮和一个渲染报价的显示面。我们不考虑报价页面的尺寸大小,把显示面保持在固定的高度。当检索到一个较长的报价时,就把部分内容剪切,在右下方显示“展开完整报价”的链接。下面是这个设置的简图:

在使用 useLayoutEffect 处理器之前,我们仍旧使用 useEffect 处理器来查看为什么它在这种情况下不起作用。下面是一个简化的代码片段,长句子被渲染后,它会检查容器的高度,并在原始高度超过阈值时添加一个“固定高度”的 css 类函数。这段代码在 useEffect 钩子处理器中运行。右边的屏幕截图显示了当长句显示时出现的类似闪烁的效果。

1_5EP9mdg-VvUmL0taMXEmdg %E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC
https://codesandbox.io/s/03a-blink-with-useeffect-e68yi

Chrome 的性能分析器揭示了为什么会出现这种情况(查看下方截图):

  • 当长文被设置为状态时,React 会将完整的引用提交到 DOM 中。

  • 浏览器进行布局计算,并在屏幕上绘制完整的报价(参考标签 “浏览器在此绘制完整的长篇报价”,绿色块是指绘制操作)。

  • useEffect 处理器开始运行。我们可以根据我们在代码中添加的自定义 perf 标记(‘use_effect_handler_end’)来发现它的执行,这个标记在第一次页面绘制时显示。

  • 我们在 useEffect 处理器中的容器上添加了一个 css 类,用来剪辑高度。这个变化迫使浏览器重新运行布局和绘制操作。请参考标签——‘浏览器应用了溢出的 css 并重新绘制了屏幕’。

  • 两次绘制操作之间的(轻微)延迟让体验变得不太好。


https://codesandbox.io/s/03a-blink-with-useeffect-e68yi

当 ‘useEffect’ 被替换成 ‘useLayoutEffect’ 时,该行为就会消失,没有其他变化(完整的代码在这里)。这里是更新后的性能简介。‘use_layout_effect_handler’ 标记的位置(在计时标签中)表示 useLayoutEffect 处理器代码的执行。它在提交状态后同步运行,导致了一次带有“剪切”引号的绘制(最佳状态)。


https://codesandbox.io/s/03b-no-blink-with-uselayouteffect-nvsd0

useContext

Context 提供了一种通过组件树传递数据的方法,所以我们不必在每一等级手动传递道具。

每当 Provider 的 value 道具发生变化时,作为 Provider 后代的所有用户都会被重新渲染( https://reactjs.org/docs/context.html#contextprovider )。从 Provider 到其后代用户的传播不受shouldComponentUpdate 方法的影响,所以即使父组件退出更新,用户也会继续更新。

我们通过例子来理解上面的内容。如果你还记得我们之前用 useState 做的主题选择示例,我们可以用 Context 和 useContext 重写一下,看看上下文的更新是如何触发重新渲染的。

1_SccAuTwnGveC4WzlPUMPhw

我们改变 UI ——只有第一个 (Themed)TickerComponent 支持主题选择(深色/浅色模式)。第二个 TickerComponent 总是在深色模式下渲染,这样我们就能够看到对消耗 useContext 的组件和不消耗 useContext 的组件的影响。

下面是组件的层次结构和它们的代码片段,包括执行日志。


https://codesandbox.io/s/04a-usecontext-uvmi0

https://codesandbox.io/s/04a-usecontext-uvmi0

执行日志跟我们预想的一样,当主题改变时,只有 ThemedTickerComponent 和它的子组件 TickerComponent(1) 被重新渲染,因为它是 themeContext 的消费者。TickerComponent2 没有日志。

// ThemedTickerComponent
const { theme } = useContext(ThemeContext);

对 useContext 的低效消耗

我重新调整了上面例子中的组件层次结构来显示 useContext 的低效使用。我删除了 ThemeSelector 组件,并将其所有代码直接移到其父(App)组件中。


https://codesandbox.io/s/04b-usecontext-bad-usage-m02xn

在这个设置下,无论它们是否消耗了主题,每一次主题改变都会重新渲染 App 组件和它的所有子组件。与上一个例子不同,在这种情况下,TickerComponent(2) 也被重新渲染。


https://codesandbox.io/s/04b-usecontext-bad-usage-m02xn

这是因为 App 组件现在成了 useContext 的消费者。

如果上下文的值变化太频繁,并且组件树中某个位置的组件消耗了 useContext,这会导致其所有的子组件被重新渲染(除非它们记入备忘录),这可能会导致性能瓶颈。一般来说,在优化之前要先测量其影响,React 开发工具的分析器应该能派上用场。

useCallback& useMemo

useCallback — 返回 记忆 回调。

传递一个内联回调和一个依赖关系数组。 useCallback 将返回回调的备忘版本,只有当其中一个依赖关系改变时才会改变。这在传递回调给优化的子组件时很有用,这些子组件依赖引用平等来避免不必要的渲染(例如shouldComponentUpdate)。

useMemo - 返回一个记忆化的值。

传递一个 "创建 "函数和一个依赖关系数组。 useMemo 只有在其中一个依赖关系发生变化时才会重新计算记忆值。这种优化有助于避免在每次渲染时都进行复杂的计算。

这两个钩子在构建对性能敏感的组件时都很方便。我们将通过另一个例子来理解这些钩子的用法。我们搭建一个股票观察列表组件,如下图所示,删除一个不会重新渲染其余 ticker 的 ticker (这是渲染多个列表时的一个正常需求)。

1_ncTeyRfGYbT_BiIPzp6LJg

我在下文逐步概述了解决方案,并点出了一些容易踩的坑。

步骤#1——从默认实现开始——不使用 useCallback/useMemo

观察列表组件的片段如下所示:

  • 这个组件存储了一个 ticker 列表(储存于状态中),并定义了一个 onRemove 处理器,通过删除作为参数发送的股票来更新状态。

  • 它通过传递 ticker 值和 onRemove 处理器作为道具,为列表中的每个 tickerComponent 进行渲染。

正如屏幕截图中所看到的,每删除一个 ticker,其他的 ticker 都会被重新渲染,并且观察列表的状态会更新,迫使观察列表组件的子节点重新渲染。我们在前面的 useState 例子中已经看到了这种默认行为。

1_vWyjMTsT4DLK2lr4n3kc2w %E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC
https://codesandbox.io/s/06a-usecallback-attempt1-xffbh

第2步——利用 useMemo 和 useCallback

除非依赖关系被改变,我们不希望每次父组件被重新渲染时,TickerComponent 也被重新渲染。

useMemo 钩子可以提供我们需要的备忘功能。

1_JEeihTPyizAfMW70XwuzHA
https://codesandbox.io/s/06b-usecallback-usememo-ngwev

这个代码段展示了使用 useMemo 钩子的记忆 TickerComponent。React 跳过了重新渲染组件的过程,并返回之前的缓存结果,除非列出的依赖关系发生变化(ticker 或 onRemove 处理器)。

接下来,我们需要优化 “onRemove” 道具。它被定义为 watchlist 组件的一个匿名函数,每次 Watchlist 组件重新渲染时该函数都会重新创建。因为它的引用会随着每次重新渲染而改变,所以它能使我们上面做的 TickerComponent 记忆失效。

const onRemove = tickerToRemove => {
   setWatchlist(
       watchlist.filter(ticker => ticker !== tickerToRemove)
   );
};

1_R-eFrwBBj1WEMee-y-PrqA
https://codesandbox.io/s/06b-usecallback-usememo-ngwev

如果不希望每次重新渲染时都引用一个新的函数,可以使用 useCallback 钩子。它接收一个函数并返回一个记忆化的函数,这个函数的引用在重新渲染之间不会改变,除非它的依赖关系发生变化。我们用 useCallback 来封装 onRemove 处理器。

下面是改变后的日志,我们会发现移除一个 ticker 仍然会重新渲染现有的 ticker 组件,可以从下图看到 ticker 组件的“Begin Render”。

1_T6DSwb7siao2Dsg2FgZBfg
https://codesandbox.io/s/06b-usecallback-usememo-ngwev

最后一步——使用 setState 的功能形式

如果仔细看一下 useCallback 封装的 onRemove 处理器,就会发现watchlist 被添加在依赖数组中,原因是该函数更新了 watchlist,而这就是导致重新渲染的原因。

1_1o9vOmwwzi4Qv10Bpl-pMg
https://codesandbox.io/s/06b-usecallback-usememo-ngwev

每当股票被删除:

  • 在过滤掉被删除的股票后,watchlist 状态会被更新为一个新的数组引用。

  • 在下一次重新渲染时,useCallback 会返回一个新的 onRemove 处理程序引用,因为它的依赖关系(watchlist)已经改变。

  • 新的 onRemove 处理程序引用将使 TickerComponents 的记忆化失效。

我们又回到了默认实现的问题上,但现在所有的记忆化占用了更多的内存(!!!)。

我们想要的是即使 watchlist 数组发生变化,onRemove 函数的引用也不能改变,所以我们需要一次性创建这个函数,并在不依赖 watchlist 的情况下将其记忆化。

来自 useState 钩子的 setter 函数支持的一个功能性的变量可以帮我们达成这个目标。我们可以不使用更新的 watchlist 数组来调用 setWatchlist,而是发送一个函数来获取当前状态作为参数。这是onRemove 处理器的修改版,没有对 watchlist 数组的依赖性。内部函数得到当前的 watchlist(状态)作为参数,并过滤出要删除的 ticker 并返回更新的 watchlist 数组,watchlist 状态通过返回值来更新。

1_rGN9kn2DuUWnUu_-HfYbVg
https://codesandbox.io/s/06c-usecallback-final-no-rerenders-bsm64

这样我们就达成了我们的优化目标。现在,删除一个 ticker 不会导致所有 ticker 组件的重新渲染(Ticker 组件四周没有闪烁边框)。

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC
https://codesandbox.io/s/06c-usecallback-final-no-rerenders-bsm64

我相信你已经注意到了,想要在代码库中添加 useCallback 和 useMemo 钩子其实很容易,但很可能没有预期的 perf 效果。如果想有效地添加这些钩子,需要对组件的层次结构有透彻的了解,并通过适当方法来衡量性能。

useReducer

官方文档 中可以看出,这是一个替代 useState 的方法。接受一个(state, action) => newState 类型的还原器,并返回匹配了dispatch方法的当前状态。如果你熟悉 Redux,你肯定知道它的运行原理)。

当你的状态逻辑很复杂,涉及到多个子值,或者下一个状态取决于上一个状态时, 用useReducer 通常比用 useState 更好。 useReducer 可以对触发深度更新的组件性能进行优化,因为它可以把 dispatch 传递下去,而不是回调。

上面例子中的 Watchlist 组件已经被修改为 useReducer,这样可以简化程序,且不会出现前面例子中使用 useCallback 时出现的状态无效的问题。

1_sG9m5bYTK7eJGviuTO7nJg
https://codesandbox.io/s/07-usereducer-5l0tk
1_tEEiqKn47BmYkdMBNTM8Pw
https://codesandbox.io/s/07-usereducer-5l0tk

这些例子帮我理解了执行流程/重新渲染方面的很多问题,使我能够更有效地使用钩子来搭建功能组件。

感谢读到这里的童鞋们,希望这篇文章能帮到你们 :sparkling_heart: :sparkling_heart: :sparkling_heart:

如果有任何问题,欢迎在下方留言哦~~~

原文作者 Gupta Garuda
原文链接 https://medium.com/@guptagaruda/react-hooks-understanding-component-re-renders-9708ddee9928

推荐阅读
相关专栏
前端与跨平台
90 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。