React 学习之认识 HOC 与 Ref

667 阅读7分钟

React 属性默认值

通过一个 静态属性 defaultProps 告知 React 默认属性

// App.jsx 文件中使用 Comp 组件,并传递属性
import React from 'react'
import ReactDOM from 'react-dom'
import Comp from './Comp'

const MOUNT_NODE = document.getElementById('root')
ReactDOM.render(<Comp a={5} b={6} />, MOUNT_NODE)

函数组件写法

从表现上来看,在函数运行之前就已经完成了父组件传递的属性与默认属性的混合

// Comp.jsx 函数组件
import React from 'react'

const Comp = props => {
    console.log(props) // 此时就已经是混合后的属性 {a: 5, b: 6, c: 3}
    return (
        <div>
            a: {props.a}, b: {props.b}, c: {props.c}
        </div>
    )
}
/* 属性默认值声明 */
Comp.defaultProps = {
    a: 1,
    b: 2,
    c: 3
}
export default Comp

类组件写法

从表现上来看,在 constructor 构造函数运行之前就已经完成了父组件传递的属性与默认属性的混合 (即在初始化阶段就完成了属性的混合)

// Comp.jsx 类组件
import React, {Component} from 'react'

class Comp extends Component {
    /* 方式一:使用 static 关键字声明默认属性 */
    static defaultProps = {
        a: 1,
        b: 2,
        c: 3
    }
    constructor(props) {
        super(props)
        console.log(props) // 此时就已经是混合后的属性 {a: 5, b: 6, c: 3}
    }
    render() {
        return (
            <div> a: {this.props.a}, b: {this.props.b}, c: {this.props.c} </div>
        )
    }
}
/* 方式二:类属性赋值声明属性默认值 */
// Comp.defaultProps = {
//     a: 1,
//     b: 2,
//     c: 3
// }
export default Comp

属性类型检查 —— prop-types

对组件使用 静态属性 propTypes,用于告知 React 如何检查类型(React 已内置 prop-types 包)

它不会影响代码的执行,只在开发阶段编译时进行类型检查,在控制台输出不符合类型约束相关的警告信息

// Comp.jsx 类组件
import React, {Component} from 'react'
import PropTypes from 'prop-types'

class Comp extends Component {
    static propTypes = {
        a: PropTypes.number.isRequired /* 约束 a 属性为数字类型,且必填 */
    }
    render() {
        return (
            <div>
                a: {this.props.a}
            </div>
        )
    }
}

export default Comp

相较于 TypeScript,只是一个弱类型的类型检查系统,在表现上来看运行时机也相当于是在运行时

更优方案当然是使用 TypeScript,借助其强类型约束来避免更少的编码错误

高阶组件 —— HOC

HOC:Higher-Order Component

从高阶函数的思想出发,通过参数传递一个组件,然后返回一个增强功能的新组件。我们就可以利用 JS 灵活性的特点玩出很多的花样来

举个 🌰

比如说,在某个场景下,这里有 20 个组件需要一个共有的功能:挂载与卸载时打印对应的时间点。那么不使用 HOC 的话,我们就需要在这 20 个组件中重复去书写 componentDidMountcomponentWillUnmount 两个生命周期的钩子函数关于打印时间点的相关代码

HOC 实现方案

而使用 HOC 的话,我们就只需要抽离一个需要打印 log 的公共方法,返回一个新的包含日志记录功能的组件就好了,如下:

import React, {Component} from 'react'

// 在命名上,返回 HOC 的函数一般以 with 开头,表示增强功能的组件
function withLog (Comp) {
    return class extends Component {
        componentDidMount() {
            console.log(`${Comp.name} is Mounted, the time is ${Date.now()}`)
        }
        componentWillUnmount() {
            console.log(`${Comp.name} will Unmount, the time is ${Date.now()}`)
        }
        
        render() {
            return <Comp {...this.props} />
        }
    }
}

export default withLog

后续使用时,我们每个需要打印日志的组件,只需要调用此函数包裹一下就可以满足打印日志功能的需求,使用示例如下:

import React, {Component} from 'react'
import withLog from './HOC/withLog'

class AComp extends Component {
    // do somthing...
}

const ALogComp = withLog(AComp)

export default ALogComp

换个思路

通过实现一个新的类,继承自 React.Component 类去扩展相应的功能

import React, {Component} from 'react'
class LogComponent extends Component {
    componentDidMount() {
        // 但不知道是哪个组件的挂载
        console.log(`Component is Mounted, the time is ${Date.now()}`)
    }
}
export default LogComponent

后续的 20 个组件只需要去继承这个类,就可以达到这个打印日志的需求... (当然,这只是我个人想到的一个取巧的方式,不推荐)

HOC 使用的注意点

  1. 返回高阶组件本身就是一个函数,那么函数传递的参数就可以随你自己处理了,内部处理逻辑也是一样

  2. 不要在 render 函数函数组件 中使用高阶组件,否则每次都会重新创建这些高阶组件,不仅浪费性能,而且会丢失之前的状态

    import React, {Component} from 'react'
    import Comp from './components/Comp'
    import withLog from './HOC/withLog'
    
    const LogComp = withLog(Comp) // 写在这里,ALogComp 会被重用
    
    class AComp extends Component {
        // do somthing...
        render() {
            // 写在这里,ALogComp 每次都会被销毁后又重新创建
            // const LogComp = withLog(Comp)
            return <LogComp />
        }
    }
    export default AComp
    
  3. 不要在高阶组件内部更改传入的组件,例如更改其原型链上的生命周期方法

    import React, {Component} from 'react'
    
    function withLog (Comp) {
        // 不要采用这种方式去覆盖传入组件身上的属性与方法
        // Comp.prototype.componentDidMount = function() {
        //     // do something...
        // }
        return class extends Component {
            render() {
                return <Comp {...this.props} />
            }
        }
    }
    
    export default withLog
    
  4. ref 传递的问题 (封装高阶组件 ref 引用存在的问题)

    直接看下文即可,本文末尾

Ref (Reference)

在某些场景下,我们可能需要调用 DOM 元素 身上的方法,或者希望直接使用 自定义组件 中的某个方法

场景一:我们点击一个按钮,使输入框聚焦

  1. 在原生 js 中,我们只需要这么处理:

    const inpDom = document.querySelector('input') // 假设页面只有一个 input
    // 然后调用 focus 方法实现聚焦
    inpDom.focus()
    
  2. 而在 React 中,也给我们提供了一个可以直接操作 DOM 的方式,与 Vue (this.$refs.xx) 类似

    import React, {Component} from 'react'
    
    class Comp extends Component {
        handleClick = () => {
            // this.refs.txt 就是 input
            this.refs.txt.focus()
        }
        render() {
            return (
                <div>
                    {/* 这里直接使用 'txt' 字符串,推荐使用 React.createRef 方法创建 */}
                    <input type="text" ref="txt" />
                    <button onClick={this.handleClick}>输入框聚焦</button>
                </div>
            )
        }
    }
    export default Comp
    

场景二:组件 Dog 中有个方法 bark,我需要在引用 Dog 组件的位置调用 Dog 内部的方法

import React, {Component} from 'react'

class Dog extends Component {
    bark() {
        console.log('Dog is barking...')
    }
    render() {
        return <div> Dog Component </div>
    }
}

class Comp extends Component {
     handleClick = () => {
        // this.refs.compDog 就是当前使用 Dog 组件产生的实例对象
        this.refs.compDog.bark() // 调用其原型链上的 bark 方法
    }
    render() {
        return (
            <div>
                <Dog ref="compDog" />
                <button onClick={this.handleClick}>调用子组件的方法</button>
            </div>
        )
    }
}
export default Comp

ref 使用小结:

  1. ref 作用于 React 内置的 Html 组件 (如:<h1> / <div> 等),得到的将是真实的 DOM 对象
  2. ref 作用于类组件,得到的将是类的实例对象
  3. ref 不能作用于函数组件上 (即引用的组件是函数组件的话,给函数组件使用 ref 会报错)

ref 属性推荐传递对象或函数

  1. 对象 (通过 React.createRef() 创建或手动创建 { current: null })
import React, {Component, createRef} from 'react'

class Comp extends Component {
    constructor (props) {
        super(props)
        this.txt = createRef() // 返回一个对象,存于 current 属性中
        // 此时,current 属性为 null
        // 初次渲染后,会将 current 属性赋值为 DOM 元素或类的实例
        /* { current: xxx } */
        
        // 也可以手动创建一个对象,只要包含 current 属性即可
        // 这种结构设计的出发点为提升效率,保证 this.txt 的引用不变
        /*
           this.txt = { current: null } // 也可以工作
        */
    }
    handleClick = () => {
        // this.refs 中就不存在 DOM 对象了
        this.txt.current.focus()
    }
    render() {
        return (
            <div>
                <input type="text" ref={this.txt} />
                <button onClick={this.handleClick}>调用子组件的方法</button>
            </div>
        )
    }
}
export default Comp
  1. 函数

如下示例所示,给 ref 传递一个函数,这个函数的参数就是遵循上面小结规则中的 DOM 元素或类的实例 (后面简称 对象K),函数的调用时间:

  1. componentDidMount 时,会调用这个传递的函数(在此之前的生命周期函数中不能使用此 ref 对象)
  2. 如果 ref 的值发生了变动 (旧的函数被新函数替代),会分别调用旧的函数以及新的函数,时间点在 componentDidUpdate 之前。旧的函数调用传递 null,新的函数传递 对象K
  3. 如果 ref 所在的组件被卸载,也会调用该函数 (传递的函数使用的是引用,如下 getRef 方法)
import React, {Component} from 'react'

class Comp extends Component {
    handleClick = () => {
        this.txt.focus() // 函数里是直接赋值给 this.txt 属性的
    }
    /* 此函数引用不变,仅会在挂载阶段及卸载阶段执行 */
    getRef = el => {
        console.log('getRef 被调用', el)
        this.stableRef = el
    }
    render() {
        return (
            <div>
                {/* 这里是箭头函数的写法,每次 render 都是一个新的函数 */}
                <input type="text" ref={el => {
                    console.log('箭头函数 ref 引用被调用', el)
                    this.txt = el // 保存 ref 引用
                }} />
                <input type="text" ref={this.getRef} />
                <button onClick={this.handleClick}>调用子组件的方法</button>
            </div>
        )
    }
}
export default Comp

ref 转发 (forwardRef)

解决问题,如下的例子,想要在 App 组件中拿到函数组件 Comp 内部的 对象K (比如例子中的 div 元素):

import React, {Component} from 'react'

function Comp (props) {
    return <div>组件 Comp</div>
}

class App extends Component {
    render() {
        return (
            <div>
                {/* 函数组件是不能使用 ref 的,给予 ref 属性,控制台会报相关的警告 */}
                <Comp />
            </div>
        )
    }
}
export default App

从而,我们需要借助 React.forwordRef 函数创建一个新的组件,来帮助我们转发 ref,然后它会作为函数组件的第二个参数传递给函数组件

import React, {Component, createRef, forwardRef} from 'react'

function Comp (props, ref) {
    return <div ref={ref}>组件 Comp</div>
}

// 返回一个新的组件,并传递给函数组件 Comp 的第二个参数
const RefComp = forwardRef(Comp)
// 新的组件接收的 ref 属性只会向组件传递,而不是引用自身

class App extends Component {
    compRef = createRef() // 这里 this.compRef.current 存储的就是 div DOM 元素
    render() {
        return (
            <div>
                <RefComp ref={this.compRef} />
            </div>
        )
    }
}
export default App

注意点:

  1. React.forwardRef 函数的参数必须是 函数组件,而不能是类组件;而且这个函数组件必须使用第二个参数来接收 ref,否则会报警告

  2. 若对类组件期望在 App 组件中直接引用类组件 Comp 中的元素,那么就可以用一个简单的属性去传递

    import React, {Component, createRef} from 'react'
    
    class Comp extends Component {
        render() {
            return <div ref={this.props.reference}>组件 Comp</div>
        }
    }
    
    class App extends Component {
        compRef = createRef() // 这里 this.compRef.current 存储的就是 div DOM 元素
        render() {
            return (
                <div>
                    {/* 自定义一个简单的属性传递 ref 对象 */}
                    <Comp reference={this.compRef} />
                </div>
            )
        }
    }
    export default App
    
  3. 在使用高阶组件时,因为返回的直接是一个新的类组件,那么在对其使用 ref 属性时,ref 对象就会指向这个 HOC 包装器,这显然不是我们期望的结果;所以需要在 HOC 中封装一下 ref 转发处理 (以之前的 withLog 为例)

    // withLog.jsx [HOC]
    import React, {Component, createRef, forwardRef} from 'react'
    
    export default function withLog(Comp) {
        class LogWrapper extends Component {
            componentDidMount() {
                console.log(`${Comp.name} is Mounted, the time is ${Date.now()}`)
            }
            /* 通过约定使用 forwardRef 属性接收外面传递进来的 ref */
            render() {
                const {forwardRef, ...rest} = this.props
                return <Comp ref={forwardRef} {...rest} />
            }
        }
        
        return forwardRef((props, ref) => {
            return <LogWrapper {...props} forwardRef={ref} />
        })
    }
    // 这样,我们在使用 withLog 这个高阶组件时,就不需要额外担心 ref 的引用问题了
    // 只需要考虑将 HOC 内部传递 ref 的属性名写得特殊一点,避免属性名重复即可
    
    // main.jsx
    import React, {Component, createRef} from 'react'
    import ReactDOM from 'react-dom'
    import CompA from './components/CompA'
    import withLog from './hoc/withLog'
    
    const LogCompA = withLog(CompA)
    class App extends Component {
        compRef = createRef()
        render() {
            return <LogCompA ref={this.compRef} />
        }
    }
    export default App
    

以上,便是 React 默认属性,高阶组件使用及封装的注意点,ref 使用与 ref 转发需要注意的相关说明