Table 用于展示多条结构类似的数据
Table Attributes
参数 | 说明 | 类型 | 可选值 | 默认值 |
---|---|---|---|---|
data | 显示的数据 | Array | — | — |
fit | 列的宽度是否自撑开 | boolean | — | true |
show-header | 是否显示表头 | boolean | — | true |
row-key | 行数据的Key,用来优化Table的渲染 | Function(row) / string | — | |
empty-text | 空数据时显示的文本内容,也可以通过 slot="empty" 设置 | String | — | 暂无数据 |
Table-column Attributes
参数 | 说明 | 类型 | 可选值 | 默认值 |
---|---|---|---|---|
label | 显示的标题 | string | — | — |
prop | 对应列内容的字段名 | string | — | — |
width | 对应列宽 | string | — | — |
基础的表格展示用法
<template>
<el-table
:data="tableData"
style="width: 100%">
<el-table-column prop="date" label="日期" width="180"></el-table-column>
<el-table-column prop="name" label="姓名" width="180"></el-table-column>
<el-table-column prop="address" label="地址"></el-table-column>
</el-table>
</template>
Table 结构
实现思路
- table.vue 控制表格渲染, table-column 控制列的渲染, table-header 表头渲染, table-body 表体渲染,table-layout 定义整体表格共享的属性和方法, table-observer 表格观察器(整体表格发生某些变化后的回调, 通知表头和表体做出相应的更新,保证 table-header 和 table-body 同步变化);
- 在 table.vue 中创建表格
状态管理器 store
, 使组件 table-column、table-header、table-body 共享之; - table-column 组件将列的相关属性和方法插入到
store.states.columns
, table-header 和 table-body 根据 store 进行单元格的渲染; - 通过设置表格的
col 的 width 属性
保证表头和表体单元格同宽,无需重复设置每个单元格的 width;
目录结构
- table.vue
- table-column.js
- table-header.js
- table-body.js
- table-layout.js
- table-observer.js
table.vue
实现 table 的渲染
tamplate 渲染
<template>
<!-- table组件是如何渲染子组件tabel-column的呢?
y-table-column职责: 收集列的属性,并将其提交到公共数据池中的columns
y-table通过prop得到的data: 提交到公共数据池中的data
接下来, 如何展示呢? -- 表格布局
样式----------------------
如何保证th和td同宽? -- 通过table下的colgroup的col宽度的设置, 这样便不用重复设置每个单元格的宽度
给table-column添加width属性
-->
<div class="y-table"
:class="[{
'y-table__border': border
}]">
<!-- 原生-子节点-列的渲染 -->
<div class="hidden-columns" ref="hiddenColumns">
<slot></slot>
</div>
<!-- 表头 -->
<div v-if="showHeader"
class="y-table__header-wrapper"
ref="headerWrapper">
<table-header
ref="tableHeader"
:store="store"
:style="{
width: bodyWidth
}"></table-header>
</div>
<!-- 表体 -->
<div
class="y-table__body-wrapper"
ref="bodyWrapper">
<table-body
:store="store"
:style="{
width: bodyWidth
}"></table-body>
</div>
<!-- 表格数据为空 -->
<div
v-if="!data || data.length === 0"
class="y-table__empty-block"
ref="emptyBolck">
<span class="y-table__empty-text">
<!-- 自定义表格为空时的数据展示 -->
<slot name="empty">{{emptyText}}</slot>
</span>
</div>
<!-- 表尾 -->
</div>
</template>
script
<script>
import TableLayout from './table-layout';
import TableHeader from './table-header';
import TableBody from './table-body';
import {
createStore,
mapStates
} from './store/helper';
import {
addResizeListener,
removeEventListener
} from '../../../src/utils/resize-event';
let tableIdSeed = 1;
export default {
name: 'YTable',
components: {
TableHeader,
TableBody,
},
props: {
// data需为数组类型Array, 且默认值为空数组[]
data: {
// 指定data的数组类型, 如果传入的data不是数组类型, 则vue会报错
type: Array,
// 对象或数组默认值必须从一个工厂函数获取
default: function () {
return [];
},
},
// border需为布尔类型Boolean
border: Boolean,
// 是否显示表头, 布尔类型, 默认为true
showHeader: {
type: Boolean,
default: true
},
// 行数据的key,用来优化table的渲染
rowKey: [String, Function],
// 列的宽度是否自动撑开
fit: {
type: Boolean,
default: true
},
/**
* 表格数据为空时的文字
* 空数据时显示的文本内容,也可以通过 slot="empty" 设置
*/
emptyText: {
type: String,
default: '暂无数据',
},
},
computed: {
/**
* 对象展开运算符
* 参考:https://vuex.vuejs.org/zh/guide/state.html#mapstate-%E8%BE%85%E5%8A%A9%E5%87%BD%E6%95%B0
* 需要安装插件:babel-plugin-transform-object-rest-spread
* 配置.bablrc文件:{"plugins": ["transform-object-rest-spread"]}
*/
...mapStates({
columns: 'columns',
tableData: 'data',
})
},
data() {
this.store = createStore(this, {
rowKey: this.rowKey,
});
// TableLayout 设置表格的相关属性
const layout = new TableLayout({
store: this.store,
table: this,
showHeader: this.showHeader,
});
return {
layout,
resizeState: {
width: null,
height: null,
}
}
},
methods: {
doLayout() {
this.layout.updateColumnsWidth();
}
},
watch: {
// 监听data
data: {
// 立即监听
immediate: true,
handler(value) {
// 提交data到公共数据池
this.store.commit('setData', value);
}
}
},
/**
* 子组件: table-header和table-body
* $children: [] 空数组
*/
created() {
// debugger;
this.tableId = 'y-table_' + tableIdSeed++;
},
beforeMount() {
// debugger;
},
/**
* $children: [table-column组件, table-header组件, table-body组件]
*/
mounted() {
// debugger;
// 更新列
this.store.updateColumns();
// 设置 col 的 width
this.doLayout();
this.resizeState = {
width: this.$el.offsetWidth,
height: this.$el.offsetHeight,
}
this.$ready = true;
},
}
</script>
table-column.js
/**
* table-column:table子组件,列
*
* 注意: 该文件不是vue文件,而是js文件
* 使用jsx语言渲染dom(需要安装相应的插件以支持JSX)
* 官方文档: https://cn.vuejs.org/v2/guide/render-function.html
* 该js是table-column组件,那么在使用该组件时,会传递过来两个属性prop和label
* 这两个属性要如何处理才能往columns中添加该列的属性呢?
*/
import {
mergeOptions,
parseWidth,
parseMinWidth,
compose,
} from './utils';
import {
defaultRenderCell,
} from './config';
let columnIdSeed = 1;
export default {
name: 'YTableColumn',
// prop传值
props: {
// 表格数据项字段名
prop: String,
property: String,
// 标题
label: String,
// 列宽
width: {},
minWidth: {},
},
data() {
return {
isSubColumn: false,
columns: [],
}
},
// 计算属性,有缓存作用
computed: {
// owner汉语意为主人,即父级的table组件
owner() {
let parent = this.$parent;
while (parent && !parent.tableId) {
parent = parent.$parent;
}
return parent;
},
/**
* 该table-column的父级组件是table还是table-column
* 如果是table-column,因该是要做单元格合处理的
*/
columnOrTableParent() {
let parent = this.$parent;
while (parent && !parent.tableId && !parent.columnId) {
parent = parent.$parent;
}
return parent;
},
// 宽度
realWidth() {
return parseWidth(this.width);
},
// 最小宽度
realMinWidth() {
return parseMinWidth(this.minWidth);
}
},
methods: {
// reduce 两两比较操作,数组之间的拼接
getPropsData(...props) {
return props.reduce((prev,cur) => {
if(Array.isArray(cur)) {
cur.forEach((key) => {
prev[key] = this[key]; // 将数据中的key对应的值赋值
})
}
return prev;
}, {});
},
// 获取child在children的位置
getColumnElIndex(children, child) {
// console.log('getColumnElIndex', children, child);
return [].indexOf.call(children, child);
},
// 设置列宽
setColumnWidth(column) {
if(this.realWidth) {
column.width = this.realWidth;
}
if(this.realMinWidth) {
column.minWidth = this.realMinWidth;
}
if(!column.minWidth) {
column.minWidth = 80;
}
column.realWidth = column.width === undefined ? column.minWidth : column.width;
return column;
},
/**
* 在table-column中设置列的渲染函数
* 列的渲染是通过table-header调用的
* @param {Object} column
* @returns column
*/
setColumnRenders(column) {
// console.log('setColumnRenders', column);
let originRenderCell = column.renderCell;
// console.log('setColumnRenders', originRenderCell, defaultRenderCell)
originRenderCell = originRenderCell || defaultRenderCell;
// 对renderCell进行包装
column.renderCell = (h, data) => {
let children = null;
// console.log('renderCell', h, data, this.$scopeSlots);
// console.log('renderCell', this.$scopedSlots, this)
// 如果该列存在默认插槽, 则渲染默认插槽, 这样的话便使得每列的自定义渲染方式整理到table-body的每列上
// 充分说明, table-body中单元格的渲染需要单独分离出来, 调用每列的渲染函数, 这样便使table-column中每列的自定义同步到table-body下
// 否则只展示该列对应的数据值
if(this.$scopedSlots.default) {
// vm.$scopedSlots 范文作用域插槽, 并传入数据
children = this.$scopedSlots.default(data);
}else {
children = originRenderCell(h, data);
}
const props = {
class: 'cell',
style: {},
};
return (
<div {...props}>
{children}
</div>
)
}
return column;
},
},
// 赋初值
beforeCreate() {
// debugger;
this.column = {};
this.columnId = '';
},
// 赋初值
created() {
// debugger;
// 定义table-column组件的columnId
const parent = this.columnOrTableParent;
// 是否是子列, 如果两者不相等,则是子列(直接父组件也是table-column)
this.isSubColumn = this.owner !== parent;
this.columnId = (parent.tableId || parent.columnId) + '_column_' + columnIdSeed++;
// 定义默认属性,暂时只有id
const defaults = {
id: this.columnId,
// 对应列内容的字段名, 也可以使用 property 属性
property: this.prop || this.property,
}
// 基础属性
// const basicProps = ['label', 'prop',];
const basicProps = ['label',];
// 收集column属性
let column = this.getPropsData(basicProps);
column = mergeOptions(defaults, column);
// 注意compose(组成,构成)执行顺序是从右到左,现在chains就相当于一个函数
// const chains = compose(this.setColumnRenders, this.setColumnWidth, this.setColumnForcedProps);
const chains = compose(this.setColumnRenders, this.setColumnWidth);
column = chains(column);
this.columnConfig = column;
},
mounted() {
// debugger;
const owner = this.owner;
const parent = this.columnOrTableParent;
// vm.$children 当前实例的直接子组件(虚拟节点)
// vm.$el.children 当前dom节点的直接子节点(真实dom节点)
// 如果直父组件是table-column
const children = this.isSubColumn ? parent.$el.children : parent.$refs.hiddenColumns.children;
const columnIndex = this.getColumnElIndex(children, this.$el);
// 甚为关键,将该列的属性存入到table的公共池中
// 父组件table的store
owner.store.commit('insertColumn', this.columnConfig, columnIndex, this.isSubColumn ? parent.columnConfig : null);
},
// render渲染
render(h) {
console.log('table-column', h, this.$slots);
return h('div', this.$slots.default);
}
}
[注意]:
- 关键代码:将该列的属性存入到table的公共池中
owner.store.commit('insertColumn', this.columnConfig, columnIndex, this.isSubColumn ? parent.columnConfig : null);
- render 渲染函数
render(h) {
return h('div', this.$slots.default);
}
table-header.js
JSX 渲染
render(h) {
let columnRows = convertToRows(this.columns);
return (
<table
class="y-table__header"
cellspacing="0"
cellpadding="0"
border="0">
<colgroup>
{
this.columns.map(column => <col name={column.id} key={column.id} />)
}
</colgroup>
<thead>
<tr>
{
this._l(columnRows, (column, cellIndex) => (
<th
key={cellIndex}
colspan={column.colspan}
rowspan={column.rowspan}>
<div
class="cell">
{column.label}
</div>
</th>
))
}
</tr>
</thead>
</table>
)
}
[注意]:
- this._l 是一个渲染列表的函数renderList
table-body.js
JSX 渲染
render(h) {
const data = this.data || [];
const columns = this.columns || [];
return (
<table
class="y-table__body"
cellspacing="0"
cellpadding="0"
border="0">
<colgroup>
{
columns.map(column => <col name={column.id} key={column.id} />)
}
</colgroup>
<tbody>
{
data.reduce((acc, row) => {
return acc.concat(this.wrappedRowRender(row, acc.length))
}, [])
}
</tbody>
</table>
)
}
[注意]
- JSX 需要插件的支持
- 官方文档:cn.vuejs.org/v2/guide/re…
- npm install @vue/babel-preset-jsx @vue/babel-helper-vue-jsx-merge-props
- 配置.babelrc: {"plugins": ["transform-object-rest-spread","transform-vue-jsx"]}
table-layout.js
import Vue from 'vue';
/**
* TableLayout 定义表格的整体相关属性和方法
* 用于table.vue整体控制table
*/
class TableLayout {
// 构造器
constructor(options) {
console.log('tableLayout', options);
this.table = null;
this.store = null;
this.showHeader = true;
this.fit = true; // 何意?
this.bodyWidth = null;
this.observers = [];
// 赋值
for(let name in options) {
if(options.hasOwnProperty(name)) {
this[name] = options[name];
}
}
if(!this.table) {
throw new Error('table is required for Table Layout');
}
if(!this.store) {
throw new Error('store is required for Table Layout');
}
}
/**
* 公共的表格处理方法
* 如: setHeight设置高度等等
*/
/**
* Vue.prototype.$isServer 是什么意思?
* Vue实例是否运行与服务器上, 属性值为true标识实例运行于服务器, 每个Vue实例都可以通过该属性判断。该属性一般用于服务器渲染, 用以区分代码是否在服务器上运行。
*
* 动态计算宽度(没有分配width的列)
* 如果这样的列只有一个, 则把剩余的宽度都分给它
* 如果这样的列有多个, 则按照最小宽度的比例分配剩余的宽度(而非平均分配)
*
* 更新columns的realWidth值
* 更新之后, 表头和表尾dom都需要相应的发生变化
*/
updateColumnsWidth() {
// console.log('updateColumnsWidth', this.table.columns, Vue.prototype.$isServer);
// console.log('updateColumnsWidth', this.table.$el);
// 如果在服务器上运行,则返回
if(Vue.prototype.$isServer) return;
const fit = this.fit;
const bodyWidth = this.table.$el.clientWidth;
let bodyMinWidth = 0;
const flattenColumns = this.table.columns;
// filter不改变原数组,且返回数组的指针指向原数组相对应的数组项的地址
// 所以改变返回的数组值, 原数组值也会发生相应的改变
let flexColumns = flattenColumns.filter((column) => typeof column.width !== 'number');
// 如果width存在且realWidth也存在, 则设置realWdith为null
flattenColumns.forEach((column) => {
if(typeof column.width === 'number' && column.realWidth) column.realWidth = null;
})
if(flexColumns.length > 0 && fit) {
flattenColumns.forEach((column) => {
bodyMinWidth += column.width || column.minWidth || 80;
})
const scrollYWidth = 0;
// 如果没有滚动条
// 通过bodyMinWidth来判断
// console.log('999', bodyMinWidth <= bodyWidth - scrollYWidth)
if(bodyMinWidth <= bodyWidth - scrollYWidth) {
// 没有分配宽度的列的总宽
const totalFlexWidth = bodyWidth - scrollYWidth - bodyMinWidth;
if(flexColumns.length === 1) {
flexColumns[0].realWidth = (flexColumns[0].minWidth || 80) + totalFlexWidth;
}else {
// 计算所有没有宽度的列的最小宽度之和
const allColumnsWidth = flexColumns.reduce((prev, column) => prev + (column.minWidth || 80), 0);
// console.log('allColumnsWidth', flexColumns, allColumnsWidth)
// 计算倍数
const flexWidthPerPixel = totalFlexWidth / allColumnsWidth;
// console.log('3333', flexWidthPerPixel, allColumnsWidth, totalFlexWidth);
// 非第一个列的宽度
let noneFirstWidth = 0;
flexColumns.forEach((column, index) => {
if(index === 0) return;
// console.log('7', column, index)
// 取不大于该值的整数,这样会把多余的宽度都分配到第一列上
const flexWidth = Math.floor((column.minWidth || 80) * flexWidthPerPixel);
// console.log('7', flexWidth, flexWidthPerPixel)
noneFirstWidth += flexWidth;
// console.log('7', noneFirstWidth)
column.realWidth = (column.minWidth || 80) + flexWidth;
})
// 计算第一个的realWidth
flexColumns[0].realWidth = (flexColumns[0].minWidth || 80) + totalFlexWidth - noneFirstWidth;
}
}
this.bodyWidth = Math.max(bodyMinWidth, bodyWidth);
this.table.resizeState.width = this.bodyWidth;
// console.log('999', this.table.columns)
// console.log('999', bodyWidth, scrollYWidth, bodyMinWidth, bodyWidth - scrollYWidth - bodyMinWidth)
} else {
// 有水平滚动条
}
}
}
export default TableLayout;
table-observer.js
/**
* 混入布局观察器
* LayoutObserver布局观察器
* 用于表格布局组件: table-header 和 table-body
*/
export default {
computed: {
// 表格布局属性
tableLayout() {
let layout = this.layout;
// console.log('tableLayout', layout);
if(!layout && this.table) {
layout = this.table.layout;
}
// 如果还是没有则抛出错误
if(!layout) {
throw new Error('Can not find table layout.');
}
return layout;
}
},
methods: {
/**
* 设置colgroup中的col的width属性
* 通过控制colgroup中的col的宽度来绑定table单元格的宽度
* 通过使用colgroup标签, 向整个列应用样式, 而不需要重复为每个单元格或每一行设置样式
* col属性:
* - width:规定单元格的宽度
* th/td属性高:
* - colspan:规定单元格可横跨的列数
* - rowspan:规定单元格可横跨的行数
*
* vue:获取当前组件所在的真实dom this.$el
* js:获取dom节点方式之一
* js:获取匹配指定css选择器的所有元素 querySelectorAll (querySelector获取匹配到的第一个元素)
* js:获取dom属性 getAttribute
* js:设置dom属性 setAttribute
* @param {Object} layout
* @returns undefined
*/
onColumnsChange(layout) {
// console.log('layout', layout);
// 获取真实dom, 通过querySelectorAll筛选
// querySelectorAll方法返回文档中匹配指定 CSS 选择器的所有元素,返回 NodeList 对象
const cols = this.$el.querySelectorAll('colgroup > col');
// console.log('77', cols, this.columns);
const flattenColumns = this.columns;
const columnsMap = {};
flattenColumns.forEach((column) => {
columnsMap[column.id] = column;
})
// console.log('flatcolumnsMaptenColumns', columnsMap)
if(!cols.length) return;
for(let i = 0, j = cols.length; i < j; i++) {
const col = cols[i];
const name = col.getAttribute('name');
const column = columnsMap[name];
// console.log(i, col, name, columnsMap, column);
if(column) {
col.setAttribute('width', column.realWidth || column.width);
}
}
}
},
mounted() {
this.onColumnsChange(this.tableLayout);
},
updated() {
// console.log('模块更新', this.$el);
this.onColumnsChange(this.tableLayout);
},
}
总结
实现思路
- table.vue 控制表格渲染, table-column 控制列的渲染, table-header 表头渲染, table-body 表体渲染,table-layout 定义整体表格共享的属性和方法, table-observer 表格观察器(整体表格发生某些变化后的回调, 通知表头和表体做出相应的更新,保证 table-header 和 table-body 同步变化);
- 在 table.vue 中创建表格
状态管理器 store
, 使组件 table-column、table-header、table-body 共享之; - table-column 组件将列的相关属性和方法插入到
store.states.columns
, table-header 和 table-body 根据 store 进行单元格的渲染; - 通过设置表格的
col 的 width 属性
保证表头和表体单元格同宽,无需重复设置每个单元格的 width;
目录结构
- table.vue
- table-column.js
- table-header.js
- table-body.js
- table-layout.js
- table-observer.js