Bootstrap

React 的源码与原理解读(十三):Hooks解读之二 useRef

写在专栏开头(叠甲)

  1. 作者并不是前端技术专家,也只是一名喜欢学习新东西的前端技术小白,想要学习源码只是为了应付急转直下的前端行情和找工作的需要,这篇专栏是作者学习的过程中自己的思考和体会,也有很多参考其他教程的部分,如果存在错误或者问题,欢迎向作者指出,作者保证内容 100% 正确,请不要将本专栏作为参考答案。

  2. 本专栏的阅读需要你具有一定的 React 基础、 JavaScript 基础和前端工程化的基础,作者并不会讲解很多基础的知识点,例如:babel 是什么,jsx 的语法是什么,需要时请自行查阅相关资料。

  3. 本专栏很多部分参考了大量其他教程,若有雷同,那是作者抄袭他们的,所以本教程完全开源,你可以当成作者对各类教程进行了整合、总结并加入了自己的理解。

本一节的内容

这个章节主要讲解React 的 useRef 这个 api,我们将从使用和源码两个角度来讲它,介绍它作为组件的全局变量和 DOM 引用两个场景下的使用和源码挂载,以及 React 一个和引用相关的 api —— forwardRef

useRef 的定义

我们首先来看看 useRef 的用法:它返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。

const refContainer = useRef(initialValue);

useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性也不会引发组件重新渲染:

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` 指向已挂载到 DOM 上的文本输入元素
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useRef 的使用场景

以下是几种 useRef 的使用场景:

操作 DOM

这是 useRef 最基础的功能,因为在业务需求中,经常会遇到操作 DOM 的场景,比如:表单获取焦点、通过 DOM 操作实现动画等等。这种情况下就可以使用 useRef 去保存对 DOM 对象的引用。也就是说:当你需要调用 React 外部系统或者浏览器的一些原生 API 的时候,通过 useRef 实现是非常有用的:

function Form() {
  const inputRef = useRef(null);
  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus
      </button>
    <div/>
  );
}

但是你需要注意的是,你的渲染不能依赖这个 ref 的属性,否则它将不能及时更新,我们可以通过下一个使用场景来看这个问题:

作为组件生命周期内的全局变量来使用

根据上面的定义我们可以了解到, useRef 存储的数据在组件的整个生命周期内都有效,也就是说,在组件销毁之前,我们维持了它的状态,我们可以在组件的逻辑中随时使用它。同时,在它的内容被修改时,不会引起组件的重新渲染,并且内容被修改,是会立即生效的。

我们之前讲过, setState 并不是同步的,他会根据 React 的调度选择一批更新一起处理。但是有时候我们有需要马上获取到一个 state 的最新状态,虽然 setState 有回调函数可以帮助我们处理逻辑,但是如果需要多次更新数据的话,冗长的回调函数会让我们的代码可读性很差。

这个时候肯定有读者会说,那我开一个变量不就好了,这个时候我们会看一下这句话, useRef 存储的数据在组件的整个生命周期内都有效,在组件销毁之前,我们维持了它的状态。重绘并不是组件销毁,但是重绘会刷新我们定义在其中的普通变量,而 useRef 存储的数据并没有影响。

我们来看这个例子:

  • 我们定义了一个 countRef 变量为 0 ,使用 handleClick 方法操作它,当我们点击按钮触发这个方法的时候,countRef.current 渲染在页面上的值并不会改变,因为我们的 countRef 的改变不会引起重绘,但是我们的 console 语句打印出来的内容每次都会 + 1
  • 我们又通过 useState 定义了一个 count 变量,以及一个 handleClick2 方法让他 + 1,当我们调用这个方法的时候,我们的组件会重绘,我们设置的 randomFlag 数据会更新(说明我们重新调用了这个 randomFlag ),同时我们展示在页面上的 count 也会 +1
  • 当我们点击几次 handleClick 方法的按钮,然后又点击一次 handleClick2 方法的按钮,此时我们再点击 handleClick 方法,console 出来的值会继续在上次的基础上 +1 ,而不是重置为从 0 开始
  • 但是每次我们重绘我们的组件的时候,我们的 randomFlag 都会重新赋值,也就是触发初始化
export default function Counter() {
  let countRef = useRef(0);
  const [count, setCount] = useState(0);

  function handleClick2() {
    setCount(count + 1);
  }
  function handleClick() {
    countRef.current = countRef.current + 1;
    console.log(countRef.current)
  }
  const randomFlag = Math.floor(Math.random()* 10000000000000)
  return (
	   <div>
			<h1 style={{ color: 'pink' }}>{randomFlag}</h1>
			<button onClick={handleClick}>
		       useRef { countRef.current }
		    </button>
            <button onClick={handleClick2}>
        	   useState { count }
		    </button>
	   </div>
  );
}

useRef 的源码

useRef 的源码非简洁,我们直接来看:

  • mountRef 获取我们传入的初始值,然后把它存到我们的 memoizedState 中,因为我们返回了我们的 ref,它是一个对象,也就是引用类型,所以我们可以直接修改这个 ref 值,它也会作用在 memoizedState

  • updateRef 则是返回我们的 memoizedState

function mountRef<T>(initialValue: T): {| current: T |} {
  const hook = mountWorkInProgressHook();

  // 存储数据,并返回这个数据
  const ref = { current: initialValue };
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {| current: T |} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

组件上 Ref 的挂载

当然,我们的 ref 不只有这么简单的一个定义,根据上面的定义我们已经认识到了 ,我们可以通过在组件上绑定 ref 来操作他的 dom ,那么这个 dom 又是怎么样绑定上去的呢,我们一步一步来看:

首先我们需要回到我们的 element 结构,我们可以看到在我们的 React Element 结构中:之前可能有很多读者会疑惑,为什么我们的 key 和 ref 两个属性要单独分开操作,他们不也是 props 吗,这里我们就明白了,key 之前提过是在 diff 算法中使用的,不参与最后的生成,而 ref 在今天解开问题了,它是我们定义的 useRef 或者 createRef 传入的,用于我们获取真实 DOM 的操作,而这个 ref 属性会在生成我们的 Fiber 的时候同步到我们的 Fiber 中:

const element = {
    $$typeof: REACT_ELEMENT_TYPEtype: type,                    
    key: key,                      
    ref: ref,                      //对组件实例的引用
    props: props,                  
    _owner: owner                  
}

之后在 beginWork 这个阶段,有些组件调用了一个函数:

function markRef(current: Fiber | null, workInProgress: Fiber) {
  const ref = workInProgress.ref;
  if (
    (current === null && ref !== null) ||
    (current !== null && current.ref !== ref)
  ) {
    workInProgress.effectTag |= Ref;
  }
}

而在 completeWork 这个阶段,又有了另一个同名函数:

function markRef(workInProgress: Fiber) {
  workInProgress.effectTag |= Ref;
}

他们的逻辑都是一样的,告诉我们的 React ,这个fiber 节点对应的 DOM 被 ref 了,我们通过 effectTag 中的一个标志位来标识我们的这个 fiber 有没有 ref,这些用于我们之后的逻辑判定要不要进行 ref 的相关操作。

之后我们进入到我们的提交阶段,我们直接来到 commitMutationEffectsOnFiber 函数中,我们之前讲过,不同类型的组件执行逻前,都会调用 recursivelyTraverseMutationEffects 函数进行第一步的处理,它负责进行删除的操作,我们一步步深入,看到它调用了safelyDetachRef 函数,这里有我们需要的逻辑:

  • 它首先获取了我们的当前 Fiber 节点的 ref 属性,它挂载了我们的 useRef
  • 如果它是一个函数类型的 ref,会执行 ref 函数,参数为null,这个函数类型的 ref 就是我们的 class 组件使用 createRef 创造的,执行这个函数函数可以将其内部的值置为空
  • 如果它不是一个 function, 也就是一个带有 current 的 DOM ,我们就把它的 useRef 属性设置为 null,从而清空我们的挂载
function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
  const ref = current.ref;
  if (ref !== null) {
    if (typeof ref === 'function') {
      let retVal;
      try {
        if (
          enableProfilerTimer &&
          enableProfilerCommitHooks &&
          current.mode & ProfileMode
        ) {
          try {
            startLayoutEffectTimer();
            retVal = ref(null);
          } finally {
            recordLayoutEffectDuration(current);
          }
        } else {
          retVal = ref(null);
        }
      } catch (error) {
        captureCommitPhaseError(current, nearestMountedAncestor, error);
      }
    } else {
      ref.current = null;
    }
  }
}

之后我们继续看在 commitLayoutEffect 这个阶段,我们说过,这个阶段会操作我们的真实 DOM,我们直接进入这个函数,我们跳过已经讲解过的部分,我们看到,在函数的最后有这样的操作,如果我们处理的 Fiber 的标记中有 Ref 标记,我们将进入 commitAttachRef 这个函数,它的作用就是把 ref 和我们的 useRef 绑定,当然如果是在类组件中或者一些另外的组件中,我们会通过safelyAttachRef 这个函数来调用 commitAttachRef ,实际上操作是一致的:

 function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
   //.......省略
   // 确保能拿到我们的 dom
   if (!enableSuspenseLayoutEffectSemantics || !offscreenSubtreeWasHidden) {
    if (enableScopeAPI) {
      // 排除 ScopeComponent 是因为之前已经处理了
      if (finishedWork.flags & Ref && finishedWork.tag !== ScopeComponent) {
        commitAttachRef(finishedWork);
      }
    } else {
      if (finishedWork.flags & Ref) {
        commitAttachRef(finishedWork);
      }
    }
  }
}

function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes,
) {
  switch (finishedWork.tag) {
    //....
    // 对于 ScopeComponent ,这里已经处理了 safelyDetachRef 和 safelyAttachRef
    case ScopeComponent: {
      if (enableScopeAPI) {
        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
        commitReconciliationEffects(finishedWork);
        if (flags & Ref) {
          if (current !== null) {
            safelyDetachRef(finishedWork, finishedWork.return);
          }
          safelyAttachRef(finishedWork, finishedWork.return);
        }
        if (flags & Update) {
          const scopeInstance = finishedWork.stateNode;
          prepareScopeUpdate(scopeInstance, finishedWork);
        }
      }
      return;
    }
  }
}

我们来看看这个函数,它的逻辑是:

  • 通过当前 fiber 的 tag 来获取对应的实例:对于 HostComponent ,实例就是获取到的 DOM 节点,其他情况就是 fiber.stateNode
  • 判断 ref 的类型,如果是函数类型,调用 ref 函数并将实例传过去;若不是,则将 ref.current 赋值为该实例
function commitAttachRef(finishedWork: Fiber) {
  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;
    }
    if (enableScopeAPI && finishedWork.tag === ScopeComponent) {
      instanceToUse = instance;
    }
    if (typeof ref === 'function') {
      let retVal;
      if (
        enableProfilerTimer &&
        enableProfilerCommitHooks &&
        finishedWork.mode & ProfileMode
      ) {
        try {
          startLayoutEffectTimer();
          retVal = ref(instanceToUse);
        } finally {
          recordLayoutEffectDuration(finishedWork);
        }
      } else {
        retVal = ref(instanceToUse);
      }
    } else {
      ref.current = instanceToUse;
    }
  }
}

拓展:forwardRef

在 React 中,配合 ref 使用的还有一个forwardRef ,它会创建一个React组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。它在以下两种场景中有用:

  • 将父组件的ref 传递给子组件

如下的例子:没有使用 forwardRef 时,父组件传入子组件ref 属性,此时ref指向的是子组件本身,所以想要获取子组件的 DOM,需要增加 buttonRef 字段

interface IProps {
  buttonRef: any;
}

class Child extends React.Component<IProps> {
  render() {
    return (
      <div>
        <button ref={this.props.buttonRef}> click </button>
      </div>
    );
  }
}

function App() {
  const child = useRef<any>();
  return (
    <div styleName="container">
      <Child buttonRef={child} />
    </div>
  );
}

当我们使用 forwardRef将 ref 转发。这样子组件在 提供内部的 dom 时,不用扩充额外的 ref 字段

const Child = forwardRef((props: any, ref: any) => {
  return (
    <div>
      <button ref={ref}>click</button>
    </div>
  );
});
function App() {
  const child = useRef<any>();
  return (
    <div styleName="container">
      <Child ref={child} />
    </div>
  );
}
  • 高阶组件中使用 forwardRef 转发组件 Ref

我们直接来看一个场景:

有一个高阶组件的作用是打印组件的 props:

function logProps(WrappedComponent) {
  class LogProps extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('old props:', prevProps);
      console.log('new props:', this.props);
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  }
  return LogProps;
}

当其作用于子组件时,因为中间隔了一层,ref 就会传递失败,最终导致父组件调用 ref.current.xxx() 时失败:

import Button from './Button';
const LoggedButton = logProps(Button);

const ref = React.createRef();

// LoggedButton 组件是高阶组件 LogProps。
// 我们的 ref 将指向 LogProps 而不是内部的 Button 组件!
// 这意味着我们不能调用例如 ref.current.xxx() 这样的方法
<LoggedButton label="Click Me" handleClick={handleClick} ref={ref} />;

此时我们就需要在 hoc 中用 forwardRef 再包一层,转发 ref:

function logProps(WrappedComponent) {
  class LogProps extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('old props:', prevProps);
      console.log('new props:', this.props);
    }
    render() {
      const { componentRef, ...rest } = this.props;
      return <WrappedComponent {...rest} ref={this.props.componentRef} />;
    }
  }
  return forwardRef((props, ref) => {
    return <LogProps {...props} componentRef={ref} />;
  });
}

总结

这期我们讲了 useRef 这个 hooks,它可以作为我们在函数生命周期里不用于渲染的全局变量来使用,也可以帮助我们获取 DOM,前者仅仅使用了它的缓存功能,后者需要在渲染的过程中,通过 React Element 的 ref 属性进行初始化。同时我们还明白了 useRef 和 useState 的区别,前者不会使得组件重绘,后者则会,但是后者可以在组件销毁前一致保持状态,全局变量则会在每次绘制时重置。为了解决在高阶组件和父子组件中操作 ref 的麻烦,React 又进入了 forwardRef 方便我们操作,以上就是 ref 相关的内容。

既然提到了 useState ,下一节中我准备讲讲这个我们使用的最多的 hooks ,以及和他相似的 useReducer,敬请关注!

;