深入解析类 React's Hooks 的实现原理

1,289 阅读8分钟

前言

本文灵感以及代码实现参考自源码:TNG-Hooks。此库的开发者也是大名鼎鼎的 You-Dont-Know-JS 的作者 **Kyle Simpson。**所以本文也可以叫做 TNG-Hooks 源码精读 :-)。

在 Github README 中提到 TNG-Hooks 的灵感起源于 React's Hooks,它提供类似于 useState、useReducer、useEffect 等钩子函数给到普通函数用于状态以及副作用的管理。

注意,TNG-Hooks 的目标对象是普通函数,并不依赖 React 无状态组件(不过实现原理都是大致相似的)。因此,无需了解 React 也可以学习它的源码实现。而且源码行数也只有约 300 行,可谓短小精悍,代码实现也相当优雅。源码十分适合阅读学习。

无论 Hooks 是作用于独立的普通函数,还是我们常见的 React 无状态组件,原理基本类似。所以本文会更倾向于分析无 React 依赖的 Hooks 实现,然后再回到 React 中进行讨论。

话不多说,本文正式开始!

Hooks 从 0 到 1 指南

基本原理概述

在开始之前,先思考一个问题:

怎样让一个函数记住状态?

当然一般情况下,函数不会记住状态。假如借助一些全局变量是否可以记住状态呢?(当然污染全局的做法不推荐)

于是编写实验代码如下,希望可以通过全局变量 val 记住 count 值:

let val: unknown;
function rememberAndUpdateState(initialVal: any) {
  const updateVal = (v: any) => {
    val = v;
  };
  updateVal(initialVal);

  return [val as any, updateVal];
}

function hit() {
  var [count, setCount] = rememberAndUpdateState(0);

  count++;
  setCount(count);

  console.log(`Hit count: ${count}`);
}


满心欢喜的运行:

hit();       // Hit count: 1
hit();       // Hit count: 1
hit();       // Hit count: 1


结果完全不符合预期。到底错在哪呢?可以发现每次运行 hit 函数,rememberAndUpdateState 内部的逻辑也都全部运行。因此 updateVal(initialVal) 这一句也会每次重置 count 为初始值。

所以我们还需要思考让 updateVal(initialVal) 只在第一次运行。我们尝试增加 funcIsFirstTimeCall 数组,并将 hit 函数引用传入,如果 hit 函数还未曾出现过(也就是第一次执行 rememberAndUpdateState),则缓存函数用以后续判断,并执行 updateVal(initialVal) 。代码如下:

let val: unknown;
const funcIsFirstTimeCall = [];
function rememberAndUpdateState(initialVal: any) {
  const updateVal = (v: any) => {
    val = v;
  };

  // 获取调用 rememberAndUpdateState 的函数,也即是 hit
  const host = arguments.callee.caller;
  // 只有在第一次才会调用初始操作
  if (!funcIsFirstTimeCall.includes(host)) {
    funcIsFirstTimeCall.push(host);
    updateVal(initialVal);
  }

  return [val as any, updateVal];
}

function hit() {
  var [count, setCount] = rememberAndUpdateState(0);

  count++;
  setCount(count);

  console.log(`Hit count: ${count}`);
}


成功运行!

hit(); // Hit count: 1
hit(); // Hit count: 2
hit(); // Hit count: 3


于是 hit 函数就简单的记住了 count 值,rememberAndUpdateState 也就是一个 useState 的最简单实现。不过,即使如此实现了,这种做法也不够优雅,因为它将不需要的细节暴露给了开发者,同时没有扩展性可言。

那如何实现一个扩展性良好的 rememberAndUpdateState (也即是 useState)呢?我们总结上述 demo,得出两条结论如下:

  1. 需要在函数之外通过其他对象变量记住状态;
  2. 需要在函数之外标记调用次数,标记区别依据可以是函数本身;


两条结论其实归纳起来,就是让函数可以有上下文。如下:

function contextWrapper() {
  let val: unknown;
  const funcIsFirstTimeCall = [];
  function rememberAndUpdateState(initialVal: any) {
		// ...
  }

  function hit() {
 		// ...
  }
  return hit;
}

const hitWithContext = contextWrapper();
hitWithContext(); // Hit count: 1
hitWithContext(); // Hit count: 2
hitWithContext(); // Hit count: 3


咦?这不就是闭包么?这不就是高阶函数么?本质上还是通过闭包实现了一个上下文。这也是 Hooks 为什么要运行在特定的上下文中,才会发挥作用。(比如 React Hooks 只能运行在 React 函数组件中)

上下文处理

那么?如何把上述实现搞得高级点、灵活点、优雅点。我们期望的使用方式如下:

function hit() {
  const [count, setCount] = useState(0);
  const newCount = count + 1;
  setCount(newCount);

  console.log(`Hit count: ${newCount}`);
}

const hitWithContext = createHC(hit);

hitWithContext(); // Hit count: 1
hitWithContext(); // Hit count: 2
hitWithContext(); // Hit count: 3

其中 createHC 就是给 hit 创建上下文的函数(你可以理解 React 中在运行时中也对函数组件也做了类似的事情)。

我们尽管可以照猫画虎地实现,但是还是有些别扭。而且 arguments.callee 也是不推荐使用的方法。
image.png
我们知道,JavaScript 语言特性之一就是单线程。也就意味着任意时间只存在一个函数执行,函数的执行顺序在 调用栈(Call Stack)中保存。函数运行前入栈,运行后出栈。简单示例如下:
image.png

在这种调用栈中,最大的好处是我们可以知道哪个函数正在执行!就如上文中使用 arguments.callee.caller 获取当前函数的调用者。如下:
image.png

那么,我们是不是也可以通过在运行时中手动实现这个机制,从而获取到当前运行 hook 的是哪个函数呢?
image.png

基于此想法,编写实验代码:

// 使用 WeakMap 记录 func 和 bucket 的映射关系
// bucket 用来记录上下文状态
const buckets = new WeakMap();
// 使用堆栈跟踪当前运行的函数
const runtimeStack = [];
function createHC(func: Function) {
  // 返回高阶函数封装上下文处理操作
  return function HOFWithContext(...args: any) {
    runtimeStack.push(func);
    if (!buckets.has(func)) {
      buckets.set(func, {
        stateSlot: [],
      });
    }
    try {
      return func.apply(this, args);
    } finally {
      runtimeStack.pop();
    }
  };
}

function useState(initialVal: any) {
  // 此时的 caller 即为当前运行 useState 的函数
  const caller = runtimeStack[runtimeStack.length - 1];
  const bucket = buckets.get(caller);

  // 如果找不到当前 bucket,证明 useState 被错误使用在无上下文环境
  if (!bucket) {
    throw new Error(
      'useState() only valid inside an Articulated Function or a Custom Hook.'
    );
  }

  // 只有在首次执行初始化操作
  if (bucket.stateSlot.length === 0) {
    const slot = [
      typeof initialVal == 'function' ? initialVal() : initialVal,
      function updateSlot(v: unknown) {
        slot[0] = v;
      },
    ];
    bucket.stateSlot = slot;
  }

  return bucket.stateSlot;
}

function hit() {
  const [count, setCount] = useState(0);
  const newCount = count + 1;
  setCount(newCount);

  console.log(`Hit count: ${newCount}`);
}

const hitWithContext = createHC(hit);

hitWithContext(); // Hit count: 1
hitWithContext(); // Hit count: 2
hitWithContext(); // Hit count: 3


runtimeStack 代表了函数的先后执行关系,而基于 WeakMap 的哈希表映射了 runtimeStack 中的函数与对应的上下文关系。
image.png
当然,上述实现代码通用性并不好,我们可以扩展一下 bucket 对象用以支持更丰富的上下文。大家可以大致阅读一下下述完整实现代码。

const buckets = new WeakMap<Function, IBucket>();
const runtimeStack: Function[] = [];

function getCurrentBucket() {
  if (runtimeStack.length > 0) {
    let bucket: IBucket;
    const func = runtimeStack[runtimeStack.length - 1];

    // 不存在则新建 bucket
    if (!buckets.has(func)) {
      bucket = {
        nextStateSlotIdx: 0,
        nextEffectIdx: 0,
        nextMemoizationIdx: 0,
        stateSlots: [],
        effects: [],
        cleanups: [],
        memoizations: [],
      };
      buckets.set(func, bucket);
    }
    return buckets.get(func);
  }
  return null;
}

export function createHC(func: Function) {
  function HOFWithContext(...args: any) {
    runtimeStack.push(func);

    const bucket = getCurrentBucket();

    // e.g. 运行 hit 函数重置 hooks 的索引,因此 hooks 的执行依赖顺序。
    bucket.nextStateSlotIdx = 0;
    bucket.nextEffectIdx = 0;
    bucket.nextMemoizationIdx = 0;

    try {
      return func.apply(this, args);
    } finally {
      try {
        // 执行副作用
        runEffects(bucket);
      } finally {
        runtimeStack.pop();
      }
    }
 
    // useEffect 钩子函数会依赖此函数运行副作用,这里先暂不介绍
    function runEffects(bucket: IBucket) {
      for (let [idx, [effect, guards]] of bucket.effects.entries()) {
        try {
          if (typeof effect === 'function') {
            effect();
          }
        } finally {
          bucket.effects[idx][0] = undefined;
        }
      }
    }
  }

  return HOFWithContext;
}

确保理解了上下文的实现原理后,后续所有的 hooks 都依赖上述代码进行开发。

实现 useReducer

我们先来了解 useReducer 的实现机制。reduce 是函数式编程的概念,在前端世界里也有 Array.prototype.reduce() 的数组工具方法,其他编程语言也基本都有类似 reduce 的说法,当然可能叫做 fold。了解了 reduce 的概念后,我们再来看 useReducer 对此概念的应用。

export function useReducer(
  reducerFn: Function,
  initialVal: any,
  ...initialReduction: any
) {
  const bucket = getCurrentBucket();

  if (!bucket) {
    throw new Error(
      'useReducer() only valid inside an Articulated Function or a Custom Hook.'
    );
  }

  if (!(bucket.nextStateSlotIdx in bucket.stateSlots)) {
    const slot: StateSlot = [
      typeof initialVal == 'function' ? initialVal() : initialVal,
      function updateSlot(v: unknown) {
        slot[0] = reducerFn(slot[0], v);
      },
    ];
    bucket.stateSlots[bucket.nextStateSlotIdx] = slot;

    if (initialReduction.length > 0) {
      bucket.stateSlots[bucket.nextStateSlotIdx][1](initialReduction[0]);
    }
  }

  return [...bucket.stateSlots[bucket.nextStateSlotIdx++]];
}

上述代码中,在 getCurrentBucket 中先去通过函数堆栈获取当前调用者的上下文 bucket 对象,若无则代表 useReducer 被错误使用,立即抛错阻止代码执行。后文中所有钩子都有同样的处理,之后不再赘述。

我们看到 bucket 对象有 nextStateSlotIdx 和 stateSlots,这是因为一个函数可能会调用多个 useReducer,比如一个函数内调用三次 useReducer (不考虑 useState)则会存在三个 state slot 如下。

image.png

声明 slot 数组的时候,在数组中的第二项里使用传入的 reducerFn 对值进行计算。使用方式如下:

function hit(amount = 1) {
  const [count, setCount] = useReducer(function reducer(accumulator: number, currentValue: number) {
    return accumulator * currentValue;
  }, 10);

  setCount(amount);

  console.log(`Hit Count: ${count}`);
}

const hitWithContext = createHC(hit);

hitWithContext(2); // Hit count: 10
hitWithContext(4); // Hit count: 20
hitWithContext(8); // Hit count: 80

请注意,这里每次 console log 打印出来的都是上次计算的值。因为 count 导出时,当前的 setCount 还未执行。

实现 useState

了解了 useReducer 的实现。我们会发现 useState 能干的事情,useReducer 也全都可以干。因此势必底层实现也可以用后者实现前者,如下:

export function useState(initialVal: any) {
  const bucket = getCurrentBucket();

  if (!bucket) {
    throw new Error(
      'useState() only valid inside an Articulated Function or a Custom Hook.'
    );
  }

  return useReducer(function reducer(preVal: any, vOrFn: any) {
    return typeof vOrFn == 'function' ? vOrFn(preVal) : vOrFn;
  }, initialVal);
}

发现 useReducer 中传入一个恒等的 reducer 即可实现 useState。假设 vOrFn 并非函数的话,简化来看就是:

useReducer(function reducer(preVal: any, vOrFn: any) {
  return vOrFn;
}, initialVal);

所以你看,是不是很简单。

实现 useRef

useRef 背后也是使用 useState 实现的。请看实现:

export function useRef(initialVal: any) {
  const bucket = getCurrentBucket();

  if (!bucket) {
    throw new Error(
      'useRef() only valid inside an Articulated Function or a Custom Hook.'
    );
  }

  const [ref] = useState({ current: initialVal });
  return ref;
}

这个没什么可讲的。

实现 useMemo

useMemo 的实践一般在于通过缓存减少重复计算量,从而提高应用性能。实现如下:

export function useMemo(func: Function, guards?: Array<any>) {
  let realGuards: Array<any>;

  if (guards && guards.length > 0) {
    realGuards = guards;
  } else {
    realGuards = [func];
  }

  const bucket = getCurrentBucket();

  if (!bucket) {
    throw new Error(
      'useMemo() only valid inside an Articulated Function or a Custom Hook.'
    );
  }

  if (!(bucket.nextMemoizationIdx in bucket.memoizations)) {
    bucket.memoizations[bucket.nextMemoizationIdx] = [];
  }

  const memoization = bucket.memoizations[bucket.nextMemoizationIdx];

  if (guardsChanged(memoization[1], realGuards)) {
    try {
      memoization[0] = func();
    } finally {
      memoization[1] = realGuards;
    }
  }

  bucket.nextMemoizationIdx++;

  return memoization[0];
}

先收集当前 guards 依赖,从 memoizations 中取得上次的依赖,通过 guardsChange 算法判断是否依赖更新,若无更新则使用上次计算的值,若更新了则重新计算。

memoization 数组格式为 [value, guards]。须知第一次初始化依赖时,此时没有上次的依赖,但又产生了新依赖,所以必然会调用一次计算 memoization[0] = func()。

至于 guardsChange 算法实现如下:

function guardsChanged(guards1: any, guards2: any): boolean {
  if (guards1 === undefined || guards2 === undefined) {
    return true;
  }

  if (guards1.length !== guards2.length) {
    return true;
  }

  for (let [idx, guard] of guards1.entries()) {
    if (!Object.is(guard, guards2[idx])) {
      return true;
    }
  }

  return false;
}

实现 useCallback

和 useRef -> useState -> useReducer 一样,useCallback 也可以使用 useMemo 实现,说白了其实不过是 Hooks 语法糖。

export function useCallback(func: Function, guards?: Array<any>) {
  const bucket = getCurrentBucket();

  if (!bucket) {
    throw new Error(
      'useCallback() only valid inside an Articulated Function or a Custom Hook.'
    );
  }

  return useMemo(function callback() {
    return func;
  }, guards);
}

可以理解为在 useMemo 传入了一个高阶函数用以返回 func,从而保留了函数本身,而不是函数调用后的返回值。不得不再次感叹高阶函数的威力。

实现 useEffect

useEffect 是我们很常用的 Hook 函数。这里需要留意的是 useEffect 会在当前函数执行完后再执行(参加上下文处理中的 runEffects)。同时,假如存在上一次 cleanup 函数(即为清理副作用的函数),则会优先执行,然后再执行当前这次的副作用函数。

代码实现如下:

export function useEffect(func: Function, guards?: Array<any>) {
  const bucket = getCurrentBucket();

  if (!bucket) {
    throw new Error(
      'useEffect() only valid inside an Articulated Function or a Custom Hook.'
    );
  }

  if (!(bucket.nextEffectIdx in bucket.effects)) {
    bucket.effects[bucket.nextEffectIdx] = [undefined, undefined];
  }

  const effectIdx = bucket.nextEffectIdx;
  const effect = bucket.effects[effectIdx];

  if (guardsChanged(effect[1], guards)) {
    effect[0] = function effect() {
      if (typeof bucket.cleanups[effectIdx] === 'function') {
        try {
          bucket.cleanups[effectIdx]();
        } finally {
          bucket.cleanups[effectIdx] = undefined;
        }
      }

      const ret = func();

      if (typeof ret === 'function') {
        bucket.cleanups[effectIdx] = ret;
      }
    };
    effect[1] = guards;
  }
  bucket.nextEffectIdx++;
}

回到 React

到这里,基本的 Hook 实现原理已经介绍完毕。

但是,如何将上述的 Hook 逻辑实现在一个 UI 框架中,这是一个值得思考的问题。思想当然都是一致的,但是实现方式绝不止一种,可能数据结构发生了变化,比如使用 Current-Owner 而非上文中的 runtimeStack 记录函数与上下文的映射信息,还有如何在适当时机触发函数组件更新等等,这些本质上和 Hooks 的原理无关了,而是一个 UI 框架的设计问题。

以上,感谢阅读。

参考资料