阅读 5147

我厂实习生写的React Hooks常用API源码解析

本文系字节跳动同组的实习生 @唯心主义蠢货 在实习时阅读 React Hooks 源码总结文档,经本人修改后分享如下:

实习期间接触的大多为React相关的项目,在使用hooks API的过程中经常会遇到一些问题,有的时候是对API的理解上的,有的时候是对为什么会这样而疑惑的,所以花了几天的时间查阅了一些资料翻看了源码,在这里做下总结,也算是一些个人理解的经验分享吧。各个版本的hooks源码可能有些许不同,我们不需关心具体变量名,只关心整体流程和数据结构即可,以下是正文部分:

hooks 要解决的问题

任何一个新技术的出现都是为了解决现存的问题。在正式的进入源码之前,我们先看一下 hooks 要解决什么问题:

  1. Class Component 类组件间复用状态逻辑很难。React之前解决复用问题的思路是:render props 和高阶组件, 缺点是难理解、存在过多的嵌套形成“嵌套地狱”

  2. 复杂的Class Component 会变的难以理解 :类组件的生命周期函数中会充斥着各种状态逻辑和副作用,这些副作用难以复用,且很零散,如在 componentDidMountcomponentDidUpdate 中获取数据,但是在 componentDidMount 中可能也包括很多其他的逻辑,使得组件越开发越臃肿,且逻辑明显扎堆在各种生命周期函数中,使得 React 开发成为了“面向生命周期编程”。

  3. Class Component 难以理解的问题,比如说:

    • this指针问题
    • 组件预编译技术会在class中遇到优化失效的case
    • class不能很好的压缩
    • class在热重载时会出现不稳定的情况

React 团队希望 v16 版本推出的 hook 可以解决掉上面的问题,然而在实现 hook 的时也需要解决掉一些难点:

  • Class Component可以永久存储实例的状态,而 Functional Component 却不能,因为每次重新执行函数 state 都会被重新赋值为0

  • 每一个Class component的实例都拥有一个成员函数this.setState用以改变自身的状态,而Function component只是一个函数,并不能拥有this.setState这种用法,只能通过全局的setState方法,或者其他方法来实现对应。

  • 在某一个组件中,会存在多次hook 的更新调用,我们不希望每一次更新都产生一次渲染,比如说下面:

    setAge(18)
    setAge(19)
    setAge(20)  // 只关心这一次计算出来的值
    复制代码

    我们只关心最后一个更新计算出来的值。

  • ……

为了解决上面的难点,React 团队设计了 Hook架构的核心逻辑:借助闭包、两个链表(一个组件的hook调用链表,每一个 hook 对象都有很多 update 组成的链表queue)和 透传 dispatch 源码,具体会在下面详细讲到 。

在真正进入源码之前,我们先看一下几个核心对象的数据结构,这样在看源码的时候不至于一头雾水。

hook对象的结构

type Hook = {
  memoizedState: any,   // 上次更新之后的最终状态值
  queue: UpdateQueue, //更新队列
  next, // 下一个 hook 对象
};
复制代码

上面的 next 指针是做什么用的呢?

在一个 Functional Component 中可以多次调用 hook ,如同下面一样:

let [name, setName] = useState('')
let [age, setAge] = useState(0)
复制代码

每调用一次 hook 方法,都会生成一个 hook 对象。上面的代码调用了 2次 useState会生成 2个 hook 对象,这2个 hook 是通过 next 指针串联成了一个链表,如下图所示:

update 对象的数据结构

type Update = {
  expirationTime: ExpirationTime,//过期时间
  action: A,//修改动作
  eagerReducer: ((S, A) => S) | null,//下一个reducer
  eagerState: S | null,//下一次的state
  next: Update; | null,//下一个update
};
复制代码

update 对象上记录了数据变更的信息。会发现上面有 next 指针,多个 update 对象也串联成一个链表的结构。

queue 对象的数据结构

上面的update 会组成一个链表, 记录在 queue 对象的 last 属性中,不同版本的 react 的变量名略有差异,新版本的react 是 pending 属性。这里需要注意由于我们需要插入update对象,然后在最后遍历链表进行计算,所以 update 组成的是一个环状链表,last 指向最后一个 update,对应update的next即为第一个update

type queue = {
  last: Update| null,   // 记录了第一个 update 对象
  dispatch,                         // 记录了解构给用户的 dispatch 方法
  lastRenderedReducer,
  lastRenderedState,
};
复制代码

在 react 源码中,上面三个最核心的数据结构:hookqueueupdate 是存在引用串联关系的,如下图所示:

好了,有了对hookqueueupdate 三个最核心的数据结构的认识,我们可以来看源码了。

useState

我们从基础的useState源码看起。

React 中大部分 hook 分为两个阶段: 第一次初始化时 mount 阶段和更新时 update 阶段。

useState 也不例外,对应了两个方法:

  1. mountState
  2. updateState

mountState

我们先看 mountState 方法中做了哪些事情,然后再看源码会清晰一些。

  1. 生成一个 hook 对象,并挂载到 fiber 对象的 memoizedState 属性所指向的链表中
  2. 生成 hook 对象的 memoizedState 属性用来记录更新的值; 生成 hook 对象的 queue 属性,也就是初始化的 update 链表
  3. 生成 dispatch 方法返回给用户。dispatch 就是我们拿到的解构的第二个参数

详细的源码解析如下:

// mountState() 会在 mount 阶段被调用
function mountState(initialState) {
  // 1. 生成一个 hook 对象
  var hook = mountWorkInProgressHook();
  // hook.memoizedState 作用是记录上一次更新的最新值。下一次更新是拿最新值开始计算,而不是最初的值。在 mount 阶段先设置为初始值
  hook.memoizedState = hook.baseState = initialState;
  // 2. 一个 hook 有可能会产生多个 update,通过 queue 对象来记录 update 链表, queue.pending 指向了 update 链表中的第一个, queue.dispatch 记录解构给用户的dispatch
  hook.queue = {
    pending: null,
    dispatch: null,   
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState
  };
  // fiber 和 hook 是一对多的关系,这种关系通过 bind 记录在 dispatch  方法中
  // 3. 调用 .bind() 预注入fiber(当前fiber)和 hook.queue。
  hook.queue.dispatch = dispatchAction.bind(null, fiber, hook.queue);

  return [hook.memoizedState, hook.queue.dispatch];
}
复制代码

上面代码需要注意的是, dispatch 方法是通过dispatchAction.bind() 生成的,然后挂载到了 hook 对象的 queue 属性上面。

用户在初始化时每调用一次 hook api,都会生成一个独一无二、很神奇的 dispatch 方法。

为啥说很神奇呢? 因为 bind() 方法很神奇。bind() 除了可以改变this的指向,还可以预注入参数。

上面代码的这一行操作 dispatchAction.bind(null, fiber, hook.queue), 实际上的作用除了把 this 绑定到了 null 上,同时新生成了一个新方法,新方法提前预注入了 fiber(当前fiber对象)和 hook.queue。当我们在业务代码中调用 setName('aaa') 时,其实真正的调用和入参是 dispatch(fiber, hook.queue, 'aaa')

也就是说,一个 functionalComponent 里面可以调用多次 hook 方法,从而 对应的fiber 对象 和 hook对象 之间存在一对多的关系,这种关系是通过 bind() 预注入参数的方式记录在新生成的函数中,也可以说是 闭包 ,因为新生成的函数中保持对fiberhook这俩变量的引用,从而不会被js引擎的垃圾回收销毁。 hook 对象身上记录了更新信息,从而在 Functional Component 里面实现了数据持久化。

上面的代码,在最开始通过调用 mountWorkInProgressHook() 方法新生成了一个 hook 对象,我们来看一下 mountWorkInProgressHook 方法做了哪些事情。

mountWorkInProgressHook

function mountWorkInProgressHook() {
  // 1. 新建了一个 hook 对象
  var hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null
  };

  // 2. 把新建的 hook 对象挂载到链表上
  if (workInProgressHook === null) {
    // 如果当前没有 hook 链表,则 memoizedState 属性指向 hook
    fiber.memoizedState = workInProgressHook = hook;
  } else {
    // 如果已经存在 hook 链表了,则通过 next 指针串联
    workInProgressHook = workInProgressHook.next = hook;
  }

  return workInProgressHook;
}
复制代码

上面代码中的 fiber memoizedState 属性可能一些同学有困惑,因为memoizedState 对象在 react 源码中很眼熟 hook 对象也有 memoizedState 的属性,他们俩没有一点关系。

hook 对象的 memoizedState 属性记录了上一次更新之后的值,是一个具体的值。

fiber 对象的 memoizedState 属性在不同类型的组件中有不同的含义 ,在 Functional Component 中, fiber memoizedState 属性用来记录hook 链表,指向第一个 hook 对象, 也就是上面的 hook 对象。在Class Component 中, memoizedState 用来记录类组件对应的数据 state

上面代码就是 mountUpdate 的全部逻辑了。在代码的最后,我们返回给用户一个解构的 dispatch 方法用来更新数据,当用户调用 setName 之类方法时,就进入了 updateState 阶段,我们接下来看 updateState 阶段背后发生了什么。

updateState

我们每次执行 dispatch 方法,就会创建一个 update 对象,update 对象记录了此次更新信息, 结构如下:

type Update = {
  expirationTime: ExpirationTime,//过期时间
  suspenseConfig: null | SuspenseConfig,
  action: A,//修改动作
  eagerReducer: ((S, A) => S) | null,    //下一个reducer
  eagerState: S | null, //下一次的state
  next: Update<S, A> | null,//下一个update
}
复制代码

创建的 update 对象会挂载到更新链表 queue 上。

更新链表 queue 是个什么东西呢?

它挂载在对应的 hook 对象的 queue 属性里面的, React采用了链表的数据结构,通过每一个 update 上的 next 指针,把同一个 hook 产生的 update 对象串联起来。当插入第二个update对象时,会将第二个 update 对象的 next 指向头节点。

那么, updateState 究竟做了哪些事情呢?以下为dispatchAction 方法:

// 参数fiber和参数queue 是通过 bind 预传入的两个参数
// action 是用户真正穿过来的值
function dispatchAction(fiber,queue,action) {    
    // 生成一个 update 对象
    const update = {   
      action,
      next: null,    
    };    
    // 将 update 对象添加到循环链表中    
    const last = queue.last;    
    if (last === null) {      
        // 链表为空,将当前更新作为第一个,并保持循环      
        update.next = update;    
    } else {      
        const first = last.next;      
        if (first !== null) {        
        // 在最新的update对象后面插入新的update对象        
            update.next = first;      
        }      
        last.next = update;    
    }    
    // 将表头保持在最新的update对象上    
    queue.last = update;   
    // 进行调度工作    
    scheduleWork(); 
}
复制代码

上面的代码无非做了 3件事情:

  • 生成一个 update 对象
  • 将 update 对象添加到循环链表中
  • 调用 scheduleWork() 进行调度工作

scheduleWork() 之后就进入了 react 调度算法的更新流程,就超出了本文讨论的范围了。

useEffect

useEffect 的使用也是分为mountupdatemount阶段主要是将effect进行挂载,要挂在到两个地方,一个是hooks链,另一个是通过pushEffectuseEffect都收集到updateQueue这个链表上,然后在刷新完成后执行updateQueue的函数。 在update阶段基本同理,只不过增加了一个deps的判断,如果deps没有变化则打上不需要更新的tag,然后在updateQueue的过程中函数不会被执行 img

// react-reconciler/src/ReactFiberHooks.js
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    UpdateEffect | PassiveEffect | PassiveStaticEffect,
    HookPassive,
    create,
    deps,
  );
}
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.effectTag |= fiberEffectTag;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookEffectTag,
    create,
    undefined,
    nextDeps,
  );
}
function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}
复制代码

useMemo 和 useCallback

这两个部分基本同理,mount过程获取存储了初值,update过程根据前后deps的shallow compare,如果发生了变化,则执行新的函数获得新值,或者将值替换为新的值,他们的本质其实是利用了上下文的切换,存在于之前上下文环境的函数或者变量,如果deps变化,则使用或者执行当前上下文环境下的函数。

// 比较相关函数
function is(x: any, y: any) {
  return (
  (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) 
  );
}

for (let i = 0; i<prevDeps.length &&i < nextDeps.length; i++) {
  if (is(nextDeps[i], prevDeps[i])) {
      continue;
  }
  return false;
}
return true
复制代码

useMemo的值在mount时进行缓存,如果deps没有变化的话,就不会更新这个函数,值不会更新。useCallback同理,但是相对有一点理解障碍,自己在使用时一直没有明白为什么函数内的变量不会更新。后来想到因为function component是刷新都会重新执行的,所以当前memorized的函数只会持有对应状态的变量的值,当function重新执行的时候,对于变量的引用不会变,deps更新之后切换上下文,下边写了一个帮助理解的小例子

// useMemo相关 
function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}
function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    // Assume these are defined. If they're not, areHookInputsEqual will warn.
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}


// useCallback相关
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}
复制代码

例子,emmmm这个例子或许不太恰当,大意就是想表达在切换上下文之后,前一次上下文内调用的函数,那个函数内部的变量仍然指向前一次的变量引用,同名变量不会覆盖

let obj = {} 
function area() {
  let b = 666
  const test = () => {
    console.log(b)
  }
  obj.test = test // 在外层缓存 模拟FiberNode上的Hook的memories
}
area()
function area2() {
  let b = 999
  obj.test() // 这里调用area里的test,b指向 666 
}
area2() // 666 
复制代码

useRef

通过以上的例子,不难看出function component在更新时会重新执行函数切换到新的上下文,所以如果想一直持有初始的值,就需要将持有的值放在fiber的memorizeState中,使用的时候再从fiber中获取,所以就有了useRef这个API,源码如下十分简单,这里就不做赘述了。

function mountRef<T>(initialValue: T): {|current: T|} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  if (__DEV__) {
    Object.seal(ref);
  }
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {|current: T|} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}
复制代码

整体结构图

加入我们吧