设计表格组件
表格组件在项目中负责列表数据的展示,尤其是在管理系统中,比如用户信息、课程订单信息的展示,都需要使用表格组件进行渲染。
关于表单的具体交互形式和复杂程度可以访问 ElementPlus、NaiveUi、 AntDesignVue 这三个主流组件库中的表格组件去体验,并且社区还提供了单独的 Surely Vue
表格组件
html 也提供了内置的表格标签,由 <table> 、<thead> 、<tbody> 、<tr> 、<th> 、<td> 这些标签来组成一个最简单的表格标签。
table 标签负责表格的容器,thead 负责表头信息的容器,tbody 负责表格的主体,tr 标签负责表格的每一行,th 和 td 分别负责表头和主体的单元格。其实标准的表格系列标签,跟 div+css 实现是有很大区别的。比如表格在做单元格合并时,要提供原生属性,这时候用 div 就很麻烦了。另外它们的渲染原理上也有一定的区别,每一列的宽度会保持一致
<table>
<thead>
<tr>
<th>课程</th>
<th>价格</th>
</tr>
</thead>
<tbody>
<tr>
<td>重学前端</td>
<td>129</td>
</tr>
<tr>
<td>玩转Vue3全家桶</td>
<td>129</td>
</tr>
</tbody>
</table>| 课程 | 价格 |
|---|---|
| 重学前端 | 129 |
| 玩转Vue3全家桶 | 129 |
简单的表格数据渲染并不需要组件,直接使用标准的 table 系列标签就可以。但有的时候也会带有一些额外的功能要求,比如嵌套列、性能优化等。这时候组件的好处就很明显了,它能帮我们省去这些基础的工作
表格组件的使用风格,从设计上说也分为了两个方向。一个方向是完全由数据驱动,这里可以参考 Naive Ui 的使用方式,n-data-table 标签负责容器,直接通过 data 属性传递数据,通过 columns 传递表格的表头配置。
下面的代码中在 colums 中去配置每行需要显示的属性,通过 render 函数可以返回定制化的结果,再使用 h 函数返回 Button 渲染出对应的按钮
<template>
<n-data-table :columns="columns" :data="data" :pagination="pagination" />
</template>
<script>
import { h, defineComponent } from 'vue'
import { NTag, NButton, useMessage } from 'naive-ui'
const createColumns = ({ sendMail }) => {
return [
{
title: 'Name',
key: 'name',
align: 'center'
},
{
title: 'Age',
key: 'age'
},
{
title: 'Action',
key: 'actions',
render (row) {
return h(
NButton,
{
size: 'small',
onClick: () => sendMail(row)
},
{ default: () => 'Send Email' }
)
}
}
]
}
const createData = () => [
{
key: 0,
name: 'John Brown',
age: 32,
tags: ['nice', 'developer']
},
{
key: 1,
name: 'Jim Green',
age: 42,
},
{
key: 2,
name: 'Joe Black',
age: 32
}
]
export default defineComponent({
setup () {
const message = useMessage()
return {
data: createData(),
columns: createColumns({
sendMail (rowData) {
message.info('send mail to ' + rowData.name)
}
}),
pagination: {
pageSize: 10
}
}
}
})
</script>还有一种是 Element-plus 使用的风格,配置数据之后,具体数据的展现形式交给子元素来决定,把 columns 当成组件去使用。配置完 data 后,使用 el-table-colum 组件去渲染组件的每一列,通过 slot 的方式去实现定制化的渲染
<el-table :data="tableData" border style="width: 100%">
<el-table-column fixed prop="date" label="日期" width="150">
</el-table-column>
<el-table-column prop="name" label="姓名" width="120"> </el-table-column>
<el-table-column prop="province" label="省份" width="120"> </el-table-column>
<el-table-column prop="city" label="市区" width="120"> </el-table-column>
<el-table-column prop="address" label="地址" width="300"> </el-table-column>
<el-table-column prop="zip" label="邮编" width="120"> </el-table-column>
<el-table-column fixed="right" label="操作" width="100">
<template v-slot="scope">
<el-button @click="handleClick(scope.row)" type="text" size="small"
>查看</el-button
>
<el-button type="text" size="small">编辑</el-button>
</template>
</el-table-column>
</el-table>
<script>
export default {
methods: {
handleClick(row) {
console.log(row)
}
},
data() {
return {
tableData: [
{
date: '2016-05-02',
name: '王小虎',
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1518 弄',
zip: 200333
},
{
date: '2016-05-04',
name: '王小虎',
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1517 弄',
zip: 200333
},
{
date: '2016-05-01',
name: '王小虎',
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1519 弄',
zip: 200333
},
{
date: '2016-05-03',
name: '王小虎',
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1516 弄',
zip: 200333
}
]
}
}
}
</script>表格组件的扩展
复杂的表格组件需要对表格的显示和操作进行扩展。首先从表格的显示上扩展,可以支持表头或者某一列的锁定,在滚动的时候锁定列不受影响。一个 table 标签很难实现这个效果,这时候就需要分为 table-head 和 table-body 两个组件进行维护,通过 colgroup 组件限制每一列的宽度实现表格的效果,而且表头还需要支持表头嵌套。
下面的示意图中,表头就是被分组显示的

还是先分析一下需求。对于表格的操作来说,首先要和树组件一样,每一样支持复选框进行选中,方便进行批量的操作。另外,表头还需要支持点击事件,点击后对当前这一列实现排序的效果,同时每一列还可能会有详情数据的展开,甚至表格内部还会有树形组件的嵌套、底部的数据显示等等。
把这些需求组合在一起,表格就成了组件库中最复杂的组件。需要先分解需求,把组件内部拆分成 table、table-column、table-body、table-header 组件。
首先在 table 组件的内部,使用 table-body 和 table-header 构成组件。table 提供了整个表格的标签容器;hidden-columns 负责隐藏列的显示,并且通过 table-store 进行表格内部的状态管理。每当 table 中的 table-store 被修改后,table-header、table-body 都需要重新渲染
<template>
<div class="el-table">
<div class="hidden-columns" ref="hiddenColumns">
<slot></slot>
</div>
<div class="el-table__header-wrapper" ref="headerWrapper">
<table-header ref="tableHeader" :store="store"></table-header>
</div>
<div class="el-table__body-wrapper"ref="bodyWrapper">
<table-body :context="context" :store="store"></table-body>
</div>
</div>
</template>然后在 table 组件的初始化过程中,首先使用 createStore 创建表格的 store 数据管理,并且通过 TableLayout 创建表格的布局,然后把 store 通过属性的方式传递给 table-header 和 table-body
let table = getCurrentInstance()
const store = createStore(table, {
rowKey: props.rowKey,
defaultExpandAll: props.defaultExpandAll,
selectOnIndeterminate: props.selectOnIndeterminate,
// TreeTable 的相关配置
indent: props.indent,
lazy: props.lazy,
lazyColumnIdentifier: props.treeProps.hasChildren || 'hasChildren',
childrenColumnName: props.treeProps.children || 'children',
data: props.data
})
table.store = store
const layout = new TableLayout({
store: table.store,
table,
fit: props.fit,
showHeader: props.showHeader
})
table.layout = layout再接着 table-header 组件内部会接收传递的 store,并且提供监听的事件,包括 click,mousedown 等鼠标操作后,计算出当前表头的宽高等数据进行显示
const instance = getCurrentInstance()
const parent = instance.parent
const storeData = parent.store.states
const filterPanels = ref({})
const {
tableLayout,
onColumnsChange,
onScrollableChange
} = useLayoutObserver(parent)
const hasGutter = computed(() => {
return !props.fixed && tableLayout.gutterWidth
})
onMounted(() => {
nextTick(() => {
const { prop, order } = props.defaultSort
const init = true
parent.store.commit('sort', { prop, order, init })
})
})
const {
handleHeaderClick,
handleHeaderContextMenu,
handleMouseDown,
handleMouseMove,
handleMouseOut,
handleSortClick,
handleFilterClick
} = useEvent(props, emit)
const {
getHeaderRowStyle,
getHeaderRowClass,
getHeaderCellStyle,
getHeaderCellClass
} = useStyle(props)
const { isGroup, toggleAllSelection, columnRows } = useUtils(props)
instance.state = {
onColumnsChange,
onScrollableChange
}
// eslint-disable-next-line
instance.filterPanels = filterPanels在 table-body 中,也是类似的实现方式和效果。不过 table-body 和 table-header 中的定制需求较多,需要用 render 函数来实现定制化的需求。
下面的代码中利用 h 函数返回 el-table__body 的渲染,通过 state 中读取的 columns 数据依次进行数据的显示
render() {
return h(
'table',
{
class: 'el-table__body',
cellspacing: '0',
cellpadding: '0',
border: '0'
},
[
hColgroup(this.store.states.columns.value),
h('tbody', {}, [
data.reduce((acc, row) => {
return acc.concat(this.wrappedRowRender(row, acc.length))
}, []),
h(
ElTooltip,
{
modelValue: this.tooltipVisible,
content: this.tooltipContent,
manual: true,
effect: this.$parent.tooltipEffect,
placement: 'top'
},
{
default: () => this.tooltipTrigger
}
)
])
]
)
}整体表格组件的渲染逻辑和过程比较复杂。为了帮你抽丝剥茧,这节课我重点给你说说 Element3 中 table 标签的渲染过程,至于具体的表格实现代码,你可以课后参考 Element3 的源码。
表格组件除了显示的效果非常复杂、交互非常复杂之外,还有一个非常棘手的性能问题。由于表格是二维渲染,而且表格组件如果想支持表头或者某一列锁定的定制效果,内部需要渲染不止一个 table 标签。一旦数据量庞大之后,表格就成了最容易导致性能瓶颈的组件,那这种场景如何去做优化呢?
这里我们要快速回顾一下性能优化那一讲的思路:性能优化主要的思路就是如何能够减少计算量。比如我们的表格如果有 1000 行要显示,但是我们浏览器最多只能显示 100 条,其他的需要通过滚动条的方式进行滚动显示,屏幕之外,成千上万个 dom 元素就成了性能消耗的主要原因。
针对这种情况,我们可以考虑类似图片懒加载的方案,对屏幕之外的 dom 元素做懒渲染,也就是非常常见的虚拟列表解决方案。
在虚拟列表解决方案中,我们首先要获取窗口的高度、元素的高度以及当前滚动的距离,通过这些数据计算出当前屏幕显示出来的数据。然后创建这些元素标签,设置元素的 transform 属性模拟滚动效果。这样表面看是 1000 条数据在表格里显示,实际只渲染了屏幕中间的这 100 行数据,当我们滚动鼠标的同时,去维护这 100 个数据列表,这样就完成了标签过多的性能问题
如果表格内部每一行的高度不同的话,我们就需要对每一个元素的高度进行估计。具体操作时,先进行渲染,然后等待渲染完毕之后获取高度并且缓存下来,即可实现虚拟列表元素高度的自适应