useCallback、useMemo是做缓存并优化性能,但是缓存机制也是有开销的,使用方法不正确会导致负优化
1、不要滥用useCallback
组件的state或者props变化会导致组件的re-render,用useCallback包裹的函数,只有在依赖项变化的时候才会生成新的函数引用指针,但是react官网里指出Hook 不会因为在渲染时创建函数而变慢
useCallback的设计初衷并非解决组件内部函数多次创建的问题,而是减少子组件的不必要重复渲染。
import React, { useState } from 'react';
function Comp() {
const [dataA, setDataA] = useState(0);
const [dataB, setDataB] = useState(0);
const onClickA = () => {
setDataA(o => o + 1);
};
const onClickB = () => {
setDataB(o => o + 1);
}
return <div>
<Cheap onClick={onClickA}>组件Cheap:{dataA}</div>
<Expensive onClick={onClickB}>组件Expensive:{dataB}</Expensive>
</div>
}
在父组件Comp中我们需要将函数 onClickB 传递给子组件Expensive(一个昂贵组件) ,这个时候Cheap 组件中触发onClickA,导致父组件中state(dataA)变化,从而re-render父组件,生成新的onClickB,导致React在diff子组件Expensive 组件时,判定属性onClick发生了变化,从而会去re-render子组件Expensive。
所以可以将onClickB用useCallback包裹起来,来保证点击Cheap组件不会生成新的onClickB,因此也不会触发Expensive组件的刷新,只有点击Expensive组件才会触发。从而可以减少不必要渲染。
useCallback正确使用:只有当子组件是一个昂贵组件的时候,传给子组件的方法才有必要用useCallBack包裹一下
- 注:子组件需要用React.memo包裹去做浅比较,传递给子组件的useCallback回调函数才有意义,如果不用React.memo包裹,父组件渲染,子组件就会重新执行,重新执行才会生成新的父组件的虚拟dom树,然后才会dom diff
2、不要滥用useMemo
创建一个memo也是需要开销的。只有当创建行为本身会产生高昂的开销(比如计算上千次才会生成变量值),才有必要使用useMemo。
3、useCallback存在的缺点与解决方法
3.1、缺点
- 函数组件都是有闭包状态的(函数组件中,每次更新state=>导致函数组件重新执行一遍,形成一个新的闭包。每个闭包中的state值是相互独立的,互不干扰。而在Class组件中由于都是在this的上下文中改变state的值,而不会形成隔离的闭包),useCallback中的第一个传参callback 内部对 state 的访问依赖于 JavaScript 函数的闭包,可以简单理解为useCallback返回的函数也是那次闭包环境下产生的函数,里边的变量和属性的值都是当时闭包环境下的值,调用函数的时候 再将缓存的函数拿出来使用,所以callback中获取不到当前环境下的最新的值,只能获取到当时环境下的值,也就是说,如果希望 callback 不变,那么访问的之前那个 callback 函数闭包中的 state/props 会永远是当时的值,只有将需要用到的state/props放入useCallback依赖中才能生成新的callback,每次才能获取到最新的state/props。
- 当useCallback依赖项经常变化时,callback依然会进行经常更新,甚至会因为依赖项的对比判断造成性能损耗
3.2、解决方法
在绝大多数情况下,开发者想要的仅仅只是避免函数的引用变化而已,要解决的问题实质是:怎么可以让我们传入的函数返回一个引用一直不变且可以让传入的函数内部所使用的state都是最新的呢?---useRef
- 1、定义一个ref,将useCallback里用得到数据在ref上存储一份,这样不会更改ref
function Form() {
const [text, updateText] = useState('');
const textRef = useRef();
useEffect(() => {
textRef.current = text; // 把它写入 ref一遍
});
const handleSubmit = useCallback(() => {
const currentText = textRef.current; // 从 ref 读取它
alert(currentText);
}, [textRef]); // 不要像 [text] 那样重新创建 handleSubmit
return (
<>
<input value={text} onChange={e => updateText(e.target.value)} />
<ExpensiveTree onSubmit={handleSubmit} />
</>
);
}
将callback中用到的state值存入到ref中,ref引用不会变,这样能保证callback指针不变,且通过ref.current能一直拿到最新的值。
- 2、将函数callback绑定到 useRef
ahooks中的usePersistFn(useMemoizedFn)源码
function usePersistFn(fn) {
// 每次传进来的fn都是新的内容
const fnRef = useRef(fn);
// 由于useRef每次刷新不重设值
// 所以这里多了一步设置current
fnRef.current = fn;
const persistFn = useRef();
// 只有初次会触发true
if (!persistFn.current) {
// 只赋值一次,保证指针一直不变
persistFn.current = function (...args) {
// fnRef.curret是实时改变的
// 保证内容是最新的
return fnRef.current.apply(this, args);
};
}
return persistFn.current;
}
首先创建persistFn的ref,然后第一次渲染时,!persistFn.current 会返回true,则会将匿名函数赋值给persistFn.current 。
至于return fnRef.current.apply(this, args); 使用apply只是为了保证this指向,匿名函数也可以换成箭头函数,就不需要apply,比如改成这样:
persistFn.current = (...args) => fnRef.current(args);
- 3、对于更改state的,且需要将回调层层传递的,官网推荐的替代方案是通过 context 用
useReducer
往下传一个dispatch
函数
const TodosDispatch = React.createContext(null);
function TodosApp() {
// 提示:`dispatch` 不会在重新渲染之间变化
const [todos, dispatch] = useReducer(todosReducer);
return (
<TodosDispatch.Provider value={dispatch}>
<DeepTree todos={todos} />
</TodosDispatch.Provider>
);
}
但问题是现在直接使用Context会有大量无用渲染的问题,要想减少无用渲染,需要注意的点有点多,比如需要做到以下几点:
1、拆分Context
2、关注Context的顺序,让不变的放在在外层,多变的在内层。
3、在当前React Context缺乏context selectors这种机制的情况下,建议使用状态管理库代替Context,毕竟大部分状态管理库都会带有selectors机制来优化性能。
参考: