手写简单 useState / useEffect,理解 hooks 原理

2,650 阅读3分钟

在《effect 范式下的组件状态和依赖》中,useState / useEffect 是被最多提及的。

useState / useEffect 是驱动 render 触发和运行的基础

那本文就从实现简单的 useState / useEffect 落脚,讨论下他们如何管理状态、驱动 render 的。

手动实现一个 useState

先回忆一下,useState 是怎么调用的?

const Comp = props => {
	const [ foo, setFoo ] = useState(1);
	return <button onClick={() => setFoo(2)}>{foo}</button>
};

useState 实现了:

  • 传入一个初始状态
  • 返回一个数组,包含最新状态和 set 状态的方法。
  • 调用 set 状态的方法时,会触发重新渲染

很容易实现

let currentState;
const useState = initialState => {
	const state = currentState || initialState;
	const setState = newState => {
		currentState = newState;
		render();	// 这个 render 可以理解为触发了整个 react app 渲染,就像 ReactDOM.render();
	};
	return [ state, setState ];
};

收纳盒:兼容多次调用

事情没这么简单,实际上 useState 在整个 app,甚至单个组件内通常都不会只调用一次,那调用的都是同一个 useState 实现,如何区分调用者呢?

假设代码内调用 useState 的顺序是不变的

在通常情况下确实是这样的。那就好办了,按顺序存到盒子里就好。

就像每周(一次渲染)七天穿不同的袜子(七次 useState)。把他们放到收纳盒里,每天去下一个格子取就好;下一周(下一次渲染周期)再从头开始。

let index = -1;
const currentStateBoxs = [];
const useState = initialState => {
  index++;
  const currentIndex = index;
  currentStateBoxs[currentIndex] = currentStateBoxs[currentIndex] || initialState;
  const setState = newState => {
    currentStateBoxs[currentIndex] = newState;
    render(); // 这个 render 可以理解为触发了整个 react app 渲染,就像 ReactDOM.render();
  };
  return [ currentStateBoxs[currentIndex], setState ];
};

为什么说不要破坏 useState 的顺序?

试想如果有个周二你没穿袜子,周三就穿错了……

const Comp = props => {
	const [ 周一袜子 ] = useState(xx);	// 拿到第一个盒子的袜子
	if (周二天气好) {
		const [ 周二袜子 ] = useState(xx);
	}
	const [ 周三袜子 ] = useState(xx);	// 如果周二没拿,周三拿了第二个盒子的袜子
};

这里问题出在 if,它让 useState 的执行顺序出现了不确定性,类似的还有 for 等其他条件、循环语句。

手动实现一个 useEffect

useEffect 是怎么用的呢?

const Comp = props => {
	const [ foo, setFoo ] = useState(1);
	useEffect(() => {
		...
	}, [ foo ]);
	return <button>{foo}</button>
};

useEffect 在依赖变化时,执行回调函数。这个变化,是「本次 render 和上次 render 时的依赖比较」。

我们需要:

  • 存储依赖,上一次 render 的
  • 兼容多次调用,也是收纳盒的思路
  • 比较依赖,执行回调函数

实现:

const lastDepsBoxs = [];
let index = 0;
const useEffect = (callback, deps) => {
	const lastDeps = lastDepsBoxs[index];
	const changed =
		!lastDeps	// 首次渲染,肯定触发
		|| !deps	// deps 不传,次次触发
		|| deps.some((dep, index) => dep !== lastDeps[index]);	// 正常比较
	if (changed) {
		lastDepsBoxs[index] = deps;
		callback();
	}
	index ++;
};

增加副作用清除

在另外一文提到过,effect 触发后会把清除函数暂存起来,等下一次 effect 触发时执行。

明确这个顺序就不难实现了:

const lastDepsBoxs = [];
const lastClearCallbacks = [];
let index = 0;
const useEffect = (callback, deps) => {
	const lastDeps = lastDepsBoxs[index];
	const changed = !lastDeps || !deps || deps.some((dep, index) => dep !== lastDeps[index]);	
	if (changed) {
		lastDepsBoxs[index] = deps;
		if (lastClearCallbacks[index]) {
			lastClearCallbacks[index]();
		}
		lastClearCallbacks[index] = callback();
	}
	index ++;
};

总结

  • 利用闭包,useState / useEffect 的实现并不深奥
  • 巧妙的是对多次调用的组织方式,“收纳盒”
  • 但“收纳盒”对顺序敏感,所以使用 hooks 要避免 if、for 等嵌套