【译】理解React hooks内部原理

1,092 阅读11分钟

通过实践,更好的认识它。

我们都已经听说过它(指Hooks)。这个React16.7中的新的hook系统在社区中引起非常多的争议。我们都已经试用并且测试过它,对于它以及它的潜力都感到非常的兴奋。当你思考hooks的时候,会觉得他们有种魔力,不知为何,React甚至能够在不暴露组件实例的情况下管理你的组件(甚至不使用this关键字)。所以究竟发生了什么,能让React做到那些呢?

今天我将深入hooks使用的内部,以便我们可以更好的理解它。这个神奇特性的问题是一旦出现问题就会很难进行定位,因为它被隐藏在了一个复杂的调用栈背后。因此,通过深入了解React的hook系统,我们将能够在我们遇到这些问题的时候快速解决,甚至是在第一时间避免他们。

在我们开始之前,我想说的是我不是一个React的开发/维护者,所以对于我所说的话应该保持一些怀疑。我确实有非常深入去是使用过React的hook,但总的来收,我不能保证这就是React真正确实这么工作的。这么说吧,我已经尽可能带着证据去斟酌我的描述,并且直接引用React的源代码,试着让我的论证尽可能的有效。

React的hooks系统的粗略示意图代表

首先,让我们理清运行机制,确保hooks是在React作用域的内部被调用的,因为你现在应该已经知道hooks在错误的上下文中是没有意义的:

dispatcher

dispatcher是一个包含hook函数的可共享的对象。它会被动态的分配或者是在渲染ReactDOM的阶段被清理,它将会确保用户不能在React组件的外部使用hooks(见使用)。

通过简单的切换,获取得到dispatcher,在我们渲染跟组件的时候,hooks会被一个叫做enableHooks的标志来被决定是被使用或者是被禁用;这意味着在技术上,我们可以在运行时使用或者禁用hooks。React16.6.X 的版本也有这个实验性质的功能被使用,但其实已经被禁用了。(见使用

当我们执行完渲染工作,我们将dispatcher置为null,从来防止Hooks在ReactDOM的渲染周期外部被意外的使用。 这是一个可以确保用户不会作出愚蠢的事情的机制(见使用)。

dispatcher在每一个和任何一个hook调用时被解析,解析是通过调用一个resolveDispatcher()函数。就是我之前说的那样,如果当前是在React渲染周期的外部,这些应该都是没有意义的,并且React会打印初告警信息:Hooks can only be called inside the body of a function component.(见使用)。

let currentDispatcher
const dispatcherWithoutHooks = { /* ... */ }
const dispatcherWithHooks = { /* ... */ }

function resolveDispatcher() {
  if (currentDispatcher) return currentDispatcher
  throw Error("Hooks can't be called")
}

function useXXX(...args) {
  const dispatcher = resolveDispatcher()
  return dispatcher.useXXX(...args)
}

function renderRoot() {
  currentDispatcher = enableHooks ? dispatcherWithHooks : dispatcherWithoutHooks
  performWork()
  currentDispatcher = null
}
Dispatcher使用的简略版本

现在我们已经对这个封装机制的有了一定的了解,我想我们该进入本文的核心部分-- hooks。首先我想给你们介绍一个新的概念:

Hooks的队列

在内部,hooks是作为节点形式被保存的,并且是以他们被调用的顺序被结合在一起。他们被这样保存是因为hooks不是简单的被创造出来以后就被独立放置。他们有一个机制来确保他们能够是他们应该有的样子。一个Hook有几个特性,我建议你在深入使用前能够将他们记在脑中:

  • 它的初始状态值是在首次渲染的时候被创建。
  • 它的状态值可以非常容易的被更新。
  • React会在之后的渲染中记住hooks的状态。
  • React将会基于调用顺序给你提供正确的state状态值。
  • React将会知道当前的hook属于那个fiber。

相对而言,我们需要重新思考下我们看待组件状态的方式。到目前位置,我们将它看成是一个普通的对象:

{
  foo: 'foo',
  bar: 'bar',
  baz: 'baz',
}
React的state -- 老的方式

但当处理hooks的时候,它应该被看成是一个队列,那里每个节点代表着一个state的简单模型:

{
  memoizedState: 'foo',
  next: {
    memoizedState: 'bar',
    next: {
      memoizedState: 'bar',
      next: null
    }
  }
}
React的state -- 新的方式

一个简单的hook节点的要点可以在这个使用中看到。你将会看到这个hook有一些额外的属性,但是要理解hooks是怎么工作的关键依赖于内部的memoizedStatenext。其余属性都是被useReducer()hook用来缓存已经分发过的actions和基本的状态state,从而reduction的过程在不同的场景中可以作为fallback被重复:

  • baseState - state对象,将会传递给reducer。
  • baseUpdate - 最近的被分发来创建baseState的action。
  • queue - 被分发的action的队列,等待进入reducer。

不幸的是我没有对于reducer hook有很好的理解,因为我没有完成关于它的任何边界场景使用,因此我不能很好的进行详细的描述。我只想说reducer的使用是如此的不一致,甚至是在<ahref='https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/packages/react-reconciler/src/ReactFiberHooks.js:381'>这里,有一条评论是:不确定这些是否是合理的语法;所以我应该在这里确定吗?!

说回hooks,在任意一个和每一个函数组件声明之前,一个被称之为prepareHooks()的函数将被调用,在那里当前的fiber和它的hooks队列中的第一个hook节点将会被存储在全局的变量中。这种方式下,任何时间我们调用一个hook函数(useXXX())它将会知道在哪个上下文中去运行。

let currentlyRenderingFiber
let workInProgressQueue
let currentHook

// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:123
function prepareHooks(recentFiber) {
  currentlyRenderingFiber = workInProgressFiber
  currentHook = recentFiber.memoizedState
}

// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:148
function finishHooks() {
  currentlyRenderingFiber.memoizedState = workInProgressHook
  currentlyRenderingFiber = null
  workInProgressHook = null
  currentHook = null
}

// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:115
function resolveCurrentlyRenderingFiber() {
  if (currentlyRenderingFiber) return currentlyRenderingFiber
  throw Error("Hooks can't be called")
}
// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:267
function createWorkInProgressHook() {
  workInProgressHook = currentHook ? cloneHook(currentHook) : createNewHook()
  currentHook = currentHook.next
  workInProgressHook
}

function useXXX() {
  const fiber = resolveCurrentlyRenderingFiber()
  const hook = createWorkInProgressHook()
  // ...
}

function updateFunctionComponent(recentFiber, workInProgressFiber, Component, props) {
  prepareHooks(recentFiber, workInProgressFiber)
  Component(props)
  finishHooks()
}
简化方式下的Hooks队列实现

一旦一次更新已经完成,一个名叫finishHooks()的函数将会被调用,在那里hooks队列中的第一个节点的引用将会被存储在渲染的fiber中,在memoizedState属性中。这意味着hooks队列和他们的state状态值可以被放在外部护理:


const ChildComponent = () => {
  useState('foo')
  useState('bar')
  useState('baz')

  return null
}

const ParentComponent = () => {
  const childFiberRef = useRef()

  useEffect(() => {
    let hookNode = childFiberRef.current.memoizedState

    assert(hookNode.memoizedState, 'foo')
    hookNode = hooksNode.next
    assert(hookNode.memoizedState, 'bar')
    hookNode = hooksNode.next
    assert(hookNode.memoizedState, 'baz')
  })

  return (
    <ChildComponent ref={childFiberRef} />
  )
}
一种外部读取组件memorized state的方式

让我们更具体一点,说出更具象的hooks,从所有hooks中最普通的开始-- state hook:

State hooks

你将会惊讶的发现,在useStatehook背后使用的是useReducer,并且它仅仅是以简单的通过提前定义reducer处理器的方式来提供这个功能(查看<ahref='https://github.com/facebook/react/blob/5f06576f51ece88d846d01abd2ddd575827c6127/packages/react-reconciler/src/ReactFiberHooks.js#L339'>使用)。这意味着useState返回的结果实际是一个reducer state,一个action dispatcher。我想你应该看下state hook使用的reducer处理器:

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}
State hook reducer, 又称基本的state reducer

因此如预期的一样,我们可以直接用新的state来提供一个action dispatcher;但你会查看那个吗?!我们也可以提供一个dispatcher:一个action函数,接受旧的state,返回新的state。这意味着当你在组件树上向下传递一个state设置器的时候,你可以运行父组件中的state的变更,而不需要通过传入一个不同的prop属性值。举个例子:

const ParentComponent = () => {
  const [name, setName] = useState()
  
  return (
    <ChildComponent toUpperCase={setName} />
  )
}

const ChildComponent = (props) => {
  useEffect(() => {
    props.toUpperCase((state) => state.toUpperCase())
  }, [true])
  
  return null
}
根据旧的值返回一个新的state值

最后,effect hooks -- 这个hook对于组件的声明周期以及它怎么工作的产生了一个重要的影响:

Effect hooks

Effect hooks运行有些许不同,有一层额外的逻辑层,我想来解释一下。再一次,在我深入讲解前, 这里有一些关于effect hooks的特性,我希望你们能够记在脑中:

  • 他们在每次渲染(render)的时候被创建,但是在绘制(painting)以后运行。
  • 如果有的话,他们将在下一次绘制(painting)之前被销毁。
  • 他们以他们定义的顺序被调用。

注意的是我这里使用的是“绘制”(painting)这个词,而不是“渲染”(rendering)。这两个是不同的东西,在最近的 React Conf 上,我见到了许多的演讲者都使用错了他们!甚至在官方的 React 文档 ,说的是“在渲染(render)以后提交到屏幕上”,这有点像是“绘制(painting)”。render方法只是创建了fiber节点,但还没有绘制任何东西。

因此,那里应该有另外的额外的队列来存放这些副作用,并且应该在绘制(painting)以后来处理。通常来讲,一个fiber存放一个包含副作用节点的队列。副作用是一个不同的类型,并且应该在合适的阶段来处理:

  • 在更新之前唤起getSnapshotBeforeUpdate()的实例。(查看使用
  • 执行所有的宿主插入,更新,删除和实例的销毁。(查看使用
  • 执行所有的生命周期和实例的回调函数。生命周期是作为一个独立的部分运行,因此所有的替换、更新和整棵树上的删除动作都已经被唤起。这个运行也会触发任一一个渲染中的副作用的初始化。(查看使用
  • 通过useEffect()hook预先安排的副作用--在 使用 中也被称之为“passive effect”.(也许我们应该在React社区中开始使用这个称谓?!)

当涉及到hook的副作用,他们应该被存储在fiber中,在一个叫做updateQueue的属性中,每一个作用的节点应该有以下的一些要点((查看使用)):

  • tag -- 一个二进制的数组,用来规定副作用的行为。(后续我将详细的讲诉)
  • create -- 一个回调函数,应该在,绘制(painting)以后执行。
  • destroy --create()返回的回调函数,应该在初始渲染(render)的时候 之前 执行。
  • inputs -- 一组数值的集合,用于决定哪些effect应该被销毁和重新创建。
  • next -- 下一个effect的引用,这是在函数组件中被决定的。

除了tag属性,其他的属性值都比较容易理解。如果你已经很好的学习过hooks,你将会知道React给你提供了一系列的特殊的effect hooks:useMutationEffect()useLayoutEffect()。这两个effects内部都是使用的 useEffect(), 本质上意味着他们也会创建一个effect节点,但是他们是通过使用不同的tag值来实现的。

这个tag值是一系列的二进制值组合。(查看使用

const NoEffect = /*             */ 0b00000000;
const UnmountSnapshot = /*      */ 0b00000010;
const UnmountMutation = /*      */ 0b00000100;
const MountMutation = /*        */ 0b00001000;
const UnmountLayout = /*        */ 0b00010000;
const MountLayout = /*          */ 0b00100000;
const MountPassive = /*         */ 0b01000000;
const UnmountPassive = /*       */ 0b10000000;
React支持的Hook的effect的类型

对于这些二进制值使用最普遍的场景是使用一个管道线(|),并将这些比特加成一个单一的数值。然后我们可以检测一个tag是否执行了一个特定的行为,或者是没有使用一个(&)。如果结果非0,它意味着这个tag执行了特定的行为。

const effectTag = MountPassive | UnmountPassive
assert(effectTag, 0b11000000)
assert(effectTag & MountPassive, 0b10000000)
一个展示怎样使用React二进制设计模式的例子

这里有一些React支持的hook effect的类型以及他们的tags。(查看使用

  • 默认的effect -- UnmountPassive | MountPassive
  • 更新effect -- UnmountSnapshot | MountMutation
  • 布局effect -- UnmountMutation | MountLayout

这里是React怎样检测行为实现的代码。(查看使用

if ((effect.tag & unmountTag) !== NoHookEffect) {
  // Unmount
}
if ((effect.tag & mountTag) !== NoHookEffect) {
  // Mount
}
React实现的实际快照

因此,基于我们已经学习到的effect hooks的内容,我们可以从外部注入一个effect到确切的fiber中:

function injectEffect(fiber) {
  const lastEffect = fiber.updateQueue.lastEffect

  const destroyEffect = () => {
    console.log('on destroy')
  }

  const createEffect = () => {
    console.log('on create')

    return destroy
  }

  const injectedEffect = {
    tag: 0b11000000,
    next: lastEffect.next,
    create: createEffect,
    destroy: destroyEffect,
    inputs: [createEffect],
  }

  lastEffect.next = injectedEffect
}

const ParentComponent = (
  <ChildComponent ref={injectEffect} />
)
view rawreact-hook-effect-injection.js hosted
effects注入的例子

这就是它!你从本文中获取到的最大收获是什么?你准备在你的React应用中怎么使用这个新的知识?非常希望看到有趣的评论!(Me Too!😄)