1 何为 LazyLoad
LazyLoad
,用中文来说就是延迟加载或惰性加载。即一个变量,在被调用的时候,才开始加载自身的内容。这样子可以避免首屏加载时间过长导致的体验不佳。在日常开发中,我们经常会用到LazyLoad
。例如React
中的React.lazy,以及Vue2
中的异步组件:()=>import('./SomeComponent'),都是用于实现组件的延迟加载。而今天这篇文章讨论的是实现Redux
的状态(State
)中非基础类型数据的延迟加载。
阅读这篇文章,你将会:
- 如何借助
redux-thunk
中实现数据延时加载 - 如何借助
redux-saga
中实现数据延时加载
这两种方式,运用在项目里,可以大大减少我们重复写dispatch
语句的次数和数量。以降低项目的复杂程度。
2 Redux Store
中使用延迟加载的好处
已知Redux State
存储的都是公共变量,而某些公共变量是通过异步获取的,如果某个组件(此处以React
组件进行讨论)在交互中需要前面所说的公共变量例如分组groups
时,则需要保证这些公共变量在组件进行交互之前就已被加载。
Redux State
的改变是通过dispatch Action
而触发的。我们通常会把dispatch Action
逻辑写在两处地方:
-
写在组件的生命周期(
useEffect
或componentDidMount
)中。但存在一个 缺点 :存在多个组件用到groups
之类的公共变量,则每个组件都要在相同的生命周期中派发相应的action
,继而多处相同的逻辑会导致让我们的项目代码非常臃肿繁琐。 -
写在入口文件中。这样子可以弥补上面第 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
是指获取的开发人员列表,是一个数组,里面的元素都是一个包含三个属性:id
,group_id
,name
的对象。其中id
指该开发人员的唯一 ID,group_id
指向分组的 ID,name
指开发人员的名字。 -
groups
:{id:name}groups
指分组信息,是一个对象。键id
指分组的唯一ID
,值name
指分组的名字。
接下来要做到的效果时,当通过异步请求获取users
且通过table
组件展示到页面时,通过user.group_id
从groups
读取分组信息,继而引起groups
异步加载数据更新自身。效果如下所示:
3.1 如何捕获读取行为
我们可以利用ES6
中的Proxy去做到捕获读取行为。已知Proxy
的初始化方式如下所示:
const p = new Proxy(target, handler)
其中实例化需要的参数如下:
target
:用Proxy
包装的目标,必须是一个非基础类型的数据,例如数组、函数、对象。handler
:一个定义读取代理函数的对象,里面的各种代理函数会在读写操作时触发执行。
我们把分组数据,也就是groups
作为target
,然后在handler
中定义部分属性,然后把这两者作为Proxy
构造函数的形参实例化出代理对象groupProxy
。这个groupProxy
赋予到Redux state
中的groups
变量上。如下图所示:
现在着重说一下handler
中哪些属性帮助我们可以捕捉那些常用的读取行为。注意此处我们只需关注于捕捉读取行为而不是写入行为。
handler.has
:捕捉in
行为,例如"0" in groups
。handler.get
:捕捉读取行为,例如groups["0"]
、groups.hasOwnProperty("0")
。handler.ownKeys
:捕获Object.keys
行为。
而Object.values
和Object.entries
会触发handler.ownKeys
和handler.get
的执行。
综上所述,当我们读取Redux State
中的groups
时,其实他是个Proxy
实例,继而在读取操作中会触发我们在handler
里定义的函数的执行。
3.2 捕获读取行为后要怎么做
捕获读取行为后,要分两种情况考虑:
Redux State
的groups
中没有user.group_id
对应的分组时:即该groups
没有加载或者数据和后台不一致。因此要做两件事:
- 往后端发出请求获取
groups
。获取数据后生成新的groupProxy
替换Redux State
的groups
。Redux State
的变化会触发React
组件重新渲染(react-redux
库中的connect
函数会让React
组件在所注入的State
变量更新时重新渲染),渲染过程中再次往Redux State
的groups
读取数据时,此时已有user.group_id
对应的分组,就会发生下面第二点。 - 往组件返回一个临时值。
Redux State
的groups
中有user.group_id
对应的分组时:则直接返回该分组。
综合上面的过程可有下面的流程图:
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
的派发流程如下所示:
然后看一下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);
就可以达到下面的效果:
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 后记
之后写的文章都会是结合项目实际经历来写,有什么不懂的欢迎可以评论留言喔。