【翻译】为什么自定义 React Hooks 会破坏应用性能

24 阅读11分钟

原文链接:www.developerway.com/posts/why-c…

作者:Nadia Makarevich

标题吓人吧?可悲的是这确实是事实:对于注重性能的应用程序,自定义 React 钩子若不谨慎编写和使用,极易成为最大的性能杀手。

本文不会讲解钩子的构建与使用方法——若你从未创建过钩子,React 官方文档已有相当完善的入门指南。今天我想重点探讨的是,在复杂应用中使用钩子会带来怎样的性能影响。

让我们基于自定义钩子构建模态对话框

本质上,钩子只是高级函数,允许开发者在无需创建新组件的情况下使用状态和上下文等功能。当需要在应用的不同部分共享同一块需要状态的逻辑时,它们就显得极其有用。钩子的出现开启了 React 开发的新纪元:从未像使用钩子时那样,我们的组件如此精简整洁,不同关注点的分离也变得如此轻松。

以实现模态对话框为例,借助自定义钩子,我们能在此打造出精妙的解决方案。

首先实现一个"基础"组件:该组件不持有状态,仅在接收到 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;
};

现在谈谈状态管理,即“打开对话框/关闭对话框”的逻辑。在“旧”方法中,我们通常会实现一个“智能”版本,它负责处理状态管理,并接受一个作为 props 的组件来触发对话框的打开。大致如下:

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包裹 modal 对话框内部的触发组件,从而干扰了其位置和可访问性。更不用说这个多余的div会导致 DOM 结构略微膨胀且杂乱。

现在见证奇迹时刻。若将"打开/关闭"逻辑提取为自定义钩子,在钩子内部渲染该组件,并通过钩子返回值暴露控制接口,我们就能兼得两全其美。在钩子中,我们将拥有一个"智能"对话框——它能自主管理状态,既不干扰触发器也不依赖触发器:

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个国家列表。但每次交互都会触发整个页面的重新渲染,这可能使其成为史上最慢的简单列表。这是代码演示链接,点击列表中不同国家即可体验(若使用最新Mac系统,请适当限制CPU性能以获得更直观感受)。

CPU限制操作指南:在Chrome开发者工具中打开"性能"标签页,点击右上角齿轮图标,将弹出包含性能限制选项的小面板。

接下来我将在此使用我们全新的完美模态对话框,观察实际效果。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>
  );
};

现在我需要在“切换主题”按钮附近添加一个按钮,点击后会弹出模态对话框,用于配置该页面的未来附加设置。幸运的是,实现起来再简单不过:在顶部添加 useModal 钩子,将按钮放置到指定位置,并向按钮传递 open 回调函数。Page 组件几乎无需改动,依然保持简洁:

你大概已经猜到结果了🙂 史上最慢的两个空div出现😱。查看codesandbox

你看,这里发生的情况是:我们的useModal钩子使用了状态。众所周知,状态变化是组件重新渲染的原因之一。这同样适用于钩子——如果钩子的状态发生变化,"宿主"组件就会重新渲染。这完全合乎逻辑。若仔细观察 useModal 钩子内部,会发现它本质上只是对 setState 的优雅封装,存在于 Dialog 组件之外。其实它和直接在 Page 组件中调用 setState 并无二致。

而钩子的巨大隐患正在于此:没错,它们确实让 API 变得优雅。但这种设计本质上将状态提升到了更高层级——而钩子的工作机制恰恰助长了这种行为。除非深入研究 useModal 的实现细节,或对钩子与重渲染机制有丰富经验,否则这种问题完全不易察觉。从 Page 组件的角度看,我甚至没有直接使用状态,所做的只是渲染 Dialog 组件并调用命令式 API 打开它。

在“旧时代”,状态会被封装在带trigger属性的略显丑陋的Model对话框中,点击按钮时Page组件保持不变。如今点击按钮会改变整个Page组件的状态,导致其重新渲染(这对本应用而言极其缓慢)。而对话框只能在React完成所有由此引发的重新渲染后出现,因此造成了显著延迟。

那么,我们能做些什么呢?我们可能没有时间和资源来修复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 组件不受影响。本质上,我们是在模拟“旧”世界中的状态模型,同时保留了基于钩子的优秀 API。请参阅包含解决方案的 codesandbox

useModal 钩子添加更多功能

让我们把关于钩子的性能讨论稍微深入一点吧🙂。假设你需要追踪模态框内容中的滚动事件,比如当用户滚动文本时触发分析事件来统计阅读情况。如果不想在BaseModal中引入"智能"功能,而选择在useModal钩子中实现呢?

实现起来相对简单。我们只需在该钩子中引入新状态来追踪滚动位置,在useEffect钩子中添加事件监听器,并将引用传递给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 组件中直接使用这个钩子。参见 codesandbox

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

好,让我们理性分析。我们已知在渲染函数内创建组件是错误做法,因为 React 会在每次重渲染时重新创建并挂载它们。同时我们知道钩子会随状态变化而改变。这意味着当引入滚动状态后,每次滚动操作都会改变状态,导致钩子重新渲染,进而触发 Dialog 组件的重新创建。这与在render函数内创建组件的问题如出一辙,解决方案也完全相同:我们需要将该组件提取到钩子外部,或者直接使用缓存机制。

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

焦点行为已修复,但这里还有另一个问题:每次滚动时,缓慢的Page组件都会重新渲染!由于对话框内容仅为文本,这个问题不太容易察觉。例如,尝试将CPU负载降低6倍,滚动页面后立即选中对话框中的文本——浏览器甚至不会允许你这样做,因为它正忙于重新渲染底层的Page组件!请参阅codesandbox演示。连续滚动几次后,你的笔记本电脑可能会因100%的CPU负载试图飞向月球😅

没错,正式发布前必须修复这个问题。让我们重新审视组件代码,特别是这部分:

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

每次重新渲染时,我们都会返回一个新的对象。由于现在每次滚动都会重新渲染我们的钩子,这意味着该对象也会在每次滚动时发生变化。但我们并未在此处使用滚动状态,它完全是 useModal 钩子的内部机制。难道仅仅缓存该对象就能解决问题吗?

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

你知道最棒(或最可怕)的部分是什么吗?它根本没发生!😱 看看这个codesandbox

这又暴露了钩子机制的另一个重大性能隐患。事实证明,钩子中的状态变更是否"内部"并不重要——无论是否影响返回值,钩子中的每次状态变更都会触发"宿主"组件的重新渲染

当然,链式钩子也是同样的道理:若某个钩子的状态改变,其"宿主"钩子也会随之改变,这种变化会沿着整个钩子链向上传播,直到触发"宿主"组件重新渲染(进而引发下游组件的连锁重渲染),期间应用的任何备忘机制都无济于事

将“滚动”功能提取为钩子毫无意义,低效的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 钩子,并将其放置在不会引发连续重渲染的位置。例如可以引入 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演示

今天的讲解到此结束!希望本文能让你充分理解自定义钩子的原理,掌握如何编写和使用它们,同时确保应用程序性能不受影响。临别前让我们重温高效钩子的核心规则:

  • 钩子中的每次状态变更都会触发其"宿主"组件重新渲染,无论该状态是否被作为钩子值返回并缓存
  • 链式钩子同样适用:钩子中的每次状态变更都会逐级影响所有"父级"钩子,直至触达"宿主"组件,进而引发重新渲染

在编写或使用自定义钩子时需注意以下事项:

  • 使用自定义钩子时,请确保该钩子封装的状态不会在组件化方案中本不应使用的层级被调用。必要时将其"下移"至更小的组件
  • 切勿在钩子中实现"独立"状态,也勿将钩子与独立状态配合使用
  • 使用自定义钩子时,确保其未执行任何未暴露于返回值的独立状态操作
  • 使用自定义钩子时,确保其调用的所有钩子均遵循上述规则

祝您安全无忧,应用从此疾如闪电!✌🏼