前言
React版本:16.8以上
如果你是一个react忠实用户,看到这个标题肯定会嗤之以鼻,useState还能不懂?这每天都要写好几遍的东西,早已如呼吸般自然~
别急,接下来将设置几道关卡,看一看你的useState是否如你想那样。
第一关:
const [arr, setArr] = useState([]);
const test = () => {
arr.push({});
};
console.log(arr);
return <div onClick={test}>App</div>;
触发test后的打印是什么?⬆️
const [arr, setArr] = useState([]);
const test = () => {
arr.push({});
setArr(arr);
};
console.log(arr);
return <div onClick={test}>App</div>;
触发test后的打印是什么?⬆️
const [arr, setArr] = useState([]);
const test = () => {
setArr([]);
};
console.log(arr);
return <div onClick={test}>App</div>;
触发test后的打印是什么?⬆️
答案揭晓:
- 未打印(没有触发render)
- 未打印(arr内存地址没有改变,所以没有监听到变化,setArr没有触发render)
- 打印[]( [] !== [],触发render)
如果答对恭喜你已经通过了第一关,让我们继续~
第二关:
const [count, setCount] = useState(0);
const test = () => {
setTimeout(() => {
setCount(count + 1);
}, 3000);
console.log(count);
};
return <div onClick={test}>App</div>;
在3s内快速点击触发test,然后在3s结束后再次点击,将会打印什么?⬆️
const [count, setCount] = useState(0);
const test = () => {
setTimeout(() => {
setCount(count => count + 1);
}, 3000);
console.log(count);
};
return <div onClick={test}>App</div>;
在3s内快速点击触发test,然后在3s结束后再次点击,将会打印什么?⬆️
答案揭晓:
- 0->0->0->...->1(闭包旧值,每次setCount的count为0+1)
- 0->1->2->...->8(新值传入,所以点击8次后为=>0 + 1 + 1... + 1 = 8)
什么,这还难不倒你?看来有点东西,现在让我们进入最后一关!
第三关:
const [count, setCount] = useState(0);
useEffect(() => {
setCount(1);
setCount(2);
setCount(3);
}, []);
console.log(count);
打印什么呢?⬆️
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
setCount(1);
setCount(2);
setCount(3);
});
}, []);
console.log(count);
打印什么呢?⬆️
答案揭晓:
- 0->3
- 0->1->2->3(react18版本后为0->3)
以上关卡答案有没有出乎大家的意料呢?如果不了解的话也别急,接下来让我们一起扒开useState的外衣,看看他里面究竟藏着什么玄机~
useState
本章节让我们从基础用法开始,一步步剖析理解useState内部的构造~
useState基础用法
const [state, dispatch] = useState(initData)
- state:定义的数据源,可视作一个函数组件内部的变量,但只在首次渲染被创造。
- dispatch:改变state的函数,推动函数渲染的渲染函数。dispatch有两种情况-非函数和函数。
- initData:state的初始值,initData有两种情况-非函数和函数。
dispatch的两种情况
- 非函数:
const [count, setCount] = useState(0);
// 一个点击事件方法
const test = () => {
setCount(2) // => count = 2
setCount(count + 1) // => count = 0 + 1 = 1
setCount(count + 1) // => count = 0 + 1 = 1
};
- 函数:
const [count, setCount] = useState(0);
// 一个点击事件方法
const test = () => {
setCount((newCount) => newCount + 1) // => count = 0 + 1 = 1
setCount((newCount) => newCount + 1) // => count = 1 + 1 = 2
setCount((newCount) => newCount + 1) // => count = 2 + 1 = 3
};
大家可以看出,非函数的情况下,setCount将参数作为新的值赋予state,下一次渲染时使用。函数的情况下,传入函数的入参(本例中的newCount)-是上一次返回的最新state,而函数的返回值,作为新的值赋予state,下一次渲染使用。
initData的两种情况
- 非函数:
const [count, setCount] = useState(0);
- 函数:
const { id } = props;
const [count, setCount] = useState(() => {
if(id === 1) return 10;
if(id === 2) return 15;
});
等同于
const { id } = props;
let initCount;
if (id === 1) {
initCount = 10;
} else if(id === 2) {
initCount = 15;
}
const [count, setCount] = useState(initCount);
大家可以看出,useState初始化的值可以写死,也可以由外部变量进行控制。
源码分析
我们都知道,react在不同阶段引用的hooks不是同一个函数,useState也不例外。首先我们先看一下react中对于useState在不同阶段的处理函数。
// ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
current: Fiber | null,
workInProgress: Fiber,
Component: (p: Props, arg: SecondArg) => any,
props: Props,
secondArg: SecondArg,
nextRenderLanes: Lanes,
): any {
//...省略无关代码
// 我们通过判断当前fiber中的memoizedState是否为空来判断当前的阶段
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
//...省略无关代码
}
我们从一个简单的例子来分析下useState的原理⬇️
function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const addCount = () => {
setCount1(count1 + 1);
}
return (
<div onClick={addCount}>
{count1} - {count2}
</div>
)
}
从组件初始化到点击按钮总共有两步:
- 初始化-首次渲染,调用mountState方法,将count1与count2初始化为0,并在页面渲染出来。
- 组件更新-点击按钮后,进入更新阶段,调用updateState方法,整个App组件函数重新执行一次,并且把count1更新为1,count2不变,在页面上渲染出来。
组件初始化阶段(首次render)
React中存在着一个叫Fiber的对象来保存着它各种节点的信息,也就是React中的虚拟DOM,这里不了解也不影响接下来的阅读,感兴趣的同学可以网上进行查阅更详细的信息。
在了解state初始化之前,我们首先得对state的结构以及state如何被记录的有一些简单的认知。
首先我们看一段hook节点初始化创建的代码:
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// 这是初始化第一个hook节点时
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// 不是第一个节点直接放到节点后面
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
我们首先来了解下各个变量的含义:
- memoizedState:Fiber上有一个记录state的对象叫做memoizedState,有了memoizedState,我们就能在每次渲染时获取state里的数据。memoizedState是一个单项链表的结构。我们每一次useState就是在链表后面生成一个新hook节点用next作为指针连接起来,初始化的memoizedState则作为头节点。
- currentlyRenderingFiber:当前组件渲染对应的fiber对象。
- workInProgressHook:当前运行到的hook,如上图所示,组件内部可能会存在多个hook。
- hook:每次useState便会产生一个hook对象来存储相关的状态。
进入正题,我们看一下初始化阶段useState调用mountState的代码:
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
// 创建新的hook节点,就是上文提到的函数
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
// state初始值赋值
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
...
}
举一个栗子⬇️
function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(1);
...
}
- 首次渲染App组件时,还没有执行useState,currentlyRenderingFiber.memoizedState会记录当前组件的状态,此时为null,workInProgressHook也为空。
- 执行第一个useState命令,进入mountWorkInProgressHook函数,创建一个初始值为0的hook节点,并将currentlyRenderingFiber.memoizedState指向它。
- 初始值被记录在currentlyRenderingFiber上,此时memoizedState由null变为0。
- 执行第二个useState命令,生成一个初始值为1的hook节点,然后将上一个hook节点指向它,重复以上步骤,形成图一中的链表结构。
你可能会问了,hook链表结构被创建出来了,那我们怎么去更新他呢?下面我们来看一看mountState函数的下半段:
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
// 上文设置初始结构部分省略
...
// 初始化queue
const queue: UpdateQueue<S, A> = {
pending: null,
lanes: NoLanes,
dispatch: null, // 更新state的函数
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
hook.queue = queue;
// 初始化触发器
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchReducerAction.bind(
null,
currentlyRenderingFiber,
queue,
): any));
// 返回初始state和触发器
return [hook.memoizedState, dispatch];
}
// 真如刚刚看到的,state初始值可以为常量,也可以为一个函数
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
这里我们只关心dispatch函数(useState的第二个参数),可以看出,每个hook节点上dispatch都由dispatchReducerAction进行功能赋值。那dispatchReducerAction是个什么东西呢?
function dispatchReducerAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
// 更新state的方式
...
}
在这里,我们了解到每个节点对应的dispatch都传入了对应的fiber和queue,那我们不妨想一想,为什么要绑定这两个值呢?
其实很简单,这里只起到一个标识的作用,fiber对应某一个fiber树,queue对应fiber树上某一个hook节点。这样的话,我们才能更快更准确地进行更新任务。
在这里你可能会问,第三个参数action是干啥用的呢?没错,它就是更新的方法,下面让我们进入组件更新部分~
组件更新
第二步是组件更新,也就是调用了setCount方法。也就是上文提到的dispatchReducerAction函数的第三个参数。
刚刚介绍完组件的初始化后,细心的同学可能会提出疑问:那queue有什么用呢?有那么复杂的数据结构,并且更新方法函数也需要传入,难道就只起到一个标识符的作用吗?如果这样的哈,我用个boolean值不也能做到吗?
当然不是,queue在整个流程中起到了至关重要的作用,整个useState的驱动都是围绕着queue来实现的,接下来让我们一起看看它到底是个什么玩意!
首先,更新时的流程和初始化渲染时的流程差不多,只不过初始化渲染时候只初始化了每个hook节点上的queue,而更新则是往queue里面加任务。
下面让我们来看一下queue的结构⬇️
const queue: UpdateQueue<S, A> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: reducer,
lastRenderedState: (initialState: any),
};
这几个字段是什么意思呢?它们在更新中起到了什么作用呢?下面我们用一个例子来讲解⬇️
function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(1);
const addCount = () => {
setCount1(count1 + 1);
}
return (
<div onClick={addCount}>
{count1} - {count2}
</div>
)
}
当我首次点击,触发addCount函数时,我们会根据入参生成一个更新节点⬇️
const update: Update<S, A> = {
lane,
action, // setCount1的入参,也就是上文中的count1 + 1
hasEagerState: false,
eagerState: null,
next: (null: any),
};
更新节点生成后,我们将它与queue队列进行处理⬇️
const pending = queue.pending;
if (pending === null) {
// 这是首次更新,创建循环链表
// 只有一个update,自己指向自己,形成环形链表
update.next = update;
} else {
// 链表插入
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
从代码中可以看到queue.pending存储产生的更新,有下列特征:
- 如果只有一个节点的时候,就自己指向自己
- 如果有多个节点,就把queue.pending插进去
从以上特征,我们不难猜到queue是一个单项循环链表结构⬇️
看到这里大家应该已经明白了,我们一系列的更新信息都是存储在queue里的,在更新阶段中会进行调用处理。
了解完结构后我们来看看更新阶段调用的hook函数:
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
// 为方便阅读,省略部分无关代码
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
// 初始化hook
const hook = updateWorkInProgressHook();
const queue = hook.queue;
const current: Hook = (currentHook: any);
let baseQueue = current.baseQueue;
const first = baseQueue.next;
let newState = current.baseState;
let update = first;
if (baseQueue !== null) {
do {
newState = reducer(newState, action);
// update为一个环形链表,循环链表直到取最新值
update = update.next;
} while (update !== null && update !== first);
newBaseQueueLast.next = (newBaseQueueFirst: any);
// 判断新数据与老数据是否相同,如果相同则标记完成,不进行render
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
// 将state数据更新成新数据
hook.memoizedState = newState;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
可以看到内部将queue上存储的一系列更新任务进行了处理,最终将对应的hook.memoizedState都更新成了最新数据。
总结
总而言之,我们hooks的更新行为都挂到了hook.queue下面,所以整个流程大致分为以下三步,我们用一个例子来说明:
function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(1);
const addCount = () => {
setCount1(count1 + 1);
setCount1(3);
setCount2(2);
}
return (
<div onClick={addCount}>
{count1} - {count2}
</div>
)
}
- 创建hook,初始化queue - mountState
- 首次执行-const [count1, setCount1] = useState(0),创建一个count1-hook,将hook.memoizedState头节点变为count1,初始化queue维护count1的更新信息。
- 执行到-const [count2, setCount2] = useState(1),将hook.memoizedState指向count2-hook,初始化queue维护count2的更新信息。
- 维护queue - dispatchReducerAction(处理更新信息)
- 调用setCount1(count1 + 1),dispatch一个内容为count + 1(0 + 1)的action,生成update1.1节点(update为一个环形链表结构,忘记的同学回到上文看看哈)
- 执行setCount1(3),dispatch一个内容为3的action,生成update1.2节点,将update1.1节点指向update1.2节点,将update1.1存储在count1-hook.queue中。
- 调用setCount2(2),dispatch一个内容为2的action,生成update2.1节点,将update2.1存储在count2-hook.queue中。
- 更新queue - updateState(也就是updateReducer)
- 在updateReducer中进行queue任务的调用处理,分别更新到hook.memoizedState上,让我们获取到最新的state值
上文只是简单介绍了useState的内部执行原理,里面的奥秘远不止那么简单,其中的各种边界条件和调度器都需要我们去探索,如有兴趣,欢迎关注下期~