Hooks 的性能优化及可能会遇到的坑总结

10,216 阅读5分钟

组件 PureRender

class 组件中性能优化可以通过 shouldComponentUpdate 实现或者继承自 PureComponent,当然后者也是通过 shouldComponentUpdate 去做的,内部对 stateprops 进行了 shallowEqual。

对于函数组件来说并没有这个生命周期可以调用,因此想实现性能优化只能通过 React.memo(<Component />) 来做,这种做法和继承 PureComponent 的原理一致。

另外如果你的函数组件需要拿到它的 ref,可以使用以下工具函数:

function memoForwardRef<N, P>(comp: RefForwardingComponent<N, P>) {
  return memo(forwardRef<N, P>(comp));
}

但是并不是以上做法以后性能就万事大吉了,你还得保证传递的 props 以及内部的状态的引用不发生预期之外的变化。

保持局部不变

对于函数组件来说,变量的引用是需要重点关注的问题,无论是函数亦或者对象。

const Child = React.memo(({ columns }) => {
  return <Table columns={columns} />
})
const Parent = () => {
  const data = [];
  return <Child columns={data} />
}

对于以上组件来说,每次 Parent 渲染的时候虽然 columns 内容没有变,但是 columns 的引用已经变了。当 props 传递给 Child 的时候,即使使用了 React.memo 但是性能优化也失效了。

对于这种情况,可以通过 useMemo 将引用存储起来,依赖不变引用也就不变。

const data = useMemo(() => [], [])

useMemo 的场景多是用于值的计算。比如密集型计算场景下你肯定不希望组件重新渲染的时候,依赖项没有变更缺重复执行计算函数得到相同的值。

对于函数来说,如果你想保存它的引用的话可以使用 useCallback 来做。

function Counter() {
  const [count, setCount] = useState(0)


  // 这样写函数,每次重新渲染都会再次创建一个新的函数
  const onIncrement = () => {
    setCount(count => count + 1)
  }

  const onIncrement = useCallback(() => {
    setCount(count => count + 1)
  }, [])

  return (
    <div>
      <button onClick={onIncrement}>INCREMENT</button>
      <p>{count}</p>
    </div>
  )
}

对于以上代码来说,组件每次渲染的时候使用了 useCallback 包裹的 onIncrement 函数引用不会改变,这也就意味着不需要频繁创建及销毁函数了。

但是在 useCallback 存在依赖的情况下函数引用并不一定按照你的想法正常保持不变,比如如下案例:

function Counter() {
  const [count, setCount] = useState(0)

  const onIncrement = useCallback(() => {
    setCount(count => count + 1)
  }, [])
  
  const onLog = useCallback(() => {
    console.log(count)
  }, [count])

  return (
    <div>
      <button onClick={onIncrement}>INCREMENT</button>
      <button onClick={onLog}>Log</button>
      <p>{count}</p>
    </div>
  )
}

count 每次改变造成组件重新渲染的时候,onLog 函数都会重新创建一次。两种常规方法可以保持在这种情况下函数引用不被改变。

  1. 使用 useEventCallback
  2. 使用 useReducer
function useEventCallback(fn, dependencies) {
  const ref = useRef(() => {
    throw new Error('Cannot call an event handler while rendering.');
  });

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}

useEventCallback 使用了 ref 不变的特性,保证回调函数的引用永远不变。另外在 Hooks 中,dispatch 也是不变的,所以把依赖 ref 改成 dispatch,然后在回调中调用 dispatch 就是另一种做法了。

性能优化并不是银弹

凡事都有两面性,在引入以上这些性能优化的时候你已经降低了原本的性能,毕竟它们都是有使用代价的,我们可以来阅读下 useCallbackuseMemo 的核心源码:

function updateCallback(callback, deps) {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function updateMemo(nextCreate, deps) {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

上述源码实现思路大致是从 fiber 中取出 memoizedState,然后对比前后 Deps,对比的实现也采用了 shallowEqual,最后如果有变化的话就重置 memoizedState

可以看出来,本文中讲到的性能优化方案基本都是采用了 shallowEqual 来对比前后差异,所以没必要为了性能优化而优化。

Hooks 的坑

Hooks 的坑 99% 都是闭包引起的,我们通过一个例子来了解下什么情况下会因为闭包导致问题。

function App() {
  const [state, setState] = React.useState(0)
  // 连点三次你觉得答案会是什么?
  const handleClick = () => {
    setState(state + 1)
    setTimeout(() => {
      console.log(state)
    }, 2000)
  }

  return (
    <>
      <div>{state}</div>
      <button onClick={handleClick} />
    </>
  )
}

上述代码触发三次 handleClick 后你觉得答案会是什么?可能答案与你所想的不大一样,结果是:

0 1 2

因为每次 render 都有一份新的状态,因此上述代码中的 setTimeout 使用产生了一个闭包,捕获了每次 render 后的 state,也就导致了输出了 0、1、2。

如果你希望输出的内容是最新的 state 的话,可以通过 useRef 来保存 state。前文讲过 ref 在组件中只存在一份,无论何时使用它的引用都不会产生变化,因此可以来解决闭包引发的问题。

function App() {
  const [state, setState] = React.useState(0)
  // 用 ref 存一下
  const currentState = React.useRef(state)
  // 每次渲染后更新下值
  useEffect(() => {
    currentState.current = state
  })

  const handleClick = () => {
    setState(state + 1)
    // 这样定时器里通过 ref 拿到最新值
    setTimeout(() => {
      console.log(currentState.current)
    }, 2000)
  }

  return (
    <>
      <div>{state}</div>
      <button onClick={handleClick} />
    </>
  )
}

其实闭包引发的问题多半是保存了 old 的值,只要想办法拿到最新的值其实基本上就解决问题了。

写在最后

如果你觉得我有遗漏什么或者写的不对的,欢迎指出。

我很想听听你的想法,谢谢阅读。

微信扫码关注公众号,订阅更多精彩内容 加笔者微信群聊技术