听风是风

学或不学,知识都在那里,只增不减。

导航

从零开始的react入门教程(十一),react ref 详解,三种写法与 ref 转发(传递)

壹 ❀ 引

在前面的文章中,我们介绍了react的状态提升,随之引出了redux以及context,其实都说到底都是为了方便管理react的状态,让数据交互与组件通信变得更为简单。我们知道,react属于单向数据流,属性方法都像瀑布的水往下层组件流动,子组件获取父组件的属性也很简单,一个props就能搞定。问题来了,那万一父组件需要获取子组件的属性方法?或者父组件需要直接操作子组件的DOM,这又该如何下手呢?这里就不得不提react中的refs属性,本篇文章将围绕ref用法(三种写法)以及ref转发(传递)展开,文中的例子推荐复制后运行,了解下大致运转过程总是好的,那么本文开始。

贰 ❀ Refs基本用法

react的16.3版本引入了新的refs创建模式,如果要使用refs我们都推荐使用React.createRef或者函数回调模式,下面的例子也会使用React.createRef模式来介绍相关用法,当然对于另外两种模式(回调与字符串)后面也会介绍,以下例子还是基于create-react-app项目,所以大家可以在文中提到的对应文件进行代码修改,然后本地运行项目即可。

贰 ❀ 壹 ref + DOM

react提供了React.createRef来创建一个ref,然后将此ref属性附加到你想操作的DOM以及想获取属性方法的子组件上,我们在index.js文件中添加如下代码:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class Parent extends Component {
    constructor(props) {
        super(props);
        // 创建一个ref,这个ref随便你取什么名字
        this.echoRef = React.createRef();
    }
    componentDidMount(){
        console.log(this.echoRef);
    }
    render() {
        // 这里的ref就是必须这么写了,通过ref属性将this.echoRef与子组件关联
        return <div ref={this.echoRef}>你好啊,echo。</div>
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

控制台执行npm start运行项目,我们提前在componentDidMount中输出了this.echoRef,打开控制台,可以看到输出的是一个对象,对象有个current属性,展开后发现current指向的就是我们添加了this.echoRefdiv元素。所以可以得知,通过this.refName.current能访问到添加了ref属性的DOM元素或者组件。

我们修改上述componentDidMount中的输出代码为this.echoRef.current.innerHtml,打开控制台:

那么问题来了,假设我创建了一个ref,用在了多个元素上会怎么样呢?这里我们修改render内部的代码为:

render() {
    return (
        <Fragment>
            <div ref={this.echoRef}>你好啊,echo。</div>
            <div ref={this.echoRef}>你好啊,时间跳跃。</div>
        </Fragment>
    )
}

保存后查看控制台,你会发现只有后面div的生效了,也就说,ref的绑定就像一个同名的变量赋值,它永远以最后关联的DOM为准,一个ref只能关联一个,无法重复使用。假设需要关联多个,我们完全可以创建多个ref,这一点大家可以自行尝试。

关于上述中,我们使用了Fragment组件,在JavaScript中其实也有DocumentFragment相关概念,意思就是最小文档片段。我们知道render中只接受一个根元素作为最外层DOM,比如如下代码就会报错:

render() {
    return (
        <div ref={this.echoRef}>你好啊,echo。</div>
        <div ref={this.echoRef}>你好啊,时间跳跃。</div>
    )
}

传统做法是添加一个公有的父级div将其包裹起来,比如:

render() {
    return (
        <div>
            <div ref={this.echoRef}>你好啊,echo。</div>
            <div ref={this.echoRef}>你好啊,时间跳跃。</div>
        </div>
    )
}

但这样会产生一层无意义的div结构,虽然对于DOM优化来说影响微乎其微,但能少一层总是好的,我们打开控制台查看Fragment包裹的html结构,如图:

你会发现Fragment并没有产生多余的DOM结构,如果你了解过vue或者微信小程序,reactFragment对标vuetemplate与小程序的block标签,题外话说到这里。

贰 ❀ 贰 ref + Class组件

前面的例子我们将ref加在了DOM上,现在我们试试加在一个子组件上,修改index.js代码为:

import React, { Component, Fragment } from 'react';
import ReactDOM from 'react-dom';

class Parent extends Component {
    constructor(props) {
        super(props);
        // 创建一个ref,这个ref随便你取什么名字
        this.echoRef = React.createRef();
    }
    componentDidMount() {
        console.dir(this.echoRef.current);
        // 这里调用了子组件的方法
        this.echoRef.current.handleClick();
    }
    render() {
        return (
            <Children ref={this.echoRef} userName="echo" />
        )
    }
}

class Children extends Component {
    constructor(props) {
        super(props);
    }
    state = {
        userName: '听风是风'
    }
    // 这个方法给父组件调用
    handleClick = () => { console.log('我在调用子组件的方法。') }
    render() {
        return (
            <div>你好,我是{this.props.userName}。</div>
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

打开控制台,你会发现当前this.echoRef.current访问到的就是我们的Children组件,而且你能看到Children上声明的state以及方法,我们通过this.echoRef.current.handleClick()调用了子组件的方法,因此控制台输出了我在调用子组件的方法。

ref不仅仅能获取子组件的属性,同样能像链式读取那样访问到自己的孙子组件,以及更下层的组件的属性,现在我们再嵌套一层组件Grandson,如下:

import React, { Component, Fragment } from 'react';
import ReactDOM from 'react-dom';

class Parent extends Component {
    constructor(props) {
        super(props);
        // 创建一个ref,这个ref随便你取什么名字
        this.echoRef = React.createRef();
    }
    componentDidMount() {
        console.dir(this.echoRef.current);
        // 这里调用了孙子组件的方法
        this.echoRef.current.timeStepRef.current.handleClick();
    }
    render() {
        return (
            <Children ref={this.echoRef} userName="echo" />
        )
    }
}

class Children extends Component {
    constructor(props) {
        super(props);
        this.timeStepRef = React.createRef();
    }
    state = {
        userName: '听风是风'
    }
    // 这个方法给父组件调用
    handleClick = () => { console.log('我在调用子组件的方法。') }
    render() {
        return (
            <Grandson ref={this.timeStepRef} />
        )
    }
}

class Grandson extends Component {
    constructor(props) {
        super(props)
    }
    // 这个方法给祖父组件使用
    handleClick = () => { console.log('我是给上上层组件使用的方法') }
    render() {
        return (
            <div>你好,我是孙子组件。</div>
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

如上图的对象解构,我们在父级组件通过this.echoRef.current.timeStepRef.current.handleClick();调用了孙子组件的方法,所以不管组件嵌套多少层,只有有定义ref你就一定能向下访问到你想要的属性,当然,这种做法想想就知道非常不好!

贰 ❀ 叁 ref + 函数组件以及ref转发

默认情况来说,ref不能添加在函数组件上,因为函数组件没有实例,如果你按照前面的做法代码会给出警告,比如下面这个例子:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

function Children () {
    return <div>我是子组件</div>;
}
class Parent extends Component {
    constructor(props) {
        super(props);
        // 创建一个ref,这个ref随便你取什么名字
        this.echoRef = React.createRef();
    }
    componentDidMount() {
        console.dir(this.echoRef.current);
    }
    render() {
        return (
            <Children ref={this.echoRef} userName="echo" />
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

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

但是呢,我们是可以在函数组件内,为其它DOM或者组件绑定ref,比如这个例子:

import React, { createRef } from 'react';
import ReactDOM from 'react-dom';

function Children(props, ref) {
    const inputRef =createRef();
    const handleClick = ()=>{
        inputRef.current.focus();
    }
    return (
        <>
            <input ref={inputRef} />
            <button onClick={handleClick}>点我让输入框聚焦</button>
        </>
    );
}
ReactDOM.render(
    <Children />,
    document.getElementById('root')
);

综合来解释,默认情况下,函数组件内部可以使用ref去绑定其它的DOM或者组件,但是如果你要直接给一个子函数组件上添加ref就会出现上面的警告,警告中也给出了解决方案,那就是使用React.forwardRef

PS:上述代码中出现的<></>标签作用与Fragment作用相同,这个做个补充。

我们先来看看如何能在函数组件上添加ref属性而不报警告,看下面这个例子:

import React, { Component, forwardRef, useRef, useImperativeHandle, createRef } from 'react';
import ReactDOM from 'react-dom';

function Children(props, ref) {
    // useRef是一个hook,你只用知道它可以创建ref
    const inputRef = useRef();
    // 你可以通过这种方式创建
    // const inputRef = createRef()
    const sayName = () => {
        console.log(1);
    }
    useImperativeHandle(ref, () => ({
        focus: () => {
            // 这里操作的是input自带的focus方法
            inputRef.current.focus();
        }
    }));
    return <input ref={inputRef} />;
}
// 由于函数组件不能用ref,这里使用`forwardRef`包裹了一层
Children = forwardRef(Children);
class Parent extends Component {
    constructor(props) {
        super(props);
        // 创建一个ref,这个ref随便你取什么名字
        this.echoRef = React.createRef();
    }
    componentDidMount() {
        console.log(this.echoRef);
        this.echoRef.current.focus();
    }
    render() {
        return (
            <Children ref={this.echoRef} />
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

一下子出现了很多奇奇怪怪的方法,别急,我们来解释它们。现在你不用了解这些方法的原理,你只用大致知道它们做了什么,深入的学习应该在更后面,因为这里涉及到了hook的知识。

react在16.8版本新增了hook特性,准确来说,hook颠覆了我们之前Class组件以及生命周期那一套。hook中没有生命周期,所有的组件都是函数组件,很明显,函数组件内是没有this的,那自然没办法从父级组件访问函数组件this(组件实例)上的属性方法。但是假设现在我们就是要操作函数组件的DOM,比如上面的例子。

我们来解释上面的代码,首先forwardRef接受一个函数,此函数支持2个参数props与refprops很好理解,就是上层传递下来的属性,而ref自然也是上层传递下来的ref。比较巧的是我们的Children本身就是个函数,因此我们直接使用forwardRef进行了包裹,可以理解为给组件升了个级。

而在Children内部const inputRef = useRef()这一句,也是使用hook提供的API创建一个ref,他与createRef的原理以及含义上有一定差异,不过这里我们就理解为创建了一个ref

紧接着inputRef与函数组件内部的input相关联,也就是说现在函数组件内是可以直接使用input内置的属性方法。前面说了,函数组件自己内部还是可以使用ref的,只是我们不能直接用ref关联一个函数组件,但是前面我们通过forwardRef给函数组件升了级。

紧接着,我们通过useImperativeHandle将函数组件内部能访问到的input上的属性方法,再次暴露给了父组件,useImperativeHandle中的ref其实就是上层传递的,这里就是通过此方法,将上层ref与函数组件内部产生了关联。我们自己定义了一个focus方法,而这个方法内部执行的却是inputfocus方法,组件内部可以通过inputRef.current.focus访问到input的方法(希望你没绕晕。)

于是我们在父组件中通过this.echoRef.current.focus()访问到了函数组件暴露给它的方法,而这个方法本质执行的其实是input自带的focus方法。

不知道你理解没有,这里我们通过forwardRef帮父组件做了一次转发,父组件其实想访问的就是input的方法,但是函数组件在中间隔了一层,父组件就没法直接拿到,而我们通过useImperativeHandle帮父组件代劳了一次,成功达到了目的。

其实forwardRef除了能让函数组件使用ref外,还有另一种强大的作用就是转发ref

比如A>B>C的组件结构,你在A中创建了一个ref,你希望将这个ref作为props传递给B,然后在B中接受这个ref再去去关联C,以达到在A中可以访问到C的属性。我们可以假想有一个hoc的场景,父组件希望访问B组件,但是B组件被hoc包裹了一层,也就是一个高阶组件。此时你的ref假设绑定在了hoc生成的B,那么ref将访问hoc组件而非B组件。那么怎么让父组件可以访问到这个B组件呢?我们可以借用forwardRef

import React, { Component, forwardRef } from 'react';
import ReactDOM from 'react-dom';


function hocComponent(Component) {
    // 单纯包装了传入的组件,生成了一个新的组件,只是在生成中我们还用了forwardRef在外面包了一层
    return forwardRef((props, ref) => {
        return <Component {...props} ref={ref} />
    })
}
class Parent extends Component {
    constructor(props) {
        super(props);
        // 创建一个ref,这个ref随便你取什么名字
        this.echoRef = React.createRef();
    }
    componentDidMount() {
        console.log(this.echoRef);
        this.echoRef.current.handleClick();
    }
    render() {
        // 传入Children给高阶组件,得到了一个新组件
        const Child = hocComponent(Children);
        return (
            <Child ref={this.echoRef} />
        )
    }
}
class Children extends Component {
    constructor(props) {
        super(props);
    }
    handleClick = () => {
        console.log('给父级调用的方法')
    }
    render() {
        return <>我是子组件啊</>;
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

这个例子就解释了hoc的情况,这个例子相对上面参杂了hook的例子来说应该好理解一点,这里就不多解释。至少到这里,我们解释了forwardRef的两种作用,一是也可以给函数组件绑定ref,第二点就是ref转发,比如hoc包裹,我们绕过绕过高阶组件,拿到高阶组件内部真正的组件属性。

另外!!!A>B>C,假设B是函数组件,我们希望A的ref绑定C从而访问C,其实还有一种做法,就是不要直接ref绑定,而是把ref作为props传递下去后再绑定,这样不管B是不是函数组件,都能成功绑定到C,再来个例子:

import React, { Component, forwardRef } from 'react';
import ReactDOM from 'react-dom';


function Children(props) {
    return (
        // 子组件接受了这个ref,然后再通过ref进行绑定
        <input ref={props.inputRef} />
    );
}

class Parent extends React.Component {
    constructor(props) {
        super(props);
        this.echoRef = React.createRef();
    }
    componentDidMount() {
        console.dir(this.echoRef.current);
    }
    handleClick = () => {
        // 成功访问了子组件下的子组件
        this.echoRef.current.focus();
    }
    render() {
        return (
            <>
                <Children
                    inputRef={this.echoRef}//我们希望把这个ref作为props传递下去
                />
                <button onClick={this.handleClick}>点我聚焦</button>
            </>
        );
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

因为给函数组件上添加ref会警告,那么我们就不用ref,而是把创建的ref作为属性传下去,在子组件中接受后,再绑定给你要访问的DOM或者组件,这样不仅解决了函数组件绑定ref的问题,还搞定了ref转发的问题,一箭双雕。

叁 ❀ 回调模式与字符串模式

上面我们介绍了createRef创建ref的模式,接下来补充函数回调模式与字符串模式,因为用法介绍的比较多了,这里只是介绍写法。

叁 ❀ 壹 回调模式

在第二小节中,我们通过createRef()模式介绍了ref的基本用法与部分使用场景,其实react还支持函数回调的形式来绑定ref,这种模式下不需要借用createRef创建一个ref,而是直接将需要绑定的DOM或者组件传递到函数中进行关联,直接看个例子:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class Children extends Component {
    constructor(props) {
        super(props);
    }
    componentDidMount() {
        console.dir(this.inputRef);
    }
    setRef = (e) => {
        this.inputRef = e;
    }
    // 此方法暴露给父组件
    inputFocus = () => {
        this.inputRef.focus();
    }
    render() {
        return <input ref={this.setRef} />;
    }

}
class Parent extends Component {
    constructor(props) {
        super(props);
    }
    componentDidMount() {
        console.dir(this.echoRef);
        // 拿到子组件内定义的方法
        this.echoRef.inputFocus();
    }
    // 定义绑定ref函数
    setRef = (e) => {
        this.echoRef = e;
    }
    render() {
        return (
            // 这里不再直接绑定ref,而是上述函数
            <Children ref={this.setRef} />
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

在上述代码中我们并没使用API创建ref,而是直接定义了一个函数,此函数接受的参数其实就是你所绑定此方法的DOM或者组件,比如在父组件中,我们通过ref={this.setRef}Children与父组件中的this.echoRef进行了绑定。而在子组件中,我们同样通过此方法绑定了input,最终我们在父组件中通过this.echoRef.inputFocus间接调用了input的方法。

在父子组件的componentDidMount中我们添加了输出信息,打开控制台可以看到父组件成功访问到了子组件,而子组件成功访问到它所绑定的input

上述代码中,我们还是通过ref={callback}的形式进行子组件绑定,另外callback也能作为props传递后,再在子组件中进行绑定,比如:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class Children extends Component {
    constructor(props) {
        super(props);
        // 子组件接受传递的回调进行绑定
        props.setRef(this);
    }
    componentDidMount() {
        console.dir(this.inputRef);
    }
    setRef = (e) => {
        this.inputRef = e;
    }
    // 此方法暴露给父组件
    inputFocus = () => {
        this.inputRef.focus();
    }
    render() {
        return <input ref={this.setRef} />;
    }

}
class Parent extends Component {
    constructor(props) {
        super(props);
    }
    componentDidMount() {
        console.dir(this.echoRef);
        // 拿到子组件内定义的方法
        this.echoRef.inputFocus();
    }
    // 定义绑定ref函数
    setRef = (e) => {
        this.echoRef = e;
    }
    render() {
        return (
            // 这里直接把回调传递下去了
            <Children setRef={this.setRef} />
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

在子组件的constructor中我们通过props.setRef(this)这一句,拿到父组件传递的callback然后绑定了当前实例,从而达到目的。还记得前面提到的A>B>C场景吗,回调模式也能这么玩,再来个例子:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class Children extends Component {
    constructor(props) {
        super(props);
    }
    render() {
        // 父级传递来的props,最后给了input用了
        return <input ref={this.props.setRef} />;
    }

}
class Parent extends Component {
    constructor(props) {
        super(props);
    }
    componentDidMount() {
        console.dir(this.echoRef);
        // 拿到子组件内定义的方法
        this.echoRef.focus();
    }
    // 定义绑定ref函数
    setRef = (e) => {
        this.echoRef = e;
    }
    render() {
        return (
            // 这里直接把回调传递下去了
            <Children setRef={this.setRef} />
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

关于回调模式就说到这里,我们最后来说下字符串模式。

叁 ❀ 贰 字符串模式

由于字符串模式在react官方文档中已明确废弃,未来可能会移除,所以这里只给一个例子,在日常开发中不推荐这种做法进行ref绑定:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class Children extends Component {
    sayName=()=>{}
    render() {
        return <input />;
    }

}
class Parent extends Component {
    constructor(props) {
        super(props);
    }
    componentDidMount() {
        console.dir(this.refs);
        // 拿到子组件内定义的方法
        // this.echoRef.focus();
    }
    render() {
        return (
            // 这里直接把回调传递下去了
            <>
                <Children ref='echoRef' />
                <input ref='timeRef' />
            </>
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

在上述代码中,我们通过ref='refName'的形式为DOM或者组件绑定ref,同样,我们可以通过this.refs.refName拿到对应绑定的组件,其实看到refs这个负数,你就应该可以猜到它能访问当前组件实例中的所有ref,上面的例子也验证了这一点,关于字符串形式就聊这么多。

肆 ❀ 总

OK,让我们回顾本文所提到的知识点,介绍了三种ref方式,React.createRef,函数回调以及字符串模式,当然我们不推荐字符串,因为它已被官方废弃。

函数组件不支持为其添加ref属性,因为函数组件没有实例,但我们提到可以通过forwardRef对于函数组件进行包裹,毕竟对于hook而言,组件都是函数组件,react也是提供了此做法来解决ref获取的问题。

我们介绍了A>B>C的组件嵌套场景,如何将A中定义的ref转发给C绑定了,除了forwardRef做法外,我们知道ref也能定义好后,作为props向下传递。

另外,我们在通过ref获取子组件属性时,比如获取一个函数,请注意函数的写法,比如这个例子中,我们能拿到sayName,但拿不到sayAge,这是因为后者本质上是绑定在原型上,无法通过这种方式直接访问,但是你可以通过原型找到它。

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class Children extends Component {
    constructor(props) {
        super(props);
    }
    sayName = () => { }
    sayAge() { }
    render() {
        // 父级传递来的props,最后给了input用了
        return <input />;
    }

}
class Parent extends Component {
    constructor(props) {
        super(props);
    }
    componentDidMount() {
        console.dir(this.echoRef);
    }
    // 定义绑定ref函数
    setRef = (e) => {
        this.echoRef = e;
    }
    render() {
        return (
            // 这里直接把回调传递下去了
            <Children ref={this.setRef} />
        )
    }
}
ReactDOM.render(
    <Parent />,
    document.getElementById('root')
);

要介绍的大概就这么多了,那么本文结束。

伍 ❀ 参考

Refs and the DOM

Refs 转发

React.forwardRef真是个好东西

你不知道的 useRef

posted on 2021-07-22 20:16  听风是风  阅读(2013)  评论(1编辑  收藏  举报