前言
React Hooks 的引入彻底改变了我们编写 React 组件的方式,自从 React 16.8 版本发布以来,Hooks 已成为 React 社区的热门话题,它为我们提供了一种全新的方式来编写可复用、可组合和易于测试的组件逻辑。然而,尽管 Hooks 带来了许多便利和灵活性,但在实践中,我们也发现了一些潜在的性能问题。本文将探讨自定义 React Hooks 对性能的影响,并分享一些简单而有效的优化技巧,帮助你轻松解决这些问题。
React Hooks 的设计动机
React Hooks 这个东西比较特别,它是 React 团队在真实的 React 组件开发实践中,逐渐认知到的一个改进点,这背后其实涉及对类组件和函数组件两种组件形式的思考和侧重。因此,我们首先得知道,什么是类组件、什么是函数组件,并完成对这两种组件形式的辨析。
早期 React 组件开发模式
早期的 React 组件开发模式主要分为两种,一种是类组件,一种是函数组件,下面先来介绍这两种组件开发模式。
类组件
所谓类组件,就是基于 ES6 Class 这种写法,通过继承 React.Component 得来的 React 组件。以下是一个典型的类组件:
js
class DemoComponent extends React.Component {
// 初始化类组件的 state
state = {
content: ""
};
// 编写生命周期方法 didMount
componentDidMount() {
// ...
}
// 编写自定义的实例方法
changeContent = value => {
// 更新 state
this.setState({
content: value
});
};
// 编写生命周期方法 render
render() {
return (
<div className="demoComponent">
<p>{this.state.content}</p>
<button onClick={() => this.changeContent('newContent')}>点我修改</button>
</div>
);
}
}
函数组件
函数组件顾名思义,就是以函数的形态存在的 React 组件,由于早期并没有 React Hooks 的概念,函数组件内部无法定义和维护 state,它只能接受参数并渲染,因此它还有一个别名叫无状态组件。以下是一个典型的函数组件:
js
function DemoComponent(props) {
const {content} = props
return (
<div className="demoComponent">
<p>{content}</p>
</div>
);
}
函数组件与类组件的不同
基于上面的两个 Demo,从形态上对两种组件做区分,它们之间肉眼可见的区别就包括但不限于:
- 类组件需要继承 class,函数组件不需要;
- 类组件可以访问生命周期方法,函数组件不能;
- 类组件中可以获取到实例化后的 this,并基于这个 this 做各种各样的事情,而函数组件不可以;
- 类组件中可以定义并维护 state(状态),而函数组件不可以;
- ...
从这里可以看出,在 React Hooks 出现之前,类组件的能力明显要强于函数组件,但这并不能掩盖掉函数组件的优势,相比于类组件,函数组件肉眼可见的特质自然包括轻量、灵活、易于组织和维护、较低的学习成本等。
为什么类组件逐渐成为历史?
太重了
类组件是面向对象编程思想的一种表征,面向对象是一个老生常谈的概念了,当我们应用面向对象的时候,总是会有意或无意地做这样两件事情。
- 封装:将一类属性和方法聚拢到一个 Class 里去。
- 继承:新的 Class 可以通过继承现有 Class,实现对某一类属性和方法的复用。
React 类组件也不例外。我们再次审视一下这个典型的类组件 Case:
js
class DemoComponent extends React.Component {
// 初始化类组件的 state
state = {
content: ""
};
// 编写生命周期方法 didMount
componentDidMount() {
// ...
}
// 编写生命周期方法 didUpdate
componentDidUpdate() {
// ...
}
// 编写自定义的实例方法
changeContent = value => {
// 更新 state
this.setState({
content: value
});
};
// 编写生命周期方法 render
render() {
return (
<div className="demoComponent">
<p>{this.state.content}</p>
<button onClick={() => this.changeContent('newContent')}>点我修改</button>
</div>
);
}
}
不难看出,React 类组件内部预置了相当多的现成的东西等着我们去调度/定制,state 和生命周期就是这些现成东西中的典型。
类组件给到开发者的东西是足够多的,但多未必就好,React 类组件提供了多少东西,你就需要学多少东西。假如背不住生命周期,你的组件逻辑顺序大概率会变成一团糟。大而全的背后,是不可忽视的学习成本。
状态难以复用
在 React 类组件中,开发者编写的逻辑和状态都是和组件强绑定在一起的,这就使得组件内部的逻辑难以拆分和复用,随之而来的就是需要学习更复杂的设计模式,比如说高阶组件,用更高的学习成本来交换一点点编码的灵活度。
函数组件更加契合 React 框架的设计理念
最能体现 React 设计思想的就是他公式:
React 组件本身的定位就是函数,一个吃进数据、吐出 UI 的函数 。作为开发者,我们编写的是声明式的代码,而 React 框架的主要工作,就是及时地把声明式的代码转换为命令式的 DOM 操作,把数据层面的描述映射到用户可见的 UI 变化中去。这就意味着从原则上来讲,React 的数据应该总是紧紧地和渲染绑定在一起的 ,而类组件做不到这一点。
为什么类组件做不到?Dan 之前写过一篇文章,专门分析了类组件和函数组件的不同,下面将会使用文章中的一个例子来讲解:
js
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
handleClick = () => {
setTimeout(this.showMessage, 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
这个组件返回的是一个按钮,交互内容也很简单:点击按钮后,过 3s,界面上会弹出"Followed xxx"的文案。类似于我们在微博上点击关注某人之后弹出的"已关注"这样的提醒。
看起来好像没啥毛病,但是如果你在这个在线 Demo中尝试点击基于类组件形式编写的 ProfilePage 按钮后 3s 内把用户切换为 Sophie,你就会看到如下图所示的效果:
明明我们是在 Dan 的主页点击的关注,结果弹窗中却提示了"Followed Sophie"!
这个现象必然让许多人感到困惑:user 的内容是通过 props 下发的,props 作为不可变值,为什么会从 Dan 变成 Sophie 呢?
因为虽然 props 本身是不可变的,但 this 却是可变的,this 上的数据是可以被修改的,this.props 的调用每次都会获取最新的 props,而这正是 React 确保数据实时性的一个重要手段。
多数情况下,在 React 生命周期对执行顺序的调控下,this.props 和 this.state 的变化都能够和预期中的渲染动作保持一致。但在这个案例中,我们通过 setTimeout 将预期中的渲染推迟了 3s,打破了 this.props 和渲染动作之间的这种时机上的关联,进而导致渲染时捕获到的是一个错误的、修改后的 this.props。这就是问题的所在。
但如果我们把 ProfilePage 改造为一个像这样的函数组件:
js
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
事情就会大不一样。
props 会在 ProfilePage 函数执行的一瞬间就被捕获,而 props 本身又是一个不可变值,因此我们可以充分确保从现在开始,在任何时机下读取到的 props,都是最初捕获到的那个 props。当父组件传入新的 props 来尝试重新渲染 ProfilePage 时,本质上是基于新的 props 入参发起了一次全新的函数调用,并不会影响上一次调用对上一个 props 的捕获。这样一来,我们便确保了渲染结果确实能够符合预期。
函数组件的兴起
经过岁月的洗礼,React 团队显然也认识到了,函数组件是一个更加匹配其设计理念、也更有利于逻辑拆分与重用的组件表达形式,接下 React 团队开始用实际行动支持开发者编写函数式组件,于是 React Hooks 便应运而生。
React Hooks 的出现,就是为了帮助函数组件补齐这些(相对于类组件来说)缺失的能力。同时也支持开发者自定义 hooks,hooks
是允许开发人员在不创建新组件的情况下使用状态和上下文等功能的高级函数, 支持开发者在应用程序的不同部分之间共享需要状态的同一逻辑。
为什么自定义 Hooks 会影响性能?
案例
下面我们会通过 Modal 弹窗这个案例来讲解自定义 hooks 为什么会影响性能,首先让我们实现一个基础的 Modal 组件,他没有任何自己的状态,通过外部传入 isOpen 参数来控制组件内容的展示。
js
type ModalProps = {
isOpen: boolean;
onClosed: () => void;
};
export const ModalBase = ({ isOpen, onClosed }: ModalProps) => {
return isOpen ? (
<>
<div css={modalBlanketCss} onClick={onClosed} />
<div css={modalBodyCss}>模态对话框内容</div>
</>
) : null;
};
接下来需要实现业务层的 Modal 组件,该组件需要增加状态管理、打开弹窗的按钮等,通常还会接受一个外部组件用来触发打开对话框,像这样:
js
export const ModalDialog = ({ trigger }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div onClick={() => setIsOpen(true)}>{trigger}</div>
<ModalBase isOpen={isOpen} onClosed={() => setIsOpen(false)} />
</>
);
};
然后像这样去使用:
js
<ModalDialog trigger={<button>Click me</button>} />
这不是一个特别好的解决方案,我们需要通过div包裹trigger
组件,使其插入模态对话框组件中,这将导致trigger
组件的点击区域和渲染位置受到外层组件的影响。
如果我们换种思路,我们将组件打开或者关闭的逻辑提取到一个 hook 中, 并在 hook 中渲染 ModalBase 组件,最重将控制组件显隐的 API 作为 hook 的返回值暴露出来,我们就可以兼顾两者的优点。
js
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
的渲染控制权完全交给业务层,同时也能通过给业务层暴露 API 来控制弹窗组件的显隐。
js
const ConsumerComponent = () => {
const {Dialog, open} = useModal();
return (
<>
<button onClick={open}>Click me</button>
<Dialog />
</>
);
};
可在codesandbox中查看这个例子,但是不要急着立即在你的应用程序中使用它,先看看它的不足之处 。
性能影响
假如我们将这个弹窗组件与一个重型组件放在一起渲染,当我们点击按钮打开弹窗再点击关闭会发生什么?
js
const HeavyComponent = () => {
console.log("heavyComponent render");
return <div>heavyComponent</div>;
};
const Page = () => {
const {Dialog, open} = useModal();
return (
<div>
<button onClick={open}>Open Modal<button/>
<Dialog />
<HeavyComponent />
</div>
);
}
从图中可以看到,每次打开或者关闭弹窗都会导致 HeavyComponent 重新渲染,这是不必要的也是非常消耗性能的。究其原因就是我们在useModal
使用了state
。正如我们所知,状态的改变是导致组件重新渲染的原因之一, 这同样适用于hook
,如果hook
的状态发生变化,那么宿主组件将重新渲染,可在 codesandbox 查看。
如果我们仔细观察useModal
的内部,我们会发现它只是在setState
周围创建了一个不错的抽象层,它存在于Dialog
组件之外。但本质上,这与在Page
组件中直接调用setState
没有任何区别。
这就是hooks
的巨大危险所在,的确,hooks 能够帮助我们封装可复用的逻辑,并且使用起来也非常简便。但结果是,hooks
的方式实际上只是将状态从原本应该存在的位置提升出来。除非我们深入研究useModal
的实现或者对 hooks和重新渲染有丰富的经验,否则这一点很容易被忽略。在Page
组件中,我甚至没有直接使用状态,从它的角度来看,我只是渲染了一个Dialog
组件并调用了一个命令式的API来打开它。
在旧版本中,状态将被封装在稍微丑陋的Modal
对话框中,其中包含一个trigger
参数,当点击按钮时,Page
组件将保持不变。现在,点击按钮会改变整个Page
组件的状态,这会导致它重新渲染(对于这个应用程序来说非常慢)。而且只有在 React 完成所有它所引起的重新渲染后,对话框才会出现,因此会出现长时间的延迟。
对于开发者来说可能没有时间和资源来修复 HeavyCompoennt
组件的底层性能问题,因为通常情况下这需要对真实应用程序进行调整。为此我们换种思路来解决这个问题,我们只需要将 Dialog
组件的的状态下移 ,远离慢速的HeavyComponent
组件:
js
const SettingsButton = () => {
const { Dialog, open } = useModal();
return (
<>
<button onClick={open}>Open settings</button>
<Dialog />
</>
);
};
然后在Page
组件中只渲染SettingsButton
:
js
const Page = () => {
const {Dialog, open} = useModal();
return (
<div>
<SettingsButton />
<HeavyComponent />
</div>
);
}
现在当按钮被点击时,只有SettingsButton
组件会重新渲染,而慢速的HeavyCompoennt
组件不受影响。本质上,我们在保留hooks
优势的同时,也能够让组件保持优秀的性能。可在 codesandbox 查看。
案例升级
为了更加突显自定义 hooks 的性能问题,下面对弹窗的功能进行升级。想象一下,假如我们需要跟踪弹窗内容的滚动,然后发送一些分析事件,用来跟踪用户的阅读情况,下面我们在 useModal
中实现它:
js
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
};
};
现在可以对弹窗中的内容进行滚动,在 Codesandbox 上可以看到这个例子。
但是我们发现滚动甚至无法正常工作,每次滚动弹窗里面的内容,他都会重置到顶部。我们仔细思考一下逻辑,弹窗每次发生滚动时,都会更新 hooks 中的状态,状态一更新这个 hooks 的宿主组件就会重新执行,进而导致这个 hook
重新执行,而每次 hook
重新执行都会导致Dialog
组件被重新创建。因此我们需要将此组件提取到 hook
外部,或者只是对其进行记忆化。
js
const Dialog = useMemo(() => {
return () => <ModalBase onClosed={close} isOpen={isOpen} ref={ref} />;
}, [isOpen]);
内容滚动的问题已经解决了,但是还有另一个问题,每次滚动时都会导致 SettingsButton 组件重新渲染,如果之后需要往 SettingsButton 组件中增加一些其他功能组件,这将会导致这些功能组件进行不必要的重复渲染,下面我们来解决这个问题。
我们知道每次 useModal 执行后都会返回一个新的对象,由于我们现在在每次滚动时重新渲染我们的hook
,这意味着该对象也会在每次滚动时发生变化。但是我们在这里并没有使用滚动状态,它完全是useModal hook
的内部状态。那么,只需对该对象进行记忆化处理就能解决问题吗?
js
return useMemo(
() => ({
isOpen,
Dialog,
open,
close,
}),
[isOpen, Dialog],
);
但是这并没有解决问题,这一在 codesanbox 查看,事实证明,无论 hooks
中的状态改变是否是内部的,都不重要。每次 hooks
中的状态改变,无论是否影响其返回值,都会导致"宿主"组件重新渲染
当然,使用链式 hooks
也是同样的情况:如果一个 hook
的状态改变,它所在的宿主 hook
也会发生改变,这个改变会沿着整个 hooks
链向上传播,直到到达宿组件并重新渲染它。
为了解决这个问题,我们唯一需要做的就是将跟踪滚动的状态和逻辑移除 useModal,并在不会导致重新渲染链的地方使用它。可以使用ModalBaseWithAnalytics
组件来实现:
js
const ModalBaseWithAnalytics = (props: ModalProps) => {
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);
};
});
return <ModalBase {...props} ref={ref} />;
};
js
export const useModal = () => {
// ...一些和原有`useModal hook`相同的逻辑
const Dialog = useMemo(() => {
return () => <ModalBaseWithAnalytics onClosed={close} isOpen={isOpen} />;
}, [isOpen, close]);
return{
isOpen,
Dialog,
open,
close,
};
};
现在,由于滚动而引起的状态变化将仅限于ModalBaseWithAnalytics
组件,并不会影响缓慢的Page
组件,请参阅codesandbox以查看效果。
总结
最后让我们回顾一下编写高性能 hook 的规则:
- 每次钩子状态发生变化时,都会导致其宿主组件重新渲染,无论这个状态是否在钩子返回值中被使用和记忆化。
- 每次 hook 状态发生变化时,所有的父级 hook 也会发生变化,直到达到宿主组件,它将再次触发重新渲染
因此在编写或使用自定义 hook 时需要注意以下几点:
- 当使用自定义 hook 返回的状态时,尽量将其下移到较小的组件中,以免造成其他组件没必要的重渲染。
- 不要在 hook 中实现独立状态或使用具有独立状态的 hook。
- 当使用自定义 hook 时,确保它不执行一些在返回值中未公开的独立状态操作。
- 当使用自定义钩子时,确保它所使用的所有钩子也遵循上述规则。