本文是我学习 ElementUI 源码的第六篇文章,上一篇文章学习了 ElementUI 中 InfinityScroll 组件的实现,这篇文章来学习一下 ElementUI 是如何实现 Pagination(分页)组件的。
分页在网页中是非常常用的功能,如何制作分页组件也是前端工程师需要掌握的一个技巧。
组件效果
ElementUI 的分页组件提供的功能比较丰富,除了常规的翻页功能外,还提供了跳页、设置每页数量、增加自定义组件等功能,效果如下:
组件实现
首先来看一下分页组件的项目结构:
其中的 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
是页码组件;ElSelect
和 ElOption
用于设置每页数量;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
};
},
};
可以看到,其中定义了四个属性,internalCurrentPage
和 internalPageSize
是组件内部真正使用的当前页和每页数量。
在使用分页组件时,我们会为当前页(或每页数量)绑定属性,而这些属性值在传入分页组件后,是不能直接在分页组件内容进行修改的,所以分页组件内部需要维护一份可变属性。
所以,在 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
高。
lastEmittedPage
和 userChangePageSize
稍后再讲。
接下来我们进入 pagination.js
的 render
函数,其内容如下:
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
对象中选出需要使用的子组件,放到 template
的 children
中,而子组件也是采用 JSX
语法直接写在 components
中的,后文将重点讲解一下 pager
组件,其余的就不展开了,没有非常特别的内容(稍微提一下,子组件中大多使用 this.$parent
直接调用的父组件的属性和方法),感兴趣的朋友可以自行查看。
再往下,我们将目光集中到 rightWrapper
上。
不知道朋友们有没有发现,layout
中的书写顺序是会影响分页组件最终的呈现效果的,其中影响最大的就是 ->
,当 layout
中出现此字符串时,处于其后的布局组件将被放入 rightWrapper
中,而它的布局方式是 float:right
。
举个例子:
<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>
效果分别是:
接下来我们看看 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;
}
});
}
},
};
这些方法并没有太多特别的地方,这里重点说一下 handleCurrentChange
和 emitChange
方法,前文遗留的 lastEmittedPage
和 userChangePageSize
属性的作用在这里体现出来了。
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
是一个计算属性,它会根据 currentPage
和 pageCount
的值而变化。
不知道有没有朋友细心观察过,当分页组件同时出现前后快速跳页按钮时,当前页码永远处于最中间,而且通过 pagers
生成的页码数量为 pager-count
设置的值减去 2,因为第一页和最后一页是直接定义好了的。
根据 ElementUI 的约定,pager-count
的取值范围为 5 - 21 之间的奇数,这样能让分页组件有统一的良好的视觉效果。
页码的生成逻辑就是上述代码中的 if
语句,感兴趣的朋友可以仔细研究一下 ElementUI 是如何进行判断的,并不复杂。
处理页码点击事件的函数 onPagerClick
内容比较简单,就不再展开了,感兴趣的朋友可以自行查阅。
结语
通过学习分页组件的源码,我们可以发现一个长期以来开发者争论的问题,即模板语法好还是 JSX
好,其实两者各有优势,有各自适用的场景。就如同 pagination.js
中选取子组件的逻辑部分,如果用模板语法的话会在 HTML 中增加很多的 v-if
,阅读起来很不方便。
本文分析基于 ElementUI 2.12.0 版本。