收集的一些React hooks的性能优化以及闭包陷阱问题

3,984 阅读3分钟

这篇文章是之前在团队里分享的,今天看了一篇hooks的文章才想起分享到掘金,因为写的很粗糙。有任何问题或者错误之处希望可以指出修改,谢谢啦

目录

  • memo useMemo useCallback 区别
  • useCallback或者useEffect依赖项问题
  • react hooks的闭包陷阱(和依赖项问题可以合并讲)
  • useEffect和useLayoutEffect区别

memo useMemo useCallback

  • memo缓存组件
import * as React from "react";
import { memo } from "react";
interface IProps {
  count: number;
  handleClick: () => void;
}

const Child: React.FC<IProps> = ({ count, handleClick }) => {
  console.log("Child render");
  return <div onClick={handleClick}>{count}</div>;
};
// 通过浅比较props的形式 props相同的情况下不会触发重新渲染
export default memo(Child);
  • useMemo缓存值(类似于vue中的computed属性)
function doubleFn() {
    return count * 2;
  }
  const clickFn = handleClick;
  // 只要count不发生变化 每次重渲染直接返回上一次count*2的计算值,不会触发handleClick的重新执行
  const doubleCount = useMemo(doubleFn, [count]);

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算

  • useCallback缓存函数
function handleClick() {
    console.log(`handleCLick-count${count}`);
  }
  // 只要count不发生变化 每次返回都是上一次函数的引用
  const clickFn = useCallback(handleClick, [count]);
  ...
  return (
    // 常用来解决传入函数的引用每一次都不同而造成重渲染的性能问题
    <Child count={doubleCount} handleClick={clickFn} />
  )

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用

总结:三者都是用来做性能优化的

例子

react hoos闭包陷阱

// 第一次渲染
function Counter() {
  const count = 0; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// 再次点击触发重新渲染
function Counter() {
  const count = 1; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// 再次点击触发重新渲染
function Counter() {
  const count = 2; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

当我们更新状态的时候,React会重新渲染组件。每一次渲染都能拿到独立的count 状态,这个状态值是函数中的一个常量。每一次调用引起的渲染中,它包含的count值独立于其他渲染(闭包)

  • 每一次 render 都会生成一个闭包,每个闭包都有自己的 state 和 props(所以在异步函数中访问hooks的state值拿到的是当前的闭包值并不是最新的state值).
  • class 中可以用闭包模拟 hooks 的表现。hooks 中可以使用 ref 模拟 class 的表现(实例属性)。
// class模拟hooks的闭包变现用一个变量保存当前值
    const count = this.state.count;
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);

解决办法

  • 添加count依赖 (性能问题:重复创建销毁定时器)
useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
    // 添加count依赖 缺点会重复生成销毁定时器影响性能
  }, [count]);
  • 换成updater方法 count => count + 1
useEffect(() => {
    const id = setInterval(() => {
      // set函数可以为一个函数(类似于setState) 参数为上一次的state值
      setCount(count => count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  • useReducer(ps dipatch也不用添加到依赖中)
const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}
  • useRef

依赖项问题

  • 对于一些不依赖hook state变量的函数 可以提取到最外层作用域中(加不加都可以)
  • 对于依赖到hooks变量的 可以用useCallback包一层 添加依赖 或者把函数放进去useEffect中 然后在useEffect中添加依赖

useEffect和useLayoutEffect区别

首先问个问题 useEffect执行时机是什么时候

渲染完成之后,其实useEffect是异步的,在渲染完成之后先解绑在重新调用effect回调

useEffect.png

例子

const intervalId = setInterval(() => {
  setLapse(Date.now() - startTime)
  // 如果时间设置较大时看不出问题,但是当设置足够小0毫秒时就会有问题了
}, 0)

先点击start 在直接点击clear会大概率发现时间并没有清零(眼尖的人可以见到先是变成0后有变成时间数,在window下较为明显)

TIM截图20191008185906.png

原因

setInterval(fn,0)可以理解为在每一次宏任务中都插入fn,因为useEffect是在render之后异步执行的,所以在调用clearInterval前就已经插入了 setLapse(Date.now() - startTime) 这么一段逻辑

执行流程

  • (setRunning(false) setLapse(0))
  • render渲染
  • setLapse(Date.now() - startTime) 罪魁祸首
  • clearInterval
  • useEffect
  • render渲染

造成在render渲染之后又setLapse当前的时间值,最后又多渲染了一次.

解决办法

那么知道了罪魁祸首是谁,那么解决办法有两个

  • 不让setLapse(Date.now() - startTime) 这一段执行 (在interval回调函数内层加上if(running)是否可行)
const intervalId = setInterval(() => {
  // 加上这个判断如果为false状态 那么也不会执行下面的setLapse(Date.now() - startTime)
  if(running) {
    // 然而并不可行 因为hooks的闭包问题会导致 在当前定时器中running状态一直为true的
    setLapse(Date.now() - startTime)
  }
}, 0)
  • setLapse(Date.now() - startTime) 之前就清除定时器

useLayoutEffect

这时候就需要用到useLayoutEffect了 useLayoutEffect会在渲染完整之后同步执行

执行流程

  • (setRunning(false) setLapse(0))
  • render渲染
  • clearInterval
  • useEffect

参考资料