四十、useRef


面试题:useRef 是干什么的?ref 的工作流程是怎样的?什么叫做 ref 的失控?

先来看下AI

useRef 是 React 提供的一个 Hook,它允许你在组件中创建一个可变的 ref 对象,这个对象在组件的整个生命周期内保持不变。useRef 主要用于获取和操作 DOM 元素或者组件实例。以下是 useRef 获取 DOM 组件的基本步骤:

1. 创建 ref 对象

首先,你需要使用 useRef 创建一个 ref 对象:

const myRef = useRef(null);

在这个例子中,myRef 是一个 ref 对象,它的 current 属性被初始化为 null

2. 将 ref 对象附加到 DOM 元素

接下来,你需要在 JSX 中将这个 ref 对象附加到你想要引用的 DOM 元素上,通过 ref 属性:

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

  // 你可以在这里使用 myRef

  return <div ref={myRef}>Hello, world!</div>;
}

在这个例子中,myRef 被附加到了一个 div 元素上。当这个 div 元素被渲染到 DOM 中时,myRef.current 将指向这个 div 元素。

3. 访问 DOM 元素

现在,你可以通过 myRef.current 访问这个 DOM 元素:

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

  // 例如,在组件挂载后聚焦输入框
  useEffect(() => {
    if (myRef.current) {
      myRef.current.focus();
    }
  }, []);

  return <input ref={myRef} type="text" />;
}

在这个例子中,useEffect 钩子确保在组件挂载后,如果 myRef.current 存在(即 DOM 元素已经被渲染),它将聚焦到这个输入框。

总结

  • 创建一个 ref 对象:使用 useRef 创建一个初始值为 null 的 ref 对象。
  • 附加 ref 对象:在 JSX 中,将 ref 对象通过 ref 属性附加到 DOM 元素上。
  • 访问 DOM 元素:通过 ref 对象的 current 属性来访问和操作 DOM 元素。

通过这种方式,useRef 提供了一种简洁且强大的机制来直接与 DOM 元素交互,这在处理焦点、动画、媒体播放等场景中非常有用。

useRef 各个阶段

ref 是英语 reference(引用)的缩写,在 React 中,开发者可以通过 ref 保存一个对 DOM 的引用。事实上,任何需要被引用的数据,都可以保存在 ref 上。在 React 中,出现过 3 种 ref 引用模式:

  • String 类型(已不推荐使用)
  • 函数类型
  • { current : T }

目前关于创建 ref,类组件推荐使用 createRef 方法,函数组件推荐使用 useRef

用法如下:

const refContainer = useRef(initialValue);

mount 阶段

mount 阶段调用的是 mountRef,对应的代码如下:

function mountRef(initialValue) {
  // 创建 hook 对象
  const hook = mountWorkInProgressHook();
  const ref = { current: initialValue };
  // hook 对象的 memoizedState 值为 { current: initialValue }
  hook.memoizedState = ref;
  return ref;
}

在 mount 阶段,首先调用 mountWorkInProgressHook 方法得到一个 hook 对象,该 hook 对象的 memoizedState 上面会缓存一个键为 current 的对象 { current: initialValue },之后向外部返回该对象。

update 阶段

update 阶段调用的是 updateRef,相关代码如下:

function updateRef(initialValue) {
  // 拿到当前的 hook 对象
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

除了 useRef 以外,createRef 也会创建相同数据结构的 ref:

function createRef(){
  const refObject = {
    current: null
  }
  return refObject;
}

再看看AI

在React的更新阶段,useRef Hook确实会复用上一次的结果,但是这并不会影响你获取更新后的最新DOM元素。这是因为useRef创建的是一个可变的ref对象,它的current属性被设计为可以在组件的整个生命周期内保持不变,并且可以被手动更新。

当你将useRef创建的ref对象附加到DOM元素上时,React会在组件的每次渲染中自动更新这个ref对象的current属性,以确保它始终指向最新的DOM元素。这意味着即使DOM元素在组件的更新过程中发生了变化(例如,由于状态变化或属性变化导致的重新渲染),你仍然可以通过ref.current访问到更新后的最新DOM元素。

更新阶段的useRef

在组件的更新阶段,updateRef函数会被调用,它的工作是返回当前的ref对象。这个函数不会创建一个新的ref对象,而是返回现有的ref对象,即hook.memoizedState。这个ref对象在组件的整个生命周期内是持久的,它的current属性会在每次渲染时更新为最新的DOM元素。

示例说明

假设你有以下组件:

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

  // 例如,在组件更新后打印出最新的DOM元素
  useEffect(() => {
    console.log(myRef.current); // 这将打印出最新的DOM元素
  });

  return <div ref={myRef}>Hello, world!</div>;
}

在这个组件中,即使div元素在组件的更新过程中发生变化,myRef.current仍然会指向最新的div元素。React会自动处理DOM元素的更新,确保myRef.current始终是最新的。

总结

  • useRef在更新阶段会复用上一次的ref对象,但React会自动更新这个对象的current属性,以指向最新的DOM元素。
  • 即使DOM元素在组件的更新过程中发生变化,你仍然可以通过ref.current访问到更新后的最新DOM元素。
  • 这种设计允许你在组件的整个生命周期内安全地引用和操作DOM元素。

ref 的工作流程

ref 创建之后,会挂在 HostComponent 或者 ClassComponent 上面,形成 ref props,例如:

// HostComponent
<div ref={domRef}></div>
  // ClassComponent
  <App ref={comRef}/>

整个 ref 的工作流程分为两个阶段:

  • render 阶段:标记 ref flag
  • commit 阶段:根据所标记的 ref flag,执行 ref 相关的操作


上图中,markRef 表示的就是标记 ref,相关代码如下:

function markRef(current, workInProgress){
  const ref = workInProgress.ref;
  if((current === null && ref !== null) || (current !== null && current.ref !== ref)){
    // 标记 Reg tag
    workInProgress.flags != Ref;
  }
}

有两种情况会标记 ref:

  • mount 阶段并且 ref props 不为空
  • update 阶段并且 ref props 发生了变化

标记完 ref 之后,来到了 commit 阶段,会在 mutation 子阶段执行 ref 的删除操作,删除旧的 ref:

function commitMutationOnEffectOnFiber(finishedWork, root){
  // ...
  if(flags & Ref){
    const current = finishedWork.alternate;
    if(current !== null){
      // 移除旧的 ref
      commitDetachRef(current);
    }
  }
  // ...
}

上面的代码中,commitDetachRef 方法要做的事情就是移除旧的 ref,相关代码如下:

function commitDetachRef(current){
  const currentRef = current.ref;

  if(currentRef !== null){
    if(typeof currentRef === 'function'){
      // 函数类型 ref,执行并传入 null 作为参数
      currentRef(null);
    } else {
      // { current: T } 类型的 ref,重置 current 指向
      currentRef.current = null;
    }
  }
}

删除完成后,会在 Layout 子阶段重新赋值新的 ref,相关代码如下:

function commitLayoutEffectOnFiber(finishedRoot, current, finishedWork, committedLanes){
  // 省略代码
  if(finishedWork.flags & Ref){
    commitAttachRef(finishedWork);
  }
}

对应的方法 commitAttachRef 就是用来重新赋值新 ref 的,相关代码如下:

function commitAttachRef(finishedWork){
  const ref = finishedWork.ref;
  if(ref !== null){
    const instance = finishedWork.stateNode;
    let instanceToUse;
    switch(finishedWork.tag){
      case HostComponent:
        // HostComponent 需要获取对应的 DOM 元素
        instanceToUse = getPublicInstance(instance);
        break;
      default:
        // ClassComponent 使用 FiberNode.stateNode 保存实例
        instanceToUse = instance;
    }

    if(typeof ref === 'function'){
      // 函数类型,执行函数并将实例传入
      let retVal;
      retVal = ref(instanceToUse);
    } else {
      // { current: T } 类型则更新 current 指向
      ref.current = instanceToUse;
    }
  }
}

ref 的失控

当我们使用 ref 保存对 DOM 的引用时,那么就有可能会造成 ref 的失控。

所谓 ref 的失控,开发者通过 ref 操作了 DOM,但是这一行为本来应该是由 React 接管的,两者产生了冲突,这种冲突我们就称之为 ref 的失控。

考虑下面这一段代码:

function App(){
  const inputRef = useRef(null);

  useEffect(()=>{
    // 操作1
    inputRef.current.focus();

    // 操作2
    inputRef.current.getBoundingClientRect();

    // 操作3
    inputRef.current.style.width = '500px';
  }, []);

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

在上面的三个操作中,第三个操作是不推荐的。

React 作为一个视图层框架,接管了大部分和视图相关的操作,这样开发者可以专注于业务上面的开发逻辑。

上面的三个操作中,前面两个并没有被 React 接管,所以当产生这样的操作时,可以百分百确定是来自于开发者的操作。但是在操作三中,并不能确定该操作究竟是 React 的行为还是开发者的行为,甚至两者会产生冲突。

例如我们再聚一个例子:

function App(){
  const [isShow, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button onClick={() => setShow(!isShow)}>React操作DOM</button>
      <button onClick={() => ref.current.remove()}>开发者DOM</button>
      {isShow && <p ref={ref}>Hello</p>}
    </div>
  );
}

上面的代码就是一个典型的 ref 失控的案例。第一个按钮通过 isShow 来控制 p 是否显示,这是 React 的行为,第二个按钮通过 ref 直接拿到了 p 的 DOM 对象,然后进行显隐操作,两者会产生冲突,上面的两个按钮,先点击任意一个,然后再点击另外一个就会报错。

ref 失控的防治

ref 失控的本质:由于开发者通过 ref 操作了 DOM,而这一行为本来应该是由 React 来进行接管的,两者之间发生了冲突而导致的。

因此我们可以从下面两个方面来进行防治:

  • 防:控制 ref 失控的影响范围,使 ref 的失控更加容易被定位
  • 治:从 ref 引用的数据结构入手,尽力避免可能引起的失控操作

在上一章我们介绍过高阶组件,在高阶组件内部是无法将 ref 直接指向 DOM 的,我们需要进行 ref 的转发。可以通过 forwardRef API 进行一个 ref 的转发,将 ref 转发的这个操作,实际上就将 ref 失控的范围控制在了单个组件内,不会出现跨越组件的 ref 失控。

因为是手动的进行 ref 的转发,所以发生 ref 失控的时候,能够更加容易的进行错误的定位

之前我们介绍过 useImperativeHandle 这个 Hook,它可以在使用 ref 时向父组件传递自定义的引用值:

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    focus(){
      realInputRef.current.focus();
    }
  }));
  return <input {...props} ref={realInputRef} />
                           });

在上面的代码中,我们通过 useImperativeHandle 来定制 ref 所引用的内容,那么在外部开发者通过 ref 只能拿到:

{
  focus(){
    realInputRef.current.focus();
  }
}

真题解答

题目:useRef 是干什么的?ref 的工作流程是怎样的?什么叫做 ref 的失控?

参考答案:

useRef 的主要作用就是用来创建 ref 保存对 DOM 元素的引用。在 React 发展过程中,出现过三种 ref 相关的引用模式:

  • String 类型(已不推荐使用)
  • 函数类型
  • { current : T }

目前最为推荐的是在类组件中使用 createRef,函数组件中使用 useRef 来创建 Ref。

当开发者调用 useRef 来创建 ref 时,在 mount 阶段,会创建一个 hook 对象,该 hook 对象的 memoizedState 存储的是 { current: initialValue } 对象,之后向外部返回了这个对象。在 update 阶段就是从 hook 对象的 memoizedState 拿到 { current: initialValue } 对象。

ref 内部的工作流程整体上可以分为两个阶段:

  • render 阶段:标记 Ref flag,对应的内部函数为 markRef
  • commit 阶段:根据 Ref flag,执行 ref 相关的操作,对应的相关函数有 commitDetachRef、commitAttachRef

所谓 ref 的失控,本质是由于开发者通过 ref 操作了 DOM,而这一行为本身是应该由 React 来进行接管的,所以两者之间发生了冲突导致的。

ref 失控的防治主要体现在两个方面:

  • 防:控制 ref 失控影像的范围,使 ref 失控造成的影响更容易被定位,例如使用 forwardRef
  • 治:从 ref 引用的数据结构入手,尽力避免可能引起失控的操作,例如使用 useImperativeHandle

AI对话

在React中,”ref失控”(Ref Unmounting)通常指的是当组件被卸载时,ref引用的DOM元素或组件实例没有被正确清理,导致可能出现的内存泄漏或其他问题。这种情况可能发生在使用useRefcreateRef创建的ref对象上,尤其是在类组件中使用ref属性时。

ref失控的例子

假设我们有一个类组件,它使用ref来引用一个DOM元素,并在componentDidMount生命周期方法中添加了一个事件监听器:

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

  componentDidMount() {
    // 添加事件监听器
    this.myRef.current.addEventListener('click', this.handleClick);
  }

  componentWillUnmount() {
    // 卸载组件时,应该移除事件监听器
    this.myRef.current.removeEventListener('click', this.handleClick);
  }

  handleClick = () => {
    console.log('Clicked!');
  };

  render() {
    return <button ref={this.myRef}>Click me</button>;
  }
}

在这个例子中,如果在组件卸载时没有移除事件监听器,那么this.myRef.current将保持对已卸载的DOM元素的引用,这就是ref失控。即使组件已经被移除,事件监听器仍然存在,如果按钮被点击,handleClick方法仍然会被调用,这可能导致错误或内存泄漏。

避免ref失控

为了避免ref失控,你应该确保在组件卸载时清理所有通过ref设置的引用和事件监听器。在类组件中,这通常在componentWillUnmount生命周期方法中完成。在函数式组件中,可以使用useEffect Hook来处理清理逻辑:

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

  useEffect(() => {
    // 添加事件监听器
    if (myRef.current) {
      myRef.current.addEventListener('click', handleClick);
    }

    // 清理函数
    return () => {
      if (myRef.current) {
        myRef.current.removeEventListener('click', handleClick);
      }
    };
  }, []);

  const handleClick = () => {
    console.log('Clicked!');
  };

  return <button ref={myRef}>Click me</button>;
}

在这个函数式组件的例子中,useEffect Hook在组件挂载时添加事件监听器,并在清理函数中移除它。这样,即使组件被卸载,事件监听器也会被正确清理,避免了ref失控。

总结

  • ref失控是指组件卸载后,ref引用的DOM元素或组件实例没有被正确清理。
  • 避免ref失控的方法是在组件卸载时移除所有通过ref设置的引用和事件监听器。
  • 在类组件中,这通常在componentWillUnmount中完成;在函数式组件中,可以使用useEffect的清理函数来处理。

除了在组件卸载时未正确清理事件监听器之外,还有其他几种情况可能导致ref失控。以下是一些常见的情况及其解决方法:

1. 跨组件引用未清理

当你在父组件中使用ref引用子组件,并且在父组件卸载时未清理这些引用,可能会导致ref失控。

示例:

// 父组件
function ParentComponent() {
  const childRef = useRef(null);

  return (
    <div>
      <ChildComponent ref={childRef} />
    </div>
  );
}

// 子组件
function ChildComponent() {
  return <div>Child</div>;
}

解决方法:

在父组件卸载时,确保清理对子组件的引用:

function ParentComponent() {
  const childRef = useRef(null);

  useEffect(() => {
    return () => {
      // 清理对子组件的引用
      childRef.current = null;
    };
  }, []);

  return (
    <div>
      <ChildComponent ref={childRef} />
    </div>
  );
}

2. 在异步操作中引用ref

在异步操作(如setTimeout、setInterval、异步API调用)中使用ref,但未在组件卸载时清理这些操作,可能导致ref失控。

示例:

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

  useEffect(() => {
    const timer = setTimeout(() => {
      if (myRef.current) {
        console.log(myRef.current);
      }
    }, 1000);

    return () => clearTimeout(timer);
  }, []);

  return <div ref={myRef}>Hello</div>;
}

解决方法:

在清理函数中清除异步操作:

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

  useEffect(() => {
    const timer = setTimeout(() => {
      if (myRef.current) {
        console.log(myRef.current);
      }
    }, 1000);

    return () => {
      clearTimeout(timer);
      // 清理ref
      myRef.current = null;
    };
  }, []);

  return <div ref={myRef}>Hello</div>;
}

3. 在闭包中引用ref

在闭包中引用ref,但未在组件卸载时清理这些闭包,可能导致ref失控。

示例:

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

  const handleClick = () => {
    setTimeout(() => {
      if (myRef.current) {
        console.log(myRef.current);
      }
    }, 1000);
  };

  return <button ref={myRef} onClick={handleClick}>Click me</button>;
}

解决方法:

确保在组件卸载时清理闭包中的引用:

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

  useEffect(() => {
    return () => {
      // 清理ref
      myRef.current = null;
    };
  }, []);

  const handleClick = () => {
    setTimeout(() => {
      if (myRef.current) {
        console.log(myRef.current);
      }
    }, 1000);
  };

  return <button ref={myRef} onClick={handleClick}>Click me</button>;
}

4. 在全局变量中引用ref

将ref存储在全局变量中,但未在组件卸载时清理这些全局变量,可能导致ref失控。

示例:

let globalRef = null;

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

  useEffect(() => {
    globalRef = myRef;
    return () => {
      globalRef = null;
    };
  }, []);

  return <div ref={myRef}>Hello</div>;
}

解决方法:

在组件卸载时清理全局变量:

let globalRef = null;

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

  useEffect(() => {
    globalRef = myRef;
    return () => {
      globalRef = null;
    };
  }, []);

  return <div ref={myRef}>Hello</div>;
}

总结

  • 跨组件引用未清理:确保在父组件卸载时清理对子组件的引用。
  • 在异步操作中引用ref:在清理函数中清除异步操作并清理ref。
  • 在闭包中引用ref:确保在组件卸载时清理闭包中的引用。
  • 在全局变量中引用ref:在组件卸载时清理全局变量。

文章作者: 吴俊杰
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 吴俊杰 !
  目录