【草窗韵语】且说Vue组件——Pagination之二三

511 阅读9分钟

不要忽视每一个细节,它往往能够决定你的格局和成败。

简述

Pagination——分页组件在前端一直扮演着一个不可或缺的角色。例如,展示列表数据的场景,通过使用分页组件执行分页多次的数据请求,以此来减轻一次性拉取全部数据对服务器的压力,和对浏览器渲染速度的影响。

设计一个分页组件主要的困难在于,当分页页数无限大时,前端无法吧所有页数全部罗列出来,而只能只保留第一页和最后一页,并使用省略号……隐藏多余的页数,并显示中间区域的少量页数。例如:

图片

这就是整个分页组件的关键逻辑,也是实现较为困难的地方。现在,让我们从头到尾来实现一遍。

DOM结构设计

    <div class="pagination-wrap" v-if="total">
        <ul class="pages" unselectable="unselectable" :style="{ 'text-align': direction }">
            <!-- 上一页 -->
            <li :class="['page-prev', currentPageNo === 1 ? 'page-disabled' : 'page-item']" @click="handlePageChange(currentPageNo-1)">
                <a href="javascript:;">上一页</a>
            </li>
            <!-- 第一页 -->
            <li :class="['page-item', currentPageNo === 1 && 'cur']" @click="handlePageChange(1)">
                <a href="javascript:;">1</a>
            </li>
            <!-- 左边显示更多... -->
            <li v-show="isMoreLeft" class="page-ellipsis">...</li>
            
            <!-- 中间页列表循环 -->
            <li 
                :class="['page-item', currentPageNo === it && 'cur']"
                :key="idx"
                v-for="(it, idx) in pageNos"
                @click="handlePageChange(it)"
            ><a href="javascript:;">{{it}}</a></li>
            
            <!-- 右边显示更多... -->
            <li v-show="isMoreRight" class="page-ellipsis">...</li>
            <!-- 末页 -->
            <li 
                v-show="pageCount > 1"
                :class="['page-item', currentPageNo === pageCount && 'cur']" 
                @click="handlePageChange(pageCount)"
            >
                <a href="javascript:;">{{pageCount}}</a>
            </li>
            <!-- 下一页 -->
            <li 
                :class="['page-next', currentPageNo === pageCount ? 'page-disabled' : 'page-item']" 
                @click="handlePageChange(currentPageNo+1)"
            >
                <a href="javascript:;">下一页</a>
            </li>
        </ul>
    </div>

分页器外部由一个div包裹,内部是一个<ul>无序列表。为了实现分页的关键逻辑。我将<ul>里面的DOM结构划分为七个部分:

模块 元素类型 功能
上一页 按钮 跳到上一页,当前为第一页时禁止操作
第一页 页码按钮 跳到第一页
…… 文本 左边显示更多,不可操作
中间区域动态页 页码按钮组 跳到指定页码
…… 文本 右边显示更多,不可操作
末页 页码按钮 跳到最后一页
下一页 按钮 跳到下一页,当前为末页时禁止操作

其中,上一页、下一页、第一页、末页(当页总数大于1时显示)是固定的DOM节点。所以,分页器的关键逻辑全部落在在了其他三个部分上:左……、右……、中间动态页。后面会一步步讲解。

API设计

1. props

属性 说明 类型 默认值
pageNo 当前页码 Number 1(页码从1开始)
pageSize 每页条数 Number 10
pageNoBtnCount 当前页左/右展示的页数量 Number 3
total 数据总数量 Number 0
direction 分页器所在的位置,支持left/center/right,即显示在左边/中间/右边 String 'right'

2. events

事件名 说明 返回值
on-change 页码改变的回调,返回改变后的页码 页码

组件逻辑设计

1. 当前页码状态化

上面的api设计中,当前页码pageNo是通过props传递进来的,现在我们将它变成分页器的内部状态,这样比较符合规范,并使用watch监听其变化并实时更新:

data () {
    return {
         currentPageNo: this.pageNo,
    }
},
watch: {
    pageNo (val) {
        this.currentPageNo = val;
    }
}

2. 上一页、下一页

上一页/下一页是分页器的一个部分,点击可跳转到当前页数的上一页和下一页。在调用事件on-change时分别对页数加1和减1。 这里需要考虑的关键点是:

  • 当前页码位于1时,上一页按钮必须置灰,并禁止操作;同样的,当前页码位于最后一页时,下一页按钮必须必须置灰,并禁止操作。置灰样式如上dom结构,对页码跳转的拦截如下:
handlePageChange (page) {
    if (page < 1 || page > this.pageCount) return;
    if (this.currentPageNo === page) return;
    this.currentPageNo = page;
    this.$emit('on-change', page);
}

3. 第1页、末页

第一页和末页按钮被设计为是固定的DOM节点,原因是他们无法参与中间动态页码区域的计算和判断。当然,当页数大于1时,末页按钮才能显示,否则隐藏。这里需要通过传入的pageSize和total来计算出总页数,然后使用v-show控制按钮的显示/隐藏:

pageCount () {
    if (!this.total || !this.pageSize) return 0;
    return Math.ceil(this.total / this.pageSize);
}
<!-- 末页 -->
<li v-show="pageCount > 1"  :class="['page-item', currentPageNo === pageCount && 'cur']" @click="handlePageChange(pageCount)"
>
    <a href="javascript:;">{{pageCount}}</a>
</li>

4. 中间区域动态页码计算

这块可以说时整个分页器的一个核心逻辑了。假如你只想做一个最简单的分页器,或者在业务场景中,所展示的数据条数并不多,那么你完全不用这么来,只需要计算出总页数,再做个遍历循环便完事了,不需要考虑的这么复杂。

然而,我们既然做的是通用的公共组件,要能够适应并兼容各类业务场景,就不得不考虑业务中可能会出现的各种情况。放到我们这个分页器来说,总页数的数量是不确定的,而页面展示的空间是有限的,当页数一多,势必会超出页面区间。所以,必须将多余的页数隐藏并使用省略号代替,然后根据当前页码计算出合理的页码选择范围并让其展示。

看起来很难实现,其实并非如此,核心代码只有20行。

  • 话不多说,先贴代码为快:
 pageNos () {
     const cnt = this.pageNoBtnCount - 1;
     const lastPageNo = this.pageCount - 1;
     let left = this.currentPageNo - cnt;
     let right = this.currentPageNo + cnt;
     let pageNos = [];
     if (left < 2) {
         right = Math.min(right + 2 - left, lastPageNo);
         left = 2;
     }
     if (right > lastPageNo) {
         left = Math.max(left - (right - lastPageNo), 2);
         right = lastPageNo;
     }
     for (let i = left; i <= right; i++) {
         pageNos.push(i);
     }
     return pageNos;
}
  • pageNos就是我们通过动态计算出来的中间页码集,它是一个存储页码的数组。这个页码集需要根据当前页码——currentPageNo计算得到。
  • pageNoBtnCount为我们设定了在当前页左右允许显示的按钮个数。例如,当前页为6,pageNoBtnCount=3(默认),然后计算出cnt = this.pageNoBtnCount - 1 = 2;这表示我们希望在页码6左右分别显示两颗页码按钮。(实际还没那么简单,后面会讲述):
const cnt = this.pageNoBtnCount - 1;
  • 根据cnt和currentPageNo,计算出这个页码集的第一颗和最后一颗按钮所在的页码,我们使用两个变量left和right来存储:
 let left = this.currentPageNo - cnt;
 let right = this.currentPageNo + cnt;
  • 那是不是直接吧left和right作为页码集的始端和终端,然后遍历循环就结束了呢?答案是否,left和right实际上是不准确的,还需要兼容各种场景。我们来想象下:如果当前页码是1,那么根据上面的代码, left = 1 - 2 = -1. right = 1 + 2 = 3。页码取值就是-1 ~3,显然是不正确的。所以我们还必须对left和right做一个矫正:
const lastPageNo = this.pageCount - 1;
if (left < 2) {
    right = Math.min(right + 2 - left, lastPageNo);
    left = 2;
}
if (right > lastPageNo) {
    left = Math.max(left - (right - lastPageNo), 2);
    right = lastPageNo;
}
  • lastPageNo是指总页数的前一页,例如,总页数是10, 那么lastPageNo就是9。这个值用处很大,让我们接着往下看;
  • 判断left是否小于2,如果是,重新计算right,且left直接就设置为2,即中间页码数组的起始位置就是从2开始。而right = Math.min(right + 2 - left, lastPageNo)。right + 2 - left, 实际上是right + ( 2 - left) 。这里的left < 2,那么2 - left代表的就是left与2之间的距离,即他俩的差值。这表示,left(中间页码集起始值)比2少了多少个单位,我就在right上面加上多少个单位,相当于左边无法显示的页码数,加到右边去了。 这一步做完,我们再拿这个right值和lastPageNo去比较,得到其中最小的那一个。为啥要最小?因为,lastPageNo是这个中间页码集的最大取值,当然不能超过它。如果超过它,那right值直接就是lastPageNo啦!
  • 怎么样?是不是看起来不是很难了?我们距离成功已经接近70%了!
  • 相对的,对中间页码集的right值的计算也是相同的道理。判断right是否大于lastPageNo。如果是,重新计算left,且right值直接就是lastPageNo(上面讲过),即中间页码数组的终止位置就是lastPageNo。而left = Math.max(left - (right - lastPageNo), 2); 一看这个跟上面的不一样,马上陷入懵逼了吗?但其实同个道理:left - (right - lastPageNo) , 因为right > lastPageNo,right - lastPageNo 则计算出right到lastPageNo(最后一页的前一页的距离,即他们的差值。这表示, right(中间页码集终止值)比lastPageNo多出多少个单位,我就在left上面加上多少个单位,相当于右边无法显示的页码数,加到左边去了。这一步做完,我们再拿这个left值和2比较,得到其中最大的一个。为啥要最大?因为2是这个中间页码集的最小取值,即以2为起点,当然不能小于它。如果小于它,那left直接就是2!
  • 松了一口气,终于吧最恶心的逻辑干掉了!

5. 左省略号、右省略号

我们已经把上一页、下一页、第一页、最后一页、以及最关键的中间页码集给搞定了。好像还差了什么?对的,我们只显示了需要展示的页码,其余被隐藏的页码,我们需要使用省略号……来代替。那怎么判断何时该展示省略号,何时不展示呢?

依然话不多说,上代码:

isMoreRight () {
    const pageNo = this.pageNos[this.pageNos.length - 1];
    return pageNo < this.pageCount - 1;
},
isMoreLeft () {
    const pageNo = this.pageNos[0];
    return pageNo > 2;
}

注意,是否展示省略号强依赖于我们上面所计算生成的pageNos(中间页码集)。当起始页码 > 2时,展示左边的省略号;当终止页码 < lastPageNo(pageCount - 1)时,展示右边的省略号。可以想象到,当pageNoBtnCount = 3, pageNo = 5时,左右两边开始出现省略号。

至此,分页器设计完毕!