Vue独立组件开发:封装一个可编辑的Table组件
表格组件 Table
是中后台产品中最常用的组件之一,用于展示大量结构化的数据。正规的表格,是由 <table>
、<thead>
、<tbody>
、<tr>
、<th>
、<td>
这些标签组成,一般分为表头 columns 和数据 data。
本文就来开发一个能进行编辑的表格组件 Table
,如下图:
定义 Props
一般的 table
结构是这样的:
<table>
<thead>
<tr>
<th>姓名</th>
<th>年龄</th>
</tr>
</thead>
<tbody>
<tr>
<td>王小明</td>
<td>18</td>
</tr>
<tr>
<td>张小刚</td>
<td>25</td>
</tr>
</tbody>
</table>
表格分为了两部分,表头 thead
和数据 tbody
,那 props
也定义两个:
-
columns
:列配置,格式为数组,其中每一列 column 是一个对象,用来描述这一列的信息,它的具体说明如下:- title:列头显示文字;
- key:对应列内容的字段名;
- render:自定义渲染列,使用 Vue 的 Render 函数,不定义则直接显示为文本。
[
{
title: '姓名',
key: 'name'
},
{
title: '年龄',
key: 'age'
}
]
data
:显示的结构化数据,格式为数组,其中每一个对象,就是一行的数据,比如:
[
{
name: '张三',
age: 20
},
{
name: '李四',
age: 18
}
]
column 定义的 key
值,与 data 是一一对应的,这是一种常见的数据接口定义规则。
因为不确定使用者要对某一列做什么交互,所以不能在 Table
内来实现自定义列。使用 Render
函数可以将复杂的自定义列模板的工作交给使用者来配置,Table
内只用一个 Functional Render
做中转。
表格结构
先来创建 table.vue
文件:
<template>
<table>
<thead>
<tr>
<th v-for="col in columns">{{ col.title }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data">
<td v-for="col in columns">{{ row[col.key] }}</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
props: {
columns: {
type: Array,
default () {
return [];
}
},
data: {
type: Array,
default () {
return [];
}
}
}
}
</script>
<style>
table{
width: 100%;
border-collapse: collapse;
border-spacing: 0;
empty-cells: show;
border: 1px solid #e9e9e9;
}
table th{
background: #f7f7f7;
color: #5c6b77;
font-weight: 600;
white-space: nowrap;
}
table td, table th{
padding: 8px 16px;
border: 1px solid #e9e9e9;
text-align: left;
}
</style>
使用刚刚创建的组件:
<template>
<div>
<table-render :columns="columns" :data="data"></table-render>
</div>
</template>
<script>
import TableRender from '../components/table.vue'
export default {
components: { TableRender },
data() {
return {
columns: [
{
title: '姓名',
key: 'name'
},
{
title: '年龄',
key: 'age'
},
{
title: '出生日期',
key: 'birthday'
},
{
title: '地址',
key: 'address'
},
{
title: '操作'
}
],
data: [
{
name: '王小明',
age: 18,
birthday: '919526400000',
address: '北京市朝阳区芍药居'
},
{
name: '张小刚',
age: 25,
birthday: '696096000000',
address: '北京市海淀区西二旗'
}
]
}
}
}
</script>
表格已经能渲染出来了。
但现在的单元格只是将 data 当作纯文本来显示,所以出生日期列显示为时间戳。如果要显示正常的日期(如1991-5-14),目前可以另写一个计算属性(computed
),手动将时间戳换算为标准日期格式后,来动态修改 data
里的 birthday
字段。
这样做对于出生日期这样的数据还好,但对于操作这一列就不可取了,因为它带有业务逻辑,点击编辑按钮,是可以对当前行数据进行修改的。这时就要用到 Render
函数。
Render 自定义列
在上一篇文章中Vue独立组件开发:换一种思路写Vue——Render 函数与 Functional Render我们已经介绍过函数式组件 Functional Render
的用法,它没有状态和上下文,主要用于中转一个组件,用在本文的 Table 组件非常合适。
新建 render.js
文件:
export default {
functional: true,
props: {
row: Object,
column: Object,
index: Number,
render: Function
},
render: (h, ctx) => {
const params = {
row: ctx.props.row,
column: ctx.props.column,
index: ctx.props.index
};
return ctx.props.render(h, params);
}
};
render.js 定义了 4 个 props:
- row:当前行的数据;
- column:当前列的数据;
- index:当前是第几行;
- render:具体的 render 函数内容。
这里的 render
选项并没有渲染任何节点,而是直接返回 props 中定义的 render,并将 h 和当前的行、列、序号作为参数传递出去。
在 table.vue
里就可以使用 render.js
组件:
<tbody>
<tr v-for="(row, rowIndex) in data">
<td v-for="col in columns">
<template v-if="'render' in col">
<Render :row="row" :column="col" :index="rowIndex" :render="col.render"></Render>
</template>
<template v-else>{{ row[col.key] }}</template>
</td>
</tr>
</tbody>
<script>
import Render from './render.js';
export default {
components: { Render },
}
</script>
如果 columns
中的某一列配置了 render
字段,那就通过 render.js
完成自定义模板,否则以字符串形式渲染。比如对出生日期这列显示为标准的日期格式,可以这样定义 column:
export default {
data () {
return {
columns: [
// ...
{
title: '出生日期',
render: (h, { row, column, index }) => {
const date = new Date(parseInt(row.birthday));
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const birthday = `${year}-${month}-${day}`;
return h('span', birthday);
}
}
]
}
}
}
columns
里定义的 render
,是有两个参数的,第一个是 createElement
(即 h),第二个是从 render.js
传过来的对象,它包含了当前行数据(row
)、当前列配置(column
)、当前是第几行(index
),使用者可以基于这 3 个参数得到任意想要的结果。由于是自定义列了,显示什么都是使用者决定的。
如果你真正理解了,应该知道 columns 里定义的 render 字段,它仅仅是名字叫 render 的一个普通函数,并非 Vue.js 实例的 render 选项,只是我们恰巧把它叫做 render 而已,如果愿意,也可以改为其它名字。
操作这一列,默认是一个修改按钮,点击后,变为保存和取消两个按钮,同时本行其它各列都变为了输入框,并且初始值就是刚才单元格的数据。变为输入框后,可以任意修改单元格数据,点击保存按钮保存整行数据,点击取消按钮,还原至修改前的数据。
当进入编辑状态时,每一列的输入框都要有一个临时的数据使用 v-model
双向绑定来响应修改,所以在 data 里再声明四个数据:
{
data () {
return {
editName: '', // 第一列输入框
editAge: '', // 第二列输入框
editBirthday: '', // 第三列输入框
editAddress: '', // 第四列输入框
editIndex: -1, // 当前聚焦的输入框的行数
}
}
}
先定义操作列的 render
函数:
{
data () {
columns: [
{
title: '操作',
render: (h, { row, index }) => {
// 如果当前行是编辑状态,则渲染两个按钮
if (this.editIndex === index) {
return [
h('button', {
on: {
click: () => {
this.data[index].name = this.editName;
this.data[index].age = this.editAge;
this.data[index].birthday = this.editBirthday;
this.data[index].address = this.editAddress;
this.editIndex = -1;
}
}
}, '保存'),
h('button', {
style: {
marginLeft: '6px'
},
on: {
click: () => {
this.editIndex = -1;
}
}
}, '取消')
];
} else { // 当前行是默认状态,渲染为一个按钮
return h('button', {
on: {
click: () => {
this.editName = row.name;
this.editAge = row.age;
this.editAddress = row.address;
this.editBirthday = row.birthday;
this.editIndex = index;
}
}
}, '修改');
}
}
}
]
}
}
render
里的 if / else
可以先看 else,因为默认是非编辑状态,也就是说 editIndex
还是 -1。
当点击修改按钮时,初始化各输入框的值,即表格行中的值。接着,再把 editIndex
置为了对应的行序号 { index }
,此时 render 的 if
条件 this.editIndex === index
为真,编辑列变成了两个按钮:保存和取消。
点击保存,直接修改表格源数据 data
中对应的各字段值,并将 editIndex
置为 -1,退出编辑状态;点击取消,不保存源数据,直接退出编辑状态。
除编辑列,其它各数据列都有两种状态:
- 当
editIndex
等于当前行号index
时,呈现输入框状态; - 当
editIndex
不等于当前行号index
时,呈现默认数据。
{
data () {
columns: [
{
title: '姓名',
key: 'name',
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [h('input', {
// domProps表示html标签上的固有属性
domProps: {
value: row.name
},
on: {
input: (event) => {
this.editName = event.target.value;
}
}
})];
} else {
edit = row.name;
}
return h('div', [
edit
]);
}
}
]
}
}
当this.editIndex === index
,渲染一个 input
输入框,初始值 value
通过 render 的 domProps
绑定了 row.name
,并监听了 input
事件,将输入的内容,实时缓存在数据 editName
中,供保存时使用。
事实上,这里绑定的 value 和事件 input 就是语法糖 v-model
在 Render 函数中的写法,在 template
中,经常写作 <input v-model="editName">
。
其它列与姓名类似,只是字段不同,这里就不展开了。
这样就完成了本文开头的表格效果。但是,发现table
的使用和elementui
中表格使用还是有很大不同,这里完成是采用h
函数来写的,这样比较难度,而且维护性也比template
差。
下面,我们来继续改造,采用slot
的方式来写。
template
模式
对于大部分写 Vue
的开发者来说,更倾向于使用 template
的语法,毕竟它是 Vue
独有的特性。现在在原有 Table
组件基础上修改,实现一种达到同样渲染效果,但对使用者更友好的 slot-scope
写法。
作用域插槽
slot
插槽都很熟悉,用于分发内容。那什么是作用域插槽呢?
先来看一个场景,比如某组件拥有下面的模板:
<ul>
<li v-for="book in books" :key="book.id">
{{ book.name }}
</li>
</ul>
使用者传递一个数组 books
,由组件内的 v-for
循环显示,这里的 {{ book.name }}
是纯文本输出,如果想自定义它的模板(即内容分发),就要用到 slot
,但 slot
只能是固定的模板,没法自定义循环体中的一个具体的项,事实上这跟上面的 Table
场景是类似的。
常规的 slot
无法实现对组件循环体的每一项进行不同的内容分发,这就要用到 slot-scope
,它本质上跟 slot
一样,只不过可以传递参数。比如上面的示例,使用 slot-scope
封装:
<ul>
<li v-for="book in books" :key="book.id">
<slot :book="book">
<!-- 默认内容 -->
{{ book.name }}
</slot>
</li>
</ul>
在 slot
上,传递了一个自定义的参数 book
,它的值绑定的是当前循环项的数据 book
,这样在父级使用时,就可以在 slot
中访问它了:
<book-list :books="books">
<template v-slot="slotProps">
<span v-if="slotProps.book.sale">限时优惠</span>
{{ slotProps.book.name }}
</template>
</book-list>
这就是作用域 slot(slot-scope)
,能够在组件的循环体中做内容分发,有了它,Table
组件的自定义列模板就不用写一长串的 Render
函数了。
第一次改造
为了兼容旧的 Render 函数用法,在 columns 的列配置 column 中,新增一个字段 slot
来指定 slot-scope
的名称:
<tbody>
<tr v-for="(row, rowIndex) in data">
<td v-for="col in columns">
<template v-if="'render' in col">
<Render :row="row" :column="col" :index="rowIndex" :render="col.render"></Render>
</template>
<template v-else-if="'slot' in col">
<slot :row="row" :column="col" :index="rowIndex" :name="col.slot"></slot>
</template>
<template v-else>{{ row[col.key] }}</template>
</td>
</tr>
</tbody>
此时columns
修改为:
columns: [
{
title: '姓名',
slot: 'name'
},
{
title: '年龄',
slot: 'age'
},
{
title: '出生日期',
slot: 'birthday'
},
{
title: '地址',
slot: 'address'
},
{
title: '操作',
slot: 'action'
}
]
在定义的作用域 slot 中,将行数据 row
、列数据 column
和第几行 index
作为 slot 的参数,并根据 column 中指定的 slot 字段值,动态设置了具名 name
。
<template>
<div>
<table-slot :columns="columns" :data="data">
<template v-slot:name="{ row, index }">
<input type="text" v-model="editName" v-if="editIndex === index" />
<span v-else>{{ row.name }}</span>
</template>
<template v-slot:age="{ row, index }">
<input type="text" v-model="editAge" v-if="editIndex === index" />
<span v-else>{{ row.age }}</span>
</template>
<template v-slot:birthday="{ row, index }">
<input type="text" v-model="editBirthday" v-if="editIndex === index" />
<span v-else>{{ getBirthday(row.birthday) }}</span>
</template>
<template v-slot:address="{ row, index }" slot="address">
<input type="text" v-model="editAddress" v-if="editIndex === index" />
<span v-else>{{ row.address }}</span>
</template>
<template v-slot:action="{ row, index }">
<div v-if="editIndex === index">
<button @click="handleSave(index)">保存</button>
<button @click="editIndex = -1">取消</button>
</div>
<div v-else>
<button @click="handleEdit(row, index)">操作</button>
</div>
</template>
</table-slot>
</div>
</template>
示例中在 <table-slot>
内的每一个 <template>
就对应某一列的 slot-scope
模板,通过配置的 name
字段,指定具名的 slot-scope
。
现在,基本是把 Render
函数还原成了 html
的写法,这样看起来直接多了,渲染效果是完全一样的。
总结
为了自定义列,我们使用了render
函数,这样可以让用户自由的定义展示的内容。
但是通过render
的写法不好看,也不好维护,于是利用作用域插槽来代替render
,这样也可以满足用户自定义内容。
通过上面两种写法的对比,各有优劣,需要我们在不同场景中灵活使用。
转载自:https://juejin.cn/post/7242596733430415421