li-jia-nan / my-blog

个人技术博客,同步掘金,文章写在 Issues 里
43 stars 1 forks source link

80行代码实现一个简易版useState #7

Open li-jia-nan opened 1 year ago

li-jia-nan commented 1 year ago

废话不多说,直接上代码:

const App = () => {
    const [num, setNum] = useState(0);
    return <div onClick={() => setNum(num + 1)}>{num}</div>;
};

上面的App组件就是我们需要实现的功能,但是我们今天的重点在useState函数,不在render函数,所以为了简化起见,我们把jsx部分删掉,返回一个方法就好了:

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(num + 1);
        }
    };
};

然后就可以直接这样调用,模拟react的点击事件:

App().onClick();

在开始实现之前,我们先分析一下,我们需要实现哪些部分,可以看到,useState包含两部分:

需要知道的是,在react中,setNum方法可以接收两种参数,一种是具体的值(也就要改变后的值),还有一种是接收一个函数,比如下面这样:

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(n => n + 1);
        }
    };
};

首先我们知道,每一个组件(不管是函数组件,还是类组件,或者原生dom的组件)在react中都有一个对应的fiber节点,所以第一步,这里我们先定义一个fiber对象,并且给它一个stateNode字段,这个字段保存的就是对应的组件本身,这个fiber对象跟下面的App组件是一一对应的(并且你需要知道,在react中,有非常多的fiber,每一个fiber都有一个与之对应的组件)。

const fiber = {
    stateNode: App,
};

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(n => n + 1);
        }
    };
};

定义好fiber对象之后,我们还需要让这个mini版的react能运行起来,所以我们还需要一个用来调度的方法,可以将它命名为schedule:

const fiber = {
    stateNode: App,
};

const schedule = () => {
    //…… 
};

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(n => n + 1);
        }
    };
};

众所周知,我们每次更新,都会触发一次调度,组件就会触发一次render,所以我们调度的方法本质就是执行了一遍App函数,所以schedule函数的内部是这样:

const fiber = {
    stateNode: App,
};

const schedule = () => {
    fiber.stateNode();
};

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(n => n + 1);
        }
    };
};

这样一来,每次schedule方法执行,就相当于触发了App组件的更新,接下来还需要一个变量,因为我们的组件在mount时和update时两种情况是不一样的,比如在react的类组件中,组件首次渲染时,会调用componentDidMount钩子函数,组件更新时,会调用componentDidUpdate钩子函数,所以我们需要区分组件的渲染是mount、还是update,那么在这里需要一个全局变量isMount用来作为标识,区分两种情况:

// 申明一个全局变量 isMount,用来区分 mount 和 update
let isMount = true;

const fiber = {
    stateNode: App,
};

const schedule = () => {
    fiber.stateNode();
    isMount = false;
    // 首次渲染之后,isMount 变成 false
};

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(n => n + 1);
        }
    };
};

显然,isMount的默认值应该是true,因为组件第一次渲染必然是mount的情况,当我们调用完schedule之后,需要把isMount改变为false

接下来的问题在于,我们开始定义的useState需要保存一个对应的值,这个num需要保存在哪里呢?

前面说过,每个函数组件都有一个对应的fiber,显然,我们的useState的数据也是保存在这个fiber中的,所以这里需要一个字段用来保存对应的数据,我们给它命名为memoizedState,初始值为null。

// 申明一个全局变量 isMount,用来区分 mount 和 update
let isMount = true;

const fiber = {
    stateNode: App,
    memoizedState: null, // 用来保存组件内部的状态
};

const schedule = () => {
    fiber.stateNode();
    isMount = false;
    // 首次渲染之后,isMount 变成 false
};

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(n => n + 1);
        }
    };
};

这时候新的问题出现了,就像下面这样,一个组件可以有多个hooks:

// ……
const [num1, setNum1] = useState(0);
const [num2, setNum2] = useState(0);
const [num3, setNum3] = useState(0);
// ……

为了解决这两个问题,我们可以通过链表的结构来保存hook的数据,也就是说,fiber中的memoizedState保存的是一个链表,这个链表保存的就是当前组件的每一个useState的数据(也就是num1、num2、num3)。

既然memoizedState是一个链表,那么我们就需要一个变量(指针),用来指向当前正在处理的hook,我们将这个变量命名为workInProgressHook,初始值为null,然后在每次调用schedule方法的时候,我们需要让指针指向当前的hook保存的值,同时为了调用方便,我们将fiber.stateNode的结果返回:

let isMount = true; // 申明一个全局变量,用来区分 mount 和 update
let workInProgressHook = null; // 申明一个全局变量,作为链表的指针

const fiber = {
    stateNode: App, // stateNode 用来保存当前组件
    memoizedState: null, // 用来保存当前组件内部的状态
};

const schedule = () => {
    workInProgressHook = fiber.memoizedState; // 让指针指向当前的useState保存的值
    const app = fiber.stateNode(); // 执行组件的渲染函数,将结果保存在app里
    isMount = false; // 首次渲染之后,isMount 变成 false
    return app; // 将fiber.stateNode的结果返回
};

const App = () => {
    const [num, setNum] = useState(0);
    return {
        onClick() {
            setNum(n => n + 1);
        }
    };
};

接下来我们要实现useState方法:

useState接收一个参数,作为初始化状态:

const useState = initialState => {
    // ……
};

通过useState,我们要计算出当前的状态,和一个改变状态的方法并返回,那么我们首先要知道,我们的useState方法对应的是哪个hook(毕竟大家调用的都是用一个方法),所以我们首先要获取到当前的useState对应的hook,先初始化一个hook变量,然后我们需要区分组件是不是首次渲染,原因是:首次渲染的时候memoizedState保存的值是null,但是在update的时候,memoizedState的值不一定是null

const useState = initialState => {
    let hook;
    if (isMount) {
        // ……
    } else {
        // ……
    }
};

首次渲染时,我们需要创建一个hook对象,这个对象保存着新的memoizedState,这个新的memoizedState对应的是hook保存的当前状态,也就是函数的参数initialState,也就是num的值(我的天,我自己都快被绕晕了)

其次我们前面说过,hook是一条链表,所以还需要一个指针next,指向下一个hook,初始值为null,代码如下:

const useState = initialState => {
    let hook;
    if (isMount) {
        hook = {
            memoizedState: initialState,
            next: null,
        };
    } else {
        // ……
    }
};

另外,在首次渲染中我们需要判断:

然后我们需要将指向当前的指针workInProgressHook赋值为当前创建的hook,这样一来,就把我们刚创建的hook和之前创建的hook连接起来了,形成了一条链表:

const useState = initialState => {
    let hook;
    if (isMount) {
        hook = {
            memoizedState: initialState,
            next: null,
        };
        if (!fiber.memoizedState) {
            fiber.memoizedState = hook;
        } else {
            workInProgressHook.next = hook;
        }
        workInProgressHook = hook;
    } else {
        // ……
    }
};

上面分析完了mount的情况,然后我们分析update的情况:

const useState = initialState => {
    let hook;
    if (isMount) {
        hook = {
            memoizedState: initialState,
            next: null,
        };
        if (!fiber.memoizedState) {
            fiber.memoizedState = hook;
        } else {
            workInProgressHook.next = hook;
        }
        workInProgressHook = hook;
    } else {
        hook = workInProgressHook;
        workInProgressHook = workInProgressHook.next;
    }
};

经过了上面的逻辑,我们已经取到了useState保存的对应数据,下一步要做的是,基于对应的数据计算新的状态(也就是实现setNum函数),我们可以给setNum函数命名为dispatchAction,接收一个参数:

const dispatchAction = action => {
    // ……
};

那么问题来了,我们怎么知道这个dispatchAction对应的是哪个useState方法呢?(因为大家都调用的是同一个dispatchAction)

为了将dispatchActionuseState一一对应起来,我们需要将useState的hook对应的数据传给dispatchAction

回到之前的useState函数,我们看到之前创建的hook只有memoizedState和next两个属性,memoizedState保存当前的状态,next是和下一个hook连接的指针,所以,我们还需要一个新的属性,用来保存改变后的状态,这个新属性我们命名为queue:

const useState = initialState => {
    let hook;
    if (isMount) {
        hook = {
            memoizedState: initialState,
            next: null,
            queue: {
                pending: null,
            }
        };
        if (!fiber.memoizedState) {
            fiber.memoizedState = hook;
        } else {
            workInProgressHook.next = hook;
        }
        workInProgressHook = hook;
    } else {
        hook = workInProgressHook;
        workInProgressHook = workInProgressHook.next;
    }
};

将新属性命名queue是因为这是一个队列,我们通过点击事件,触发了数据更新,多次调用会触发多次更新,同样,多次调用也会创建多个action,所以我们需要把它们连接起来,所以这里的pending变量用来保存当前的hook对应的数据将要发生的改变,再回到dispatchAction函数中,在dispatchAction中我们就需要接收这个值,形参我们可以命名为queue(对应useState函数中的queue):

const dispatchAction = (queue, action) => {
    // ……
};

接下来我们需要在dispatchAction函数里面创建一个值,代表一次更新,我们命名为update,需要注意的是,因为更新是可以多次触发的,所以这个update也是一个链表,这个update中保存着action和next:

const dispatchAction = (queue, action) => {
    const update = {
        action,
        next: null,
    };
};

下一步要做是事情,就是单纯的链表操作:

判断useState函数中的queue.pending是否存在:

操作完链表之后,下一步我们要做的就是在函数的最后调用schedule方法,也就是说,让dispatchAction函数触发一次更新

const dispatchAction = (queue, action) => {
    const update = {
        action,
        next: null,
    };
    if (queue.pending === null) {
        update.next = update;
    } else {
        update.next = queue.pending.next;
        queue.pending.next = update;
    }
    queue.pending = update;
    schedule();
};

接下来,我们再次回到之前的useState函数,现在useState中的hook的queue.pending上可能存在一条链表,我们需要通过这条链表计算新的state。

这里特别说明一下:下面的部分我自己也看不懂了,快被绕晕了,但是为了坚持写完这篇文章,还是硬着头皮写完了,参考卡颂老师在B站发布的视频《React Hooks的理念、实现、源码》,感兴趣的伙伴可以去看下

要计算新的state,首先需要拿到旧的state,也就是hook.memoizedState,我们声明一个新变量用来保存旧的state,命名为baseState,然后需要判断hook.queue.pending是否存在,如果存在,代表本次更新有新的update需要被执行,我们要拿到第一个update,然后遍历链表,取出对应的action,然后基于action计算出新的state,这里需要注意的是,因为我们的action是一个函数,所以需要将baseState传给action,返回新的值,赋给baseState,然后更新firstUpdate,将firstUpdate指向下一个update,循环结束之后,将hook.queue.pending赋值为null,清空链表

const useState = initialState => {
    let hook;
    if (isMount) {
        hook = {
            memoizedState: initialState,
            next: null,
            queue: {
                pending: null,
            }
        };
        if (!fiber.memoizedState) {
            fiber.memoizedState = hook;
        } else {
            workInProgressHook.next = hook;
        }
        workInProgressHook = hook;
    } else {
        hook = workInProgressHook;
        workInProgressHook = workInProgressHook.next;
    }
    let baseState = hook.memoizedState;
    if (hook.queue.pending) {
        let firstUpdate = hook.queue.pending.next;
        do {
            const action = firstUpdate.action;
            baseState = action(baseState);
            firstUpdate = firstUpdate.next;
        } while (firstUpdate !== hook.queue.pending.next);
        hook.queue.pending = null; // 循环结束,清空链表
    }
    hook.memoizedState = baseState;
    return [baseState, dispatchAction.bind(null, hook.queue)];
};

最后把hook.memoizedState赋值为新的baseState,然后返回一个数组,数组的第0项是新的baseState,第1项是dispatchAction,因为dispatchAction函数需要传入对应的queue,所以我们需要用bind将它的this指向null,bind的第二个参数就是hook.queue

最后一步,我们在App组件中打印出num,并且在F12的控制台里面输入schedule().onClick();就可以模拟点击事件了:

// 打开F12调用onClick方法,模拟点击事件
schedule().onClick();

完整代码如下:

let isMount = true; // 申明一个全局变量,用来区分 mount 和 update
let workInProgressHook = null; // 申明一个全局变量,作为链表的指针

const fiber = {
    stateNode: App, // stateNode 用来保存当前组件
    memoizedState: null, // 用来保存当前组件内部的状态
};

function useState(initialState) {
    let hook;
    if (isMount) {
        hook = {
            memoizedState: initialState,
            next: null,
            queue: {
                pending: null,
            }
        };
        if (!fiber.memoizedState) {
            fiber.memoizedState = hook;
        } else {
            workInProgressHook.next = hook;
        }
        workInProgressHook = hook;
    } else {
        hook = workInProgressHook;
        workInProgressHook = workInProgressHook.next;
    }
    let baseState = hook.memoizedState;
    if (hook.queue.pending) {
        let firstUpdate = hook.queue.pending.next;
        do {
            const action = firstUpdate.action;
            baseState = action(baseState);
            firstUpdate = firstUpdate.next;
        } while (firstUpdate !== hook.queue.pending.next);
        hook.queue.pending = null; // 循环结束,清空链表
    }
    hook.memoizedState = baseState;
    return [baseState, dispatchAction.bind(null, hook.queue)];
};

function dispatchAction(queue, action) {
    const update = {
        action,
        next: null,
    };
    if (queue.pending === null) {
        update.next = update;
    } else {
        update.next = queue.pending.next;
        queue.pending.next = update;
    }
    queue.pending = update;
    schedule();
};

function schedule() {
    workInProgressHook = fiber.memoizedState; // 让指针指向当前的useState保存的值
    const app = fiber.stateNode(); // 执行组件的渲染函数,将结果保存在app里
    isMount = false; // 首次渲染之后,isMount 变成 false
    return app; // 将fiber.stateNode的结果返回
};

function App() {
    const [num, setNum] = useState(0);
    console.log(num);
    return {
        onClick() {
            setNum(n => n + 1);
        },
    };
};

截止目前,我们用不到80行代码实现了useState的功能。