前言
在经典的 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
的初始值。当参数缺省时 current
为 undefined
。
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 对于 getDerivedStateFromProps 方法稍有不同,但不影响本文讨论的内容。
在类组件中,我们通常的做法是,在构造组件(即 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
)。显然 domRef2
和 domRef1
不是同一个变量。然后 3 秒过去了,定时器被触发 domRef.current.focus()
,那么这里的 domRef
是 domRef1
还是 domRef2
呢?
如果对闭包不熟悉的话,我们打个断点,看看:
显然定时任务内的 domRef
是 domRef1
,即上一次的 domRef
变量,由于 domRef.currect
为 null
,自然会抛出错误。
说了那么多只是为了强调:函数组件内请不要使用
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)
未完待续...