网络日志

使用React hooks 的context与reducer搭建数据层

思来想去很久我决定在新项目中使用context与reducer来搭建数据层,以前我是Redux的忠实拥护者,在一些项目中我甚至把所有的state放在redux当中进行维护,为的就是寻求一种state的统一,使数据更加持久化,让整个数据层更加明了;但是在接触context与reducer的使用之后我决定做出改变;context与reducer的结合使用实在是太香了,context与reducer的结合使用比Redux的流程优化了不少;当然这是context与reducer的结合使用对比Redux的操作流程我得出的结论,但是对比于React hooks的其他方法来说context与reducer依旧是比较晦涩难懂的知识点;这也不是必须要学会的知识点(只要面试官不问),因为我觉得光useState与effect就能解决开发中百分之九十五的问题,如果余下的百分之五需要你多层级的父子组件当中进行数据通信也没必要非要用到context与reducer,因为一层层的组件透传,redux,mobx,都能实现;

言归正传,context与reducer以及他们如何搭配使用;我从Reducer开始讲起;

Reducer

首先我们先明白一个概念什么是Reducer,细心的你可能已经发现Redux当中也有Reducer这个概念;而Reducer的中文翻译是还原剂的意思,我到现在都没明白为什么要这样子命名(想骂鬼佬的取名),所以我把它当成一个概念来理解,一句话就是接收一个初始值与改变这个初始值的方法,并返回一个state与一个和state配套的dispatch方法,而接收的这个方法就是reducer;官方给出的解释是:

useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。(如果你熟悉 Redux 的话,就已经知道它如何工作了。)

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数 。

如果你熟悉Vue你会发现这个hooks和computed很像,都是为了解决一些单数据或者多数据的属性计算;

那么如何来使用Reducer?首先我们需要设想一个简单的使用场景:

某天下午六点当你高高兴兴的收拾好东西准备和去女友约会,一位不怀好意的单身的产品经理出于嫉妒的目的让你在页面中加一个计数器功能(程序员怎么可能会有女朋友),此时的你会选择怎么做?打这个产品经理一顿出气?还是老老实实的完成以下代码?(法制社会,当然选写代码)

import  react,  {useReducer}  from 'react'
import styles from './index.less';

export default function IndexPage() {

  const [count,dispatch] = useReducer((state,action)=>{
    if (action === 'sub') {
      return state - 1
    }else if(action === 'add'){
      return state +1
    }else{
      return state
    }
  },0)
  return (
    <div>
      <button onClick={()=>{dispatch('add')}}>+</button>
      <button onClick={()=>{dispatch('sub')}}>-</button>
      <h1>{count}</h1>
    </div>
  );
}

不太好懂,我解释下使用useReducer,需要在useReducer中传入一个reducer和初始值,他会返回一个state(为了不混淆,我决定在代码中使用count代替)和dispatch方法,你可以用这个dispatch去改变这个state,然后再说下reducer,也就是useReducer接收的第一个参数,它需要传入一个state与action,state是它要改变的值,而action是改变这个值的判断条件,代码中是判断action是不是add如果是add则返回state+1,是sub则返回state-1,如果都不是就摆烂返回原来的state,然后在jsx语法中我们使用h1去包裹我们需要渲染的count,并通过两个按钮调用dispatch方法并传入sub和add去改变这个count的值;总的来说不是特别好懂,建议手敲两遍看看效果然后再结合文字很快你就明白它的原理了,reducer是一个不是很说的清楚,但是一做就明白的方法;

然后我们再把话题放回到你与产品经理的对峙,当你信心满满的发布这段代码到服务器并向产品经理炫耀你那温柔可人的女友时,此时你们单身四十年还秃顶的前端主管正站在你的身后看着你的代码,头上冒着青筋手里的饮料瓶被他捏的吱吱响,你意识到问题不对了。这时主管发话了,“你就是这样子写计数器的?!这么简单的代码也需要用到reducer?!”一段灵魂发问之后,你把代码改成了以下这样:

import  react,  {useState}  from 'react'
import styles from './index.less';

export default function IndexPage() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={()=>{setCount(count-1)}}>+</button>
      <button onClick={()=>{setCount(count-1)}}>-</button>
      <h1 className={styles.title}>{count}</h1>
    </div>
  );
}

这两段代码实现的效果是一样的,主管说的没错,对于一些简单的计算逻辑useState比useReducer更加快捷方便甚至易懂,也使你的代码更加清晰;但是如果是一些复杂逻辑比如购物车的卡券组合,银行的汇率计算等等,此时你刚好又在各个组件当中进行各种通信,我会建议你使用useReducer,因为你可以通过结合context来进行diapatch的透传从而改变你的state,这样子会使你的代码可读性变得更高,也会避免疯狂的组件间向下传值,使你的数据通信更加明了,从而优化你的数据流;

然后我们讲讲reducer的好搭档context;

Context

在以往我们编写React项目中我们都是通过props在组件中自外向内传值的,这样子其实会造成一个很大的问题如果是多层组件,而你恰恰需要从最外层向最内层传值将会变得非常痛苦,而且会让代码变得非常难懂,在这里我们就需要用到Context,官方是这样子描述Context的

在一个典型的 React 应用中,数据是通过 props 属性自上而下(由父及子)进行传递的,但此种用法对于某些类型的属性而言是极其繁琐的(例如:地区偏好,UI 主题),这些属性是应用程序中许多组件都需要的。Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props。

那么我们要怎样来使用Context?我们继续来设想一个场景:还是这个倒霉的产品经理,当他看到你给的计数器之后并不是很满意,他想要把计数器展示加在页面底部,而底部与计数器显示页面并不是一个组件,然后在你的骂骂咧咧中你完成了以下代码:

import React, {createContext, useContext, useState} from "react";
const CountContext = createContext(null);
//底部组件
const Bottom = ()=>{
  let count = useContext(CountContext)
  return <div>{count}</div>
}
//显示页面
export default ()=>{
  const [count,setCount] = useState(0)
  return (
    <div>
      <button onClick={()=>setCount(count+1)}>+</button>
      <button onClick={()=>setCount(count-1)}>-</button>
      <CountContext.Provider value={count}>
        <Bottom/>
      </CountContext.Provider>
    </div>
  )
}

我来解释下以上代码,为了让展示更加直观我将展示页面与底部组件写在了同一个页面;首先我们要先创建一个Context用来储存与派发数据,所以我们要用到createContext这个方法,

const CountContext = createContext(null);

这个方法有一个参数是你需要传输的数据的默认值,由于我们这里用到的组件当中不需要默认值,并且我们需要对这个值在展示页面中创建和使用,所以不需要单独设置默认值,因此我将这个值设为null;创建完Context之后,你所创建的Context会返回一个Provider组件,此时你需要将你需要传值的组件进行包裹;

//显示页面
export default ()=>{
  //在此我们创建一个state用于改变计数器的值
  const [count,setCount] = useState(0)
  return (
    <div>
      <button onClick={()=>setCount(count+1)}>+</button>
      <button onClick={()=>setCount(count-1)}>-</button>
    /**
    使用useContext来接收Context,接收参数为你所创建的Context,
    这个hooks会返回一个state给你,也就是你用Provider所传输的参数,
    文中接收的参数为count**/
      <CountContext.Provider value={count}>
        <Bottom/>
      </CountContext.Provider>
    </div>
  )
}

当完成Context的创建与count的存储传输之后我们需要在子组件当中对count进行接收,此时我们需要在子组件中调用useCntext这个hooks对count进行接收

import React, {createContext, useContext, useState} from "react";
const CountContext = createContext(null);
const Bottom = ()=>{
/*
    使用useContext来接收Context,接收参数为你所创建的Context,
    这个hooks会返回一个state给你,也就是你用Provider所传输的参数,
    文中接收的参数为count
*/
  let count = useContext(CountContext)
  return <div>{count}</div>
}

此时如果你完成了以上代码,你会发现已经可以通过Context来对子组件进行数据通信了;

Context与Reducer搭建数据层

既然我们已经了解了Context与Reducer的运行,应该也能联想出Context与Reducer所搭建的数据流的运行流程;使用Context来进行state的存储与通信,Reducer用来返回修改state的dispatch方法,整套流程上存储派发更新三个步骤都有了;但是我们要怎么样实现数据层呢?

比如某天那个不懂事的产品经理有一个需求,让你写一个修改用户信息的需求,而用户信息我们存在于数据流中进行维护;这时候如果我们需要修改用户信息,让数据在全局进行改变与渲染则需要使用Context与Reducer的搭配使用;

为了方便演示我暂时不将它拆开封装,等讲完原理我会将它分类拆开封装;首先我们需要编写一个Store文件;

import React, {createContext, useReducer} from "react";

//创建一个context
export const StoreContext = createContext({});
//以下为为了区分事件所创立的typesKey
export const UPDATE_NAME = 'UPDATE_NAME';//更新名字
export const UPDATE_AGE = 'UPDATE_AGE';//更新年龄
export const UPDATE_SEX = 'UPDATE_SEX';//更新性别
export const ADD_AGE = 'ADD_AGE';//年龄加一
export const SUB_AGE = 'SUB_AGE';//年龄减一
export const TOGGLE_SEX = 'TOGGLE_SEX';//切换性别
export const UPDATE_ALL_INFO = 'UPDATE_ALL_INFO'//更新全部信息
//默认State
const defaultState = {
    name: '丧彪',
    age: 18,
    sex: '男'
}

const reducer = (state, action) => {
    //在reducer中需要对事件进行判断来达到不同的state更新
    if (action.type === UPDATE_NAME) {
        state.name = action.name
    } else if (action.type === UPDATE_AGE) {
        state.age = action.age
    } else if (action.type === ADD_AGE) {
        state.age++
    } else if (action.type === SUB_AGE) {
        state.age--
    } else if (action.type === TOGGLE_SEX) {
        state.sex = state.sex==='男'?'女':'男'
    } else if (action.type === UPDATE_ALL_INFO) {
        state.name = action.name
        state.age = action.age
        state.sex = action.sex
    }
    return JSON.parse(JSON.stringify(state))
}
export const Store = (props: any) => {
    //使用useReducer来创建需要向下传值的state与改变state的dispatch方法
    const [state, dispatch] = useReducer(reducer, defaultState);
    //在这里我们需要把dispatch也传给子组件,使子组件拥有跟新功能
    return (<StoreContext.Provider value={{state, dispatch}}>
            {props.children}
        </StoreContext.Provider>
    )
}

因为已经讲了reducer和context的创建与使用流程,这段代码已经难不倒大家了,所以我只是简单的在代码中进行注意点编注;需要注意的是React追求数据不可变泛式,所以每次更新都需要传入一个新的变量而defaultState是一个对象,所以当我们使用useReducer之后对原对象进行了修改并返回,原则上来讲不是一个新的对象,所以react并不能很好的监听数据变化来进行render与commit,所以需要在reducer中每次返回一个新对象,因此我使用简单的JSON数据类型转化来生成一个新对象返回;

完成以上代码我们需要创建两个组件,一个组件用来展示我们的数据,另一个组件用来改变我们的数据;

展示组件

import React,{useContext} from "react";
import {StoreContext} from "@/store";
export default ()=>{
    const {state} = useContext(StoreContext);
    return (
        <div style={{background:'blue'}}>
            用户姓名是:{state.name}
            <br/>
            用户性别是:{state.sex}
            <br/>
            用户年龄是:{state.age}
        </div>
    )
}

更新组件

import React, {useContext} from "react";
import {
    StoreContext,
    UPDATE_NAME,
    UPDATE_AGE,
    UPDATE_SEX,
    ADD_AGE,
    SUB_AGE,
    TOGGLE_SEX
} from "@/store";

export default () => {
    const {dispatch} = useContext(StoreContext);
    let [name, age, sex] = [null, null, null]

    return (
        <div>
            更新姓名 <input type="text" onInput={e => {
            name = e.target.value
        }}/>
            <button onClick={()=>dispatch({type:UPDATE_NAME,name})}>update</button>
            <br/>
            <br/>
            <br/>
            性别切换:
            <button onClick={()=>dispatch({type:TOGGLE_SEX})}>toggle</button>
            <br/>
            <br/>
            <br/>
            更新性别 <input type="text" onInput={e => {
            sex = e.target.value
        }}/>
            <button onClick={()=>dispatch({type:UPDATE_SEX,sex})}>update</button>
            <br/>
            <br/>
            <br/>
            更新年龄: <input type="text" onInput={e => {
            age = e.target.value
        }}/>
            <button onClick={()=>dispatch({type:UPDATE_AGE,age})}>update</button>
            <br/>
            <br/>
            <br/>
            年龄+1: <button onClick={()=>dispatch({type:ADD_AGE})}>add</button>
            <br/>
            <br/>
            <br/>
            更新-1: <button onClick={()=>dispatch({type:SUB_AGE})}>sub</button>

        </div>
    )
}

再然后我们在入口文件中引入我们所写的Store,展示组件和更新组件;并用Store对展示组件和更新组件进行包裹向下传值

import  react from 'react'
import styles from './index.less';
import {Store} from "@/store";
import Preview from '@/pages/Preview'
import Update from '@/pages/Update'
export default function IndexPage() {

  return (
      <Store>
          <Preview/>
          <Update/>
      </Store>
  );
}

以上我们便完成了数据层存储,传值,修改的整个过程;

但是如果是构建企业级的数据层我们的store虽然已经完成了整个周期,但是因为把所有文件都写在一起使代码可读性变得很差,所以在这里我们需要对store进行拆分封装,拆分封装主要是根据个人习惯,以及是否适合项目来进行拆分;以下我举一个例子,我将其拆分为:

  1. actionTypes.ts 用来封装Reducer判断数据跟新方式的key;
  2. actions.ts 用来封装改变数据与数据初始化的Reducer返回值方法;
  3. state.ts 用来封装默认state;
  4. index.tsx store 入口文件,用来暴露Types与创建的Context Provider组件;

actionTypes.ts 用来封装Reducer判断数据跟新方式的key

export const UPDATE_NAME = 'UPDATE_NAME';//更新名字
export const UPDATE_AGE = 'UPDATE_AGE';//更新年龄
export const UPDATE_SEX = 'UPDATE_SEX';//更新性别
export const ADD_AGE = 'ADD_AGE';//年龄加一
export const SUB_AGE = 'SUB_AGE';//年龄减一
export const TOGGLE_SEX = 'TOGGLE_SEX';//切换性别
export const UPDATE_ALL_INFO = 'UPDATE_ALL_INFO'//更新全部信息

actions.ts 用来封装改变数据与数据初始化的Reducer返回值方法

import  * as types from './actionTypes'
export default {
    [types.UPDATE_NAME](state,actions){
        state.name = actions.name
        return state
    },
    [types.UPDATE_AGE](state,actions){
        state.age = actions.age
        return state
    },
    [types.UPDATE_SEX](state,actions){
        state.sex = actions.sex
        return state
    },
    [types.ADD_AGE](state){
        state.age++
        return state
    },
    [types.SUB_AGE](state){
        state.age--
        return state
    },
    [types.TOGGLE_SEX](state){
        state.sex =  state.sex==='男'?'女':'男'
        return state
    },
}

state.ts 用来封装默认state

export default  {
    name: '丧彪',
    age: 18,
    sex: '男'
}

index.tsx store 入口文件,用来暴露Types与创建的Context Provider组件

import React, {createContext, useReducer} from "react";
import defaultState from './state'
import * as Types from './actionTypes'
import actions from './actions'
export const types = Types
export const StoreContext = createContext({});

const reducer = (state, action) => {
    return actions[action.type]?JSON.parse(JSON.stringify(actions[action.type](state,action))):state
}
export const Store = (props: any) => {
    const [state, dispatch] = useReducer(reducer, defaultState);
    return (<StoreContext.Provider value={{state, dispatch}}>
            {props.children}
        </StoreContext.Provider>
    )
}

以下为封装好的数据层在页面中引用:

  1. Priview.tsx 展示组件;
  2. Update.tsx 更新组件;
  3. Index.tsx 展示组件与更新组件的父组件;

Priview.tsx 展示组件;

import React,{useContext} from "react";
import {StoreContext} from "@/store";
export default ()=>{
    const {state} = useContext(StoreContext);
    return (
        <div style={{background:'blue'}}>
            用户姓名是:{state.name}
            <br/>
            用户性别是:{state.sex}
            <br/>
            用户年龄是:{state.age}
        </div>
    )
}

Update.tsx 更新组件

import React, {useContext} from "react";
import {
    StoreContext,
    types
} from "@/store";

export default () => {
    const {dispatch} = useContext(StoreContext);
    let [name, age, sex] = [null, null, null]

    return (
        <div>
            更新姓名 <input type="text" onInput={e => {
            name = e.target.value
        }}/>
            <button onClick={()=>dispatch({type:types.UPDATE_NAME,name})}>update</button>
            <br/>
            <br/>
            <br/>
            性别切换:
            <button onClick={()=>dispatch({type:types.TOGGLE_SEX})}>toggle</button>
            <br/>
            <br/>
            <br/>
            更新性别 <input type="text" onInput={e => {
            sex = e.target.value
        }}/>
            <button onClick={()=>dispatch({type:types.UPDATE_SEX,sex})}>update</button>
            <br/>
            <br/>
            <br/>
            更新年龄: <input type="text" onInput={e => {
            age = e.target.value
        }}/>
            <button onClick={()=>dispatch({type:types.UPDATE_AGE,age})}>update</button>
            <br/>
            <br/>
            <br/>
            年龄+1: <button onClick={()=>dispatch({type:types.ADD_AGE})}>add</button>
            <br/>
            <br/>
            <br/>
            更新-1: <button onClick={()=>dispatch({type:types.SUB_AGE})}>sub</button>

        </div>
    )
}

Index.tsx 展示组件与更新组件的父组件

import  react from 'react'
import styles from './index.less';
import {Store} from "@/store";
import Preview from '@/pages/Preview'
import Update from '@/pages/Update'
export default function IndexPage() {

  return (
      <Store>
          <Preview/>
          <Update/>
      </Store>
  );
}
其他

context与reducer的封装差不多就这点东西,如果想知道整个运行流程请参照reducer与context搭配使用的未拆分流程;这里说一句每次调用dispatch的时候其实都会在原来的state上面进行修改并返回(包含全部数据的对象),因此在这里我拿简单的解决深拷贝的方式来处理了下,个人不太喜欢这种方式,因为对性能有损耗,但是如果你追求整个项目的性能,其实可以用到一些第三方库,比如immerjs或者immutablejs来搭建一个持久化数据层,如果你这样子做了,那么对项目的内存读取以及防止内存溢出上面会有较大提升,但是这些改变是肉眼无法轻易察觉的,如果你的项目是长久的巨型的那么我建议你使用immerjs与immutablejs;

下一期讲讲immerjs以及以及简单的搭建持久化数据层;