参考文章:React Hooks 原理
先回顾一下 useState 的用法
import React, { useState } from 'react';
function Example() {
// 声明一个叫 "count" 的 state 变量
const [count, setCount] = useState(0);
return (
You clicked {count} times
);
}
总结:调用 useState 得到一个状态和一个修改该状态的函数
依据这个特点,来实现一个简易的 useState 吧
预设一个执行环境
先预设一个执行环境,在这里我们用 console.log 去模拟视图的渲染
let onClick;
function render() {
const [count, setCount] = useState(0);
console.log("使用 clg 模拟视图渲染", count);
// 使用 onClick 模拟更新操作
onClick = () => {setCount(count + 1)};
}
render();
onClick();
onClick();
使用上面这段代码,来模拟 React 界面的渲染和点击两次按钮
最初版本的,满足一个状态和修改状态函数的返回
function useState(defaultState) {
let _state = defaultState;
function setState(newState) {
_state = newState;
render();
}
return [_state, setState];
}
使用结果如下:
这是因为,每次 setState 时,它state改变然后需要重新 render,在重新 render 时,执行 useState 又了赋初始值,这样就导致每次的 state 都被初始值覆盖了。
改进版本,修复了 setState 无效的 bug
针对上面的问题,我们可以使用闭包的性质,把 state 提取出来,让它成为一个自由变量,然后每次 调用 useState 时都判断一下,当前state有没有值,有的话就不要让初始值对它进行覆盖了。
// 提取成为全局的自由变量
let _state;
function useState(defaultState) {
// 赋初始值前,先进行判断当前state是不是没用过
_state = _state !== undefined ? _state : defaultState;
function setState(newState) {
_state = newState;
render();
}
return [_state, setState];
}
使用结果如下:
但是,如果我们又调用一个 useState 去开辟一个名为 name 的 state,然后通过一个 onChange 方法去使用会怎么样呢?
改造一下最初模拟的运行环境,让它变成这样:
let onClick;
let onChange;
function render() {
const [count, setCount] = useState(0);
const [name, setName] = useState("77")
console.log("使用 clg 模拟视图渲染 --- count", count);
console.log("使用 clg 模拟视图渲染 --- name", name);
// 使用 onClick 模拟更新操作
onClick = () => { setCount(count + 1) };
onChange = (name) => { setName(name) };
}
render();
onClick();
onClick();
onChange("kiana")
onChange("kiana_k423")
运行结果如下:
原因也很显而易见,就是因为,多个 state 共用了一个全局的 _state 自由变量
最后改进的版本,修复多次调用 useState,各个 state 状态错乱的 bug
针对这个问题,react 是如何解决的呢?
react 选择的是链表结构,每个 hook 除了自身的state,另外还有一个 next 属性,用于指定下一个 hook
这里,我们选择数组简单模拟一下:
let _memoizedState = []; // 多个 hook 存放在这个数组
let _idx = 0; // 当前 memoizedState 下标
/**
* 模拟实现 useState
* @param {any} defaultState 默认值
* @returns state 和 setState 方法
*/
function useState(defaultState) {
// 查看当前位置有没有值
_memoizedState[_idx] = _memoizedState[_idx] || defaultState;
// 再一次利用闭包,让 setState 更新的都是对应位置的 state
const curIdx = _idx;
function setState(newState) {
// 更新对应位置的 state
_memoizedState[curIdx] = newState;
// 更新完之后触发渲染函数
render();
}
// 返回当前 state 在 _memoizedState 的位置
return [_memoizedState[_idx++], setState];
}
最后根据上面那个模拟的执行环境再来使用一下:
// 模拟的 react render
let onClick;
let onChange;
function render() {
// _idx 重新置为 0, 也是契合react每次更新时都从 hooks 头节点开始更新每一个 hook
// 重置的操作也可以写在 useState 的 render 之前,都是一样的思路
_idx = 0;
const [count, setCount] = useState(0);
const [name, setName] = useState("77")
console.log("使用 clg 模拟视图渲染 --- count", count);
console.log("使用 clg 模拟视图渲染 --- name", name);
// 使用 onClick, onChange 简单模拟一下更新操作
onClick = () => { setCount(count + 1) };
onChange = (name) => { setName(name) };
}
render();
console.log("-------------");
onClick();
onClick();
console.log("-------------");
onChange("kiana")
onChange("kiana_k423")
效果如下:
总结
这个简易实现和 react hooks 源码还是有很大的出入的,首先 react hooks 源码中采用的是链表结构,然后链表中单个节点的数据结构定义如下:
// react-reconciler/src/ReactFiberHooks.js
export type Hook = {
memoizedState: any, // 最新的状态值
baseState: any, // 初始状态值,如`useState(0)`,则初始值为0
baseUpdate: Update | null, // 当前需要更新的 Update ,每次更新完之后,会赋值上一个 update,方便 react 在渲染错误的边缘,数据回溯
queue: UpdateQueue | null, // 临时保存对状态值的操作,更准确来说是一个链表数据结构中的一个指针
next: Hook | null, // 指向下一个链表节点
};
能看到还是比较复杂的,这是因为 react hooks 它除了上述的核心功能之外,还需要考虑很多边界情况,异步更新,优先级调度以及封装自定义hook的情况。
文章采用数组结构,是忽略了很多 react 异步渲染和优先级调度的一些场景的。但是也足够契合 react hook的核心思路,也更方便去理解和实现。(其实就是执行 setState后,函数式组件重新render,同时也会重新去从头到下去执行 hooks)
就比如:const [count, setCount] = useState(0);
很明显,这个 count 是const 声明是不可变的,但是执行 setCount 之后视图上的 count 就更新了,这就是因为,当执行 setCount 时,该函数式组件就重新 render 了,重新 render 的过程中 count 又被重新地 const 声明了。
而文章的简易实现,只是为了更好地理解在使用 react hooks 时为什么要写在函数式组件顶端且一定要保证顺序调用。这就是是因为初始化阶段和更新阶段 hooks 都是按照同一个顺序去执行的,倘若更新阶段执行的 hooks 比初始化阶段的 hooks 要少或者要多都是会报错的。 另外有关 useEffect 的简易实现也可以继续看一下