为什么自定义 react hook 会破坏你的应用性能?

389 阅读13分钟

本文源于翻译:www.developerway.com/posts/why-c…

译者注:本文作者通过Dialog对话组件举例,解释了为什么看似完美的hook提取方案会导致应用性能严重下降。其本质原因在于hook内部的state变化会导致“宿主”组件重新渲染,无论是否hook内部嵌套了几层,或者是对返回结果做了记忆化处理。强烈推荐阅读,这将对我们日常写custom hook` 有巨大帮助。

这个标题很可怕,不是吗?不幸的是,它是真的:对于对性能敏感的应用程序,如果没有非常小心编写和使用,自定义的React hooks很可能会成为最大的性能杀手。

我不打算在这里解释如何构建和使用React hooks,如果您以前从未构建过React hooks,请参阅对此有很好介绍的React官方文档。今天我想重点关注的是hooks在复杂应用程序中的性能影响。

让我们使用custom hooks构建一个模态对话框

本质上讲,hooks只是允许开发人员在不创建新组件的情况下使用状态和上下文等功能的高级函数。当您需要在应用程序的不同部分共享相同的依赖状态的逻辑时,它们非常有用。有了hook,React开发迎来了一个新时代:以前我们的组件从未像使用hooks那样简洁整洁,分离不同关注点也从未像使用hooks那样容易实现。

让我们以实现模态对话框为例。通过自定义钩子,我们可以在这里创建一个美妙的组件。

首先,让我们实现一个“基础”组件,它没有任何状态,但是当提供isOpenprop时会渲染对话框,并在点击对话框下方的遮罩时触发onClose回调。

type ModalProps = {
  isOpen: boolean;
  onClosed: () => void;
};

export const ModalBase = ({ isOpen, onClosed }: ModalProps) => {
  return isOpen ? (
    <>
      <div css={modalBlanketCss} onClick={onClosed} />
      <div css={modalBodyCss}>模态对话框内容</div>
    </>
  ) : null;
};

现在加上状态管理,即“打开对话框/关闭对话框”的逻辑部分。在“旧”方法中,我们通常会实现一个“智能”版本,该版本处理状态管理,并将一个组件作为触发打开对话框的prop。像这样:

export const ModalDialog = ({ trigger }) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <div onClick={() => setIsOpen(true)}>{trigger}</div>
      <ModalBase isOpen={isOpen} onClosed={() => setIsOpen(false)} />
    </>
  );
};

然后像这样使用它:

<ModalDialog trigger={<button>Click me</button>} />

这不是一个特别好的解决方案,我们需要通过div包裹trigger组件,使其插入模态对话框组件中,这将导致trigger组件的点击区域和渲染位置受到外层组件的影响。更不用说这个不必要的div会导致DOM稍微变大和混乱。

现在看看神奇的地方。如果我们将“打开/关闭”逻辑提取到一个自定义钩子中,在钩子中渲染该组件,并将控制它的API作为钩子的返回值暴露出来,我们就可以兼顾两者的优点。在钩子中,我们将有只处理自身状态的“智能”对话框,且不会干扰trigger组件:

export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);

  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);
  const Dialog = () => <ModalBase onClosed={close} isOpen={isOpen} />;

  return { isOpen, Dialog, open, close };
};

在使用者方面,代码量会很小,但是可以完全独立控制触发对话框的组件:

译者注:这里指上文中的trigger 组件可以独立渲染和使用,而不必受到Dialog组件的影响。

const ConsumerComponent = () => {
  const { Dialog, open } = useModal();

  return (
    <>
      <button onClick={open}>Click me</button>
      <Dialog />
    </>
  );
};

如果这不是完美的话,我不知道什么是! 😍 在codesandbox中查看这个美丽的例子。只是不要急着立即在您的应用程序中使用它,先看看它的不足之处 😅。

性能影响

在我之前的一篇文章中,我详细介绍了导致性能不佳的各种模式,我实现了一个“慢”应用程序:只是在一个在页面中渲染的未经过优化的250个国家名称的简单列表。但是,那里的每个交互都会导致整个页面重新渲染,这使得它可能是有史以来最慢的简单列表。这里是codesandbox,在列表中点击不同的国家,感受下我的意思(如果您使用的是最新的Mac,请稍微降低CPU性能以获得更好的印象)。

如何降低CPU性能:在Chrome开发者工具中打开“性能”选项卡,然后点击右上角的“齿轮”图标-它将打开一个带有限制选项的小面板。

现在我将在这里使用我们新的完美模态对话框,看看会发生什么。主页面组件的代码相对简单,如下所示:

export const Page = ({ countries }: { countries: Country[] }) => {
  const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
  const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
  const [mode, setMode] = useState<Mode>('light');

  return (
    <ThemeProvider value={{ mode }}>
      <h1>Country settings</h1>
      <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
      <div className="content">
        <CountriesList countries={countries} onCountryChanged={(c) => setSelectedCountry(c)} savedCountry={savedCountry} />
        <SelectedCountry country={selectedCountry} onCountrySaved={() => setSavedCountry(selectedCountry)} />
      </div>
    </ThemeProvider>
  );
};

现在,我需要一个按钮放在“切换主题”按钮附近,点击该按钮会打开一个模态对话框,显示该页面的一些未来的额外设置。幸运的是,现在这变得非常简单:在顶部添加useModal钩子,在需要的位置添加按钮,并将open回调传递给按钮。Page组件几乎没有改变,仍然非常简单: image.png 你可能已经猜到结果了 🙂 :这是有史以来最慢的显示两个空div的方式。这里是codesandbox

你知道的,这里发生了什么,是我们的useModal钩子使用了state。而且正如我们所知,状态的改变是导致组件重新渲染的原因之一。这同样适用于hook-如果hook的状态发生变化,那么"host"组件将重新渲染。这完全合乎逻辑。如果我们仔细观察useModal钩子的内部,我们会发现它只是在setState周围创建了一个不错的抽象层,它存在于Dialog组件之外。但本质上,这与在Page组件中直接调用setState没有任何区别。

译者注:作者这里主要想强调hooksstate的更新也会导致使用它的组件触发重新渲染。原则上,与父组件无关的state都应该最小化到子组件中,以避免无用的重新渲染,而我们使用hooks优化逻辑时,往往会忽略了这个原则,而这个原则又是极其重要的。

这就是hooks的巨大危险所在:是的,它们帮助我们创建了非常好的API。但结果是,hooks的方式实际上鼓励我们将状态从原本应该存在的位置提升出来。除非你深入研究useModal的实现或者对钩子和重新渲染有丰富的经验,否则这一点很容易被忽略。在Page组件中,我甚至没有直接使用状态,从它的角度来看,我只是渲染了一个Dialog组件并调用了一个命令式的API来打开它。

在“旧世界”中,状态将被封装在稍微丑陋的Modal对话框中,其中包含一个trigger prop,当点击按钮时,Page组件将保持不变。现在,点击按钮会改变整个Page组件的状态,这会导致它重新渲染(对于这个应用程序来说非常慢)。而且只有在React完成所有它所引起的重新渲染后,对话框才会出现,因此会出现长时间的延迟。 image.png 那么,我们该怎么办呢?我们可能没有时间和资源来修复Page组件的底层性能问题,因为通常情况下这需要对“真实”应用程序进行调整。但至少我们可以确保新功能不会增加性能问题,并且本身运行速度很快。我们在这里所要做的就是将模态框的状态“下移”,远离慢速的Page组件:

const SettingsButton = () => {
  const { Dialog, open } = useModal();

  return (
    <>
      <button onClick={open}>Open settings</button>
      <Dialog />
    </>
  );
};

然后在Page组件中只渲染SettingsButton

export const Page = ({ countries }: { countries: Country[] }) => {
  // ...原来组件的一些状态
  return (
    <ThemeProvider value={{ mode }}>
      // ...一些原有的逻辑
      <SettingsButton />
      // ...一些原有的逻辑
    </ThemeProvider>
  );
};

现在,当按钮被点击时,只有SettingsButton组件会重新渲染,而慢速的Page组件不受影响。本质上,我们在保持优雅的基于hooks的API的同时,模拟了在“旧”世界中的状态模型。在这里可以查看带有解决方案的codesandbox

image.png

让我们给useModal hook添加更多功能

让我们稍微加深一下关于性能的讨论吧。想象一下,例如,您需要跟踪模态对话框内容中的滚动事件。也许您想在用户滚动文本时发送一些分析事件,以跟踪阅读情况。如果我不想在BaseModal中引入“智能”功能,而是在useModal hook中实现它,会发生什么呢?

这是相对容易实现的。我们可以在useModal hook中引入一个新的状态来跟踪滚动位置,在 useEffect hook中添加事件侦听器,并传递引用(ref)给BaseModal来获取要附加事件侦听器的内容元素。代码示例如下:

export const ModalBase = React.forwardRef(({ isOpen, onClosed }: ModalProps, ref: RefObject<any>) => {
  return isOpen ? (
    <>
      <div css={modalBlanketCss} onClick={onClosed} />
      <div css={modalBodyCss} ref={ref}>
        // 在这里添加一些内容
      </div>
    </>
  ) : null;
});


export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef<HTMLElement>(null);
  const [scroll, setScroll] = useState(0);

  // 和之前一样的内容

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleScroll = () => {
      setScroll(element?.scrollTop || 0);
    };

    element.addEventListener('scroll', handleScroll);
    return () => {
      element.removeEventListener('scroll', handleScroll);
    };
  });

  const Dialog = () => <ModalBase onClosed={close} isOpen={isOpen} ref={ref} />;

  return {
    isOpen,
    Dialog,
    open,
    close,
  };
};

现在我们可以对这个状态做任何操作了。现在让我们假装之前的性能问题不是那么大的问题,并且再次直接在性能较差的Page组件中使用这个hook请参见codesandbox

但是,滚动甚至无法正常工作!😱 每次我尝试滚动对话框内容时,它都会重置到顶部!

好的,让我们逻辑思考一下。我们已经知道在渲染函数内创建组件是有问题的,因为 React 会在每次重新渲染时重新创建和挂载它们。而且我们也知道hook在每次状态变化时都会发生变化。这意味着现在,当我们引入滚动状态时,每次滚动变化时我们都在改变状态,这导致了hook的重新渲染,进而导致 Dialog组件被重新创建。这与在渲染函数内创建组件时的问题完全相同,因此我们需要将此组件提取到 hook外部,或者只是对其进行记忆化。

const Dialog = useMemo(() => {
  return () => <ModalBase onClosed={close} isOpen={isOpen} ref={ref} />;
}, [isOpen]);

焦点行为已经修复,但这里还有另一个问题:每次滚动时,慢速的Page组件都会重新渲染!由于对话框内容只是文本,这个问题可能有点难以察觉。试试将CPU减少6倍,然后滚动,并立即在对话框中选中文本。浏览器甚至无法完成操作,因为它太忙于重新渲染底层的Page组件!请参见codesandbox。在几次滚动之后,你的笔记本电脑可能会因为100%的CPU负载而试图飞到月球上 😅。

是的,在发布到生产环境之前,我们绝对需要解决这个问题。让我们再次查看我们的组件,特别是这部分代码:

return {
  isOpen,
  Dialog,
  open,
  close,
};

我们在每次重新渲染时返回一个新的对象,由于我们现在在每次滚动时重新渲染我们的hook,这意味着该对象也会在每次滚动时发生变化。但是我们在这里并没有使用滚动状态,它完全是useModal hook的内部状态。那么,只需对该对象进行记忆化处理就能解决问题吗?

return useMemo(
  () => ({
    isOpen,
    Dialog,
    open,
    close,
  }),
  [isOpen, Dialog],
);

你知道最好(或最可怕)的一点是什么吗?它没有解决问题!😱 在 Codesandbox 上可以看到。

这是与 hooks 相关的另一个重大性能问题。事实证明,无论 hooks 中的状态改变是否是“内部”的,都不重要。每次 hooks 中的状态改变,无论是否影响其返回值,都会导致“宿主”组件重新渲染

当然,使用链式 hooks 也是同样的情况:如果一个 hook 的状态改变,它所在的“宿主” hook 也会发生改变,这个改变会沿着整个 hooks 链向上传播,直到到达“宿主”组件并重新渲染它(这将引发另一系列的重新渲染,但这次是向下重新渲染),而不管其中是否应用了任何记忆化处理

译者注:作者这里想表达的是不管hooks嵌套了几层,或者是对返回值做个记忆化,只要有其中一个hook的状态发生改变,都会导致引用最外层hook的“宿主”组件发生重新渲染。

将“滚动”功能提取为一个 hook 并不会产生任何区别,缓慢的 Page 组件将重新渲染。😔

const useScroll = (ref: RefObject) => {
  const [scroll, setScroll] = useState(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleScroll = () => {
      setScroll(element?.scrollTop || 0);
    };

    element.addEventListener('scroll', handleScroll);
    return () => {
      element.removeEventListener('scroll', handleScroll);
    };
  });

  return scroll;
};

export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef<HTMLElement>(null);
  const scroll = useScroll(ref);

  const open = useCallback(() => {
    setIsOpen(true);
  }, []);

  const close = useCallback(() => {
    setIsOpen(false);
  }, []);

  const Dialog = useMemo(() => {
    return () => <ModalBase onClosed={close} isOpen={isOpen} ref={ref} />;
  }, [isOpen, close]);

  return useMemo(
    () => ({
      isOpen,
      Dialog,
      open,
      close,
    }),
    [isOpen, Dialog, open, close],
  );
};

请看codesandbox链接。

如何解决这个问题?唯一需要做的就是将滚动跟踪的hook移出useModal钩子,并在不会导致重新渲染链的地方使用它。可以使用ModalBaseWithAnalytics组件来实现:

const ModalBaseWithAnalytics = (props: ModalProps) => {
  const ref = useRef<HTMLElement>(null);
  const scroll = useScroll(ref);

  console.log(scroll);

  return <ModalBase {...props} ref={ref} />;
};

然后在useModal钩子中使用它,而不是使用ModalBase组件:

export const useModal = () => {
  // ...一些和原有`useModal hook`相同的逻辑

  const Dialog = useMemo(() => {
    return () => <ModalBaseWithAnalytics onClosed={close} isOpen={isOpen} ref={ref} />;
  }, [isOpen, close]);

  return useMemo(
    () => ({
      isOpen,
      Dialog,
      open,
      close,
    }),
    [isOpen, Dialog, open, close],
  );
};

译者注:避免hook触发组件重新的关键在于:将state提取到独立的组件中,并对组件渲染的结果进行记忆化处理。这个与使用children属性传递子组件是同一原理,只要属性值不发生变化,不管属性值所对应的内部逻辑如何变化,都不会引起使用此属性的组件发生重新渲染。

现在,由于滚动而引起的状态变化将仅限于ModalBaseWithAnalytics组件,并不会影响缓慢的Page组件。请参阅codesandbox以查看效果。 这就是今天的全部内容!希望这篇文章让你对自定义钩子有足够的警觉,并帮助你在编写和使用自定义钩子时不会影响应用程序的性能。在结束前,让我们回顾一下编写高性能钩子的规则

  • 每次钩子状态发生变化时,都会导致其"宿主"组件重新渲染,无论这个状态是否在钩子返回值中被使用和记忆化
  • 与此类似,每次钩子状态发生变化时,所有的"父级"钩子也会发生变化,直到达到"宿主"组件,它将再次触发重新渲染

在编写或使用自定义钩子时需要注意以下几点:

  • 当使用自定义钩子时,确保钩子封装的状态在组件的层级上不会被无需使用。如果需要,将其"下移"到较小的组件中
  • 不要在钩子中实现"独立"状态或使用具有独立状态的钩子
  • 当使用自定义钩子时,确保它不执行一些在返回值中未公开的独立状态操作
  • 当使用自定义钩子时,确保它所使用的所有钩子也遵循上述规则

祝你的应用程序安全,并且从现在开始变得极快!✌🏼