需求分析
- 支持斑马纹,默认斑马纹样式;
- 支持表格边框线,默认没有边框线;
- 支持table内容padding间距配置(是否为紧凑型);
- 支持给table设置高度;
- 支持全选与全选取消,默认不展示;
- 支持给任意选项排序;
- 支持请求数据时,展示loading状态;
- 支持可展开,当表格内容较多不能一次性完全展示时;
- 支持在table最后一列传自定义内容,比如按钮;
- 默认固定表头;
方法实现
一、table组件传参定义:
一共可传12个参数和1个事件:
- striped:是否展示斑马纹样式,默认展示;
- bordered:是否有表格边框线,默认无边框线;
- compact:table内容间距配置,两种方案:4px和8px,默认8px;
- height:table容器高度,默认无高度;
- checkable:是否展示全选项,默认不展示;
- orderBy:排序方式,比如升序、降序;
- loading:是否为loading加载状态;
- extendField:可展开内容数据名;
- selectedItems:被选中的行,值为数组;
- colums:表头<th>内容配置,必填;
- dataSource:表主体<td>内容配置,必填;
- @update:orderBy:点击排序按钮的事件,可向接口按指定排序方式请求数据;
<t-table :columns="columns"
:data-source="dataSource"
bordered-items.sync="selected"
:order-by.sync="orderBy"
:loading="loading"
:selectedItems.sync="selected"
extendField="description"
:striped="true"
bordered
checkable
compact
numberVisible
:height="400"
@update:orderBy="sort">
<template slot-scope="row">
<button @click="look(row.item)">查看</button>
<button @click="edit(row.item)">编辑</button>
</template>
</t-table>
table数据:
data() {
return {
currentPage: 1,
selected: [],
columns: [
{text: '姓名', field: 'name', width: 100},
{text: '分数', field: 'score'},
],
orderBy: {
score: 'desc'
},
loading: false,
dataSource: [
{id:1, name:'A-Tione1', score: 99, description: '11111'},
{id:2, name:'A-Tione2', score: 98, description: '22222'},
{id:3, name:'A-Tione3', score: 97},
{id:4, name:'A-Tione4', score: 96},
{id:5, name:'A-Tione5', score: 90},
{id:6, name:'A-Tione6', score: 105},
{id:7, name:'A-Tione7', score: 80},
{id:8, name:'A-Tione8', score: 150},
{id:9, name:'A-Tione9', score: 60},
{id:10, name:'A-Tione10', score: 107},
],
}
},
二、table组件内部实现:
1、支持斑马纹,默认斑马纹样式
在table标签中定义striped类名,根据props传参来确定是否显示类名,具体的样式由css负责,js负责控制class的显影。
<table class="t-table" ref="table" :class="{bordered, compact, striped: striped}"></table>
props: {
striped: {
type: Boolean,
default: true
},
bordered: {
type: Boolean,
default: false
},
compact: {
type: Boolean,
default: false
},
2、支持表格边框线,默认没有边框线
同上
3、支持table内容padding间距配置(是否为紧凑型)
同上
4、支持给table设置高度
首先在table外加一个包裹容器div,将高度设置在div容器上面,再给容器加上overflow:auto,可使容器里面的table滑动展示。
但这里要注意一个点,我们需要做固定表头,固定表头会使高度有问题(具体请看10、默认固定表头实现),所以我们需要用js计算将table的高度减掉一个表头的高度,这样table容器的总高度才会是设置的正确高度。
<div class="t-table-wrapper" ref="wrapper">
<div :style="{height, overflow:'auto'}" ref="tableWrapper">
<table class="t-table" ref="table" :class="{bordered, compact, striped: striped}">
</table>
</div>
</div>
props: {
height: {
type: Number
},
},
mounted() {
let table2 = this.$refs.table.cloneNode(false)
this.table2 = table2
let tHead = this.$refs.table.children[0]
let {height} = tHead.getBoundingClientRect()
this.$refs.tableWrapper.style.height = this.height-height + 'px'
table2.appendChild(tHead)
this.$refs.wrapper.appendChild(table2)
},
5、支持全选与全选取消
在th标签中点击为全选或者全不选,在td标签中点击为单个选中或者不选中。
那么我们先看看th标签中的全选逻辑,onChangeAllItems方法,当用户点击全选checked时,我们判断checked是否为true,如果为true则将dataSource全部遍历,组件外接收并更新selectedItems。如果为checked为false,则为空数组。
inSelectedItems方法判断更新后的每一行数据checked为true还是为false。
另外这里还有一个半选的问题,如果数据没有满选,应该展示一个半选样式(如上图),mdn上说这里半选样式只能在js上面添加,因此我们要在watch上监听一下selectedItems,并适时的加入半选样式。
this.$refs.allChecked.indeterminate = false or true
<table class="t-table" ref="table" :class="{bordered, compact, striped: striped}">
<thead>
<tr>
<th v-if="checkable" :style="{width: '50px'}" class="t-table-center">
<input ref="allChecked" type="checkbox" @change="onChangeAllItems"
:checked="areAllItemsSelected"/>
</th>
</tr>
</thead>
<tbody>
<template v-for="(item, index) in dataSource">
<tr :key="item.id">
<td v-if="checkable" :style="{width: '50px'}" class="t-table-center">
<input type="checkbox" @change="onChangeItem(item, index, $event)"
:checked="inSelectedItems(item)">
</td>
</tr>
</template>
</tbody>
</table>
watch: {
selectedItems() {
if (this.selectedItems.length === this.dataSource.length) {
this.$refs.allChecked.indeterminate = false
} else this.$refs.allChecked.indeterminate = this.selectedItems.length !== 0
}
},
computed: {
areAllItemsSelected() { //判断所有元素是否被选中
const a = this.dataSource.map(item => item.id).sort()
const b = this.selectedItems.map(item => item.id).sort()
let equal = true
if (!b.length) {
return equal = false
}
if (a.length !== b.length) {
return equal
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
equal = false
break
}
}
return equal
},
},
methods: {
inSelectedItems(item) {
return this.selectedItems.filter(value => value.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.dataSource : [])
}
}
6、支持给任意选项排序
确定要排序的字段,score: 'desc' 。然后通过监听事件@update:orderBy="sort"监听触发排序事件,然后向后端请求排序数据。
这里要注意如果外部参数orderBy没有展示排序字段的,则再在table组件中对应列不展示排序样式也不可点击触发。
外部参数设置:
<template>
<t-table :columns="columns"
:data-source="dataSource"
:order-by.sync="orderBy"
@update:orderBy="sort">
</t-table>
</template>
data() {
return {
columns: [
{text: '姓名', field: 'name', width: 100},
{text: '分数', field: 'score'},
],
orderBy: {
score: 'desc'
},
loading: false,
dataSource: [
{id:1, name:'A-Tione1', score: 99, description: '11111'},
{id:2, name:'A-Tione2', score: 98, description: '22222'},
{id:3, name:'A-Tione3', score: 97},
{id:4, name:'A-Tione4', score: 96},
{id:5, name:'A-Tione5', score: 90},
{id:6, name:'A-Tione6', score: 105},
{id:7, name:'A-Tione7', score: 80},
{id:8, name:'A-Tione8', score: 150},
{id:9, name:'A-Tione9', score: 60},
{id:10, name:'A-Tione10', score: 107},
],
}
},
methods: {
sort() { //模拟排序,发送ajax请求,获取新数据
this.loading = true
setTimeout(()=> {
this.dataSource.sort((a, b) => a.score - b.score)
this.loading = false
},1000)
},
},
组件内部 :
<th :style="{width: column.width + 'px'}" v-for="column in columns" :key="column.field">
<div class="t-table-header">
{{column.text}}
<span v-if="column.field in orderBy" class="t-table-sorter"
@click="changeOrderBy(column.field)">
<t-icon name="asc" :class="{active: orderBy[column.field] === 'asc'}"></t-icon>
<t-icon name="desc" :class="{active: orderBy[column.field] === 'desc'}"></t-icon>
</span>
</div>
</th>
methods: {
changeOrderBy (key) {
const copy = JSON.parse(JSON.stringify(this.orderBy))
let oldValue = copy[key]
if (oldValue === 'asc') {
copy[key] = 'desc'
} else if (oldValue === 'desc') {
copy[key] = true
} else {
copy[key] = 'asc'
}
this.$emit('update:orderBy', copy)
},
}
7、支持请求数据时,展示loading状态
通常loading状态是用于处理请求接口时等待接口响应的样式展示。
内部实现无需js代码,只需在html中增加v-if判断,但loading为true时展示菊花,否则不展示即可。展示菊花为css样式,将其table组件全部遮盖展示loading动画即可。
外部数据传参:
<t-table :loading="loading"</t-table>
sort() {
this.loading = true
setTimeout(()=> {
this.dataSource.sort((a, b) => a.score - b.score)
this.loading = false
},1000)
},
内部实现:
<template>
<div class="t-table-wrapper" ref="wrapper">
<div :style="{height, overflow:'auto'}" ref="tableWrapper">
<table class="t-table" ref="table" :class="{bordered, compact, striped: striped}">
...
</table>
</div>
<div v-if="loading" class="t-table-loading">
<t-icon name="loading"></t-icon>
</div>
</div>
</template>
8、支持可展开,当表格内容较多不能一次性完全展示时
在外部传入进来的数据结构中,extendField字段为展开内容字段,当展示时则可点击展开。
这里的展开是通过新开一个tr标签来实现的,要注意的是新的tr标签中的td是需要做合并操作的,这里需要动态计算colspan,这里用到了计算属性expendedCellColSpan方法来计算table中的一行有多少列,以此来计算合并的项数。
展开的内容确定则是通过一个数组来实现的,点击某一行找到这行的id,然后push到数组中,再进行hash匹配。否则splice将这行数据从数组中删除。
<table class="t-table" ref="table" :class="{bordered, compact, striped: striped}">
<thead>
<tr>
<th v-if="extendField" :style="{width: '50px'}" class="t-table-center">展开</th>
</tr>
</thead>
<tbody>
<template v-for="(item, index) in dataSource">
<tr :key="item.id">
<td v-if="extendField" :style="{width: '50px'}" class="t-table-center" @click="expendItem(item.id)">
<t-icon class="t-table-expendIcon" name="right"></t-icon>
</td>
</tr>
<tr v-if="inExpendedIds(item.id)" :key="`${item.id}-expend`">
<td :colspan="columns.length + expendedCellColSpan">
{{item[extendField] || '无'}}
</td>
</tr>
</template>
</tbody>
</table>
computed: {
expendedCellColSpan() {
let result = 0
if (this.checkable) {result += 1}
if (this.numberVisible) {result += 1}
if (this.extendField) {result += 1}
if (this.$scopedSlots.default) {result += 1}
return result
}
},
methods: {
inExpendedIds(id) {
return this.expendedIds.indexOf(id) > -1
},
expendItem(id) {
if (this.inExpendedIds(id)) {
this.expendedIds.splice(this.expendedIds.indexOf(id), 1)
} else {
this.expendedIds.push(id)
}
},
}
9、支持在table最后一列传自定义内容,比如按钮
在组件内部的solt 上定义一个属性,名字可以任意取(比如item)。然后我们在组件入参的位置加上一个slot-scope=”xx“,通过xx.item即可拿到solt上传递的值(例如一行数据的值)。从而实现在组件外部进行查看编辑等等逻辑。
组件传参:
<t-table :columns="columns"
:data-source="dataSource“>
<template slot-scope="row">
<button @click="look(row.item)">查看</button>
<button @click="edit(row.item)">编辑</button>
</template>
</t-table>
组件内部:
<table class="t-table" ref="table" :class="{bordered, compact, striped: striped}">
<thead>
<tr>
<th ref="actionsHeader" v-if="$scopedSlots.default">操作</th>
</tr>
</thead>
<tbody>
<template v-for="(item, index) in dataSource">
<tr :key="item.id">
<td v-if="$scopedSlots.default">
<div ref="actions" style="display: inline-block">
<slot :item="item"></slot>
</div>
</td>
</tr>
</template>
</tbody>
</table>
10、默认固定表头
思路:
因为table组件内部的td不能单独设置overflow: auto;,所以固定表头并不能很容易地实现,因此我们要另想办法。
怎么办呢?再创建一个table,将旧table的th装进新table中去,然后再通过css将新table放置在旧table上方即可。因此整个table的高度是由新table+旧table组成的,因此我们在设置容器高度时需要减去多出来的新table高度才正确。
这里我们在mounted钩子中就需要做些dom魔法:临时创建一个table容器, 再将旧table的children的第一个节点也就是th挪到新table中。注意这里虽然th移动了位置,但是th上面的node节点与监听事件仍然与旧table中的th保持一致,并不会丢失。简单来看仅仅是样式移动了位置,逻辑并没有移动。
同理,如果我们要做固定列,比如一行的内容太多,则需要固定编辑的列。我们也可以使用相同思路,重新创建一个新table,再将旧table中的编辑列移至新table中,然后再通过css调整到正确的位置即可。
组件内部实现:
mounted() {
let table2 = this.$refs.table.cloneNode(false)
this.table2 = table2
let tHead = this.$refs.table.children[0]
let {height} = tHead.getBoundingClientRect()
this.$refs.tableWrapper.style.height = this.height-height + 'px'
table2.appendChild(tHead)
this.$refs.wrapper.appendChild(table2)
}
完整代码可参考我的GitHub:https://github.com/A-Tione/tione/blob/master/src/table/table.vue