抛开class的历史包袱, hook的有点不谈。我们直接切入正题:
用Function组件代替class 组件。
我们不妨畅想一下最终的支持状态的函数组件代码:
function Counter(){
let state = {count:0}
function clickHandler(){
setState({count: state.count+1})
}
return (
<div>
<span>{count}</span>
<button onClick={clickHandler}>increment</button>
</div>
)
}
上述代码使用函数组件定义了一个计数器组件Counter,其中提供了状态state,以及改变状态的setState函数。这些 API 对于Class component来说无疑是非常熟悉的,但在Function component中却面临着不同的挑战:
class 实例可以永久存储实例的状态,而函数不能,上述代码中 Counter 每次执行,state 都会被重新赋值为 0; 每一个Class component的实例都拥有一个成员函数this.setState用以改变自身的状态,而Function component只是一个函数,并不能拥有this.setState这种用法,只能通过全局的 setState 方法,或者其他方法来实现对应。
以上两个问题便是选择改造Function component所需要解决的问题。
状态储存
纯js 函数实现状态存储,第一反应肯定是闭包了:
一个典型的闭包如下:
function closure() {
let a = 1;
return () => {
getValue() {
return a;
}
}
}
const returnFunc = closure();
console.log(returnFunc.getValue());
对应的,我们改造我们的函数:
function Counter(){
const [state, setState] = useState(1);
// 支持传入初始值,返回一个可以维护这个值的函数
function useState(initialValue) {
let v = initialValue;
const update = (callback) => {
callback(v);
}
// 之所以用数组,是因为返回对象的话,需要指定的key进行解构,数组的话则不用
return [v, update];
}
return (
<div>
<span>{count}</span>
<button onClick={clickHandler}>increment</button>
</div>
)
}
我们虽然通过闭包使更新函数可以返回初始状态,但是依然存在问题:
function没有生命周期,每次更新必然整个函数重新执行,导致state
每次都被重新赋值为默认值。
为了解决这个问题,我们自然想到,那就在组件加载的时候以initailValue进行赋值,每次更新则不重新赋值。
这又会导入新的问题,我们要区分加载与更新两个状态。那么就加个状态区分吧:
let isMount = true;
function Counter() {
//....
function useState(initialValue) {
let v; // 之前的值不存在了?
if(isMount) {
v = initailValue
}
const update = (callback) => {
callback(v);
}
// 之所以用数组,是因为返回对象的话,需要指定的key进行解构,数组的话则不用
return [v, update];
}
// ...
}
新的问题又来了: isMount
必须和function
同时存在,同生同死。 useState
中要能访问到上次更新的值。也就是关键目标中的状态存储。
好在我们是jsx。写的function会做进一步的编译。那我们干脆再加一个对象:
const FiberNode = {
isMount: true,
stateNode: Counter,
memoizedState: null,
}
function Counter(){
function useState(initialState) {
let v;
if (FiberNode.isMount) {
v = initialState;
FiberNode.isMount = false;
} else {
v = FiberNode.memoizedState
}
FiberNode.memoizedState = v;
const update = (callback) => {
callback(v);
}
// 之所以用数组,是因为返回对象的话,需要指定的key进行解构,数组的话则不用
return [v, update];
}
}
貌似很完美,但是如果我们同时维护了多个状态呢?memoizedState
则不能是一个值。我第一反应是数组。
确实数组也可以实现响应的功能,但React实际上用的是链表。我专门去查了一下:
因为数组在内存中是连续存储的,要想在某个节点之前增加,且保持增加后数组的线性与完整性,必须要把此节点往后的元素依次后移。要是插在第一个节点之前,那就GG了,数组中所有元素位置都得往后移一格,最后把这个后来的“活宝元素”,稳稳的放在第一个腾出来的空闲位置上。而链表却为其他元素着想多了。由上图可知,链表中只需要改变节点中的“指针”,就可以实现增加。
全文参见: 数组与链表的区别。
为了实现链表结构,那么对每个hook而言必然会有一个指针指向下一个状态。而且这个hook也要维护好自己的状态。
同时,创建新的hook时,为了挂载到链表上,需要拿到之前的链表,也就是上一个hook;
let hook = {
memoizedState: null, // hook的值
next: null, // 指针指向下一个hook
}
const FiberNode = {
isMount: true,
stateNode: Counter,
memoizedState: null,
workInProgressHook: null // 指向最后一个hook,也就是当前访问的hook
}
function Counter(){
function useState(initialState) {
let hook;
if (FiberNode.isMount) { // 挂载时
hook ={
memoizedState :initialState,
next: null
};
if(!FiberNode.memoizedState) {
// 第一个hook
FiberNode.memoizedState = hook;
} else {
// 将之前一个hook的next指向新的hook
FiberNode.workInProgressHook.next = hook;
}
// 将新的hook保存为当前访问的hook,以便有新的hook时可以指向
FiberNode.workInProgressHook = hook;
FiberNode.isMount = false;
} else {
// 拿到当前正在访问的hook;之所以赋值hook,是为了挂载和更新保持统一逻辑
hook = FiberNode.workInProgressHook;
// 切换到下一个,以备下次访问
FiberNode.workInProgressHook = FiberNode.workInProgressHook.next
}
const baseState = hook.memoizedState; // 当前hook更新前的值
const update = (callback) => {
//callback(v);
const newValue = callback(baseState);
hook.memoizedState = newValue;
}
// 之所以用数组,是因为返回对象的话,需要指定的key进行解构,数组的话则不用
return [v, update];
}
}
状态更新
在React中,有如下方法可以触发状态更新(排除SSR相关):
-
ReactDOM.render
-
this.setState
-
this.forceUpdate
-
useState
-
useReducer
这些方法调用的场景各不相同,为了接入同一套状态更新机制,最好是把更新流程记录在fiber上,在render阶段再统一更新。因此就有必要把更新逻辑提取出来。
/**
* action: 回调
* queue: 对应的hook更新队列
*/
function dispatchAction(action, queue) {
const update = {
action,
next: null
}
// react 17会启用同步更新模式,更新会有优先级。环结构方便指针移动
if (queue.next === null) {
// 当前hook没有要触发的更新
update.next = update;
} else {
// u1 => u0 => u0 =u0
update.next = queue.pending.next;
// u1 => u0 => u1
queue.pending.next = update;
}
queue.pending = update;
}
所以改变update方法只是先在fiber上打上一个更新标签,并记录需要进行哪些更新,然后重新执行整个function component。
useState也就是正常函数,每次执行function component 都会执行useState这个函数。只是这个useState只是在挂载时才会根据initialState进行计算。
非挂载状态下是根据fiber上记录的需要更新的操作,进行计算新的state状态。
对原代码再做进一步修改:
const FiberNode = {
isMount: true,
stateNode: Counter,
memoizedState: null,
workInProgressHook: null
}
function Counter(){
function useState(initialState) {
let hook;
if (FiberNode.isMount) {
hook ={
memoizedState :initialState,
next: null,
queue: { // action的更新队列,环结构
pending: null
}
};
if(!FiberNode.memoizedState) {
// 第一个hook
FiberNode.memoizedState = hook;
} else {
FiberNode.workInProgressHook.next = hook;
}
FiberNode.workInProgressHook = hook;
FiberNode.isMount = false;
} else {
hook = FiberNode.workInProgressHook;
FiberNode.workInProgressHook = FiberNode.workInProgressHook.next
}
const baseState = hook.memoizedState;
// 根据action队列计算状态
if (hook.queue.pending) {
// 如果存在就表示有新的update需要执行
let firstUpdate = hook.queue.pending.next;
do {
// 考虑多次调用的情况
const action = firstUpdate.action; // num => num + 1;
baseState = action(baseState);
firstUpdate = firstUpdate.next;
} while (firstUpdate !== hook.queue.pending.next)
hook.queue.pending = null;
}
hook.memoizedState = baseState;
return [v, dispatchAction.bind(null, hook.queue)];
}
}
function dispatchAction(action, queue) {
const update = {
action,
next: null
}
// react 17会启用同步更新模式,更新会有优先级。环结构方便指针移动
if (queue.next === null) {
// 当前hook没有要触发的更新
update.next = update;
} else {
// u1 => u0 => u0 =u0
update.next = queue.pending.next;
// u1 => u0 => u1
queue.pending.next = update;
}
queue.pending = update;
}
真实react hook逻辑复杂很多,本文是基于卡颂大大的视频 React Hooks的理念、实现、源码 中提取的精简源码进行正向推演。卡颂大大的境界比较高,很多他觉得简单的对于自己理解起来就有点难度,因此写下文章记录自己的思考。如有问题,欢迎指正。