如果自己实现React Hook - useState

136 阅读6分钟

抛开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的理念、实现、源码 中提取的精简源码进行正向推演。卡颂大大的境界比较高,很多他觉得简单的对于自己理解起来就有点难度,因此写下文章记录自己的思考。如有问题,欢迎指正。