React 学习之 Context (旧与新)

953 阅读7分钟

前提环境

比如说,项目的组件结构如下图,在 App 组件中有一个数据为 loginUser,而需要在 Child1 组件中使用这个数据,那么在使用上下文之前,我们就只能通过 props 一层一层地往子组件传递,当这个层级很深的时候,属性的传递就会变得非常繁琐;而且如果属性名变更或者新增数据传递,那可能需要在每一层都去改动这个相关的数据传递。

所以,为了解决这个问题,就出现了上下文。图示如下图,黄色背景即为 App 组件创建的上下文,那么这个组件的后代组件都可以共享这个上下文中存储的数据。

组件结构示意图

上下文的特点:

  1. 当某个组件创建了上下文后,上下文中存储的数据,会被所有的后代组件共享

  2. 如果某个组件依赖了上下文,会导致该组件的数据来源不再纯粹 (一般认为外部数据仅来源于 props 的组件为纯粹组件),所以尽量谨慎使用

  3. 一般用于第三方通用组件

旧版 API

因为保不齐需要维护旧版 React 开发的项目,所以旧版 API 也需要了解

上下文的创建

只有 类组件 才可以创建上下文

  1. 给类组件书写 静态属性 childContextTypes,使用该属性对上下文中的数据类型进行约束

  2. 添加实例方法 getChildContext,此方法返回的对象,即为上下文中的数据,该数据必须满足类型约束

  3. 每次均会在 render 之后调用,新版本写法已废弃;如今在 <React.StrictMode> 节点下使用会报警告 (API 已过时): Warning: Legacy context API has been detected within a strict-mode tree.

    import React, {Component} from 'react'
    import PropTypes from 'prop-types'
    
    export default class OldContext extends Component {
        static childContextTypes = {
            a: PropTypes.number,
            b: PropTypes.string.isRequired,
            addToA: PropTypes.func
        }
        state = {
            a: 1
        }
        /* 此函数会在 render 后自动调用 */
        getChildContext() {
            console.log('getChildContext 获取上下文数据')
            return {
                a: this.state.a,
                b: 'K',
                /* 提供更改上下文数据的方法 */
                addToA: n => {
                    this.setState({ a: this.state.a + n })
                }
            }
        }
        render() {
            // 自身获取上下文数据
            // const {a, b} = this.getChildContext()
            return (
                <div>OldContext</div>
            )
        }
    }
    
  4. 以上面的图示为例,如果 ChildA 也创建了一个上下文,并且跟 App 组件创建的上下文有重名属性,那么它们的属性类型约束必须保持一致; ChildA 通过 this.context 取这个同名属性的数据时,会 获取到其父级上下文中的数据,而不是自身创建的上下文数据;但当后代获取上下文数据时,就类似作用域链取数据的方式,获取 最近的上下文 中的数据。

上下文的使用

  1. 自身组件非要这么使用的话:const ctxData = this.getChildContext()【没什么话说】

  2. 如果要使用上下文中的数据,组件 (函数/类) 必须拥有一个 静态属性 contextTypes,该属性描述了需要获取的上下文中获取数据的类型

    • 类组件: 可以从 constructor 中的第二个参数获取;或者使用 this.context 从属性中获取,使用 constructor 时,要在 constructor(props, context) 构造函数中调用 super(props, context) 交给父类处理,就会将 context 注入到属性中;不使用 constructor 时会默认注入
    // 以上面提供的上下文数据为例
    import React, {Component} from 'react'
    import PropTypes from 'prop-types'
    
    // ChildComp 组件是上面 OldContext 组件的子组件或更深的子组件
    export default class ChildComp extends Component {
        // 声明这个组件需要使用的上下文数据;声明哪些,就只能获取哪些
        static contextTypes = {
            a: PropTypes.number // 类型不匹配,会输出警告信息
        }
        constructor(props, context){
            super(props)
            console.log(context) // 这里只会拿到 {a: 1}
        }
        render() {
            return (
                <div>ChildComp</div>
            )
        }
    }    
    
    • 函数组件: 通过 第二个参数 获取上下文数据
    // 还是以上面提供的上下文数据为例
    import React, {} from 'react'
    import PropTypes from 'prop-types'
    
    export default function ChildComp (props, ctx) {
        console.log(ctx) // 拿到 a 与 addToA
        return (
            <div>
                ChildComp
                <p>a: {args[0].a}</p>
                <button onClick={() => {
                    ctx.addToA(5)
                }}>a + 5</button>
            </div>
        )
    }
    
    ChildComp.contextTypes = {
        a: PropTypes.number, // 类型不匹配,会输出警告信息
        addToA: PropTypes.func
    }
    

函数组件使用上下文与 ref 转发的冲突

上面函数组件通过第二个参数获取上下文,昨天刚好有写 ref 使用与转发的文章函数组件转发的 ref 也是通过函数的第二个参数接收处理的,那么肯定存在冲突的问题。经过测试得知如果函数组件同时使用 上下文ref 转发的情况下,转发的 ref 对象将会作为第二个参数传递给这个函数组件,这个组件就无法获得上下文数据了。测试代码如下:

import React, { Component, forwardRef, createRef } from 'react'
import PropTypes from 'prop-types'

function CompA(props, ref,...args) {
    console.log('ref: ', ref) // 打印 ref: {current: null} => divDOM
    console.log('CompA args: ', args) // 打印 []
    return <div ref={ref}>CompA</div>
}
CompA.contextTypes = {
    a: PropTypes.number
}
const RefCompA = forwardRef(CompA) // ref 转发组件 A

function CompB(props, ...args) {
    console.log('CompB args: ', args) // 打印 [{a: 1, changeA: func}]
    return (
        <div>
            CompB<br/>
            <p>a: {args[0].a}</p>
            <button onClick={() => { args[0].changeA() }}>a + 1</button>    
        </div>
    )
}
CompB.contextTypes = {
    a: PropTypes.number,
    changeA: PropTypes.func
}

export default class OldContext extends Component {
    static childContextTypes = {
        a: PropTypes.number,
        b: PropTypes.string.isRequired,
        changeA: PropTypes.func
    }
    state = {
        a: 1
    }
    getChildContext() {
        return {
            a: this.state.a,
            b: 'K',
            changeA: () => { this.setState({ a: this.state.a + 1 }) }
        }
    }
    compRef = createRef() // 创建 ref 对象
    render() {
        console.log('render', this.compRef) // 打印 {current: null} => divDOM
        return (
            <div>
                OldContext
                <RefCompA ref={this.compRef}/>
                <CompB />
            </div>
        )
    }
}

新版 API

由于旧版 API 存在比较严重的效率问题,并且容易导致滥用。所以,在新版 API 中,就将上下文抽离为一个独立于组件的对象

上下文的创建

借助 React.createContext() 创建上下文,参数即为上下文的默认值,返回一个包含 Provider (上下文生产者)Consumer (上下文消费者) 组件的对象

import React, {Component, createContext} from 'react'

// ts 中参数必填(我只尝试了 ts),一般抽离为一个文件导出
const ctx = createContext({})

export default class NewContext extends Component {
    state = {
        a: 1,
        b: 'K'
    }
    render() {
        {/* 必填 value 属性 */}
        return (
            <ctx.Provider value={this.state}>
                <div>NewContext</div>
            </ctx.Provider>
        )
    }
}

上下文的使用

  1. 类组件

    • 使用 静态属性 contextType (注意:type 没有 s),且应该赋值为创建的上下文对象,直接使用 this.context 使用上下文数据

    • 不使用 静态属性 contextType,则可以直接使用 ctx.Consumer 组件的方式,子节点使用函数渲染即可 (写法可见下面函数组件)

    import React, {Component, createContext} from 'react'
    
    const ctx = createContext({})
    
    class ChildComp extends Component {
        static contextType = ctx
        render() {
            return (
                <div>
                    子组件,上下文数据:a = {this.context.a},b = {this.context.b}
                </div>
            )
        }
    }
    
    // class ChildComp extends Component {
    //     render() {
    //         return (
    //             <ctx.Consumer>
    //             {
    //                 context => (<div>
    //                     子组件,上下文数据:
    //                      a = {context.a},
    //                      b = {context.b}
    //                 </div>)
    //             }
    //             </ctx.Consumer>
    //         )
    //     }
    // }
    
    export default class NewContext extends Component {
        state = {
            a: 1,
            b: 'K'
        }
        render() {
            return (
                <ctx.Provider value={this.state}>
                    <div>
                        NewContext
                        <ChildComp />
                    </div>
                </ctx.Provider>
            )
        }
    }
    
  2. 函数组件中,需要借助 Consumer 组件 来使用上下文数据,它的子节点 children 是一个函数,这个函数的参数即为上下文的数据 (或者使用后面 Hook 文章中的 useContext Hook)

    import React, {Component, createContext} from 'react'
    
    const ctx = createContext({})
    
    function ChildComp (props) {
        render() {
            return (
                <div>
                    <ctx.Consumer>
                        {val => <span>a: {val.a}</span>}
                    </ctx.Consumer>
                </div>
            )
        }
    }
    
    export default class NewContext extends Component {
        state = {
            a: 1,
            b: 'K'
        }
        render() {
            return (
                <ctx.Provider value={this.state}>
                    <div>
                        NewContext
                        <ChildComp />
                    </div>
                </ctx.Provider>
            )
        }
    }
    

注意点

如果上下文提供者 (Context.Provider) 中的 value 属性发生变化,默认情况下会导致该上下文提供的所有后代元素全部重新渲染,若该子元素 未使用 上下文数据则可优化 (比如 shouldComponentUpdate 返回 false 可以跳过重新 render) 。若后代元素使用了上下文,则无论是否有优化,一定会重新渲染 (且会跳过 shouldComponentUpdate 函数的运行)

import React from 'react'
import ctx from './ctx'

class ContextComp extends React.Component {
    state = {
        a: 1,
        changeA: () => {
            this.setState({
                ...this.state,
                a: this.state.a + 1
            })
        }
    }
    render() {
        console.log('ContextComp render')
        return (
            <ctx.Provider value={this.state}>
                <div>
                    <ChildComp />
                </div>
            </ctx.Provider>
        )
    }
}

class ChildComp extends React.Component {
    shouldComponentUpdate() {
        console.log('ChildComp shouldComponentUpdate => false')
        return false
    }
    render() {
        console.log('ChildComp render')
        return (
            <>
                ChildComp
                <GrandSonComp />
            </>
        )
    }
}

class GrandSonComp extends React.Component {
    shouldComponentUpdate() {
        console.log('GrandSonComp shouldComponentUpdate => false')
        return false
    }
    render() {
        console.log('GrandSonComp render')
        return (
            <ctx.Consumer>
            {
                context => (
                    <div>
                        <p>{context.a}</p>
                        <button
                            onClick={() => context.changeA()}
                        >更新 context 的 a</button>
                    </div>
                )
            }
            </ctx.Consumer>
        )
    }
}

如上面这个栗子,点击 GrandSonComp 组件的按钮 更新 context 的 a,你会发现 GrandSonComp 组件内部 a 的值已更新,但控制台打印结果如下:

'ContextComp render'
'ChildComp shouldComponentUpdate =>' false

我们发现,在 ChildComp 的生命周期函数中 shouldComponentUpdate 返回 false 会导致它本身及 GrandSonComp 组件均不会重新渲染,但 ctx.Consumer 组件数据变更则会导致其本身及其子组件重新渲染 (表现为页面显示的 a 刷新)

上面的 GrandSonComp 组件稍作修改:

// ......
class GrandSonComp extends React.Component {
    shouldComponentUpdate() {
        console.log('GrandSonComp shouldComponentUpdate => false')
        return false
    }
    static contextType = ctx // 静态属性使用方式
    handleClick = () => {
        this.context.changeA()
    }
    render() {
        console.log('GrandSonComp render')
        return (
            <>
                <p>{this.context.a}</p>
                <p>
                    <button onClick={this.handleClick}>更新 ctx.a</button>
                </p>
            </>
        )
    }
}

点击按钮打印结果如下:

'ContextComp render'
'ChildComp shouldComponentUpdate =>' false
'GrandSonComp render'

我们看到,GrandSonComp 组件重新渲染,而且是直接跳过它的 shouldComponentUpdate 函数直接渲染