在Redux中实现Lazy-Load,能让你少写很多dispatch语句

1,791 阅读11分钟

1 何为 LazyLoad

LazyLoad,用中文来说就是延迟加载惰性加载。即一个变量,在被调用的时候,才开始加载自身的内容。这样子可以避免首屏加载时间过长导致的体验不佳。在日常开发中,我们经常会用到LazyLoad。例如React中的React.lazy,以及Vue2中的异步组件:()=>import('./SomeComponent'),都是用于实现组件的延迟加载。而今天这篇文章讨论的是实现Redux的状态(State)中非基础类型数据的延迟加载。

阅读这篇文章,你将会:

  1. 如何借助redux-thunk中实现数据延时加载
  2. 如何借助redux-saga中实现数据延时加载

这两种方式,运用在项目里,可以大大减少我们重复写dispatch语句的次数和数量。以降低项目的复杂程度。

2 Redux Store中使用延迟加载的好处

已知Redux State存储的都是公共变量,而某些公共变量是通过异步获取的,如果某个组件(此处以React组件进行讨论)在交互中需要前面所说的公共变量例如分组groups时,则需要保证这些公共变量在组件进行交互之前就已被加载。

Redux State的改变是通过dispatch Action而触发的。我们通常会把dispatch Action逻辑写在两处地方:

  1. 写在组件的生命周期(useEffectcomponentDidMount)中。但存在一个 缺点 :存在多个组件用到groups之类的公共变量,则每个组件都要在相同的生命周期中派发相应的action,继而多处相同的逻辑会导致让我们的项目代码非常臃肿繁琐。

  2. 写在入口文件中。这样子可以弥补上面第 1 点的缺点,但也存在一个 缺点 :公共变量多的情况下会导致请求过多导致首屏加载时间变长(毕竟浏览器对同域名的请求有最大并发数的限制)。

而当我们使用延迟加载,且把延迟加载的逻辑写在与Redux相关的操作中时,就可以很好避免上面的情况。

而且,无论是第一点还是第二点都面临一个同步更新的问题,即不能保证Redux State中的状态是最新的。我们来设定一个场景:在一个商品网站中,Redux State中存在一个存储标签的对象类型的变量tags,这个tags需要从后端获取,而每个商品都会被附上tags中的其中一个标签。而tags是会随着其他商家的新增标签而变化的,那么在不能保证Redux State中的tags和后端数据库中的tags保持一致的情况下,则会出现你浏览新商品时,会存在该商品的标签显示不全的情况。

针对上述问题,部分项目会采用轮询或者websocket通信来保证数据的一致性,可是这么写更是会增加项目的复杂度。

而此处用的延迟加载,也可以完美解决上面的同步更新的问题(这么说好像就不叫作延时加载了,不过其实是这个延时加载的思路顺便解决了这个同步更新的问题)。

3 延迟加载的实现思路

接下来我们假设一个需求,在一个公司内部的网站(类似于工单管理系统)上,我们需要查询获取开发人员users的相关信息,而每个开发人员都属于一个分组中。记录这些分组的是一个存储在Redux State中的对象类型的变量groups。其数据关系如下:

  • users: Array<{id:string, group_id:string,name:string}>

    users是指获取的开发人员列表,是一个数组,里面的元素都是一个包含三个属性:idgroup_idname的对象。其中id指该开发人员的唯一 ID,group_id指向分组的 ID,name指开发人员的名字。

  • groups{id:name}

    groups指分组信息,是一个对象。键id指分组的唯一ID,值name指分组的名字。

接下来要做到的效果时,当通过异步请求获取users且通过table组件展示到页面时,通过user.group_idgroups读取分组信息,继而引起groups异步加载数据更新自身。效果如下所示:

redux-lazy-load.gif

3.1 如何捕获读取行为

我们可以利用ES6中的Proxy去做到捕获读取行为。已知Proxy的初始化方式如下所示:

const p = new Proxy(target, handler)

其中实例化需要的参数如下:

  • target:用Proxy包装的目标,必须是一个非基础类型的数据,例如数组、函数、对象。
  • handler:一个定义读取代理函数的对象,里面的各种代理函数会在读写操作时触发执行。

我们把分组数据,也就是groups作为target,然后在handler中定义部分属性,然后把这两者作为Proxy构造函数的形参实例化出代理对象groupProxy。这个groupProxy赋予到Redux state中的groups变量上。如下图所示:

image.png

现在着重说一下handler中哪些属性帮助我们可以捕捉那些常用的读取行为。注意此处我们只需关注于捕捉读取行为而不是写入行为。

  1. handler.has:捕捉in行为,例如"0" in groups
  2. handler.get:捕捉读取行为,例如groups["0"]groups.hasOwnProperty("0")
  3. handler.ownKeys:捕获Object.keys行为。

Object.valuesObject.entries会触发handler.ownKeyshandler.get的执行。

综上所述,当我们读取Redux State中的groups时,其实他是个Proxy实例,继而在读取操作中会触发我们在handler里定义的函数的执行。

3.2 捕获读取行为后要怎么做

捕获读取行为后,要分两种情况考虑:

  1. Redux Stategroups中没有user.group_id对应的分组时:即该groups没有加载或者数据和后台不一致。因此要做两件事:
  • 往后端发出请求获取groups。获取数据后生成新的groupProxy替换Redux StategroupsRedux State的变化会触发React组件重新渲染(react-redux库中的connect函数会让React组件在所注入的State变量更新时重新渲染),渲染过程中再次往Redux Stategroups读取数据时,此时已有user.group_id对应的分组,就会发生下面第二点。
  • 往组件返回一个临时值。
  1. Redux Stategroups中有user.group_id对应的分组时:则直接返回该分组。

综合上面的过程可有下面的流程图:

image.png

4 用redux-thunk实现lazy-load

在本次需求中,后端有两个接口,一个是请求users的接口(http://localhost:8888/users),另一个是请求groups的接口(http://localhost:8888/groups)。后端的代码如下所示:

var express = require('express')
var app = express()

// users数据
const USERS = [
  {
    id:'0',
    name:'用户A',
    group_id:'0'
  },
  {
    id:'1',
    name:'用户B',
    group_id:'0'
  },
  {
    id:'2',
    name:'用户C',
    group_id:'1'
  }
]

// groups数据
const GROUPS={
  '0':'分组A',
  '1':'分组C'
}

const PORT=8888

// 通过中间件解决浏览器的同源策略问题
app.use(function(req,res,next){
  // 响应头设置Access-Control-Allow-Origin字段
  res.header("Access-Control-Allow-Origin", "*");
  next()
})

app.get('/users', function (req, res) {
  res.send({users:USERS})
})

app.get('/groups', function (req, res) {
  // 此处设置延迟1s后才响应数据是为了能够明显地看到groups于users后加载的效果
  setTimeout(() => {
    res.send({groups:GROUPS})
  }, 1000);
})

app.listen(PORT)

我们接下来试一下用redux-thunk实现上面的逻辑。首先先展示请求函数

export const fetchUsers = ()=>{
  return fetch('http://localhost:8888/users').then(res=>res.json())
}

const loading = false
export const fetchGroups = ()=>{
  return fetch('http://localhost:8888/groups').then(res=>res.json())
}

接下来重点看一下store的编写,首先看一下action中的内容:

action

import {fetchGroups} from '../../apis'

// 设置Redux State中的groups
export const SET_GROUPS=(groups)=>({
  type:'SET_GROUPS',
  groups
})

// 生成groupProxy且派发SET_GROUPS
export const MAKE_GROUPS_PROXY = (groups)=>(dispatch)=>{
  const groupProxy = new Proxy(groups,{
    get(target, property){
      /**
       * 对property的类型和值进行校验,
       * 因为在打开chrome的redux-devtools进行调试时,redux-devtools会调用
       * groups的constructor和Symbol(Symbol.toStringTag)等进行监听更新,我们没必要处理
       * 那些处于原型链上的属性的调用,所以针对property进行类型校验看传入的是否为group_id
       */
      if(!(typeof property==='string'&&/\d+/.test(property))) return target[property]
      /**
       * 如果被代理的对象target中不存在该分组,则返回“加载中”作为临时值,且派发REQUEST_GROUPS()
       * 触发groups同步后端的数据
       */
      if(!(property in target)){
        dispatch(REQUEST_GROUPS())
        return '加载中'
      }
      // 如果被代理的对象target中存在该分组,则直接返回该值
      return target[property]
    }
  })
  dispatch(SET_GROUPS(groupProxy))
}

// 用于避免多个读取行为触发REQUEST_GROUPS多次执行
let loading = false
// 请求groups且派发MAKE_GROUPS_PROXY
export const REQUEST_GROUPS = ()=>async (dispatch) => { 
  if(loading) return
  loading = true
  const {groups} = await fetchGroups()
  loading = false
  dispatch(MAKE_GROUPS_PROXY(groups))
}

重点说一下MAKE_GROUPS_PROXY。他是一个纯函数,可是却写成redux-thunk中**异步Action Creator**的形式。是因为这里需要store.dispatch用于派发SET_GROUPS生成得Action。上面的Action Creator的派发流程如下所示:

image.png

然后看一下reducer的代码:

const reducer = (state,action)=>{
  switch (action.type) {
    case 'SET_GROUPS':
      return {groups:action.groups}
    default:
      return state
  }
}

export default reducer

最后看生成store的逻辑:

import { createStore,applyMiddleware } from 'redux'
import reducer from './reducer'
import thunk from 'redux-thunk'
import {MAKE_GROUPS_PROXY} from './action'

const store = createStore(reducer,{groups:{}}, applyMiddleware(thunk))
/** 
 * 派发MAKE_GROUPS_PROXY({})更新State中的groups,
 * 原本groups在上面的store初始化过程中设定为一个纯对象,此处要把他替换成代理实例
 */
store.dispatch(MAKE_GROUPS_PROXY({}))

export default store

最后在展示主页面逻辑: App.jsx

import React, { useState } from "react";
import { connect } from "react-redux";
import { Table, Button, Space } from "antd";
import {fetchUsers} from '../apis'
import {LoadingOutlined  } from '@ant-design/icons'

const App = (props) => {
  const {groups} = props
  const [users, setUsers] = useState([]);

  const getUsers = async()=>{
    const {users} = await fetchUsers()
    setUsers(users)
  }

  const columns = [
    {
      title: "名字",
      dataIndex: "name",
      align: "center",
      width: 100,
    },
    {
      title: "所属分组",
      dataIndex: "group_id",
      align: "center",
      width: 100,
      render:(group_id)=>groups[group_id]==='加载中'?<LoadingOutlined />:groups[group_id]
    },
  ];
  return (
    <Space direction="vertical" style={{ margin: 12 }}>
      <Button type="primary" onClick={getUsers}>查询</Button>
      <Table
        columns={columns}
        dataSource={users}
        bordered
        title={() => "人员信息"}
        rowKey="id"
      ></Table>
    </Space>
  );
};

const mapStateToProps = ({groups}) => ({
  groups
})

export default connect(mapStateToProps)(App);

就可以达到下面的效果:

redux-lazy-load.gif

项目地址

5 用redux-saga实现lazy-load

当然,有些项目里用redux-saga而不是redux-thunk。因此,这里也提供redux-saga下实现lazy-load的做法。

不过要分析一下,redux-saga中把所有不纯的操作(即非纯函数)都要写成saga来处理。而我们是需要在makeGroupProxy(生成group的代理实例)的方法里,让handler中的get属性,在读取失败的情况下触发响应的saga执行。我们知道,外部触发saga执行通常就有dispatch相应的action了,而我们无法像redux-thunk一样在makeGroupProxy内部拿到store.dispatch。那么,还有另外一种方式让我们在外部触发saga执行吗?答案是有的:eventChannel

之前写过一篇文章介绍eventChannel的用法,我这里就不再重复了。在这个的基础上,直接上redux-saga版本的lazy-load实现代码:

在上面的redux-thunk的代码中,我们不再需要action方面的代码,取而代之的是新建一个saga并在里面开始编写:

saga

import { eventChannel, buffers } from 'redux-saga';
import {fetchGroups} from '../../apis'
import {call,take,put} from 'redux-saga/effects'

// trigger用于触发saga,在Proxy实例化过程中的handler里面调用
let trigger = null

export const makeGroupProxy = (target={})=>{
  return new Proxy(target, {
    get: (target, property) => {
      if(!(typeof property==='string'&&/\d+/.test(property))) return target[property]
      if (!(property in target)) {
        // 如果trigger是函数类型,则调用触发saga执行,继而触发groups的更新
        if (trigger instanceof Function) {
          trigger({});
        }
        return '加载中';
      }
      return target[property];
    }
  });
}

// 用于生成eventChannel
const makeRefreshGroupChannel = () => {
  return eventChannel((emitter) => {
    // emitter赋予给trigger
    trigger = emitter;
    return () => {};
    /**此处buffers.dropping(0)代表eventChannel通道不允许缓存没来得及处理的外部事件源
     * 这样子就可以避免多个读取事件失败时,emitter多次被调用导致请求groups重复 
     */
  }, buffers.dropping(0));
};

export function* watchGroupSaga(){
  // 生成eventChannel通道并监听
  const chan = yield call(makeRefreshGroupChannel);
  try {
    // 当trigger被调用时,进入while语句
    while (yield take(chan)) {
      const {groups} = yield call(fetchGroups);
      if (groups) {
        yield put({
          type: 'SET_GROUPS',
          // 生成groupProxy后更新到Redux State的groups上
          groups: makeGroupProxy(groups),
        });
      }
    }
  } finally {
    console.warn('watchGroup end.');
  }
}

最后在生成store的逻辑上对应改动:

import { createStore,applyMiddleware } from 'redux'
import reducer from './reducer'
import createSagaMiddleware from "redux-saga";
import {makeGroupProxy,watchGroupSaga} from './saga'

const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer,{groups:makeGroupProxy({})}, applyMiddleware(sagaMiddleware))
// 执行watchGroupSaga开启对eventChannel的监听
sagaMiddleware.run(watchGroupSaga);

export default store

就只需还这两处,其余的代码和redux-thunk的一致,既可实现开头的例子中groups的延时加载。

项目地址

6 拓展

其实Proxy的用法有很多,我在一年前就写过利用Proxy来实现惰性加载大型第三方库的文章,有兴趣的可以看一下。

7 后记

之后写的文章都会是结合项目实际经历来写,有什么不懂的欢迎可以评论留言喔。