IWSR / react-code-debug

react v18 源码分析
7 stars 1 forks source link

React Hooks: useRef 与 Refs #6

Open IWSR opened 2 years ago

IWSR commented 2 years ago

React Hooks: useRef & Refs

说明

  1. 本文将分析以下两点

    1. useRef 是如何存储数据的
    2. Refs 是如何引用到真实 dom 的
  2. 阅读本文需先阅读 React Hooks: hooks 链表

  3. 分析基于以下代码

  import { useRef } from "react";

  function UseRef() {
    const ref1 = useRef(null);
    const ref2 = useRef({
      a: 1,
    });

    const handleClick = () => {
      ref2.current = {
        a: 3
      }
    }

    return (
      <div id="refTest" ref={ref1} onClick = {handleClick}>123</div>
    )
  }

  export default UseRef;

TLNR

  1. useRef 的数据存在其对应的 hook 对象内(memoizedState),且修改数据不会触发页面更新
  2. Refs 与 DOM 绑定的过程发生在 commit 阶段的 layout 小阶段内。

useRef解析

mount 场景下的 useRef

在 mount 场景下对 useRef 进行打点会进入到 mountRef 这一函数内。

function mountRef<T>(initialValue: T): {|current: T|} {
  // hooks 链表的内容,用以创建 hook 对象并挂载到 hooks 链表上
  const hook = mountWorkInProgressHook();   
  if (enableUseRefAccessWarning) {
    const ref = {current: initialValue};
    hook.memoizedState = ref;
    return ref;
  } else {
    // 创建一个对象存储初始值,而初始值就是在 useRef 内传递的参数
    const ref = {current: initialValue};
    // 并将该对象存储到 hook 对象的 memoizedState 属性上
    hook.memoizedState = ref;
    return ref;
  }
}

东西少的可怜,简直没什么好分析的。

update 场景下的 useRef

与其他 hooks 一样,它也存在 update 阶段,因为代码也很简单,就直接粘过来了。

function updateRef<T>(initialValue: T): {|current: T|} {
  // updateWorkInProgressHook 会根据 current 树上对应的 hook 对象
  // 来创建一个新的 hook 对象,ref 的引用地址(存于memoizedState)同样也会被赋值到新 hook 的 memoizedState 上
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

一目了然的代码。当然我们对 useRef 内的值的改动不像 useState 一样需要调用 dispatch,因此也不会调用 scheduleUpdateOnFiber,那么这个改动也不会触发 react 的渲染。

总结

useRef 内传入的值会被赋值给 ref.current 上,且 ref 会被挂载到 useRef 对应的 hook 对象上的 memoizedState 属性上,每一次 update 阶段创建新 hook 对象时都会把老 hook 对象上的 memoizedState 赋值到新 hook 对象上,因此可以保证 ref 的引用地址不变从而保证存储的值不变(也就是说每次渲染返回的 ref 对象都是同一个对象)。且对 ref 内存储的值的改变不会引起 react 的渲染。

Refs

由于 useRef 的内容过少,因此我想再聊一聊例子中的 ref。

不过关于 ref 的调试思路比较复杂,由于 JSX 会被 Babel 转译为 React.createElement。这个函数存在于 packages/react/src/ReactElement.js 中。该函数返回的类型声明为

  {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };

很显然可以看到 ref 是被单独分离出来的,但如果需要进行调试,则需要将目光放到根据 ReactElement 创建 fiber 的过程中,也就是 beginWork 内。不过这里分为两个阶段 —— 1. UseRef 的 beginWork;2. div#refTest 的 beginWork。

UseRef 的 beginWork

在进入 UseRef 的 beginWork 时,会运行 mountIndeterminateComponent

image

  /**
   *  删除了许多不需要的代码
  */
  function mountIndeterminateComponent(...) {
    ...
    /**
     * renderWithHooks 会返回 UseRef 内 return 的结果
     * 也就是该 function component 返回的 ReactElement
     * */
    value = renderWithHooks(...)
    ...
    /**
     * reconcileChildren 传入 ReactElement,用以 diff 与生成 fiber
    */
    reconcileChildren(null, workInProgress, value, renderLanes);
  }

  function renderWithHooks(...) {
    ...
    let children = Component(props, secondArg);
    ...
    return children;
  }

reconcileChildren 这个函数已经是面试的老面孔了,diff 的入口就是它。不过这里不讲 diff,就直接看涉及到 ref 的部分(reconcileChildren -> ChildReconciler -> reconcileSingleElement)

image

很显然在创建完新 fiber 节点后,会在该 fiber.ref 上赋值( coerceRef 返回的其实就是 ReactElement 上的 ref )。

div#refTest 的 beginWork

图片

根据断点的记录会进入 updateHostComponent,该函数内与 ref 相关的代码存在于 markRef。

function markRef(current: Fiber | null, workInProgress: Fiber) {
  // 此处的 ref 为例子内的 ref1
  const ref = workInProgress.ref;
  /**
   * 当满足以下两个条件的任意一个时,会进入执行体
   * 1. 初次更新时,ref 不为空
   * 2. 非初次更新时,WIP 上的 ref 与 current 上的 ref 不相同,也就是发生了变动
  */
  if (
    (current === null && ref !== null) ||
    (current !== null && current.ref !== ref)
  ) {
    // Schedule a Ref effect
    /**
     * 通过位操作在 WIP 的 flags 上标记 Ref,以表示发生了更新
    */
    workInProgress.flags |= Ref;
    if (enableSuspenseLayoutEffectSemantics) {
      workInProgress.flags |= RefStatic;
    }
  }
}

markRef 和它的名称一样,主要是为 fiber 打上存在 ref 的标记。

阶段性总结1

beginWork 内与 ref 相关的逻辑其实就做了两件事

  1. 在创建完 fiber 节点后,会将 ReactElement 上的 ref 属性赋值给 fiber.ref
  2. 如果当前遍历到的 fiber 节点上的 ref 不为空或者其值发生变动,就给这个 fiber 节点打上标记(workInProgress.flags |= Ref;)

refs 如何与 dom 绑定

很显然 refs 的逻辑到这里还没结束,我们完成了对 refs 的初始化,但是赋值呢?我们该怎么把对应的 dom 绑定到 refs 上呢?

解释这个问题我们需要参考官网关于 refs 的描述

React 会在组件挂载时给 current 属性传入 DOM 元素,并在组件卸载时传入 null 值。ref 会在 componentDidMount 或 componentDidUpdate 生命周期钩子触发前更新。

既然描述和生命周期有关系,那只能写一个 class 的例子看看了。

  import React from "react";

  class UseRef extends React.Component {
    constructor(props) {
      super(props);
      this.myRef = React.createRef();
    }

    componentDidMount() {
      console.log(this.myRef);
    }

    render() {
      return <div ref={this.myRef} />;
    }
  }

  export default UseRef;

对 componentDidMount 打上断点,可以看到这样的调用栈

image

可以很明显的看到,componentDidMount 的调用发生在 commit 阶段( commit 阶段的入口就是 commitRootImpl ),而 commit 阶段又分为三个子阶段

  1. beforeMutation 入口为 commitBeforeMutationEffects
  2. mutation 入口为 commitMutationEffects
  3. layout 入口为 commitLayoutEffects

在这三个阶段中,只有在 layout 阶段可以拿到完全处理完之后的 dom 结构。那么 refs 与 dom 绑定的步骤也很适合放在此处处理。

事实上也确实如此

image

对于 refs 的赋值就藏在 commitLayoutEffects 的 commitLayoutEffectOnFiber 内

  function commitLayoutEffectOnFiber(...) {
    ...
    if (!enableSuspenseLayoutEffectSemantics || !offscreenSubtreeWasHidden) {
      if (enableScopeAPI) {
        // TODO: This is a temporary solution that allowed us to transition away
        // from React Flare on www.
        /**
         * finishedWork.flags & Ref
         * & 操作符可表示是否包含特征,如果不包含特征结果则为0
         * 如 0110 表示拥有的所有特征,0010 表示某一个特定特征
         * 需要判断 0110 是否拥有特征 0010,则运行 0110 & 0010 得到结果为 0010
         * 结果为非0,则可以判断 0110 包含了 0010
        */
        if (finishedWork.flags & Ref && finishedWork.tag !== ScopeComponent) {
          commitAttachRef(finishedWork);
        }
      } else {
        if (finishedWork.flags & Ref) {
          commitAttachRef(finishedWork);
        }
      }
    }
    ...
  }

还记得在 beginWork 时给 fiber 打上的标记吗?这个函数就用到了。当 flags 包含 Ref的标记时便执行 commitAttachRef

  function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref; // fiber 上的 ref,此时 ref.current 为 null
  if (ref !== null) {
    const instance = finishedWork.stateNode; // stateNode 存着的是 dom对象
    let instanceToUse;
    switch (finishedWork.tag) {
      case HostComponent:
        instanceToUse = getPublicInstance(instance);
        break;
      default:
        instanceToUse = instance;
    }

    ...

    if (typeof ref === 'function') {
      let retVal;
      ...
      retVal = ref(instanceToUse); // refs 也可以是回调。。。
    } else {

      ref.current = instanceToUse; // 这里就是绑定 dom 的逻辑了,赋值后可以保证 refs 的 dom 为最新。
    }
  }
}

阶段性总结2

refs 与 dom 的绑定发生在 commit 的 layout 阶段。