一、每次setState触发渲染,我们所定义的组件函数都会被react执行一次
以这段代码为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 function App() {
const [state, setState] = useState(0);
console.log(state)
const onclick = () => {
setState(state + 1)
}
return (
<div className='div' onClick={onclick}>
{state}
</div>
);
}
你每次点击一次div,就会触发onclick函数去更新状态变量。同时你会发现每次状态更新,第4行的console.log都会被执行。上一节我们讲到performConcurrentWorkOnRoot是开始执行更新任务,我们从performConcurrentWorkOnRoot继续往后看: performConcurrentWorkOnRoot->renderRootConcurrent->workLoopConcurrent->performUnitOfWork->beginWork(位于packages\react-reconciler\src\ReactFiberBeginWork.js)->updateFunctionComponent->renderWithHooks->Component。 沿着函数调用链至此,你可以看到,它又执行了一次我们所定义的组件函数。 不是说react就是为了使得函数每次更新都执行一次。而是每次更新都需要依赖去执行一次这个函数来做状态变量得更改,来解析jsx结构计算更新后的组件结构。而函数每次都执行,就引入了useCallBack,useMemo这两个hook,因为常规来讲这个例子中的click方法,也是每次都重新创建赋值的,会耗费性能。state变量其实也是每次重新执行然后重新定义的值。上一次执行的函数空间就被销毁了
二、const [state, setState] = useState(initialState);状态变量state在调用setState之后,会触发render任务,在render过程中才去更改状态变量state的值。
在useState调用栈中我们知道,在调用setState时实际最终是触发了调度器去调度performConcurrentWorkOnRoot任务渲染。当我们把任务交给调度器时,这个任务便会在何时执行便脱离了原来的代码执行流程,由调度器去确定执行时机,所以当我们在一个js任务中调用了setState之后,在这段代码之后去获取状态变量state的值并不是setState之后的值。因为这时,render过程去更新fiber树,commit渲染ui结果的过程并没有发生。只有当调度器调度执行了更新fiber的任务之后,state才被更改。fiber你暂且可以看作是一颗虚拟Dom树,树的每个节点都存放着对应组件的相关信息,包括state。因此你会发现setState之后state的值并没有立即被更改。如这段react样例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 function App() {
const [state, setState] = useState(0);
const onclick = () => {
setState(1)
console.log(state)
}
return (
<div className='div' onClick={onclick}>
测试一下
</div>
);
}
state初始值为0,点击一下div组件触发onclick方法。setState(1)将状态state改为1,但是console.log打印结果依然为0。这个样例非常简单,你可以自己尝试一下。如果你有看过useState函数调用栈的章节,你应该知道: setState 就相当于 dispatchSetState.bind( null, currentlyRenderingFiber, queue, )。 dispatchSetState方法的实现源码位于packages\react-reconciler\src\ReactFiberHooks.js。这里setState的传参就对应dispatchSetState方法的第三个参数,它会将要更新的值加入更新队列中,等待调度。 state的新值的获得是react如(一)中所说,在重新执行我们的组件函数时,在const [state, setState] = useState(0);这行重新获得的。 其实从某种意义上来讲,这次得state跟上一次得state其实是完全处在两个不同得函数作用域的两个变量。更新后的那个状态是react重新执行的组件函数中定义的新的值。而不是说我把上一变量修改为新的变量了。上一次的函数空间包括里面的各种变量,函数都已经被完全销毁了。所有的都是新执行生成的。可以看这个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 function App() {
const [state, setState] = useState(0);
const onclick = () => {
setState(10)
setTimeout(() => {
console.log('setTimeout', state)
}, 2000);
}
useEffect(() => {
console.log('useEffect', state)
}, [state])
return (
<div className='div' onClick={onclick}>
<p></p>{state}
</div>
);
}
控制台打印结果如下:
useEffect副作用会在首次执行时调用一次此时state为0。然后点击div触发click时间,重新setState。组件重新刷新,出发副作用,打印刷新后的state为10。但是setTimeout在2s后打印结果依然为0,这是因为这里打印的state是第一次组件函数调用时定义的静态变量state。你setState之后,又一次重新执行了这个函数,重新定义了新的state变量。而setTimeout是在第一次执行中组件函数的函数作用域中使用的,console.log打印的state变量是第一执行的组件函数中的state变量。
总结:setState看起来像是异步的,你不能直接setState之后就拿到新的state值,而是在调度更新时才能拿到。实际调度更新过程中,新值得获取是在渲染过程重新调用组件函数时获得的。而且已经是一个全新的函数作用域了。
三、一次任务中多次setState,setState会被进行合并。
同样也因为fiber更新并不发生在调用setState的任务中。那么当你在一次任务中更新多次同一个state,实际上只有最后一次setState会真正被应用到fiber中。所以你会看到一个现象,多次setState同一个state会被合并为最后一次setState。如下面这段示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 function App() {
const [state, setState] = useState(0);
console.log(state)
const onclick = () => {
for (let i = 0; i < 10; i++) {
setState(i)
}
}
return (
<div className='div' onClick={onclick}>
<p></p>{state}
</div>
);
}
当你触发click时间时,调用了10次setState,但实际你会注意到console.log只打印了一次9。也就是在一次同步任务中调用多次setState这些中间状态会被合并,只执行最后一次。 没错,看起来是这样,但实际上setState既然调用了,肯定是要执行的。只是这些更新都被放在了hook的状态队列中。在进行更新时,会进行合并。这个合并就发生在源码的updateState方法,也就是更新时useState的实际方法。沿着updateState->updateReducer->updateReducerImpl的调用链。在updateReducerImpl方法中有一段do...while()的循环体,就是在进行队列合并。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123 do {
// An extra OffscreenLane bit is added to updates that were made to
// a hidden tree, so that we can distinguish them from updates that were
// already there when the tree was hidden.
const updateLane = removeLanes(update.lane, OffscreenLane);
const isHiddenUpdate = updateLane !== update.lane;
// Check if this update was made while the tree was hidden. If so, then
// it's not a "base" update and we should disregard the extra base lanes
// that were added to renderLanes when we entered the Offscreen tree.
const shouldSkipUpdate = isHiddenUpdate
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
: !isSubsetOfLanes(renderLanes, updateLane);
if (shouldSkipUpdate) {
// Priority is insufficient. Skip this update. If this is the first
// skipped update, the previous update/state is the new base
// update/state.
const clone: Update<S, A> = {
lane: updateLane,
revertLane: update.revertLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// Update the remaining priority in the queue.
// TODO: Don't need to accumulate this. Instead, we can remove
// renderLanes from the original lanes.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane,
);
markSkippedUpdateLanes(updateLane);
} else {
// This update does have sufficient priority.
// Check if this is an optimistic update.
const revertLane = update.revertLane;
if (!enableAsyncActions || revertLane === NoLane) {
// This is not an optimistic update, and we're going to apply it now.
// But, if there were earlier updates that were skipped, we need to
// leave this update in the queue so it can be rebased later.
if (newBaseQueueLast !== null) {
const clone: Update<S, A> = {
// This update is going to be committed so we never want uncommit
// it. Using NoLane works because 0 is a subset of all bitmasks, so
// this will never be skipped by the check above.
lane: NoLane,
revertLane: NoLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
} else {
// This is an optimistic update. If the "revert" priority is
// sufficient, don't apply the update. Otherwise, apply the update,
// but leave it in the queue so it can be either reverted or
// rebased in a subsequent render.
if (isSubsetOfLanes(renderLanes, revertLane)) {
// The transition that this optimistic update is associated with
// has finished. Pretend the update doesn't exist by skipping
// over it.
update = update.next;
continue;
} else {
const clone: Update<S, A> = {
// Once we commit an optimistic update, we shouldn't uncommit it
// until the transition it is associated with has finished
// (represented by revertLane). Using NoLane here works because 0
// is a subset of all bitmasks, so this will never be skipped by
// the check above.
lane: NoLane,
// Reuse the same revertLane so we know when the transition
// has finished.
revertLane: update.revertLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// Update the remaining priority in the queue.
// TODO: Don't need to accumulate this. Instead, we can remove
// renderLanes from the original lanes.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
revertLane,
);
markSkippedUpdateLanes(revertLane);
}
}
// Process this update.
const action = update.action;
if (shouldDoubleInvokeUserFnsInHooksDEV) {
reducer(newState, action);
}
if (update.hasEagerState) {
// If this update is a state update (not a reducer) and was processed eagerly,
// we can use the eagerly computed state
newState = ((update.eagerState: any): S);
} else {
newState = reducer(newState, action);
}
}
console.log('update', update)
update = update.next;
} while (update !== null && update !== first);
整个合并流程还涉及到一些优先级之类的逻辑,暂不展开。主要知道这个循环就是遍历状态队列,然后进行合并,得到这一次要真实渲染的状态。而不是每一次的setState都会被真实渲染。
四、setState的同异步
在学界流传着一个说法,setState在原生事件和在setTimeout等脱离react上下文的代码中中是同步的,其它情况是异步。这个说法是对的,但是过于笼统和不准确。 首先react存在legacy和concurrent两种模式。setState也有类组件中的this.setState和hook中的setState。这些情况之间是存在差异的。 “setState在原生事件和在setTimeout等脱离react上下文的代码中中是同步的,其它情况是异步的"。这种说法仅对legacy模式下类组件的this.setState有效。 其余情况均为异步。