[译] React Hooks 底层解析

JonesLaura 发布于5月前

[译] React Hooks 底层解析

原文:https://medium.com/the-guild/under-the-hood-of-reacts-hooks-system-eb59638c9dba

对于 React 16.7 中新的 hooks 系统在社区中引起的骚动,我们都有所耳闻了。人们纷纷动手尝试,并为之兴奋不已。一想到 hooks 时它们似乎是某种魔法,React 以某种甚至不用暴露其实例(起码没有用到这个关键词)的手段管理了你的组件。那么 React 究竟捣了什么鬼呢?

今天让我们来深入 React 关于 hooks 的实现以更好地理解它。这个魔法特性的问题就在于一旦其发生了问题是难以调试的,因为它隐藏在了一个复杂的堆栈追踪的背后。因此,深入理解 React 的 hooks 系统,我们就能在遭遇它们时相当快地解决问题,或至少能在早期阶段避免它们。

丑话说在前面,我并不是一名 React 的开发者/维护者,以及我的言论不需要太过当真。我非常深入的研究了 React 的 hooks 系统的实现,但不管怎么说我也不能保证这就是 React 如何工作的真谛。也就是说,我的言论基于 React 的源码,并尽可能地让我的论据可靠。

[译] React Hooks 底层解析

首先,让我们了解一遍确保 hooks 在 React 的作用域内被调用的机制,因为你大概已经知道如果不在正确的上下文中调用,hooks 是没有意义的:

Dispatcher

dispatcher 是一个包含了 hooks 函数的共享对象。它将基于 ReactDOM 的渲染阶段被动态地分配或清理,并且它将确保用户不会超出一个 React 组件去访问 hooks (https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/packages/react-reconciler/src/ReactFiberDispatcher.js#L24)。

hooks 被一个叫做 enableHooks   的标志位变量启用或禁用,在我们刚刚渲染根组件时,判断该标志位并简单的切换到合适的 dispatcher 上;这意味着从技术上来说我们能在运行时启用或禁用 hooks。React 16.6.X 也试验性的实现了该特性, 但实际上是被禁用的 (https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/packages/react-reconciler/src/ReactFiberScheduler.js#L1211).

当我们完成了渲染工作,我们将 dispatcher 作废,这预防了 hooks 被意外地从 ReactDOM 的渲染循环之外访问。该机制将确保用户不出昏招 (https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/packages/react-reconciler/src/ReactFiberScheduler.js#L1376)。

在所有 hook 的每一次调用时,都会用 resolveDispatcher()   获得 dispatcher 的引用。正如我之前所说,在 React 渲染循环之外的访问应该是没有意义的,这种情况下 React 应该打印警告信息: “Hooks can only be called inside the body of a function component” (https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/packages/react/src/ReactHooks.js#L17)。

//react-hooks-dispatcher.js

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
}

我们了解了这个简单的封装机制,让我们移向本文的核心 -- hooks。马上为你介绍一个新概念:

hooks 队列

在帷幕之后,hooks 表现为以其调用顺序被链接在一起的节点(nodes)。它们之所以表现成这样是因为 hooks 并非被简单的创建后就独自行事了。有一个允许它们按身份行事的机制。我想请你在深入其实现之前记住一个 hook 的若干属性:

  • 其初始状态是在初次渲染中被创建的

  • 其状态可以被动态更新

  • React 会在之后的渲染中记住 hook 的状态

  • React 会按照调用顺序提供给你正确的状态

  • React 知道该 hook 是属于哪个 fiber 的

相应的,我们需要重新思考我们看待一个组件的状态的方式了。至今为止我们是将其当作一个 plain object 的:

//react-state-old.js

{
  foo: 'foo',
  bar: 'bar',
  baz: 'baz',
}

但当我们处理 hooks 时应将其视作一个队列,其每个节点都表现为一个单个的状态模型:

//react-state-new.js

{
  memoizedState: 'foo',
  next: {
    memoizedState: 'bar',
    next: {
      memoizedState: 'baz',
      next: null
    }
  }
}

单个 hook 节点的模式可以在实现中看到。你将发现 hook 有一些附加的属性,但理解 hooks 如何工作的关键就潜藏在 memoizedState 和 next 中。其余的属性被 useReducer() hook 特别的用来缓存已分发过的 actions 和基础状态,这样在 useReducer 的遍历过程中相关逻辑就可以在各种情况下作为一个 fallback 被重复执行:

  • baseState :会被传给 reducer 的状态对象

  • baseUpdate :最近一次 dispatch 过的用来创建 baseState 的 action

  • queue :一个 dispatch 过的 actions 列表,等待遍历 reducer

糟糕的是我无法全面领悟 reducer hook,因为我没能设法复现几乎任何一个它的边缘情况,所以也就不展开细说了。我只能说 reducer 的实现是如此的前后矛盾以至于其自己的 一处注释(https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/packages/react-reconciler/src/ReactFiberHooks.js#L381) 中甚至说 “TODO: 不确定这是不是预期的语义...我不记得是为什么了” ;所以我又能如何确定呢?!

回到 hooks,在每个函数组件调用之前,一个叫做 prepareHooks()   的函数先被调用,当前 fiber 和其位于 hooks 队列中的首个 hook 会被存储在全局变量中。通过这种方式,每次我们调用一个 hook 函数( useXXX() )时,它都知道在哪个上下文中运行了。

//react-hooks-queue.js

let currentlyRenderingFiber
let workInProgressQueue
let currentHook

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

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

// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js#L115
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#L267
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()
}

一旦一次更新完成,一个叫做 finishHooks()   的函数就会被调用,一个对 hooks 队列中首个节点的引用将被存储在已渲染的 fiber 的   memoizedState   属性中。这意味着 hooks 队列和它们的状态可被从外部处理:

//react-state-external.js

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} />
  )
}

让我们看看更多的细节并谈谈个别 hooks,从最常用的 state hook 开始:

State hooks

你知道了可能会惊讶,但 useState   hook 在幕后使用了   useReducer   并简单地提供给后者一个预定义的 reducer 处理函数。这意味着从   useState   返回的结果实际上是一个 reducer state 以及一个 action dispatcher。我想让你看看 state hook 使用的 reducer 处理函数:

//react-basic-state-reducer.js

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

所以按照预期,我们可以向 action dispatcher 直接传入新的 state;但你看到了什么?!我们也能传入一个 action 函数,用以处理旧 state 并返回一个新的。 这在官方文档中从未提及(在本文成文之际)并且这有点遗憾因为这特别有用! 这意味着当你已经把 state setter 发往组件树后仍可改变父组件的当前状态,而不用向其传入一个不同的 prop。比如:

//react-state-dispatcher.js

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

const ChildComponent = (props) => {
  useEffect(() => {
    props.toUpperCase((state) => state.toUpperCase())
  }, [true])
  
  return null
}

最后来看看在一个组件的生命周期上施展魔法效果的 — effect hooks,以及它是如何工作的:

Effect hooks

Effect hooks 表现得稍有不同,我也想说说其额外的一个逻辑层。再说一次,在我深入解释实现之前,希望你记住关于 effect hooks 属性的一些事情:

  • 它们在渲染时被创建,但在绘制(painting)之后才运行

  • 如果存在,它们会在下次绘制之前才被销毁

  • 按定义的顺序被调用

注意我使用了术语 “painting” 而不是 “rendering”。这两者截然不同,而我注意到最近许多演说者最近在 React Conf (https://conf.reactjs.org/) 上使用了错误的词语!甚至在官方 React 文档中他们也说 “after the render is committed to the screen”,其实应该是类似 “painting” 的。render() 方法只是创建 fiber 节点但并不绘制任何东西。

相应 地,也应该有另一个额外的队列来保存这些 effects 并能在绘制后被处理。一般来说,一个 fiber 持有一个包含了 effect 节点的队列。每个 effect 都属于一个不同的类型并应该在其相应的阶段被处理:

  • 在突变前调用 getSnapshotBeforeUpdate()   的实例

  • 执行宿主上的所有插入、更新、删除和 ref 卸载

  • 执行所有生命周期和 ref 回调。生命周期作为一个独立发生的阶段,整个树中的所有置入、更新和删除也都会被调用。该阶段也会触发任何特定于渲染器的初始化 effects

  • 由 useEffect()   hook 调度的 effects -- 从源码中可知其称呼为 “passive effects(消极影响)” (我们或许应该在 React 社区中开始用这个术语了?!)

hook effects 应该被存储在 fiber 的 updateQueue   属性上,并且每个 effect 节点应该有如下结构:

  • tag :一个二进制数字,表示该 effect 的行为(稍后我会详述)

  • create :绘制之后应该运行的回调

  • destroy :由   create()   回调返回,应该早于初次渲染运行

  • inputs :一个值的集合,用来决定 effect 是否应该被销毁或重建

  • next :一个对定义在函数组件中的下一个 effect 的引用

除了 tag   属性,其他属性都很易于理解。如果你熟悉 hooks,应该知道 React 提供了一对特殊的 effect hooks: useMutationEffect()   和   useLayoutEffect() 。两者内部都用了   useEffect() ,意味着本质上它们都创建了一个 effect 节点,但它们用了不同的 tag 值。

tag 由一组二进制值构成:

//react-hook-effects-types.js

const NoEffect = /*             */ 0b00000000;
const UnmountSnapshot = /*      */ 0b00000010;
const UnmountMutation = /*      */ 0b00000100;
const MountMutation = /*        */ 0b00001000;
const UnmountLayout = /*        */ 0b00010000;
const MountLayout = /*          */ 0b00100000;
const MountPassive = /*         */ 0b01000000;
const UnmountPassive = /*       */ 0b10000000;

对于这些二进制值最常见的用例会是使用一个通道操作( | )并像单独的值一样增加二进制位。而后我们就可以使用一个   &   符号检查一个 tag 是否实现了一个特定的行为。如果结果非零,就意味着 tag 的实现达到了预期。

//react-bin-design-pattern-test.js

const effectTag = MountPassive | UnmountPassive
assert(effectTag, 0b11000000)
assert(effectTag & MountPassive, 0b10000000)

这是被 React 支持的 hook effect 类型,以及其 tags:

  • Default effect — UnmountPassive | MountPassive.

  • Mutation effect — UnmountSnapshot | MountMutation.

  • Layout effect — UnmountMutation | MountLayout.

并且 React 是这样检查行为实现的:

//react-effect-hooks-real-usage.js

if ((effect.tag & unmountTag) !== NoHookEffect) {
  // Unmount
}
if ((effect.tag & mountTag) !== NoHookEffect) {
  // Mount
}

所以,基于我们以及学过的涉及 effect hooks 的知识,实际上可以从外部向一个特定 fiber 注入一个 effect:

//react-hook-effect-injection.js

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} />
)

大功告成!

--End--

[译] React Hooks 底层解析

搜索 fewelife 关注公众号

转载请注明出处

查看原文: [译] React Hooks 底层解析

  • organicelephant
  • bluepeacock
  • smallswan