30行代码从零实现useState和useEffect
本文完整示例代码在:codesandbox.io
实现一个最基础的useState
让我们从最简单的useState实现开始:
function useState(initVal) { let _val = initVal let state = () => _val; const setState = (data) => { _val = data; }; return [state, setState];}const [a, setA] = useState(0);console.log(a()); // 0setA(1);console.log(a()); // 1
上线代码虽然可以实现state的初始化和更新,但是不足是:我们使用的是一个函数来获取state的,而React里面使用的是变量获取的。
我们来一步步优化代码。
重构:模拟 React 的工作方式
useState使用React模块导出的,我们也来实现一下:
const React = (function () { let _val; function useState(initVal) { // 注意:state的值优先从_val读取 let state = _val || initVal; const setState = (data) => { _val = data; }; return [state, setState]; } return { useState, }; })();
使用useState
React是在组件内使用useState,组件如下:
function Component() { const [count, setCount] = React.useState(0);
return { render: () => console.log(count), updateCount: () => setCount(count + 1), };}
Component的render方法就是打印当前a变量,而click则会重新设置a
添加render方法
我们需要在React模块内增加一个render方法来渲染上面的Component:
const React = (function () { // ... function render() { let c = Component(); c.render(); return c; } return { useState, render, };})();
function Component() { // ...}
接下来执行上面的代码,可以看到每一次点击,我们都会重新渲染组件,得到正确的state值
// 渲染组件var app = React.render(Component); // 0app.updateCount();var app = React.render(Component); // 1app.updateCount();var app = React.render(Component); // 2
不过现在上面这个实现不支持多个state,我们继续完善
支持多个state
为了支持多个 state,我们需要对实现进行进一步改进。
完整实现如下:
const React = (function () { let hooks = []; let index = 0; function useState(initVal) { let state = hooks[index] || initVal; // 使用_index保存index, 否则setState调用的时候里面的index有bug let _index = index; const setState = (data) => { hooks[_index] = data; }; index++; return [state, setState]; } function render() { // 每次重新渲染必须要hooks index index = 0; let c = Component(); c.render(); return c; } return { useState, render, };})();function Component() { const [count, setCount] = React.useState(0); const [fruit, setFruit] = React.useState("apple"); return { render: () => console.log(count, fruit), updateCount: () => setCount(count + 1), updateFruit: (fruit) => setFruit(fruit), };}
var app = React.render(Component);app.updateCount();var app = React.render(Component);app.updateFruit("banana");var app = React.render(Component);
这里有一些关键点需要注意:
- useState里面的index不能直接使用index,因为当你调用setState时index已经变化了,下面是错误的示范
function useState(initVal) { let state = hooks[index] || initVal; const setState = (data) => { hooks[index] = data; }; index++; return [state, setState];}
- 每次调用React.render的时候,需要重置index,重新渲染的时候才能获取到最新的state
function render() { // 每次重新渲染必须要hooks index index = 0; ... }
hooks可以用在条件语句吗?
面试的时候我们经常会遇到这个问题,结合我们上面的实现可以看出,Hooks 不能在条件语句中使用。这是因为:
- Hooks 的工作原理依赖于它们被调用的顺序。
- 在我们的实现中,我们使用了一个全局的
index
来跟踪当前正在处理的 Hook。每次调用 Hook 时,index
都会递增。 - 如果我们在条件语句中使用 Hook,那么在某些渲染中,这个 Hook 可能不会被调用,这会导致
index
的不一致。 - 不一致的
index
会导致 Hooks 的状态混乱,可能会把一个 Hook 的状态赋给另一个 Hook。
举个例子:
function Component() { const [count, setCount] = React.useState(0);
if (count > 5) { const [name, setName] = React.useState("John"); }}
在这个例子中,当 count <= 5
时,name
这个 state 不会被创建。但是当 count > 5
时,突然多了一个 state。这会导致后面的 Hooks 的 index
发生错位,从而引起错误。
实现useEffect
让我们来实现 useEffect
Hook。useEffect
用于处理副作用,它接受两个参数:一个回调函数和一个依赖数组。
useEffect
实现的核心思路是:我们需要吧effect的依赖存在hooks数组,里面然后去对比新旧依赖
useEffect
函数首先检查是否存在旧的依赖数组。- 如果存在旧的依赖数组,它会比较新旧依赖是否有变化。我们使用数组
some
方法进行比较 - 如果依赖发生了变化(或者是第一次运行),就执行回调函数。
- 最后,保存新的依赖数组,并增加 index。
代码如下:
const React = (function () { let hooks = []; let index = 0; function useState(initVal) { // ... 之前的 useState 实现 ... } function render() { // ... 之前的 render 实现 ... } function useEffect(cb, depArray) { const oldDeps = hooks[index]; let hasChanged = true; if (oldDeps) { if (!depArray.some((dep, i) => dep !== oldDeps[i])) { hasChanged = false; } } if (hasChanged) { cb(); } hooks[index] = depArray; index++; } return { useState, render, useEffect, };})();
function Component() { const [a, setA] = React.useState(0); const [fruit, setFruit] = React.useState("apple"); React.useEffect(() => { console.log("hello"); }, [a]); return { render: () => console.log(a, fruit), updateCount: () => setA(a + 1), updateFruit: (fruit) => setFruit(fruit), };}
var app = React.render(Component);app.updateCount();var app = React.render(Component);app.updateFruit("banana");var app = React.render(Component);
控制台的输出顺序如下:第一行先执行了副作用也就是console,当我们updateCount
之后,又会执行一次副作用的console
在effect的依赖树组里面再加一个fruit
变量,也可以正常运行
完。