React Hooks - 组件重新渲染原理


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

当我开始研究 React Hooks 时,官方就已经给出了其核心概念的解释 - hooks 背后的动机 api的详细 参考hook 规则。但是当我开始使用它们时,我就感觉到我对组件生命周期的理解还是不够的。有时我无法解释为什么我的组件会被重新渲染(似乎是一个常见问题),是什么变化导致了无限渲染循环(另一个常见问题),有时我不确定我使用 useCallback/useMemo是否真的能提高性能。

使用挂钩的功能组件会经历一个生命周期,但与基于类的对应组件不同,它没有明确的生命周期方法。虽然这种非显式性质允许在自定义挂钩中提取可重用的行为,但我发现自己缺乏的是对挂钩如何影响执行流程的理解。

这篇文章是我探索的结果,我想了解每个内置挂钩是如何影响组件重新渲染/生命周期的。我将通过详细的例子来分享我的学习成果,同时也强调一些我遇到的陷阱。本文篇幅很长,涵盖了大量内置挂钩的截图和例子。如果你只对某个特定的挂钩感兴趣,可以直接点击以下链接。

目录

术语

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

(来自Kent C. Dodds的文章)。

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

现在,是什么促使React触发了渲染阶段? StackOverflow’s 帖子提供了一个简洁的答案。

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

本文中提到的每一个 "重新渲染 "都是关于上述的 “渲染阶段”。

关于本文中的例子:

  • 我们将实现一个简单的 ticker行情组件,并围绕这个组件添加功能,以了解各种内置挂钩的行为。这个组件显示一个 ticker票符号和它的价格,下拉菜单允许用户挑选不同的股票。

1_Wc8kWh4X78JzQoAHxCUhTg

  • 所有的例子都显示了组件旁边的执行日志。每当React重新渲染该组件时,该组件周围都会出现一个虚线边界(作为视觉线索)。

  • 这些代码样本有意保持简单,主要集中在执行流程上(没有类型;没有后台调用;股票报价是模拟数据)。
  • 当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后的高亮边界。执行日志显示此步骤为 “开始渲染”。
%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC|200x109

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

以下是日志的快照:
1_pgcKRF_R7mv9ALbp8qhnMQ

这个动画从代码的上下文中强调了组件的行为(步骤1-12)。

父组件中状态更新的影响

由于功能组件中的本地状态变化,我们看到了组件重新渲染的周期。当父组件和子组件都有自己的本地状态(通过useState),并且父组件的状态被改变时,会发生什么呢?

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

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

App组件在其状态中持有 "主题 "变量,并有两个Ticker组件作为子代。主题作为道具被传递给TickerComponents。TickerComponent消耗props.theme并将其值渲染成css类(与之前的例子相比,这是对TickerComponent的唯一改变)。


( code , sample execution run).


  • 第一组。当安装App组件时,它被初始化为默认的 "dark "主题,而ticker组件被初始化为默认的 "AAPL "ticker。
  • 第二、三组:在Ticker组件中都选择了新的ticker,状态的变化触发了局部的重新渲染(就像前面的例子)。
  • 第四组:现在主题值在父体中被改变,父体(App)组件和它的子体(Ticker组件)都被重新渲染了。

当App组件重新渲染时,它的子组件也会重新渲染,不管它们是否消耗主题值(作为一个道具)。请看这个 已更新的例子,在这个例子中,第二个ticker组件不消耗主题状态,但当主题改变时就会重新渲染。这是React的默认行为,可以通过用useMemo挂钩包装子组件来改变它,我们很快就会看到。

solutions.

补充说明:

当一个状态变量被初始化时,使用一些道具值作为默认值(下面的片段),该道具值只在状态被创建时被使用。任何对道具的进一步更新都不会自动 "同步 "组件的本地状态。这样的假设会导致一个错误的行为,请查看 Dan关于这个话题的优秀 帖子,以及在React文档中了解对这种反模式的详细解释和可能的解决方案。

const [localState, setLocalState] = useState(props.theme);
  • 当setState处理程序被多次调用时,当调用代码在基于React的事件处理程序时,React会对这些调用进行批处理并只触发一次重新渲染。如果这些调用来自非基于React的处理程序,如setTimeout,那么每次调用都会触发重新渲染。(参考这个话题的帖子 )

useEffect

从文档中可以看出–突变、订阅、定时器、日志和其他副作用都不允许出现在函数组件的主体内(指React的渲染阶段)。这样做会导致UI中出现混乱的bug和不一致的地方。

相反,使用 useEffect 。传递给 useEffect 的函数将在渲染提交到屏幕后运行。把特效看作是从React的纯函数世界进入命令世界的逃生舱门。

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

让我们通过几个例子来理解这些语句。

在第一个例子中,我们将简单地在TickerComponent上添加useEffect处理程序和一些日志语句。我们的目标是了解这个函数在组件生命周期中是如何被执行的。

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

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

让我们仔细观察执行日志,了解具体发生了什么 –

#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处理器。这使得它成为启动副作用(后端调用、注册订阅、记录、计时器等)的理想场所。

  • 由useEffect处理程序返回的函数(清理处理程序)正好在useEffect处理程序下一次运行之前(或在卸载之后)运行。这是一个理想的结果,可以进行与副作用相关的清理操作(取消订阅/脱离事件处理程序等)。这对于避免内存泄漏也很重要。由于 "清理 "处理程序是一个闭包,它捕获了函数创建和返回时的状态,即使该函数在下一次重新渲染时被执行,也会自然地进行(查看日志中的步骤#9和#12–清理处理程序中的状态值来自早期迭代)。

让我们用一个具体的例子将这些概念联系起来。要想更新ticker代码组件,使其不断刷新活动 ticker的最新报价。我们将使用一个(模拟的)流式股票报价服务。该API允许为一个ticker注册一个回调,该回调每隔几秒钟就会被调用一次,并显示当前的价格行情。它返回一个清理处理程序,当被调用时,API停止执行该 ticker的回调。

下面是浓缩的代码片段(完整版本在这里)。每一个价格变化的通知都会在价格上做一个setState,从而触发一个重新渲染。当活动的股票被改变时,你可以看到清理处理程序是针对以前的 ticker运行的。


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

大多数情况下,无限重现是由于没有正确配置依赖列表的结果。例如,如果你添加了一个函数引用作为依赖,如果引用在每次重新渲染时发生变化(函数被重新创建),那么useEffect就会在每次重新渲染时运行,这个处理程序的状态变化会导致重新渲染,循环往复,造成无限的渲染循环。


从快照中可以注意到一些有趣的事情。

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

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

  • Checkout Dan’s Don’t Stop the Data Flow in Side Effects ’ post to appreciate the elegance of the useEffect hook.

  • 在新的股票状态 "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

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处理程序代码的执行。它在提交状态后同步运行,导致了一次带有 "剪切 "引号的绘画操作(我们理想的最终状态)。


useContext

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

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

让我们观察几个例子。如果你还记得我们之前用useState为主题选择的例子,我们可以用Context和useContext重写一下,看看上下文的更新是如何触发重新渲染的。

1_SccAuTwnGveC4WzlPUMPhw

Here is the hierarchy of the components and their code snippets including the execution log.

我们将对用户界面做一个改变–我们将使只有第一个(Themed)TickerComponent支持主题(黑暗/光明模式)。第二个TickerComponent总是在黑暗模式下显示。这使我们能够看到对消耗useContext的组件和不消耗的组件的影响。

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




执行日志没有任何意外,当主题选择被改变时,只有ThemedTickerComponent和它的子TickerComponent(1)被重新渲染,因为它是themeContext的消费者。TickerComponent2没有日志。

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

对useContext的低效消耗

让我稍微重新安排一下上面例子中的组件层次结构,以显示useContext的低效使用。我删除了ThemeSelector组件,并将其所有代码直接移到其父(App)组件中。


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


这是因为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

第2步–利用useMemo和useCallback

我们不希望TickerComponent在每次其父组件被重新渲染时也被重新渲染,除非依赖关系被改变。 useMemo挂钩提供了我们正在寻找的备忘功能。
1_JEeihTPyizAfMW70XwuzHA

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

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

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

1_R-eFrwBBj1WEMee-y-PrqA

我们不希望每次重新渲染时都引用一个新的函数。 useCallback钩子就是我们要找的。它接收一个函数并返回一个记忆化的函数,这个函数的引用在重新渲染之间不会改变,除非它的一个依赖关系发生变化。让我们用useCallback来包装onRemove处理程序。

下面是做完这两个改动后的日志。有趣的是,这两个变化都没有停止在移除一个ticker时重新渲染现有的ticker组件。你可以看到 "Begin Render "来自现有的ticker组件。

1_T6DSwb7siao2Dsg2FgZBfg

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

如果你仔细看一下useCallback包裹的onRemove处理程序,watchlist被添加到依赖数组中,因为该函数更新了watchlist,这是导致重新提交的原因。

1_1o9vOmwwzi4Qv10Bpl-pMg

每当一个股票被删除时。

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

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

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

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

为了实现我们的需求–即使watchlist数组发生变化,onRemove函数的引用也不应该改变。我们需要一次性创建这个函数,并在不依赖watchlist的情况下将其备忘化。

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

1_rGN9kn2DuUWnUu_-HfYbVg

这帮我们实现了必要的优化。现在,删除一个代码不会重新渲染现有的代码组件(代码组件周围没有闪烁的边框)。

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC

你可能已经注意到了,在代码库中添加useCallback和useMemo钩子是很容易的,但却没有预期的perf效益。有效地添加这些钩子需要对组件的层次结构有透彻的了解,并有适当的方法来衡量性能的提高。

请务必查看Kent C. Dodds的这篇出色的文章,它涵盖这个主题。https://kentcdodds.com/blog/usememo-and-usecallback

useReducer

官方文档 中可以看出,这是一个替代useState的方法。接受一个(state, action) => newState类型的还原器,并返回当前状态与一个调度方法的配对。如果你熟悉Redux,你已经知道这是如何工作的)。

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

上面例子中的Watchlist组件已经被修改为useReducer。这种方法使事情变得更加简单,而且不会遇到我们在前一个例子中使用useCallback时出现的状态无效问题。

1_sG9m5bYTK7eJGviuTO7nJg

1_tEEiqKn47BmYkdMBNTM8Pw

这些例子使我在解决执行流程/重排方面遇到的很多问题都变得清晰起来。它们还帮助我建立了一个更好的心智模型,使我能够更有效地使用钩子来构建功能组件。

谢谢你花时间读完本文。希这篇文章能对你有所帮助。如有任何反馈,欢迎评论

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

推荐阅读
作者信息
AgoraTechnicalTeam
TA 暂未填写个人简介
文章
131
相关专栏
精选文章
82 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和 Agora 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。