解密React state hook
关键词:
- 更新队列
- 更新函数
- 懒计算
- 立即计算
- 跳过更新。
开始
先看个问题,下面组件中如果点击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>
</>
)
}
正确的答案:
- 第一次点击“setCounter”按钮,
state的值变成2触发一次re-render; 即输出:
Counter.render 2
Display.render 2
- 第二次点击“setCounter”按钮,虽然
state没有变,但是又触发了一次组件Counterre-render,但是没有触发组件Displayre-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。
参考
写得很好
其实 React 有点复杂化这里了,虽然应该是经过调研的。即,所有情况都采用立即计算(源码里的 eager compute)是最简单也最符合直觉的,否则像现在这样,多出的一次 render 是很蛋疼的,虽然用这换来了 better UI experience。我曾经问过原因,不过好像没回哈哈
各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用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更新队列中的更新函数优先执行。 这个要怎么解释呢?
@Vsnoy https://codesandbox.io/s/muddy-platform-2pj1nr?file=/src/App.js 我这边没有问题
@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
还有一个问题不太理解,关于懒计算切换立即计算的时机。
举个栗子,按照博文说的,分析下
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
各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用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
还有一个问题不太理解,关于懒计算切换立即计算的时机。
举个栗子,按照博文说的,分析下
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
这是什么原因呢?同问。
各个更新队列的更新函数执行顺序取决于任务队列创建先后(即调用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