从源码学 API 系列之 useRef()

552 阅读4分钟

useRef()

useRef 用于在 react 组件多次渲染中保存同一个引用。该引用可以指向任何类型的 js 值。保存 DOM 节点只是useRef应用的一个特例。要想访问真实的 DOM 节点,我们要结合 react 支持的 ref 属性来完成。该 API 的使用示例代码如下:

import { useRef } from 'react';  

function MyComponent() {  
const refObject = useRef(null);  

function handleClick() {  
    inputRef.current.focus();  
}

return <input ref={refObject} />;
}

ref object 没有任何魔法,它就是一个 plain JavaScript object。它的结构我们可以打印出来看看:

// .......
const refObject = useRef(null);  
console.log('refObject:', refObject);
// ......

结果是:

refObject: {
    current: null
}

当然,本文是「从源码看 API系列」文章。所以,我们还是得从源码的角度看看ref object 是怎么被创建出来的。

ref object 的创建

在我之前的文章(【react】react hook 运行原理解析)中说过,hook 函数其实是有两个生命周期阶段:

  • mount
  • update

useRef 的 mount 阶段,ref object 会被创建出来:

// react/packages/react-reconciler/src/ReactFiberHooks.old.js

function mountRef(initialValue) {
    const hook = mountWorkInProgressHook();

    {
      const ref = {
        current: initialValue,
      };
      hook.memoizedState = ref;
      return ref;
    }
}

上面的代码可以归结为三个步骤:

  1. 创建新的 hook 对象
  2. 创建 ref object
  3. 最后,把 ref object 挂载到 hook 对象上的 memoizedState 属性上。

想更近一步探究 mountWorkInProgressHook() 的原理,可以看我的【react】react hook 运行原理解析

可以看出,ref object 就是一个 plain JavaScript object,没有任何魔法,没有任何惊喜。它的数据结构跟我们打印出来的也是一模一样的。

ref object 的传递

在 「render 阶段」,ref object 作为一个对象的引用,它会在创建之后一路传递出去。先是传递给createElement(),再透传给 ReactElement()来创建 react element:

// react/packages/react/src/ReactElement.js
export function createElement(type, config, children) {
  // ......
  let ref = null;
  // .......

  if (config != null) {
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    // .......
  }
  
  // .......
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
}

最后,ref object 被挂载到 react element 的ref属性上:

// react/packages/react/src/ReactElement.js

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // ......
    ref: ref,
    // ......
  };

 // ......

  return element;
};

到这里,ref object引用被传递的旅程还没有结束。 接下来,react 会进入render 阶段的 work loop。 work loop 又可以分两个子阶段:

  • begin work
  • complete work

react 会在 beging work 的时候根据 react element 去创建 fiber 节点;在complete work 阶段根据 react element 去创建 DOM 节点,并将 DOM 节点存放在 fiber 节点的 stateNode 属性上。

下面就是 react 创建新 fiber 节点的时候把 ref object引用透传并挂载到 fiber 节点的 ref 属性上的源码:

// react/packages/react-reconciler/src/ReactChildFiber.old.js
function createChild(
    returnFiber: Fiber,
    newChild: any,
    lanes: Lanes,
 ): Fiber | null {
 
     // ......
     if (typeof newChild === 'object' && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE: {
          const created = createFiberFromElement(
            newChild,
            returnFiber.mode,
            lanes,
          );
          created.ref = coerceRef(returnFiber, null, newChild);
          created.return = returnFiber;
          return created;
        }
        // ......
    }
    
  // ......
 }

到这里,ref object 对象引用的传递之旅算是结束了。

ref object current 值的填充

接下来,我们得看看ref object 对象引用是在什么时候被填充上具体的值的。本文我们专注于 useRef 应用于 DOM 操作的场景,所以,这个值就是真实的 DOM 节点引用。

走完 render 阶段,react 接着进入同步执行的 commit 阶段。commit 阶段会分为四个子阶段:

  1. beforeMutation
  2. mutation
  3. layout
  4. passive

ref object 对象current属性的赋值是发生在 layout 子阶段:

// react/packages/react-reconciler/src/ReactFiberCommitWork.old.js
function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    // ......
    case ClassComponent: {
      // ......

      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
    }
    // ......
    case HostComponent: {
       // ......

      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
    }
   // ......
  }
}

function safelyAttachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
  try {
    commitAttachRef(current);
  } catch (error) {
    captureCommitPhaseError(current, nearestMountedAncestor, error);
  }
}


  function commitAttachRef(finishedWork) {
    const ref = finishedWork.ref;

    if (ref !== null) {
      const instance = finishedWork.stateNode;
      let instanceToUse;

      switch (finishedWork.tag) {
        case HostComponent:
          instanceToUse = getPublicInstance(instance);
          break;

        default:
          instanceToUse = instance;
      } // Moved outside to ensure DCE works with this flag

      if (typeof ref === "function") {
        let retVal;

        {
          retVal = ref(instanceToUse);
        }
      } else {
        ref.current = instanceToUse;
      }
    }
  }

从上面的源码中,我们可以得到下面的认知:

  1. 只有 hostComponent 和 classComponent 才会通过 ref 属性来承接你的 ref object 对象,最后帮你把 DOM 节点引用挂载到 ref object 对象上来。 functionComponent 是不接受ref属性的。

    严谨来说,functionComponent 默认不接受ref属性。当然,通过React.forwardRef()是能解决这个问题的。关于这一点,官方文档也是有提及的,可以查看: I can’t get a ref to a custom component

  2. react 帮我们填充 ref object 对象值的时候,它并不确保一定是填充 DOM 节点的引用。上面代码中,getPublicInstance(instance) 在生产上的实现是直接返回 instance。因此,我们可以得知,react 统一是用当前 fiber 节点的 stateNode 属性值来填充 ref object 对象的值的。

    而我们知道, stateNode 属性值的类型因 fiber 节点的 tag类型不同而不同的。对于 hostComponent 来说,stateNode 属性的值是「 DOM 节点引用」,而对于 classComponent 来说, stateNode 属性的值是「 classComponent 的一个实例」。 这个实例的自有属性一般有:

·image.png

其中,_reactInterals 属性是进入 react 内部实现的 escape hatch。具体来说,_reactInterals 属性是当前 classComponent 所对应的 fiber 节点。因为 fiber 节点是身处一个由child,sibling,return三个指针所连接起来的链表当中。所以,通过它我们能访问链表上所有的其他 fiber 节点。对于 hostComponent 来说,因为它的 stateNode 属性值是 DOM 节点的引用,因而我们也就能访问到 classComponent 内部的真实的 DOM 节点。

举个例子说,假设某个 classComponent 类型的组件是第三方提供的,它的内部实现如下:

class Test extends Component {
  constructor() {
    super();
  }
  render() {
    return <div>test class component ref</div>;
  }
}

该组件会在 <App> 组件里面消费:

function App() {
  const classComponentRef = useRef();

  return (
    <div className="App">
        <Test ref={classComponentRef}></Test>
    </div>
  );
}

就像上面说的,我们通过 classComponentRef 拿到的是 Test 组件的实例。那如果我们想访问 Test 组件里面的 div DOM 元素呢?如果我现在限制你不能用原生的 DOM 查找 API(document.getElementxxxByxxx()或者document.querySelector()) 来访问,你会有什么其他的方法吗?当前有,这个方法就是上面提到的这个「escape hatch」。具体做法是:

function App() {
  const classComponentRef = useRef();
  const changeText = ()=> { ref.current._reactInternals.child.stateNode.innerText = "我能访问你,并修改你的 innerText ";}

  return (
    <div className="App">
        <Test ref={classComponentRef}></Test>
        <button onClick={changeText}>反模式访问 DOM </button>
    </div>
  );
}

当然,相对于 react 理念来说,这种做法肯定是一种反模式。

  1. 在 modern react 中,组件的 ref 属性只能接受两种类型的值:
    • 函数类型 - <Test ref={(classComponentRef)=> {}}>
    • ref 对象 -
      // ......
      const classComponentRef = useRef();
      // ......
      <Test ref={classComponentRef}></Test>
      

ref object current 值的读取

当我们会在随后的 render 阶段去读取 ref objectcurrent属性值的时候,其实就是代码执行会走到了 useRef hook 函数的 update 阶段。

在 update 阶段,我们先是找到对应的 hook 对象,然后原封不动地从 hook 对象的memoizedState 属性取出来,没有任何多余的其他操作:

// react/packages/react-reconciler/src/ReactFiberHooks.old.js
function updateRef(initialValue) {
    const hook = updateWorkInProgressHook();
    return hook.memoizedState;
}

从源码可以看出,在 update 阶段,initialValue 是被忽略的。

从引用的角度来看,无论是 mount 阶段还是 update 阶段,ref objecthook.memoizedState指向的是同一个引用。

ref 的一个反模式

// 代码来自于 react@19.3.0
function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    let instanceToUse;
    switch (finishedWork.tag) {
      case HostHoistable:
      case HostSingleton:
      case HostComponent:
        instanceToUse = getPublicInstance(finishedWork.stateNode);
        break;
      case ViewTransitionComponent: {
        if (enableViewTransition) {
          const instance: ViewTransitionState = finishedWork.stateNode;
          const props: ViewTransitionProps = finishedWork.memoizedProps;
          const name = getViewTransitionName(props, instance);
          if (instance.ref === null || instance.ref.name !== name) {
            instance.ref = createViewTransitionInstance(name);
          }
          instanceToUse = instance.ref;
          break;
        }
        instanceToUse = finishedWork.stateNode;
        break;
      }
      case Fragment:
        if (enableFragmentRefs) {
          const instance: null | FragmentInstanceType = finishedWork.stateNode;
          if (instance === null) {
            finishedWork.stateNode = createFragmentInstance(finishedWork);
          }
          instanceToUse = finishedWork.stateNode;
          break;
        }
      // Fallthrough
      default:
        instanceToUse = finishedWork.stateNode;
    }
    if (typeof ref === 'function') {
      if (shouldProfile(finishedWork)) {
        try {
          startEffectTimer();
          finishedWork.refCleanup = ref(instanceToUse);
        } finally {
          recordEffectDuration(finishedWork);
        }
      } else {
        finishedWork.refCleanup = ref(instanceToUse);
      }
    } else {
      if (__DEV__) {
        // TODO: We should move these warnings to happen during the render
        // phase (markRef).
        if (typeof ref === 'string') {
          console.error('String refs are no longer supported.');
        } else if (!ref.hasOwnProperty('current')) {
          console.error(
            'Unexpected ref object provided for %s. ' +
              'Use either a ref-setter function or React.createRef().',
            getComponentNameFromFiber(finishedWork),
          );
        }
      }

      // $FlowFixMe[incompatible-use] unable to narrow type to the non-function case
      ref.current = instanceToUse;
    }
  }
}

从用户 ref 值的传递路径到最终挂载具体的 instance 给 ref 的实现代码来看,react 内部对 ref 值的校验只有这么一处:

// 代码来自于 react@19.3.0
function commitAttachRef(finishedWork: Fiber) {
     // ......
     if (__DEV__) {
       // TODO: We should move these warnings to happen during the render
       // phase (markRef).
       if (typeof ref === 'string') {
         console.error('String refs are no longer supported.');
       } else if (!ref.hasOwnProperty('current')) {
         console.error(
           'Unexpected ref object provided for %s. ' +
             'Use either a ref-setter function or React.createRef().',
           getComponentNameFromFiber(finishedWork),
         );
       }
     }

     // $FlowFixMe[incompatible-use] unable to narrow type to the non-function case
     ref.current = instanceToUse;
   }
 }
}

校验点有二:

  • ref 不能是字符串了

  • ref 必须是一个有自有属性 current的对象

    结合 ref 的实现原理,我们完全可以把 ref 的创建提升到函数作用域的外部,而不去使用 react 提供的 createRef()或者 useRef() 这俩个API,我们照样会实现相同的功能。如下面的演示代码:

import { useState } from 'react';
import reactLogo from './assets/react.svg';
import viteLogo from '/vite.svg';
import './App.css';
import { usePrev } from './hook/usePrev';

const fakeRef = {
  current: null,
};

window.fakeRef = fakeRef;

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      {count < 10 && <h1 ref={fakeRef}>Vite + React</h1>}
      <div className="card">
        <button onClick={() => setCount((count) => count + 2)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  );
}

export default App;

你在 console 去打印 fakeRef.current 一样能拿到 H1 的 dom 对象。当 H1 卸载的时候,react 能正常将 fakeRef.current 设置为 null,以防止内存泄露。

以上,是基于我们对源码研究结果得到的一个 ref 应用的反模式,实际生产中是不应该这么干的。通过这个发模式,我们很容易解开useRef()的面纱,加深对它的理解。

总结

从本质的角度来看,react function component 就是一个 js 函数。这个 js 函数会不断地被重复调用。一旦被调用了,组件内部的变量会被重新声明,引用类型的同名变量也因此拿到的是不同的引用。在多次调用中,让同一个变量名保存的是同一个引用,这就是 useRef() 所提供的功能。

实现这个功能的原理就是利用于类似于「全局变量」的原理:ref object保存在组件函数的内部作用域之上的上层作用域中(fiber 节点的 hook 对象上 memoizedState),通过

  • 「传递引用」的方式来「读」
  • 「mutable」的方式来「写」

来保证了 ref object 在 function component 被多次渲染期间的唯一不变性。