[译] 为什么自定义 hook 有可能会毁掉你应用的性能

Posted on:  at 

原文地址:https://www.developerway.com/posts/why-custom-react-hooks-could-destroy-your-app-performance
原文作者:NADIA MAKAREVICH
原文发布于:2022 年 01 月 24 日

标题很吓人,不是吗?可悲的是,事实确实如此:对于对性能敏感的应用程序来说,如果不小心编写和使用自定义 React hook,它很容易就会成为最大的性能杀手。

我不会在这里解释如何构建和使用 hook,如果您以前从未自己实现过 hook,React 文档对此有很好的介绍。我今天要重点讨论的是 hook 对复杂应用程序性能的影响。

让我们使用自定义 hook 实现一个弹窗

从本质上讲,hook 只是一种高阶函数,允许开发人员在不创建新组件的情况下使用状态和上下文等功能。当您需要在应用程序的不同部分之间共享同一段需要状态的逻辑时,自定义 hook 就显得非常有用。有了它,React 开发进入了一个新时代:有了 hook 之后,我们的组件变得纤细整洁、不同关注点的分离也更加容易的实现。

例如,让我们来实现一个弹窗。看看使用传统的方式和自定义 hook 的方式,哪种方式比较优雅。

首先,我们来实现一个 ModalBase 组件,它不包含状态,只是在 isOpen 属性为真时渲染对话框,并在点击弹窗背后的遮罩时触发 onClose 回调。

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

export const ModalBase = ({
  isOpen,
  onClosed,
}: ModalProps) => {
  return isOpen ? (
    <>
      <div css={modalBlanketCss} onClick={onClosed} />
      <div css={modalBodyCss}>Modal dialog content</div>
    </>
  ) : null;
};

接着是状态管理,即“打开弹窗/关闭弹窗”逻辑。在“传统”方法中,我们通常会实现一个高阶组件,它处理状态管理,并接受一个触发器组件属性来触发弹窗的打开。类似这样:

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 中,从而将触发器组件的位置和可访问性耦合在一起。更不用说的是,这个不必要的 div 会导致 DOM 更大、更乱。

接下来是使用自定义 hook 的方式。如果我们将“打开/关闭”逻辑提取到自定义 hook 中,在 hook 中渲染该组件,并作为 hook 的返回值导出,导出中的组件和 API 分别控制何处安置组件和何处触发打开弹窗函数,我们就能获得两全其美的效果。在 hook 中,同样实现了“高阶”弹窗,它可以处理自己的状态,但没有和触发器耦合在一块,也不需要关注如何实现触发器:

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 };
};

而在消费者侧,我们只需少量代码,就能实现触发器、显示弹窗:

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

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

如果这还不算完美,那我就不知道什么才算完美了!😍详见 codesandbox。只是不要急着马上在你的应用程序中使用它,除非你读完了它的性能阴暗面 😅。

性能影响

上一篇文章中,我详细介绍了导致性能低下的各种模式,并实现了一个“慢”应用程序:只是在页面上呈现一个未经优化的简单列表,其中包含约 250 个国家。但每一次交互都会导致整个页面重新渲染,因此它可能是有史以来最慢的简单列表。详见 codesandbox,点击列表中的不同国家就能明白我的意思(如果你使用的是最新的 Mac,可以稍微调低一下 CPU,以获得更直观的体验)。

怎样调低你的 CPU:在 Chrome 开发者工具的 Performance 页签,点击顶部右侧的“齿轮”展开菜单,找到里面的“CPU throttling”进行配置。

在这个慢应用中使用我们新的、完美模式弹窗 hook,看看会发生什么。Page 组件的代码相对简单,如下所示:

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>
  );
};

现在我需要在“Toggle theme”按钮附近添加一个按钮,打开一个弹窗,为该页面添加一些未来的附加设置。幸运的是,有了自定义 hook 之后再简单不过了:在代码顶部引入 useModal hook,在需要的地方添加按钮,并将打开弹窗函数传递给按钮。页面组件几乎没有变化,仍然非常简单:
image.png

您可能已经猜到结果了🙂。引入的这 2 个几乎为空的 divs,同样导致了超慢的整个列表重渲染的现象😱。请参见 codesandbox

我们来看看发生了什么,我们的 useModal 挂钩使用了状态。我们知道,状态变化是组件重新渲染的原因之一。这同样适用于 hook——如果 hook 里面的状态发生变化,“宿主”组件就会重新渲染。这完全说得通。如果我们仔细观察一下 useModal hook,就会发现它可以视为对一系列 setState 操作的一个抽象封装,而且它存在于 Dialog 组件之外。从本质上讲,它与在 Page 组件中直接调用 setState 没有什么区别。

这就是 hook 的最大危险所在:是的,hook 帮助我们把 API 整理得非常漂亮。但从使用之后的结果代码、自定义 hook 所鼓励的使用方式,实质上是将状态从他本来该在的位置上往上提升了。除非你进入 useModal 的实现内部,或者对 hook 和重渲染机制有丰富的经验。否则你完全不会注意到这一点。我只会觉得我在 Page 组件中渲染了一个弹窗组件并调用了一个命令式 API 来打开它而已,不会察觉到我引入了新的、有可能导致组件重渲染的状态。

在“传统”方法中,状态被封装在通过属性传入 trigger 的略显简陋的 Modal 组件中。当触发器按钮被点击的时候,Page 不会触发重渲染。而现在点击触发器按钮时会改变 Page 组件的状态,从而触发重渲染(上述示例代码这个重渲染代价尤为缓慢)。只有重渲染完成后,弹窗才会出现,因此出现了很大的延迟。

image.png

那么,我们能做些什么呢?假设我们没有时间和资源来修复 Page 组件的底层性能(假设它糟糕的重渲染性能是历史遗留原因),因为实际的应用程序开发更多会出现这种情景。我们不敢动已有的代码,但至少我们可以确保新功能的添加不会增加性能问题,而且本身速度很快。在这里,我们只需将状态“向下”移动,远离缓慢的 Page 组件即可:

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

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

然后在 Page 中渲染 SettingButton 组件:

export const Page = ({
  countries,
}: {
  countries: Country[];
}) => {
  // same as original page state
  return (
    <ThemeProvider value={{ mode }}>
      // stays the same
      <SettingsButton />
      // stays the same
    </ThemeProvider>
  );
};

现在,当点击按钮时,只有 SettingsButton 组件会重新渲染,而缓慢的 Page 组件则不受影响。从本质上讲,我们是在模仿“传统”方法中的状态模型,同时保留了基于 hook 的 API。请参见解决方案的 codesandbox

image.png

useModal 增加更多功能

让我们对 hook 性能的讨论更加深入一点 🙂。举例来说,设想一下您需要跟踪弹窗内容中的滚动事件。也许您想在用户滚动文本时发送一些分析事件,以跟踪阅读情况。如果我不想在 ModalBase 中实现这个功能,而是在 useModal hook 中实现,会发生什么?

这一点相对容易实现。我们只需引入一个新的状态来跟踪滚动位置,在 useEffect 中添加事件监听器,并将 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}>
          // add a lot of content here
        </div>
      </>
    ) : null;
  },
);

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

  // same as before

  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 组件的性能问题并非难以解决,再次把 useModal hook 直接在 Page 组件中使用。代码详见 codesandbox

滚动甚至无法正常工作!😱 每次我试图滚动弹窗里面的内容时,它都会重置到滚动条顶部!

好吧,让我们从逻辑上思考一下。根据我们已经知道的,在 render 函数里面创建组件是致命的。因为 React 会在每次重渲染时重新创建并挂载这些组件。我们还知道,hook 会随着状态的改变而改变。这就意味着,当我们引入滚动状态时,每次滚动都会改变状态,从而导致 hook 重新渲染,导致 Dialog 组件重新创建。这与在 render 函数内部创建组件的问题一样,解决方法也完全相同:我们需要将组件提取到 hook 外部,或者直接将其记忆化。

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

滚动无效的问题已经解决,但是还有另一个问题:每次滚动时,缓慢的 Page 组件都会重新渲染。由于弹窗的内容都是文本,因此很难注意到这个问题。你可以将 CPU 降低 6 倍。滚动一次,然后尝试选中弹窗中的部分文字。你会发现甚至无法选中任何文字,就像是浏览器卡死了一样。这是因为它正在忙于处理一连串的重渲染!示例请见 codesandbox

毫无疑问,在将其投入生产之前,我们肯定需要解决这个问题。让我们再看看我们的组件代码,尤其是这一部分:

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

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

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

你知道最棒(或最可怕)的是什么吗?它没有!😱详见 codesandbox

这也是 hook 在性能方面的另一个巨大缺陷。事实证明,在 hook “之内”或“之外”发生的状态变化并不重要。hook 中的每次状态变化,无论是否影响其返回值,都会导致挂载它的组件重新渲染。

当然,嵌套使用时也是如此:如果一个 hook 的状态发生变化,会令挂载了它的另一个 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

如何解决?那么,唯一能做的就是将滚动跟踪移到 useModal hook 之外,并将其用在不会导致连锁重新渲染的地方。例如,可以引入 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 = () => {
  // the rest is the same as in the original 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],
  );
};

这样改造之后,滚动跟踪导致的状态变化就被局限在 ModalBaseWithAnalytics 组件中,不会影响到缓慢的 Page 组件了,详见 codesandbox

今天就写到这里!希望这篇文章能~~吓到你~~让你对自定义 hook 如何在不影响应用程序性能的情况下编写和使用。在离开之前,让我们回顾一下 hook 的性能规则:

  • hook 中的每一次状态变化都会导致其“宿主”组件重新渲染,无论 hook 是否返回了该状态,也无论该状态是否被记忆化。
  • hook 的调用链也是一样,hook 的每次状态变化都会导致所有“父” hook 发生变化,直到到达“宿主”组件,而“宿主”组件又会触发重新渲染。

以及在编写或使用自定义 hook 时需要注意的事项:

  • 在使用自定义 hook 时,确保该 hook 封装的状态要满足限制:这些状态都是“宿主”组件有用到的。否则的话,如有必要,可将其“下移”到较小的组件中。
  • 永远不在在 hook 内定义和使用自循环的状态。或者不要使用有使用了自循环状态的 hook。
  • 当使用自定义 hook 时,确保它不会执行一些独立的状态操作,即这些操作不会在其返回值中暴露出来。
  • 在使用自定义 hook 时,请确保该 hook 使用的所有 hook 也遵循上述规则。

注意安全,祝愿您的应用程序从现在起快速运行!✌🏼