React 为什么会 re-renders

3,927 阅读7分钟

Hi,大家好,我今天分享的主题是 React 为什么会重新渲染(Why React Re-Renders)。

作为一名开发人员,我使用 React 快 5 年时间,但对于 React 重新渲染(re-render)的过程:它是如何工作的其实并不是非常了解。最近我阅读一些文章和通过实践,对 re-render 有了进一步的认识,想和大家一起探讨下。本次分享主要包含三个内容:

  1. 为什么会 re-render,它做了什么
  2. 什么情况下会导致 re-render
  3. 如何避免非必要的 re-render,提升应用的性能

一、为什么会 re-render?

在讨论 React re-render 时,我们需要注意两个重要阶段

  • 初始化渲染阶段:组件第一次出现在屏幕中。
  • 重新渲染阶段:指已经在屏幕中挂载的组件,第二次或多次连续的渲染。

React 主要工作是保证应用 UI 与 React State 同步,而 re-render 目的则是计算出哪些(UI)是需要更新的。

当 React 需要使用新数据更新应用程序时,就会 re-render——通常这是由于用户与应用交互、异步请求、订阅模型(model)传入的外部数据导致的。

re-render 实际上做了啥?

  • 首先,以函数式组件为例:当点击 button 时,state 会更新

    const App = () => {
      const [state, setState] = useState(1);
    
      const onClick = () => {
        setState(state + 1);
      };
    
      return (
        <>
          <button onClick={onClick}>click here</button>
          <Child />
        </>
      );
    };
    

    re-render 会再次执行函数,且该组件下的所有子节点都会 re-render

  • 其次,re-render 并不意味着操作 DOM

    • jQuery 时代,我们根据数据 Data 的变更,通过 $.xxx 方法手动的更新 DOM。比如:在一个列表中,每次 jQuery 操作都是直接操作 DOM,在复杂场景下,难以写出高效更新 DOM 的方法,从而导致性能低下;其次,开发体验差,对新增和删除操作而言,其开发复杂度还好,但是修改操作会变得非常麻烦
    • React 中,我们通过数据 Data 驱动 Components,每次 render 都会生成 VirtualDOM——一个纯JS对象。而我们(开发者)大部分工作到这里就停止了,后续将由 React 计算出每次更新的差别,自动更新 DOM,并且最大化的保证性能

    Untitled.png

二、什么情况下会导致 re-render?

  • 组件的 state 更新:codesandbox
    • 不管哪个 state 变量改变时,整个 App 都会 re-renders?
      • 在 React 应用中,data 不能「向上流动」,re-render 只会影响声明/拥有该状态的组件以及该组件的后代。
  • 父组件 render: codesandbox
    • ❓ 组件的 props 改变将会触发 re-renderprops not relevant

      只有使用了 memoization 的组件(React.memo, useMemo),讨论 props 改变才变得重要。

      我们讨论一个非 memoization 组件,它的 props 改变导致的 re-render 其实没有意义。为了使 props 改变,它需要父组件更新。这意味着父组件必须重新渲染,这将触发子组件的重新渲染,与其 props 无关。

  • 使用 Context: codesandbox
    • 当 Context 提供者(Provider)的值发生更改时,使用此 useContext 的所有组件都将 re-render,即使它们没有直接使用数据的变更部分
    • Redux VS Mobx
  • hooks 链式调用: codesandbox
    • hooks 内发生的一切都“属于”使用它的组件。hooks 可以链接,链中的每个 hooks 仍然“属于”宿主组件
      • hooks 内部的状态变化将触发宿主组件(使用它的组件)的不可预防的重新渲染。
      • 如果 hooks 使用 Context 和 Context 的值更改,它将触发宿主组件的不可预防的重新渲染。

三、如何避免非必要的 re-render?

开发中我们经常遇到一个场景:仅仅更新一个 state 状态后,会触发多次 re-render,感觉莫名其妙,程序虽然运行起来了,但是好像里面都是黑盒。

if-your-code-work.jpg

关于如何避免 re-render 我们经常会使用 React.memo 声明一个纯函数组件——在使用 React.memo 之前我们或许还可以试试其他的方法。

  • ❌  不要在 render 函数中创建其他的 Components(codesandbox)。每次 re-render React 会**重新加载(re-mount)**这个组件:先将它销毁,再重新创建。这样比普通的 re-render 更慢,还可能会造成一些 bug
    • re-renders 期间可能会有异常闪动
    • re-renders 时 state 都会重置
    • re-render 时都会触发没有依赖的 useEffect
    • 如果组件是 focused 状态,焦点会丢失
  • 状态下移,把可变的部分拆到平行组件里:这个方法在管理大型组件的状态时,可能很有用。因为通常某些状态仅用于渲染树中「隔离」的一小部分(codesandbox)。一个典型的例子,比如页面中通过 button 来管理一个对话框 dialog 的状态
    • 🏝 我们通常根据 UI 来设计组件,如果再细粒度一些,以 state 隔离为目标来设计组件,达到低耦合、提升可维护性的目的,也可以逐步沉淀出基础组件、业务组件。
  • children as props
    • 上面例子其实用了 props.children,React props 传递任何东西,并不会被触发 re-render。
  • component as props
    • 那用其他 props 属性可以吗?可以!比如:

      <Changed left={<Expansive1 />} right={<Expansive2 />} />
      

      <Changed /> re-render 并不会导致 <Expansive /> re-render

  • 使用 memo
    • Lodash 中的记忆化(traditional memorization with lodash)

      import memoize from 'lodash/memoize';
      
      function swatch(color) {
        console.log(color);
        return color;
      }
      
      const memoizedSwatch = memoize(swatch);
      
      swatch('red');
      swatch('blue');
      swatch('red');
      swatch('blue');
      // color :>>  red 
      // color :>>  blue
      // color :>>  red 
      // color :>>  blue
      
      memoizedSwatch('red');
      memoizedSwatch('blue');
      memoizedSwatch('red');
      memoizedSwatch('blue');
      // color :>>  red 
      // color :>>  blue
      
    • 使用 React.memo 包裹组件,可以阻止渲染树中某个节点被触发 re-render 向下传播——除非这个组件的 props 被改变。

    • React.memo 直接告诉 React:除非 props 有更新,否则我不需要 re-renders

    • 在渲染没有依赖来源(without props)的大型组件时,非常有用

    • 所有非原始类型的 values 必须被 memorized,才能使 React.memo 正常工作

    • 🏝 components as props or children

      • React.memo 必须使用在作为 children/props 传递的元素。Memoizing 父组件不会生效,因为 children 和 props 是一个对象,因此它们在每次 re-render 时都会改变

使用 useMemo/useCallback 提升 re-renders 的性能

  • 反模式:非必要的 useMemo/useCallback(AntiPattern:unnecessary useMemo/useCallback on props
    • 记忆化 props 不会阻止一个子组件 re-renders。如果父组件 re-renders,它将触发无视 props 触发子组件的重新更新
  • 必要的 useMemo/useCallback(Necessary useMemo/useCallback)
    • 被 React.memo 包裹的子组件,所有非原始类型的值,应该使用 memorized(child component is wrapped in React.memo, all props that are not primitive values have to be memorized)
    • 如果组件使用非原始值作为 hooks 的依赖,如 useEffect, useMemo, useCallback,它应该被 memoized
    • 🏝 useMemo 昂贵的计算
      • useMemo 有代价:它会消耗一点额外内存并导致初始化稍慢,它不应该被用于每次计算(unless you’re actually calculating prime numbers, which you shouldn’t do on the frontend anyway)。在 React 中挂载和更新组件是最昂贵的计算

      • 与组件更新相比,纯 JS 操作可以忽略不计。所以,一个典型的 useMemo 用例应该是缓存 React elements,例如:返回新元素的 map 函数

        // map 函数生成元素
        const items = useMemo(() => {
          return values.map((val) => <Child value={{ value: val }} />);
        }, []);
        
  • useCallback 与 useMemo 相似,它用来 memoized 一个函数

Conclusion

  • 使用 React.memo 的组件,引用类型 props 需要使用 memoization
  • 使用引用类型作为 hooks 依赖,依赖项需要使用 memoization
  • 不应该被用于每次计算,除非计算性能消耗比较大,反而可以用 useMemo 缓存 React Elements,比如 map 函数

FAQ

Q: 为什么 memo 不是 React 的默认行为?

A:性能优化都是有成本的,对于很多没有子节点的组件,通过 memo 进行浅比较反而会造成更大的浪费。

Link

关于 React Re-Render

Why React Re-Renders

React re-renders guide: everything, all at once

如果文章对你有帮助,记得点赞、收藏加关注,thx~🥳🥳