likes
comments
collection
share

React 源码:hooks——useEffect、useState、useMemo 等源码实现

作者站长头像
站长
· 阅读数 5

最近在学习慕课网 手写 React 高质量源码迈向高阶开发,之前自己也尝试看过源码,不过最终放弃了

放弃的最主要原因是 react 内部的调用链太长了,每天在缕清调用链上都花了不少时间,createRoot 都没有看完

最近看到慕课网有一个 react 源码课,就想着跟着课程然后在自己源码,看看这次能够看到什么地步

它这个课程前八章是 react@16 的源码,从第九章开始才是 react@18 的源码

React 源码系列:

本篇是介绍常用的 hooks 源码:useEffectuseMemouseCallbackuseStateuseRef 等源码

对于这些 hooks 不会深入讲解,只实现其核心功能

hooksreact16.8 新增加的功能,它能够让我们在不编写类组件时拥有状态

hooks 本质是一个函数,在特定的时间点 react 会自动调用它

useState

useState 的作用是声明一个状态变量,返回一个数组,数组的第一个元素是状态变量,第二个元素是改变状态变量的函数

function FunctionComponent() {
  // 第一个参数是状态变量,第二个参数是改变状态变量的函数
  const [count, setCount] = useState(0);
}

我们怎么来实现 useState 呢?

首先定义两个变量 hookIndexstates

states 是一个数组,用来保存每个 useState 的状态, hookIndex 是用来记录当前是第几个 useState

function FunctionComponent() {
  const [count, setCount] = useState(1); // hookIndex = 0   states = [1]
  const [count2, setCount2] = useState(2); // hookIndex = 1  states = [1, 2]
}

我们先定义一个 useState 函数,接收一个初始值

function useState() {
  // hookIndex 用来记录当前是第几个 useState
  // 所以我们可以通过 hookIndex 来获取当前的状态
  // 如果从 states 有当前状态,那么就取出当前状态,如果没有就用 initialValue
  states[hookIndex] = states[hookIndex] || initialValue;
  // 定义一个可以修改状态的函数
  const setState = () => {
    // 从 states 中取出当前的状态
    states[hookIndex] = newValue;
  };
  // 返回值是一个数组,第一个元素是状态,第二个元素是修改状态的函数
  // hookIndex++ 这里是先从 states 中取出当前的状态,然后再将 hookIndex 加 1
  return [states[hookIndex++], setState];
}

这样编写有一个问题,hookIndex 是全局变量,如果我们有多个 useState 的话,那么 hookIndex 就会被覆盖,我们就拿不到对应的状态了

怎么解决这个问题呢?

我们可以利用闭包的特性保存hookIndex

function useState() {
  // hookIndex 用来记录当前是第几个 useState
  // 所以我们可以通过 hookIndex 来获取当前的状态
  // 如果从 states 有当前状态,那么就取出当前状态,如果没有就用 initialValue
  states[hookIndex] = states[hookIndex] || initialValue;
  // 定义一个 currentIndex 来保存当前的 hookIndex
  // 这里是利用了闭包
  const currentIndex = hookIndex;
  // 定义一个可以修改状态的函数
  const setState = () => {
    // 从 states 中取出当前的状态
    states[currentIndex] = newValue;
  };
  // 返回值是一个数组,第一个元素是状态,第二个元素是修改状态的函数
  // hookIndex++ 这里是先从 states 中取出当前的状态,然后再将 hookIndex 加 1
  return [states[hookIndex++], setState];
}

这样我们就可以拿到对应的状态了

但是怎么更新页面呢?

之前我们在 react-dom 中实现了一个 updateDomTree 函数

我们调用这个函数就可以了,不是在 setState 中直接调用,而是 react-dom 提供一个调用 updateDomTree 的方法

定义 emitUpdateForHooks 的函数,这个在 render 时声明做两件事情:

  • 重置 hookIndex
    • 重置 hookIndex 的函数是 hooks 中提供,将 hookIndex 重置为 0
  • 调用 updateDomTree 函数
    • hooks 更新是从根元素开始的,这里传入的 VNode 是根元素
// react-dom.js
// 声明一个变量,这个变量是用来保存更新 DOM 的函数
export let emitUpdateForHooks;

function render(VNode, containerDOM) {
  mount(VNode, containerDOM);
  // hooks 用来更新页面,在调用 render 时赋值
  emitUpdateForHooks = function () {
    // 重置 hookIndex 为 0
    resetHookIndex();
    // 调用 updateDomTree 函数,传入 VNode 和 VNode 对应的 DOM
    // 这里传入的 VNode 是根元素
    updateDomTree(VNode, VNode, findDOMByVNode(VNode));
  };
}

// hooks.js
function resetHookIndex() {
  // 将 hookIndex 重置为 0
  hookIndex = 0;
}

这里要说明一点 emitUpdateForHooks 函数执行后会更新页面,更新页面时又会把所有的组件执行一遍,组件的内部的代码也会被执行,比如 useState,那它的 hookIndex 还是从头开始的

function Component() {
  const [count, setCount] = useState(0); // 初始化时 hookIndex 是 0,执行 emitUpdateForHooks 后 hookIndex 还是 0
  const [count2, setCount2] = useState(0); // 初始化时 hookIndex 是 1,执行 emitUpdateForHooks 后 hookIndex 还是 1
}

useReducer

useReduceruseState 的原始版本,或者说 useStateuseReducer 的语法糖,可以实现更复杂的逻辑

它接收两个参数:

  • reducer 函数
    • 函数接收两个参数
      • state 当前状态
      • action 传入的参数
    • 函数返回值是新的状态
  • initialValue 初始值
const [state, dispatch] = useReducer(reducer, { age: 42 });
function reducer(state, action) {
  if (action.type === "incremented_age") {
    return {
      age: state.age + 1,
    };
  }
  throw Error("Unknown action.");
}
// 使用
dispatch({ type: "incremented_age" });

那它的源码怎么实现呢?

useReducer 源码和 useState 非常像,区别就是 states[currentIndex] 的值是调用 reducer 函数返回的新的状态

其实 useState 内部就是调用的 useReducer 函数,只不过我在这里分开写了,没有进行封装

// 接收两个参数 reducer 和 initialState
function useReducer(reducer, initialState) {
  states[hookIndex] = states[hookIndex] || initialState;
  let currentIndex = hookIndex;
  function dispatch(action) {
    // 和 useState 不同的地方
    // 这里是调用 reducer 函数,传入当前的状态和 action
    // reducer 函数返回值是新的状态
    states[currentIndex] = reducer(states[currentIndex], action);
    emitUpdateForHooks();
  }

  return [states[hookIndex++], dispatch];
}

useEffect 和 useLayoutEffect

useEffect 它的作用类似于类组件的三个生命周期函数:

  • componentDidMount:在组件挂载时执行
  • componentDidUpdate:在依赖更新时执行
  • componentWillUnmount:在组件卸载时执行
// 第二个参数是个空数组,类似于 componentDidMount
useEffect(() => {}, []);
// 第二个参数不传,类似于 componentDidMount 和 componentDidUpdate
useEffect(() => {});
// 第二个参数有依赖,类似于 componentDidUpdate
useEffect(() => {}, [count]);
// 函数返回一个函数时,类似于 componentWillUnmount
useEffect(() => {
  return () => {};
}, []);

虽然 useEffect 可以模拟类组件的生命周期函数,但并不完全等价于类组件的生命周期函数

比如下面这几种情况:

  1. 并不完全等价于 componentDidUpdate
    // count 是初始值时,useEffect 会执行
    // count 变化时,useEffect 也会执行
    useEffect(() => {}, [count]);
    // 也就是说 count 值只要变化,useEffect 就会执行,所以第一次拿到的是一个 undefined,你会写很多这样的代码
    useEffect(() => {
      if (count === undefined) return;
    }, [count]);
    
  2. 并不完全等价于 componentWillUnmount
    // count 变化时,return 的函数都会执行,先调用上一次的 return 函数,再调用这一次的 useEffect 传入的函数
    useEffect(() => {
      // useEffect 执行时,先调用上一次的 return 函数,再调用这一次的 useEffect 传入的函数
      return () => {};
    }, [count]);
    
  3. 组件更新就会执行
    useEffect(() => {
      // 这里的代码会在组件更新时执行,每次都会执行
    });
    

所以对于 useEffect 来说,不能直接按照类组件的生命周期函数去理解

useLayoutEffectuseEffect 其实是很像的,主要的区别是:

  • useLayoutEffect 会在 DOM 挂载之前执行,不会阻止浏览器的重绘
  • useEffect 会在 DOM 挂载之后执行,会阻止浏览器的重绘

那它的源码怎么实现呢?

具体分为这六步骤:

  1. 这个 hooks 也是用到了 stateshookIndex,首先需要保存 hookIndex
    // 定义一个 currentIndex 来保存当前的 hookIndex
    const currentIdnex = hookIndex;
    
  2. states 中取出 currentIndex 位置的 stateuseEffect 保存到 states 中的是一个数组,数组中保存的是 effectFunction 的返回函数 destroyFunctionuseEffect 的第二个参数 deps
    // 从 states 中取出 currentIndex 位置的 state
    // 第一项是 effectFunction 的返回函数 destroyFunction
    // 第二项是 useEffect 的第二个参数 deps
    const [destroyFunction, preDeps] = states[currentIndex] || [null, null];
    
  3. 这两个条件不满足时,就会执行 effectFunction
    • 判断 deps 是否存在,如果 deps 不存在,那么 states[currentIndex] 就是 null
    • 判断 deps 中的每一项是否和 preDeps 中的每一项相等
    // 依赖不存在,或者依赖相比与上一次有变化时更新,需要执行 effectFunction
    if (
      !states[currentIndex] ||
      (deps && deps.some((item, index) => item !== preDeps[index]))
    ) {
      // ...
    }
    
  4. 在执行 effectFunction 之前,需要先执行上一次的 effectFunction 返回的 destroyFunction
    // 执行上一次的 effectFunction 返回的 destroyFunction
    destroyFunction && destroyFunction();
    // 执行 effectFunction 函数,并拿到 effectFunction 返回的 destroyFunction
    const nextDestroyFunction = effectFunction();
    
  5. effectFunction 的返回的 destroyFunctiondeps 保存到 states[currentIndex]
    • 在保存前需要先判断一下 deps 是否存在,如果 deps 不存在,那么 states[currentIndex] 就是 null
    // 将 effectFunction 的返回的 destroyFunction 和 deps 保存到 states[currentIndex] 中
    // 在保存前需要先判断一下 deps 是否存在,如果 deps 不存在,那么 states[currentIndex] 就是 null
    states[currentIndex] = deps ? [nextDestroyFunction, deps] : null;
    
  6. 最后 hookIndex++

这里要注意的是,因为 useEffecteffectFunction 是在页面挂载后执行的,在 effectionfunction 可以操作 DOM,所以我们这里在要用 setTimeout 来模拟

function useEffect(effectFunction, deps) {
  // 定义一个 currentIndex 来保存当前的 hookIndex
  const currentIndex = hookIndex;
  // 从 states 中取出 currentIndex 位置的 state
  // 第一项是 effectFunction 的返回函数 destroyFunction
  // 第二项是 useEffect 的第二个参数 deps
  const [destroyFunction, preDeps] = states[currentIndex] || [null, null];
  // 依赖不存在,或者依赖相比与上一次有变化时更新,需要执行 effectFunction
  if (
    !states[currentIndex] ||
    (deps && deps.some((item, index) => item !== preDeps[index]))
  ) {
    // 使用 setTimeout 模拟 useEffect 在 DOM 挂载后执行,setTimeout 是宏任务,需要等到浏览器的重绘完成后才会执行
    setTimeout(() => {
      // 执行上一次的 effectFunction 返回的 destroyFunction
      destroyFunction && destroyFunction();
      // 执行 effectFunction 函数,并拿到 effectFunction 返回的 destroyFunction
      const nextDestroyFunction = effectFunction();
      // 将 effectFunction 的返回的 destroyFunction 和 deps 保存到 states[currentIndex] 中
      // 在保存前需要先判断一下 deps 是否存在,如果 deps 不存在,那么 states[currentIndex] 就是 null
      states[currentIndex] = deps ? [nextDestroyFunction, deps] : null;
    });
  }
  // hookIndex++
  hookIndex++;
}

useLayoutEffectuseEffect 的源码是差不多的,只不过它是在DOM 挂载之前执行,所以它和 useEffect 的区别就是执行 effectFunction 的时机不同

setTimeout 是宏任务,需要等到浏览器的重绘完成后才会执行,但是 useLayoutEffect 会打断浏览器的重绘,所以需要使用微任务 queueMicrotask

function useLayoutEffect(effectFunction, deps) {
  const currentIndex = hookIndex;
  const [destroyFunction, preDeps] = states[currentIndex] || [null, null];
  if (
    !states[currentIndex] ||
    (deps && deps.some((item, index) => item !== preDeps[index]))
  ) {
    // 使用 queueMicrotask 来代替 setTimeout,queueMicrotask 是微任务,会打断浏览器的重绘
    queueMicrotask(() => {
      destroyFunction && destroyFunction();
      const nextDestroyFunction = effectFunction();
      states[currentIndex] = deps ? [nextDestroyFunction, deps] : null;
    });
  }
  hookIndex++;
}

useRef

useRefcreateRef 其实差不多,都会返回一个 { current: null } 的对象

不同的是 useRef 只在第一次返回 { current: null } 的对象,之后就会返回对应的 ref

那怎么实现 useRef 的源码呢

function useRef(initialValue) {
  states[hookIndex] = states[hookIndex] || { current: initialValue };
  return states[hookIndex++];
}

它的源码就这么简单

你可能会觉得 ref 怎么绑定

因为 useRef 返回的是 { current: null } 的对象,我们把这个对象赋值给 DOMref 属性

DOMref 属性会在 createDOM 函数中绑定

function createDOM(VNode) {
  // ...
  // 将 dom 赋值给 ref.current
  ref && (ref.current = dom);
  return dom;
}

我们就能够拿到 ref 对应的 DOM

useRef 还可以接收一个初始值:useRef(10)

那为什么我们在 setState 后依然能够拿到最新个值呢?

这是因为 states[hookIndex] 作用,如果 states[hookIndex] 值,就会取出 states[hookIndex] 的值,如果没有就用 initialValue

useImperativeHandle

useImperativeHandle 作用是提供给父组件一个可以调用子组件的方法

我们先看一下不使用 useImperativeHandle,直接使用 ref 的话,有什么问题,具体看下面代码

const MyInput = React.forwardRef(function MyInput(props, ref) {
  return <input {...props} ref={ref} />;
});

function Form() {
  const ref = useRef(null);

  function handleClick() {
    ref.current.focus();
    ref.current.value = "Hello, world!"; // 父组件可以直接操作子组件的 DOM,这样就会对子组件造成破坏
  }

  return (
    <form>
      <MyInput label="Enter your name:" ref={ref} />
      <button type="button" onClick={handleClick}>
        Edit
      </button>
    </form>
  );
}

直接使用 ref,父组件就可以直接操作子组件的 DOM,去做一些其他事情,这并不是子组件想要的方式

所以就需要子组件对 ref 做一层代码,只给父组件提供一些可以给父组件操作的方法,具体使用方式如下代码:

const MyInput = React.forwardRef(function MyInput(props, ref) {
  const inputRef = useRef(null);

  useImperativeHandle(
    ref,
    () => ({
      focus() {
        inputRef.current.focus();
      },
    }),
    []
  );

  return <input {...props} ref={inputRef} />;
});

function Form() {
  const ref = useRef(null);

  function handleClick() {
    ref.current.focus();
  }

  return (
    <form>
      <MyInput label="Enter your name:" ref={ref} />
      <button type="button" onClick={handleClick}>
        Edit
      </button>
    </form>
  );
}

那源码怎么实现呢?

其实 useImperativeHandle 源码很简单,接收两个参数,第一个参数是 ref,第二个参数是一个函数,函数返回值是一个对象,这个对象就是父组件可以调用的方法

function useImperativeHandle(ref, dataFactory) {
  ref.current = dataFactory();
}

ref.current 就是 DOM 节点,我们将 ref.current 的结果修改为 dataFactory 函数返回值,就实现了对 ref 的代理

useMemo 和 useCallback

useMemouseCallback 作用是缓存数据,他们的区别是:

  • useMemo 缓存的是数据,比如有一个值需要依赖几个值计算出来
  • useCallback 缓存的是函数,比如有一个函数,函数内部依赖了几个值

每次 setState 函数都会重新执行,函数重新执行,函数内部的逻辑就会执行,有些复杂的逻辑多次执行的化,会影响页面性能

所以 react 就推出了 useMemo 来缓存数据,依赖的值变了,useMemo 内部的逻辑才会重新执行,否则不会执行,提供了页面的性能,useCallback 同理

我们来看下 useMemo 源码是如何实现的

它的源码实现其实和 useEffect 差不多,具体的流程可以看 useEffect,区别就是 useEffect 不需要返回数据,而 useMemo 需要将数据返回出去

function useMemo(dataFactory, deps) {
  // 从 states 中取出 hookIndex 位置的 state
  // 这里为啥不用 currentIndex 进行缓存,因为这里不需要改变 states 中的值
  let [preData, preDeps] = states[hookIndex] || [null, null];
  // 依赖不存在,或者依赖相比与上一次有变化时更新,需要执行 dataFactory 函数
  if (
    !states[hookIndex] ||
    (deps && deps.some((item, index) => item !== preDeps[index]))
  ) {
    // 执行 dataFactory 函数,并拿到 dataFactory 函数返回的数据 newData
    let newData = dataFactory();
    // 将 newData 和 deps 保存到 states[hookIndex] 中
    // 在保存前需要先判断一下 deps 是否存在,如果 deps 不存在,那么 states[hookInex] 就是 null
    states[hookIndex++] = deps ? [newData, deps] : null;
    // 将 newData 返回出去
    return newData;
  }
  // 依赖没有变化,不需要执行 dataFactory 函数,直接返回上一次 dataFactory 函数返回的数据 preData
  hookIndex++;
  return preData;
}

useCallback 源码和 useMemo 差不多,区别就是 useCallback 返回的是一个函数

function useCallback(callback, deps) {
  let [preCallback, preDeps] = states[hookIndex] || [null, null];
  if (
    !states[hookIndex] ||
    (deps && deps.some((item, index) => item !== preDeps[index]))
  ) {
    // 和 useMemo 区别这里,callback 无需执行,直接保存起来,并将 callback 返回出去
    states[hookIndex++] = deps ? [callback, deps] : null;
    return callback;
  }
  hookIndex++;
  return preCallback;
}

总结

  1. 大部分 hooks 都用到了 stateshookIndex 这两个变量,也就是说 hooks 本质将你传入的函数,数据保存到 states 中,然后组件执行时,拿到 hooks 对应的 state,通过对比 preStatenewState 是否相等,决定是否执行
  2. 大部分的 hooks 的源码都比较相似,比如 useMemouseCallbackuseStateuseReducer
  3. useRef 比较特殊,它不依赖 state,所以用它保存的数据,不会触发页面的更新

源码

  1. useState
  2. useReducer
  3. useEffect
  4. useLayoutEffect
  5. useRef
  6. useImperativeHandle
  7. useMemo
  8. useCallback