你过度使用了 useMemo:重新思考 Hooks 的记忆化
作者:奥汉斯·伊曼纽尔✏️
根据我的经验,有两大类内容我发现useMemo是无关紧要的、过度使用的,并且很可能对应用程序的性能有害。
第一类情况很容易理解;然而,第二类情况却相当微妙,容易被忽略。如果你在任何正式的生产应用中使用过 Hooks,那么你很可能曾经想过useMemo在这两类情况中的某一类中使用 Hook。
我将向您展示为什么这些并不重要,而且可能会损害您的应用程序的性能;更有趣的是,我将向您展示我的建议,说明如何在这些用例中避免过度使用它们。useMemo
我们开始吧?
哪些地方不宜使用useMemo
为了便于学习,我们将这些分类称为狮子和变色龙。
忽略那些令人分心的分类名称,坚持住!
遇到狮子时,你的第一反应是逃跑,保护你的心脏不被撕裂,然后活下来讲述这段经历。根本没时间闲聊。
这是A类。它们是狮子,遇到它们你应该立即逃跑。
我们先从这些开始,然后再看看更微妙的变色龙。
1. 相同的参考值和低成本的操作
请参考以下示例组件:
/**
@param {number} page
@param {string} type
**/
const myComponent({page, type}) {
const resolvedValue = useMemo(() => {
getResolvedValue(page, type)
}, [page, type])
return <ExpensiveComponent resolvedValue={resolvedValue}/>
}
在这个例子中,很容易理解作者使用 `<div>` 的合理性useMemo。他们的想法是,ExpensiveComponent当对 `<div>` 的引用发生变化时,他们不希望 `<div>` 被重新渲染resolvedValue。
useMemo虽然这是一个合理的担忧,但要证明在任何特定时间使用它是合理的,需要问两个问题。
首先,传递给该函数的函数是否是useMemo一个计算成本很高的函数?如果是,那么该getResolvedValue计算过程是否也很耗时?
大多数 JavaScript 数据类型的方法都经过优化,例如 `get()` Array.map、Object.getOwnPropertyNames()`get()` 等。如果执行的操作开销不大(例如,考虑大 O 表示法),则无需缓存返回值。使用缓存的成本useMemo 可能高于重新计算函数的成本。
其次,在输入值相同的情况下,对记忆化值的引用是否会改变?例如,在上面的代码块中,给定page`as`2和type`as` "GET",对 `to` 的引用是否会resolvedValue改变?
最简单的答案是考虑resolvedValue变量的数据类型。如果resolvedValue变量是primitive`int`、string` int` number、boolean`int`、null` undefinedint` 或symbol`int` 类型,那么引用永远不会改变。这意味着,该元素ExpensiveComponent不会被重新渲染。
请参考以下修改后的代码:
/**
@param {number} page
@param {string} type
**/
const MyComponent({page, type}) {
const resolvedValue = getResolvedValue(page, type)
return <ExpensiveComponent resolvedValue={resolvedValue}/>
}
根据上述解释,如果resolvedValue返回字符串或其他原始值,并且getResolvedValue不是一个开销很大的操作,那么这段代码完全正确且性能良好。
只要page和type相同——即没有属性更改——resolvedValue就会持有相同的引用,只是返回值不是原始类型(例如,对象或数组)。
记住这两个问题:被记忆化的函数是否开销很大,以及返回值是否为原始值?有了这两个问题,你就可以随时评估你对它的使用情况useMemo。
2. 出于各种原因缓存默认状态
请看以下代码块:
/**
@param {number} page
@param {string} type
**/
const myComponent({page, type}) {
const defaultState = useMemo(() => ({
fetched: someOperationValue(),
type: type
}), [type])
const [state, setState] = useState(defaultState);
return <ExpensiveComponent />
}
上面的代码看起来似乎无害,但useMemo其中的调用其实完全无关紧要。
首先,出于同理心,请理解这段代码背后的思路。编写者的意图值得称赞。他们希望在属性改变defaultState时创建一个新对象type,并且不希望defaultState每次重新渲染都导致对该对象的引用失效。
虽然这些都是合理的担忧,但这种做法是错误的,并且违反了一个基本原则:useState不会在每次重新渲染时重新初始化,而只会在组件重新挂载时重新初始化。
传递给该函数的参数useState最好称为INITIAL_STATE`get_ ...
useState(INITIAL_STATE)
尽管作者担心defaultState当type数组依赖关系发生useMemo变化时会得到新值,但这是一个错误的判断,因为useState它忽略了新计算的defaultState对象。
useState这与如下所示的延迟初始化方式相同:
/**
@param {number} page
@param {string} type
**/
const myComponent({page, type}) {
// default state initializer
const defaultState = () => {
console.log("default state computed")
return {
fetched: someOperationValue(),
type: type
}
}
const [state, setState] = useState(defaultState);
return <ExpensiveComponent />
}
在上面的例子中,defaultState`init` 函数只会在组件挂载时调用一次。它不会在每次重新渲染时都调用。因此,除非组件重新挂载,否则日志“default state computed”只会出现一次。
以下是重写后的代码:
/**
@param {number} page
@param {string} type
**/
const myComponent({page, type}) {
const defaultState = () => ({
fetched: someOperationValue(),
type,
})
const [state, setState] = useState(defaultState);
// if you really need to update state based on prop change,
// do so here
// pseudo code - if(previousProp !== prop){setState(newStateValue)}
return <ExpensiveComponent />
}
现在我们将考虑一些我认为更微妙的情况,在这些情况下你应该避免useMemo。
3. 用作useMemoESLint Hook 警告的逃生通道
虽然我实在无法忍受去阅读那些寻求抑制官方ESLint Hooks插件代码检查警告方法的评论,但我理解他们的困境。
我同意 Dan Abramov 的观点。从插件中禁用该eslint-warnings功能将来可能会给你带来麻烦。
一般来说,我认为在生产应用程序中抑制这些警告是一个坏主意,因为这会增加在不久的将来引入不易察觉的错误的可能性。
即便如此,在某些情况下,我们仍然需要抑制这些代码检查警告。以下是我遇到的一个例子。为了便于理解,代码已进行简化:
function Example ({ impressionTracker, propA, propB, propC }) {
useEffect(() => {
// 👇Track initial impression
impressionTracker(propA, propB, propC)
}, [])
return <BeautifulComponent propA={propA} propB={propB} propC={propC} />
}
这是一个相当棘手的问题。
在这个特定的用例中,你并不关心 props 是否改变。你只关心用初始trackprops 调用函数。这就是展示次数跟踪的工作原理。你只会在组件挂载时调用展示次数跟踪函数。这里的区别在于,你需要用一些初始 props 来调用该函数。
你或许认为简单地将 `<name>` 重命名props为类似 `<name>` 的名称initialProps就能解决问题,但这行不通。这是因为 `<name>`BeautifulComponent还依赖于接收更新后的 prop 值。
在这个例子中,你会收到 lint 警告信息:“ React Hook useEffect 缺少依赖项:'impressionTracker'、'propA'、'propB' 和 'propC'。请添加它们或删除依赖项数组。”
这条信息措辞略显生硬,但代码检查工具只是在履行其职责。最简单的解决方法是添加注释eslint-disable,但这并非总是最佳方案,因为useEffect将来在同一调用中可能会引入新的错误。
useEffect(() => {
impressionTracker(propA, propB, propC)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
我的建议解决方案是使用useRefHook 来保留对不需要更新的初始属性值的引用。
function Example({impressionTracker, propA, propB, propC}) {
// keep reference to the initial values
const initialTrackingValues = useRef({
tracker: impressionTracker,
params: {
propA,
propB,
propC,
}
})
// track impression
useEffect(() => {
const { tracker, params } = initialTrackingValues.current;
tracker(params)
}, []) // you get NO eslint warnings for tracker or params
return <BeautifulComponent propA={propA} propB={propB} propC={propC} />
}
在我的所有测试中,代码检查器只useRef对这种情况有效。使用`--require` 后useRef,代码检查器会认为引用的值不会改变,因此不会发出任何警告!即使使用 `--require` 也无法useMemo阻止这些警告。
例如:
function Example({impressionTracker, propA, propB, propC}) {
// useMemo to memoize the value i.e so it doesn't change
const initialTrackingValues = useMemo({
tracker: impressionTracker,
params: {
propA,
propB,
propC,
}
}, []) // 👈 you get a lint warning here
// track impression
useEffect(() => {
const { tracker, params} = initialTrackingValues
tracker(params)
}, [tracker, params]) // 👈 you must put these dependencies here
return <BeautifulComponent propA={propA} propB={propB} propC={propC} />
}
在上述错误方案中,即使我通过记忆化初始属性值来跟踪初始值useMemo,代码检查器仍然会报错。在useEffect调用过程中,记忆化的值tracker仍然params需要作为数组依赖项输入。
我见过有人useMemo这么做。这种代码写法很糟糕,应该避免。请使用useRefHook,就像初始解决方案中展示的那样。
总之,在大多数我确实想要关闭衣物掉毛警告的合理情况下,我发现它useRef是个完美的帮手。欣然接受吧。
4.useMemo仅用于参照等式
大多数人说应该用它useMemo来处理复杂的计算和维护引用等式。我同意第一点,但不同意第二点。不要useMemo仅仅为了维护引用等式而使用 Hook。这样做只有一个原因——我稍后会讨论。
为什么useMemo仅仅用于指称相等关系是件坏事?难道这不是大家都提倡的吗?
请看下面这个精心设计的例子:
function Bla() {
const baz = useMemo(() => [1, 2, 3], [])
return <Foo baz={baz} />
}
在组件中Bla,值baz被记忆化并不是因为数组的计算成本很高,而是因为每次重新渲染时[1,2,3]对变量的引用都会发生变化。baz
虽然这看起来似乎不是个问题,但我认为useMemo这里不应该使用这种钩子。
第一,查看数组依赖关系。
useMemo(() => [1, 2, 3], [])
这里,传递给useMemoHook 的是一个空数组。这意味着该值[1,2,3]只会在组件挂载时计算一次。
所以我们知道两件事:被记忆化的值不是昂贵的计算,而且在挂载后不会重新计算。
如果你发现自己处于这种情况,我建议你重新考虑一下 Hook 的使用useMemo。你缓存的值并非计算成本高昂,而且在任何时候都不会重新计算。这完全不符合“缓存化”的定义。
这是对useMemoHook 的一种糟糕用法。它在语义上是错误的,而且很可能会造成更大的内存分配和性能损失。
那么,你应该怎么做?
首先,作者究竟想实现什么目标?他们并不是想记忆某个值;相反,他们希望在重新渲染时保持对某个值的引用不变。
别给那只狡猾的变色龙任何机会。遇到这种情况,就用useRef钩子。
例如,如果您真的非常讨厌当前属性的用法(就像我的很多同事一样),那么只需按如下所示进行解构和重命名即可:
function Bla() {
const { current: baz } = useRef([1, 2, 3])
return <Foo baz={baz} />
}
问题解决了。
事实上,你可以使用 `require`useRef来保持对昂贵函数求值的引用——只要函数不需要在 props 更改时重新计算即可。
useRef是应对这种情况的正确钩子,而不是useMemo钩子。
利用useRefHook 模拟实例变量是 Hook 最不常用的强大功能之一。Hook 的useRef功能远不止保存对 DOM 节点的引用。好好利用它吧。
请记住,这里所说的条件是指,如果您仅仅因为需要保持对某个值的引用一致而对其进行缓存。如果您需要根据属性或值的变化重新计算该值,那么请随意使用钩子useMemo函数。在某些情况下,您仍然可以使用useRef`--memo`,但useMemo考虑到数组依赖关系列表,`--memo` 通常更方便。
结论
远离狮子,但别被变色龙蒙蔽。如果你放任它们,变色龙会改变肤色,融入你的代码库,污染你的代码质量。
别让他们得逞。
想知道我对高级 Hooks 的看法吗?我正在制作一个关于高级 Hooks 的视频课程。注册后,我会第一时间通知你!
编者按:发现本文有误?您可以在这里找到正确版本。
插件:LogRocket,一款用于 Web 应用的 DVR

LogRocket是一款前端日志工具,可让您重现问题,如同在您自己的浏览器中发生一样。无需猜测错误原因,也无需用户提供屏幕截图和日志转储,LogRocket 即可让您重现会话,快速了解问题所在。它与任何框架的应用程序完美兼容,并提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的额外上下文信息。
除了记录 Redux 操作和状态之外,LogRocket 还会记录控制台日志、JavaScript 错误、堆栈跟踪、包含标头和正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能生成像素级精确的视频。
免费试用。
这篇文章《你过度使用 useMemo:重新思考 Hooks 记忆化》最初发表在LogRocket 博客上。
文章来源:https://dev.to/bnevilleoneill/you-re-overusing-usememo-rethinking-hooks-memoization-19g8



