现在, 我们已经知道React开发过程中一些常用的模式以及其工作原理,那么现在,让我们进一步探讨性能问题吧。准确的说,让我们来讨论一个与在 React 中提升性能密切相关,但实际上在我们至少一半的应用场景中都达不到预期效果的话题。那就是记忆化(缓存)。我们常用的useMemo和useCallback钩子函数以及React.memo这个高阶组件。
顺便说一下,我说一般的场景达不到预期,并不是开玩笑或者夸大。做好记忆化远比看起来要难。至少我希望你读完本章节后,可以同意这个观点。读完本章节,你将会学到:
- 我们试图通过记忆化(而且本质上不是性能问题!)来解决的问题是什么呢?
- useMemo和useCallback的底层工作原理以及它们之间的区别。
- 为何仅对组件自身的属性进行记忆化是一种反模式。
- 什么是React.memo,我们为什么需要它,以及使用它的基本规则。
- 如何在 “子元素作为子组件” 模式下正确使用React.memo。
- 在大计算量时,useMemo如何发生作用。
问题:比对前后值
这本质上就是比对值。如果是原始类型,直接比较就行了:
const a = 1;
const b = 1;
a === b; // will be true, values are exactly the same
但如果比对的是引用类型,就完全不一样了。
当我生成了一个对象const a = { id: 1 },这个变量存储的并不是这个对象内的值,而是指向这个对象的引用。当我们生成一个值一样的对象const b = { id: 1 },这个对象存储的是另一个引用,指向了记忆体中的另一个地址。而不同的地址之间,是永远不同的。
所以,即使两个对象看起来完全一样,但是这两个对象分别指向的引用地址是完全不一样的:它们指向记忆体中不同的对象。因此,这两个对象的比较结果永远为false:
const a = { id: 1 };
const b = { id: 1 };
a === b // will always be false
如果想让a === b为真,我们需要确保b对象的引用地址与a对象的引用地址完全一样:
const a = { id: 1 };
const b = a;
a === b // now will be true
这就是React的重新渲染器在比对前后值时的逻辑。它会在我们使用有依赖项的钩子函数时,进行前后比较,就像这个使用useEffect的例子:
const Component = () => {
const submit = () => {};
useEffect(() => {
// call the function here
submit();
// it's declared outside of the useEffect
// so should be in the dependencies
}, [submit])
return ...
}
在这个例子中,这个submit函数是在useEffect外声明的。所以,如果我们想要在useEffect钩子内使用这个submit函数,submit需要放在声明数组里面。但是,submit函数又是在Component内声明的,所以在每次重新渲染Component时,都会重新声明submit函数。回忆一下,我们在第二章的内容,重新渲染的本质是React调用组件函数,而在这个过程中,组件内的每个变量都会被重新生成。
所以,React会比较submit的前值和后值,来决定是否要使用useEffect内的回调函数。而比较的结构将永远为false,因为前后函数对象的引用地址不一样。因此,useEffect钩子函数会在每次重新渲染时被触发。
useMemo 和 useCallback:它们的运作原理
为了避免不必要的重新触发,我们需要一个方法来储存submit函数。而这时,我们就可以使用useMemo和useCallback了。它们有着相似的API和相似的用法:确保那些钩子(hooks)所赋值的变量中的引用只有在钩子的依赖项发生变化时才会改变。
如果我把submit放在useCallback里:
const submit = useCallback(() => {
// no dependencies, reference won't change between re-renders
}, [])
这样,submit函数的引用地址将一直不变,所以重新渲染函数的比对结果将为true,依赖submit函数的useEffect钩子将不会被无效触发了。
const Component = () => {
const submit = useCallback(() => {
// submit something here
}, [])
useEffect(() => {
submit();
}, [submit]);
return ...
}
使用useMemo也可以达到同样的效果:
const submit = useMemo(() => {
return () => {
// this is out submit function - it's returned from the function that is passed to memo
}
}, [])
代码示例: advanced-react.com/examples/05…
如你所见,这两个API之间有细微的不同。useCallback把我们想要缓存的函数当作第一个参数,而useMemo接收一个函数,并缓存这个函数的返回值。而这也是这两个API产生不同的原因。
因为这两个钩子都把函数作为第一个参数,且我们在React组件内声明这些函数,这也意味着这些作为第一个参数传入的函数在每次重新渲染时,都会被再次创造。这个现象是基于JavaScript的原理产生的,与React无关。如果我声明了一个函数,该函数接受另一个函数作为参数,然后多次使用内联函数对其进行调用,那么每次调用时该内联函数都会从头重新创建。
// function that accepts a function as a first argument
const func = (callback) => {
// do something with this callback here
}
// function as an argument - first call
func(() => {})
// function as an argument - second call, new function as an argument
func(() => {})
而我们的钩子函数,本质上就是集成在React生命周期的函数。
所以,为了确保useCallback返回的是同一个索引,React做了类似这样的事情:
let cachedCallback;
const func = (callback) => {
if (depenenciesEquall()) {
return cachedCallback;
}
cachedCallback = callback;
return callback;
}
useCallback会存储作为参数的第一个函数,之后,只要该useCallback的依赖数组没有发生变化,它会原来的函数。如果依赖数组发生了变化,它会更新缓存,并返回更新后的函数。
至于useMemo,其过程也是大致类似的,只不过,useMemo缓存和返回的是运算结果:
let cachedResult;
const func = (callback) => {
if (dependenciesEqual()) {
return cachedResult
}
cachedResult = callback();
return cachedResult
}
虽然这个钩子在具体实现上比这个要复杂得多,但是其基本思路是一样的。
这个为什么这么重要?对于真实的应用而言,不过是理解API其内部差异罢了。然而,有一种观点时不时会冒出来,认为useMemo在性能方面要优于useCallback,因为useCallback在每次重新渲染时都会重新创建传递给它的函数,而useMemo不会这样做。如你所见,这种观点是不正确的。对于这两者(useMemo和useCallback)来说,作为第一个参数传入的函数都会被重新创建。
理论上,我能想到的唯一真正有影响的情况是,当我们作为第一个参数传递的不是函数本身,而是另一个函数执行的结果(以内联方式硬编码的)时。基本就是像这样的情况:
const submit = useCallback(something(), []);
在这个例子中,something会在每一次重新渲染时被重新生成,即使submit的依赖数组并没有发生变化。
反模式:缓存属性
第二个我们常用到缓存钩子的场景是,把缓存后的值作为依赖,传递给属性。你可以这样写代码:
const Component = () => {
const onClick = useCallback(() => {
// do something on click
}, []);
return <button onClick={onClick}>click me</button>;
}
不幸的是,这个useCallback是无效的。有一个普遍的观点(甚至ChatGPT也这么认同)认为:缓存属性可以避免组件重新渲染。但是正如我们从前面的章节得知:如果一个组件重新渲染了,嵌套在该组件的子孙组件都会重新渲染。
所以,我们是否有把onClick函数包裹在useCallback里面,并不重要。这样做反而会降低代码的可读性。实际上,我们只有两种主要的用例需要对组件的属性进行记忆优化。第一种情况是,当这个属性在下游组件的另一个钩子(hook)中被用作依赖项的时候。
const Parent = () => {
// this needs to be memoized
// Child uses it inside useEffect
const fetch = () => {};
return <Child onMount={fetch} />
}
const Child = ({ onMount }) => {
useEffect(() => {
onMount();
}, [onMount])
}
这段代码是不言自明的:如果一个非原始值进入依赖数组,它的引用需要稳定地不被重新创建。
而第二种情况,就是当一个组件被包裹在React.memo里面。
什么是React.memo
React.memo 或者 memo是React为我们提供的一个非常有用的工具。它允许我们缓存一个组件本身。如果一个组件被它的父组件(也仅仅是父组件)触发重新渲染,且该组件是被包裹在React.memo里面,那么React在重新渲染到这个组件时,会停下来比对这个组件的属性。如果该组件的属性没有发生变化,这个组件会停止重新渲染,这个组件内的子孙组件的重新渲染也会停止。
这是我们又一次提到React的比对行为了。如果一个组件被React.memo包裹,属性的变化也会触发组件的重新渲染:
const Child = ({ data, onChange } => {});
const ChildMemo = React.memo(Child);
const Component = () => {
// object and function declared inline
// will change with every re-render
return <ChildMemo data={{...some_object}} onChange={() => {...}}>
}
在这个例子中,data和onChange是在行内声明的,所以每次重新渲染时,它们都会被重新创立。
而这时,useMemo和useCallback就可以发挥作用了:
const Child = ({ data, onChange }) => {};
const ChildMemo = React.memo(Child);
const Component = () => {
const data = useMemo(() => { ... }, []); // some object
const onChange = useCallback(() => {}, []); some callback
// data and onChange now have stable reference
// re-renders of ChildMemo will be prevented
return <ChildMemo data={data} onChange={onChange} />
}
通过缓存data和onChange,我们确保了它们的引用的稳定性。如此一来,React在比对ChildMemo组件时,就会认定属性没有变化,则组件就不会重新渲染。
代码示例: advanced-react.com/examples/05…
但是,缓存所有的属性,并不是那么简单的。我们很容易缓存错。而且,只要一个属性缓存失败,将会导致React.memo, useCallback 和 useMemo将会失去效果。
React.memo 和 属性的属性
第一个缓存属性失败的例子,是来自属性的属性。尤其是当涉及到在中间的组件中展开属性(props)这种情况时。想象一下你有这样一连串的组件:
const Child = () => {}
const ChildMemo = React.memo(Child);
const Component = (props) => {
return <ChildMemo {...props}>
}
const ComponentInBetween = (props) => {
return <Component {...props}>
}
const InitialComponent = (props) => {
// this one will have state and will trigger re-render of Component
return (
<ComponentInBetween {...props} data={{ id: '1' }}>
)
}
你也许认为,React会深度遍历InitialComponent组件的每一个子组件,去检查是否哪个组件被React.memo包裹。但事实上,并没有这回事。
实际上,InitialComponent缓存CHildMemo组件会失败,因为它传递了一个没有被缓存的状态进去。
代码示例: advanced-react.com/examples/05…
所以,如果你不打算确保每一个属性被缓存,在使用React.memo时,你需要遵守以下几个规则:
规则1:不要直接展开来自其他组件的属性
不要这样:
const Component = (props) => {
return <ChildMemo {...props} />;
}
而是应该这样:
return <ChildMemo some={props.some} other={props.other}/>;
}
规则2:避免传递来自其他组件的非原始类型属性
刚刚上面使用some、other属性的代码,其实也是很脆弱的。只要传到这里面的属性是非原始类型,这个缓存就会被破坏。
规则3:避免传递来自自定义钩子的非原始类型属性
这个规则或许与我们的直觉有些相悖:我们应该通过自定义钩子来抽象出业务逻辑。React的自定义钩子好比一把双刃剑:它隐藏了一些抽象的逻辑,但是也隐藏了这些数据或者函数是否有稳定的引用。看看这个代码:
const Component = () => {
const { submit } = useForm();
return <ChildMemo onChange={submit} />
}
submit函数被隐藏在useForm里面。而且,每次重新渲染时,这个钩子都会被重新调用。你能通过上面的代码来确定传递到ChildMemo的submit方法是否安全吗?
事实上,并不行。即使你看到了useForm的代码,大概率是这样的:
const useForm = () => {
// lots and lost of code to control the form state
const sbumit = () = {
// do something on submit , like validation
}
return {
submit,
}
}
把submit传递进ChildMemo后,我们破坏了对ChildMemo的缓存,ChildeMemo会像没有被React.memo包裹过一样,频繁地重新渲染。
代码示例: advanced-react.com/examples/05…
这个模式是多么的脆弱,对吧?
React.memo 和 子属性
让我们看看这段代码:
const ChildMemo = React.memo(Child);
const Component = () => {
return (
<ChildMemo>
<div>Some text here</div>
</ChildMemo>
)
}
这看起来很简单:一个被缓存的、没有属性的组件,仅仅渲染了里面的div,对吧?然而,缓存又被破坏了,React.memo又失去作用了。
记得我们在第二章讨论的子组件作为属性吗?div标签不过是针对子组件的嵌套语法糖罢了。我们可以把代码写成这样:
const Component = () => {
return <ChildMemo chilren={<div>Some text here</div>} />
}
这两段代码运行起来,是一样的。而JSX嵌套语法糖里的内容,本质上是React.createElement返回的一个对象。在这个代码中,它是一个类型为“div”的对象:
{
type: "div",
... // the rest of the stuff
}
而站在缓存组件的视角:这个子属性本质上是一个没有被缓存的对象!!
为了修复这个问题,我们需要把这个div也缓存了:
const Component = () => {
const content = useMemo(
() => <div>Some text here</div>,
[]);
return <ChildMemo children={content} />;
}
或者,我们可以使用酷炫的嵌套语法:
const Component = () => {
const content = useMemo(
() => <div>Some text here</div>,
[]);
return <ChildMemo>{content}<ChildMemo />;
}
代码示例: advanced-react.com/examples/05…
同理,在使用渲染属性模式时,也会破坏缓存:
const Component = () => {
return (
<ChilMemo>{() => <div>Some text here</div>}</ChildMemo>
)
}
在这里的子组件本质上是一个函数,这个函数会在每次重新时被重新生成。所以它也需要用useMemo来缓存:
const Component = () => {
const content = useMemo(
() => () => <div>Some text here</div>,
[]
);
return <ChildMemo>{content}</ChildMemo>;
}
或者,也可以用useCallback来缓存:
const Component = () => {
content = useCallback(
() => <div>Some text here</div>,
[]);
return <ChildMemo>{content}</ChildMemo>
}
代码示例: advanced-react.com/examples/05…
React.memo 和 缓存子组件
如果你把app里所有这个模式的问题都修复了,也许你会为组件记忆化的成果感到自信。然而,生活从未如此容易过!你怎么看待这段代码?它会破坏组件的缓存吗?
const ChildMemo = React.memeo(Child);
const ParentMemo = React.memeo(Parent);
const Component = () => {
return (
<ParentMemo>
<ChildMemo />
</ParentMemo>
)
}
这两个组件都被缓存了,对吧?然而不并不是。ParentMemo将会和没有被React.memo包裹一样运行--ParentMemo的子组件事实上并没有被缓存。
让我们仔细研究一下这段代码。我们知道,元素是通过React.createElement语法糖生成的、一个指明类型的对象。如果我生成了<Parent />元素,它会是这样:
{
type: Parent
... // the rest of React stuff
}
对于被缓存的组件,其返回对象是一样的。<ParentMemo/>元素会被转化成一个和<Parent/>的返回结构一样的对象。只有type属性会保留ParentMemo的信息。
而这个对象也只是一个普通的对象,并不难缓存自身。从缓存和属性的视角来看,ParentMemo组件有的子组件属性是一个没有被缓存的对象。因此,ParentMemo的缓存又被破坏了。
为了解决这个问题,我们需要缓存这个对象:
const Component = () => {
cosnt child = useMemo(() => <ChildMemo />, []);
return <ParentMemo>{child}</ParentMemo>
}
甚至,我们都不需要ChildMemo组件。是否缓存Child组件,取决于上下文和开发的意图。如果只是避免Child被重复渲染,ChildMemo其实不是必要的,直接调用Child组件即可:
const Component = () => {
cosnt child = useMemo(() => <Child />, []);
return <ParentMemo>{child}</ParentMemo>
}
代码示例: advanced-react.com/examples/05…
useMemo 与 昂贵的计算
最后,我们要讨论的另一个关于useMemo的重要话题是“昂贵计算”。
首先,什么是“昂贵计算”?是连接字符串很耗计算机费性能吗?还是为长度为300的数组进行排序?或者展示5000词的内容?我并不知道,你也不知道。事实上,没人知道知道计算被这样测量:
- 在一个特定的测试设备
- 基于特定上下文
- 与同时期正在发生的其他事情相比
- 与之前的情况或者理想状态相比。
在我的笔记本电脑上对一个包含 300 个元素的数组进行排序,即便将中央处理器(CPU)的速度放慢 6 倍,耗时也不到 2 毫秒。但在某些老旧的安卓 2 系统的手机上,这可能需要耗费一秒钟的时间。
在一段文本上执行一个耗时 100 毫秒的正则表达式会让人感觉很慢。但如果它是作为点击某个按钮后的运行结果,而且只是偶尔运行一次,被深埋在设置界面的某个角落里,那么它几乎可以说是瞬间完成的。一个运行耗时 30 毫秒的正则表达式似乎足够快了。但如果它是在主页面上针对每一次鼠标移动或滚动事件都要运行一次的话,那它就慢得让人难以忍受,需要加以改进了。
情况总是依具体情形而定。当你迫切想要用useMemo来包裹某些内容,因为觉得那是一项 “开销较大的计算” 时,默认的思路应该是 “先进行测量”。
其次要考虑的是 React 相关的情况。尤其是组件渲染与原生 JavaScript 计算相比较这一点。通常情况下,在useMemo内进行的任何计算,无论如何都会比重新渲染实际元素快一个数量级。例如,在我的笔记本电脑上对一个包含 300 个元素的数组进行排序耗时不到 2 毫秒。而根据该数组重新渲染列表元素,即便这些元素只是带有一些文本的简单按钮,也耗时超过 20 毫秒。如果我想要提升那个组件的性能,最佳做法应该是消除所有不必要的重新渲染,而不是对耗时不到 2 毫秒的计算进行记忆优化(使用useMemo)。
所以,除了 “先进行测量” 这一规则外,当涉及到记忆优化(memoization)时,还应该遵循这样一条规则:“不要忘了测量重新渲染组件元素所花费的时间”。如果你把每一个 JavaScript 计算都用useMemo包裹起来,从中节省了 10 毫秒,但实际组件的重新渲染仍然耗时将近 200 毫秒,那这么做又有什么意义呢?这么做只会让代码变得复杂,却没有任何明显的收益。
最后,useMemo只对重新渲染有用。这就是它的关键所在以及它的工作原理。如果你的组件从不重新渲染,那么useMemo就毫无作用。
不仅如此,它还会迫使 React 在初次渲染时做额外的工作。别忘了:在组件首次挂载时,useMemo这个钩子第一次运行时,React 需要对其进行缓存。这会为此消耗一点内存和计算能力,否则这些资源原本是可以节省下来的。当然,仅仅使用一个useMemo,其影响是微乎其微、难以测量出来的。但在大型应用中,有成百个useMemo分散在各处,实际上它会明显拖慢初次渲染的速度。最终就会积少成多,造成严重的性能问题。
知识概要
好吧,败了兴了。这是不是意味着我们不该使用缓存?并不全是。在处理性能问题时,它还是有用的。在我们应对性能问题的这场 “战斗” 中,它(前文提及的某种工具或技术,需结合更多前文判断)可能是一个非常有价值的工具。但是考虑到围绕它存在诸多需要注意的事项以及复杂性,我建议首先尽可能多地使用基于组合的优化技术。当其他所有方法都不管用时,再把React.memo当作最后的手段来使用。
我们需要记住:
- React比较对象、数据和函数,是基于其引用,而不是其值。这个比较行为会发生在钩子函数的依赖数组和包裹在
React.memo里的组件的属性。 - 传递给
useMemo或者useCallback的内敛函数会在每次重新渲染时被重新生成。useCallback缓存的是函数本身,而useMemo缓存的是函数的返回值。 - 缓存一个组件的属性只有满足以下条件时才生效:
- 这个组件被包裹在
React.memo中。 - 该组件在任意钩子(Hooks)中使用这些属性(Props)作为依赖项。
- 该组件会将这些属性(Props)向下传递给其他组件,而且这些其他组件存在上述任意一种情况。
- 这个组件被包裹在
- 如果一个组件被React.memo包裹,并且它的重新渲染是由其父组件触发的,那么只要该组件的属性(props)没有发生变化,React 就不会重新渲染这个组件。在其他任何情况下,重新渲染将会照常进行。
- 对一个被React.memo包裹的组件的所有属性(props)进行记忆优化(memoizing)比看起来要困难得多。要避免向其传递来自其他属性或钩子(hooks)的非基本数据类型的值。
- 在对属性进行记忆化时,记住“子组件”属性也是一个非原始类型的值,也需要被缓存。