背景
由于公司业务的复杂和公司内部的table组件不够强壮,导致每次开发复杂的表格时都存在很多痛点,为了解决这些痛点决定自己实现一个table组件(码农嘛 总是想要最爽的体验) 一开始造轮子的过程中,觉得elementUI的table组件过于庞大了(还有就是elementUI是采用渲染函数进行开发的table组件,虽然能看懂但毕竟业务中写template居多,对渲染函数不够熟悉),于是乎想通过自己对vue那么一丁点的浅薄理解 实现一个template的table组件. PS:为什么本文要强调是利用template实现的table的,其一是因为目前市面上成熟的UI框架源码都采用渲染函数或者JSX的写法,没有一个较为上乘的template例子,其二是因为作者在开发的后期发现JSX的写法真香,遂抛弃了template写法. 具体缘由可以参考知乎里的这则陈嘉涵的回答
需求分析
- 让程序员学习成本低,与原公司组件的api要尽量高度相同
- 固定表头(最麻烦的功能,因为这个功能导致其他地方都很难做,采坑不少)
- 虚拟渲染 支持大数据(万级数据 不卡顿)
- 支持插槽,给使用者高度自定义
- 支持表格loading效果
- 表格支持复选框/序号/行展开
- 支持默认排序,用户自定义排序规则
- 一些表格的样式选择,如是否带边框,斑马纹等等.
代码实现
最终界面效果 PS:开发组件思路是先实现 再优化 再写界面 最终因为template写法都抛弃了,所以界面没做美化~ 代码也没管它了
预期用户如何使用组件
<h-table :data.sync="data" checkable expand="description" numberVisable bordered compact :selected-items.sync="selectedTable" :height="400" :loading="loading" @sort-change="sortChange">
<h-table-column name="姓名" field="name" :width="100"></h-table-column>
<h-table-column name="分数" field="score" :width="100"></h-table-column>
<template slot-scope="scope">
<button @click="edit(scope.item)">编辑</button>
<button @click="view(scope.item)">删除</button>
</template>
</h-table>
数据:
columns: [
{ name: "姓名", field: "name", width: 100 },
{ name: "分数", field: "score", sortable: 'descending' },
],
data: [
{ name: "luo", score: "100", description: "xxxxxxxxx" },
{ name: "zong", score: "90", description: "xxxxxxxxx" },
{ name: "bin", score: "80" },
{ name: "luo", score: "100" },
{ name: "zong", score: "90" },
{ name: "bin", score: "80" },
{ name: "luo", score: "100" },
{ name: "zong", score: "90" },
{ name: "bin", score: "80" },
{ name: "bin", score: "80" },
{ name: "luo", score: "100" },
{ name: "zong", score: "90" },
{ name: "bin", score: "80" },
{ name: "bin", score: "80" },
{ name: "luo", score: "100" },
{ name: "zong", score: "90" },
{ name: "bin", score: "80" },
{ name: "luo", score: "100" },
{ name: "zong", score: "90" },
{ name: "bin", score: "80" },
];
最终实现代码 gitHub地址
<template>
<div class="h-table-wrapper" ref="wrapper">
<div ref="scroll" style="overflow: auto;position:relative;" @scroll="handleScroll">
<div class="h-table-view-phantom" :style="{ height: contentHeight + 'px' }"></div>
<div ref="tableWrapper">
<table class="h-table" :class="{ bordered, compact, striped }" ref="table">
<thead>
<tr>
<th v-if="expand" :style="{ width: '50px' }" class="h-table-center"></th>
<th v-if="checkable" :style="{ width: '50px' }" class="h-table-center"><input type="checkbox" @change="onChangeAllItems($event)" :checked="areAllItemsSelected" ref="allChecked" /></th>
<th :style="{ width: '50px' }" class="h-table-center" v-if="numberVisable">#</th>
<th :style="{ width: column.width + 'px' }" v-for="column in columns" :key="column.field">
<div class="h-table-header">
{{ column.name }}
<span class="h-table-sorter" v-if="'sortable' in column">
<h-icon name="arrow-up-filling2" :class="{ active: column['sortable'] === 'descending' }" @click="changeOrderBy(column['field'], 'descending', column)" />
<h-icon name="arrow-down-filling2" :class="{ active: column['sortable'] === 'ascending' }" @click="changeOrderBy(column['field'], 'ascending', column)" />
</span>
</div>
</th>
<th v-if="$scopedSlots.default"></th>
</tr>
</thead>
<tbody>
<template v-for="(item, index) in visibleData">
<tr :key="item.id">
<td v-if="expand" :style="{ width: '50px' }" class="h-table-center">
<h-icon name="arrow-right" class="h-table-expandIcon" @click="expandItem(item.id)"></h-icon>
</td>
<td v-if="checkable" :style="{ width: '50px' }" class="h-table-center"><input type="checkbox" @change="onChangeItem(item, index, $event)" :checked="inSelectItmes(item)" /></td>
<td :style="{ width: '50px' }" class="h-table-center" v-if="numberVisable">{{ index + 1 }}</td>
<template v-for="column in columns">
<td :style="{ width: column.width + 'px',height: itemHeight + 'px' }" :key="column.field">
<template v-if="column.render">
<vnodes :vnodes="column.render({ value: item[column.field] })"></vnodes>
</template>
<template v-else>
{{ item[column.field] }}
</template>
</td>
</template>
<td v-if="$scopedSlots.default"><slot :item="item"></slot></td>
</tr>
<tr v-if="inExpandIds(item.id)" :key="`${item.id}-expand`">
<td :colspan="columns.length + expandedCellColSpan">
{{ item[expand] }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div class="h-table-loading" v-if="loading">
<h-icon name="loading"></h-icon>
</div>
</div>
</template>
<script>
import Hicon from "./icon.vue";
import Input from "./input.vue";
import { orderBy } from "./util.js";
export default {
name: "hTable",
props: {
// columns: {
// type: Array,
// require: true,
// },
selectedItems: {
type: Array,
default: () => [],
},
data: {
type: Array,
require: true,
validator(array) {
return !(array.filter((item) => item.id === undefined).length > 0);
},
},
expand: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
height: {
type: Number,
},
numberVisable: {
type: Boolean,
default: false,
},
bordered: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
striped: {
type: Boolean,
default: true,
},
checkable: {
type: Boolean,
default: false,
},
sortMethod: Function,
sortBy: [String, Function, Array],
sortOrders: {
type: Array,
default() {
return ["ascending", "descending", null];
},
validator(val) {
return val.every((order) => ["ascending", "descending", null].indexOf(order) > -1);
},
},
itemHeight: {
type: Number,
default: 40,
},
},
components: {
Input,
Hicon,
vnodes: {
functional: true,
render: (h, ctx) => ctx.props.vnodes,
},
},
data() {
return {
copyData: this.data,
table2: "",
expandIds: [],
columns: [],
visibleData: [],
};
},
watch: {
selectedItems() {
if (this.selectedItems.length === this.data.length || this.selectedItems.length === 0) {
this.$refs.allChecked.indeterminate = false;
} else {
this.$refs.allChecked.indeterminate = true;
}
},
},
computed: {
areAllItemsSelected() {
const a = this.data.map((item) => item.id).sort();
const b = this.selectedItems.map((item) => item.id).sort();
let equal = true;
if (a.length === b.length) {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
equal = false;
break;
}
}
return equal;
} else {
return false;
}
},
expandedCellColSpan() {
let result = 0;
if (this.checkable) {
result += 1;
}
if (this.expand) {
result += 1;
}
if (this.numberVisable) {
result += 1;
}
return result+1;
},
contentHeight() {
console.log("this.data.length * this.itemHeight ", this.data.length * this.itemHeight);
return this.data.length * this.itemHeight + "px";
},
visibleDataHeight() {
return this.visibleData.length * this.itemHeight + "px";
},
},
created() {},
mounted() {
console.log("this.$slots.default", this.$slots.default);
this.columns = this.$slots.default.map((node) => {
let { name, field, width,sortable } = node.componentOptions.propsData;
let render = node.data.scopedSlots && node.data.scopedSlots.default;
return { name, field, width, render,sortable };
});
console.log("this.columns", this.columns);
let table2 = this.$refs.table.cloneNode(false);
this.table2 = table2;
table2.classList.add("h-table-copy");
// table2.appendChild(this.$refs.table.children[0]);
let tHead = this.$refs.table.children[0];
let { height } = tHead.getBoundingClientRect();
this.$refs.tableWrapper.style.marginTop = height + "px";
this.$refs.tableWrapper.style.height = this.height - height + "px";
table2.appendChild(tHead);
this.$refs.wrapper.appendChild(table2);
// this.updateHeadersWidth();
// this.onWindowResize = () => this.upforceUpdateHeadersWidth();
// window.addEventListener("resize", this.onWindowResize);
//虚拟列表
this.updateVisibleData();
},
beforeDestroy() {
this.table2.remove();
// window.removeEventListener("resize", this.onWindowResize);
},
methods: {
updateVisibleData(scrollTop) {
scrollTop = scrollTop || 0;
const visibleCount = Math.ceil(this.$refs.tableWrapper.clientHeight / this.itemHeight);
const start = Math.floor(scrollTop / this.itemHeight);
const end = start + visibleCount;
console.log("start", start, end);
this.visibleData = this.data.slice(start, end);
this.$refs.tableWrapper.style.webkitTransform = `translate3d(0, ${start * this.itemHeight}px, 0)`;
},
handleScroll() {
const scrollTop = this.$refs.scroll.scrollTop;
console.log('scrollTop',scrollTop);
this.updateVisibleData(scrollTop);
},
inExpandIds(id) {
return this.expandIds.indexOf(id) >= 0;
},
expandItem(id) {
if (this.inExpandIds(id)) {
this.expandIds.splice(this.expandIds.indexOf(id), 1);
} else {
this.expandIds.push(id);
}
},
updateHeadersWidth() {
//TODO:动态更新表头宽度...未完成
let table2 = this.table2;
let tableHeader = Array.from(this.$refs.table.children).filter((node) => node.tagName.toLowerCase() === "thead")[0];
let tableHeader2;
Array.from(table2.children).map((node) => {
if (node.tagName.toLowerCase() !== "thead") {
node.remove;
} else {
tableHeader2 = node;
}
});
Array.from(tableHeader.children[0].children).map((th, i) => {
const { width } = th.getBoundingClientRect();
tableHeader2.children[0].children[i].style.width = width + "px";
});
},
changeOrderBy(field, value, column) {
let copy = JSON.parse(JSON.stringify(this.columns));
copy.forEach((e) => {
if (e.field == field) {
if (value === e["sortable"]) {
e["sortable"] = null;
} else {
e["sortable"] = value;
}
let { text, field } = column;
this.$emit("sort-change", { column: { text, field }, order: e["sortable"] });
this.$emit("update:data", e["sortable"] ? orderBy(this.data, e.field, e["sortable"], this.sortMethod, this.sortBy) : this.copyData);
}
});
this.$emit("update:columns", copy);
},
inSelectItmes(item) {
return this.selectedItems.filter((i) => i.id === item.id).length > 0;
},
onChangeItem(item, index, e) {
let selected = e.target.checked;
let copy = JSON.parse(JSON.stringify(this.selectedItems));
if (selected) {
copy.push(item);
} else {
copy = copy.filter((i) => i.id !== item.id);
}
this.$emit("update:selectedItems", copy);
},
onChangeAllItems(e) {
let selected = e.target.checked;
this.$emit("update:selectedItems", selected ? this.data : []);
},
},
filters: {},
};
</script>
<style lang="scss">
@import "var";
.h-table {
box-sizing: border-box;
width: 100%;
border-collapse: collapse;
border-spacing: 0;
border-bottom: 1px solid #dae4f3;
&.bordered {
border: 1px solid #dae4f3;
border-bottom: unset;
td,
th {
border: 1px solid #dae4f3;
}
}
&.compact {
td,
th {
padding: 4px;
}
}
td,
th {
border-bottom: 1px solid #dae4f3;
text-align: left;
padding: 8px;
}
&.striped {
tbody {
> tr {
&:nth-child(odd) {
background-color: #fff;
}
&:nth-child(even) {
background-color: #eaf4fe;
}
}
}
}
&-view-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
height: 40000px;
}
&-sorter {
display: inline-flex;
flex-direction: column;
cursor: pointer;
svg {
width: 14px;
height: 14px;
fill: #bbb;
&.active {
fill: #333;
}
&:first-child {
position: relative;
}
&:nth-of-type(2) {
position: relative;
top: 2px;
}
}
}
&-header {
display: flex;
align-items: center;
}
&-wrapper {
position: relative;
// overflow: auto;
}
&-loading {
background-color: rgba(255, 255, 255, 0.7);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
svg {
width: 50px;
height: 50px;
@include spin;
}
}
&-copy {
position: absolute;
top: 0;
left: 0;
width: 100%;
background: #fff;
}
&-expandIcon {
width: 15px;
height: 15px;
}
& &-center {
text-align: center;
}
}
</style>