本文是我学习 ElementUI 源码的第六篇文章,上一篇文章学习了 ElementUI 中 InfinityScroll 组件的实现,这篇文章来学习一下 ElementUI 是如何实现 Pagination(分页)组件的。

分页在网页中是非常常用的功能,如何制作分页组件也是前端工程师需要掌握的一个技巧。

组件效果

ElementUI 的分页组件提供的功能比较丰富,除了常规的翻页功能外,还提供了跳页、设置每页数量、增加自定义组件等功能,效果如下:

pagination

组件实现

首先来看一下分页组件的项目结构:

struct

其中的 pagination.js 是真正对外提供的分页组件,它聚合了包括上下页按钮、跳页、总量、每页数量、页码等组件,pagination.js 是使用 JSX 语法编写的,好处在于可以少定义很多的判断变量同时又没有纯 render 函数那么复杂;而 pager 就是页码组件。

pagination 组件

打开 pagination.js,先看看引入的内容:

import Pager from './pager.vue';
import ElSelect from 'element-ui/packages/select';
import ElOption from 'element-ui/packages/option';
import ElInput from 'element-ui/packages/input';
import Locale from 'element-ui/src/mixins/locale';
import { valueEquals } from 'element-ui/src/utils/util';

Pager 是页码组件;ElSelectElOption 用于设置每页数量;ElInput 用于实现跳页;Locale 是用于实现国际化的,本文不会讲解;而 valueEquals 是用于判断两个变量是否相等的(针对基本类型和数组),其内容如下:

export const valueEquals = (a, b) => {
  // see: https://stackoverflow.com/questions/3115982/how-to-check-if-two-arrays-are-equal-with-javascript
  if (a === b) return true;
  if (!(a instanceof Array)) return false;
  if (!(b instanceof Array)) return false;
  if (a.length !== b.length) return false;
  for (let i = 0; i !== a.length; ++i) {
    if (a[i] !== b[i]) return false;
  }
  return true;
};

这段代码源于 StackOverflow 上的一个回答,ElementUI 对其做了一些优化,感兴趣的朋友可以查看原回答。需要注意的是,这段代码判断的数组只是简单的一维数组,对于对象数组和多维数组是无效的。

接下来我们开始阅读 pagination.js 的组件代码,props 属性不需要多讲,直接跳到 data 部分,其内容如下:

export default {
  data() {
    return {
      internalCurrentPage: 1,
      internalPageSize: 0,
      lastEmittedPage: -1,
      userChangePageSize: false
    };
  },
};

可以看到,其中定义了四个属性,internalCurrentPageinternalPageSize 是组件内部真正使用的当前页和每页数量。

在使用分页组件时,我们会为当前页(或每页数量)绑定属性,而这些属性值在传入分页组件后,是不能直接在分页组件内容进行修改的,所以分页组件内部需要维护一份可变属性。

所以,在 watch 中,ElementUI 定义了更新这两个属性的监听事件:

export default {
  watch: {
    currentPage: {
      immediate: true,
      handler(val) {
        this.internalCurrentPage = this.getValidCurrentPage(val);
      }
    },

    pageSize: {
      immediate: true,
      handler(val) {
        this.internalPageSize = isNaN(val) ? 10 : val;
      }
    },

    internalCurrentPage: {
      immediate: true,
      handler(newVal) {
        this.$emit('update:currentPage', newVal);
        this.lastEmittedPage = -1;
      }
    },

    internalPageCount(newVal) {
      /* istanbul ignore if */
      const oldPage = this.internalCurrentPage;
      if (newVal > 0 && oldPage === 0) {
        this.internalCurrentPage = 1;
      } else if (oldPage > newVal) {
        this.internalCurrentPage = newVal === 0 ? 1 : newVal;
        this.userChangePageSize && this.emitChange();
      }
      this.userChangePageSize = false;
    }
  }
};

internalCurrentPage 的监听是为了实现 .sync 修饰符的同步更新功能。

可以看到 watch 中还有一个对 internalPageCount 的监听,这个属性是定义在 computed 中的,它用于计算实际的页码总数:

export default {
  computed: {
    internalPageCount() {
      if (typeof this.total === 'number') {
        return Math.max(1, Math.ceil(this.total / this.internalPageSize));
      } else if (typeof this.pageCount === 'number') {
        return Math.max(1, this.pageCount);
      }
      return null;
    }
  },
};

在使用分页组件时,我们既可以传入 total,也可以传入 pageCount,甚至同时传入两者,为了得到恰当的页码总数,ElementUI 定义了 internalPageCount 属性。

从代码中我们可以看出,total 属性的优先级比 pageCount 高。

lastEmittedPageuserChangePageSize 稍后再讲。

接下来我们进入 pagination.jsrender 函数,其内容如下:

export default {
  render(h) {
    const layout = this.layout;
    if (!layout) return null;
    if (this.hideOnSinglePage && (!this.internalPageCount || this.internalPageCount === 1)) return null;

    let template = <div class={['el-pagination', {
      'is-background': this.background,
      'el-pagination--small': this.small
    }] }></div>;
    const TEMPLATE_MAP = {
      prev: <prev></prev>,
      jumper: <jumper></jumper>,
      pager: <pager currentPage={ this.internalCurrentPage } pageCount={ this.internalPageCount } pagerCount={ this.pagerCount } on-change={ this.handleCurrentChange } disabled={ this.disabled }></pager>,
      next: <next></next>,
      sizes: <sizes pageSizes={ this.pageSizes }></sizes>,
      slot: <slot>{ this.$slots.default ? this.$slots.default : '' }</slot>,
      total: <total></total>
    };
    const components = layout.split(',').map((item) => item.trim());
    const rightWrapper = <div class="el-pagination__rightwrapper"></div>;
    let haveRightWrapper = false;

    template.children = template.children || [];
    rightWrapper.children = rightWrapper.children || [];
    components.forEach(compo => {
      if (compo === '->') {
        haveRightWrapper = true;
        return;
      }

      if (!haveRightWrapper) {
        template.children.push(TEMPLATE_MAP[compo]);
      } else {
        rightWrapper.children.push(TEMPLATE_MAP[compo]);
      }
    });

    if (haveRightWrapper) {
      template.children.unshift(rightWrapper);
    }

    return template;
  },
};

template 是最终用于渲染的模版,在定义时是最顶级元素,其内容会根据用户传入的 layout 属性从 TEMPLATE_MAP 对象中选出需要使用的子组件,放到 templatechildren 中,而子组件也是采用 JSX 语法直接写在 components 中的,后文将重点讲解一下 pager 组件,其余的就不展开了,没有非常特别的内容(稍微提一下,子组件中大多使用 this.$parent 直接调用的父组件的属性和方法),感兴趣的朋友可以自行查看。

再往下,我们将目光集中到 rightWrapper 上。

不知道朋友们有没有发现,layout 中的书写顺序是会影响分页组件最终的呈现效果的,其中影响最大的就是 ->,当 layout 中出现此字符串时,处于其后的布局组件将被放入 rightWrapper,而它的布局方式是 float:right

rightWrapper

举个例子:

<div class="wrapper">
  <el-pagination background :current-page="1" layout="prev,pager,next,total" :total="100"></el-pagination>
</div>
<div class="wrapper">
  <el-pagination background :current-page="1" layout="total,prev,pager,next" :total="100"></el-pagination>
</div>
<div class="wrapper">
  <el-pagination background :current-page="1" layout="total,prev,pager,next,slot" :total="100">我是插入的内容</el-pagination>
</div>
<div class="wrapper">
  <el-pagination background :current-page="1" layout="slot,->,prev,pager,next,total" :total="100">我是插入的内容</el-pagination>
</div>

效果分别是:

layout

接下来我们看看 pagination.js 中的 methods,其内容如下:

export default {
  methods: {
    handleCurrentChange(val) {
      this.internalCurrentPage = this.getValidCurrentPage(val);
      this.userChangePageSize = true;
      this.emitChange();
    },

    prev() {
      if (this.disabled) return;
      const newVal = this.internalCurrentPage - 1;
      this.internalCurrentPage = this.getValidCurrentPage(newVal);
      this.$emit('prev-click', this.internalCurrentPage);
      this.emitChange();
    },

    next() {
      if (this.disabled) return;
      const newVal = this.internalCurrentPage + 1;
      this.internalCurrentPage = this.getValidCurrentPage(newVal);
      this.$emit('next-click', this.internalCurrentPage);
      this.emitChange();
    },

    getValidCurrentPage(value) {
      value = parseInt(value, 10);

      const havePageCount = typeof this.internalPageCount === 'number';

      let resetValue;
      if (!havePageCount) {
        if (isNaN(value) || value < 1) resetValue = 1;
      } else {
        if (value < 1) {
          resetValue = 1;
        } else if (value > this.internalPageCount) {
          resetValue = this.internalPageCount;
        }
      }

      if (resetValue === undefined && isNaN(value)) {
        resetValue = 1;
      } else if (resetValue === 0) {
        resetValue = 1;
      }

      return resetValue === undefined ? value : resetValue;
    },

    emitChange() {
      this.$nextTick(() => {
        if (this.internalCurrentPage !== this.lastEmittedPage || this.userChangePageSize) {
          this.$emit('current-change', this.internalCurrentPage);
          this.lastEmittedPage = this.internalCurrentPage;
          this.userChangePageSize = false;
        }
      });
    }
  },
};

这些方法并没有太多特别的地方,这里重点说一下 handleCurrentChangeemitChange 方法,前文遗留的 lastEmittedPageuserChangePageSize 属性的作用在这里体现出来了。

handleCurrentChange 方法是传入到 pager 组件的,用于处理点击页码事件,它获取页码的方法是通过 Number(event.target.textContent) 从元素的文本中提取的,而 pager 组件中的点击事件是通过事件委托交给 ul 元素处理的(后文会讲)。当点击当前页码时,事件也会触发,所以用 lastEmittedPage 来标识是否是点击的当前页码,以防止触发 current-change 事件。

userChangePageSize 的作用也是一样,当用户修改每页数量时会造成 pager 组件重新渲染,页码更新,因此需要触发 current-change 事件。

pager 组件

之所以要讲一下 pager 组件,主要是因为它对于页码的处理过程值得学习一下。

首先看一下组件结构:

<ul @click="onPagerClick" class="el-pager">
  <li
    :class="{ active: currentPage === 1, disabled }"
    v-if="pageCount > 0"
    class="number">1</li>
  <li
    class="el-icon more btn-quickprev"
    :class="[quickprevIconClass, { disabled }]"
    v-if="showPrevMore"
    @mouseenter="onMouseenter('left')"
    @mouseleave="quickprevIconClass = 'el-icon-more'">
  </li>
  <li
    v-for="pager in pagers"
    :key="pager"
    :class="{ active: currentPage === pager, disabled }"
    class="number">{{ pager }}</li>
  <li
    class="el-icon more btn-quicknext"
    :class="[quicknextIconClass, { disabled }]"
    v-if="showNextMore"
    @mouseenter="onMouseenter('right')"
    @mouseleave="quicknextIconClass = 'el-icon-more'">
  </li>
  <li
    :class="{ active: currentPage === pageCount, disabled }"
    class="number"
    v-if="pageCount > 1">{{ pageCount }}</li>
</ul>

关注两点,第一,页码的点击事件都通过事件委托交给了 ul 元素处理,也就是 onPagerClick,性能更佳;第二,第一页和最后一页以及快速跳页按钮是直接定义好的了,重点是页码如何生成。

我们来看一下 pagers 的代码:

export default {
  computed: {
    pagers() {
      const pagerCount = this.pagerCount;
      const halfPagerCount = (pagerCount - 1) / 2;

      const currentPage = Number(this.currentPage);
      const pageCount = Number(this.pageCount);

      let showPrevMore = false;
      let showNextMore = false;

      if (pageCount > pagerCount) {
        if (currentPage > pagerCount - halfPagerCount) {
          showPrevMore = true;
        }

        if (currentPage < pageCount - halfPagerCount) {
          showNextMore = true;
        }
      }

      const array = [];

      if (showPrevMore && !showNextMore) {
        const startPage = pageCount - (pagerCount - 2);
        for (let i = startPage; i < pageCount; i++) {
          array.push(i);
        }
      } else if (!showPrevMore && showNextMore) {
        for (let i = 2; i < pagerCount; i++) {
          array.push(i);
        }
      } else if (showPrevMore && showNextMore) {
        const offset = Math.floor(pagerCount / 2) - 1;
        for (let i = currentPage - offset ; i <= currentPage + offset; i++) {
          array.push(i);
        }
      } else {
        for (let i = 2; i < pageCount; i++) {
          array.push(i);
        }
      }

      this.showPrevMore = showPrevMore;
      this.showNextMore = showNextMore;

      return array;
    }
  },
};

可以看到 pagers 是一个计算属性,它会根据 currentPagepageCount 的值而变化。

不知道有没有朋友细心观察过,当分页组件同时出现前后快速跳页按钮时,当前页码永远处于最中间,而且通过 pagers 生成的页码数量为 pager-count 设置的值减去 2,因为第一页和最后一页是直接定义好了的。

pager

根据 ElementUI 的约定,pager-count 的取值范围为 5 - 21 之间的奇数,这样能让分页组件有统一的良好的视觉效果。

页码的生成逻辑就是上述代码中的 if 语句,感兴趣的朋友可以仔细研究一下 ElementUI 是如何进行判断的,并不复杂。

处理页码点击事件的函数 onPagerClick 内容比较简单,就不再展开了,感兴趣的朋友可以自行查阅。

结语

通过学习分页组件的源码,我们可以发现一个长期以来开发者争论的问题,即模板语法好还是 JSX 好,其实两者各有优势,有各自适用的场景。就如同 pagination.js 中选取子组件的逻辑部分,如果用模板语法的话会在 HTML 中增加很多的 v-if,阅读起来很不方便。

本文分析基于 ElementUI 2.12.0 版本。

最近更新:
作者: MeFelixWang