【翻译】第一章 介绍 re-renders

221 阅读12分钟

让我们开始文章吧。我首先要讨论的是性能问题的:在应用开发过程中,性能问题一直都是最重要的话题之一,当然,性能问题也是本书的重要展开的之一。

在讨论React框架的性能问题时,理解re-renders和re-renders所带来的影响,是非常重要的:re-renders是如何被触发的、re-renders如何在app中传导、当一个组件发生重新渲染时发生了什么,以及为什么我们要首先讨论re-renders。

在这一章,我们会介绍一些基础概念(当然,这些基础概念会在后面几章得到进一步的讲解)。为了使讲解过程变得更有趣,我们会以调研调查的形式进行讲解。我们会先看一个非常常见的性能问题,看看这个问题会导致什么结果,然后学习如何使用一些技巧来修复这个性能问题。在这个过程中,你将会学到:

  • 什么是re-renders,为什么需要re-renders
  • re-renders的初始资源是什么
  • React如何把re-renders传导到整个应用
  • re-renders背后的秘密,以及为何props的变化不影响re-renders
  • 使用“状态下移”来提升性能
  • 为什么在重新渲染时,钩子可能是危险的

性能问题

想象一下,身为一个开发者的你,接手了一个规模庞大、结构复杂且性能敏感的,已经被众多人多年使用的应用。此时你的第一个需求是去添加一个触发弹窗的简单按钮。

你阅读代码后,找到了应该触发这个对话框的地方:

const App = () => {
    // lots of code here
    return (
        <div className="layout">
            {/* button should go somewhere here */}
            <VerySlowComponent />
            <BunchOfStuff />
            <OtherStuffAlsoComplicated />
        </div>
    );
};

之后,你实现了这个功能。这个功能看起来很简单:

const App = () => {
    // add some state
    const [isOpen, setIsOpen] = useState(false);
    return (
        <div className="layout">
            {/* add the button */}
            <Button onClick={() => setIsOpen(true)}>
                Open dialog
            </Button>
            {/* add the dialog itself */}
            {isOpen ? (
                <ModalDialog onClose={() => setIsOpen(false)} />
                ) : null}
            <VerySlowComponent />
            <BunchOfStuff />
            <OtherStuffAlsoComplicated />
        </div>
    );
};

其实就是添加控制弹窗展示与否的状态,再添加一个触发该状态变化的按钮即可。这样,这个弹窗就会在该状态为true时渲染出来。

但是当你启动这个应用时,你会发现,过了将近1秒钟后,这个弹窗才出现!

代码示例: advanced-react.com/examples/01…

遇到过类似的React性能问题的人可能会说:“嗯哼,当然会这样了。你重新渲染了整个应用,你只要用React.memo来包住整个应用,在用useCallback钩子来防止它就行了。“理论上而言,这是对的。但是,在这里并不需要Memoization缓存,而且这样做弊大于利。我们还有更高效的方法。

但首先,我们先回顾这段代码到底发生了什么,以及其背后的原理。

状态更新,嵌套组件和重新渲染

让我们回到开始的地方:当我们在讨论性能问题时,组件的生命周期是最重要的议题。组件的生命周期有:挂载、卸载和重新渲染。

当一个组件第一次出现在屏幕上,我们称呼这个过程为挂载。在挂载时,React会为这个组件生成第一个实例,初始化这个组件的状态,运行该组件的钩子函数,并将这些元素挂载在DOM树上。

之后,当React会卸载一个组件,当React认为这个组件不再被需要了。在卸载掉过程中,React会做最后的清除工作:销毁该组件的实例,以及与该实例相关的实例,并最后销毁与该组件关联的DOM元素。

最后介绍的是重新渲染。重新渲染指的是React因状态变动而更新一个组件对过程。与挂载相比,重新渲染是一个轻量的过程:React仅仅是复用了现有的实例、运行现有的钩子,然后进行必要的计算,再以此进行DOM更新。

每一次重新渲染,都是从状态着手的。在React框架里,每次我们使用像useState、useReducer,或者如Redux这样的第三方状态管理库时,React会为组件添加响应式。组件的状态会一直保存下来,贯穿其整个组件的生命周期。

重新渲染是理解React的关键点之一。重新渲染,就是数据更新导致组件更新和钩子调用的过程。如果没有重新渲染这个过程,React将不会进行数据更新,React组件也将失去响应式,组件将会成为一个静态组件。第一次的状态更新,发生在一个组件初始化的时候。让我们以最初的app为例:

const App = () => {
    const [isOpen, setIsOpen] = useState(false);
    return (
       <Button onClick={() => setIsOpen(true)}>
          Open dialog
       </Button>
    );
};

当我们点击Button按钮时,我们会触发SetIsOpen函数:把isOpen从false更新为true。因此,App组件会触发对应的更新。

当状态完成更新、组件完成重新渲染后,更新后的状态会被传递到其他依赖该组件的组件。React框架会自动帮我们完成这一过程:它会获取初始组件在内部渲染的所有组件,重新渲染这些组件,然后重新渲染嵌套在他们内部的组件,并以此类推,直到到达组件链的末尾。

如果你把一个React应用想象成一棵树,那么从状态更新被发起的地方开始,往下的所有内容都将被重新渲染。

在这个应用示例中,它渲染的所有组件都会在状态更新时以非常慢的速度重新渲染。

image.png 图片

const App = () => {
     const [isOpen, setIsOpen] = useState(false);
     
     // everything that is returned here will be re-rendered when the state is updated
     return (
         <div className="layout">
             <Button onClick={() => setIsOpen(true)}>
                 Open dialog
                 </Button>
                 {isOpen ? (
                     <ModalDialog onClose={() => setIsOpen(false)} />
                 ) : null}
                 <VerySlowComponent />
             <BunchOfStuff />
             <OtherStuffAlsoComplicated />
         </div>
  );
};

因此,花费了接近一秒才打开对话框 - React需要先重新渲染该组件下所有东西,之后才能展示对话框。

需要重点记忆的是:React在重新渲染组件树上的内容时,它只会向下走,从不会向上走。如果组件树中层的某个状态更新了,只有该组件及其子组件会发生重新渲染。

image.png

处于层级“底部”的组件影响层级“顶部”组件的唯一方法,要么是显式地在“顶部”组件中调研状态更新,要么是将组件作为函数进行传递。

重新渲染后的大秘宝

你是否意识到,我目前还没有讨论到props?你可能会说:“当props发生变化时,组件会发生重新渲染。”。其实这是普遍的对React的误解。其实并非如此。

一般情况下,当一个状态发生变化时,React会重新渲染该组件及其内嵌组件,不论props如何。如果一个组件的状态没有发生变化,那props的变化可以理解为“浅变化”:React没有监控这些props。

如果我有一个带props的组件,且我想只更新props而不更新状态,那么代码如下:

const App = () => {
     // local variable won't work
     let isOpen = false;
     return (
         <div className="layout">
             {/* nothing will happen */}
             <Button onClick={() => (isOpen = true)}>
                 Open dialog
             </Button>
             {/* will never show up */}
             {isOpen ? (
                 <ModalDialog onClose={() => (isOpen = false)} />
             ) : null}
         </div>
     );
};

这段代码并没有如预期运行。当点击Button按钮时,isOpen状态发生了变化。但是这个过程并没有触发React的生命周期,所以并没有发生重新渲染,所以ModalDialog对话框并没有出现。

在重新渲染函数的上下文中,props是否发生变化之后在一种情况下产生影响:该组件被嵌套在更高层组件等R。eact.memo中。只有在这种情况下,React会在它重新渲染链停下来,并检查套在memo中的props。如果没有props发生变化,重新渲染流程会停止在这里。只要有一个props发生变化,重新渲染会继续向下进行。

image.png

合理地使用记忆化来防止重新渲染说一个复杂的话题,有一些需要注意的地方。在第 5 章 “使用 useMemo、useCallback 和 React.memo 进行记忆化” 中可以更详细地了解它。

状态下移

我们现在已经明白了React如何重新渲染组件,现在是时候用这一知识去解决开头遇到的问题了。让我们仔细阅读我们调用对话框的代码:

const App = () => {
  // our state is declared here
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div className="layout">
      {/* state is used here */}
      <Button onClick={() => setIsOpen(true)}>Open dialog</Button>
      {/* state is used here */}
      {isOpen ? <ModalDialog onClose={() => setIsOpen(false)} /> : null}
      <VerySlowComponent />
      <BunchOfStuff />
      <OtherStuffAlsoComplicated />
    </div>
  );
};

正如你所看到的,它相对独立:我们仅在 Button 组件和 ModalDialog 自身中使用它。代码的其余部分,所有那些非常慢的组件,并不依赖于它,因此当这个状态发生变化时实际上不需要重新渲染。这是一个被称为不必要重新渲染的经典例子。

把它们包在React.memo里确实可以防止它们被重新渲染。但是React.memo有很多使用细节,而且React.memo本身很复杂。我们有一个更好的方法。我们要做的事是把组件里依赖状态抽象出来,把抽象出来的部分称为一个更新的组件。

const ButtonWithModalDialog = () => {
     const [isOpen, setIsOpen] = useState(false);
     // render only Button and ModalDialog here
     return (
         <>
             <Button onClick={() => setIsOpen(true)}>
                 Open dialog
             </Button>
             {isOpen ? (
                 <ModalDialog onClose={() => setIsOpen(false)} />
             ) : null}
         </>
     );
};

这样,重新渲染时,就只会重新渲染ButtonWithModalDialog组件。

const App = () => {
     return (
         <div className="layout">
             {/* here it goes, component with the state inside */}
             <ButtonWithModalDialog />
             <VerySlowComponent />
             <BunchOfStuff />
             <OtherStuffAlsoComplicated />
         </div>
     );
};

代码示例: advanced-react.com/examples/01…

现在,当Button被点击时,控制对话框是否展示的状态会被更新,一些组件会被重新渲染。但是,这个重新渲染的过程之后发生在ButtonWithModalDialog内部,只有一个小按钮和一个对话框会被重新渲染,而这个应用的其他部分则不会发生重新渲染。

本质上来说,我们是在渲染树上创造了一个新的分支,并把相关状态下移到新的分支上。

image.png

自定义钩子带来的风险

在处理状态、重新渲染和性能问题时,还有一个重要的概念,就是自定义钩子。毕竟,我们可以用自定义钩子来把一些状态的逻辑抽象出来。我们可以把刚刚的代码抽象成一个useModalDialog钩子。一下是一个简单的版本:

const useModalDialog = () => {
  const [isOpen, setIsOpen] = useState(false);
  return {
      isOpen,
      open: () => setIsOpen(true),
      close: () => setIsOpen(false),
  };
 };

之后,我们通过使用钩子的方式来调用弹窗:

const App = () => {
  // state is in the hook now
  const { isOpen, open, close } = useModalDialog();
  return (
      <div className="layout">
          {/* just use "open" method from the hook */}
          <Button onClick={open}>Open dialog</Button>
          {/* just use "close" method from the hook */}
          {isOpen ? <ModalDialog onClose={close} /> : null}
          <VerySlowComponent />
          <BunchOfStuff />
          <OtherStuffAlsoComplicated />
      </div>
  );
 };

为什么说这样做是“危险”的呢。这样写看起来是一个合理的模式,而且代码看起来更加干净,因为这个钩子帮我们隐藏了状态的变化。但是状态依然存在。每次状态发生变化时,它依然会在钩子内触发重新渲染。这些状态是否在程序中直接使用,甚至这个钩子函数是否返回任何能源,这都无关紧要。

代码示例: advanced-react.com/examples/01…

比如说,如果我想巧妙的设置这个对话框的位置,并在那个监听窗口大小调整的钩子函数内部引入一些状态:

const useModalDialog = () => {
  const [width, setWidth] = useState(0);
  useEffect(() => {
      const listener = () => {
          setWidth(window.innerWidth);
      }
      
      window.addEventListener('resize', listener);
      
      return () => window.removeEventListener('resize', listener);
  }, []);
  // return is the same
  return ...
 }

这整个应用会在每次窗口大小发生变化时发生重新渲染,即便这个状态并没有返回给调用者。

代码示例: advanced-react.com/examples/01…

钩子函数好比牛仔裤里的口袋。如果你把一个十公斤的哑铃放在口袋里,而不是放在手里,这依然会给你的行动带来负担。但是如果你把这个哑铃放在一个能自动驾驶的卡车里,你可以带着它自由的行走,甚至停下来喝杯咖啡。组件之于状态,就好比这辆卡车。

完全相同的逻辑适用于使用了其他钩子的钩子:任何能够触发重新渲染的情况,无论它是在钩子多深的地方发生,都将会在使用了那个最初钩子的组件中触发重新渲染。如果我将那个额外的状态提取到一个返回空值的钩子函数中,应用程序在每次窗口大小调整时,仍会重新进行渲染:

const useResizeDetector = () => {
  const [width, setWidth] = useState(0);
  useEffect(() => {
      const listener = () => {
          setWidth(window.innerWidth);
      };
      
      window.addEventListener('resize', listener);
      
      return () => window.removeEventListener('resize', listener);
  }, []);
  
  return null;
 }
 
 const useModalDialog = () => {
      // I don't even use it, just call it here
      useResizeDetector();
      
      // return is the same
      return ...
 }
 
 const App = () => {
      // this hook uses useResizeDetector underneath that triggers
     state update on resize
      // the entire App will re-render on every resize!
      const { isOpen, open, close } = useModalDialog();

      return // same return
 }

代码示例: advanced-react.com/examples/01…

所以,对这些代码要慎重。

为了修复这个应用,你应该把这些按钮、对话框和钩子提取到一个组件里:

const ButtonWithModalDialog = () => {
  const { isOpen, open, close } = useModalDialog();
  
  // render only Button and ModalDialog here
  return (
  <>
      <Button onClick={open}>Open dialog</Button>
      {isOpen ? <ModalDialog onClose={close} /> : null}
  </>
  );
 };

代码示例: advanced-react.com/examples/01…

所以,把状态放在哪里上很重要的。理性情况下,为了避免未来的性能问题,你应该把状态放在尽可能小和轻量的组件里。在下一章,我们将会看到另一种模式如何帮到我们。

知识概要

这仅仅是个开端。在接下来的章节中,我们将会深入探究所有这些内容是如何运作的。在此期间,以下是本章需要记住的一些要点:

  • 重新渲染是React更新的方式。没有重新渲染,我们的应用将失去响应性。
  • 状态更新是所有重新渲染的初始自由。
  • 如果一个组件被触发了重新渲染,那么嵌套在其内的所有组件也将会重新渲染。
  • 在常规的React重新渲染周期中(没有使用缓存),属性的变化并不起作用:组件在属性没有变化时,也能重新渲染。
  • 在大型应用中,我们可以使用状态下移技巧来避免不必要的重新渲染。
  • 一个钩子中的状态更新,会触发所有使用了该钩子的组件进行重新渲染,即便这个状态并没有被使用。
  • 在钩子函数使用其他钩子函数的情况下,该钩子函数链中的任何状态更新都将触发使用第一个钩子函数的组件的重新渲染。