欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

在Redux里怎么存数据?论数据标准化

程序员文章站 2022-04-20 20:05:36
...

在一个复杂的项目中,我们可以将Redux store看成为客户端的缓存数据库。

众所周知数据库的设计大有讲究,需要考虑到数据查找的性能,和可拓展性。作为前端“数据库”的Redux store也是同样的,存放的方式、位置不好会增加bug出现的几率、读写数据的复杂度、维护的成本。

那你说我们在数据层已经将数据处理了一遍,前端再加一层处理不是多此一举吗?的确,随着对用户体验的要求越来越高,而支持着这种极致体验的网络速度还无法跟上的时候,特殊手段的出现是必然的。

在这里,我想讨论几种我用到或者看到的实现方式,每种可能适应不同的场景。如果你有更好的想法,欢迎留下评论。

全局唯一的数据

比如当前登陆用户的一些必要信息。每次读取API都直接覆盖之前存的数据,特别简单直观。

store结构例子:

type MyStore = {
  id: number,
  name: string,
  // ...
};

详情数据

这里的详情指通常是作为一个页面的主要数据存在的,没有这个数据页面就没法显示的这种。比如:博客里的博客内容,电商里的产品信息,商店信息等。

这类的数据通常都会有一个全局唯一的ID,我们就拿这个ID作为Object key来使用,如下:

type MyStoreById = {
    [key: number]: {
        id: number,
        content: string,
        // ...
    }

列表数据

列表数据应该是最常见的了,比如:待办事项列表,博客文章列表,搜索结果列表等。

这类的数据,处理的复杂度就在于,你可能需要根据具体的业务逻辑和UX来设计存储的方式,而且通常要考虑分页的问题。

分页是指一个列表太长的时候,需要分成多页显示。目前市面上常见的逃不开到底/顶懒加载,和点击页码切换这两种UX(像瀑布流这种就是两者结合)。

最常见的做法是通过offset(直译为偏移,列表开始的位置),limit(列表的长度)向API请求一定数量的数据。API返回数据中带上一些必要的帮助前端判断有没有下一页或者一共多少页的信息,最常用的是列表总长。也就是说你在store里存的一般不会是很简单的一个Object数组,而可能是这样的:

type MyListStore = {
    [key: string | number]: {
        list: ListItem[],
        total: number
    }
};

那这个key用什么呢?答案也是不唯一的。我们要考虑的不仅仅是读写数据时的时间、空间复杂度,还有重用性,扩展性等等。

拿博客来说,通常博客的列表会根据栏目来分。这里其实有2种列表:栏目列表,和该栏目的博客列表。

拿每个栏目里的博客列表来说,有这个列表的前提是有这个栏目的ID对吧。所以就可以用栏目ID作为key。

但是别忘了上面讲的分页问题。下拉懒加载的可以就粗暴地使用单个数组,每次懒加载都往数组最后追加数据, 如下:

type Action = {
    type: string,
    key: number,
    data: BlogList
};

type Blog = {
    id: number,
    title: string,
    content: string
};

type BlogList = {
    list: Blog[],
    total: number
};

type ListReducerState = {
    [key: number]: BlogList
};

export default function listReducer(
    state: ListReducerState = {}, 
    action: Action
) {
    switch (action.type) {
        caseDATA_LOADED: {   // API返回数据后执行
            const { key, data } = action;
            if (!Array.isArray(data.list) || !data.list.length) { return; }
            
            const existingList = state[key] || {};
            const updatedList = {
                // 往数组最后追加最新拉下来的数据
                list: [...(existingList.list || []), ...data.list],
                total: data.total
            };
            return {
                ...state,
                [key]: updatedList
            };
        }
    }
    return state;
}

如上这种做法适用于下拉懒加载是由于懒加载的请求一定是按照从上到下顺序来的,请求不允许跳跃。对于这样的列表读取数据特别地简单。

但可以根据点击页码切换的话,放在一个列表里的结构就坑大了。

首先需要解决的问题是新加载的一页数据存储在什么位置呢?如上一般直接放在同一个列表最后面不是明智的行为。因为用户可以随便选择跳哪页,导致请求的数据可能是不连续的。并且redux的设计理念跟Promise不同,它将请求数据的行为,和获取数据的过程隔离开了。在redux中想获取对应页面的数据,就要约定好数据存放的规则。

我曾经试图用offset和limit来覆盖列表指定位置的数据。如下:

function MyListReducer(state, action) {
    switch (action.type) {
        caseLIST_LOADED: {
            const { key, data, offset, limit } = action;
            const items = [...state[key].items];
            for (let i= 0; i < limit; i++) {
                items[offset + i] = data.items[i];
            }
            return {
                ...state,
                [key]: {
                    ...state[key],
                    ...data,
                    items,
                }
            };
        }
        // ...
    }
    return state;
}

然后如下选出该页的item:

function getItems(state, key, offset, limit) {
    return state && state[key] && state[key].items.slice(offset, limit);
}

这似乎是可以通用于两种分页设计的完美方案。但很快问题就暴露了出来。这个选取列表部分数据的方法会每次新建一个数组,在react中如果用props传入这组数据,可能会大大增加不必要的渲染,导致性能下降。

还可能遇到非常奇葩的问题。比如线上我们不靠谱的API因为缓存数据可能已经不再有效,在传回数据前额外做了一次数据过滤,导致返回的列表数据可能不足limit的值。也就是说,这个列表里可能会有空白位置,渲染时要注意处理,防止JS报错。再如,脑洞大开的QA尝试同一页面加了两个同类型的列表模块,只是limit不同。2个异步请求有非常偶然的机会遇到同一位置的数据不同,而这2个请求最后都会改变同一个列表里的数据,这就有了一个竞争的问题,而且有一定几率碰到中间有一两个数据发生重复。那么,显示时还要做一次去重吗?怎么看都有些得不偿失。

不如每页都分开存吧。这样只有在更新这一页数据的时候,才会改变它的引用,不会因为其他部分更新导致重新渲染。也不用处理很奇葩的问题,选取数据的时候也简单许多。

有朋友曾经纠结过这降低了数据的重用性。这就多虑了,有多大的几率用户可能看到同一组不需要更新的数据呢?

假如你坚定地跟我说有,那好,我这还有个idea:

type MyListStore = {
    list: {
        [key: string]: {
            items: ItemId[],
            total: number
        }
    },
    itemPool: {
        [itemId: number]: Item[]
    }
};

对,就是在列表中只存一组id,所有列表项的数据存在另外一个Object中,作为一个item池。不过这很有可能意味着你要把API同学幸苦拼好的数据重新组装。

列表和详情

有列表,通常就有详情页。而API通常为了节省带宽,在列表中会考虑只返回必要的属性值。比如博客列表就只需要返回博客标题和一个摘要,不会包括整篇博客内容。

为什么提到这个问题呢?因为从用户的行为来看,浏览列表之后,非常可能点击查看详情。也就是说,列表里的某一组数据非常有可能被重新利用。那何不在进入详情时,先用这部分数据显示一部分内容,等拿到详情数据后替换呢?

这样做的话,首先列表项最好用Map类型,或者用上述item池的方式存储,否则详情页读取指定数据的时候查找的复杂度高。然后,详情页就要充分做好数据缺失的准备,不要因为读取一个undefined对象的属性值而报错导致整个页面挂掉。

API状态

在异步处理的时候,显示一些状态和进度对用户体验来说是非常重要的。最常见的就如,拉取数据的时候,需要显示一个加载的动图;提交表单之后,就要禁用提交按钮,防止用户多次提交,并且告诉用户系统正在处理之类。

这就涉及到,如何在像react这样的框架中,和redux配合显示API的状态。

当然你可以简单粗暴地用Promise。但在react中需要注意component是否已经unmount。比如:

class MyComponent extends React.Component {
    static state = {
        loading: false
    };

    componentDidMount() {
        this.loadData();
        this._unmounted = false;
    }
    componentWillUnmount() {
        this._unmounted = true;
    }
    async loadData() {
        this.setState({
            loading: true
        });
        await this.props.loadData();
        if (!this._unmounted) {
            this.setState({
                loading: false
            });
        }
    }
    render() {
        return this.state.loading? (
            <div>Loading...</div>
        ) : (
            <Item data={this.props.data} />
        )
    }
}

这里看起来非常多此一举的this._unmounted,是因为用户非常有可能在API返回结果前,就离开了当前页面导致component unmount。如果在unmount之后还继续做setState之类的操作,React会抱怨。

其实React从本质上看是“反映当前的状态”,它期望根据一组状态值来渲染对应的UI,而不期望像Promise一样,需要等待。所以有人就提出了将API的状态在redux store中保存,而redux store只要有更新就会通知React更新,这样React就能通过读取redux中存储的当前状态就可以显示相应的内容。

举例:

export function dataReducer(state = {}, action) {
    switch(action.type) {
        case ‘fetch_data_succeed’:
            return {
                ...state,
                [action.key]: action.data
            }; 
    }
    return state;
}

export const API_STATE = {
    init: 0,
    loading: 1,
    loaded: 2,
    error: 3
};
export function apiStateReducer(state = {}, action) {
    switch(action.type) {
        case ‘fetch_data_requested’:
            return {
                ...state,
                [action.key]: {
                    state: API_STATE.loading
                }
            };
        case ‘fetch_data_succeed’:
            return {
                ...state,
                [action.key]: {
                    state: API_STATE.loaded
                }
            };
        case ‘fetch_data_failed’:
            return {
                ...state,
                [action.key]: {
                    state: API_STATE.error,
                    error: action.error
                }
            };
    }
    return state;
}

export default combineReducer({
    data: dataReducer,
    apiState: apiStateReducer
});

上例中我建了2个reducer,但其实只处理了3个行为(action):

  • fetch_data_requested 表示已请求API
  • fetch_data_succeed 表示API请求成功,拿到结果
  • fetch_data_failed 表示API请求失败,服务器报错

第一个dataReducer只管请求成功后将数据保存。第二个apiStateReducer则只保存这个API请求的状态。两者分开,可以保证互不影响。

然后在React component中,如果要显示一个加载中的状态,就只要在这个apiState中查找对应的状态就行:

import React from ‘react’;
import { connect } from ‘react-redux’;

function MyComponent({ loading, data }) {
    return loading ? (
        <div>Loading...</div>
    ) : (
        <DataComponent data={data} />
    );
}
export default connect((state, ownProps) => {
    const { key } = ownProps;
    return {
        loading: state.apiState[key].state === API_STATE.loading,
        data: state.data[key]
    };
})(MyComponent);

扩展:实现缓存

对这个apiState稍加修改,还可以实现类似“缓存”的功能,可以用于节流。

首先,在我们的apiState里加一个时间戳,假设用发出请求的时间作为基准:

type ApiState = {
    state: API_STATE,
    error: string | number | void,
    requestTime: number
};

Reducer 相应地修改为:

export function apiStateReducer(state = {}, action) {
+  const requestTime = state[action.key] && state[action.key].requestTime;
    switch(action.type) {
        case ‘fetch_data_requested’:
            return {
                ...state,
                [action.key]: {
+                  requestTime: Date.now(),
                    state: API_STATE.loading
                }
            };
        case ‘fetch_data_succeed’:
            return {
                ...state,
                [action.key]: {
+                  requestTime,
                    state: API_STATE.loaded
                }
            };
        case ‘fetch_data_failed’:
            return {
                ...state,
                [action.key]: {
+                  requestTime,
                    state: API_STATE.error,
                    error: action.error
                }
            };
    }
    return state;
}

在action中就可以判断上次请求时间是否已经超过比如1分钟,超过1分钟才重新请求(例子用thunk中间件):

export function fetchAction(params) {
    return async (dispatch, getState) => {
        const key = makeKey(params);
        const { requestTime } = getState().myState.apiState[key] || {};
        if (Date.now() - requestTime < 60000) {
            return;
        }
        dispatch({ type: ‘fetch_data_requested’ });
        const response = await makeAPICall(API_ENDPOINT, params);
        if (hasError(response)) {
            dispatch({ type: ‘fetch_data_failed’, error: getError(response) });
        } else {
            dispatch({ type: ‘fetch_data_succeed’,  data: response });
        }
        return response;
    };
}