细读 React | Refs

配图源自 Freepik

在经典的 React 数据流中,props 是父组件与子组件交互的唯一方式,而且 props 是自上而下(由父及子)进行传递的。后来,由于一些全局性的属性需要在各个组件中共享,但鉴于 props 需逐层手动添加极其繁琐,于是 React 提供了一种全新的方式 Context,它无需在组件树中逐层传递 props,属于 Provider/Consumer 模式。

但是,在某些情况下,你需要在典型数据流之外强制修改子组件。被修改的子组件可能是一个 React 组件的实例,也可能是一个 DOM 元素。为此 React 提供了 Refs。

以下场景适合使用 Refs:

  • 管理焦点,文本选择或媒体播放
  • 触发强制动画
  • 集成第三方 DOM 库

我想,Refs 最常用的场景应该是获取某个真实 DOM 元素或者 React 组件实例吧。其实不止于此,还可以用于组件通信等高阶一点的用法。

一、Refs 基础

1. Ref 创建与访问

以下方式可以创建 Ref 对象:

  • React.useRef():适用于函数组件
  • React.createRef():适用于类组件
  • 回调 Ref:可用于函数组件或类组件
  • 字符串 Ref:已过时,不建议使用...

优先选择前两种,若 React 版本较低再考虑后面的两种方式。

React.useRef()

适用于 React 16.8 + 函数组件

React.useRef(initialValue) 方法返回一个 Ref 对象,该对象只有一个 current 属性。其中 initialValue 参数用于指定 current 的初始值。当参数缺省时 currentundefined

import React from 'react'

function Parent() {
  const domRef = React.useRef()
  const classRef = React.useRef()
  const funcRef = React.useRef()

  useEffect(() => {
    console.log(domRef.current) // 指向 div 节点
    console.log(classRef.current) // 指向 Child1 组件实例
    console.log(funcRef.current) // undefined
  }, [])

  return (
    <>
      <div ref={domRef}>这是DOM节点</div>
      <Child1 ref={classRef}>这是类组件</Child1>
      <Child2 ref={funcRef}>这是函数组件</Child2>
    </>
  )
}

我们知道,函数组件每一次更新是通过重新调用函数实现的,意味着里面的变量会重新创建,那么使用 Hook 才能保留上一次的引用。因此,请不要在函数组件内使用 React.createRef()

还有,若在函数组件上设置 ref 属性,由于函数组件是没有实例的,因此类似 <Child2 ref={funcRef} /> 设置 ref 属性是无效的。当你在任意地方访问 funcRef.current 的时候只会得到初始值

在类组件或函数组件内,无论 Ref 对象是通过哪一种方式创建的,只要给子函数组件设置 Refs,开发模式下都会发出如下警告:

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

关于 React.forwardRef() 下文会介绍的。

React.createRef()

适用于 React 16.3 + 类组件。由于函数组件的渲染机制,此方法不适合用于函数组件。若低于 React 16.3 版本,请使用回调 Ref。

React.createRef() 方法返回一个 Ref 对象,该对象只有一个 current 属性,初始值为 null。将来 current 属性会指向 Ref 对象所绑定的 DOM 节点或 React(类)组件。绑定方式很简单,只要将 Ref 对象添加到 ref 属性上即可。

import React from 'react'

class Parent extends React.Component {
  domRef = React.createRef()
  classRef = React.createRef()
  funcRef = React.createRef()

  componentDidMount() {
    console.log(this.domRef.current) // 指向 div 节点
    console.log(this.classRef.current) // 指向 Child1 组件实例
    console.log(this.funcRef.current) // null
  }

  render = () => (
    <>
      <div ref={this.domRef}>这是DOM节点</div>
      <Child1 ref={this.classRef}>这是类组件</Child1>
      <Child2 ref={this.funcRef}>这是函数组件</Child2>
    </>
  )
}

从上述示例中,可以看到 Ref 对象的创建与访问很简单。

回调 Ref

适用于 React 16.2 及以下版本

这种方式可以更精细地控制何时设置和解除 Refs。

它的创建方式不同于 React.createRef()React.useRef(),你需要在 DOM 节点或 React(类)组件中传递一个函数,这个函数接受 React 组件实例或 DOM 节点作为参数,使得它们能在其他地方被存储和访问。

import React from 'react'

class Parent extends React.Component {
  componentDidMount() {
    console.log(this.domRef) // 指向 div 节点
    console.log(this.classRef) // 指向 Child1 组件实例
    console.log(this.funcRef) // undefined
  }

  setCallbackRef(instKey) {
    return ref => {
      // 将函数赋予 ref 属性时,对应的 DOM 节点、类组件实例作为函数参数返回。
      // 作用于函数组件,将不符合任何参数,即 ref 为 undefined。
      this[instKey] = ref
    }
  }

  render = () => (
    <>
      <div ref={this.setCallbackRef('domRef')}>这是DOM节点</div>
      <Child1 ref={this.setCallbackRef('classRef')}>这是类组件</Child1>
      <Child2 ref={this.setCallbackRef('funcRef')}>这是函数组件</Child2>
    </>
  )
}

字符串 Ref

这是一个过时的 Ref,不建议使用。它存在一些问题,可能会在未来的版本中移除。

创建字符串 Ref 非常简单,在 DOM 节点或 React(类)组件的 ref 属性设置为一个字符串即可,它们将会绑定到当前组件实例的 refs 对象下,ref 属性的名称将作为 refs 对象的键名。

import React from 'react'

class Parent extends React.Component {
  componentDidMount() {
    // 所有字符串 Ref 将会被添加到组件实例的 refs 对象上。
    console.log(this.refs.domRef) // 指向 div 节点
    console.log(this.refs.classRef) // 指向 Child1 组件实例
    console.log(this.refs.funcRef) // undefined
  }

  render = () => (
    <>
      <div ref="domRef">这是DOM节点</div>
      <Child1 ref="classRef">这是类组件</Child1>
      <Child2 ref="funcRef">这是函数组件</Child2>
    </>
  )
}

2. 绑定与解除 Ref

React 16.4 及更高版本的生命周期如下,若不了解,先简单看看,以便于后续理解。

一个 React 组件完整的生命周期包括了 Mounting(挂载)、Updating(更新)、Unmounting(卸载)三个阶段。每一阶段又可以再细分为 Render、Pre-commit、Commit 阶段。例如 constructor() 只存在于 Mounting 的 Render 阶段;Unmounting 只含 Commit 阶段。

注意,React 16.3 对于 get­Derived­State­From­Props 方法稍有不同,但不影响本文讨论的内容。

在类组件中,我们通常的做法是,在构造组件(即 constructor() 方法内)时,将创建的 Ref 对象挂载到实例属性,以便可以在整个组件中引用它们。

例如:

class Comp extends React.Component {
  constructor(props) {
    this.xxxRef = React.createRef()
  }
  
  // 或者
  // xxxRef = React.createRef()
}

以上两种方式,都将 xxxRef 挂载到 Comp 实例上,在组件的任意生命周期方法内都能访问。

好了,前面提到调用 React.createRef() 方法返回 Ref 对象的值为 { current: null },那什么时候才会将 React 组件实例或 DOM 节点绑定到 Ref 对象的 current 属性上呢?

  • 在组件挂载的 Render 阶段绑定 Refs
  • 在组件卸载的 Commit 阶段解除 Refs

例如 React.createRef(),在组件挂载时将组件实例或 DOM 节点关联到 xxxRef.current 上。当组件卸载时,xxxRef.current 又会传入 null,实现解除目的。

至于将 Ref 对象存放在哪,是你的自由,但通常会挂载到组件实例上,方便调用。

3. createRef 与 useRef

前面提到 React.createRef() 不要在函数组件内使用,为什么呢?

举个例子:

import React from 'react'

function Comp() {
  const domRef = React.createRef()
  const [num, setNum] = useState(0)

  const focus = () => domRef.current.focus()
  const update = () => setNum(num + 1)

  return (
    <>
      <input ref={domRef} />
      <button onClick={focus}>聚焦</button>
      <button onClick={update}>点击触发更新 {num}</button>
    </>
  )
}

在上述示例中,我们在函数组件 Comp 中使用 React.createRef() 创建了一个 Ref 对象 domRef,其关联了 input 节点,另外还有一个聚焦按钮,点击时聚焦 input 输入框。而最后一个更新按钮用于触发组件更新。

我们知道,函数组件每一次更新是通过重新调用函数实现的,意味着里面的变量会重新创建。换句话说,每一次 Comp 函数被调用,domRef 都是一个全新的变量。

在这个示例中,“似乎”在函数组件中使用 React.createRef() 也没问题,对吗?虽然 domRef 每次函数执行都会重新创建,但也会关联到 input 节点上,因此点击聚焦按钮触发 domRef.current.focus() 也没问题。

我们来改造一下上面的示例:

import React from 'react'

function Comp() {
  const domRef = createRef()
  const [num, setNum] = useState(0)

  useEffect(() => {
    setNum(1) // 触发一个更新
    setTimeout(() => {
      domRef.current.focus() // 这里能正常聚焦吗?
    }, 3000)
  }, [])

  return <input ref={domRef} />
}

以上示例,将会报错!

原因也很简单。前面说了,每一次更新都会重新执行 Comp() 函数。简单分析一下:

当我们第一次加载 Comp 组件时,创建了一个 domRef 变量(假设称为 domRef1),当 Comp 渲染完毕会执行副作用操作 useEffect 的回调函数,里面的 setNum(1) 将会触发一次更新,并创建了一个异步任务,异步任务中存在对 domRef1 的引用。

然后在下一次渲染之前,Ref 对象会被解除,并传入 null,即 domRef1 = { current: null }

然后执行 Comp() 函数重新渲染,又会创建一个 domRef 变量(假设称为 domRef2)。显然 domRef2domRef1 不是同一个变量。然后 3 秒过去了,定时器被触发 domRef.current.focus(),那么这里的 domRefdomRef1 还是 domRef2 呢?

如果对闭包不熟悉的话,我们打个断点,看看:

显然定时任务内的 domRefdomRef1,即上一次的 domRef 变量,由于 domRef.currectnull,自然会抛出错误。

说了那么多只是为了强调:函数组件内请不要使用 React.createRef()

至于为什么仍引用着 domRef1,原因自然是闭包。

闭包是基于词法作用域书写代码时所产生的自然结果。变量的作用域与函数如何执行没关系,跟如何创建有关系。这就是闭包形成的原因(下面举个例子简单说下,若已理解闭包直接跳过,有兴趣者看)。

var a = 'global'

function foo() {
  var a = 'local'
  function bar() {
    console.log(a) // 无论 bar 何时何地调用,总会打印 local
    // 注意,词法作用域与 this 是两回事,别混淆了。
  }
}

// 当执行函数 foo() ,会创建函数执行上下文(可看作是一个对象,包含了 { AO, Scope, this } 三个属性):
// 
// * AO :它会记住当前函数内声明的变量(含形参)、函数、 arguments (非箭头函数)等。
// * Scope :它是基于当前函数 foo 的 [[scope]] 属性 + 当前执行上下文的 AO 对象组成的,这个就是常说的作用域链。
// * this :它跟函数如何调用有关(但跟闭包没关系,不展开讲述)
// 
// 有一点很重要,就是函数被定义时,其内部属性 [[scope]] 就会记录当前的 Scope 。比如当 bar() 被调用,要查找变量 a ,它先从 bar 执行上下文中的 Scope 查找,发现当前 AO 对象没有,于是往上一层 Scope 中查找并成功找到变量 a ,值为 local ,就停止查找了。
// 
// 回到这个 Comp 例子,它本身只是一个函数而已。每当执行一次 Comp() 函数,会创建一个全新的执行上下文, AO 会记录变量 domRef (当然 num 、 setNum 它也会记录的),
// ...
// 由于词法作用域与函数如何调用没关系,所以你不用管 useEffect 、 useEffect 内部的函数、及其回调函数是如何调用的。你只要清楚内部各种函数没有定义一个名为 domRef 的变量即可。
// ...
// 然后执行到 setTimeout 这行代码,会在 useEffect 回调函数内创建一个匿名的箭头函数,尽管我们没有办法引用它,但 AO 也会记住的。由于 Comp 之后的每个执行上下文中都没有 domRef 变量,所以最终执行匿名箭头函数,寻找变量 domRef 时,总会往作用域链上找到 Comp.[[scope]] ,并从其 AO 对象上找到了 domRef 变量,值为 { current: null } 。

就前面 Comp 的示例,使用回调 Ref 或字符串 Ref 的方式也是不可以的,原因同理。唯有使用 React.useRef() 解决,我就不写 Demo 了,你们都懂。

为什么 React.useRef() 能解决这个问题呢?

原因也很简单,当第一次加载函数组件时,执行 React.useRef() 生成一个 Ref 对象,React 会将其在某个神秘的角落记录起来,后面组件更新再从小黑屋里将原先的 Ref 对象取出来(下文再详解)。

二、Refs 进阶

是的,前面都是 Refs 的基础用法,也是必须要掌握的内容。

那么进阶 Refs 是什么呢?主要是利用 React.forwardRef() API 对 Ref 对象进行转发,它是 React 16.3 新增的特性,称为“Refs 转发”。

1. 转发 Refs 到 DOM 节点

前面介绍的 Refs 都有些缺点:

  • 无法在函数组件上使用 ref 属性。
  • 在类组件上使用 ref 属性,只会得到组件实例。

在以前,如果父组件的 Ref 对象要传递给子组件的某个 DOM 节点或者更下层,唯一方法只有变通地使用特殊的属性名来传递 Ref 对象。自 React 16.3 起,可以使用 React.forwardRef() 方案。例如:

import React from 'react'

class Parent extends React.Component {
  parentRef1 = React.createRef()
  parentRef2 = React.createRef()

  componentDidMount() {
    // 以下这两种方式都可以获取到子组件的 input 节点
    console.log(this.parentRef1.current)
    console.log(this.parentRef2.current)
  }

  render = () => (
    <>
      <div>这是父组件</div>

      {/* 原始方法:使用特殊的属性名来传递 */}
      <Child forwardRef={this.parentRef1}>这是子组件</Child>

      {/* ForwardRef 方法:可以将 Ref 对象直接传入 ref 属性,可以是类组件或函数组件  */}
      <NewChild ref={this.parentRef2}>这是子组件</NewChild>
    </>
  )
}

function Child(props) {
  return (
    <>
      <div>{props.children}</div>
      <input ref={props.forwardRef} placeholder="子组件的input" />
    </>
  )
}


// 第二个参数 ref 只在使用 React.forwardRef 定义组件时存在,函数组件或类组件不接收 ref 参数。
const NewChild = React.forwardRef((props, ref) => (
  <Child {...props} forwardRef={ref} />
))

说真的,这种转发 Refs 个人感觉很鸡肋,是我没 Get 到吗?

2. 高阶组件转发 Refs

高阶组件是参数为组件,返回值为新组件的函数。

高阶组件定义如上,假设我们不对 Refs 进行转发,当我们给高阶组件包装后的新组件添加 ref 属性,显然这个 Ref 对象将会指向高阶组件返回的新组件的实例。这种场景下,Refs 转发就显得很重要了。

import React from 'react'

class Parent extends React.Component {
  parentRef = React.createRef()

  componentDidMount() {
    // 将会获取到 Child 组件
    console.log(this.parentRef.current)
  }

  render = () => (
    <>
      <div>这是父组件</div>
      <NewChild ref={this.parentRef}>这是子组件</NewChild>
    </>
  )
}

class Child extends React.Component {
  render = () => (
    <>
      <div>{this.props.children}</div>
      <input ref={this.props.forwardRef} placeholder="子组件的input" />
    </>
  )
}

function HOCWrapper(Comp) {
  // 这里的高阶组件啥也没做,就单纯做了个转发罢了
  // 为了举例而举例...
  class WrapperComponent extends React.Component {
    render() {
      const { forwardRef, ...others } = this.props
      return <Comp {...others} ref={forwardRef} />
    }
  }

  // 如果这里不做转发,将来对 Comp 作用的 Ref 对象,将指向 WrapperComponent 组件实例
  return React.forwardRef((props, ref) => <WrapperComponent {...props} forwardRef={ref} />)
}

const NewChild = HOCWrapper(Child)

未完待续...

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,204评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,091评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,548评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,657评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,689评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,554评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,302评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,216评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,661评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,851评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,977评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,697评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,306评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,898评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,019评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,138评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,927评论 2 355

推荐阅读更多精彩内容