听风是风

学或不学,知识都在那里,只增不减。

导航

React性能优化,六个小技巧教你减少组件无效渲染

壹 ❀ 引

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

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

贰 ❀ 减少无效渲染

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

常见的无效渲染主要分为两部分,第一部分是状态设计的不合理性所导致,比如A B C三个组件就应该拥有三个状态,但不合理的设计共用了一个state,这就导致用户改A就一定更新state,而state变化就会导致B C的无效渲染,当然这部分更看重各位同学对于组件设计的经验积累,在数据设计上注意下就好。本文着重探讨的是第二种情况,数据的不稳定所造成的无效渲染,如何理解这个不稳定呢?我们认定两个对象相等,除了对象的结构值看着一样之外,更重要的是保证其引用相同;而实际组件开发中,我们常常会遇到前后props结构一样但是引用每次都不同的情况。所以针对于值相同引用不同的情况,我们使用一些技巧保证其引用的稳定就好了。

说到这里有同学可能就会想,那是不是组件内只要产生新引用数据的行为就不对呢?其实并不是,当数据本身就应该更新时,它在这一刻产生一个全新的引用很合情合理,不然我们项目里什么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的参数,这就会导致多条数据源全部不稳定,这是非常糟糕的。

有同学可能对这种只缓存最新一次结果的缓存函数比较好奇,它这种判断参数变没变到底有哪些依据呢,这里我找了一个类似的库,同样是只缓存最新一次的缓存方法,对于入参变没变判断的源码:

function memoized(
  this: ThisParameterType<TFunc>,
  ...newArgs: Parameters<TFunc>
): ReturnType<TFunc> {
  // 只有当需要缓存,且this相同,且新旧入参相同时才会返回缓存的结果
  if (cache && cache.lastThis === this && isEqual(newArgs, cache.lastArgs)) {
    return cache.lastResult;
  }
	// 删除无意义的部分
}

再看看isEqual的实现,大家就知道这个入参的判断逻辑了:

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;
}

通过代码我们能得到,只要参数前后的数量对不上,或者参数的引用对不上,值对不上,这些我们都默认成不相等,此时就会重新计算缓存最新的结果,反之则认为相同,直接返回上次的缓存结果,对这个库感兴趣的可以看看 memoize-one

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变化所导致的渲染。

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

叁 ❀ 总

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

posted on 2022-08-14 16:00  听风是风  阅读(1897)  评论(0编辑  收藏  举报