ReactHooks原理及简单实现

本文从Hooks的基本原理出发,逐步实现简易的useStateuseEffect,代码虽短,五脏俱全。

<!--more-->

本文代码均放在github上面。

1. Hook的背景

首先要理解hooks出现的场景,用useState为例

早期我们需要将state放在class Component属性上面,对于函数组件而言,只能通过props传入;那么为什么不能让函数组件拥有与类组件类似的state呢?

之所以可以把state挂载到类组件实例上,是因为在diff时,如果没有重新生成组件实例,那么只会重新执行render方法,因此可以保证实例上的state不会丢失。

函数组件的特殊性在于每次都会重新执行,找不到一个可以保存数据的地方(很明显不能直接保存在函数组件这个函数上面,函数类的局部变量在运行后也会被回收)。

等等!如果是需要找一个地方保存数据,我们不是可以使用闭包吗?

2. 版本1:使用闭包保存变量

说动就动,实现第一版的useState,使用闭包state函数(注意这里是一个函数而不是变量)来维持对于useState内部变量val的访问

function useState(initVal) {
    let val = initVal;  // 首次初始化赋值,为了简化逻辑,我们假设整个系统只有这一个state

    const state = () => {
        return val;
    };
    const setState = (newVal) => {
        val = newVal;
    };
    return [state, setState];
}

然后在函数组件中,可以通过state()来获取到val的值

function Counter() {
    const [count, setCount] = useState(1);

    return {
        click() {
            setCount(count() + 1);
        },
        render() {
            console.log(`count is ${count()}`);
        },
    };
}

const App = Counter();

App.render();  // 1
App.click();
App.render(); // 2

nice!成功了一大半。

不过很显然,这种类似于getter的调用方式不符合我们的使用习惯,但val是useState内的一个局部变量,如果直接返回val字面量,会导致引用丢失,无法获取到setState后更新的值。

那么,如何实现useState返回一个数据类型为基础类型的state、同时又能保证获数据正常更新呢?

换个思路,实际上我们需要的是每次render时获取到新的state值,那么我们每次运行render时都重新运行useState获取state不就行了吗?这样我们无需关心返回state到底是一个函数、还是一个普通的字面量了。这也更符合Hooks真实的场景:函数组件每次都重新执行并返回新的VNode。

3. 版本2:获取更新后的state字面量

接下来实现第二个版本,

  • 每次useState都会返回更新后的state,这一点好办,我们把state放在useState的外面,作为useState的自由变量即可
  • 需要实现一个render方法,保证内部每次重新执行函数组件
const SimpleReact = (() => {
    let state; // 使用IFFE维持这个state
    return {
        render(Component) {
            // 追踪Component的状态
            const instance = Component();
            instance.render(); // 假设这个方法是真实渲染html
            return instance;
        },
        useState(initVal) {
            state = state || initVal; // 首次初始化赋值
            const setState = (newVal) => {
                state = newVal;
            };
            return [state, setState]; // 每次返回的state都是最新的
        },
    };
})();

然后修改一下使用方法,现在我们需要把函数组件传入到render方法中进行渲染

const { render, useState } = SimpleReact;
function Counter() {
    const [count, setCount] = useState(1);

    return {
        click() {
            setCount(count + 1);
        },
        render() {
            console.log(`count is ${count}`);
        },
    };
}

let App;
App = render(Counter); // 1

App.click();

App = render(Counter); // 2

bingo!看起来十分完美,一个真正的Hook!除了我们只能使用这一个state,也就是IFFE中,useState唯一保存的那个自由变量。

4. 版本3:支持多个state

然后我们来实现第三个版本,也就是支持任意数量的state。思考一下,该如何实现多个state呢?一个变成多个,用数组嘛~

不妨回忆一下,React的Hooks,为啥只能写在顶层环境中,而不能写在循环、条件或嵌套函数中调用 Hook 中呢?Hook 规则 - Reac官方文档

从版本二可以看出来,每次render的时候都会重新运行整个函数组件,继而执行函数组件中的每一个hook,换句话说,我们实际上是知道每一个hook的运行顺序的(只要不在条件判断或者循环等地方使用),这样就可以用一个数组来保存每一个hook对应的state值

const SimpleReact = (() => {
    let hooks = []
    let currentHook = 0
    return {
        render(Component) {
            // 追踪Component的状态
            const instance = Component();
            instance.render();
            currentHook = 0 // render之后重置索引,下次render重新从0开始
            return instance;
        },  
        useState(initVal) {
            let cur = currentHook
            hooks[currentHookcurStash] = hooks[cur] || initVal
            const setState = (newVal) => {
                hooks[cur] = newVal
            };

            currentHook++
            return [hooks[cur], setState];
        },
    };
})();

这里使用了一个游标currentHook来记录当前运行的useState,这样就可以在函数组件运行的时候,获取保存在数组中的每个state的新值

const { render, useState } = SimpleReact;

function Counter() {
    const [count, setCount] = useState(1);
    const [count2, setCount2] = useState(100);

    return {
        click() {
            setCount(count + 1);
        },
        click2(){
            setCount2(count2 + 10);
        },
        render() {
            console.log(`count is ${count}`);
            console.log(`count2 is ${count2}`);
        },
    };
}

let App;
App = render(Counter); // count 1, count2 100

App.click();

App = render(Counter); // count 11, count2 100

App.click2();
App = render(Counter); // count 11, count2 110

在不考虑组件销毁等影响全局hooks数组的情况下,我们的实现简直“完美了”!

5. useEffect

上面花了3个章节来介绍useState的实现,接下来我们实现第二个Hook:useEffect,默认情况下,useEffect会在首次渲染和每次更新之后都会执行注册的回调函数。

为了便于理解,我们也先假设只有一个useEffect。

const [count, setCount] = useState(1);

useEffect(()=>{
    console.log(`effect count is ${count}`)
})

由于每次执行函数组件时都会重新执行所有hooks,因此这里传给useEffect的回调方法在每次执行时都是一个新的匿名函数(可以叫做effect),并可以作用域链访问到外部的state自由变量

而在上面我们已经实现了useState会返回更新后的state值,因此useEffect每次执行时都能拿到最新的state,而无需再关心如何更新回调中的变量值等逻辑。

因此,一个最简单的useEffect实现是

useEffect(callback) {
   callback();
}

看起来更新是整个函数组件渲染运行的一部分。

每次渲染都执行effect好像是比较奢侈的行为,我们可以在只有当state变化时才执行effect,从而实现性能优化

看起来我们只需要在useEffect中判断state是否发生了变化,然后再运行回调就行了。

const SimpleReact = (() => {
    let state;
    let lastState;
    return {
        render(Component) {
            const instance = Component();
            instance.render();
            return instance;
        },
        useState(initVal) {
            state = state || initVal;
            const setState = (newVal) => {
                state = newVal;
            };
            return [state, setState];
        },
        // 监听单个state的变化
        useEffect(callback, val) {
            const hasChanged = !val || val !== lastState;
            if (hasChanged) {
                callback();
                lastState = val
            }
        },
    };
})();

我们增加了一个lastState的自由变量来保存state上一次的值,然后在useEffect中判断是否变化(最简单的值相等比较)。

接下来测试一下

const { render, useState, useEffect } = SimpleReact;

function Counter() {
    const [count, setCount] = useState(1);

    useEffect(() => {
        console.log("effect run", count);
    }, count);

    return {
        click() {
            setCount(count + 1);
        },
        noop(){
            setCount(count) // 不会触发effect
        },
        render() {
            console.log(`count is ${count}`);
        },
    };
}

let App;
App = render(Counter);

App.click();

App = render(Counter);

App.noop();
App = render(Counter)

上面的代码会依次输出

effect run 1
count is 1
effect run 2
count is 2
count is 2

成功了!最后一步,我们照着前面数组的思路,来支持多个useEffect

const SimpleReact = (() => {
    let states = [];
    let effects = [];

    let currentState = 0;
    let currentEffect = 0;

    return {
        render(Component) {
            // 追踪Component的状态
            const instance = Component();
            instance.render();

            // 函数组件render之后重置索引
            currentState = 0;
            currentEffect = 0;
            return instance;
        },
        useState(initVal) {
            let cur = currentState;
            states[cur] = states[cur] || initVal;
            const setState = (newVal) => {
                states[cur] = newVal;
            };

            currentState++;
            return [states[cur], setState];
        },
        // 监听单个state的变化
        useEffect(callback, arr) {
            let cur = currentEffect;

            // 参数列表中只要有某一个值发生变化,就会执行callback
            const hasChanged =
                !effects[cur] ||
                !effects[cur].every((val, index) => arr[index] === val);

            if (hasChanged) {
                callback();
            }

            currentEffect++;
            effects[cur] = arr;
        },
    };
})();

可以看见跟实现多个useState的做法并没有什么差别,除了数组保存的元素含义不同

  • states中每个元素表示useState按使用顺序返回的state
  • effects中每个元素表示useEffect按使用顺序传入的需要监听的数据列表,并在下次调用时判断两次传入的依赖数据值是否一致

const { render, useState, useEffect } = SimpleReact;

function Counter() {
    const [count, setCount] = useState(1);
    const [count2, setCount2] = useState(100);

    useEffect(() => {
        console.log("count effect", count);
    }, [count]);

    useEffect(() => {
        console.log("count2 effect", count2);
    }, [count2]);

    useEffect(() => {
        console.log("all effect", { count, count2 });
    }, [count, count2]);

    return {
        click() {
            setCount(count + 1);
        },
        click2() {
            setCount2(count2 + 10);
        },
        render() {
            // console.log(`count is ${count}`);
        },
    };
}

let App;
App = render(Counter);

App.click();

App = render(Counter);

App.click2()

App = render(Counter);

上面的代码会依次输出

count effect 1
count2 effect 100
all effect { count: 1, count2: 100 }
count effect 2
all effect { count: 2, count2: 100 }
count2 effect 110
all effect { count: 2, count2: 110 }

看起来达到我们的目的了。

6. 小结

至此,我们实现了支持使用多个useStateuseEffect,而其实现原理仅仅是依靠了数组和执行hook的索引值。查看完整代码,请移步github

现在,可以更深刻地明白为什么不能在循环、判断或函数内部调用hooks,而必须保证函数组件每次执行时运行相同数量的hook。

实现了这两个接口之后,可以尝试去实现一些自定义Hook,如一行代码实现useRef

function useRef(initVal) {
    return { current: initVal };
}

接下来最重要的理解Hook带来的改变,以及如何使用Hook来编写更加简洁的代码了。

使用cocos实现一个合成大西瓜实现一个Vue右键菜单指令