React性能优化,六个小技巧教你减少组件无效渲染
阅读原文时间:2023年07月09日阅读:2

壹 ❀ 引

在过去的一段时间,我一直围绕项目中体验不好或者无效渲染较为严重的组件做性能优化,多少积累了一些经验所以想着整理成一片文章,下图就是优化后的一个组件,可以对比优化前一次切换与优化后多次切换的渲染颜色深度与按钮的切换速度:

关于减少组件无效渲染,与其说是提几点建议,不如说是在优化过程中所记录的一些不规范的写法,能写出更好的代码总是更棒的,也希望这几点建议能对大家能有些许帮助。当然,以下建议不管class组件还是hooks中其实都会犯,所以都有参考意义,那么本文开始。

贰 ❀ 减少无效渲染

在介绍如何减少无效渲染之前,我觉得有必要先声明两点,这对于理解如何减少无效渲染很有帮助:

第一点,所有的无效渲染都是由于数据的不稳定所造成的,而这里的不稳定一般分为两种,一种情况是数据前后不管是值还是引用都是完全相同的,可以说就是同一份数据;另一种是前后数据引用每次都不同,但它们的数据结构和值看起来又完全一样。所以对于第一种情况,既然你的值完全相同那我们完全没必要重复渲染,而对于第二种情况当你的值的结构没变化时我们保证其引用稳定就好了。

说到这里有同学可能就会想,那是不是组件内只要产生新引用数据的行为就不对呢?其实并不是,当数据本身就应该更新时,它在这一刻产生一个全新的引用很合情合理,不然我们项目里什么filter、map之类的岂不是都用不了。你也可以想想常见的state更新,我们更新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>
  )
};

第二,当我们发现某个组件无效渲染严重时,你的关注点应该往上看,简单来说,我们的优化应该自上而下,当上层组件数据稳定了,在做下层组件优化时会方便很多,不然你在某个中间组件一顿操作,结果数据加工的入参自身就不稳定,这就很头疼。

强调了这两点,我们正式来了解如何减少无效渲染。

贰 ❀ 壹 合理使用memo与PureComponent

我们知道class组件的PureComponent以及函数组件的memo都具有浅比较的作用,所谓浅比较就是直接比较前后两个数据是否相等,比如:

const a = [];
const b = a;
// 因为 a b 引用和值都相同,所以相等
a === b; // true
const c = [];
// 虽然 c 也是空数组,但是引用不同所以不相等
a === c; // false

我们假设组件前后都接收了一个空数组,且它们引用也相同,那么此时如果我们组件套用了memo,那么组件就不会因为这个完全相同的数据重复渲染。因为PureComponentmemo效果相同,这里我写了一个在线的memo例子方便大家理解效果,大家可以点击按钮查看控制台,直接对比加与不加memo的差异。

在这个例子中,我在组件外层定义了一份引用始终相同的数据user,之后通过点击按钮故意改变父组件P的状态让其渲染,以此带动子组件C1 C2渲染,可见加了memoC2除了初次渲染之后并不会跟随父组件重复渲染,这就是memo的作用。

当然,假设我们的user每次都是重新创建的新对象,那我们加了memo也没任何作用,毕竟引用不同浅比较判断为false,还是会重复渲染。

另外,请合理使用这两个api,并不是所有场景都需要这么做,假设你的组件的数据流足够简单甚至没有props,你完全没必要在组件外层套一层memo

其次,某些情况下,因为我们为组件嵌套了memo会导致我们通过react插件查看组件时显示组件名为Unkown,不利于调试,所以如果你要用,你可以通过displayName显示声明组件的名称,比如:

import React, { memo } from 'react';
const Echo = memo((props) => {});
// 某些情况下,使用 memo 会导致控制台调试时组件名显示为 Unkown ,可通过 displayName 显示声明组件名解决
Echo.displayName = 'Echo';
export {Echo};

在了解了如何减少引用相同引用时的无效渲染,接下来看看那些造成引用不同的问题场景。

贰 ❀ 贰 props直接传递新对象

第一种也是最直接也最容易看出来的一种不规范写法,一般存在于对于react不太了解的新人或者一些老旧代码中,比如:

const App = () => {
  return (
    // 这里每次都会传递一个新的空数组过去,导致Child每次都会渲染,加了 memo 都救不了
    <Child userList={[]} />
  )
};

当然,它也可能不是一个空数组,但注定每次都是一个全新引用的数据:

const App = (props) => {
  return (
    <Child userList={[...props.list]} />
  )
};

贰 ❀ 叁 不稳定的默认值

正常来说,比如子组件的userList属性规定类型是数组,在父组件加工数据时提供数据默认值是非常好的习惯,于是我常常在组件内部或者mapStateToProps中看到类似的写法:

const App = (props) => {
  // 当存在时赋予空数组,保证下层数组类型的正确性
  const userList = props.userList || [];
  return (
    <Child userList={userList} />
  )
};

App多次渲染且props.userList为假值时,此时的userList也会被不断的赋予全新的空数组。还记得前文说的吗,当你结构没变化时,我们保证其引用不变不就好了,所以结合问题一与问题二,对于空数组都可以在全局赋予一个空数组,比如:

const emptyArr = [];
const App = (props) => {
  // 当存在时赋予空数组,保证下层数组类型的正确性
  const userList = props.userList || emptyArr;
  return (
    <Child userList={userList} />
  )
};

这样不管App如何渲染,当userList被赋予为空数组时也能让前后引用相同。

贰 ❀ 肆 合理使用useMemo与useCallback

我们知道useMemouseCallback都能起到缓存的作用,比如下面这个例子:

// 只要 App 自身重复渲染,此时 handleClick 与 list 都会重新创建,导致引用不同,所以 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也无济于事,所以对于函数组件而言,一般要往下传递的数据我们可以通过useMemouseCallback包裹,保证其引用稳定性。当然,如果一份数据只是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} />
}

一般useMemo、useCallbackmemo会联合使用,既然你的下层组件都会做浅比较,我们尽可能稳定上层数据引用的稳定性就很有必要;而假设组件连memo都没有,即便我们做了缓存子组件还是一样会重复渲染,所以要不要用以及为何而用大家一定要搞清楚。

贰 ❀ 伍 更稳定的useSelector

我们可以使用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])

有同学可能就要说了,这不对吧,此时的useSelector每次都返回state.userList难道不是一个全新的对象?那useMemo不还是每次都会执行,导致userList每次都是全新的数组吗?其实并不是。

对于redux而言,我们可以将整个react appstore理解成一颗巨大的树,而树有很多分支的树根,每一枝树根都可以理解成某个组件所依赖的state,那么请问假设A组件的树根被更新了,它会对store的其它树根的引用造成影响吗?此时树还是这颗树啊,而那些没变的树根依旧是之前的树根。

我们可以通过下面的例子来理解这个过程:

const store = {
  A: [],
  B: {},
};
const b = store.B;
store.A = [1, 2];
const c = store.B;
c === b; // true

所以回到上文的代码,假设state中关于state.userList就没有变化,那么前后不管取多少次,因为引用相同,useMemo除了初始化会执行一次之外,之后都不会重新执行,这就能让userList彻底稳定下来。

而假设我们因为成员接口让state.userList进行了更新,正常来说应该在reducer中重新生成一个新数组再赋予给store,那么在下次useSelector执行时,我们也能拿到全新引用的users,而监听usersuseMemo就能按照正确的预期再度更新了。

其实在实际项目中,大家可能还有使用useSelector + createSelector的用法,useSelector用于监听statecreateSelector中负责提取state中的部分数据进行加工以及缓存,所以我在我们项目中多次看到类似如下的代码:

const userList = useSelector((state) => {
  // 这里你就理解成一个具有缓存效果的函数就好了,入参不变结果就不变
  const users = userSelector.getUser(state, userIDs);
  return users.filter(user => user.age > 18);
});

本来userSelector就具备缓存的作用,当你userIDs没变化时,我失踪走缓存给你稳定的数据,结果前面刚帮你稳定完,后面通过filter又产生了一个全新的数据,数据的稳定性直接被破坏了,所以改法还是跟之前一样:

const users = useSelector((state) => {
  return userSelector.getUser(state, userIDs);
});

const userList = useMemo(() => {
  return users.map(user => user.age > 18);
}, [users])

贰 ❀ 陆 贪心的createSelector

既然上文提到了createSelector,这里再讲一个项目中大家很容易犯的错误写法。前文已经说了createSelector你可以理解成一个缓存函数,只是一般我们会将其与state挂钩,用于在加工state时初步做一些缓存,但实际开发中我发现了部分同事写了类似这样的代码:

export const getUserListSelector = createSelector([a, b], (a, b) => {
  // 一些加工
});

这里我们定义了一个名为getUserListSelector的方法,它接受a,b两个参数,只要这两个参数不变,那么紧跟其后的callback就不会重复执行,这样就起到缓存的作用。

但同事在调用时是这么用的:

// a场景
const users = useSelector((state) => {
  return getUserListSelector(state, userIDs);
});

// b场景
const users = useSelector((state) => {
  return getUserListSelector(state);
});

简单来理解就是,其实存在两种取数据的场景,我们将其糅合到了getUserListSelector中,当a场景时我们需要传递两个参数,而b场景我们只用一个即可,有问题吗?有很大的问题。

createSelector这个东西与传统的缓存函数不同,一般的缓存函数是,只要你的参数不同,我们就用你参数的作为key,让结果作为value存起来,对应到上面的场景执行两次后,我们最终缓存可能是这样:

const cache = {
  a-b:value1,
  a:value2
}

两次入参不同,导致2次不同的缓存结果。但很尴尬的是,createSelector永远只缓存最新一次的缓存结果,也就是说对于上述createSelector只要a b两个场景都会调用,那么这个最终的数据永远都稳定不下来,两个场景始终会影响彼此。

怎么解决呢?解耦即可,我们应该让createSelector去取更稳定的数据,即便这个数据不够精准,返回后再分别在a b两个场景中单独去加工,为什么强调这一点呢?要知道createSelector经常存在嵌套关系,某个selector可能是另一个selector的入参,假设上述这个不稳定的selector返回的数据又成了其它selector的参数,这就会导致多条数据源全部不稳定,这是非常糟糕的。

OK,关于如何提升数据的稳定性,我们先介绍到这里,我也相信通过本文的阅读对于你之后的开发多少会有一些清晰的帮助。

叁 ❀ 如何排查不稳定数据?

其实聊到这里,我想大家多少都有了一些体会,可能有同学就想提问了,我自己新写一个组件我知道如何去规避这些点,那一个现有的组件假设渲染很严重,我又该如何去排查是哪些不稳定的数据导致了重复渲染呢?

方法肯定是有的,我们可以借助why-did-you-update或者why-did-you-render性能监测库,具体用法可见GitHub文档,这里就不赘述,当配置好后打开控制台刷新页面,你就能看到对应组件重复渲染的原因,比如:

上图就是因为新旧props引用不同所导致的无效渲染,至于如何减少无效渲染我想你现在也有了一些答案。

除了上面这两个库之外,我们还能利用react官网的插件Profiler,我们可以点击设置将记录组件渲染原因的选项勾上:

之后通过点击录制组件的渲染,hover到对应的组件上去就知道渲染的原因了,这也是一种排查手段,比如有如下组件:

const C = memo((props) => {
  return <div>{props.num}</div>
});

const Parent = () => {
  const [state, setState] = useState(1);
  const handleClick = () => {
    setState(state + 1);
  };
  return (
    <>
      <C num={state} />
      <button onClick={handleClick}>changeState</button>
    </>
  );
}

我们点击父组件的按钮每次修改state,并将state传递给子组件C,通过录制,我们可以很清晰的看到是因为props num变化所导致的渲染。

有了排查手段以及修复手段,其实只要大家耐心和细心,我相信大家都能写出更优雅的组件。

叁 ❀ 总

那么到这里,本文围绕如何减少无效渲染的介绍就结束了,其实说了那么多,核心点还是关注在如何保证数据引用的稳定性,除此之外,我们也顺带介绍了几点如何排查组件渲染的小技巧。下周就离职回武汉了,希望一切能顺利,本文结束。