三.数据输出(表格)
前言 --> 表格组件特点
- 下拉菜单组件应该由两部分组成:
- 表头
- 数据展示部分
- 它的主要功能包括:
- 数据展示部分也可以嵌入基本组件,也可以使用自定义组件
- 由
<table>
、<thead>
、<tbody>
、<tr>
、th
、td
这些标签组成,一般分为表头columns和数据data.
1.目录结构
├── table
│ ├── TableRender.vue
│ ├── render.js
│ ├── pages
│ └── index.js
1.Render 表格组件
先将基础表格封装成一个组件
<!-- src/components/table/TableRender.vue -->
<template>
<table>
<thead>
<tr>
<th v-for="(col, i) in columns" :key="i">{{ col.title }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in data" :key="i">
<td v-for="(col, j) in columns" :key="j">{{ row[col.key] }}</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
props: {
columns: {
type: Array,
default: () => [],
},
data: {
type: Array,
default: () => [],
},
},
}
</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>
组件的封装-->使用
可以看到一个纯文本显示的表格组件已经封装好了
现在有些地方需要用一些额外的逻辑(如生日)修改显示的效果,这里可以使用 render 函数来实现
Render 自定义列模板
js// src/components/table/render.js export default { functional: true, props: { row: Object, column: Object, index: Numner, 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 组件:
// table.vue
<template>
<table>
<thead>
<tr>
<th v-for="col in columns">{{ col.title }}</th>
</tr>
</thead>
<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>
</table>
</template>
<script>
import Render from "./render.js"
export default {
components: { Render },
props: {
columns: {
type: Array,
default() {
return []
},
},
data: {
type: Array,
default() {
return []
},
},
},
}
</script>
如果 columns 中的某一列配置了 render 字段,那就通过 render.js 完成自定义模板,否则以字符串形式渲染。比如对出生日期这列显示为标准的日期格式,可以这样定义 column:
export default {
data() {
return {
columns: [
// ...
{
title: "出生日期",
render: (h, { row, column, index }) => {
const data = 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 个参数得到任意想要的结果。由于是自定义列,显示什么都是使用者决定的,因此在使用了 render 的 column 里可以不用谢字段key
。
- 修改当前行
有了 render,Table 组件就已经完成了,剩余工作都是使用者来配置 columns 完成各种复杂的业务逻辑。
操作这一列,默认是一个修改按钮,点击后,变为保存和取消两个按钮,同时本行其他各列都变为了输入框,并且初始值就是刚才单元格的数据。变为输入框后,可以任意修改单元格数据,点击保存按钮保存完整行数据,点击取消按钮,还原至修改前的数据。
当进入编辑状态时,每一列的输入框都要有一个临时的数据使用v-model
双向绑定来响应修改,所以在 data 里再声明四个数据。
// table-render.vue
{
data(){
return {
// ...
editName: '', // 第一列输入框
editAge: '', // 第二列输入框
editBirthday: '', //第三列输入框
editAddress: '', //第四列输入框
}
}
}
同时还要知道是在修改第几行的数据,所以再加上一个数据标识当前正在修改的行序号(从 0 开始)
// table-render.vue
{
data (){
return {
// ...
editIndex: -1, // 当前聚焦的输入框的行数
}
}
}
editIndex
默认给了 -1,也就是一个不存在的行号,当点击修改按钮时,再将它置为正确的行号。我们先定义操作列的 render:
// table-render.vue
{
data() {
return {
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,。当点击修改按钮时,把 render 中第二个参数{row}
中的各列数据赋值给了之前在 data 中声明的 4 个数据,这样做是因为之后点击取消按钮时,editName 等值已经修改好了,还没有还原,所以在开启编辑状态的同时,初始化各输入框的值(当然也可以在取消时重置)。最后再把editIndex
置为对应的行序号{index}
,此时 render 的if
条件this.editIndex === index
为真,编辑列变为两个按钮,保存和取消。点击保存,直接修改表格源数据 data 中对应的各字段值,并将 editIndex 置为-1,退出编辑状态;点击取消,不保存源数据,直接退出编辑器。
除了编辑列,其他各数据列都有两种状态:
- 1.当 editIndex 等于当前行号 index 时,呈现输入框状态
- 2.当 editIndex 不等于当前行号 index 时,呈现默认数据。
以姓名为例:
// table-render.vue
{
data(){
return {
columns :[
// ...
{
title: '姓名',
key: 'name',
render: (h,{ row, index}) =>{
let edit
// 当前行为聚焦时
if(this.editIndex === index){
edit = [h('input'),{
domProps: {
value: row.name
},
on: {
input:(event)=>{
this.editName = event.target.value
}
}
}]
}else{
edit = row.name
}
return h('div', [
edit
])
}
}
]
}
}
}
变量edit
根据 editIndex 呈现不同的节点,还是先看 else,直接显示了对应字段的数据。在聚焦是(this.editIndex === index),渲染一个input
输入框,初始值value
通过 render 的domProps
绑定了row.name
(这里也可以绑定 editName),并监听了 input 时间,将输入的内容,实时缓存在数据editName
中,供保存使用。实际上,这里绑定的 value 和时间 input 就是语法糖v-model
在 Render 函数中的写法,在 template 中,经常写作<input v-model="editName">
其他列类似
// table-render.vue,部分代码省略
{
data () {
return {
columns: [
// ...
{
title: '年龄',
key: 'age',
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [h('input', {
domProps: {
value: row.age
},
on: {
input: (event) => {
this.editAge = event.target.value;
}
}
})];
} else {
edit = row.age;
}
return h('div', [
edit
]);
}
},
{
title: '出生日期',
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [h('input', {
domProps: {
value: row.birthday
},
on: {
input: (event) => {
this.editBirthday = event.target.value;
}
}
})];
} else {
const date = new Date(parseInt(row.birthday));
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
edit = `${year}-${month}-${day}`;
}
return h('div', [
edit
]);
}
},
{
title: '地址',
key: 'address',
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [h('input', {
domProps: {
value: row.address
},
on: {
input: (event) => {
this.editAddress = event.target.value;
}
}
})];
} else {
edit = row.address;
}
return h('div', [
edit
]);
}
},
]
}
}
}
实际上,很多 Vue.js 的开发难题,都可以用 Render 函数来解决,它比 template 模板更灵活,可以完全发挥 JavaScript 的编程能力,因此很多 JS 的开发思想可以借鉴。如果你习惯 JSX,那完全可以抛弃传统的 template 写法。
Render 虽好,但也有弊端,通过上面的示例可以发现,写出来的 VNode 对象时很难读的,维护性比 template 差。
2.方案一
第一种方案,用最简单的 slot-scope 实现,同时兼容 Render 函数的旧用法。拷贝上一节的 Table 组件目录,更名为table-slot
,同时也拷贝路由,更名为table-slot.vue
。为了兼容旧的 Render 函数用法,在 columns 的列配置 column 中,新增一个字段slot
来指定 slot-scope 的名称:
<!-- src/components/table-slot/table.vue -->
<template>
<table>
<thead>
<tr>
<th v-for="col in columns">{{ col.title }}</th>
</tr>
</thead>
<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>
</table>
</template>
相比原先的文件,只在 'render' in col
的条件下新加了一个 template
的标签,如果使用者的 column 配置了 render 字段,就优先以 Render 函数渲染,然后再判断是否用 slot-scope 渲染。在定义的作用域 slot 中,将行数据 row
、列数据 column
和第几行 index
作为 slot 的参数,并根据 column 中指定的 slot 字段值,动态设置了具名 name
。使用者在配置 columns 时,只要指定了某一列的 slot,那就可以在 Table 组件中使用 slot-scope。我们以上一节的可编辑整行数据为例,用 slot-scope 的写法实现完全一样的效果:
<!-- src/views/table-slot.vue -->
<template>
<div>
<table-slot :columns="columns" :data="data">
<template slot-scope="{ row, index }" slot="name">
<input type="text" v-model="editName" v-if="editIndex === index" />
<span v-else>{{ row.name }}</span>
</template>
<template slot-scope="{ row, index }" slot="age">
<input type="text" v-model="editAge" v-if="editIndex === index" />
<span v-else>{{ row.age }}</span>
</template>
<template slot-scope="{ row, index }" slot="birthday">
<input type="text" v-model="editBirthday" v-if="editIndex === index" />
<span v-else>{{ getBirthday(row.birthday) }}</span>
</template>
<template slot-scope="{ row, index }" slot="address">
<input type="text" v-model="editAddress" v-if="editIndex === index" />
<span v-else>{{ row.address }}</span>
</template>
<template slot-scope="{ row, index }" slot="action">
<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>
<script>
import TableSlot from "../components/table-slot/table.vue"
export default {
components: { TableSlot },
data() {
return {
columns: [
{
title: "姓名",
slot: "name",
},
{
title: "年龄",
slot: "age",
},
{
title: "出生日期",
slot: "birthday",
},
{
title: "地址",
slot: "address",
},
{
title: "操作",
slot: "action",
},
],
data: [
{
name: "王小明",
age: 18,
birthday: "919526400000",
address: "北京市朝阳区芍药居",
},
{
name: "张小刚",
age: 25,
birthday: "696096000000",
address: "北京市海淀区西二旗",
},
{
name: "李小红",
age: 30,
birthday: "563472000000",
address: "上海市浦东新区世纪大道",
},
{
name: "周小伟",
age: 26,
birthday: "687024000000",
address: "深圳市南山区深南大道",
},
],
editIndex: -1, // 当前聚焦的输入框的行数
editName: "", // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁
editAge: "", // 第二列输入框
editBirthday: "", // 第三列输入框
editAddress: "", // 第四列输入框
}
},
methods: {
handleEdit(row, index) {
this.editName = row.name
this.editAge = row.age
this.editAddress = row.address
this.editBirthday = row.birthday
this.editIndex = index
},
handleSave(index) {
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
},
getBirthday(birthday) {
const date = new Date(parseInt(birthday))
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
return `${year}-${month}-${day}`
},
},
}
</script>
示例中在 <table-slot>
内的每一个 <template>
就对应某一列的 slot-scope 模板,通过配置的 slot
字段,指定具名的 slot-scope。可以看到,基本是把 Render 函数还原成了 html 的写法,这样看起来直接多了,渲染效果是完全一样的。在 slot-scope 中,平时怎么写组件,这里就怎么写,Vue.js 所有的 API 都是可以直接使用的。
方案一是最优解,一般情况下,使用这种方案就可以了,其余两种方案是基于 Render 的。
3.方案二
第二种方案,不需要修改原先的 Table 组件代码,只是在使用层面修改即可。先来看具体的使用代码,然后再做分析。注意,这里使用的 Table 组件,仍然是上一节 src/components/table-render
的组件,它只有 Render 函数,没有定义 slot-scope:
<!-- src/views/table-render.vue 的改写 -->
<template>
<div>
<table-render ref="table" :columns="columns" :data="data">
<template slot-scope="{ row, index }" slot="name">
<input type="text" v-model="editName" v-if="editIndex === index" />
<span v-else>{{ row.name }}</span>
</template>
<template slot-scope="{ row, index }" slot="age">
<input type="text" v-model="editAge" v-if="editIndex === index" />
<span v-else>{{ row.age }}</span>
</template>
<template slot-scope="{ row, index }" slot="birthday">
<input type="text" v-model="editBirthday" v-if="editIndex === index" />
<span v-else>{{ getBirthday(row.birthday) }}</span>
</template>
<template slot-scope="{ row, index }" slot="address">
<input type="text" v-model="editAddress" v-if="editIndex === index" />
<span v-else>{{ row.address }}</span>
</template>
<template slot-scope="{ row, index }" slot="action">
<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-render>
</div>
</template>
<script>
import TableRender from "../components/table-render/table.vue"
export default {
components: { TableRender },
data() {
return {
columns: [
{
title: "姓名",
render: (h, { row, column, index }) => {
return h(
"div",
this.$refs.table.$scopedSlots.name({
row: row,
column: column,
index: index,
})
)
},
},
{
title: "年龄",
render: (h, { row, column, index }) => {
return h(
"div",
this.$refs.table.$scopedSlots.age({
row: row,
column: column,
index: index,
})
)
},
},
{
title: "出生日期",
render: (h, { row, column, index }) => {
return h(
"div",
this.$refs.table.$scopedSlots.birthday({
row: row,
column: column,
index: index,
})
)
},
},
{
title: "地址",
render: (h, { row, column, index }) => {
return h(
"div",
this.$refs.table.$scopedSlots.address({
row: row,
column: column,
index: index,
})
)
},
},
{
title: "操作",
render: (h, { row, column, index }) => {
return h(
"div",
this.$refs.table.$scopedSlots.action({
row: row,
column: column,
index: index,
})
)
},
},
],
data: [],
editIndex: -1, // 当前聚焦的输入框的行数
editName: "", // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁
editAge: "", // 第二列输入框
editBirthday: "", // 第三列输入框
editAddress: "", // 第四列输入框
}
},
methods: {
handleEdit(row, index) {
this.editName = row.name
this.editAge = row.age
this.editAddress = row.address
this.editBirthday = row.birthday
this.editIndex = index
},
handleSave(index) {
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
},
getBirthday(birthday) {
const date = new Date(parseInt(birthday))
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
return `${year}-${month}-${day}`
},
},
mounted() {
this.data = [
{
name: "王小明",
age: 18,
birthday: "919526400000",
address: "北京市朝阳区芍药居",
},
{
name: "张小刚",
age: 25,
birthday: "696096000000",
address: "北京市海淀区西二旗",
},
{
name: "李小红",
age: 30,
birthday: "563472000000",
address: "上海市浦东新区世纪大道",
},
{
name: "周小伟",
age: 26,
birthday: "687024000000",
address: "深圳市南山区深南大道",
},
]
},
}
</script>
在 slot-scope 的使用上(即 template 的内容),与方案一是完全一致的,可以看到,在 column 的定义上,仍然使用了 render 字段,只不过每个 render 都渲染了一个 div 节点,而这个 div 的内容,是指定来在 <table-render>
中定义的 slot-scope:
render: (h, { row, column, index }) => {
return h(
"div",
this.$refs.table.$scopedSlots.name({
row: row,
column: column,
index: index,
})
)
}
这正是 Render 函数灵活的一个体现,使用 $scopedSlots
可以访问 slot-scope,所以上面这段代码的意思是,name 这一列仍然是使用 Functional Render,只不过 Render 的是一个预先定义好的 slot-scope 模板。
有一点需要注意的是,示例中的 data
默认是空数组,而在 mounted 里才赋值的,是因为这样定义的 slot-scope,初始时读取 this.$refs.table.$scopedSlots
是读不到的,会报错,当没有数据时,也就不会去渲染,也就避免了报错。
这种方案虽然可行,但归根到底是一种 hack,不是非常推荐,之所以列出来,是为了对 Render 和 slot-scope 有进一步的认识。
4.方案三
第 3 中方案的思路和第 2 种是一样的,它介于方案 1 与方案 2 之间。这种方案要修改 Table 组件代码,但是用例与方案 1 完全一致。
在方案 2 中,我们是通过修改用例使用 slot-scope 的,也就是说 Table 组件本身没有支持 slot-scope,是我们“强加”上去的,如果把强加的部分,集成到 Table 内,那对使用者就很友好了,同时也避免了初始化报错,不得不把 data 写在 mounted 的问题。
保持方案 1 的用例不变,修改 src/components/table-render
中的代码。为了同时兼容 Render 与 slot-scope,我们在 table-render
下新建一个 slot.js 的文件:
// src/components/table-render/slot.js
export default {
functional: true,
inject: ["tableRoot"],
props: {
row: Object,
column: Object,
index: Number,
},
render: (h, ctx) => {
return h(
"div",
ctx.injections.tableRoot.$scopedSlots[ctx.props.column.slot]({
row: ctx.props.row,
column: ctx.props.column,
index: ctx.props.index,
})
)
},
}
它仍然是一个 Functional Render,使用 inject
注入了父级组件 table.vue(下文改写) 中提供的实例 tableRoot
。在 render 里,也是通过方案 2 中使用 $scopedSlots
定义的 slot,不过这是在组件级别定义,对用户来说是透明的,只要按方案 1 的用例来写就可以了。
table.vue 也要做一点修改:
<!-- src/components/table-slot/table.vue 的改写,部分代码省略 -->
<template>
<table>
<thead>
<tr>
<th v-for="col in columns">{{ col.title }}</th>
</tr>
</thead>
<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-scope :row="row" :column="col" :index="rowIndex"></slot-scope>
</template>
<template v-else>{{ row[col.key] }}</template>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import Render from "./render.js"
import SlotScope from "./slot.js"
export default {
components: { Render, SlotScope },
provide() {
return {
tableRoot: this,
}
},
props: {
columns: {
type: Array,
default() {
return []
},
},
data: {
type: Array,
default() {
return []
},
},
},
}
</script>
因为 slot-scope 模板是写在 table.vue 中的(对使用者来说,相当于写在组件 <table-slot></table-slot>
之间),所以在 table.vue 中使用 provide 向下提供了 Table 的实例,这样在 slot.js 中就可以通过 inject 访问到它,继而通过 $scopedSlots
获取到 slot。需要注意的是,在 Functional Render 是没有 this 上下文的,都是通过 h 的第二个参数临时上下文 ctx 来访问 prop、inject 等的。
方案 3 也是推荐使用的,当 Table 的功能足够复杂,层级会嵌套的比较深,那时方案 1 的 slot 就不会定义在第一级组件中,中间可能会隔许多组件,slot 就要一层层中转,相比在任何地方都能直接使用的 Render 就要麻烦了。所以,如果你的组件层级简单,推荐用第一种方案;如果你的组件已经成型(某 API 基于 Render 函数),但一时间不方便支持 slot-scope,而使用者又想用,那就选方案 2;如果你的组件已经成型(某 API 基于 Render 函数),但组件层级复杂,要按方案 1 那样支持 slot-scope 可能改动较大,还有可能带来新的 bug,那就用方案 3,它不会破坏原有的任何内容,但会额外支持 slot-scope 用法,关键是改动简单。
总结
通过对前端组件的分析,需要重点关注组件中易变性对组件封装的影响,它会对组件的可复用性、可扩展性产生很大影响