【译&补】使用ref回调替代useRef吧
在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。
本文深入探讨了React中的useMemo和useCallback的使用场景和性能影响。useMemo用于缓存计算结果以减少不必要的计算,适合在计算开销较大的情况下使用,而useCallback用于缓存函数定义以避免不必要的组件刷新。作者强调,过度使用这两个Hook可能导致性能下降,建议在实际需要时再使用,并提出了其他优化方案,如使用useReducer和合理组织组件逻辑。
今天写需求的时候被mentor敲打了,说随便用useCallback
不好,但没有细说原因。此前在跟其他老哥交流的时候也告诉我说能不用就不用,我也很好奇为什么,这里就来好好深究一下useCallback
和useMemo
,以及它们到底应该在什么情况下使用。(最后发现自己之前的理解完全错了)
useMemo
开始首先,我们在react.dev官网看看它的定义:
useMemo is a React Hook that lets you cache the result of a calculation between re-renders.
根据定义,useMemo
的作用很纯粹:在组件更新时缓存计算结果。
熟悉Vue的同学肯定立刻想到了computed
计算属性,但不尽然如此(至少在传统选项式API中)。在React中的函数组件和JSX语法里,其实可以直接定义一个用于计算逻辑的函数,它可以作为闭包可以直接获取状态,并在每次re-render的过程中都会重新定义和运行,并不需要做特殊的“响应式优化“,如下面的代码所示:
function MemoDemo(props) {
// ... some states definition
const calcProduct = () => {
// ... some calculation
}
const product = calcProduct()
const cachedProduct = useMemo(calcProduct, [...])
return <div>{product}</div>
}
可以看到,在上面这个简单的例子中,直接定义一个闭包函数并在渲染时调用它就可以实现运算逻辑的封装,避免了return
部分的过度复杂。
在Vue2(或者说选项式API)中computed
计算属性除了用于优化外更多的是因为只有在computed
中才能拿到data
和props
;而在Vue3里的computed
Hook就和React中逻辑几乎类似了。
<template>
<div>{{product}}</div>
</template>
<script setup>
// ... some states definition
// Hook Style: 在Vue3组合式API中和React基本一致
const calcProduct = () => {...}
const product = calcProduct()
const cachedProduct = computed(calcProduct)
// Traditional: 选项式API中必须在组件内部才能得到响应式状态
data() {
...
}
computed() {
...
}
</script>
useMemo
的真实用途上面我们知道,直接创造闭包就可以实现类似“计算属性”的功能,其实现方法是在每次重新渲染时都重新生成和运行闭包函数。而useMemo
存在的意义就是当这次重新运行开销很大时尽可能地优化(没有必要就不刷新),降低加载时间。
const memorized = useMemo(expensiveCalculation, [deps])
useMemo
可以在re-render之间缓存值,利用这个特性可以减少不必的计算,只在相关量(deps)更新时才重新运行计算过程,这也是这个Hook的本意。
比如在一个科学运算App中,除了远算结果外还有很多无关的状态如theme
,其他无关变量等,在这些无关状态修改时有可能带动整个应用一起刷新,这时如果有useMemo
来缓存结果就可以避免重新进行运算。
由于JS在每次运行形如{}
的字面量或function
和()⇒{}
声明的函数时都会生成新的变量(具体来说是新的指针引用),而当指针更新时触发React更新。(即使它们的内容可能是完全相等的)
而React函数组件就相当于一个函数闭包,每次刷新组件就相当于重新运行一次这个函数,其中的对象和函数都会生成一个新的指针,这就会导致接受了这些新指针作为props的组件可能会做无意义的刷新。
memo
声明可缓存组件,并用useMemo
缓存传入的props
useMemo
来缓存组件,并把变量(ReactNode)直接传入JSXfunction ComplexComponent(props) => {...}
// 法1:使用memo缓存组件,useMemo缓存props
const CachedComplexComponent = memo(ComplexComponent(props))
function App {
const props = useMemo(..., [])
// 法2:直接使用useMemo缓存JSX组件
const cachedComponent = useMemo(<ComplexComponent ...props />, [props])
// 以下两种方式效果是等价的
return (
<>
<CachedComplexComponent props={props} />
{cachedComponent}
</>
)
}
后者之所以可行是因为React Node实际上也只是一个普通的JS对象,对应V-DOM上的一个节点,因此可以被缓存和计算。
但前者是更传统的方法,也是更推荐的做法,因为直接使用useMemo
缓存整个组件不能更方便地选择哪些情况下缓存,而memo
可以直接缓存对应props。
根据官方文档,在新的缓存Hook如useMemo
和useCallback
中都使用了Object.is
来判断相等,而此前就存在的API如React.memo
则使用传统的引用相等(全等===
)来判断相等。
Object.is
依据以下原则判断相等:
undefined
, null
, true
, 字符串等Object.is
和===
唯一的区别是处理+0,-0和NaN时,Object.is
将他们分开看待,而===
将-0和+0视作相等,每个NaN都是不同的引用。
== 在比较前会进行隐式转换,这导致了许多潜在问题,目前唯一有用的应用可能是== null用于判断空值
useMemo
一般来说,大部分运算是足够快的,不需要对其优化就可以流畅运行(除非你已经感觉到了因计算产生的卡顿)。
需要注意的是,在开发时(Dev Mode)得到的性能指标往往是不准确的,他们往往和实际生产环境有一些区别:
因此,考虑到实际情况,真正需要用到useMemo
的场景其实出乎意料的少:
memo
使用)useMemo
的性能开销使用useMemo
进行优化实际上可以看成两个不同的阶段:
useMemo
并不能优化这个过程,甚至会引入额外的开销useMemo
根据deps数组内依赖项,使用Object.is
比较决定是使用缓存值还是重新运行计算过程而useMemo
的性能开销也可以分为两个部分:
useMemo
为了判断是否使用缓存值引入的开销也就是说是否需要引入useMemo
来优化性能变成了两种开销的trade-off问题。
根据这篇博客的方法,我们对useMemo
进行性能测试,并更改循环的运行次数进行重复实验,试图找出使用useMemo
的“甜蜜点“(虽然大部分时候我们不需要这么做)。
从n=1的测试结果我们可以看出,未memo的运算时间基本保持稳定,而memo后的结果虽说差别仍很小,但可以看出在这样轻量级的计算下引入memo在首次渲染和后续更新都会拖慢速度,显然是得不偿失的。
值得注意的是,即便在这种数量级下,memo仍相比重新计算有近19%的落后幅度。
在n=100的环节下useMemo
开始逐渐挽回颜面,在后续更新环节中似乎比原生方法要快那么一丁点儿,但首次渲染仍显著地慢于原生方法。
这证明了此前大部分的计算过程都不需要用memo缓存的结论。
在n=1000的情况下发生了有趣的现象,即useMemo
在首次运行时的开销暴涨了,与此同时重复渲染的开销并没有明显优于原生方法(约37%)。这可能是由于useMemo
需要对运算结果进行缓存的缘故。
在n=5000时,useMemo
终于开始发挥作用。虽然在首次渲染时useMemo
花费了显著的额外时间用于缓存结果,但在后续的渲染中获得了喜人的提升。
useMemo
来缓存运算结果,除非重新运算让你感受到了卡顿useCallback
照例我们在从react.dev官方看看其定义:
useCallback is a React Hook that lets you cache a function definition between re-renders.
定义很简单,和useMemo
相比的区别就是一个缓存函数的返回值,一个缓存函数本身。
const cachedFn = useCallback(fn, dependencies)
useCallback
直接返回fn
useCallback
根据deps
是否更新决定返回上次得到的fn
还是重新计算fn
的闭包从某种意义上来说,useCallback
可以看作useMemo
的语法糖:
// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
useCallback
的真正用途这时有同学就要发出疑问了,将函数缓存起来有什么用吗?初始化一个函数真的有很大开销吗?再复杂的函数不运行它开销也不会很大呀。
我在这里要说:你说得对!
不仅创建函数闭包没有任何性能问题(React官方承诺),而且useCallback
总是会引入额外的开销,因为函数总是会被创建,这是不可避免的!
因此,如果你想用useCallback
来“优化“所谓函数创建过程,那不能说是南辕北辙,只能说是背道而驰。
useCallback
在React中只有唯二两个真实作用:
deps
数组的情况,死循环memo
组件使用下面这个简单的例子可以解释useCallback
的工作效果:
function Demo() {
const [state, setState] = useState(0);
const log = () => {
console.log(state);
};
return <button onClick={() => () => setState((prev) => prev++)}>{state}</button>
}
在上面这个简单的例子中,Demo
组件其实是一个普通函数,其内的log
函数就是包含state
的闭包,每次按下按钮刷新组件时,log
变量就会被整个重新初始化一遍,将其内的state
刷新成最新的值。
而下面这个例子引入了useCallback
,运行后我们可以清楚地看出区别:
function Demo() {
const [state, setState] = useState(0);
const cachedLog = useCallback(() => {
console.log(state);
}, []);
return <button onClick={() => () => setState((prev) => prev++)}>{state}</button>
}
这个例子中由于deps
数组为空,因此只会在组件首次加载时返回一次fn
,后续都使用缓存的值,因此无论怎么调用cachedLog
都只会打印初始值0。
useCallback
useCallback
用于优化函数创建了!因此和useMemo
类似,useCallback
的适用场景更窄,只有在以下两个场景才是有价值的:
memo
组件,且不希望该组件因为回调函数的刷新而意外更新(尤其是在细粒度更新的应用中)useEffect
)中被调用并作deps
被传入,如果不使用useCallback
缓存起来就会导致循环刷新useCallback
*包裹,确保调用者可以正常优化但上述两种情况或多或少都有其他的解决方案,比如你可以将被缓存的函数移到Effect函数体内部作为闭包,这样可以避免额外的开销。
其实在很多时候比起使用useCallback
缓存回调函数,我们有其他更“高明“的方法来避免刷新。
useEffect
闭包很多时候我们在useEffect
中调用的函数实际上不需要放在顶层作用域中,放在useEffect
函数体内部作为闭包是更好的做法,这避免了useCallback
的额外开销。(用于判断函数是否更新)
function Demo() {
const cachedInit = useCallback(() => {
...
}, [...])
useEffect(() => {
const init = () => {
...
}
init()
}, [])
useEffect(() => {
cachedInit()
}, [cachedInit])
}
在上面的代码中,前后两个useEffect
的效果是一样的,但前者避免了useCallback
的开销,显然是更好的选择。
useReducer
在redux一文中我们了解到了store和reducer机制,而只要store
本身不变,无论状态如何改变,dispatch
是不会改变的。
因此我们可以使用useContext
+ useReducer
,用dispatch
来代替useCallback
回调函数来执行操作,也可以绕过缓存来避免刷新。
const TodosDispatch = React.createContext(null);
function TodosApp() {
// Note: `dispatch` won't change between re-renders
const [todos, dispatch] = useReducer(todosReducer);
return (
<TodosDispatch.Provider value={dispatch}>
<DeepTree todos={todos} />
</TodosDispatch.Provider>
);
}
仅应该使用useCallback
和useMemo
来作为性能优化手段,而不是实现业务逻辑(意思是离开了这些缓存Hook也应该保持功能一致,只有性能差别)。
在很多情况下,记忆化缓存(memorization)都是不必要的。比起大量使用useMemo
和useCallback
这样的缓存Hook,我们更应该从优化组件的组织方式和逻辑本身入手。
props.children
来在调用时向Wrapper组件传入组件,而不是直接写在Wrapper组件的声明中,这样可以避免因Wrapper自己状态变化导致内部组件刷新**(Why?)**作者: ChlorineC
创建于: 2023-08-13 16:48:00
更新于: 2025-01-19 05:10:00
版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。
深入探讨React Hooks的设计原则、实现机制及其在函数式组件中的应用,强调声明式渲染的优势,并提供自定义Hooks的实现示例,如useAsync,用于处理异步请求和状态管理。
作者分享了个人博客框架迭代历程:从Hexo迁移到Astro的尝试,到发现Elog平台带来的启发,最终思考将Notion作为内容管理系统的新方案。文章重点描述了作者对博客系统的核心需求——解决创作同步问题,以及在探索NotionNext等解决方案过程中遇到的技术限制和思考。
本文探讨了前端开发的复杂性,提倡回归以后端为中心的架构,强调使用简单的工具和技术,如模板引擎、jQuery、alpine.js和htmx,来简化开发流程。作者认为,现代前端工具链的复杂性逐渐偏离了简化开发的初衷,建议在不引入过多复杂性的情况下,利用Web Components等标准技术来实现可复用组件,从而提高开发效率。