Skip to content

React(v16.8) Hooks 简析 #12

Description

@blue68

动机

  • 在组件之间复用状态逻辑很难, providers,consumers,高阶组件,render props等可以将横切关注点 (如校验,日志,异常等) 与核心业务逻辑分离开,但是使用过程中也会带来扩展性限制,ref传值问题,“嵌套地狱”等问题;Hook 提供一种简单直接的代码复用方式,可以使开发者在无需修改组件结构的情况下复用状态逻辑。

  • 复杂组件生命周期常常包含一些不相关的逻辑,相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起;很多开发者将 React 与状态管理库结合使用,这往往会引入了很多抽象概念,开发过程中还需要在不同的文件之间来回切换;Hook 提供一种更合理的代码组织方式,可以将组件中相互关联的代码聚集在一起,而不是被生命周期方法强制拆开,使其更加可预测。

  • class 组件存在着一些问题(如:class 不利于代码压缩,并且会使热重载出现不稳定的情况);Hook支持函数组件,使开发者在非class 的情况下可以使用更多的 React 特性。

使用

Hooks 只能在函数组件 (FunctionComponent) 中使用,赋予无实例无生命周期的函数组件以 class 组件的表达力并且更合理地拆分/组织代码,解决复用问题。

实现原理

Fiber 提供了 hooks 实现的基础:hooks 是基于 Fiber 对象上能存储 memoizedState,它以双向链表的形式存储在 Fiber 的 memoizedState 字段中。

什么是 Fiber?

Fiber 把应用树区分成每一个节点的更新,每一个 ReactElement 对应一个 Fiber 对象,Fiber 呈链表结构,串联整个应用树结构 (child, siblings, return),如图:

Fiber Tree

Fiber 会记录节点的各种状态 (state, props)(包括functional Component),并且在 update 的时候,会从原来的 Fiber(current)clone 出一个新的 Fiber(alternate)。两个 Fiber diff 出的变化(side effect)记录在 alternate上,在更新结束后 alternate 会取代之前的 current 的成为新的 current 节点。

Fiber 对 react 渲染机制的改变主要的影响:

  • 异步更新:因为 Fiber 把应用树区来分成每一个节点的更新,它们的更新互相独立,不会有相互的影响,所以可以异步打断现在的更新,然后去等待一个别的任务执行完成之后回过头来继续进行更新。

  • 提供了 hooks 实现的基础:hooks 是基于 Fiber 对象上能存储 memoizedState, 基于
    memoizedState 上可以存储这些东西,一步一步向下构建了 hooks API 的体系。

主要 Hooks

  • 常用的:useState, useEffect, useContext, useReducer;

  • 此外不常用的:useLayoutEffect, useCallback, useMemo, useRef, useImperativeHandle。

以 useState 为例了解 hook 的渲染更新过程
先了解 Hook 的数据结构

export type Hook = {
  memoizedState: any, //上一次渲染的时候的state

  baseState: any, // 当前正在处理的state
  baseUpdate: Update<any, any> | null, // 当前的更新
  queue: UpdateQueue<any, any> | null, // 产生的update放在这个队列里

  next: Hook | null, // 下一个
};

运行下面的组件代码

export default function App() {
  const [name, setName] = useState('dora')

  const nameChange = e => {
    setName(e.target.value)
  }

  return (
    <React.Fragment>
      <input type='text' value={name} onChange={nameChange} />
      <p>{name}</p>
    </React.Fragment>
  )
}

初始化 state 时调用 mountState,初始化 initialState,并且记录在 workInProgressHook.memoizedState 和 workInProgressHook.baseState上,然后创建 queue 对象, queue 的 dispatch 属性是用来记录更新 state 的方法的,dispatch 就是 dispatchAction绑定了对应的 Fiber 和 queue。然后返回初始的格式 [name, setName] = useState('dora');执行源码如下:

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    ((currentlyRenderingFiber: any): Fiber),
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

再来看更新过程

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

由此可见 useState 只是个语法糖,本质就是 useReducer;那么再来看 useReducer:

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  queue.lastRenderedReducer = reducer;
  if (numberOfReRenders > 0) {
    //在当前更新周期中又产生了新的更新
    //就继续执行这些更新直到当前渲染周期中没有更新为止
    ...
  }
  const last = queue.last;
  const baseUpdate = hook.baseUpdate;
  const baseState = hook.baseState;

  let first;
  if (baseUpdate !== null) {
    if (last !== null) {
      last.next = null;
    }
    first = baseUpdate.next;
  } else {
    first = last !== null ? last.next : null;
  }
if (first !== null) {
    let newState = baseState;
    let newBaseState = null;
    let newBaseUpdate = null;
    let prevUpdate = baseUpdate;
    let update = first;
    let didSkip = false;
    do {
      const updateExpirationTime = update.expirationTime;
      if (updateExpirationTime < renderExpirationTime) {
        if (!didSkip) {
          didSkip = true;
          newBaseUpdate = prevUpdate;
          newBaseState = newState;
        }
        if (updateExpirationTime > remainingExpirationTime) {
          remainingExpirationTime = updateExpirationTime;
        }
      } else {
        markRenderEventTimeAndConfig(
          updateExpirationTime,
          update.suspenseConfig,
        );
        if (update.eagerReducer === reducer) {
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      prevUpdate = update;
      update = update.next;
    } while (update !== null && update !== first);
    if (!didSkip) {
      newBaseUpdate = prevUpdate;
      newBaseState = newState;
    }
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }
    hook.memoizedState = newState;
    hook.baseUpdate = newBaseUpdate;
    hook.baseState = newBaseState;
    queue.lastRenderedState = newState;
  }  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

就是根据 reducer 和 update.action 来创建新的 state,并赋值给 Hook.memoizedState以及 Hook.baseState;在当前更新周期中又产生了新的更新, 就继续执行这些更新直到当前渲染周期中没有更新为止。然后对每个更新判断其优先级(根据expirationTime值的大小),如果不是当前整体更新优先级内得更新会跳过,第一个跳过得 Update 会变成新的 baseUpdate,他记录了在之后所有得 Update,即便是优先级比他高得,因为在他被执行得时候,需要保证后续的更新要在他更新之后的基础上再次执行。

最后执行 dispatchAction方法,发起一次 scheduleWork 的调度,完成更新,此处省略代码。

useEffect vs useLayoutEffect

useEffect 和 useLayoutEffect 带给 FunctionalComponent 产生副作用能力的 Hooks,他们的行为非常类似 componentDidMount 和 componentDidUpdate 的合集,并且通过 return 一个函数指定如何“清除”副作用。

先看 useEffect 和 useLayoutEffect 更新的过程:

updateEffect

updateLayoutEffect

两者都调用了 updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps) 方法,传入的第二个参数又作为 pushEffect 的入参生成一个新的 effect。

updateEffectImpl

这个 effect 的 tag 就是入参 hookEffectTag,pushEffect 方法返回一个新的 effect,并且创建了一个 updateQueue,这个 queue 会在 commit 阶段被执行。

可以看到,这个阶段,useEffect 和 useLayoutEffect 的主要区别在生成的 effect 的 tag参数不同,通过计算,两者的tag值分别为二进制:0b11000000 和 0b00100100;

再来看 commit 阶段调用的 commitHookEffectList 方法。

commitHookEffectList

通过对比传入的 effectTag(unmountTag & mountTag) 和 Hook 对象上的 effectTag,判断是否需要执行对应的 destory 和 create 方法,那么又在哪些地方调用了commitHookEffectList 方法呢?可以看下其中两处:commitLifeCycles 和 commitWork。

commitLifeCycles

commitWork

在 commitLifeCycles 中传入的 unmountTag 和 mountTag 值分别为:0b00010000 和 0b00100000;
在 commitWork 中传入的 unmountTag 和 mountTag 值分别为:0b00000100 和 0b00001000;
分别计算 effect.tag & unmountTag 和 effect.tag & mountTag:

conclusion

可以看到 useLayoutEffect 的 destory 会在 commitWork 的时候被执行;而他的 create会在 commitLifeCycles 的时候被执行;useEffect 在这个流程中都不会被执行。

事实上:

  • useLayoutEffect 会在当前 commit 执行的过程中就会被执行 destroy 和 create, 而对于 useEffect,会异步地等到这次所有的 dom 节点更新完成,浏览器渲染完成后,才会去执行这部分代码。

  • 它对于 useLayoutEffect 来说,它是不会去阻塞浏览器的渲染,因为我们可能在 useLayoutEffect 里面去执行一些 dom 相关的操作,甚至 setState 来执行一些更新,这种更新都会同步执行,相当于 react 的运行时它要占用更长的 js 的运行时间,导致浏览器没有时间去渲染,最终可能会导致页面会有些卡顿。

  • 服务端渲染情况下,无论 useLayoutEffect 还是 useEffect 都无法在 Javascript 代码加载完成之前执行。可以通过使用 showChild &&进行条件渲染,并使用 useEffect(() => { setShowChild(true); }, []) 延迟展示组件。

  • useLayoutEffect 的执行过程跟 componentDidMount 和 componentDidUpdate 非常相似,所以 React 官方也说了,如果你一定要选择一个类似于生命周期方法的 Hook,那么 useLayoutEffect 是不会错的那个,但是我们推荐你使用 useEffect,在你清楚他们的区别的前提下,后者是更好的选择。

更多 Hook 使用,请查看以下文档

官方文档
https://zh-hans.reactjs.org/docs/hooks-reference.html

官方源码
https://github.com/facebook/react

作者:朵拉

扫码关注:「铜板街科技」微信公众号,精彩内容定期推送。公众号消息界面回复「推荐」「前端」「Java」「客户端」获取更多精准内容。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions