30分钟教你写出自己的useState

3,893 阅读9分钟

一、背景

React Hook 作为16.8版本的新特性已经存在很长时间了,但是你是否总是看到这样一个warning 而百思不得其解:

今天我们就来探讨一下React Hook其中一条规则,手把手教你实现一个简易的useState,在实现的过程中,你就会明白Hook 规则存在的意义。

其实总的来说,React Hook 就只有两条大的规则:

  1. 只在最顶层使用 Hook
  2. 只在 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: 30display: '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中会有很多很复杂的步骤,但我们只考虑和我们要解决的事情最相关的步骤,其实就两点:

  1. 新状态替换旧状态
  2. 重新渲染组件

那我们就根据这两点来实现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就为0stateId就为0,它会把[5, f] 取出,然后赋值给 [error, setError],这显然是错误的,这就是人们总说的,React Hook是通过调用顺序来区分状态的。

五、总结

好了,看到这里,如果你一步一步都看懂了,那么你一定比之前更加理解了React Hook背后规则的意义,当然,真实的Hook的实现肯定要比我们这里的实现复杂一万倍,这篇文章更多的是希望在你使用React Hook的时候,脑海里会有一个更加直观的模型。 自己动手试一试吧,你一定会体会到只有程序员才能体会到的通透!



- EOF -

当你迷茫时

一杯咖啡,千行代码

欢迎关注公众号:一杯代码