用 Hook 优化性能

364 阅读12分钟

在 React 中,随着 Hook 的广泛使用,函数组件逐渐代替 class 组件,通过官方提供的 Hook API,可以优化性能:1、在函数组件中,经常会碰到大计算量的函数 fn,如果 fn 不是每次重新渲染都必须执行,可以使用 useMemo 减少 fn 的执行次数;2、在 React 中,父组件的重新渲染会导致子组件的重新渲染,但是如果子组件的数据没有发生改变,子组件的重新渲染是一种性能的浪费,可以使用 React.memo + useCallback + useMemo 的方法阻止子组件不必要的重新渲染;3、执行 useState 会返回当前 state 以及更新 state 的函数,执行更新函数,会引发组件及其子组件的重新渲染,如果本轮渲染,更新函数返回的值与上一次返回的相同,子组件的重新渲染是一种性能的浪费,可以通过使用 useReducer 代替 useState 来避免这种情况发生。

一、使用 useMemo 减少大计算量函数的执行次数

通过代码,了解 useMemo 的用法。

let preValue = null;

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

  const nowValue = useMemo(() => { 
    console.log('参数函数执行了')
    return { name: 'superlu' }
  }, []);
  console.log('nowValue: ', nowValue);

  console.log('nowValue === preValue ', nowValue === preValue);
  preValue = nowValue;

  return (
    <>
    { count } &nbsp; &nbsp;
    <button onClick={
      () => setCount(count + 1)
    }>
      + 1
    </button>
    </>
  );
}

结果如下:

代码的思路是比较本次渲染和上次渲染,useMemo 返回的对象是否为同一个对象。从结果可以看出,本次渲染和上次渲染,useMemo 返回的是同一个对象。useMemo 接受 2 个参数,第一个参数是函数,第二个参数是数组,返回参数函数的执行结果,当传入空数组时,useMemo 只会执行一次参数函数,下次渲染时,useMemo 不会执行参数函数,而是直接将上一次渲染,参数函数的执行结果返回。数组中也可以有依赖项,只有在下一次渲染,依赖项的值发生改变,useMemo 才会执行参数函数,返回参数函数的执行结果,如果在下一次渲染,依赖项的值未发生改变,useMemo 不会执行参数函数,而是直接将上一次渲染,参数函数的执行结果返回。如果一个函数 fn 耗时较长,并且不必在每次渲染时都执行,那么我们可以使用 useMemo 进行优化,减少函数 fn 的执行次数。

二、使用 React.memo 阻止子组件不必要的重新渲染

React.memo 可以阻止子组件不必要的重新渲染,可以和 useCallback、useMemo 一起使用。下面通过代码,了解 useCallback 和 React.memo 的用法。

2.1、useCallback

通过 2 段代码,了解 useCallback 的用法。

第一段代码:

let preFunc = null;

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

  const nowFunc = useCallback(() => console.log('我是 useCallback 返回的函数'), []);
  nowFunc();

  console.log('preFunc === nowFunc ', nowFunc === preFunc);
  preFunc = nowFunc;

  return (
    <>
    { count } &nbsp; &nbsp;
    <button onClick={
      () => setCount(count + 1)
    }>
      + 1
    </button>
    </>
  );
}

结果如下:

代码的思路是比较本次渲染和上次渲染,useCallback 返回的函数是否为同一个函数。从结果可以看出,本次渲染和上次渲染,useCallback 返回的是同一个函数。useCallback 接受 2 个参数,第一个参数是函数,第二个参数是数组,返回一个函数,当传入空数组时,useCallback 在每次渲染时返回的函数是相同的。数组中也可以有依赖项,只有本次渲染和上次渲染,依赖项的值未发生改变,useCallback 返回的才是同一个函数,一旦依赖项的值发生改变,useCallback 将会返回一个新的函数,下面通过代码了解这一点。

第二段代码:

let preFunc = null;

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

  const nowFunc = useCallback(() => console.log('我是 useCallback 返回的函数'), [count]);
  nowFunc();

  console.log('preFunc === nowFunc ', nowFunc === preFunc);
  preFunc = nowFunc;

  return (
    <>
    { count } &nbsp; &nbsp;
    <button onClick={
      () => setCount(count + 1)
    }>
      + 1
    </button>
    </>
  );
}

结果如下:

从结果可以看出,由于依赖项 count 在每次渲染时,值已经发生改变,所以 useCallback 在每次渲染时都返回一个新的函数。

2.2、React.memo

首先 React.memo 是高阶组件,也就是 React.memo 函数接受一个组件参数,返回一个组件。下面通过 4 段代码,了解 React.memo 的用法。

第一段代码:

function AppChild({ text }) {
  console.log('AppChild render');

  return (
    <div>{ text }</div>
  );
}

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

  return (
    <>
      { count } &nbsp;&nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button>
      <br />
      <br />
      <AppChild
        text='我是 App 的子组件'
      />
    </>
  );
}

结果如下:

从图中可以看出,父组件 App 的数据发生变化,导致父组件重新渲染,导致子组件 AppChild 重新渲染,然而子组件的数据并没有发生变化,子组件的重新渲染造成性能浪费。

第二段代码:

function AppChild({ text }) {
  console.log('AppChild render');

  return (
    <div>{ text }</div>
  );
}

const AppChildMemo = React.memo(AppChild);

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

  return (
    <>
      { count } &nbsp;&nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button>
      <br />
      <br />
      <AppChildMemo
        text='我是 App 的子组件'
      />
    </>
  );
}

结果如下:

从图中可以看出,父组件 App 的重新渲染,没有引起子组件 AppChildMemo 的重新渲染,从而优化了性能。从这里可以看出,React.memo 接受一个组件参数,返回一个组件,返回的组件,在父组件发生重新渲染时,可以不进行重新渲染,但这是有条件的,接下来将通过代码了解这个条件。

第三段代码:

function AppChild({ text }) {
  console.log('AppChild render');

  return (
    <div>{ text }</div>
  );
}

const AppChildMemo = React.memo(AppChild);

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

  return (
    <>
      { count } &nbsp;&nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button>
      <br />
      <br />
      <AppChildMemo
        text='我是 App 的子组件'
        testFunc={ () => {} }
        testObj={ {} }
      />
    </>
  );
}

结果如下:

从图中可以看出,父组件 App 的重新渲染,造成了子组件 AppChildMemo 的重新渲染,这是因为,React.memo 阻止 AppChildMemo 重新渲染的条件是,在本轮渲染中,AppChildMemo 的 props 必须和上一次渲染 props 的值相同。通过代码可以看出,前后 2 次渲染,text 的值是相同的,但是 testFunc、testObj 的值是不相同的,所以,React.memo 不能阻止子组件 AppChildMemo 的重新渲染。对于 testFunc、testObj 这类复杂类型,可以使用 useCallback、useMemo 让 textFunc、textObj 每次渲染时,值都相同。

第四段代码:

function AppChild({ text }) {
  console.log('AppChild render');

  return (
    <div>{ text }</div>
  );
}

const AppChildMemo = React.memo(AppChild);

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

  return (
    <>
      { count } &nbsp;&nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button>
      <br />
      <br />
      <AppChildMemo
        text='我是 App 的子组件'
        testFunc={ useCallback(() => {}, []) }
        testObj={ useMemo(() => ({}), []) }
      />
    </>
  );
}

结果如下:

从结果可以看出,即使子组件 AppChildMemo 的 props 中存在复杂类型,也可以通过 React.memo + useCallback + useMemo 的方法阻止子组件的不必要的重新渲染。

2.3、React.memo 无法阻止 Context 引起的渲染

React.memo 无法阻止 Context 引起的重新渲染,通过代码了解这一点。

const ThemeContext = React.createContext('red');

function App() {
  const [color, setColor] = useState('blue');

  return (
    <ThemeContext.Provider value={color}>
      <button onClick={ () => setColor('green') }>change color</button>
      <br />
      <br />
      <AppChildMemo />
    </ThemeContext.Provider>
  )
}

const AppChildMemo = React.memo(AppChild);

function AppChild() {
  console.log('AppChild render');
  const color = useContext(ThemeContext);

  return (
    <div style={{ background: color }}>我是 App 的子组件</div>
  );
}

结果如下:

子组件 AppChild 通过 useContext 获取 context 的值,当 context 的值发生变化时,从图中可以看出,React.memo 也无法阻止子组件 AppChild 的重新渲染。

三、使用 useReducer 替代 useState 提高性能

通过 2 段代码,了解 useReducer 的使用。

第一段代码:

function reducer(state, action) {
  switch(action.type) {
    case 'increment':
      return state + 1;
  }
}

function App() {
  // 副作用
  useEffect(() => {
    console.log('App effect');
  });

  const [state, dispatch] = useReducer(reducer, 0);
  return (
    <>
      Count: {state} &nbsp; &nbsp;
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <br />
      <br />
      <AppChild />
    </>
  );
}

function AppChild() {
  console.log('CounterChild render');

  return (
    <div>我是 App 的子组件</div>
  );
}

结果如下:

useReducer 接受 2 个参数,第一个参数是函数,第二个参数是初始值,返回一个 state 和 dispatch 函数,每次渲染,useReducer 返回的 dispatch 函数是相同的,在第一次渲染时,state 为传给 useReducer 的初始值,从图中的结果可知,执行 dispatch 会引起组件、子组件的重新渲染,以及组件副作用的执行,重新渲染时,state 的值为 reducer 函数执行返回的结果。执行 dispatch 引起子组件重新渲染及组件副作用的执行是有条件的,下面将通过代码了解这个条件。

function reducer(state, action) {
  switch(action.type) {
    case 'keep':
      return state;
  }
}

function App() {
  console.log('App render')

  // 副作用
  useEffect(() => {
    console.log('App effect');
  });

  const [state, dispatch] = useReducer(reducer, 0);
  return (
    <>
      Count: {state} &nbsp; &nbsp;
      <button onClick={() => { 
        console.log('click happen')
        dispatch({type: 'keep'})
      }}>
        keep
      </button>
      <br />
      <br />
      <AppChild />
    </>
  );
}

function AppChild() {
  console.log('CounterChild render');

  return (
    <div>我是 App 的子组件</div>
  );
}

结果如下:

从结果中可以看出,dispatch 执行,App 的副作用没有执行,App 的子组件 AppChild 没有重新渲染,dispatch 执行,引起组件副作用执行和子组件重新渲染的条件是,reducer 函数返回的结果与上一次返回的结果不同。可以发现,执行 dispatch,App 也没有重新渲染,但是这是不确定事件,App 仍然有可能渲染。

四、Hook 总结

初学 Hook,将 Hook 的知识点总结为 3 方面:1、Hook 规则;2、自定义 Hook;3、Hook API。

4.1、Hook 规则

  1. 在 React 函数组件的最顶层调用 Hook(Hook API 或自定义 Hook)。
  2. 在自定义 Hook 函数的最顶层调用其他 Hook(Hook API 或自定义 Hook)。

4.2、自定义 Hook

下面通过 2 段代码了解自定义 Hook。

第一段代码:

function App() {
  useEffect(() => {
    console.log('App render');
  })

  return (
    <>
    </>
  );
}

第二段代码:

// 自定义 Hook
function useCustomize() {
  useEffect(() => {
    console.log('App render');
  })
}

function App() {
  useCustomize();

  return (
    <>
    </>
  );
}

通过对比 2 段代码,可知,自定义 Hook 只是将函数组件中的部分逻辑抽离出来,封装成函数,达到代码复用的目的。封装自定义 Hook 函数需要注意 2 点:1、自定义 Hook 函数名以 use 开头;2、自定义 Hook 函数可以调用 Hook API 或其他自定义 Hook 函数,但是必须在自定义 Hook 函数的最顶层调用。

4.3、Hook API

目前有 10 个 Hook API,接下来将了解 useEffect 和 useRef 这 2 个 API。

4.3.1、useEffect

useEffect 和 useMemo 一样,接受 2 个参数,第一个参数是函数,第二个参数是数组,数组中的项为依赖项。了解 useEffect,需要了解参数函数的执行时机,下面通过 4 段代码,了解参数函数的执行时机。

第一段代码:

function App() {
  console.log('App render')

  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('App effect happen');
    return () => {
      console.log('App destroy happen');
    };
  })

  return (
    <>
      { count } &nbsp; &nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button>
    </>
  );
}

结果如下:

参数函数可以返回一个函数,这个函数称为清除函数,从结果可知,在首次渲染时,参数函数会在组件渲染后执行,重新渲染时,先执行清除函数,再执行参数函数。在这段代码中,没有给 useEffect 传入第二个参数,在下段代码中,给 useEffect 传入空数组。

第二段代码:

function App() {
  const [count, setCount] = useState(0);
  const [visible, setVisible] = useState(true);

  return (
    <>
      { count } &nbsp; &nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button> &nbsp; &nbsp;
      <button onClick={
        () => setVisible(false)
      }>
        destory
      </button>
      <br />
      <br />
      {
        visible && <AppChild />
      }
    </>
  );
}

function AppChild() {
  useEffect(() => {
    console.log('AppChild effect happen');
    return () => {
      console.log('AppChild destory happen');
    };
  }, []);

  return (
    <div>我是 App 的子组件</div>
  );
}

结果如下:

从图中结果可知,当 useEffect 传入空数组时,参数函数只会在组件首次渲染时执行,清除函数只会在组件销毁时执行。当数组有依赖项时,只有当依赖项的值改变,组件重新渲染才会执行清除函数(先)和参数函数(后)。通过下一段代码了解父子组件间,参数函数的执行顺序。

第三段代码:

function App() {
  const [count, setCount] = useState(0);
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    console.log('App effect happen');
    return () => {
      console.log('App destory happen');
    };
  })

  return (
    <>
      { count } &nbsp; &nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button> &nbsp; &nbsp;
      <button onClick={
        () => setVisible(false)
      }>
        destory
      </button>
      <br />
      <br />
      {
        visible && <AppChild />
      }
    </>
  );
}

function AppChild() {
  useEffect(() => {
    console.log('AppChild effect happen');
    return () => {
      console.log('AppChild destory happen');
    };
  });

  return (
    <div>我是 App 的子组件</div>
  );
}

结果如下:

通过结果可知,每次渲染,子组件 useEffect 中的参数函数先于父组件的执行。当组件树销毁时,组件树中组件清除函数的执行顺序通过下面一段代码了解。

第四段代码:

function App() {
  const [count, setCount] = useState(0);
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    console.log('App effect happen');
    return () => {
      console.log('App destory happen');
    };
  })

  return (
    <>
      { count } &nbsp; &nbsp;
      <button onClick={
        () => setCount(count + 1)
      }>
        + 1
      </button> &nbsp; &nbsp;
      <button onClick={
        () => setVisible(false)
      }>
        destory
      </button>
      <br />
      <br />
      {
        visible && <AppChild />
      }
    </>
  );
}

function AppChild() {
  useEffect(() => {
    console.log('AppChild effect happen');
    return () => {
      console.log('AppChild destory happen');
    };
  });

  return (
    <>
      <div>我是 App 的子组件</div>
      <AppChildChild />
    </>
  );
}

function AppChildChild() {
  useEffect(() => {
    console.log('AppChildChild effect happen');
    return () => {
      console.log('AppChildChild destory happen');
    };
  });

  return (
    <div>我是 App 子组件的子组件</div>
  );
}

结果如下:

通过结果可知,组件树销毁时,组件树中父组件的清除函数先于子组件的执行。通过这 4 段代码可以总结出:1、对于一个组件,首次渲染后,执行传入 useEffect 的参数函数,重新渲染,如果传入 useEffect 的数组的依赖项的值发生改变或没有给 useEffect 传入第二个参数,清除函数先于函数参数执行,组件销毁,执行清除函数;2、对于一个组件树,每次渲染,子组件的参数函数先于父组件的参数函数执行,组件树销毁时,组件树中的父组件的清除函数先于子组件的执行。

4.3.2、useRef

useRef 可以用来获取 DOM 节点或 class 组件实例,另外,因为每次渲染,useRef 返回同一个对象,可以在这个对象上添加变量,用于一个组件渲染间通信。

五、总结

本文介绍了用 Hook 优化性能的方法及总结了 Hook 的知识点,对 Hook 进一步的学习理解将会更新在本文中。

六、更新日志

2020-05-31 发布