paint-brush
React 中的记忆化:强大的工具还是隐藏的陷阱?经过@socialdiscoverygroup
971 讀數
971 讀數

React 中的记忆化:强大的工具还是隐藏的陷阱?

经过 Social Discovery Group15m2024/07/01
Read on Terminal Reader

太長; 讀書

React 应用程序开发中一种普遍的做法是用记忆覆盖所有内容。Social Discovery Group 团队发现,在 React 应用程序中过度使用记忆会导致性能问题。了解它失败的地方以及如何在开发中避免这些隐藏的陷阱。
featured image - React 中的记忆化:强大的工具还是隐藏的陷阱?
Social Discovery Group HackerNoon profile picture
0-item


React 应用程序开发中一种普遍的做法是使用记忆化覆盖所有内容。许多开发人员大量使用这种优化技术,将组件包装在记忆化中以防止不必要的重新渲染。从表面上看,这似乎是提高性能的万无一失的策略。然而, Social Discovery Group团队发现,如果使用不当,记忆化实际上可能会以意想不到的方式中断,从而导致性能问题。在本文中,我们将探讨记忆化可能失败的意外地方,以及如何避免 React 应用程序中的这些隐藏陷阱。


什么是记忆化?

记忆化是编程中的一种优化技术,它涉及保存昂贵操作的结果并在再次遇到相同输入时重新使用它们。记忆化的本质是避免对相同输入数据进行冗余计算。对于传统的记忆化来说,这种描述确实是正确的。您可以在代码示例中看到所有计算都被缓存了。


 const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }


记忆化具有多种优势,包括提高性能、节省资源和缓存结果。但是,React 的记忆化会一直有效,直到有新的 props 进来,这意味着只保存最后一次调用的结果。


 const prev = { value: null, result: null } function calculate(a) { if (prev.value === a) { return prev.result } prev.value = a prev.result = a * a return prev.result }


React 中的记忆工具

React 库为我们提供了几种用于记忆的工具。这些是 HOC React.memo、钩子 useCallback、useMemo 和 useEvent,以及 React.PureComponent 和类组件的生命周期方法 shouldComponentUpdate。让我们研究一下前三个记忆工具,并探索它们在 React 中的用途和用法。


React.memo

此高阶组件 (HOC) 接受组件作为其第一个参数,并接受可选的比较函数作为其第二个参数。比较函数允许手动比较前一个和当前的 props。当未提供比较函数时,React 默认为浅层相等。了解浅层相等仅执行表面级别的比较至关重要。因此,如果 props 包含具有非常量引用的引用类型,React 将触发组件的重新渲染。


 const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })


React.useCallback

useCallback 钩子允许我们保留在初始渲染期间传递的函数的引用。在后续渲染中,React 将比较钩子的依赖项数组中的值,如果所有依赖项均未发生更改,它将返回与上次相同的缓存函数引用。换句话说,useCallback 会在渲染之间缓存对函数的引用,直到其依赖项发生更改。


 const callback = useCallback(() => { // do something }, [a, b, c])


React.useMemo

useMemo 钩子允许您在渲染之间缓存计算结果。通常,useMemo 用于缓存昂贵的计算,以及在将对象传递给 memo HOC 中包装的其他组件或作为钩子中的依赖项时存储对对象的引用。


 const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])


项目的完整记忆

在 React 开发团队中,一种普遍的做法是全面记忆。这种方法通常涉及:

  • 将所有组件包装在 React.memo 中
  • 对传递给其他组件的所有函数使用 useCallback
  • 使用 useMemo 缓存计算和引用类型


然而,开发人员并不总是能理解这种策略的复杂性以及记忆化是多么容易受到损害。包装在备忘录 HOC 中的组件意外重新渲染的情况并不少见。在 Social Discovery Group,我们听到同事们声称,“记忆化所有内容并不比完全不记忆化要昂贵得多。”


我们注意到,并非所有人都完全掌握了记忆化的一个关键方面:只有当原语被传递给记忆化组件时,它才能在不进行额外调整的情况下发挥最佳作用。


  1. 在这种情况下,仅当 prop 值实际发生变化时,组件才会重新渲染。props中的原始值是好的。

  2. 第二点是当我们在 props 中传递引用类型时。重要的是要记住并理解 React 中没有魔法——它是一个根据 JavaScript 规则运行的 JavaScript 库。props中的引用类型(函数、对象、数组)是危险的。


    例如:


 const a = { c: 1 } const b = { c: 1 } a === b // false First call: MemoComponent(a) Second call: MemoComponent(b) const MemoComponent = memo(({object}) => { return <div /> }, (prevProps, props) => (prevProps.object === props.object)) // false


如果创建了一个对象,则另一个具有相同属性和值的对象不等于第一个对象,因为它们具有不同的引用。


如果我们在组件的后续调用中传递了看似相同的对象,但实际上是不同的对象(因为它的引用不同),React 使用的浅层比较会将这些对象识别为不同的对象。这将触发包装在 memo 中的组件的重新渲染,从而破坏该组件的记忆化。


为了确保记忆化组件的安全运行,必须结合使用 memo、useCallback 和 useMemo。这样,所有引用类型都会有常量引用。

团队合作:memo、useCallback、useMemo

让我们分解一下这份备忘录,好吗?

上面描述的所有内容听起来都合乎逻辑且简单,但让我们一起来看看使用这种方法时可能犯的最常见错误。有些错误可能很微妙,有些可能有点牵强,但我们需要意识到它们,最重要的是,理解它们,以确保我们用完全记忆实现的逻辑不会中断。


内联到记忆化

让我们从一个典型的错误开始,在每次后续渲染 Parent 组件时,记忆化的组件 MemoComponent 将不断重新渲染,因为对传入 params 的对象的引用始终是新的。


 const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }


为了解决这个问题,使用前面提到的 useMemo 钩子就足够了。现在,对我们对象的引用将始终是常量。


 const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }


或者,如果数组始终包含静态数据,则可以将其移动到组件外部的常量中。


 const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }


本例中类似的情况是传递一个没有记忆的函数。在这种情况下,与上一个示例一样,通过向 MemoComponent 传递一个函数,MemoComponent 的记忆将被破坏,该函数在每次渲染父组件时都会有一个新的引用。因此,MemoComponent 将重新渲染,就像没有被记忆一样。


 const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }


在这里,我们可以使用 useCallback 钩子来保留父组件渲染之间传递函数的引用。


 const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }


记下了。

此外,在 useCallback 中,没有什么可以阻止您传递返回另一个函数的函数。但是,重要的是要记住,在这种方法中,函数“someFunction”将在每次渲染时被调用。避免在“someFunction”内进行复杂的计算至关重要。


 function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }


道具传播

下一个常见情况是 props 传播。假设您有一个组件链。您多久会考虑一下从 InitialComponent 传递的数据 props 可以传播多远,而对于此链中的某些组件来说,这些数据 props 可能是不必要的?在此示例中,此 props 将破坏 ChildMemo 组件中的记忆,因为在 InitialComponent 的每次渲染中,其值始终会发生变化。在实际项目中,记忆组件链可能很长,所有记忆都将被破坏,因为传递给它们的是值不断变化的不必要 props:


 const Child = () => {} const ChildMemo = React.memo(Child) const Component = (props) => { return <ChildMemo {...props} /> } const InitialComponent = (props) => { // The only component that has state and can trigger a re-render return ( <Component {...props} data={Math.random()} /> ) }


为了保护自己,请确保仅将必要的值传递给记忆组件。而不是:


 const Component = (props) => { return <ChildMemo {...props} /> }


使用(仅传递必要的道具):


 const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )


备忘录和孩子们

让我们考虑以下示例。一个熟悉的情况是当我们编写一个接受 JSX 作为子项的组件时。


 const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }


乍一看,这似乎无害,但实际上并非如此。让我们仔细看看将 JSX 作为子元素传递给 memoized 组件的代码。此语法只不过是将此“div”作为名为“children”的 prop 传递的语法糖。


子组件与我们传递给组件的任何其他 prop 没有什么不同。在我们的例子中,我们传递的是 JSX,而 JSX 又是 `createElement` 方法的语法糖,因此本质上,我们传递的是类型为 `div` 的常规对象。这里,适用于记忆组件的通常规则:如果在 props 中传递了非记忆对象,则组件将在其父组件渲染时重新渲染,因为每次对该对象的引用都是新的。



之前在关于传递非记忆化对象的块中讨论过此类问题的解决方案。因此,在这里,传递的内容可以使用 useMemo 进行记忆化,并将其作为子项传递给 ChildMemo 不会破坏此组件的记忆化。


 const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }


ParentMemo 和 ChildMemo

让我们考虑一个更有趣的例子。


 const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }


乍一看,这似乎无害:我们有两个组件,它们都被记忆了。但是,在这个例子中,ParentMemo 的行为就像没有被 memo 包裹一样,因为它的子组件 ChildMemo 没有被记忆。ChildMemo 组件的结果将是 JSX,而 JSX 只是 React.createElement 的语法糖,它返回一个对象。因此,在 React.createElement 方法执行后,ParentMemo 和 ChildMemo 将成为常规 JavaScript 对象,并且这些对象不会以任何方式被记忆。

家长备忘录

儿童备忘录

{`` type: {`` ...`` $$typeof: Symbol(react.memo),`` type: {`` name: "Parent"`` }`` },`` ...``}

{`` type: {`` ...`` $$typeof: Symbol(react.memo),`` type: {`` name: "Child"`` }`` },`` ...``}


结果,我们将一个非记忆化的对象传递给 props,从而打破了父组件的记忆化。


 const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }


为了解决这个问题,只需记住传递的子项,确保其引用在父 App 组件渲染期间保持不变。


 const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }


来自自定义钩子的非原语

另一个危险且隐蔽的领域是自定义钩子。自定义钩子帮助我们从组件中提取逻辑,使代码更具可读性并隐藏复杂的逻辑。然而,它们也向我们隐藏了它们的数据和函数是否有常量引用。在我的示例中,提交函数的实现隐藏在自定义钩子 useForm 中,并且在 Parent 组件的每次渲染上,钩子都会被重新执行。


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };


我们能从代码中看出将 submit 方法作为 prop 传递给 memoized 组件 ComponentMemo 是否安全吗?当然不能。而在最坏的情况下,自定义 hook 的实现可能如下所示:


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }


通过将提交方法传递到记忆化组件中,我们将破坏记忆化,因为对提交方法的引用在每次渲染父组件时都会是新的。要解决这个问题,您可以使用 useCallback 钩子。但我想强调的重点是,如果您不想破坏已实现的记忆化,则不应盲目使用自定义钩子中的数据将其传递到记忆化组件中。


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }


什么时候记忆化是过度的,即使你用记忆化覆盖了所有内容?

与任何方法一样,应谨慎使用完全记忆,并应努力避免过度记忆。让我们考虑以下示例:


 export function App() { const [state, setState] = useState('') const handleChange = (e) => { setState(e.target.value) } return ( <Form> <Input value={state} onChange={handleChange}/> </Form> ) } export const Input = memo((props) => (<input {...props} />))


在这个例子中,我们很容易将 handleChange 方法包装在 useCallback 中,因为以当前形式传递 handleChange 将破坏 Input 组件的记忆,因为对 handleChange 的引用始终是新的。但是,当状态发生变化时,Input 组件无论如何都会重新渲染,因为新的值将在 value prop 中传递给它。因此,我们没有将 handleChange 包装在 useCallback 中这一事实不会阻止 Input 组件不断重新渲染。在这种情况下,使用 useCallback 就有些过了。接下来,我想提供一些在代码审查期间看到的真实代码示例。


 const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])


考虑到两个数字相加或字符串连接的操作非常简单,并且我们在这些示例中得到的是原始值作为输出,显然在这里使用 useMemo 是没有意义的。类似的例子在这里。


 const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])


根据 useMemo 中的依赖项,可以得到不同的结果,但同样,它们是原语,内部没有复杂的计算。在组件的每次渲染上执行任何这些条件都比使用 useMemo 更便宜。


结论

  1. 优化 - 并非总是有益的。*性能优化并非免费,并且这些优化的成本可能并不总是与您从中获得的收益相称。
    *
  2. 衡量优化结果。*如果不衡量,您就无法知道优化是否改善了任何情况。最重要的是,没有衡量,您就无法知道优化是否让情况变得更糟。
    *
  3. 衡量记忆的有效性。*是否使用完全记忆只能通过衡量其在特定情况下的表现来了解。缓存或记忆计算时,记忆并非免费,这会影响应用程序首次启动的速度以及用户开始使用它的速度。例如,如果您想记忆一些复杂的计算,其结果需要在按下按钮时发送到服务器,那么您是否应该在应用程序启动时将其记忆?也许不是,因为用户可能永远不会按下该按钮,并且执行该复杂计算可能完全没有必要。
    *
  4. 先思考,后记忆。*只有当传递给组件的 props 被包装在 `memo` 中,或者当接收到的 props 用于钩子的依赖项中,以及当这些 props 被传递给其他记忆化的组件时,记忆化传递给组件的 props 才有意义。
    *
  5. 记住 JavaScript 的基本原理。在使用 React 或任何其他库和框架时,重要的是不要忘记所有这些都是用 JavaScript 实现的,并根据该语言的规则运行。


可以用什么工具来测量?

我们可以推荐至少 4 种工具来衡量应用程序代码的性能。


React.Profiler

React.Profiler ,您可以包装所需的特定组件或整个应用程序,以获取有关初始渲染和后续渲染时间的信息。您还可以了解在哪个确切阶段获取了指标。


React 开发者工具

React 开发者工具是一个浏览器扩展,允许您检查组件层次结构,跟踪状态和道具的变化,并分析应用程序的性能。


React 渲染跟踪器

另一个有趣的工具是React 渲染跟踪器,当非记忆组件中的 props 不变或变为类似的 props 时,它有助于检测可能不必要的重新渲染。


带有 storybook-addon-performance 插件的故事书。

另外,在 Storybook 中,你可以安装一个有趣的插件,名为故事书插件性能来自 Atlassian。使用此插件,您可以运行测试以获取有关初始渲染、重新渲染和服务器端渲染速度的信息。这些测试可以针对多个副本运行,也可以同时运行多次,从而最大限度地减少测试不准确性。



**作者:Social Discovery Group 高级软件工程师 Sergey Levkovich