本专栏致力于每周分享一个项目,如果本文对你有帮助的话,欢迎点赞或者关注☘️
React18 源码系列会随着学习 React 源码的实时进度而实时更新:约,两天一小改,五天一大改。
useMemo是一个 React Hook,可让您在重新渲染之间缓存计算结果。
const cachedValue = useMemo(calculateValue, dependencies)
在组件的顶层调用useMemo来缓存重新渲染之间的计算:
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
参数:
calculateValue:计算要缓存的值的函数。它应该是纯的,不应该接受任何参数,并且应该返回任何类型的值。 React 将在初始渲染期间调用您的函数。在下一次渲染时,如果自上次渲染以来dependencies没有更改,React 将再次返回相同的值。否则,它将调用calculateValue,返回其结果并存储它以便以后可以重用。dependencies:calculateValue代码中引用的所有反应值的列表。反应性值包括 props、state 以及直接在组件体内声明的所有变量和函数。依赖项列表必须具有恒定数量的项目,并且像[dep1, dep2, dep3]那样内联编写。 React 将使用[Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)比较将每个依赖项与其之前的值进行比较。
return:
- 在初始渲染时,
useMemo返回不带参数调用calculateValue的结果。 - 在下一次渲染期间,它将返回上次渲染中已存储的值(如果依赖项未更改),或者再次调用
calculateValue,并返回calculateValue返回的结果。
用法
跳过昂贵的重新计算
要缓存重新渲染之间的计算,请将其包装在组件顶层的useMemo调用中:
import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
您需要将两件事传递给useMemo:
- 一个不带参数的计算函数,例如
() =>,并返回您想要计算的内容。 - 依赖项列表,包括计算中使用的组件内的每个值。
在初始渲染时,您从useMemo获得的值将是调用您的calculation的结果。
在每次后续渲染中,React 都会将依赖项与您在上次渲染期间传递的依赖项进行比较。如果没有任何依赖项发生更改(与[Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)相比), useMemo将返回您之前计算过的值。否则,React 将重新运行您的计算并返回新值。
换句话说, useMemo会在重新渲染之间缓存计算结果,直到其依赖项发生变化。
默认情况下,React 每次重新渲染时都会重新运行组件的整个主体。例如,如果此TodoList更新其状态或从其父级接收新的 props,则filterTodos函数将重新运行:
function TodoList({ todos, tab, theme }) {
const visibleTodos = filterTodos(todos, tab);
// ...
}
通常,这不是问题,因为大多数计算都非常快。但是,如果您正在过滤或转换大型数组,或者进行一些昂贵的计算,则在数据未更改的情况下,您可能需要跳过再次执行此操作。如果todos和tab都与上次渲染期间相同,则像之前一样将计算包装在useMemo中,这样您就可以重用之前已经计算过的visibleTodos 。
您应该仅依赖useMemo作为性能优化。 如果您的代码没有它就无法工作,请首先找到根本问题并修复它。然后你可以添加useMemo来提高性能。
如何判断计算是否昂贵
一般来说,除非您要创建或循环数千个对象,否则它可能并不昂贵。如果您想获得更多信心,可以添加控制台日志来测量一段代码所花费的时间:
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
执行您正在测量的交互(例如,在输入中键入内容)。然后,您将在控制台中看到类似filter array: 0.15ms的日志。如果记录的总时间加起来很大(例如1ms或更多),那么记住该计算结果可能是有意义的。
您应该在各处添加 useMemo 吗
如果您的应用程序类似于此网站,并且大多数交互都很粗糙(例如替换页面或整个部分),则通常不需要记忆。另一方面,如果您的应用程序更像是绘图编辑器,并且大多数交互都是细粒度的(例如移动形状),那么您可能会发现记忆非常有用。
使用useMemo进行优化仅在少数情况下有价值:
- 您在
useMemo中进行的计算明显很慢,并且其依赖关系很少发生变化。 - 您将它作为 prop 传递给包含在
[memo](https://react.dev/reference/react/memo)中的组件。如果值没有更改,您希望跳过重新渲染。仅当依赖项不相同时,记忆化才允许您的组件重新渲染。 - 您传递的值稍后将用作某些 Hook 的依赖项。例如,也许另一个
useMemo计算值取决于它。或者您可能依赖于[useEffect.](https://react.dev/reference/react/useEffect)
在其他情况下,将计算包装在useMemo中没有任何好处。这样做也没有太大的危害,因此一些团队选择不考虑个别案例,并尽可能多地记住。这种方法的缺点是代码的可读性较差。此外,并非所有记忆都是有效的:“始终是新的”单个值足以破坏整个组件的记忆。
在实践中,您可以通过遵循以下几条原则来避免大量记忆:
- 当一个组件在视觉上包装其他组件时,让它接受 JSX 作为子组件。这样,当包装器组件更新自己的状态时,React 知道它的子组件不需要重新渲染。
- 保持渲染逻辑纯净。如果重新渲染组件导致问题或产生一些明显的视觉伪像,则这是组件中的错误!修复错误而不是添加记忆。
- 更推荐保持本地状态,并且不要在不必要的情况下进一步提升状态。
- 避免update state 的不必要的 Effect。 React 应用程序中的大多数性能问题都是由源自 Effects 的更新链引起的,这些更新会导致组件反复渲染。
- 尝试从Effect中删除不必要的依赖项。例如,将某些对象或函数移动到效果内部或组件外部通常比记忆更简单。
跳过组件的重新渲染
在某些情况下, useMemo还可以帮助您优化重新渲染子组件的性能。为了说明这一点,假设此TodoList组件将visibleTodos作为 prop 传递给子List组件:
export default function TodoList({ todos, tab, theme }) {
// ...
return (
<div className={theme}>
<List items={visibleTodos} />
</div>
);
}
您已经注意到,切换theme属性会使应用程序冻结一会儿,但如果您从 JSX 中删除<List /> ,感觉会很快。这告诉您值得尝试优化List组件。
默认情况下,当组件重新渲染时,React 会递归地重新渲染其所有子组件。 这就是为什么当TodoList使用不同的theme重新渲染时, List组件也会重新渲染。这对于不需要太多计算来重新渲染的组件来说很好。但是,如果您已经验证重新渲染速度很慢,则可以通过将其包装在 memo 中来告诉List当其 props 与上次渲染相同时跳过重新渲染[memo](https://react.dev/reference/react/memo)
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
通过此更改,如果 List 的所有 props 与上次渲染时相同,则List将跳过重新渲染。
export default function TodoList({ todos, tab, theme }) {
// Every time the theme changes, this will be a different array...
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
{/* ... so List's props will never be the same, and it will re-render every time */}
<List items={visibleTodos} />
</div>
);
}
在上面的示例中, filterTodos函数始终创建一个不同的数组, 类似于{}对象字面量始终创建新对象的方式。通常,这不会成为问题,但这意味着Listprop永远不会相同,并且您的[memo](https://react.dev/reference/react/memo)优化将不起作用。这就是useMemo派上用场的地方:
export default function TodoList({ todos, tab, theme }) {
// Tell React to cache your calculation between re-renders...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...so as long as these dependencies don't change...
);
return (
<div className={theme}>
{/* ...List will receive the same props and can skip re-rendering */}
<List items={visibleTodos} />
</div>
);
}
记住各个 JSX 节点
您可以将<List /> JSX 节点本身包装在useMemo中,而不是将List包装在[memo](https://react.dev/reference/react/memo)中:
export default function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
const children = useMemo(() => <List items={visibleTodos} />, [visibleTodos]);
return (
<div className={theme}>
{children}
</div>
);
}
行为是一样的。如果visibleTodos没有改变, List不会被重新渲染。
像<List items={visibleTodos} />这样的 JSX 节点是一个像这样的对象 { type: List, props: { items: visibleTodos } } 。创建这个对象非常便宜,但是React不知道它的内容是否与上次相同。这就是为什么默认情况下,React 会重新渲染List组件。
但是,如果 React 看到与之前渲染期间完全相同的 JSX,它不会尝试重新渲染您的组件。这是因为 JSX 节点是不可变的。 JSX 节点对象不可能随着时间的推移而改变,因此 React 知道跳过重新渲染是安全的。然而,要使其发挥作用,节点实际上必须是同一个对象,而不仅仅是在代码中看起来相同。这就是useMemo在此示例中所做的事情。
手动将 JSX 节点包装到useMemo中并不方便。例如,您不能有条件地执行此操作。这通常就是为什么你会用[memo](https://react.dev/reference/react/memo)包装组件而不是包装 JSX 节点。
防止Effect过于频繁地触发
有时,您可能想在Effect 中使用一个值:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const options = {
serverUrl: 'https://localhost:1234',
roomId: roomId
}
useEffect(() => {
const connection = createConnection(options);
connection.connect();
// ...
这就产生了一个问题。每个反应值都必须声明为 Effect 的依赖项。但是,如果您将options声明为依赖项,则会导致您的 Effect 不断重新连接到聊天室:
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // 🔴 Problem: This dependency changes on every render
// ...
为了解决这个问题,您可以将需要从 Effect 中调用的对象包装在useMemo中:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const options = useMemo(() => {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}, [roomId]); // ✅ Only changes when roomId changes
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // ✅ Only changes when createOptions changes
// ...
如果useMemo返回缓存的对象,这可以确保options对象在重新渲染之间是相同的。
然而,由于useMemo是性能优化,而不是语义保证,如果有特定原因, React 可能会丢弃缓存的值。这也会导致Effect重新触发,因此最好通过将对象移动到Effect内来消除对函数依赖的需要:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const options = { // ✅ No need for useMemo or object dependencies!
serverUrl: 'https://localhost:1234',
roomId: roomId
}
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Only changes when roomId changes
// ...
记住另一个 Hook 的依赖项
假设您有一个计算依赖于直接在组件主体中创建的对象:
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 Caution: Dependency on an object created in the component body
// ...
依赖这样的对象就失去了记忆的意义。当组件重新渲染时,组件主体内部的所有代码都会再次运行。创建searchOptions对象的代码行也将在每次重新渲染时运行。 由于searchOptions是useMemo调用的依赖项,并且每次都不同,React 知道依赖项不同,并且每次都会重新计算searchItems 。
要解决此问题,您可以在将searchOptions对象作为依赖项传递之前先记住它:
function Dropdown({ allItems, text }) {
const searchOptions = useMemo(() => {
return { matchMode: 'whole-word', text };
}, [text]); // ✅ Only changes when text changes
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes
// ...
在上面的示例中,如果text没有更改,则searchOptions对象也不会更改。然而,更好的解决方法是将searchOptions对象声明移至useMemo计算函数内部:
function Dropdown({ allItems, text }) {
const visibleItems = useMemo(() => {
const searchOptions = { matchMode: 'whole-word', text };
return searchItems(allItems, searchOptions);
}, [allItems, text]); // ✅ Only changes when allItems or text changes
// ...
记忆一个函数
假设Form组件包装在[memo](https://react.dev/reference/react/memo)中。你想将一个函数作为 prop 传递给它:
export default function ProductPage({ productId, referrer }) {
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}
return <Form onSubmit={handleSubmit} />;
}
正如{}创建不同的对象一样,函数声明(如function() {}和表达式(如() => {}在每次重新渲染时都会生成不同的函数。就其本身而言,创建新函数不是问题。这不是要避免的事情!但是,如果Form组件已被记忆,那么您可能希望在没有更改 props 时跳过重新渲染它。总是不同的prop会破坏记忆的意义
要使用useMemo记忆函数,您的计算函数必须返回另一个函数:
export default function Page({ productId, referrer }) {
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
};
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
这看起来很笨重!记忆函数很常见,React 有一个专门用于此的内置 Hook。将函数包装到[useCallback](https://react.dev/reference/react/useCallback)而不是useMemo中, 以避免编写额外的嵌套函数:
export default function Page({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
上面的两个例子是完全等价的。 useCallback的唯一好处是它可以让您避免在内部编写额外的嵌套函数。它没有做任何其他事情
故障排除
在严格模式下,React 将调用某些函数两次而不是一次:
function TodoList({ todos, tab }) {
// This component function will run twice for every render.
const visibleTodos = useMemo(() => {
// This calculation will run twice if any of the dependencies change.
return filterTodos(todos, tab);
}, [todos, tab]);
// ...
这是预期的结果,不会破坏您的代码。
例如,这个不纯的计算函数会改变您作为 prop 收到的数组:
const visibleTodos = useMemo(() => {
// 🚩 Mistake: mutating a prop
todos.push({ id: 'last', text: 'Go for a walk!' });
const filtered = filterTodos(todos, tab);
return filtered;
}, [todos, tab]);
您的计算不应更改任何现有对象,但可以更改您在计算期间创建的任何新对象。例如,如果filterTodos函数始终返回不同的数组,您可以更改该数组:
const visibleTodos = useMemo(() => {
const filtered = filterTodos(todos, tab);
// ✅ Correct: mutating an object you created during the calculation
filtered.push({ id: 'last', text: 'Go for a walk!' });
return filtered;
}, [todos, tab]);
每次我的组件渲染时, useMemo中的计算都会重新运行
确保您已将依赖项数组指定为第二个参数!
如果你忘记了依赖数组, useMemo每次都会重新运行计算:
当您发现哪个依赖项破坏了记忆化时,要么找到一种方法将其删除,要么也将其记忆化。
我需要为循环中的每个列表项调用useMemo ,但这是不允许的
假设Chart组件包装在[memo](https://react.dev/reference/react/memo)中。当ReportList组件重新呈现时,您希望跳过重新呈现列表中的每个Chart 。但是,您不能在循环中调用useMemo :
function ReportList({ items }) {
return (
<article>
{items.map(item => {
// 🔴 You can't call useMemo in a loop like this:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure key={item.id}>
<Chart data={data} />
</figure>
);
})}
</article>
);
}
相反,为每个项目提取一个组件并记住各个项目的数据:
function ReportList({ items }) {
return (
<article>
{items.map(item =>
<Report key={item.id} item={item} />
)}
</article>
);
}
function Report({ item }) {
// ✅ Call useMemo at the top level:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure>
<Chart data={data} />
</figure>
);
}
或者,您可以删除useMemo并将Report本身包装在[memo](https://react.dev/reference/react/memo)中。如果item属性没有改变, Report将跳过重新渲染,因此Chart也将跳过重新渲染:
function ReportList({ items }) {
// ...
}
const Report = memo(function Report({ item }) {
const data = calculateReport(item);
return (
<figure>
<Chart data={data} />
</figure>
);
});
参考链接
- react学习资源:
- react.dev/reference/r…
关于作者
作者:Wandra
内容:算法 | 趋势 |源码|Vue | React | CSS | Typescript | Webpack | Vite | GithubAction | GraphQL | Uniqpp。
专栏:欢迎关注呀🌹
本专栏致力于每周分享一个项目,如果本文对你有帮助的话,欢迎点赞或者关注☘️