欢迎来到知多少栏目,我是今天的主持人【赵花花5070】,今天邀请了三位神秘嘉宾,分别是memo(), useMemo(), useCallback() 。
在正式介绍三位嘉宾之前,我们先去看看他们平时的工作环境是怎么样的,请看示例。
NodeJs
v16.13.2
React
“react”: “^18.2.0”
“react-dom”: “^18.2.0”
> ...
> src
不知道大家对这三位嘉宾有多少了解,但不论是了解还是不了解的,都请容许我在这里再次介绍一下。
memo 主要用来优化函数组件的重复渲染行为,针对的是一个组件。
光说是没有用的,我们拿个示例来看看,到底有什么样的效果。
使用memo缓存组件
const MemoChild = memo((props) => {
console.log('子组件被重新渲染了')
return <div>子组件</div>
})
左侧用于触发事件的组件
const LeftPage = (props) => {
const {fn} = props;
const [count, setCount] = useState(0);
return (
<div>
<div>left</div>
<button onClick={() => {
let num = count + 1;
setCount(num)
fn(num)
}}>点击::</button>
</div>
)
}
父组件
const App = () => {
console.log("============重新渲染分隔符============")
console.log("父组件==》》")
const [count, setCount] = useState(0);
const changeFn = (val) => setCount(val);
return (
<>
<div>
<div>count值变化::{count}</div>
</div>
<LeftPage fn={changeFn} />
<MemoChild/>
</>
)
}
上述代码运行结果如下:
可以看到每一个组件都被渲染两次,这是因为React的严格模式导致的,react严格模式是为了在我们开发过程中帮助我们发现小bug,通过故意重复调生命周期函数让我们发现问题。
需要注意的一点是这仅适用于开发模式,生产模式下生命周期不会被调用两次
。
如果不想要渲染两次就直接把src目录下入口文件index.js文件中的
标签给注释或者拿掉就行。
那么此处就不深究那么多(这不是本次笔记的重点),依然使用严格模式。
此时通过另外一个组件去触发变更count值,观察一下使用memo包装的组件是否再次渲染。
可以看到使用memo包装的组件没有再次渲染,这样子就起到了一个组件缓存的作用,但实际项目中,有的组件是想要缓存但是又有依赖属性,这又如何使用,又能否实现呢?
请接着往下看。
避雷点1 —> 传递固定不变number类型参数
在上面1的代码基础上稍作修改,其他组件不变化,再观察一下运行结果有什么变化。
memo包裹的组件打印输出父组件传递的属性a
const MemoChild = memo((props) => {
console.log('子组件被重新渲染了')
return <div>子组件{props.a}</div>
})
App组件新增属性 a = {1}
...
return (
<>
...
<MemoChild a={1}/>
...
</>
)
给memo包裹的组件新增属性a,那么此时再次触发变更count值事件,那么memo又有什么变化呢?
连续触发两次变更事件后通过截图可以看出,跟上面不传递参数的输出并没有什么区别,为什么呢?
不着急,接着往下看,最后我们再来总结一下捋一捋。
避雷点2 —> 传递一个布尔值
App组件新增属性 a={true}
...
return (
<>
...
<MemoChild a={true}/>
...
</>
)
避雷点3 —> 传递改变的参数(count)
上面既然传递的是一个固定不变的值,那这次传递一个变化的值会怎么样呢?
继续在上次的代码的基础上稍微改吧改吧。
App组件新增属性 a = {count}
...
return (
<>
...
<MemoChild a={count}/>
...
</>
)
避雷点4 —> 传递一个函数
App组件新增属性 a={() => {}}
...
return (
<>
...
<MemoChild a={() => {}}/>
...
</>
)
避雷点5 —> 传递一个数组
App组件新增属性 a={[1,2,3,4,5]}
...
return (
<>
...
<MemoChild a={[1,2,3,4,5]}/>
...
</>
)
避雷点6 —> 传递一个对象
App组件新增属性 a={{a:1, b: 2}}
...
return (
<>
...
<MemoChild a={{a:1, b: 2}}/>
...
</>
)
通过上述所有示例运行结果来看,这个就不难看出来是跟数据类型有关系的。
通过上面三条观察结果,可以得出,每次组件是否发生变化,是与组件接收的参数 props 里面的数据指向内存中映射地址
是有关系的。如果这个地址发生了变化,那么组件会再次渲染,如果没有变化,则不渲染。
useMemo 返回的是一个值。
说明白了memo,接下来说一下useMemo,这个就不是对应的组件,请看示例。
const App = () => {
console.log("============重新渲染分隔符============")
console.log("父组件==》》")
const [count, setCount] = useState(0);
const changeFn = (val) => setCount(val);
const a = 1;
const result = useMemo(() => {
console.log('useMemo被执行了')
return a + 1;
}, [a])
return (
<>
<div>
<div>count值变化::{result}</div>
</div>
<LeftPage fn={changeFn} />
</>
)
}
可以看的出来,监听一个不变且函数体内进行计算的值也不变的值,除了初始化的时候渲染执行,其后的每次事件触发都没有再次执行,说明这是起到了一个缓存的目的。
这时候我突发奇想,如果我监听一个变化,但函数体内进行计算的值是变化
的,那输出又是怎么样的呢?
...
const result = useMemo(() => {
console.log('useMemo被执行了')
return count + 1; // 函数体内进行计算的值count
}, [a]) // 监听的值a
return (
<>
...
<div>
<div>count值变化::{result}</div>
</div>
...
</>
)
这个运行结果与上面的示例没有什么不同,那么接下来再看看下面这个示例。
const result = useMemo(() => {
console.log('useMemo被执行了')
return a + 1; // 函数体内进行计算的值a
}, [count]) // 监听的值count
通过上面三个示例,大家应该就能够完全明白这个的具体使用方法及注意事项,这里的函数体内是否执行的关键是看 useMemo(() => {}, [ params1,params2,… ]) 第二个参数里面的值是否发生变化。
注意:即使jsx中没有使用useMemo返回的值,初始化及监听数据发生变化的时候还是会执行,不会起到缓存的目的。
useCallback 返回的是一个函数,如果需要使用则需要调用这个函数。
都说这个useCallback具有对函数体缓存的作用,如果再次触发就不会再创建这个函数,然而实时上不是的。真相往往会让人大吃一惊。
const changeFn = useCallback(() => {
// 可以做一些事情
},[params1,params2,...])
只有当params1 或者 params2中的任意一个依赖项发生变化,就会执行这个useCallback()函数。
是不是以为只要使用useCallback包裹的函数,就能够进行缓存,不再被创建新的函数体,继续执行上一次的函数,不论依赖项是否发生变化,useCallback里面的函数体逻辑都走缓存?
不用多想,我就知道很多人都是这么想的,我以前在项目中也有这么使用过。
可是再一想,不对呀,如果都走缓存,为什么官方不直接使用缓存技术,还特意创建一个hooks api?
实时上有点想当然了,实际上的初始化的时候会在内存中申请一篇空间创建一个函数,这就跟创建普通函数一模一样,即使使用useCallback。
当数据发生变化时,上一次没有使用useCallback包裹的函数会被丢掉,重新创建内存空间进行存储。
而采用useCallback包裹的函数不但要执行useCallback额外的函数,还要在内存中创建一个新的空间用于函数存储。为了判断数据是否需要使用缓存,上次创建的不会被丢弃反而会存储起来,最后会对上次与本次创建的函数进行对比,如果依赖项没有发生变化则会直接返回上一次的计算结果,否则会重新计算。
需要注意的是,如果useCallback函数体中使用了组件中其他的依赖项,那么这个数据会一直存储在内存中不会被销毁。
如下示例:
const a = 1;
const fn = useCallback(() => {
return a + 1;
}, [])
上面例子中声明的常量 a 本来是可以使用完就销毁掉的,但是因为 fn 函数里面使用到了这个 a 常量,那么即使在 fn 函数使用完成后 a 常量也不会销毁掉。
这么一看使用useCallback一点好处也没有,并且如何正确使用,怎么才算是正确使用?
其实这个hook主要是为了解决,父组件有更新,子组件没有依赖项,子组件不更新(不重新计算)的问题而设计的。
但真正使用起来的时候并没有我们所想想的那么好使,一般需要搭配 memo 一起使用,啥意思呢?
就是说当使用memo缓存的子组件,其参数props同样被缓存,如果这个props没有发生变化,则子组件不会重新渲染也就不会造成useCallback里面重复计算,这样就能够起到一个优化的目的,但是一个函数计算一般都是很快,主要问题一般都是出在渲染问题上。
通过一段代码来验证一下。
const MemoChild = memo((props) => {
console.log('子组件被重新渲染了')
return <CenterPage type={1} fn={props.fn}/>
})
const App = () => {
console.log("============重新渲染分隔符============")
console.log("父组件==》》")
const handleClick = useCallback(() => {
console.log('useCallback被执行了')
}, [])
return (
<div className="app">
{/* */}
<div>
<div>{name}</div>
<button onClick={() => {
changeNameFn(name+'_111')
}}>点击::</button>
</div>
<CenterPage fn={handleClick}/>
{/* */ }
</div>
);
}
因为fn传递的是一个函数,所以每次父组件状态更新,那么子组件必然要再次渲染。
那么把上面的代码稍微修改一下呢?
...
const result = useMemo(() => {
console.log('useMemo被执行了')
return count + 1; // 函数体内进行计算的值count
}, [a]) // 监听的值a
return (
<>
...
{/* */ }
<MemoChild fn={handleClick}/>
</>
)
从上述运行结果中不难看出子组件没有再次渲染,那么就不存在useCallback包裹的函数重新渲染,所以使用的还是上次缓存下来的函数。
但是如果useCallback依赖的参数发生了变化,那么也就起不到什么作用了,会再次渲染一次,函数也会再次进行计算丢掉上次缓存的。
可以在上次代码的基础上再修改修改,接着往下看。
const handleClick = useCallback(() => {
console.log('useCallback被执行了')
}, [name])
最后为了始终保持函数缓存使用,可以考虑 ahooks 的 useMemoizedFn1 函数,从而达到持久化fn。
useMemo() 和 useCallback() 都接收两个参数,第一个参数为函数(fn),第二个参数为数组([]),数组中是变化依赖的参数2。
memo() 则可以直接作用于组件。
不建议大量使用memo, useMemo, useCallback 去做缓存,因为会增加大量无效工作导致重复渲染,特别是初次渲染增加极大负担,使代码变得难以维护不容易被理解。
持久化 function 的 Hook,理论上,可以使用 useMemoizedFn 完全代替 useCallback。 ↩︎
数组中的数据如果发生了变化则会执行 useMemo(),useCallback() 中的第一个参数函数体。 ↩︎