![](/style/images/good.png)
![](/style/images/bad.png)
解密React state hook
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> }
.
.
.
正确的答案是:
第一次点击“setCounter”按钮,
state
的值变成2触发一次re-render
;
即输出:Counter.render 2 Display.render 2
第二次点击“setCounter”按钮,虽然
state
的值没有变,但也触发了一次组件Counter
re-render
,但是没有触发组件Display
re-render
;
即输出:Counter.render 2
- 第三次点击“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
都对应一个更新队列,一个组件里可能会涉及多个更新队列。
- 各个更新队列是互相独立的;
- 各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用
useState/useReducer
的先后顺序)。 - 同一个更新队列里多个更新函数是依次执行的,前一个更新函数的输出作为下一个更新函数的输入(类似管道)。
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 总结
- 可以触发批处理的回调函数:
- React事件处理函数;
- React生命周期函数,如
useEffect
副作用函数; - 组件渲染函数内部
在实现getDerivedStateFromProps
中会遇到这种调用场景。 - 不会触发批处理的回调函数:
非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
执行完更新函数2
时state
发生了变化,React立马转成懒加载模式,后面的更新函数都不立即执行了。
4.4 重新认识跳过更新
什么是跳过更新
- 不会渲染子组件;
- 不会触发组件
effect
回调。 - 但是跳过更新并不表示不会重新执行渲染函数(从上面得知)
什么情况下会跳过更新
除了上面提到的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
触发的更新被跳过了。
五、总结下
- 任务队列是为了懒计算更新函数;
- 批处理是为了控制并触发
re-render
; - 懒计算和立即计算是为了优化性能,既要实现
state
不变时不重新渲染组件,又要实现懒计算state
。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK