手把手讲解,从零实现react hooks

476 阅读8分钟

仓库源码:github.com/BokFang/my-…

个人博客:github.com/BokFang/Blo…

觉得有收获的话,star支持一哈

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。常用的hook有useState,useMemo,useCallback,useEffect,useLayoutEffect,useContext,useReducer。

今天我们就逐个来手动实现一下。

useState

useState返回一个 state,以及更新 state 的函数。

使用

我们使用useState实现一个加数器,点击+1时数字+1。

import React from "react";
import ReactDOM from "react-dom";
function Couter() {
  const [num, setNum] = React.useState(0);
  return (
    <div>
      <p>{num}</p>
      <button onClick={() => setNum(num + 1)}>+1</button>
    </div>
  );
};
ReactDOM.render(<Couter />, document.getElementById("root"));

如图:

image

实现

第一版

useState函数传入一个值作为初始值,并返回一个数组,第一个值为变量,第二个参数为改变变量的函数,所以我们思路如下:

  • 使用一个变量lastState存放初始值initialState,若该变量本身有值,则使用本身值;
  • 创建一个setState函数,传入一个新值(修改后的值),将新值覆盖掉lastState,并重新渲染组件;
  • 将lastState和setState函数放在数组中返回。
import React from "react";
import ReactDOM from "react-dom";
let lastState;
function useState(initialState) {
  lastState = lastState || initialState;
  function setState(newState) {
    lastState = newState;
    render();// 重新渲染组件
  };
  return [lastState, setState];
};
function Counter() {
  const [num, setNum] = useState(0);
  return (
    <div>
      <p>{num}</p>
      <button onClick={() => setNum(num + 1)}>+1</button>
    </div>
  );
};
function render() {
  ReactDOM.render(<Counter />, document.getElementById("root"));
};
render();

这么一看,好像没有啥问题,但是当用useState创建多个变量时,就会出现多个变量共用一个值的情况。 同时还有一个问题,就是setState可以传一个值,也可以传一个返回值的函数。 我们接着改一改。

第二版

  • 将存放初始值的变量改为数组hookState
  • 用hookIndex代表数组hookState的下标,当多次调用useState时,下标往后移。
let hookState = [];
let hookIndex = 0;
function useState(initialState) {
  hookState[hookIndex] = hookState[hookIndex] || initialState;
  const currentIndex = hookIndex;// 形成闭包保存当前下标值
  function setState (newState) {
    if (typeof newState === 'function') {
      newState(hookState(currentIndex));
    }
    hookState[currentIndex] = newState;
    render();
    hookIndex = 0;
  };
  return [hookState[hookIndex++], setState];
};

这样,一个好用的useState就完成了!

useCallback/useMemo

useCallback的作用是:当传入的依赖项发生变化时,才更新返回的回调函数。一般用于防止组件无意义的重复渲染。 useMemo和useCallback类似,但他返回的是一个值,useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。也就是说,当传入的依赖项发生变化时,才更新返回的值。 我们先来看一个父组件内含一个子组件的场景:

import React from "react";
import ReactDOM from "react-dom";
function Child({data, onButtonClick}) {
  console.log('child render');// 用来判断子组件是否渲染
  return <button onClick={onButtonClick}>{data}</button>
}
function App() {
  const [num, setNum] = React.useState(0);
  const [name, setName] = React.useState('Fang');
  const add = () => setNum(num+1);
  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)}/>
      <Child data={num} onButtonClick={add}/>
    </div>
  );
}
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}
render();

渲染出来如下:

image

当我们在input框输入内容时,子组件也会跟着重新渲染,log出child render。 如果我们想要子组件在自己数据更新时才进行重新渲染的话,就需要useCallback。

使用

我们用memo将子组件包着,使得子组件的props值发生变化时,子组件才重新渲染; 同时使用useCallback将add函数包一下,用useMemo把num包一下,使得如果不点击button改变数字时,返回值不会发生改变,从而使子组件不会做无效刷新:

import React from "react";
import ReactDOM from "react-dom";
let Child = ({ data, onButtonClick }) => {
  console.log("child render");
  return <button onClick={onButtonClick}>{data}</button>;
};
Child = React.memo(Child);
function App() {
  const [num, setNum] = React.useState(0);
  const [name, setName] = React.useState("Fang");
  const data = React.useMemo(() => ( num ), [num]);
  const add = React.useCallback(() => setNum(num + 1), [num]);
  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <Child data={data} onButtonClick={add} />
    </div>
  );
};
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
};
render();

这样,当input框输入内容时,就不会log出child render。

实现

我们先实现一个useCallback。实现思路如下:

  • 把回调函数callback和依赖项dependencies作为参数传入;
  • 第一次执行时,将callback和dependencies存起来;
  • 当不是第一次执行时,将存的旧依赖lastDependencies拿出,与传入的dependencies作对比;
  • 若相同,则返回旧回调lastCallback;若不同则返回新的回调callback。 这里我们可以和useState共用一个数组存放,所以将自己实现的useState也拷过来。代码如下:
import React from "react";
import ReactDOM from "react-dom";
let hookState = [];
let hookIndex = 0;
function useState(initialState) {
  hookState[hookIndex] = hookState[hookIndex] || initialState;
  const currentIndex = hookIndex;
  function setState(newState) {
    hookState[currentIndex] = newState;
    render();
    hookIndex = 0;
  };
  return [hookState[hookIndex++], setState];
};
function useCallback(callback, dependencies) {
  if (hookState[hookIndex]) {
    const [lastCallback, lastDependencies] = hookState[hookIndex];
    const same = dependencies.every(
      (item, index) => item === lastDependencies[index]
    );
    if (same) {
      hookIndex++;
      return lastCallback;
    } else {
      hookState[hookIndex++] = [callback, dependencies];
      return callback;
    };
  } else {
    hookState[hookIndex++] = [callback, dependencies];
    return callback;
  };
};
let Child = ({ data, onButtonClick }) => {
  console.log("child render");
  return <button onClick={onButtonClick}>{data}</button>;
};
Child = React.memo(Child);
function App() {
  const [num, setNum] = useState(0);
  const [name, setName] = useState("Fang");
  const data = React.useMemo(() => num, [num]);
  const add = useCallback(() => setNum(num + 1), [num]);
  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <Child data={data} onButtonClick={add} />
    </div>
  );
};
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
};
render();

这样就实现了useCallback。而useMemo思路也非常类似,改成返回值就行:

let hookState = [];
let hookIndex = 0;
function useMemo(factory, dependencies) {
  if (hookState[hookIndex]) {
    const [lastMemo, lastDependencies] = hookState[hookIndex];
    const same = dependencies.every(
      (item, index) => item === lastDependencies[index]
    );
    if (same) {
      hookIndex++;
      return lastMemo;
    } else {
      const newMemo = factory();
      hookState[hookIndex++] = [newMemo, dependencies];
      return newMemo;
    };
  } else {
    const newMemo = factory();
    hookState[hookIndex++] = [newMemo, dependencies];
    return newMemo;
  };
};

useEffect/useLayoutEffect

我们先看看useEffect。

简单来说,它相当于class组件中的componentDidMount、componentDidUpdate、componentWillUnmount集合在一起。使用的方式就是,第一个参数传执行的回调函数,第二个参数传依赖项数组。当依赖项发生变化时,才执行useEffect中传入的回调函数。

import React from "react";
import ReactDOM from "react-dom";
function App() {
  const [num, setNum] = React.useState(0);
  const [name, setName] = React.useState("Fang");
  function changeTitle () {
  document.title = num;
  console.log('changeTitle');
  }
  changeTitle();
  return (
    <div>
      <p>number:{num}</p>
      <p>name:{name}</p>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button
        onClick={() => {
          setNum(num + 1);
        }}
      >
        +
      </button>
    </div>
  );
}
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}
render();

使用

React.useEffect(() => { changeTitle() },[num]);

实现

第一版

思路如下:

  • 准备一个数组hookState来存放要执行的回调函数,hookIndex表示数组的下标值;
  • 第一次执行时,将dependencies存起来;
  • 当不是第一次执行时,将存的旧依赖lastDependencies拿出,与传入的dependencies作对比;
  • 若相同,则不执行回调函数;若不同执行回调,并更新依赖项dependencies。
let hookState = [];
let hookIndex = 0;
function useEffect(callback, dependencies) {
  if (hookState[hookIndex]) {// 非第一次执行
    const lastDependencies = hookState[hookIndex];
    const same = dependencies.every(
      (item, index) => item === lastDependencies[index]
    );
    if (same) {
      hookIndex++;
    } else {
      hookState[hookIndex++] = dependencies;
      callback();
    };
  } else {// 第一次执行
    hookState[hookIndex++] = dependencies;
    callback();
  };
};

然后我们再说说useLayoutEffect。其实两者是很相似的,区别是useEffect的回调会被放入宏任务队列中,而useLayoutEffect的回调会被放入微任务队列中,useEffect中传的回调在布局绘制后执行,而useLayoutEffect中传的回调会在布局绘制前执行。如下图: image

我们用setTimeout把useEffect放到宏任务队列,用queueMicrotask把useLayoutEffect放到微任务队列中。

第二版

useEffect:
let hookState = [];
let hookIndex = 0;
function useEffect(callback, dependencies) {
  if (hookState[hookIndex]) {
    const lastDependencies = hookState[hookIndex];
    const same = dependencies.every(
      (item, index) => item === lastDependencies[index]
    );
    if (same) {
      hookIndex++;
    } else {
      hookState[hookIndex++] = dependencies;
      setTimeout(callback);
    };
  } else {
    hookState[hookIndex++] = dependencies;
    setTimeout(callback);
  };
};
useLayoutEffect:
let hookState = [];
let hookIndex = 0;
function useLayoutEffect(callback, dependencies) {
  if (hookState[hookIndex]) {
    const lastDependencies = hookState[hookIndex];
    const same = dependencies.every(
      (item, index) => item === lastDependencies[index]
    );
    if (same) {
      hookIndex++;
    } else {
      hookState[hookIndex++] = dependencies;
      queueMicrotask(callback);
    };
  } else {
    hookState[hookIndex++] = dependencies;
    queueMicrotask(callback);
  };
};

到这就结束了吗?其实还没有,因为useEffect还集成了componentWillUnmount的功能,也就是销毁函数,目的是为了清理副作用。

举个例子,还是熟悉的加数器,我们设置一个定时器setInterval,每个一秒加数器num+1,并依赖于num。这就会有一个问题,每次num变化,都会新建一个setInterval,越来越多的setInterval最终会导致页面卡死:

function App() {
  const [num, setNum] = React.useState(0);
  React.useEffect(() => {
    setInterval(() => {
      setNum((num) => num + 1);
    }, 1000);
  }, [num]);
  return (
    <div>
      <p>number:{num}</p>
    </div>
  );
}
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}
render();

我们需要在useEffect传的回调函数中返回销毁函数,而useEffect会在每次创建新的定时器时都先把上一个定时器销毁。

  React.useEffect(() => {
    const timer = setInterval(() => {
      setNum((num) => num + 1);
    }, 1000);
    return () => {clearInterval(timer)}
  }, [num]);

第三版

useEffect:
let hookState = [];
let hookIndex = 0;
function useEffect(callback, dependencies) {
  if (hookState[hookIndex]) {// 非第一次渲染
    const [oldDestroy, lastDependencies] = hookState[hookIndex];
    const same = dependencies.every(
      (item, index) => item === lastDependencies[index]
    );
    if (same) {
      hookIndex++;
    } else {// 第一次渲染
      oldDestroy();
      let destroy = setTimeout(callback);// 拿到销毁函数
      hookState[hookIndex++] = [destroy, dependencies];
    };
  } else {
    let destroy = setTimeout(callback);// 拿到销毁函数
    hookState[hookIndex++] = [destroy, dependencies];
  };
};

useContext

useContext接收一个context 对象并返回该 context 的当前值。 简单来说,就是使用React.createContext创建一个React的上下文context,然后订阅了这个上下文的组件就可以直接拿到上下文中提供的数据。

使用

import React from "react";
import ReactDOM from "react-dom";
const Context = React.createContext();
function Child() {
  const { num, setNum } = React.useContext(Context);
  return (
    <div>
      <p>{num}</p>
      <button onClick={() => setNum(num + 1)}>+</button>
    </div>
  );
}
function App() {
  const [num, setNum] = React.useState(0);
  return (
    <Context.Provider value={{ num, setNum }}>
      <Child />
    </Context.Provider>
  );
}
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}
render();

实现

useContext的实现其实是非常简单的,context对象上有一个_currentValue属性,这个属性存放的就是在value属性传的值。

function useContext(context) {
  return context._currentValue;
}

useReducer

useReducer和useState有点类似,它更适合用于管理包含多个子值的 state 对象。它的工作原理和redux类似,接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。

使用

还是熟悉的加数器例子:

import React from "react";
import ReactDOM from "react-dom";
function counterReducer(state, action) {
  switch(action.type) {
    case 'add':
      return state + 1;
      default: 
      return state;
  };
};
function App() {
  const [state, dispatch] = React.useReducer(counterReducer, 0);
  return (
    <div>
      <p>{state}</p>
      <button onClick={() => {dispatch({type: 'add'})}}>+</button>
    </div>
  );
}
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}
render();

实现

思路与useState类似:

  • 准备一个数组hookStates来存放初始值initialState,若该变量本身有值,则使用本身值。hookIndex表示数组的下标值;
  • 声明一个dispatch函数,传递的参数为action,如果reducer存在,则将hookStates[currentIndex],和action传入并执行,存入hookStates。若不存在,则直接把action存入;
  • 重新渲染组件,并将储存在hookStates的值和dispatch函数返回
let hookStates = [];
let hookIndex = 0;
function useReducer(reducer, initialState) {
  hookStates[hookIndex] = hookStates[hookIndex] || initialState;
  const currentIndex = hookIndex;
  function dispatch(action) {
    hookStates[currentIndex] = reducer? reducer(hookStates[currentIndex], action) : action;
    render();// 重新渲染组件
    hookIndex = 0;
  };
  return [hookStates[hookIndex++], dispatch];
};

useReducer就实现了!