一、背景
React Hook 作为16.8版本的新特性已经存在很长时间了,但是你是否总是看到这样一个warning 而百思不得其解:
今天我们就来探讨一下React Hook其中一条规则,手把手教你实现一个简易的useState,在实现的过程中,你就会明白Hook 规则存在的意义。
其实总的来说,React Hook 就只有两条大的规则:
- 只在最顶层使用 Hook
- 只在 React 函数中调用 Hook
其中规则2不难理解,因为本来Hooks就是为React发明的东西,当然就只能在React函数中调用,这里我们重点讨论规则1。
不管是来自React的官方文档,还是各种教程,其实我们也很容易知道为什么只能在最顶层使用Hook,无非就是说React 靠的是 Hook 的调用顺序,才能知道哪个 state
对应哪个 useState
。
但是你是否依然在脑海里无法直观的理解这背后的原理,不急,我们今天通过一个实例,手把手写一个最最最简单的useState
,让你瞬间理解规则,开心搬砖。
二、准备
我们首先先写一个简单的加减程序,代码如下:
function App() {
const [number, setNumber] = useState(5);
const [error, setError] = useState(null);
const handlePlus = () => {
if (number >= 0) {
setError(null);
}
setNumber(number + 1);
}
const handleMinus = () => {
if (number < 1) {
setError('Number should be positive.');
} else {
setNumber(number - 1);
}
}
return (
<div className="App">
<label style={{ height: 30, display: 'block', color: 'red' }}>{error}</label>
<div>
<button onClick={handleMinus}>minus</button>
<span style={{ margin: 30 }}>{number}</span>
<button onClick={handlePlus}>plus</button>
</div>
</div>
);
}
export default App;
非常简单的一个程序,一个加按钮,一个减按钮,通过useState,实现数字的加减操作。
由于我们要学习理解为何hook必须在顶层使用,所以我们再加入一个error state,当数字为负时显示一条错误信息。
我们就通过这个例子,自己实现一个useState,通过使用我们自己的useState,到达程序一样的效果。
三、useState原理实现
我们这就开始一步步实现自己的useState,为了区分,我们命名这个方法为useMyState。
3.1 useState基本结构
我们现在可以先想一想这个hook的基本的输入输出,根据useState我们知道,它接收一个初始值,返回一个状态和一个改变状态的函数,听上去很简单,那这个方法怎么写呢,代码如下:
function useMyState(defaultValue) {
const setMyValue = () => {};
const tuple = [defaultValue, setMyValue];
return tuple
}
这个就是基本结构,是不是很简单?接收一个默认值,定义一个改变值的函数setValue,组成一个元组,返回这个元组。
3.2 状态储存的实现原理
接下来,我们就开始考虑setValue的实现。
首先,我们先想一下,在我们使用官方的useState的时候,当我们调用它返回的状态改变函数,比如setState的时候,React做了哪些事。
当然真实的React中会有很多很复杂的步骤,但我们只考虑和我们要解决的事情最相关的步骤,其实就两点:
- 新状态替换旧状态
- 重新渲染组件
那我们就根据这两点来实现setMyValue,当然我们一开始也是摸索,比如可能的实现如下:
const setMyValue = (value) => {
state = value;
renderWithMyHook();
}
但我们立马就会发现另一个问题,这个state是如何记录每一次渲染的状态的,比如组件第一次渲染的时候:
const [number, setNumber] = useMyState(5);
const [error, setError] = useMyState(null);
我们会得到一个number,和改变number的函数setNumber,但当我们调用setNumber的时候,状态是如何更新到number的呢?
为了解决这个问题,我们就需要用一种方法记录组件的状态,这里我们定一个myStates数组,如下:
const myStates = [];
let stateCalls = -1;
myStates是一个二维数组,用于存储组件中所有状态,stateCalls记录在一次渲染中useMyState调用的次数,用-1初始的原因是后面记录状态的数组以0开始,我不想在这里浪费时间转换下标。
好了,我们有了存储状态的地方,现在来修改一个下useMyState,让它能够记住状态,代码如下:
function useMyState(defaultValue) {
stateCalls += 1;
const stateId = stateCalls;
const setMyValue = (value) => {
};
const tuple = [defaultValue, setMyValue];
myStates[stateId] = tuple;
return tuple
}
做了这个改进之后,我们的组件就可以记住状态了,比如当我们首次渲染的时候,我们的myStates里就会是如下数据:
[
[5, f],
[null, f]
]
但是这里有一个小问题不知道各位有没有注意到,我们知道,React的组件都会因为各种原因rerender,那么useMyState在每一次渲染的时候都会执行一遍,按照现在我们实现的函数,当我们调用状态改变函数或者因为父组件props更新,而导致的重新渲染,我们都会往myStates增加新的状态,即使这个状态在第一次渲染的时候就已经存在了。
所以我们的函数需要改变,当状态已经存在时,就返回已经存在的状态,反之,增加新状态,代码如下:
function useMyState(defaultValue) {
stateCalls += 1;
const stateId = stateCalls;
if (myStates[stateId]) {
return myStates[stateId];
}
const setMyValue = (value) => {};
const tuple = [defaultValue, setMyValue];
myStates[stateId] = tuple;
return tuple
}
3.2 状态改变函数的实现原理
状态存储问题解决了,我们终于可以开始实现状态改变函数setMyValue了,有了前面的基础,我们很容易就能通过下面的代码实现状态的改变:
const setMyValue = (value) => {
myStates[stateId][0] = value;
};
很简单,直接更新对应状态数组的值。
最后一个问题,重新渲染组件,这个没什么过多可说的,为了实现我们的目的,我们定义一个函数用于渲染,代码如下:
function renderWithMyHook() {
stateCalls = -1;
ReactDOM.render(
<App />,
document.getElementById('root')
);
}
所以我们在React程序入口处用我们这个函数进行渲染,这里也要把stateCalls重置为-1,因为我们要记录一次渲染useMyState的调用次数,所以在渲染前把值重置。
有了渲染函数,我们把它加入setMyValue,代码如下:
const setMyValue = (value) => {
myStates[stateId][0] = value;
renderWithMyHook();
};
好了,大功告成,完整代码如下:
const myStates = [];
let stateCalls = -1;
function useMyState(defaultValue) {
stateCalls += 1
const stateId = stateCalls;
if (myStates[stateId]) {
return myStates[stateId];
}
const setMyValue = (value) => {
myStates[stateId][0] = value;
renderWithMyHook();
};
const tuple = [defaultValue, setMyValue];
myStates[stateId] = tuple;
return tuple
}
function App() {
const [number, setNumber] = useMyState(5);
const [error, setError] = useMyState(null);
const handlePlus = () => {
if (number >= 0) {
setError(null);
}
setNumber(number + 1);
}
const handleMinus = () => {
if (number < 1) {
setError('Number should be positive.');
} else {
setNumber(number - 1);
}
}
return (
<div className="App">
<label style={{ height: 30, display: 'block', color: 'red' }}>{error}</label>
<div>
<button onClick={handleMinus}>minus</button>
<span style={{ margin: 30 }}>{number}</span>
<button onClick={handlePlus}>plus</button>
</div>
</div>
);
}
function renderWithMyHook() {
stateCalls = -1;
ReactDOM.render(
<App />,
document.getElementById('root')
);
}
renderWithMyHook()
四、React Hook规则的原因
好了,我们现在可以来直观的感受,为什么React Hook只能放在顶层调用,不能放在循环或者条件语句中了,比如我们有如下代码:
if (xxx < 10) {
const [number, setNumber] = useMyState(5);
}
const [error, setError] = useMyState(null);
如果xxx在首次渲染的时候小于10,状态数组如下:
[
[5, f],
[null, f]
]
当第二次渲染的时候xxx > 10
, 那么就会跳过 useMyState(5);
,然而此时当useMyState(null);
运行的时候,stateCalls
就为0
,stateId
就为0
,它会把[5, f]
取出,然后赋值给 [error, setError]
,这显然是错误的,这就是人们总说的,React Hook是通过调用顺序来区分状态的。
五、总结
好了,看到这里,如果你一步一步都看懂了,那么你一定比之前更加理解了React Hook背后规则的意义,当然,真实的Hook的实现肯定要比我们这里的实现复杂一万倍,这篇文章更多的是希望在你使用React Hook的时候,脑海里会有一个更加直观的模型。 自己动手试一试吧,你一定会体会到只有程序员才能体会到的通透!
当你迷茫时
一杯咖啡,千行代码
欢迎关注公众号:一杯代码