4

解密React state hook

 2 years ago
source link: https://segmentfault.com/a/1190000040483472
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

解密React state hook

先看个问题,下面组件中如果点击3次组件Counter的“setCounter”按钮,控制台输出是什么?

function Counter() {
    const [counter, setCounter] = useState(1);
    console.log('Counter.render', counter);
    return (
           <>
               <Display counter={counter}/>
               <button onClick={() => setCounter(2)}>setCounter</button>
           </>
   )
}

function Display({ counter }) {
    console.log('Display.render', counter);
    return <p>{counter}</p>
}

.
.
.
正确的答案是:

  1. 第一次点击“setCounter”按钮,state的值变成2触发一次re-render
    即输出:

    Counter.render 2
    Display.render 2
  2. 第二次点击“setCounter”按钮,虽然state的值没有变,但也触发了一次组件Counter re-render,但是没有触发组件Display re-render
    即输出:

    Counter.render 2
  3. 第三次点击“setCounter”按钮,state没有变,也没有触发re-render

一、更新队列

1.1 什么是更新队列

其实每个state hook都关联一个更新队列。每次调用setState/dispatch函数时,React并不会立即执行state的更新函数,而是把更新函数插入更新队列里,并告诉React需要安排一次re-render
举个栗子:

function Counter() {
    const [counter, setCounter] = useState(0);
    console.log('Counter.render', counter);
    return (
           <>
               <Display counter={counter}/>
               <button onClick={() => setCounter(counter + 1)}>Add</button>
               <button onClick={() => {
                   console.log('Click event begin');
                   
                   setCounter(() => {
                       console.log('update 1');
                       return 1;
                   });

                   setCounter(() => {
                        console.log('update 2');
                        return 2;
                   });

                   console.log('Click event end');
               }}>setCounter</button>
           </>
   )
}

先点击下"Add"按钮(后面解释原因),再点击“setCounter”按钮看下输出:

Click event begin
Click event end
update 1
update 2
Counter.render 2
Display.render 2

通过例子可以看出在执行事件处理函数过程中并没有立即执行state更新函数。这主要是为了性能优化,因为可能存在多处setState/dispatch函数调用。

1.2 多个更新队列

每个state都对应一个更新队列,一个组件里可能会涉及多个更新队列。

  1. 各个更新队列是互相独立的;
  2. 各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用useState/useReducer的先后顺序)。
  3. 同一个更新队列里多个更新函数是依次执行的,前一个更新函数的输出作为下一个更新函数的输入(类似管道)。
function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(1);
    const [counter2, setCounter2] = useState(1);
    return (
           <>
               <p>counter1: {counter}</p>
               <p>counter2: {counter2}</p>
               <button onClick={() => {
                    setCounter(() => {
                       console.log('setCounter update1');
                       return 2;
                   })
                    setCounter2(() => {
                        console.log('setCounter2 update1');
                        return 2;
                    })
                    setCounter(() => {
                        console.log('setCounter update2');
                        return 2;
                    })
                    setCounter2(() => {
                        console.log('setCounter2 update2');
                        return 2;
                    })
               }}>setCounter2</button>
           </>
   )
}

点击"setCounter2"按钮看看输出结果。上例中setCounter对应的更新队列的更新函数永远要先于setCounter2对应的任务队列的更新函数执行。

二、懒计算

什么时候执行更新队列的更新函数呢?懒计算就是执行更新函数的策略之一。懒计算是指只有需要state时React才会去计算最新的state值,即得等到再次执行useState/useReducer时才会执行更新队列里的更新函数。

function Display({ counter }) {
    console.log('Display.render', counter);
    return <p>{counter}</p>
}

function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(0);
    console.log('Counter.render', counter);
    return (
           <>
               <Display counter={counter}/>
               <button onClick={() => setCounter(counter + 1)}>Add</button>
               <button onClick={() => {
                   console.log('Click event begin');
                   
                   setCounter(prev => {
                       console.log(`update 1, prev=${prev}`);
                       return 10;
                   });

                   setCounter(prev => {
                        console.log(`update 2, prev=${prev}`);
                        return 20;
                   });

                   console.log('Click event end');
               }}>setCounter</button>
           </>
   )
}

先点击下"Add"按钮,再点击“setCounter”按钮看下输出:

Click event begin
Click event end
Counter.render begin
update 1, prev=1
update 2, prev=10
Counter.render 20
Display.render 20

通过栗子会发现:

  1. 先执行渲染函数,再执行更新函数;
  2. 第二个更新函数的实参就是第一个更新函数的返回值。

三、批处理

在懒计算中只有再次执行渲染函数时才会知道state是否发生变化。那React什么时候再次执行组件渲染函数呢?
一般我们都是在事件处理函数里调用setState,React在一个批处理里执行事件处理函数。事件处理函数执行完毕后如果触发了re-render请求(一次或者多次),则React就触发一次且只触发一次re-render

3.1 特性

1. 一个批处理最多触发一次re-render, 并且一个批处理里可以包含多个更新队列;

function Counter() {
    console.log('Counter.render begin');
    const [counter1, setCounter1] = useState(0);
    const [counter2, setCounter2] = useState(0);

    return (
           <>
               <p>counter1={counter1}</p>
               <p>counter2={counter2}</p>
               <button onClick={() => {                   
                   setCounter1(10);
                   setCounter1(11);

                   setCounter2(20);
                   setCounter2(21);
               }}>setCounter</button>
           </>
   )
}

点击"setCounter"按钮,看下输出:

Counter.render begin

2. 批处理只能处理回调函数里的同步代码,异步代码会作为新的批处理;

function Display({ counter }) {
    console.log('Display.render', counter);
    return <p>{counter}</p>
}

function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(0);
    return (
           <>
               <Display counter={counter}/>
               <button onClick={() => {                   
                   setCounter(prev => {
                       return 10;
                   });

                   setTimeout(() => {
                        setCounter(prev => {
                            return 20;
                        });
                   })
               }}>setCounter</button>
           </>
   )
}

点击"setCounter"按钮,看下输出:

Counter.render begin
Display.render 10
Counter.render begin
Display.render 20

触发两次批处理。

3. 异步回调函数里触发的re-render不会作为批处理

setTimeout/setInterval等异步处理函数调用并不是React触发调用的,React也就无法对这些回调函数触发的re-render进行批处理。

function Display({ counter }) {
    console.log('Display.render', counter);
    return <p>{counter}</p>
}

export default function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(0);
    return (
           <>
               <Display counter={counter}/>
               <button onClick={() => {                   
                    setCounter(prev => {
                       return 10;
                    });

                   setCounter(prev => {
                        return 11;
                    });

                   setTimeout(() => {
                        setCounter(prev => {
                            return 20;
                        });

                        setCounter(prev => {
                            return 21;
                        });
                   })
               }}>setCounter</button>
           </>
   )
}

点击setCounter按钮输出:

Counter.render begin
Display.render 11
Counter.render begin
Display.render 20
Counter.render begin
Display.render 21

可以看出事件处理函数的里两次setState进行了批处理,而setTimeout回调函数里的两次setState分别触发了两次re-render。

3.2 总结

  1. 可以触发批处理的回调函数:
  2. React事件处理函数;
  3. React生命周期函数,如useEffect副作用函数;
  4. 组件渲染函数内部
    在实现getDerivedStateFromProps中会遇到这种调用场景。
  5. 不会触发批处理的回调函数:
    非React触发调用的回调函数,比如setTimeout/setInterval等异步处理函数

四、跳过更新

我们都知道如果state的值没有发生变化,React是不会重新渲染组件的。但是从上面得知React只有再次执行useState时才会计算state的值啊。
为了计算最新的state需要触发re-render,而state如果不变又不渲染组件,这好像是个先有蛋还是先有鸡的问题。React是采用2个策略跳过重新渲染:

4.1 立即计算

除了上面提到的都是懒计算,其实React还存在立即计算。当React执行完当前渲染后,会立马执行更新队列里的更新函数计算最新的state

  • 如果state值不变,则不会触发re-render
  • 如果state值发生变化,则转到懒计算策略。

当上一次计算的state没有发生变化或者上次是初始state(说明React默认采用立即计算策略),则采用立即执行策略调用更新函数:

1. 当前state是初始state;

function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(1);
    return (
           <>
               <p>counter={counter}</p>
               <button onClick={() => {                   
                    console.log('Click event begin');
                    setCounter(() => {
                        console.log('update');
                        return counter;
                    })
                    console.log('Click event end');
               }}>setCounter</button>
           </>
   )
}

点击“setCounter”按钮看下输出:

Click event begin
update
Click event end

这样说明了React默认采用立即执行策略。

2. 上一次计算state不变

function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(1);
    return (
           <>
               <p>counter={counter}</p>     

               <button onClick={() => {                   
                    console.log('Click event begin');
                    // 保持state不变
                    setCounter(() => {
                        console.log('update');
                        return counter;
                    })
                    console.log('Click event end');
               }}>setCounter</button>
                <button onClick={() => {
                    setCounter(2)
                }}>setCounter2</button>
           </>
   )
}

先点击两次或者更多次"setCounter2"按钮(营造上次计算结果是state不变),再点击一次“setCounter”按钮看下输出。

4.2 懒计算

懒计算就是上面说到的那样。懒计算过程中如果发现最终计算的state没有发现变化,则React不选择组件的子组件,即此时虽然执行了组件渲染函数,但是不会渲染组件的子组件

function Display({ counter }) {
    console.log('Display.render', counter);
    return <p>{counter}</p>
}

function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(1);
    return (
           <>
               <Display counter={counter} />
               <button onClick={() => setCounter(2) }>setCounter2</button>
           </>
   )
}

点击两次“setCounter2”按钮,看下输出:

Counter.render begin
Display.render 2
Counter.render begin

第二次点击虽然触发了父组件re-render,但是子组件Display并没有re-render

懒计算导致的问题只是会多触发一次组件re-render,但这一般不是问题。React useState API文档也提到了:

Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with useMemo.

4.3 立即计算自动转懒计算

在一个批处理中采用立即计算发现state发生变化,则立马转成懒计算模式,即后面的所有任务队列的所有更新函数都不执行了。

function Counter() {
    console.log('Counter.render begin');
    const [counter, setCounter] = useState(1);
    return (
           <>
               <p>counter={counter}</p>     

               <button onClick={() => {                   
                    console.log('Click event begin');
                    // 保持state不变
                    setCounter(() => {
                        console.log('update 1');
                        return counter;
                    })

                    // state + 1
                    setCounter(() => {
                        console.log('update 2');
                        return counter + 1;
                    })

                    // state + 1
                    setCounter(() => {
                        console.log('update 3');
                        return counter + 1;
                    })
                    console.log('Click event end');
               }}>setCounter</button>
           </>
   )
}

点击“setCounter”按钮,看下输出:

Click event begin // 先调用事件处理函数
update 1 // 上个state是初始state,采用立即执行策略,所以立马执行更新函数1
update 2 // 更新函数1并没有更新state,继续采用立即执行策略,所以立马执行更新函数2,但是state发生了变化,转懒计算策略
Click event end
Counter.render begin
update 3

执行完更新函数2state发生了变化,React立马转成懒加载模式,后面的更新函数都不立即执行了。

4.4 重新认识跳过更新

什么是跳过更新

  1. 不会渲染子组件;
  2. 不会触发组件effect回调。
  3. 但是跳过更新并不表示不会重新执行渲染函数(从上面得知)

什么情况下会跳过更新

除了上面提到的state没有发生变化时会跳过更新,还有当渲染函数里调用setState/dispatch时也会触发跳过更新。

function Display({ counter }) {
    console.log('Display.render', counter);
    return <p>{counter}</p>
}

export default function Counter() {
    const [counter, setCounter] = useState(0);
    console.log(`Counter.render begin counter=${counter}`);
    
    if(counter === 2) {
        setCounter(3)
    }
    
    useEffect(() => {
        console.log(`useEffect counter=${counter}`)
    }, [counter])

    return (
           <>
               <Display counter={counter}/>
               <button onClick={() => {                   
                    setCounter(2)
               }}>setCounter 2</button>
           </>
   )
}

点击setCounter 2按钮输出:

Counter.render begin counter=2
Counter.render begin counter=3
Display.render 3
useEffect counter=3

可以看到state=2触发的更新被跳过了。

五、总结下

  1. 任务队列是为了懒计算更新函数;
  2. 批处理是为了控制并触发re-render
  3. 懒计算立即计算是为了优化性能,既要实现state不变时不重新渲染组件,又要实现懒计算state

整理自GitHub笔记:解密React state hook


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK