前端开发者学堂 - fedev.cn

初探React Context API

发布于 大漠

最近在整理CSS自定义属性在React中的使用时了解到“可以使用React Context API相关的知识更好的在React组件中使用CSS自定义属性”,但是自己对这方面的知识了解的并不多,因此想借此机会来学习React Context API相关的知识。也基于这个原因有了这篇文章。

为什么要React Context API

我们从一个React的实例开始。假设你要构建一个React的应用,该应用有一个最简单的功能,就是Dark Mode的切换。简单地说,在Web应用上一个切换组件(比如ThemeToggle),用户点击该切换按钮可以让页面在暗色系(dark)和亮色系(light)之间切换。

通常我们会通过props为所有组件提供当前主题的模式,并使用state来更新当前的主题。

/src/components/目录下分别创建了GrandChildChildParentComponentThemeToggle几个组件:

示例代码如下:

// /src/components/GrandChild
import React from 'react'

const GrandChild = (props) => {
    const styled = {
        color: `${props.theme.color}`,
        background: `${props.theme.background}`
    }
    return <h1 style={{...styled,...props.styles}}>Theme Toggle</h1>
}

export default GrandChild;

// /src/components/Child
import React from 'react'
import GrandChild from '../GrandChild'

const Child = (props) => {
    const styled = {
        border: `5px solid ${props.theme.color}`,
        padding: `10vmin 20vmin`,
        borderRadius: '8px'
    }

    return <GrandChild theme = {props.theme} styles={styled} />
}

export default Child

// /src/components/ParentComponent
import React from 'react'
import Child from '../Child'

const ParentComponent = (props) => <Child theme = {props.theme} />

export default ParentComponent

// /src/components/ThemeToggle
import React from 'react'

const ThemeToggle = (props) => {
    const styled = {
        background: `${props.theme.background}`,
        color: `${props.theme.color}`,
        border: `4px solid currentColor`,
        borderRadius: `6px`,
        padding: `2vmin 4vmin`,
        margin: `4vmin`,
        cursor: `pointer`
    }
    return <button onClick={props.click} style={styled}>Toggle Dark Mode</button>
}

export default ThemeToggle

// /src/App.js
import React, {Fragment} from 'react';
import ParentComponent from './components/ParentComponent'
import ThemeToggle from './components/ThemeToggle'

const dark = {
    background: '#121212',
    color: '#fff'
}

const light = {
    background: '#fff',
    color: '#444'
}

const App = () => {
    const [theme, setTheme] = React.useState('light')

    const onClickHander = () => {
        theme === 'light' ? setTheme('dark') : setTheme('light')
    }


    return <Fragment>
        <ParentComponent theme={theme === 'light' ? light : dark} />
        <ThemeToggle click={onClickHander} theme={theme === 'light' ? light : dark} />
    </Fragment>
}

export default App;

效果如下:

在这个示例中,在ParentComponent组件中指定了theme这个props,并且将这个props一级一级往下传,传给组件树下的所有组件。即,将theme传递到需要它的地方,在本例中会传到GrandChild组件。而Child组件和themeprops)没有任何关系,它只是作为一个媒体而以。

试想一下,在React中组件树就有点类似于我们熟悉的DOM树:

注意,上图中每个白色的矩形方框代表的就是React的组件

正如上图所示,我们可以在最底层组件中添加state,但如果要将数据传递给兄弟组件的话,在React Context API之前,我们只能将state放到他们的父组件中(组件树中更高的组件位置),然后通过props将其传递回同级组件:

就像上面的示例,我们需要将state从组件树的最顶层一级一级往下传,哪怕是所有中间层组件不需要使用这些数据,但它必须为了后面的组件做为媒介,将state传递到最底层组件。

在React社区中,将这种冗长和耗时的过程称为Prop Drilling

对应到上面的示例中,那就是:

React Context API正是用来解决Prop Drilling的问题。React Context API提供了一种通过ProviderConsumer用来提供数据和消费数据,它们可以在组件件中传递数据,最主要的是不必要一级一级的通过props向组件树传递state。简单地说,在组件树最顶层的组件中通过Provider提供数据,在后面的任何一个子组件树可以通过Consumer来消费Provider提供的数据:

React Context API简介

React的官网是这样描述Context的:

Context提供了一个无需为每层组件手动添加props,就能在组件树间进行数据传递的方法

在这个特性还没出现之前,在React应用中数据的通讯是通过props属性自上而下(由父及子)进行传递的,换句话说,必须通过props传递到每个组件中,然后在组件中重复相同的过程。但这种做法对于某些类型的属性而言是极其繁锁的,也会变得非常的糟糕,最终可能会导致props在我们的组件中要不断的一层一层嵌套。

React Context API的出现主要是为了帮助我们解决这方面的问题,它提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递props。也就是说,它允许父组件隐式地将数据传递给子组件(不管组件树有多深)。换句话说,可以将数据添加到父组件中,然后任何子组件都可以访问它

假设我们有这样的一个使用场景,在一个React应用中,我们有AppContainerFormButton四个组件,它们之间是依次被嵌套:

App ➜ Container ➜ Form ➜ Buttton

假如我们使用props传递就需要一层一层往里传:

换成Context,就可以直接获取最顶层App组件绑定的值:

React Context API入门级应用

对于像我这样的初级使用者而言,要想彻底的了解React Context API,只能从最简单的应用开始。为了更好的理解它的使用,我们从最简单的示例开始。

创建上下文对象

在JavaScript中(或React)中常将Context称为上下文

在React中使用Context的话,我们首要做的就是创建上下文对象(Context Object)。可以使用React上的.createContext()创建一个Context对象:

const DataContext = React.createContext()

尝试着把这个Context对象DataContext在控制台上打印出来:

这个时候可以从组件树中离自身最近的那个匹配的Prrovider中读取到当前的context值。

只有当组件所处的树中没有匹配到Provider时,其defaultValue参数才会生效。这有助于在不使用Provider包装组件的情况下对组件进地测试。

另外,createContext()方法提供了ProviderConsumer能力,其中一个是提供者,另一个是消费者,而且这两个属性都是成对出现的,即每一个Provider都会有对应的一个Consumer

Provider将作为父组件使用,它持有所有Consumer都可以共享的值。注意,Consumer只能用于Provider的子组件

使用Provider提供数据

上一步,使用React.createContext()创建了一个名为DataContext的上下文对象,在其中,我们用一些值(value)初始化一个状态(state),可以使用DataContextProvider接受一个value属性,传递给子组件消费(Consumer

const App = () => <DataContext.Provider value={{userName: 'Airen', age: 30 }}>
    <h4>Child Component</h4>
</DataContext.Provider>;

Providervalue属性的值可以是字符串、数字或对象。

消费Provider提供的数据

Provider创建了数据,其创建的数据可以通过context对象的Consumer属性给子组件消费。主要有三种方法来消费Provider属性创建的数据。

使用Consumer组件消费数据

创建一个新组件,并且在该组件中使用DataContextConsumer来消费数据。这将返回一个函数,该函数允许组件消费Provider中设置的值。比如:

const ParagraphChildComponent = () => <DataContext.Consumer>
    { value => <h4>I'm {value.userName}, {value.age} yeas old this year!</h4>}
</DataContext.Consumer>

const App = () => <DataContext.Provider value={{userName: 'Airen', age: 30 }}>
    <ParagraphChildComponent />
</DataContext.Provider>;

浏览器渲染出来的结果如下:

注意,DataContext.Consumer组件中返回的函数是基于context值进行渲染。

这种方式对于处理不需要任何逻辑的组件非常有用。

使用this.contextcontextType来消费数据

在使用类的组件中,可以使用this.contextconteextType来消费Provider组件中设置的值。比如:

class ParagraphChildComponentWithClass extends React.Component {
    render() {
        return <h4>He is {this.context.userName}, {this.context.age} yeas old this year!</h4>
    }
}

ParagraphChildComponentWithClass.contextType = DataContext

const App = () => <DataContext.Provider value={{userName: 'Airen', age: 30 }}>
    <ParagraphChildComponentWithClass />
</DataContext.Provider>;

挂载在class上的contextType属性会被重新赋值为一个由React.createContext()创建的context对象。这能让你使用this.context来消费最近context上的那个值。你可以在任何生命周期中访问到它,包括render函数中。

class MyClass extends React.Component {
    componentDidMount() {
        let value = this.context
        console.log('➜componentDidMount➜➜➜', value)
    }
    componentDidUpdate() {
        let value = this.context
        console.log('➜componentDidUpdate➜➜➜', value)
    }
    componentWillUnmount(){
        let value = this.context
        console.log('➜componentWillUnmount➜➜➜', value)
    }
    render() {
        let value = this.context
        return <h4>➜UserName: {value.userName}➜➜➜ Age: {value.age}</h4>
    }
}

MyClass.contextType = DataContext

const App = () => <DataContext.Provider value={{userName: 'Airen', age: 30 }}>
    <MyClass />
</DataContext.Provider>;

使用useContext来消费数据

上面两种方式都可以用来消费Provider提供的数据,但这两种方法要在适用的组件中使用。除此之外,还可以使用React的useContext方法来消费Provider提供的数据。该方法用于从context中获取props,其方法与上面两种方法相同,但看起来更简单得多:

const ParagraphChildComponentWithUseContext = () => {
    const {userName, age} = useContext(DataContext)

    return <h4>She is {userName}, {age} yeas old this year!</h4>
}

const App = () => <DataContext.Provider value={{userName: 'Airen', age: 30 }}>
    <ParagraphChildComponentWithUseContext />
</DataContext.Provider>;

后面我们会多花一点时间来聊useContext

现在对React Context API有了一个最基础的了解,接下来我们来看看在实例中怎么使用它。

React Context API实例

我们来使用React Context API改造文章最开始的Dark Mode案例。

首先需要创建一个React的上下文context,正如上面介绍的,可以使用React.createContext来创建。比如,我在src/目录下创建一个ThemeContext.js文件,用来管理这个context。在这个示例中,给.createContext()传入一个字符串light,它是当前的主题模式:

// src/ThemeContext.js
import React from 'react'

const ThemeContext = React.createContext('light')

export default ThemeContext;

为了使ThemeContext这个上下文对所有React组件可用,我们必须在React组件树的最顶层,使用Provider来为后代子组件提供需要消费的数据。因为我们需要在lightdark两种状态下切换,因此,需要对ThemeContext.js稍作修改。使用React.createContext()创建context上下文时需要接受一个类似useState钩子结果的对象作为参数:

// src/ThemeContext.js
import React from 'react'

const ThemeContext = React.createContext(['light', () => {}])

export default ThemeContext;

在这个示例中,我们在App.js文件中来创建上下文的Provider。在创建Provider之前,需要先导入上面已创建的ThemeContext

// src/App.js
import ThemeContext from './ThemeContext'

一旦ThemeContext被导入,那么App组件中的内容必须包含在ThemeContext.Provider标签内。Provider组件有一个名为valueprops,它将包含我们想要给组件树中组件所需要的数据。这个时候,我们的App组件类似下面这样:

// src/App.js
import React from 'react';
import ThemeContext from './ThemeContext'

const App = () => {
    const theme = 'light'

    return <ThemeContext.Provider value={theme}>
        <div>子组件放在这里</div>
    </ThemeContext.Provider>
}

export default App;

这个时候,light的值可以用于React组件树中的所有组件。

现在我们可以像前面一样。创建Dark Mode所需要的颜色体系。

const dark = {
    background: '#121212',
    color: '#fff'
}

const light = {
    background: '#fff',
    color: '#444'
}

为好更好管理这两个主题颜色,这里将它们放到一个名为Color.js文件中(该文件也放在src/目录下):

// src/Color.js
export const dark = {
    background: '#121212',
    color: '#fff'
}

export const light = {
    background: '#fff',
    color: '#444'
}

接下来,我们来改造前面创建的ParentComponentChildGrandChildThemeToggle组件。先来重写ThemeToggle组件:

// src/components/ThemeToggle/index.js
import React, { useContext } from 'react'
import { dark, light} from '../../Color'
import ThemeContext from '../../ThemeContext'

const ThemeToggle = () => {
    const [theme, setTheme] = useContext(ThemeContext)
    
    const clickHandler = () => {setTheme(theme === 'light' ? 'dark' : 'light')}

    const currentTheme = theme === 'light' ? light : dark

    const styledButton = {
        background: `${currentTheme.background}`,
        color: `${currentTheme.color}`,
        border: `4px solid currentColor`,
        padding: `2vmin 4vmin`,
        margin: `4vmin`,
        cursor: `pointer`,
        borderRadius: `6px`,
        fontSize: `2rem`,
        fontFamily: `"Gochi Hand", sans-serif`
    }

    return <button style={styledButton} onClick={clickHandler}>{theme === 'light' ? 'Toggle Dark' : 'Toggle Light'}</button>
}

export default ThemeToggle

这里我们采用了useContext来消费顶层组件AppThemeContext.Provider提供的数据,它将返回一个数组。我们使用ES6的解构(Destructuring),可以从数组中获取元素。然后,为ThemeToggle组件写一个onClick事件处理程序,即 clickHandler

接下来调整最里面的组件GrandChild

// src/components/GrandChild/index.js
import React, { useContext } from 'react'
import ThemeContext from '../../ThemeContext'
import {dark, light} from '../../Color'

const GrandChild = () => {
    const theme = useContext(ThemeContext)[0]

    const currentTheme = theme === 'light' ? light : dark

    const styled = {
        color: `${currentTheme.color}`,
        background: `${currentTheme.background}`,
        border: `5px solid ${currentTheme.color}`,
        padding: `10vmin 20vmin`,
        borderRadius: '8px'
    }

    return <h1 style={styled}>{ theme === 'light' ? 'Light' : 'Dark'} Theme</h1>
}

export default GrandChild;

在这里使用useContext(ThemeContext)[0]取出context上下文中数组中的第一个值,即theme。然后根据theme值的变化获取Color.js对应的两个颜色主题darklight。这样就可以将对应主题的颜色运用到元素中。

对于ChildParentComponent两个组件,我们要变得容易地多:

// src/components/ParentComponent/index.js
import React from 'react'
import Child from '../Child'

const ParentComponent = () =>  <Child  />

export default ParentComponent

// src/components/Child/index.js
import React from 'react'
import GrandChild from '../GrandChild'

const Child = () => <GrandChild  />

export default Child

最后的效果如下:

虽然GrandChild组件是组件树中最底层,但通过useContext()可以直接消费最顶层提供的数据:

React Context API来管理全局状态

在React中,以往都是使用Redux工具来管理全局状态,但随着React Context API的出现,我们可以使用它来创建一个类似于Redux的全局状态管理工具。

React的官方文档也提到过,React Context API提供了一种通过组件树传递数据的方式,而不履在每一层手动传递props。事实上,在React中我们可以结合useContextuseReducer两个钩子函数,创建一个全局存储(store)来管理整个应用程序的状态,并支持在整个应用程序中方便地更新状态(state)。

为了更好的向大家阐述这方面的特性,即,使用React Context API和React的useContextuseReducer钩子函数来管理React应用程序的全局状态。用一个计数器的示例来帮助我们理解这方面的特性。

创建一个存储器

首先在src/目录下创建一个store.js文件,用来创建全局状态的存储器。 这个存储器(该文件)包含两个部分:context(上下文)和 state(状态)。使用React.createContext()创建一个React上下文,会返回一个Provider,它会接受一个value值(即props)。Provider将会包裹整个应用程序(App)。而且value将接收useReducer钩子函数创建的状态(state)。该钩子函数接受一个(state, action) => newState类型的Reducer,并返回新的状态(newState)和一个dispatch函数。Reducer(减速器)定义了一组操作(action)以及根据操作类型更新状态的方式。

useContext钩子是组件访问全局存储(Global Store)的方式。最后,创建一个自定义钩子useStore,这样就不必要导出StoreContext,然后导入它和useContext

// src/store.js
import React, { createContext, useReducer, useContext } from 'react'

const StoreContext = createContext()

const initialState = {
    count: 0,
    message: ''
}

const reducer = (state, action) => {
    switch (action.type) {
        case 'increment':
            return {
                count: state.count + 1,
                message: action.message
            }
        case 'decrement':
            return {
                count: state.count - 1,
                message: action.message
            }
        case 'reset':
            return {
                count: 0,
                message: action.message
            }
        default:
            throw new Error(`未定义的操作类似:${action.type}`)
    }
}

export const StoreProvider = ({ children }) => {
    const [state, dispatch] = useReducer(reducer, initialState)

    return <StoreContext.Provider value={{state, dispatch}}>
        {children}
    </StoreContext.Provider>
}

export const useStore = () => useContext(StoreContext)

让全局存储可以随处被访问

为了让store可以随处可访问,将整个应用程序封装在刚刚创建的Provider中。

// src/App.js
import React from 'react';
import {StoreProvider} from '../src/store'
import './App.css';

const App = () => {
    return <StoreProvider>
        <h4>所有子组件放在这里</h4>
    </StoreProvider>
}

export default App;

这样我们就要以在任何地方访问useStore。比如新创建的Count组件中,从store中提取statedispatch函数。dispatch需要调用action类型以及任何有效的负载(Payload)信息,比如本例中的message

// src/components/Count
import React from 'react'
import { useStore } from '../../store'
import './index.css'

const Count = () => {
    const {state, dispatch} = useStore()

    const incrementClickHandler = () => dispatch({
        type: 'increment',
        message: 'Incremented'
    })

    const decrementClickHandler = () => dispatch({
        type: 'decrement',
        message: 'Decremented'
    })

    const resetClickHandler = () => dispatch({
        type: 'reset',
        message: 'Reset'
    })

    return <div className="count">
        <h1>{state.message} {state.count} </h1>
        <div className="actions">
            <button onClick={incrementClickHandler} className="button increment"><span>+</span></button>
            <button onClick={decrementClickHandler} className="button decrement"><span>-</span></button>
            <button onClick={resetClickHandler} className="button reset"><span>R</span></button>
        </div>
    </div>
}

export default Count

App<StoreProvider>中引用Count组件:

const App = () => {
    return <StoreProvider>
        <Count />
    </StoreProvider>
}

看到的效果如下:

虽然示例中提到的这种方式,我们可以很好的管理全局的状态,但也存在一定的缺陷:

每当上下文(context)被修改时,useContext会重新渲染

也就是说,订阅全局状态的每个组件将在上下文更新时重新渲染,对页面的性能有直接影响

React Context API对性能的影响

上面的示例提到过,订阅全局状态的每个组件将在上下文更新时会重新渲染。那么我们要如何避免这方面的问题呢。我们从一个简单的示例开始,一步一步地向大家演示在使用React Context API和React Hooks时在哪些方面要改进,以及组件如何避免上下文更新时重新渲染。

首先在src/目录下创建一个名为GlobalContext.js文件,主要用来存放创建Context所需的逻辑:

// src/GlobalContext.js
import React, { createContext } from 'react'

const GlobalContext = createContext()

export default GlobalContext

创建了一个最简单的上下文GlobalContext,这个时候,我们就可以在App.js中使用它:

// src/App.js

import React from 'react';
import GlobalContext from '../src/GlobalContext'
import './App.css';

const App = () => {
    return <GlobalContext.Provider>
        <h1>子组件放这里</h1>
    </GlobalContext.Provider>
}

export default App;

接着创建一个ProfileCard组件,当它被挂载(mounted)时,会通过调用API得到用户个人信息,然后使用setProfile更新状态并显示它们。

// src/components/ProfileCard
import React, { useState, useEffect } from 'react'

const ProfileCard = props => {
    const [profile, setProfile] = useState([])
    useEffect(() => {
        (async () => {
            const result = await fetch('https://api.github.com/users/airen')
            const data = await result.json()
            setProfile(data)
        })()
    },[])

    const { avatar_url,location, name, bio, public_repos, followers, following, html_url } = profile

    return <div className="profile">
        <div className="header">
            <div className="avatar">
                <a href={html_url}>
                    <img src={avatar_url} alt={name} />
                </a>
            </div>
            <h2>{name} <span>{bio}</span></h2>
            <h3>{location}</h3>
            <div className="footer">
                <ul className="details">
                    <li>
                        <span>{followers}</span>
                        <strong>Followers</strong>
                    </li>
                    <li>
                        <span>{following}</span>
                        <strong>Following</strong>
                    </li>
                </ul>
            </div>
        </div>
    </div>
}

export default ProfileCard

并将ProfileCard组件放到<GlobalContext.Provider>标签内:

// src/App.js
const App = () => {
    return <GlobalContext.Provider>
        <ProfileCard />
    </GlobalContext.Provider>
}

加上CSS之后,看到的效果如下:

我们请求数据需要有一定的时间,我们创建一个Loading组件,这样在加载数据的时候,可以告诉用户:

// src/components/Loading
import React from 'react'
import './index.css'

const Loading = props => {
    return <div className="global-spinner-overlay">
        <ul className="loading-animation alternate">
            <li></li>
            <li></li>
            <li></li>
            <li></li>
            <li></li>
        </ul>
    </div>
}

export default Loading

同样将该组件引入到App中:

// src/App.js
const App = () => {
    return <GlobalContext.Provider>
        <Loading />
        <ProfileCard />
    </GlobalContext.Provider>
}

现在的效果并不完美。我们还必须在App.jsLoading组件添加一些逻辑,控制它什么时候显示。接下来,创建一个ContextProvider组件,它将封装这个逻辑,并且让App.js更干净。先对前面的GlobalContext.js做修改,不再导出GlobalContext,而是将它作为一个常量。另外在该文件中创建一个GlobalContextProvider组件,将使用useState钩子一米存储和更新Loading组件可见性状态(state)。为了达到该效果,可能会像下面这样做:

// src/GlobalContext.js
import React, { createContext, useState } from 'react'

export const GlobalContext = createContext()

const GlobalContextProvider = (props) => {
    const [isLoadingOn, setLoading] = useState(false)

    return <GlobalContext.Provider value={{isLoadingOn, setLoading}}>
        {props.children}
    </GlobalContext.Provider>
}

export default GlobalContextProvider

同时App.js中的代码也需要做相应的调整:

// src/App.js
const App = () => {
    return <GlobalContextProvider>
        <Loading />
        <ProfileCard />
    </GlobalContextProvider>
}

然后在Loading组件中,导入GlobalContext而且使用useContext钩子函数引入ProviderisLoadingOn来控制Loading组件的渲染。当isLoadingOntrue时显示Loading,反之不显示:

// src/components/Loading
import React, { useContext } from 'react'
import {GlobalContext} from '../../GlobalContext'
import './index.css'

const Loading = props => {
    const {isLoadingOn} = useContext(GlobalContext)
    return isLoadingOn ? <div className="global-spinner-overlay">
        <ul className="loading-animation alternate">
            <li></li>
            <li></li>
            <li></li>
            <li></li>
            <li></li>
        </ul>
    </div> : null
}

export default Loading

按同样的方式,在ProfileCard中引入GlobalContext,但不需要isLoadingOn,而是需要setLoading函数:

// src/components/ProfileCard
import React, { useState, useEffect, useContext } from 'react'
import {GlobalContext} from '../../GlobalContext'
import './index.css'

const ProfileCard = props => {
    const [profile, setProfile] = useState([])
    const { setLoading } = useContext(GlobalContext)
    useEffect(() => {
        (async () => {
            setLoading(true)
            const result = await fetch('https://api.github.com/users/airen')
            const data = await result.json()
            setProfile(data)
            setLoading(false)
        })()
    },[setLoading])

    const { avatar_url,location, name, bio, followers, following, html_url } = profile

    return <div className="profile">
        <div className="header">
            <div className="avatar">
                <a href={html_url}>
                    <img src={avatar_url} alt={name} />
                </a>
            </div>
            <h2>{name} <span>{bio}</span></h2>
            <h3>{location}</h3>
            <div className="footer">
                <ul className="details">
                    <li>
                        <span>{followers}</span>
                        <strong>Followers</strong>
                    </li>
                    <li>
                        <span>{following}</span>
                        <strong>Following</strong>
                    </li>
                </ul>
            </div>
        </div>
    </div>
}

export default ProfileCard

上面演示的是最简单的实现,但它存在一些问题。我们继续在该示例的基础上进行优化。首先需要对GlobalContext进行改造。就是实现如何将isLoadingOnsetLoading传递给上下文的Provider

<GlobalContext.Provider value={{isGlobalOn, setLoading}}>
    {props.children}
</GlobalContext.Provider>

当传递给Provider的值发生更改时,会重新渲染所有上下文使用者,这也意思味着,如果改变isLoadingOn的值或父组件重新渲染,那么LoadingProfileCard组件都将重新渲染。这对于页面的性能来说是有很大影响。要解决这个问题,可以使用useMome钩子函数,它会缓存值对象。这样一来,只有isLoadingOn的值发生变化时,它才会被重新创建。

// src/GlobalContext.js
import React, { createContext, useState, useMemo } from 'react'

export const GlobalContext = createContext()

const GlobalContextProvider = (props) => {
    const [isLoadingOn, setLoading] = useState(false)

    const value = useMemo(() =>({
        isLoadingOn,
        setLoading
    }),[isLoadingOn])

    return <GlobalContext.Provider value={value}>
        {props.children}
    </GlobalContext.Provider>
}

export default GlobalContextProvider

这解决了在每个渲染上重新创建一个新对象,从而重新渲染所有消费者(Consumer)的问题。不幸的是,这样还是存在一个问题。即避免重新渲染所有上下文使用者

现在,只要isLoadingOn值发生变化就会创建一个新的值对象。然而,尽管Loading组件依赖于isLoadingOn,但它并不依赖于setLoading函数。同样,ProfileCard只需要访问setLoading函数。因此,在每次isLoadingOn可见性发彺更改时都重新渲染ProfileCard是没有意义的,因为组件并不直接依赖于它。所以说,为了避免这个问题,我们可以创建另一个上下文来分离isLoadingOnsetLoading

这个时候GlobalContext.js文件可以改造成:

// src/GlobalContext.js
import React, { createContext, useState, useMemo } from 'react'

export const GlobalContext = createContext()
export const GlobalActionsContext = createContext()

const GlobalContextProvider = (props) => {
    const [isLoadingOn, setLoading] = useState(false)

    return <GlobalContext.Provider value={isLoadingOn}>
        <GlobalActionsContext.Provider value={setLoading}>
            {props.children}
        </GlobalActionsContext.Provider>
    </GlobalContext.Provider>
}

export default GlobalContextProvider

由于有两个上下文提供者(Provider),组件可以准确地使用它们需要的内容。现在,我们需要更新LoadingProfileCard组件时使用正确的值。

Loading组件原本就是通地GlobalContext来消费Provider提供的数据,所以它不需要做任何的调整。但ProfileCard组件中,需要将GlobalContext更换成新创建的上下文GlobalActionsContext:

// src/components/ProfileCard
import React, { useState, useEffect, useContext } from 'react'
import {GlobalActionsContext} from '../../GlobalContext'
import './index.css'

const ProfileCard = props => {
    const [profile, setProfile] = useState([])
    const setLoading  = useContext(GlobalActionsContext)
    useEffect(() => {
        (async () => {
            setLoading(true)
            const result = await fetch('https://api.github.com/users/airen')
            const data = await result.json()
            setProfile(data)
            setLoading(false)
        })()
    },[setLoading])

    const { avatar_url,location, name, bio, followers, following, html_url } = profile

    return <div className="profile">
        <div className="header">
            <div className="avatar">
                <a href={html_url}>
                    <img src={avatar_url} alt={name} />
                </a>
            </div>
            <h2>{name} <span>{bio}</span></h2>
            <h3>{location}</h3>
            <div className="footer">
                <ul className="details">
                    <li>
                        <span>{followers}</span>
                        <strong>Followers</strong>
                    </li>
                    <li>
                        <span>{following}</span>
                        <strong>Following</strong>
                    </li>
                </ul>
            </div>
        </div>
    </div>
}

export default ProfileCard

这样一来,我们就解决了性能问题。但我们还有地方需要进一步的改进,即使用上下文值的方式有关。

要在任何组件中使用isLoadingOn上下文值,我们必须直接导入上下文以及useContext钩子。通过使用useContext色子调用的包装器,我们可以使它变得不那么单调乏味。我们不再直接导出上下文值,而是使用自定义函数来使用上下文。

// src/GlobalContext.js
import React, { createContext, useState, useContext } from 'react'

const GlobalContext = createContext()
const GlobalActionsContext = createContext()

export const useGlobalContext = () => useContext(GlobalContext)
export const useGlobalActionsContext = () => useContext(GlobalActionsContext)

const GlobalContextProvider = (props) => {
    const [isLoadingOn, setLoading] = useState(false)

    return <GlobalContext.Provider value={isLoadingOn}>
        <GlobalActionsContext.Provider value={setLoading}>
            {props.children}
        </GlobalActionsContext.Provider>
    </GlobalContext.Provider>
}

export default GlobalContextProvider

接下来,我们必须调整LoadingProfileCard组件,并使用useGlobalContextuseGlobalActionsContext来替换useContext钩子函数:

// src/components/Loading
import React from 'react'
import {useGlobalContext} from '../../GlobalContext'
import './index.css'

const Loading = props => {
    const isLoadingOn = useGlobalContext()
    return isLoadingOn ? <div className="global-spinner-overlay">
        <ul className="loading-animation alternate">
            <li></li>
            <li></li>
            <li></li>
            <li></li>
            <li></li>
        </ul>
    </div> : null
}

export default Loading

// src/components/ProfileCard
import React, { useState, useEffect } from 'react'
import {useGlobalActionsContext} from '../../GlobalContext'
import './index.css'

const ProfileCard = props => {
    const [profile, setProfile] = useState([])
    const setLoading  = useGlobalActionsContext()
    useEffect(() => {
        (async () => {
            setLoading(true)
            const result = await fetch('https://api.github.com/users/airen')
            const data = await result.json()
            setProfile(data)
            setLoading(false)
        })()
    },[setLoading])

    const { avatar_url,location, name, bio, followers, following, html_url } = profile

    return <div className="profile">
        <div className="header">
            <div className="avatar">
                <a href={html_url}>
                    <img src={avatar_url} alt={name} />
                </a>
            </div>
            <h2>{name} <span>{bio}</span></h2>
            <h3>{location}</h3>
            <div className="footer">
                <ul className="details">
                    <li>
                        <span>{followers}</span>
                        <strong>Followers</strong>
                    </li>
                    <li>
                        <span>{following}</span>
                        <strong>Following</strong>
                    </li>
                </ul>
            </div>
        </div>
    </div>
}

export default ProfileCard

由于useContext只能在Context.Provider中调用。为了确保我们不会在Provider之外使用上下文,我们可以检查是否有上下文值。

// src/GlobalContext.js
import React, { createContext, useState, useContext } from 'react'

const GlobalContext = createContext()
const GlobalActionsContext = createContext()

export const useGlobalContext = () => {
    const context = useContext(GlobalContext)

    if (context === undefined) {
        throw new Error(`useGlobalContext must be called within GlobalContextProvider`)
    }

    return context
}

export const useGlobalActionsContext = () => {
    const context = useContext(GlobalActionsContext)

    if (context === undefined) {
        throw new Error(`useGlobalActionsContext must be called within GlobalContextProvider`)
    }

    return context
}

const GlobalContextProvider = (props) => {
    const [isLoadingOn, setLoading] = useState(false)

    return <GlobalContext.Provider value={isLoadingOn}>
        <GlobalActionsContext.Provider value={setLoading}>
            {props.children}
        </GlobalActionsContext.Provider>
    </GlobalContext.Provider>
}

export default GlobalContextProvider

正如上面代码所示,我们首先检查context值,而不是立即返回useContext的结果。如果context未定义,则抛出相应错误。然而,对于每个useContext消费者(Consumer)都这样做有点重复,因此,我们可以将其抽取出来重复使用。

// src/GlobalContext.js
import React, { createContext, useState, useContext } from 'react'

const GlobalContext = createContext()
const GlobalActionsContext = createContext()

/* eslint-disable*/ 
const useContextFactory = (name, context) => {
    return () => {
        const ctx = useContext(context)
        if (ctx === undefined) {
            throw new Error(`use${name}Context must be use withing a ${name}ContextProvider`)
        }
        return ctx
    }
}

/* eslint-enable */

export const useGlobalContext = useContextFactory('GlobalContext', GlobalContext)
export const useGlobalActionsContext = useContextFactory('GlobalActionsContext', GlobalActionsContext)

const GlobalContextProvider = (props) => {
    const [isLoadingOn, setLoading] = useState(false)

    return <GlobalContext.Provider value={isLoadingOn}>
        <GlobalActionsContext.Provider value={setLoading}>
            {props.children}
        </GlobalActionsContext.Provider>
    </GlobalContext.Provider>
}

export default GlobalContextProvider

有关于这方面的介绍还可以阅读《Global state with React》一文。

小结

React Context API是React的一个特性,在React中可以很好的用来处理组件之间的数据通讯,也能更易于处理状态的管理。但React Context API也不是银弹,也不是全能的,可以用于任何场景之中。在实际使用之中,我们还是需要根据具体场景做出正确的选择。在文章中我们主要对React Context API做了一些初步的探讨,学习了这方面最基础知识和运用。如果你在这方面有经验,欢迎在下面的评论分享。