拥抱React-Hooks(四)- 性能优化

ReRender 问题


React 组件树中,组件重新渲染的情况主要有以下几种:

  • 当组件的 state 或者 props 发生变化,会触发组件的重新渲染。
  • 类组件中,当 setState 调用后,组件的 render 方法也会自动调用,并且会嵌套渲染所有子组件。
  • 函数组件中,使用 useStatestate 改变导致组件重新渲染时,也会嵌套渲染所有子组件。

上述特性暴露出 React 一个典型的性能问题 - reRender 问题。确切的说,是子组件不必要的 reRender 问题。

ContextProvidervalue 值改变以后,所有对应的 Consumer 组件也会重新渲染。不过没有reRender 问题。

Diff


reRender 问题是问题么,React 不是有 diff 算法么?我之前也有这个误解。
我们可以把重新渲染过程分为两个阶段:

  • 阶段一:重新渲染,生成新的虚拟 Dom 树。reRender 问题发生在这个阶段。
  • 阶段二:通过对比新旧虚拟 Dom 树。如有差异,更新真实 Domdiff 算法作用于这个阶段。

虽然不必要的 reRenderdiff 的时候没有差异,所以不会更新真实 Dom。但是生成新的虚拟Dom,进行diff计算本身就消耗很多性能。

shouldComponentUpdate


在类组件中,我们一般通过 shouldComponentUpdate 生命周期来阻止 reRender
shouldComponentUpdate 函数在重渲染时,会在 render() 函数调用前被调用,它接受两个参数:nextPropsnextState,分别表示下一个props和下一个state的值。并且,当函数返回false时候,阻止接下来的render()函数的调用,阻止组件重新渲染,而返回true时,组件照常重新渲染。

class Square extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    if (this.props.number === nextProps.number) {
      return false;
    } else {
      return true;
    }
  }
  render() {
    return <Item>{this.props.number * this.props.number}</Item>;
  }
}

实际项目中,要考虑对象引用,复杂数据结构在 === 时候的坑。浅比较可能对比不出深层差异,深比较本身性能消耗也较大。要仔细权衡。

如果你不关心具体数据的变更,想对 propsstate 整体对比的常规写法如下:

shouldComponentUpdate(nextProps, nextState) {
  if (this.props === nextProps && this.state === nextState) {
    return false;
  } else {
    return true;
  }
}

这个时候,你可以考虑使用 PurComponent

React.PurComponent

React.PurComponent “纯”组件,是自带 shouldComponentUpdate 生命周期函数实现的组件。它会在组件重新渲染的时候对 新旧 stateprops 整体做浅层比较。如果都相等,就阻止重新渲染。

// PurComponent 实现源码
if (this._compositeType === CompositeTypes.PureClass) {
  shouldUpdate = !shallowEqual(prevProps, nextProps) || ! shallowEqual(inst.state, nextState);
}
// shallowEqual 源码
const hasOwn = Object.prototype.hasOwnProperty
function is(x, y) {
  if (x === y) {
    return x !== 0 || y !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export default function shallowEqual(objA, objB) {
  //在 === 基础上 修复了 NaN 和 +-0 的情况
  if (is(objA, objB)) return true

  if (typeof objA !== 'object' || objA === null ||
      typeof objB !== 'object' || objB === null) {
    return false
  }

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) return false

  for (let i = 0; i < keysA.length; i++) {
    if (!hasOwn.call(objB, keysA[i]) ||
        !is(objA[keysA[i]], objB[keysA[i]])) {
      return false
    }
  }

  return true
}

因为是浅层比较,一些不合理的写法,会导致 PurComponentshallowEqual 无法找出差异。

看下面代码:

const newObj = this.state.obj;
newObj.id = 1;
this.setState({
  obj: newObj
})

newObjobj 指向同一个引用地址,shallowEqual 比较结果是 true 阻止了重新渲染。正确的书写方式是通过 clone来定义newObj

另外,因为是浅层比较,对于复杂数据结构,不建议使用 PurComponent
PurComponent 并不等于高性能,对于 props 经常改变的组件,PurComponent 频繁比较,反而性能会降低。

React.memo


React.memo 为高阶组件。它与 React.PureComponent 非常相似,但只适用于函数组件,而不适用类组件。
React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹。其内部使用了 useStateuseContext。当 state , context 发生变化时,它仍会重新渲染。

默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。

function MyComponent(props) {
  /* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
  /*
  如果把 nextProps 传入 render 方法的返回结果与
  将 prevProps 传入 render 方法的返回结果一致则返回 true,
  否则返回 false
  */
}
export default React.memo(MyComponent, areEqual);

useCallback Hook


在函数组件中,我们经常会创建很多函数。还会把函数作为子组件的参数向下传递。那么当组件 reRender 的时候,这些函数每次都会重新定义。

所以我们会有这样的误解:这会不会很消耗性能,是不是应该缓存下来?而 useCallback 的功能就是在函数组件中做函数的缓存来优化性能。

所以很多人陷入一个误区:对于会频繁 reRender 的函数组件,我们定义的函数都应该使用 useCallback 来缓存下来,避免反复定义。

  • 一方面,官方文档也指出,在现代浏览器中,创建函数和闭包的性能消耗,只有在个别极端情况下才会有明显差异。* 另一方面,在 javascript 中,当组件刷新时,未被 useCallback 包裹的函数将被垃圾回收并重新定义,但被 useCallback 所制造的闭包将保持对回调函数和依赖项的引用,不利于垃圾回收。用的越多,反而负担越重。

useCallback 正确的用法是配合 React.memo 来避免渲染成本较高的子组件非必要 reRender 问题。比如这样一个典型的场景:

import React, { useState } from 'react';

const  FatherComp = () => {
  const [dataA, setDataA] = useState(0);
  const [dataB, setDataB] = useState(0);

  const onClickA = () => {
      setDataA(o => o + 1);
  };

  const onClickB = () => {
      setDataB(o => o + 1);
  }

  return (
    <div>
      <A data= {dataA} onClick={(onClickA)}/>
      <B data= {dataB} onClick={(onClickB)}/>
    </div>
  )
}
export default FatherComp;

父组件 FatherComp 中有2个受控子组件,其中组件 A 交互频繁,而导致父组件频繁 reRender。子组件 B 也因此频繁进行不必要的 reRender。如果 B 是一个渲染成本非常高的组件,那就得优化其不必要reRender 的问题。

这个时候,你只是用 React.memo 包裹 B 组件是没有办法阻止其重新渲染的,因为每次 onClickB 都是重新定义的,B 组件的 props 是改变的。这个时候就需要 useCallback 来包裹 onClickB 来达到阻止的效果。

import React, { useState } from 'react';

const  FatherComp = () => {
  const [dataA, setDataA] = useState(0);
  const [dataB, setDataB] = useState(0);

  const onClickA = () => {
    setDataA(o => o + 1);
  };

  const onClickB = useCallback(() => {
    setDataB(o => o + 1);
  },[])

  return (
    <div>
      <A data= {dataA} onClick={(onClickA)}/>
      <MemoB data= {dataB} onClick={(onClickB)}/>
    </div>
  )
}

const MemoB =  React.memo(B);

export default FatherComp;

我们抽象 useCallback 的API为:const memoFun = useCallback(arrFun, depsArr);

  • arrFun: 必须,是一个函数,首次渲染时,会赋给 memoFun 并缓存。
  • depsArr:可为空,依赖项,更新渲染时:
    • 依赖项没有变化,会直接将上次缓存的 arrFun 赋给 memoFunmemoFun 没有变)。
    • 依赖项发生变化,会重新声明一个 arrFun 赋给 memoFunmemoFun 改变了)。

useMemo Hook


useMemo 这个 Hook,用于函数组件重新渲染时,阻止无用方法的调用(无需重新调用),尤其一些高开销的计算逻辑。它用于局部优化,而不是阻止整个组件 Rerender

import React, { useMemo, useState } from 'react'

export default function UseMemoPage() {

    const [count, setCount] = useState(0)
    const [value, setValue] = useState("")

    //-----------当前的计算只和count有关------
    const expensive = useMemo(() => {
        console.log("compute");
        let sum = 0;
        for (let i = 0; i < count; i++) {
            sum += i
        }
        return sum;
        // 只有 count 发生改变的时候,才执行这个方法
    }, [count])
    //-------------重点!!局部优化---------

    return (
        <div>
            <h3>UseMemo</h3>
            <p>count:{count}</p>
            <p>expensive:{expensive}</p>
            <button onClick={() => setCount(count + 1)}>add</button>
            <input value={value} onChange={event => setValue(event.target.value)} />
        </div>
    )
}

局部高开销计算封装成一个函数 , 和 依赖项数组 一起作为参数传入 useMemo,返回一个 memoized 值 。重新渲染时,它仅会在某个依赖项改变时才重新调用函数计算 memoized 值。

  • 传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo

  • 所有计算函数中引用的值都应该出现在依赖项数组中。如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值,便没有任何意义。

  • 依赖项数组不会作为参数传给计算函数。

  • 保险起见,先编写在没有 useMemo 的情况下也可以执行的代码。之后再在你的代码中添加 useMemo,以达到优化性能的目的。

总结

  • shouldComponentUpdate生命周期 和 React.PurComponent 用于类组件优化 reRender
  • React.memouseCallback 用于函数组件优化 reRender性能问题。适用于组件树中父组件交互频繁,而自身 props 较少修改而渲染消耗较大的子组件。
  • useMemo用于局部包含复杂计算逻辑方法优化,相对其他API,更灵活,更放心。
  • 以上 API 本身存在额外性能消耗。所以很多时候,reRender 问题不一定要优化。
  • 别不小心阻止了必要reRender的情况。

参考文献

官网-purecomponent
React 渲染优化
React rerender component