React hooks optimization guide

write at the beginning

  • Before reading: I hope you know the use of basic hooks, such as useCallback, useReducer, etc. I will not introduce too many hooks in the article.
  • The problem of comments: In order to express more clearly, in some React code snippets, I will add comments to explain some things. For the convenience of writing, I use // instead of { /* */ } in jsx, please Ignore this error.

Start with a simple useToggle

I believe that everyone has used the checkbox or switch component, we implement one useToggle()to make things easier

function App() {
  const [on, toggle] = useToggle();
  //on是状态,toggle切换状态
  ...
}
复制代码

Simple implementation

import React from 'react';
export function useToggle(on: boolean): [boolean, () => void] {
  const [_on, setOn] = React.useState(on);
  return [_on, () => {setOn(!_on)}]
}
复制代码

Now this useToggle can be put into use, there is a small problem, have you observed it? Every time the on state changes, it will cause us to return to the new toggle method. Of course, it has no effect if it is simply used.

Try to optimize useToggle

Now that we have a new requirement, try to optimize useToggle to implement it.

E.g:

function App() {
  const [on, toggle] = useToggle();
  return(
    <div>
      //NeedOn仅需要使用on状态
      <NeedOn on ={on}></NeedOn>
      //Button组件仅负责修改on的状态
      <Button toggle = {toggle}></Button>
    </div>
  )
}
复制代码

In the above example, we have two components, NeedOn only needs to use the on state, and Button is only responsible for modifying the on state.

And now every change of on will cause the app to re-render, which in turn will cause NeedOnand Buttonre-render. Now we need to make on changes Buttonwithout re-rendering without changing the function.

How to solve it? First we have to address the toggle change, because one of the basic conditions for a component not to re-render is that its props don't change anymore. We can use useCallback() to solve this problem.

import React from 'react';
export function useToggle(on: boolean): [boolean, () => void] {
  const [_on, setOn] = React.useState(on);
  const _toggle = React.useCallback(() => {
    setOn(!_on);
  },[])
  return [_on,_toggle];
}
//现在我们使用了useCallback来缓存_toggle。
//由于传入数组为空_toggle再也不会被更新了,现在我们再也不用担心Button组件进行多余的渲染了。
// -> -> ->                                                                                                               如果您觉得这段代码没问题,那您可能需要重新学习一下hooks了
复制代码

The above code does not have rendering problems, but there is a more serious problem: the code logic error

Let's test it shallowly.

online test

//测试代码,可跳过
//默认on为true,调用4次toggle,期望结果为:[true, false, true, false, true]
function useToggle(on = true){
  const [_on, setOn] = React.useState(on);
  const _toggle = React.useCallback(() => {
    setOn(!_on);
  }, [])
  return [_on, _toggle];
}
const values = [];
const App = () => {
  const [on, toggle] = useToggle(true)
  
    const renderCountRef = React.useRef(1)
    
    React.useEffect(() => {
      if (renderCountRef.current < 5) {
        renderCountRef.current += 1
        toggle()
      }
    }, [on])

    values.push(on)
    
    return null
}
setTimeout(() => {console.log(values)},1000);
ReactDOM.render(<App/>,document.getElementById('root'))
复制代码

The expected result is [true, false, true, false, true], the actual result is: [true,false,false]. Let's focus on logical errors first and ignore the length exception of the result for now .

Logical error caused by capture value

这个逻辑错误是因为hooks的capture value特性,什么是capture value,有点类似于js的闭包。你可以认为每次组件render的时候,都是一个独立的快照,会有独属于它自己的”作用域”。

function Count(){
  //count为一个常量,每次render的count都是独立的
  //第一次点击,count:0
  //第二次点击,count:1
  //第三次点击,count:2
  const [count,setCount] = React.useState(0);
  setTimeout(() => {
    console.log(count);
    //这里始终输出对应的值而不是输出最新的值
    //假如说你回调触发之前,2秒内点击三次button,之后3次回调函数依次触发:依然输出0,1,2而不是2,2,2
  },2000)
  
  return (
    <div>
      <span>{count}</span>
      <button onClick= {() => {setCount(count + 1)}}>增加?</button>
    </div>
  )
}
复制代码

现在,我们已经知道错误的原因了:由于_toggle方法不会被更新,该方法引用外部的常量on一直为默认值即true,后续_toggle所有的调用都是重复把true变为false。

那该怎么解决呢?在useCallback里传入正确的依赖项?

import React from 'react';
export function useToggle(on: boolean): [boolean, () => void] {
  const [_on, setOn] = React.useState(on);
  const _toggle = React.useCallback(() => {
    setOn(!_on);
  },[_on])
  //在数组中传入_on,这样每次_on改变的时候,_toggle也会改变。
  return [_on,_toggle];
}
复制代码

这样的话,和我们一开始的写法本质上是没有区别的,当on改变时,也会返回新的toggle。

使用useCallback及通过函数来更新state

其实只需要在useState中使用函数来更新:

import React from 'react';
export function useToggle(on: boolean): [boolean, () => void] {
  const [_on, setOn] = React.useState(on);
  const _toggle = React.useCallback(() => {
    setOn(_on => !_on);
    //现在我们在setOn内传入函数,函数内的_on每次都是最新的。
    //同时,依赖数组是空的,这也意味着_toggle是不会更新的。
  },[])
  return [_on,_toggle];
}
复制代码

现在,我们已经写出了一个不错的hooks,我们使用useCallback来缓存toggle,这样当on改变的时候,toggle并不会改变,即Button组件的props不会改变,那么Button也不会再重新渲染了,是这样吗?

使用useReducer

我们都知道useReducer的dispatch是不会改变的,那我们可以在useToggle内部使用useReducer来通过返回dispatch的方式来达到目的。

import React from 'react';
export function useToggle(on = true) {
  function reducer(state,action){
    switch(action.type){
      case 'toggle': return !state;
      default: throw new Error();
    }
  }
  const [_on, dispatch] = React.useReducer(reducer,on);
  return [_on,dispatch];
} 
复制代码

优化结束了吗?

很不幸,如果只优化useToggle并没有什么用。在线代码:优化useToggle

因为当父组件渲染时,子组件一定会重新渲染,无论子组件的props是否改变。

因此除了对useToggle进行优化外,我们还要对Button进行缓存,使用useCallback的好兄弟useMemo来实现。

function App() {
  const [on, toggle] = useToggle();
  const MyButton = useMemo(() => {
    return <Button toggle = {toggle}></Button>
  },[])
  return(
    <div>
      //NeedOn仅需要使用on状态
      <NeedOn on ={on}></NeedOn>
      //由于没有在useMemo中传入依赖,MyButton不会改变
      {MyButton}
    </div>
  )
}
复制代码

现在,我们才算完成了最初的需求,回顾一下步骤:

  • 我们优化了useToggle,用useCallback缓存内部的toggle函数,使on改变时,toggle不会改变。
  • 基于优化后的useToggle,我们又使用useMemo对Button进行了缓存,这样当on改变时,虽然会导致App重新渲染,但不会再引起Button的重新渲染。

在线代码:优化完成

useMemo之前

其实大多数情况下,并不需要做上面这样优化,因为对性能提示并没有太大的帮助,而且频繁的使用useMemo和useCallback还会加重心智负担。所以当你要使用useMemo来减少某一个具体组件的重复渲染之前,可以先思考一下是否有使用它的必要。

下面这个例子可能会出现在各位的代码中。

function ExpensiveTree() {
  let now = performance.now();
  while (performance.now() - now < 100) {
    // Artificial delay -- do nothing for 100ms
  }
  return <p>I am a very slow component tree.</p>;
}

function App() {
  const [on, toggle] = useToggle();
  return(
    <div>
      //NeedOn仅需要使用on状态
      <NeedOn on ={on}></NeedOn>
      //Button组件仅负责修改on的状态
      <Button toggle = {toggle}></Button>
      <ExpensiveTree/>
    </div>
  )
}
复制代码

还是最开始的例子,不同的是我们现在的目标是阻止ExpensiveTree的重新渲染。简单的使用useMemo就能达到目的。但除此之外呢?

下沉state

ExpensiveTree重新渲染是因为App重新渲染,那我们试着来直接避免App的重新渲染。

App重新渲染是因为on(state)的改变。因此我们把NeedOnButton抽离就可以了,或者说把useToggle(useState)下放到子组件中。

function ExpensiveTree() {
  let now = performance.now();
  while (performance.now() - now < 100) {
    // Artificial delay -- do nothing for 100ms
  }
  return <p>I am a very slow component tree.</p>;
}
function Toggle(){
  const [on, toggle] = useToggle();
  return(
    //NeedOn仅需要使用on状态
    <NeedOn on ={on}></NeedOn>
    //Button组件仅负责修改on的状态
   <Button toggle = {toggle}></Button>
  )
}
function App() {
  
  return(
    <div>
      <Toggle/>
      <ExpensiveTree/>
    </div>
  )
}
复制代码

现在Toggle会重新渲染,而App和ExpensiveTree则不会。

提升内容

但向下面这种情况,我们好像并不能将useToggle下沉。因为我们要基于on的状态来改变样式。

function ExpensiveTree() {
  let now = performance.now();
  while (performance.now() - now < 100) {
    // Artificial delay -- do nothing for 100ms
  }
  return <p>I am a very slow component tree.</p>;
}
function App() {
  const [on, toggle] = useToggle();
  return(
    //现在我们需要根据on的状态来改变样式
    <div style={on ? {color: 'red'} : {color: 'black'}>
      <NeedOn on ={on}></NeedOn>
      <Button toggle = {toggle}></Button>
      <ExpensiveTree/>
    </div>
  )
}
复制代码

该怎么做呢?

function ExpensiveTree() {
  let now = performance.now();
  while (performance.now() - now < 100) {
    // Artificial delay -- do nothing for 100ms
  }
  return <p>I am a very slow component tree.</p>;
}
function Toggle({children}){
  const [on, toggle] = useToggle();
  return (
    <div className={on ? 'white' : 'black'}>
      <NeedOn on ={on}></NeedOn>
      <Button toggle = {toggle}></Button>
      {children}
    </div>
  )
}
function App() {
    return(
      <Toggle>
        <ExpensiveTree/>
      </Toggle>
  )
}
复制代码

我们抽离出一个Toggle组件,然后通过传入ExpensiveTree的方式来达到目的。

在线代码:提升内容

因为当on改变,Toggle重新渲染的时候,我们通过App传入的ExpensiveTree是不会变化的。现在我们既可以通过on来修改样式,也避免了ExpensiveTree的重新渲染。

以上,当我们使用React的时候,可以小小的关注一下某些不必要的“昂贵”的组件的重新渲染,是否可以通过一些简单的处理来避免掉。

bail out导致的长度异常

在上面的一个例子中由于capture value的特性,导致逻辑出现异常,但除此之外结果[true,false,false]的长度也与实际情况有些出入。

bailing out of a state update,该特性在官网上被提到过。

即如果你更新的state和当前的state是”相同”的话,就会导致bail out,该组件的子孙组件不会重新渲染且该组件useEffect不会被触发。

“相同”指,Object.is(nextState,curState)返回true。Object.is为浅比较.

重新回顾一下上面的测试代码

function useToggle(on = true){
  const [_on, setOn] = React.useState(on);
  const _toggle = React.useCallback(() => {
    setOn(!_on);
  }, [])
  return [_on, _toggle];
}
const values = [];
const App = () => {
  const [on, toggle] = useToggle(true)
  
    const renderCountRef = React.useRef(1)
    
    React.useEffect(() => {
      if (renderCountRef.current < 5) {
        renderCountRef.current += 1
        toggle()
      }
    }, [on])
    //初始化时,推入true
    //初始化后useEffect执行 -> 第一次触发toggle,修改on值为false
    //第一次toggle触发后useEffect执行 -> 第二次触发toggle,经react检测,Object(false,false)为true,bail out.
    values.push(on)
    
    return null
}
setTimeout(() => {console.log(values)},1000);
ReactDOM.render(<App/>,document.getElementById('root'))
复制代码

为什么useEffect执行两次,而values.push推入三次?

不知道大家有这个疑问没有,首先这个问题其实上面提到过:

如果你更新的state和当前的state是”相同”的话,就会导致bail out,该组件的子孙组件不会重新渲染且该组件useEffect不会被触发。

具体来解释的话就要再深入一点点,我们应该知道,目前react采用fiber架构,而fiber的更新是分为两个阶段的,即render和commit阶段。对于类组件来说大部分生命周期在commit阶段触发(带will的生命周期在render中触发),对于hooks来说,useEffect(包括useLayoutEffect)也在commit阶段中触发。在render阶段我们对比jsx对象与旧fiber,并将变化记录到effectList链表中.而为了确保是否真的应该bail out(我们知道react有批处理,即多个同步的setState会被合并,所以只看单个setState的话是无法确保这次更新是否应该bail out),而在React reRedener的时候,这多个setState被链式的储存在fiber节点的updateQueue属性上。react会在render阶段通过updateQueue链式的计算最后的state并将结果储存到fiber的memoizedState属性上。在此时进行对比,才能决定是否应该bail out。

而在我们的测试代码中,values.push在render阶段触发,所以它会被触发3次,而useEffect不在render阶段,不会触发第3次。

这两个链接可以帮助你理解这个问题。

Why React needs another render to bail out state updates?

useState not bailing out when state does not change #14994

参考

www.developerway.com/posts/how-t…

Guess you like

Origin juejin.im/post/7079414356277985316