ReactHooks原理及简单实现
本文从Hooks的基本原理出发,逐步实现简易的useState
和useEffect
,代码虽短,五脏俱全。
本文代码均放在github上面。
- 29行代码深入React Hooks原理,写的很好
Hook的背景
首先要理解hooks出现的场景,用useState
为例
早期我们需要将state放在class Component
属性上面,对于函数组件而言,只能通过props传入;那么为什么不能让函数组件拥有与类组件类似的state呢?
之所以可以把state挂载到类组件实例上,是因为在diff时,如果没有重新生成组件实例,那么只会重新执行render
方法,因此可以保证实例上的state不会丢失。
函数组件的特殊性在于每次都会重新执行,找不到一个可以保存数据的地方(很明显不能直接保存在函数组件这个函数上面,函数类的局部变量在运行后也会被回收)。
等等!如果是需要找一个地方保存数据,我们不是可以使用闭包吗?
版本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。
版本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
唯一保存的那个自由变量。
版本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数组的情况下,我们的实现简直“完美了”!
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 }
看起来达到我们的目的了。
小结
至此,我们实现了支持使用多个useState
和useEffect
,而其实现原理仅仅是依靠了数组和执行hook的索引值。查看完整代码,请移步github。
现在,可以更深刻地明白为什么不能在循环、判断或函数内部调用hooks,而必须保证函数组件每次执行时运行相同数量的hook。
实现了这两个接口之后,可以尝试去实现一些自定义Hook,如一行代码实现useRef
function useRef(initVal) {
return { current: initVal };
}
接下来最重要的理解Hook带来的改变,以及如何使用Hook来编写更加简洁的代码了。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。