1
头图

在使用 React 进行开发过程中,或多或少使用过 Refs 进行 DOM 操作或者访问一些DOM上的API,又或使用 Refs 保存数据。不管怎么说 Refs 总是 React 提供的一大助力,这篇文章主要介绍 Refs 功能和使用场景以及注意事项。希望能增强对 Refs 的理解,掌握好这把利剑。

什么是 Refs?

Refs 是 React 提供的用来保存 object 引用的一个解决方案,在函数式组件使用 useRef 创建一个 ref 对象,ref 对象存在一个可直接修改的 current 属性,内容都是存在 current 上。Refs 使用场景主要分为两个方向,其一是实现 DOM 访问与操控在两次render之间传递数据内容【和state机制有很大不同,下文会有对比介绍】。如果在组件返回的 jsx dom上绑定了 ref 属性,React 在处理 jsx 时会把该dom节点【原生node节点】的引用存储在 ref.current 上。

使用方式

分为三步:

  • 第一步、使用 useRef 创建 ref 对象(useRef 是 FC hooks, class 组件使用 React.createRef() 创建 )
  • 第二步、赋值&使用【操作dom则绑定为dom的ref属性的值,用于保存值的时候传递内容给 ref.current】,
  • 第三步、访问ref内容【进行dom对应的api访问,进行 scroll 、focus等操作。又或者从current中读取保存的数据】最终的目的还是最后访问拿到对应的数据进行操作。下边我们分别用两个小 demo 简单先看看用法,理论和总结在后边一点【熟悉Refs使用的直接跳转: 核心要点】。

Example one 实现点击按钮 focus input 框

import React, { useRef } from "react";

export default function Comp() {
  // 第一步:使用 useRef 创建一个 ref 对象 { current: null }
  const ref = useRef();

  function handleClick() {
    // 第三步:访问到 ref 上存的内容,这里是 input 的node节点
    ref.current.focus();
  }

  // 第二步:赋值 ref
  return (
    <>
      <input ref={ref} />
      <button onClick={handleClick}>开始输入</button>
    </>
  );
}

Example two 实现数据发送3s内撤回功能:在点击发送后3s内如果点击 “取消发送” 则取消本次发送

简单起见我们按钮不实际发送请求,定时 3s 如果3s内点击了 “取消发送”则取消发送。发送功能用提醒 “已发送”代替,出现“已发送”表示执行了发送。

import React, { useRef, useState } from "react";

export default function CompA() {
  // 第一步:使用useRef 创建 ref 对象
  const ref = useRef();
  const [isSending, setIsSending] = useState(false);

  function send() {
    // ...
    window.alert("消息已发送!");
    setIsSending(false);
  }

  function undo() {
    // 第三步: 访问存在 ref 上的 timeout ID, 进行定时取消
    clearTimeout(ref.current);
  }

  function handleClickSendBtn() {
    setIsSending(true);
    // 第二步: 赋值,将 timeout ID 存在 ref 上
    ref.current = setTimeout(send, 3000);
  }

  function handleClickCancelBtn() {
    undo();
    setIsSending(false);
  }

  return (
    <>
      <button onClick={handleClickSendBtn} disabled={isSending}>
        {isSending ? "发送中..." : "发送"}
      </button>
      {isSending && <button onClick={handleClickCancelBtn}>取消发送</button>}
    </>
  );
}

<h3 id='core-point'>Refs 核心要点</h3>

我们通过两个简单 case 演示了一下,DOM 操作 以及用于在两次 re-render 之间传递内容(case 2 传递的内容是 timeout 的ID)。在使用 Refs 的过程中有几点尤其需要注意。

避免重复创建 ref 内容

在使用 useRef 进行创建 ref 时可以传递 null、number 、object 等内容也可以传递初始化函数。React 只会保存一次初始值,并把它带到下一次 render 中。因此在 useRef 在创建ref的时候传递重复的内容是不生效的,如果你认为每次都生成一个新的值赋给ref但是React给你的却是第一次传递的值,这可能不符合你的预期。

import React, { useEffect, useRef, useState } from "react";

export function CompB() {
  // 注意: 这个部分不会每次生成一个新的时间戳,只会采用 mounted 时新建的第一次时间戳。
  const ref = useRef(+new Date());
  
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`第${count}渲染时间:`, +new Date());
    console.log("ref", ref.current);
  });

  function handleClick() {
    // 为了让点击时,更新 state 触发 re-render
    setCount(count + 1);
  }

  return (
    <>
      <button onClick={handleClick}>点击让组件渲染</button>
    </>
  );
}

注意:效果图中初始化的时候会打印两次重复的第0次渲染,是因为 React 在 dev 模式下会执行两遍组件内容,检测组件是否是纯组件。并非代码问题,后续研读源码时会有文章介绍,欢迎关注。

ref.current 存储的内容修改是突变

对于 state 来说,直接修改state不会生效。需要使用 useState 给的第二个返回值来进行修改。而 ref 则是可直接修改 current 属性上的内容,并且修改后可以立即取到值。ref存储的实际就是一个引用,因此是可突变的。

import React, { useRef } from 'react';
export function Comp() {
  const ref = useRef(0);
  
  useEffect(()=>{
    console.log(ref.current); // 0
    // 突变
    ref.current = ref.current + 1;
    console.log(ref.current); // 1
  });
  
  return <div></div>
}

ref 作为数据存储时内容的变化不会引起 re-render

React 组件的 re-render 的触发一般是【state、props、context】中的出现变化引起的。修改 Ref 的内容不会引起组件的 re-render 因此不能用 ref 去干预 React 生成jsx。换句话说就是不能用在jsx中做渲染或者条件判断,不然可能得到没办法预料的jsx结果。

ref 的读写只能在 useEffect 或者回调函数中进行

React 约定 state、props、context 都是一样的就应该输出同样的jsx内容,只要这三个要素不变那么以不同的调用顺序执行组件应该得到同样的结果。要说清楚为什么Ref的读写只能在useEffect和回调函数中,得先铺垫一下React的一些架构知识。

React 架构上分为三个部分【调度器Scheduler、协调器Reconciler、渲染器Renderer】,整体上又是两个阶段【render 阶段,commit阶段】。render 阶段的目的是找出哪些组件需要更新,以及如何更新(这些内容会标记在Fiber节点上)【更新过程可中断可抢占的,高优的更新可抢占优先先执行。这个阶段主要是 Scheduler 负责调度优先级, 协调器负责找出更新的内容并标记好】,commit 阶段的作用用一句话就是【根据 render 阶段标记的结果Fiber上的tag,操作dom,执行 useEffect 以及对应阶段的生命周期函数】。

在 render 阶段会执行组件,如果出现高优更新抢占,那么低优先级的更新在高优更新执行完成后会重新执行一遍【函数式组件也就是个function函数,在函数体中间的执行 ref 写操作会被多次执行】,我们会发现如果ref的赋值操作在这个期间执行了那么组件更新的结果就是不可预期的【未被抢占时ref的结果是1,被抢占1次时是2。这完全是不可预期大的】。而 useEffect 或者回调函数都不是在 render 阶段执行的因此每次更新只执行一次。也就是说ref的读写不能出现在render阶段,就只能写在 useEffect【类组件对应的是生命周期函数,注意不能写在 componentWillxxx 生命周期中,因为 componentWillxxx 生命周期函数执行在 render 阶段】和回调函数中

// bad
function MyComponent() {
  // ...
  // 🚩 Don't write a ref during rendering
  myRef.current = 123;
  // ...
  // 🚩 Don't read a ref during rendering
  return <h1>{myOtherRef.current}</h1>;
}


// good
function MyComponent() {
  // ...
  useEffect(() => {
    // ✅ You can read or write refs in effects
    myRef.current = 123;
  });
  // ...
  function handleClick() {
    // ✅ You can read or write refs in event handlers
    doSomething(myOtherRef.current);
  }
  // ...
}

跨组件传递ref 获取dom时需要借助 forwardRef 包裹组件

React 默认情况下不允许组件访问其他组件的dom节点,因此关闭了直接 props 传递 ref 标记组件的dom这种操作。得借助 React.forwardRef api 传递 实现这种跨组件的dom操作。

import React, { useEffect, useRef, useState, forwardRef } from "react";

export function ParentComp() {
  const childInputRef = useRef(null);

  function handleClick() {
    childInputRef.current?.focus();
  }

  return (
    <>
      <button onClick={handleClick}>编辑</button>
      <ChildComp ref={childInputRef} />
    </>
  );
}

// 使用forwordRef 包裹组件,接受 ref 并转发绑定到对应dom上
const ChildComp = forwardRef((props, ref) => {
  return (
    <div>
      <input {...props} ref={ref} />
    </div>
  );
});

ref 绑定的dom在离屏或者未挂载时ref.current 值会被修改为null

ref 绑定的dom在离屏或者未挂载时ref.current 值会被修改为 null 。如果在组件中间会进行条件渲染,那么需要处理一下判断逻辑,不然代码可能会抛出异常。另外在父组件引用子组件 dom 的场景也应该增加对 null 的判断。至此 Refs 的要点已经介绍完成。

最佳实践

接下来我们接着聊聊什么情况下使用 Refs 比较好,React 官方把 Refs 定义为逃生通道,就是暗示要谨慎使用。

dom 操作相关

  • 如果需要进行焦点管理位置滚动等非破坏性行为以及调用 dom 节点的 api 那么推荐使用 Refs。
  • 如果是为了修改 dom ,比如修改dom属性,标签名称等等,可能会与 React 存在冲突,不推荐这样使用Refs 而是应该换种思路考虑使用 state 进行条件渲染。

用于在两次 render 之间传递数据

  • 如果组件中大部分功能都依赖该数据,那么不应该存放在ref中。
  • 如果数据在jsx中使用,那么不推荐放在ref中, 这会带来问题【详见:ref 的读写只能在 useEffect 或者回调函数中进行】,推荐使用 useState。
  • 想要保存数据并且不希望数据变化时引起组件 re-render, 而只是在回调函数中需要获取到对应内容时,推荐使用 Ref。如 interval id 。

小乌龟快跑
15 声望2 粉丝