使用游标和队列实现一个“无限”翻页功能

2,866 阅读4分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1、前言

  • 列表查询

Table用于展示多条结构类似的数据载体,经常出现在统计、排序、筛选、对比等场景中,对此已非常熟悉,对于一维表格通常都是行和列组成,每一行是一个实体,每一列是实体相关的一个属性。(在数据分析中常用矩阵向量来建模。)一般由于实体数据都比较多,出于查询性能和渲染压力等方面考虑,往往会采取分页(携带当前页和每页显示实体数)查询。

  • 详情查询

实体属性太多,列表属性可展示有限(太多辣眼睛),那么解决矛盾的办法就是,通常增加点击单个实体,侧面板会弹出实体相关更详(rong)细(yu)的信息,但是矛盾又出现了,每次查看实体详情还得关了面板,回到列表再点下一条。怎么省去这些繁琐的操作?借鉴列表查询的交互能否在侧面板实现下一页或者上一页的功能呢?我们尝试借鉴mysql游标思路实现一下。

2、简图

Pasted Graphic 2.png

Tips:
1、实体详情页 “->” 可以查看列表中的下一个实体详情;“<-” 可以查看列表中的上一个实体详情。
2、如果下一页或者上一页超出列表边界,下一页会自  动查接口翻页,而列表保持不变。

3、分析

整体以一般到特殊再到边界分析(前提假设:数据默认都以第二页,每页数据10条为例)

3.1 常规操作

image.png 初始化首先定位游标位置,在游标指针指向正确后,当有下一页(或者上一页)操作时,先移动游标位置,再根据游标指针获取队列中的实体属性。

3.2 特殊操作

假设实体总条数28,恰好游标已移动到队列的尾部也就是20的位置上,此时队列中只有20条数据,那接下来需要先扩容队列数据(请求第三页的数据);同理游标位置出于11的位置,上一页时操作同下一页先扩容再移动游标。如下图:

image.png

(往下翻操作)

image.png

(往上翻操作)

3.3 边界操作

作为一个洁癖程序猿,在交互细节也得考虑下。假设实体总数28,往下翻页,刚好游标移动到队列的尾部(注意此时是所有数据都已拉回)那么下一页的按钮应该禁用。一点是详情翻页用户并不像列表翻页很清楚某个实体数据在的位置(哪一页的哪一条,前面和后面各有多少条一目了然),然而详情翻页用户只清楚当前这一条数据,并不清楚上下位置关系,此时禁用会给用户很好的归属感。另外一点可以减少不必要的请求和详情重绘。细品,还真是那个味~

4、整体交互流程图

image.png

5、Code

基于整体分析,思路比较清晰了,接下来我们开始codeing。

5.1 Core

在js中我们使用数组索引模拟游标,使用数组的concat 和 unshift 来模拟队列。游标配合队列需要一些状态和方法,我们封装成一个类里。

/**
 * @file 详情翻页
 * @return 当前实体信息和翻页检查结果
 * @params id 用来定位游标初始化位置
 * @param  innerPageCfg 初始化分页数据
*/
export default class PaginationSet {
    constructor(props) {
        this.values = [];
        this.currIndex = 0; // 模拟游标
        this.totalCount = 0;
        this.currPage = 1;
        this.pageSize = 20;
        this.init(props);
    }
    init(props) {
        const {
            sourceData,
            innerPageCfg
        } = props;
        this.initInnerPageCfg(innerPageCfg);
        this.initPotCustIdList(sourceData);
    }
    filterId(sourceData) {
        return sourceData.map(item => item.potCustId);
    }
    concat(sourceData, page) {
        const potCustIdList = this.filterId(sourceData);
        const values = potCustIdList.map(id => {
            return {
                currPage: page,
                potCustId: id,
                pageSize: this.pageSize,
                totalCount: this.totalCount
            };
        });
        this.values = this.values.concat(values);
    }
    unshift(sourceData, page) {
        this.currIndex = sourceData.length - 1; // 修正当前索引
        const potCustIdList = this.filterId(sourceData);
        const values = potCustIdList.map(id => {
            return {
                currPage: page,
                potCustId: id,
                pageSize: this.pageSize,
                totalCount: this.totalCount
            };
        });
        this.values = values.concat(this.values);
    }
    has(val) {
        return this.values.includes(val);
    }
    add(val) {
        if (!this.has(val)) {
            this.values.push(val);
        }
    }
    clear() {
        this.values = [];
        this.currIndex = 0;
    }
    initInnerPageCfg(innerPageCfg) {
        const {
            currPage,
            totalCount,
            pageSize
        } = innerPageCfg;

        this.totalCount = totalCount || 0;
        this.currPage = currPage || 1;
        this.pageSize = pageSize || 20;
    }
    initPotCustIdList(sourceData) {
        const potCustIdList = this.filterId(sourceData);
        this.values = potCustIdList.map(id => {
            return {
                currPage: this.currPage,
                potCustId: id,
                pageSize: this.pageSize,
                totalCount: this.totalCount
            };
        });
    }
    findCurrindex(potCustId) {
        this.values.forEach((item, i) => {
            if (item.potCustId === potCustId) {
                this.currIndex = i;
            }
        });
    }
    hasNextItem() {
        const current = this.values[this.currIndex];
        return (this.currIndex === this.values.length - 1) && current.currPage * current.pageSize >= current.totalCount;
    }
    hasPrevItem() {
        const current = this.values[this.currIndex];
        return this.currIndex === 0 && current.currPage === 1;
    }
    enable() {
        return this.hasPrevItem() || this.hasNextItem();
    }
    next() {
        if (this.currIndex < this.values.length) {
            this.currIndex++;
        }
        let temp = {
            value: this.values[this.currIndex] && this.values[this.currIndex].potCustId,
            done: this.currIndex >= this.values.length,  // 翻页检查器
            pageInfo: {
                page: this.values[this.values.length - 1].currPage + 1,
                pageSize: this.values[this.values.length - 1].pageSize
            },
            tail: this.hasNextItem()
        };
        return temp;
    }
    prev() {
        if (this.currIndex >= 0) {
            this.currIndex--;
        }
        let temp = {
            value: this.values[this.currIndex] && this.values[this.currIndex].potCustId,
            done: this.currIndex < 0, // 翻页检查器
            pageInfo: {
                page: this.values[0].currPage - 1,
                pageSize: this.values[0].pageSize
            },
            tail: this.hasPrevItem()
        };
        return temp;
    }
}

5.2 pagination.vue

<template>
    <span class="pagination-wrapper">
        <span  :class="[prev && 'forbidde']">
            <i  class="el-icon-arrow-left"
                :title="prev ? '暂无上一条' : '上一条'"
                @click.stop="flipPagination('prev')"
            >
            </i>
        </span>
        <span  :class="[next && 'forbidde']">
            <i  class="el-icon-arrow-right"
                :title="next ? '暂无下一条' : '下一条'"
                @click.stop="flipPagination('next')"
            >
            </i>
        </span>
    </span>
</template>
<script>
export default {
    data() {
        return {
            next: false,
            prev: false,
        }
    }
    methods: {
        initPagination() {
            // this.detailpageInfo 为父组件传来上面👆🏻PaginationSet的实例
            this.detailpageInfo.findCurrindex(this.potCustId); // 定位游标
            this.isTail();
        },
        isTail() {
            // 翻页检查
            this.prev = this.detailpageInfo.hasPrevItem();
            this.next = this.detailpageInfo.hasNextItem();
        },
        flipPagination(type) {
            let temp = this.detailpageInfo[type](); // 返回的实体信息和翻页检查结果
            if (temp.done) { // 扩容
                const pageInfo = temp.pageInfo;
                this.getData(type, pageInfo)
            } else {
                this.$emit('更新详情页', temp);
            }
        },
        getData() {
            new Promise().then(res => {
               type  === 'next' ? 
               this.detailpageInfo.concat(res.dataList, res.page.currPage) :this.detailpageInfo.unshift(res.dataList, res.page.currPage);
               this.$emit('更新详情页', temp);
            })
        }
    }
}
</script>

5.3 list.vue

最外层组件,mounted以后等list数据回来初始化PaginationSet,传给detail组件。

    this.detailpageInfo = new PaginationSet({
                                sourceData: dataList,
                                innerPageCfg: pageCfg
                            }); 

6、总结

1、外层列表翻页每次都会重新实例化PaginationSet,可以保证队列里的游标起始位置总是正确的。
2、PaginationSet中队列翻页innerPageCfg配置总是和外层翻页配置同步,可以保证内外展现顺序表现一致。
3、如果不关闭详情页,队列其实起到了数据缓存的作用,提升用户体验,减少服务器压力。