本文属于我在公司的一篇技术分享文章,它在我之前 React性能优化,六个小技巧教你减少组件无效渲染一文的基础上进行了拓展,增加了工具篇以及部分更详细的解释,所以内容上会存在部分重复,以下是分享的原文。
在过去一段时间,好像每次代码走读大家都对于useMemo、useCallback
以及memo
的使用都会存在部分疑惑,比较巧的是这几个API
都与性能优化相挂钩;可以想象性能优化这一块一定会属于未来前端团队挑战之一,所以掌握部分优化技巧是很有必要的。那么这一次我想聚焦在react
组件渲染优化上,为大家分享无效渲染常见排查手段以及避坑经验,通过本文大家将收获如下几个知识点:
useMemo
、memo
、useCallback
,什么时候该用什么时候不该用?useSelector
每次都会执行吗?聊聊store
更新机制其实在之前我一直在强调我们需要减少的是无效渲染,而不是渲染;对于一个组件而言,状态如果发生了改变,组件自身再次渲染这非常合理,但如何组件状态或数据未改变,那此刻的渲染就是无效渲染。
回归到无效渲染,我将无效渲染的原因分为两类:
prop
不稳定关于第一点不难理解,比如现在需要开发一个相对复杂的功能,这个功能包含A B C三个子功能,正常来说我应该为三个功能定义三个组件,以及对应的三个状态,但假设有同学就是让这三个组件共用了一个状态,那此刻不管你动了谁,另外两个组件都得跟着渲染。
这类问题其实不仅存在state
定义上,对于store
的接口封装同样会有相同的问题,比如公司项目里存在如下类似的代码:
我之前疑惑,为什么这里不是直接一句useSelector
取出user
对象,然后直接解构出五个属性,而不是要写五遍useSelector
一个一个的取。在沟通后了解到,这么做就是为了避免其它地方的组件改动了user
里的某个属性,而这里直接取user
的话因为引用一定会变从而导致重新渲染,所以单点取的好处就是你外面改了这里五个属性之外的其它属性,我这里因为没用到,所以就不会重新渲染。
回到user
的数据设计上,如果user
的数据结构包含万物,它能被使用的组件越多,那么它被影响的可能性自然就越大,所以尽可能保证数据设计的简洁以及合理性很有必要。
造成无效渲染的第二大原因就是引用数据类型引用不稳定所导致,举个最简单的例子,组件A渲染前后都接受了一个空数组,且两次数组的引用都不同,这对于组件而言因为引用不同所以是新数据自然得再次渲染,但对于研发而言,我们心里其实是知道这是无意义的,所以如何保证其引用的稳定性就是解决无效渲染的核心了。
说到这里有同学可能就会想,那是不是组件内只要产生新引用数据的行为就不对呢?其实并不是,当数据本身就应该更新时,它在这一刻产生一个全新的引用很合情合理,不然我们项目里什么filter、map
之类的岂不是都用不了了。你也可以想想react
自身的setState
更新,我们更新state
时本身也是得传入一个全新的对象而不是直接修改,所以要更新时产生新对象非常合理。
const App = () => { const [state, setState] = useState({ name: "听风", age: 29 }); const handleClick = () => { // 错误做法,直接修改 state, 不会更新 // state.name = '行星飞行'; // 正确做法就得重新赋予一个全新的对象,不然 state 不会更新 setState({ ...state, name: '行星飞行' }); } return ( <div> <div>{state.name}</div> <div>{state.age}</div> <button onClick={handleClick}>change name</button> </div> ) };
我们知道class
组件的PureComponent
以及函数组件的memo
都具有浅比较的作用,所谓浅比较就是直接比较前后两份数据是否相等,比如:
const a = []; const b = a; // 因为 a b 引用和值都相同,所以相等 a === b; // true const c = []; // 虽然 c 也是空数组,但是引用不同所以不相等 a === c; // false
那么加memo
到底解决了什么问题?我们假设组件前后都接收了一个空数组,且它们引用也相同,那么此时如果我们组件套用了memo
,那么组件就不会因为这个完全相同的数据重复渲染。这里我写了一个在线的memo例子方便大家理解效果,大家可以点击按钮查看控制台,直接对比加与不加memo
的差异。
在这个例子中,我在组件外层定义了一份引用始终相同的数据user
,之后通过点击按钮故意改变父组件P
的状态让其渲染,以此带动子组件C1 C2
渲染,可见加了memo
的C2
除了初次渲染之后并不会跟随父组件重复渲染,这就是memo
的作用。
当然,假设我们的user
每次都是重新创建的新对象,那我们加了memo
也没任何作用,毕竟引用不同浅比较判断为false
,还是会重复渲染。
另外,请合理使用memo
,并不是所有场景都需要这么做,这会增加内存开销,假设你的组件的数据流足够简单甚至没有props
,你完全没必要在组件外层套一层memo
。
那么接下来的建议,也都是基于子组件加了memo
展开的,不然你即便保证父组件每一个数据引用都不变,父组件渲染时子组件还是一样会渲染(默认行为)。
正常来说,比如子组件的userList
属性规定类型是数组,而在父组件加工数据时提供默认值是非常好的习惯,大家可能经常看到这样的写法:
const App = (props) => { // 假定userList是接口提供,接口没回来取不到 const userList = props.userList || []; return ( <Child userList={userList} /> ) };
那这就造成一个问题,当接口没响应完成,只要App
发生渲染,此刻userList
都会不断被重新赋值空数组,对于Child
而言,因为每次引用不同,自然Child
也都要跟着渲染,所以正确的做法是将默认值提到组件外:
const emptyArr = []; const App = (props) => { const userList = props.userList || emptyArr; return ( <Child userList={userList} /> ) };
这一种也是最直接也最容易看出来的一种不规范写法,一般存在于对于react
不太了解的新人或者一些老旧代码中,比如:
const App = () => { return ( // 这里每次都会传递一个新的空数组过去,导致Child每次都会渲染,加了 memo 都救不了 <Child userList={[]} /> ) };
当然,它也可能不是一个空数组,但注定每次都是一个全新引用的数据:
const App = (props) => { return ( <Child style={{color : red}} /> ) };
再或者使用了产生新数组的方法,比如:
const App = (props) => { const getUserList = ()=>{ return props.userlist.map((e) => e.name) } return ( <Child userList={getUserList()} /> ) };
我们知道useMemo
与useCallback
都能起到缓存的作用,比如下面这个例子:
// 只要 App 自身重复渲染,此时 handleClick 与 user 都会重新创建,导致引用不同,所以 C 即便加了 memo 还是会重复渲染 const App = (props)=> { const handleClick = () => {}; const fn = () => {} const list = []; const user = userList.filter(); return <C onClick={handleClick} list={list} user={user} /> }
只要组件App
自身重复渲染,组件内的这些属性方法本质上会被重新创建一遍,这就导致子组件C
即便添加memo
也无济于事,所以对于函数组件而言,一般要往下传递的数据我们可以通过useMemo
与useCallback
包裹,保证其引用稳定性。当然,如果一份数据只是App
组件自己用,那就没必要特意包裹了:
// 常量提到外层,保证引用唯一 const list = []; const App = ()=> { // 使用 useCallback 缓存函数 const handleClick = useCallback(() => {}); // 只是自己使用,不作为props传递时,没必要使用 useCallback 嵌套 const handleOther = () => {} // 使用 useMemo 缓存结果 const user = useMemo(()=>{ return userList.filter(); },[userList]) return <C onClick={handleClick} list={list} user={user} /> }
比如上述代码中的handleOther
就是组件自身用,它不作为props
往下层传递,那就根本没必要给这个函数做缓存。
另外问大家一个问题,不管是 useCallback
还是 ahooks
的 useMemoizedFn
,假设有下面这段代码:
const add = (a,b) => a + b; const add_ = useMemoizedFn(add); // 或者 // const add_ = useCallback(add, []); add_(1,2); add_(1,2);
请问第二次执行 add_
时, a + b
这段逻辑会走吗?
会,因为useCallback
缓存的是函数本身,它不会帮你缓存函数的结果,它的作用就是帮你保证函数引用不变,仅此而已。
总结来说,useMemo
、useCallback
这些与memo
一定一定是配合使用的,如果下层组件加了memo
,那么你上层组件就应该尽可能保证作为props
数据引用的稳定性;如果上层组件加了useCallback
,那么你的子组件就一定得配合的加memo
,不然函数缓存啥的其实都白加了。
我们可以使用useSelector
监听全局store
的变化并从中取出我们想要的数据,而相同的数据获取如果是在class
组件中则应该写在mapStateToProps
中,但不管哪种写法,当我们从state
中获取数据后就应该注意保持数据的稳定性,来看个例子:
const userList = useSelector((state) => { const users = state.userList; return users.filter((user) => user.age > 18); });
在上述例子中,我们从state
中获取了userList
,之后又进行了数据加工过滤出年龄大于18
的用户,这个写法看似没什么问题,但事实上全局state
的状态并没有我们的想的那么稳定,所以useSelector
执行的次数要比你想的要多,此时只要useSelector
执行一次,我们都会从state
中获取数据,并通过filter
加工成一个全新的数组。
如何改善呢?其实很简单,将加工的行为提到外部即可,比如:
const users = useSelector((state) => { return state.userList; }); const userList = useMemo(() => { return users.filter(user => user.age > 18); }, [users])
PS:每次store变化,每一个useSelector都会执行吗?
问大家一个问题,每次store
变化时,是不是所有生命周期内组件的useSelector
都会执行一遍?如果执行那像上述代码返回的state.userList
是不是每次都是一个全新的对象?那useMemo
会不会每次都执行,导致userList
每次都是全新的数组吗?其实并不是。
我们可以将全局store
理解成一棵大树,不同组件的数据都是这棵树的树枝,请求也好更新也好,一定只是更新这棵树的不同树枝,这棵树从来就没变过(store
引用不变)。打个比方,假设A
组件更新了树枝a
(a
引用变了),B
组件依赖的是树枝b
,那么A
组件的更新会导致B
组件重复渲染吗?其实不会,这个过程可以简化为如下代码(如果你能不假思索的回答正确,那说明你对于store
更新以及引用关系很清晰了):
// 初始化store,这是一棵大树 const store = { o1: {// 树枝o1 num: 1, }, o2: {// 树枝o2 num: 2, }, }; // 保存第一份数据 const a = store.o1; const b = store.o2; // 假定后端返回新数据,局部更新store中的树枝 o1 store.o1 = { num: 3, }; // 再次取值 const a_ = store.o1; const b_ = store.o2; // 此刻这两个相等吗? console.log(a === a_); console.log(b === b_);
当B
组件通过useSelector
取出引用没变化的树枝b
时,因为就没变化,它不会无效渲染。
对于redux
而言,我们可以将整个react app
的store
理解成一颗巨大的树,而树有很多分支的树根,每一枝树根都可以理解成某个组件所依赖的state
,那么请问假设A
组件的树根被更新了,它会对store
的其它树根的引用造成影响吗?此时树还是这颗树啊,而那些没变的树根依旧是之前的树根。
所以回到上文的代码,假设state
中关于state.userList
就没有变化,那么前后不管取多少次,因为引用相同,useMemo
除了初始化会执行一次之外,之后都不会重新执行,这就能让userList
彻底稳定下来。
而假设我们因为成员接口让state.userList
进行了更新,正常来说应该在reducer
中重新生成一个新数组再赋予给store
,那么在下次useSelector
执行时,我们也能拿到全新引用的users
,而监听users
的useMemo
就能按照正确的预期再度更新了。
好像代码走读大家对于使用useMemo
都存在部分争议,其实理解这个问题很简单,useMemo
到底是用来干嘛的?它的本意是对特别复杂的逻辑的结果进行缓存,比如一段代码需要跑很久,有性能损耗,我们通过deps
监听变化再决定useMemo
的回调是否需要再次执行。因为缓存所以值没变,因为值不变所以引用不变,因为引用不变所以减少了无效渲染。
那么什么时候推荐用,什么时候不推荐用:
deps
因不可抗拒力无法稳定,此时useMemo
每次都会执行,这种加了也没太大意义。为什么说第三种完全没必要用,首先基本类型不存在引用变化,值变了就是变了,那就应该渲染;值没变组件自身也不会渲染,外加上逻辑又特别简单,这种就完全没缓存的必要,可以说完全是负优化,比如这种:
大家要注意,缓存不是零代价,useMemo、useCallback
与memo
的执行都会做一次浅比较,也就是它会拿前后deps
监听的数据一一做===
的对比,如果引用变了或者值变了它就会执行,所以对比也需要时间啊,然后你还浪费内存,得不偿失。
关于第四点,其实我们加了useMemo
是有义务在测试阶段自测其稳定性的,如果你监听的deps
完全就是一个无法稳定的数据,此刻你要么想办法将上层数据先稳定下来,要么就先别加useMemo
,不然做的都是无效功。(确实会存在无法稳定的数据)
聊完了常见的可以避免无效渲染的写法,有同学可能就要说了,从头开始写我可以注意,那假设现在要我优化一个已经写好的无效渲染比较严重的组件,那我怎么下手呢?单看代码我也不知道它引用是否稳定,我们来科普三种方式。
why-did-you-render是一个专门用来帮你检查无效渲染的库,安装和使用非常简单,这里简单说下:
首先项目安装why-did-you-render
:
npm install @welldone-software/why-did-you-render --save-dev
在你的应用根目录,比如app.tsx
顶部引入配置:
import whyDidYouRender from '@welldone-software/why-did-you-render'; // 一般只在开发环境启用 if (process.env.NODE_ENV === 'development') { whyDidYouRender(React); }
之后找到你想监听的组件,比如我想监听SidebarMenu
组件,那么在文件底部添加如下代码:
SidebarMenu.whyDidYouRender = true;
之后刷新页面,如果该组件有无效渲染,那么就会有对应的理由,比如:
意思是说,有一个useState
的结果,前后两次都是空数组且引用不同,所以造成了无效渲染。
注意,对于why-did-you-render
而言,它一定是只帮你找出无效渲染,且理由都是上面这种引用数据前后完全一样,只是单纯的引用不同,如何保证引用那么又回到了上文提到的减少无效渲染的手段,如果一个组件一个无效渲染提示都没有,那说明这个组件非常健康。
但需要注意的是,why-did-you-render
的感知更多是每次刷新页面的初次渲染,它无法感知hover
这种带来的无效渲染,比如notta
的侧边栏,可以看到hover
时明明一直在渲染,但是控制台一个提示都没有,那怎么办?别急,快去西天请useWhyDidYouUpdate。
useWhyDidYouUpdate
是ahooks
提供的一个专门用来查看组件update
原因的hook
,我们直接上官方例子,它的用法也非常简单:
// 传入你想在控制台的名字,方便区分,以及你觉得可疑的数据 useWhyDidYouUpdate(componentName, props);
比如官方例子:
useWhyDidYouUpdate('useWhyDidYouUpdateComponent', { ...props, randomNum });
这里的useWhyDidYouUpdateComponent
只是你希望在控制台输出的名字,叫啥都行,阿猫阿狗也行,而后面的接收的是一个对象,比如你只想监听props
那就直接传递props
即可:
const App = (props)=>{ useWhyDidYouUpdate('哈哈哈哈哈', props); return null; }
如果你除了props
还想监听其它的,你就可以自定义传递一个解构的对象,比如:
const App = (props)=>{ const userList = useSelector( state => state.userList); const [num,setNum] = useState(1); // 后面参数是一个对象,你要监听多个就解构组合成一个对象就好了 useWhyDidYouUpdate('哈哈哈哈哈', {...props, num, userList}); return null; }
需要注意的是,useWhyDidYouUpdate
会告诉你你监听数据中所有变化的数据,不管它是不是无效的更新,所以相对于why-did-you-render
它更加全,缺点是需要你自己来区分识别。
profiler
属于React Developer Tools的一部分,所以如果你要使用它,记得先安装此插件,之后打开控制台,你就能看到profiler
的选项了,记得勾选记录组件渲染原因的选项。
之后跟performance
录制的操作一样,点击录制,此时你可以尽情操作你的组件,再点击暂停,你就能看到profiler
会帮你记录刚才所有有发生渲染组件的火焰图数据。一般情况下我们只需要关注橙色的组件,比如:
如果是state
或者props
变化,它会直接告诉你变量名,但如果是hooks
变化,它只能告诉你这是第几个,其实这个就很难对应,所以到头来你还是得结合上面的两个工具,综合来判断到底是哪些数据不稳定,造成的组件的多次渲染。
我们来总结下这三个工具:
所以聊下来你会发现,这个三个工具并不是相许取代关系,而是配合使用的关系。
在很多情况下,大家都会在心里潜意识认为缓存是无代价的,所以本能会想到只要是生成一个数据,我都给它加上useMemo
或者其它的缓存,但事实上所有的缓存都是空间换时间,你想下次取缓存结果,那你之前的结果就一定得保存,要保存就得占用内存空间,这里做个科普,介绍下市面上常见的两种缓存策略。
这种缓存原理很简单,建立一个map
,然后利用你的传参作为key
,只要你下次执行时key
能在map
中找到值,那就默认返回结果不用重新再次执行逻辑,反之找不到,那就再次执行结果,并结合此刻的key
进行新的缓存,一个大致的实现就是:
const getterCache = (fn) => { const cache = new Map(); return (...args) => { const [uuid] = args; // 这里的时间复杂度是O(1) let data = cache.get(uuid); if (data === undefined) { // 没有缓存 data = fn.apply(this, args); // 执行原函数获取值 cache.set(uuid, data); } return data; }; };
市面上使用这种思想的缓存库比如memoizee,但是这个库对于缓存的策略不是使用的map
或者对象,而是数组,所以当数量达到十万级别,且你的缓存函数定义的问题没生效,这就导致每次执行都会在十万级的数组中查找目标,然后没找到再计算出一个存入数组,导致额外的性能问题。
比如:
import memoize from 'memoizee' const fn = function (a) { return a * a; }; // 使用缓存 console.time('使用缓存'); const memoizeFn = memoize(fn); for (let i = 0; i < 100000; i++) { memoizeFn(i); } memoizeFn(90000); console.timeEnd('使用缓存'); // 不使用缓存 console.time('不使用缓存'); for (let i = 0; i < 100000; i++) { // 单纯执行,啥也不缓存 fn(i); } fn(90000); console.timeEnd('不使用缓存');
那你会说,什么辣鸡库,用了反而更慢,其实不是库的问题,而是使用者的问题;还记得缓存函数的初衷吗,对于执行比较耗时的逻辑进行缓存以提升性能。而这个例子演示证明了一个问题,一个标准的缓存库,它内部一定也定义了缓存逻辑,跑这段逻辑是需要时间的,而假设你需要缓存的逻辑非常简单,比如上述代码就是数字相乘,你会发现不使用缓存的时间直接秒杀缓存,一个函数需要执行上万次且逻辑简单,你缓存它干嘛??
记住,缓存都是有代价的,缓存代码执行耗时,内存占用,这些都是你需要考虑的,不要为了缓存而缓存,这是我想表达的观点。
与上面的缓存不同,这种缓存策略的不是一个缓存多次结果的对象,而是永远是最新参数的结果,比如 memoize-one。
import memoizeOne from 'memoize-one'; function add(a, b) { return a + b; } const memoizedAdd = memoizeOne(add); // 第一次执行,结果是3 memoizedAdd(1, 2); // 第二次执行,因为参数还是1和2,直接走换乘 memoizedAdd(1, 2); // 第三次执行,因为参数变了,重新执行得到结果5 memoizedAdd(2, 3); // 第四次执行,参数还是2和3,走缓存 memoizedAdd(2, 3);
这里我简单看了下源码:
function memoized( this: ThisParameterType<TFunc>, ...newArgs: Parameters<TFunc> ): ReturnType<TFunc> { // 只有当需要缓存,且this相同,且新旧入参相同时才会返回缓存的结果 if (cache && cache.lastThis === this && isEqual(newArgs, cache.lastArgs)) { return cache.lastResult; } // 删除无意义的部分 } function isEqual(first: unknown, second: unknown): boolean { // 真正的对比其实用的是 === if (first === second) { return true; } // 删除部分NaN的对比 return false; } export default function areInputsEqual( newInputs: readonly unknown[], lastInputs: readonly unknown[], ): boolean { // 先判断入参的长度是否相同,参数长度都不同直接返回false if (newInputs.length !== lastInputs.length) { return false; } for (let i = 0; i < newInputs.length; i++) { // 遍历,一次拿新旧参数进行对比 if (!isEqual(newInputs[i], lastInputs[i])) { return false; } } return true; }
其次就是每次执行看结果是不是空,以及前后参数进行浅比较看是否相等,如果都相等就直接返回cache
,如果不是就重新计算更新cache
。这种策略是不是让你想到memo、useMemo
了?没错,react
的这些方法也是这个策略,这也是为什么解决无效渲染需要保证引用数据稳定性的原因,因为浅比较就是最简单的===
。
综合两种策略,你会发现第一种的好处是,它会尽可能帮你把没见过的参数以及结果都缓存起来,便于下次你再执行时直接使用,但弊端就是执行次数越多,你的内存占用越大。第二种策略不会有内存占用的烦恼,但如果你的参数变化特别频繁,你会发现你的缓存起不到什么作用。事实上这两种缓存使用场景不同,不具备可比性,在合适的场景使用就好了。
聊到最后,我们介绍了避免无效渲染的常规写法,以及如何排查一个组件无效渲染的原因,有同学可能就要问了,那一个项目那么大,我怎么知道哪些组件需要优化呢?
两种办法,第一种使用profiler
交互页面,关注橙色组件即可。第二种办法还是使用React Developer Tools
,勾选如下渲染,这些刷新页面你就能看到每个组件的渲染情况。
主要关注颜色深度,绿色到黄色,越偏黄色表示渲染越多,那么接下来进入提问环节。