[译] 为什么自定义 hook 有可能会毁掉你应用的性能
原文地址: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,在需要的地方添加按钮,并将打开弹窗函数传递给按钮。页面组件几乎没有变化,仍然非常简单:
您可能已经猜到结果了🙂。引入的这 2 个几乎为空的 divs,同样导致了超慢的整个列表重渲染的现象😱。请参见 codesandbox。
我们来看看发生了什么,我们的 useModal
挂钩使用了状态。我们知道,状态变化是组件重新渲染的原因之一。这同样适用于 hook——如果 hook 里面的状态发生变化,“宿主”组件就会重新渲染。这完全说得通。如果我们仔细观察一下 useModal
hook,就会发现它可以视为对一系列 setState
操作的一个抽象封装,而且它存在于 Dialog
组件之外。从本质上讲,它与在 Page
组件中直接调用 setState
没有什么区别。
这就是 hook 的最大危险所在:是的,hook 帮助我们把 API 整理得非常漂亮。但从使用之后的结果代码、自定义 hook 所鼓励的使用方式,实质上是将状态从他本来该在的位置上往上提升了。除非你进入 useModal
的实现内部,或者对 hook 和重渲染机制有丰富的经验。否则你完全不会注意到这一点。我只会觉得我在 Page
组件中渲染了一个弹窗组件并调用了一个命令式 API 来打开它而已,不会察觉到我引入了新的、有可能导致组件重渲染的状态。
在“传统”方法中,状态被封装在通过属性传入 trigger
的略显简陋的 Modal
组件中。当触发器按钮被点击的时候,Page
不会触发重渲染。而现在点击触发器按钮时会改变 Page
组件的状态,从而触发重渲染(上述示例代码这个重渲染代价尤为缓慢)。只有重渲染完成后,弹窗才会出现,因此出现了很大的延迟。
那么,我们能做些什么呢?假设我们没有时间和资源来修复 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。
为 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 也遵循上述规则。
注意安全,祝愿您的应用程序从现在起快速运行!✌🏼