Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

解密React state hook #204

Open
yaofly2012 opened this issue Nov 26, 2020 · 8 comments
Open

解密React state hook #204

yaofly2012 opened this issue Nov 26, 2020 · 8 comments

Comments

@yaofly2012
Copy link
Owner

yaofly2012 commented Nov 26, 2020

关键词:

  1. 更新队列
  2. 更新函数
  3. 懒计算
  4. 立即计算
  5. 跳过更新。

开始

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

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

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

正确的答案:

  1. 第一次点击“setCounter”按钮,state的值变成2触发一次re-render
    即输出:
Counter.render 2
Display.render 2
  1. 第二次点击“setCounter”按钮,虽然state没有变,但是又触发了一次组件Counter re-render,但是没有触发组件Display re-render;
    即输出:
Counter.render 2
  1. 第三次点击“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

会发现此时先执行的渲染函数,再执行更新函数。第二个更新函数的实参就是第一个更新函数的返回值。

三、批处理

在懒计算中只有再次执行渲染函数时才会知道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. 可以触发批处理的回调函数:
  • React事件处理函数;
  • React生命周期函数,如useEffect副作用函数;
  • 组件渲染函数内部
    在实现getDerivedStateFromProps中会遇到这种调用场景。
  1. 不会触发批处理的回调函数:
    非React触发调用的回调函数,比如setTimeout/setInterval等异步处理函数

四、跳过更新

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

  1. 懒计算
  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. 但是跳过更新并不表示不会重新执行渲染函数(从上面得知)

什么情况下会跳过更新

  1. 上面提到的state没有发生变化时会跳过更新;
  2. 当渲染函数里调用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

参考

  1. React Hook 修改状态的时候,传的状态值没有变化,但第一次依旧重新渲染了?
  2. We don't know how React state hook works
  3. A (Mostly) Complete Guide to React Rendering Behavior
@yaofly2012 yaofly2012 changed the title 解密 state hook 解密 React state hook Nov 26, 2020
@yaofly2012 yaofly2012 changed the title 解密 React state hook 解密React state hook Nov 26, 2020
@NE-SmallTown
Copy link

写得很好

其实 React 有点复杂化这里了,虽然应该是经过调研的。即,所有情况都采用立即计算(源码里的 eager compute)是最简单也最符合直觉的,否则像现在这样,多出的一次 render 是很蛋疼的,虽然用这换来了 better UI experience。我曾经问过原因,不过好像没回哈哈

@Vsnoy
Copy link

Vsnoy commented Jul 28, 2022

各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用useState/useReducer的先后顺序)。

多个更新队列那个例子里,我把useState顺序调换了下

const [counter2, setCounter2] = useState(1);
const [counter, setCounter] = useState(1);

结果是
Counter.render begin
setCounter update1
Counter.render begin
setCounter2 update1
setCounter2 update2
setCounter update2

可见setCounter2更新队列中的更新函数,并不总是先于setCounter更新队列中的更新函数优先执行。
这个要怎么解释呢?

@Ha0ran2001
Copy link

@Vsnoy
Copy link

Vsnoy commented Aug 3, 2022

@Vsnoy https://codesandbox.io/s/muddy-platform-2pj1nr?file=/src/App.js 我这边没有问题

我用你的例子,把两个useState换了下顺序还是这个结果啊。
按照博主的意思,更新队列是按照state hook的声明顺序依次执行的。

const [counter2, setCounter2] = useState(1);
const [counter, setCounter] = useState(1);

这么声明的话,期望的输出应该是

setCounter2 update1
setCounter2 update2
setCounter update1
setCounter update2

实际却是

setCounter update1
setCounter2 update1
setCounter2 update2
setCounter update2

Demo链接:https://codesandbox.io/s/boqlx9

@Vsnoy
Copy link

Vsnoy commented Aug 3, 2022

还有一个问题不太理解,关于懒计算切换立即计算的时机。

举个栗子,按照博文说的,分析下

function Counter() {
  const [counter, setCounter] = useState(0)
  
  console.log('Counter.render', counter)

  return (
    <>
      <p>{`couter: ${counter}`}</p>

      <button onClick={
        () => {
          console.log('click start')

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

          setCounter(prev => {
            console.log(`update 2, prev ${prev}`)
            return 2
          })
          
          console.log('click end')
        }
      }>
        setCounter
      </button>
    </>
  )
}

第一次点击setCounter,输出如下

click start 
update 1, prev 0  // 上一个state是初始state,执行立即计算,更新state为1
click end  // 上一个state是1,上上个state是0,state发生变化,采取懒计算
update 2, prev 1  // 等到执行useState的时候,执行懒计算,更新state为2
Counter.render 2

第二次点击setCounter,输出如下

click start
click end  // 上一个state是2,上上个state是1,state发生变化,采取懒计算
update 1, prev 2  // 等到执行useState的时候,执行懒计算,依次执行更新函数。这里更新state为1
update 2, prev 1  // 这里更新state为2
Counter.render 2

前面为止,用博文所提内容都能解释得通,等到第三次点击setCounter,结果貌似就无法照上面来解释了。
第三次点击setCounter,输出如下

click start 
update 1, prev 2  // 上一个state是2,上上个state是1,state发生变化,应该采取懒计算,但这里明显使用了立即计算?
click end 
update 2, prev 1 
Counter.render 2

原本以为自第二次setCounter之后都应该采用懒加载了,但这里切换为了立即计算,不太理解。
希望有大佬能够解惑!

Demo链接:https://codesandbox.io/s/condescending-water-gp38nr

@Vsnoy
Copy link

Vsnoy commented Aug 4, 2022

各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用useState/useReducer的先后顺序)。

多个更新队列那个例子里,我把useState顺序调换了下

const [counter2, setCounter2] = useState(1);
const [counter, setCounter] = useState(1);

结果是 Counter.render begin setCounter update1 Counter.render begin setCounter2 update1 setCounter2 update2 setCounter update2

可见setCounter2更新队列中的更新函数,并不总是先于setCounter更新队列中的更新函数优先执行。 这个要怎么解释呢?

各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用useState/useReducer的先后顺序)。

这个我想明白了,博主这句话应该要严谨点,加个前提条件:懒计算时。

懒计算的时候,是等到再次执行useState的时候,才会执行对于更新队列里的更新函数,这时候更新队列执行顺序就取决于useState的声明顺序。

立即计算的时候,与更新队列没有关系,是根据setState的顺序,依次执行。

拿这个栗子分析一下

function Counter() {
  console.log("Counter.render");

  const [counter2, setCounter2] = useState(1);
  const [counter, setCounter] = useState(1);

  return (
    <>
      <p>counter1: {counter}</p>
      <p>counter2: {counter2}</p>
      <button
        onClick={() => {
          console.log('click start')

          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;
          });

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

点击setCounter,输出结果是

click start 
setCounter update1  // 上一个state (counter) 是初始state,执行立即计算
click end  // 上一个state (counter) 是2,上上个state是1,state发生变化,采取懒计算
Counter.render 
setCounter2 update1  // 懒计算等到再次执行useState需要用到state的时候才会计算
setCounter2 update2  // 这里先遇到counter2的useState,则先逐一执行并清空counter2对应的更新队列里的更新函数
setCounter update2  // 然后遇到counter的useState,同理逐一执行并清空counter对应的更新队列里的更新函数

@yaofly2012 @Ha0ran2001

@xiao252
Copy link

xiao252 commented Mar 1, 2023

还有一个问题不太理解,关于懒计算切换立即计算的时机。

举个栗子,按照博文说的,分析下

function Counter() {
  const [counter, setCounter] = useState(0)
  
  console.log('Counter.render', counter)

  return (
    <>
      <p>{`couter: ${counter}`}</p>

      <button onClick={
        () => {
          console.log('click start')

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

          setCounter(prev => {
            console.log(`update 2, prev ${prev}`)
            return 2
          })
          
          console.log('click end')
        }
      }>
        setCounter
      </button>
    </>
  )
}

第一次点击setCounter,输出如下

click start 
update 1, prev 0  // 上一个state是初始state,执行立即计算,更新state为1
click end  // 上一个state是1,上上个state是0,state发生变化,采取懒计算
update 2, prev 1  // 等到执行useState的时候,执行懒计算,更新state为2
Counter.render 2

第二次点击setCounter,输出如下

click start
click end  // 上一个state是2,上上个state是1,state发生变化,采取懒计算
update 1, prev 2  // 等到执行useState的时候,执行懒计算,依次执行更新函数。这里更新state为1
update 2, prev 1  // 这里更新state为2
Counter.render 2

前面为止,用博文所提内容都能解释得通,等到第三次点击setCounter,结果貌似就无法照上面来解释了。 第三次点击setCounter,输出如下

click start 
update 1, prev 2  // 上一个state是2,上上个state是1,state发生变化,应该采取懒计算,但这里明显使用了立即计算?
click end 
update 2, prev 1 
Counter.render 2

原本以为自第二次setCounter之后都应该采用懒加载了,但这里切换为了立即计算,不太理解。 希望有大佬能够解惑!

Demo链接:https://codesandbox.io/s/condescending-water-gp38nr

这是什么原因呢?同问。

@Ha0ran2001
Copy link

各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用useState/useReducer的先后顺序)。

多个更新队列那个例子里,我把useState顺序调换了下

const [counter2, setCounter2] = useState(1);
const [counter, setCounter] = useState(1);

结果是 Counter.render begin setCounter update1 Counter.render begin setCounter2 update1 setCounter2 update2 setCounter update2
可见setCounter2更新队列中的更新函数,并不总是先于setCounter更新队列中的更新函数优先执行。 这个要怎么解释呢?

各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用useState/useReducer的先后顺序)。

这个我想明白了,博主这句话应该要严谨点,加个前提条件:懒计算时。

懒计算的时候,是等到再次执行useState的时候,才会执行对于更新队列里的更新函数,这时候更新队列执行顺序就取决于useState的声明顺序。

立即计算的时候,与更新队列没有关系,是根据setState的顺序,依次执行。

拿这个栗子分析一下

function Counter() {
  console.log("Counter.render");

  const [counter2, setCounter2] = useState(1);
  const [counter, setCounter] = useState(1);

  return (
    <>
      <p>counter1: {counter}</p>
      <p>counter2: {counter2}</p>
      <button
        onClick={() => {
          console.log('click start')

          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;
          });

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

点击setCounter,输出结果是

click start 
setCounter update1  // 上一个state (counter) 是初始state,执行立即计算
click end  // 上一个state (counter) 是2,上上个state是1,state发生变化,采取懒计算
Counter.render 
setCounter2 update1  // 懒计算等到再次执行useState需要用到state的时候才会计算
setCounter2 update2  // 这里先遇到counter2的useState,则先逐一执行并清空counter2对应的更新队列里的更新函数
setCounter update2  // 然后遇到counter的useState,同理逐一执行并清空counter对应的更新队列里的更新函数

@yaofly2012 @Ha0ran2001

@yaofly2012 对,初始state导致立即计算,立即计算内部导致懒计算,懒计算时遇到count2的useState触发批处理,然后遇到count1的useState

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants