最近同事问了我一个问题,为啥 Modal.success 中的 hook 无法实时更新,代码如下
import React, { useState } from "react"
import { Modal, Button } from 'antd'
function App() {
const [num, setNum] = useState(0)
const success = () => {
setNum(100)
Modal.success({
content: <span>num: {num}</span>,
onOk: () => {}
})
}
return <Button onClick={success}>Success</Button>
}
export default App
看起来代码没有任何问题,但是点击按钮后展示如下
也就是说弹窗中展示的是初始值0,并不是setNum设置的最新值100,这就非常奇怪,useState不是可以实时更新num的值吗,为什么这里失效了,初步怀疑是由于setNum异步执行导致,于是修改代码如下
import React, { useState } from "react"
import { Modal, Button } from 'antd'
function App() {
const [num, setNum] = useState(0)
const success = () => {
setNum(100)
Modal.success({
content: <span>num: {num}</span>,
onOk: () => {
setTimeout(() => {
console.log(num);
}, 1000);
}
})
}
return <Button onClick={success}>Success</Button>
}
export default App
在 onOk 中设置一个定时器,一秒后再获取 num,发现得到的值还是0,排除异步因素,那为什么会出现这种情况呢?
于是花费了一些时间对这个问题进行研究,下面是对这个问题的解析,你可以自己先思考一下,然后再继续浏览,看看我们想法是否一致。
要解释这个问题需要具备两个知识点:
- useState的执行原理
- 闭包的原理
首先大致说一下 useState 原理:在 React 内部,初始化的时候会将传入的 num 挂载到一个全局的 hook 对象上,调用 setNum 的时候会更新 hook 对象上挂载的 num 值,然后触发重新渲染,继而执行 updateState,在updateState的时候从全局的 hook 对象上拿到最新的 num 值,然后返回最新的 num 和 setNum,此时组件中的num 就从0变成了100。
为了方便理解,我写了一个极简的 useState 如下,当然和源码相比还非常简陋,但是基本原理是一样的。
const hook = {};
function useState(initialState) {
if (!hook.state) {
hook.state = initialState;
}
function setState(newState) {
hook.state = newState;
render(); // 重新渲染
}
return [hook.state, setState];
}
从上面的 useState 中可以看出,num之所以变化,是由于 setState 会触发重新渲染组件,进而将 最新值渲染到页面,至此 useState 的原理已经解释完毕。
接下来就是闭包的原理,对于闭包的解释网上有很多文章这里就不再详细介绍,需要注意的是针对闭包的定义大致有两种,以上面的 App 组件为例,红皮书认为 success 是闭包,Chrome 开发团队认为外层的 App 是闭包,因为日常开发都是以 Chrome为主,所以我采纳 Chrome 开发团队的观点。
接下来就是问题解释了,因为 success 函数引入了外层 App 中的 num 变量,所以 App 形成了闭包,App 执行完毕后不会被垃圾回收,仍会以闭包的形成存在于内存中,闭包中的 num 就等是0,所以当点击按钮执行 success 方法,在 success 内部无论是以同步还是异步获取的 num 始终是0,这也就解释了上面的问题。
深入思考下,如果我们关闭弹窗重新点击,那么弹窗中显示的是多少呢?答案是100,原理和第一次点击一样,不同的是第一次点击时候会执行 setNum(100) 触发重新渲染,生成一个新的闭包,新闭包中的num变成了100罢了。
既然知道问题的根源,那么如何规避这个问题呢?
最简单的方法就是将值挂载到一个对象上面,如下
import React from "react"
import { Modal, Button } from 'antd'
const obj = {
num: null
}
function App() {
obj.num = 0
const success = () => {
obj.num = 100
Modal.success({
content: <span>num: {obj.num}</span>,
onOk: () => { }
})
}
return <Button onClick={success}>Success</Button>
}
export default App
因为之前 num 是一个基本类型,所以每次闭包中的值都会重新拷贝,虽然引用类型也会被拷贝,但是只会拷贝 指针,堆内存中的值却始终只有一份,所以无论通过哪个指针都可以获取最新的值。
其实 React 官方也给我们提供现成的 hook,useRef 也可以解决这个问题,下面是 useRef 的源码,其实和我们的处理方法原理差不多。
function mountRef<T>(initialValue: T): {|current: T|} {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}
function updateRef<T>(initialValue: T): {|current: T|} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
至此,这个问题已经基本回答完毕。
但是既然讲的了 useState,就顺手分析一波源码,加深下理解。
在 React 内部 useState 其实是对应了两个函数,分别是 mountState 和 updateState,顾名思义,就是初始化和更新时调用的,App 组件第一次 render 的时候会执行 useState(0) 对应的就是执行 mountState,源码如下
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
// $FlowFixMe: Flow doesn't like mixed types
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
pending: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchAction.bind(
null,
currentlyRenderingFiber,
queue,
): any));
return [hook.memoizedState, dispatch];
}
主要是通过 mountWorkInProgressHook 生成了一个 hook,然后将初始值挂载到 hook 上面,之后创建了一个队列 queue,并 queue 挂载到 hook 上,最后返回初始值和 dispatch。dispatch 其实就是 dispatchAction,所以我们看下 dispatchAction 的源码
function dispatchAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
const eventTime = requestEventTime();
const lane = requestUpdateLane(fiber);
const update: Update<S, A> = {
lane,
action,
eagerReducer: null,
eagerState: null,
next: (null: any),
};
// Append the update to the end of the list.
const pending = queue.pending;
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
const alternate = fiber.alternate;
if (
fiber === currentlyRenderingFiber ||
(alternate !== null && alternate === currentlyRenderingFiber)
) {
// This is a render phase update. Stash it in a lazily-created map of
// queue -> linked list of updates. After this render pass, we'll restart
// and apply the stashed updates on top of the work-in-progress hook.
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
} else {
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// The queue is currently empty, which means we can eagerly compute the
// next state before entering the render phase. If the new state is the
// same as the current state, we may be able to bail out entirely.
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher;
if (__DEV__) {
prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
// Stash the eagerly computed state, and the reducer used to compute
// it, on the update object. If the reducer hasn't changed by the
// time we enter the render phase, then the eager state can be used
// without calling the reducer again.
update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
// It's still possible that we'll need to rebase this update later,
// if the component re-renders for a different reason and by that
// time the reducer has changed.
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
if (__DEV__) {
ReactCurrentDispatcher.current = prevDispatcher;
}
}
}
}
scheduleUpdateOnFiber(fiber, lane, eventTime);
}
}
这个函数主要是生成一个 update 对象,然后挂载到传入的 queue 队列上面,然后将传入的值挂载到 update 的 eagerState 上,这样一来,更新的时候 hook 就可以通过调用 queue 上的 update 上的 eagerState 来获取每次传入的值了,最后调用 scheduleUpdateOnFiber 重渲染,这个方法非常复杂,就不展开了,这里只需要知道这个方法会触发重新渲染,进而执行 updateState 就行了,下面是updateState的源码:
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
updateState 其实调用的是 updateReducer,传入了一个内置的 reducer 也就是这里的basicStateReducer。
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
// $FlowFixMe: Flow doesn't like mixed types
return typeof action === 'function' ? action(state) : action;
}
这个方法很简单,没啥看的,我们继续看 updateReducer 的源码:
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const hook = updateWorkInProgressHook();
const queue = hook.queue;
queue.lastRenderedReducer = reducer;
const current: Hook = (currentHook: any);
// The last rebase update that is NOT part of the base state.
let baseQueue = current.baseQueue;
// The last pending update that hasn't been processed yet.
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// We have new updates that haven't been processed yet.
// We'll add them to the base queue.
if (baseQueue !== null) {
// Merge the pending queue and the base queue.
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
if (baseQueue !== null) {
// We have a queue to process.
const first = baseQueue.next;
let newState = current.baseState;
let newBaseState = null;
let newBaseQueueFirst = null;
let newBaseQueueLast = null;
let update = first;
do {
const updateLane = update.lane;
if (!isSubsetOfLanes(renderLanes, updateLane)) {
// 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,
action: update.action,
eagerReducer: update.eagerReducer,
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.
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,
action: update.action,
eagerReducer: update.eagerReducer,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// Process this update.
if (update.eagerReducer === reducer) {
// If this update was processed eagerly, and its reducer matches the
// current reducer, we can use the eagerly computed state.
newState = ((update.eagerState: any): S);
} else {
const action = update.action;
newState = reducer(newState, action);
}
}
update = update.next;
} while (update !== null && update !== first);
if (newBaseQueueLast === null) {
newBaseState = newState;
} else {
newBaseQueueLast.next = (newBaseQueueFirst: any);
}
// Mark that the fiber performed work, but only if the new state is
// different from the current state.
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
这个方法首先通过 updateWorkInProgressHook 获取 hook,之后获取 hook 上的队列 queue,这个队列是一个单链结构,并通过 do while 来循环这个单链,获取初始化时挂载到 update上 的 eagerState 属性,传入到上面内置的 basicStateReducer 中,生成新的 newState,然后将 newState 重新挂载到hook上,之后返回新的值dispatch,这样 App 组件中的 num 是通过 setNum 设置的值了,至此源码分析结束。