Vue插槽组件化开发指南

目录

  1. 介绍
  2. 插槽基础知识
  3. 实战案例:通用页面布局组件
  4. 高级应用
  5. 最佳实践

介绍

在Vue组件化开发中,插槽(Slot)是一种强大的内容分发机制,允许父组件向子组件注入内容。通过合理使用插槽,我们可以创建高度可复用且灵活的组件,提高开发效率并保持代码的一致性。

本文将通过一个实际案例 - 通用页面布局组件,详细介绍Vue插槽的使用方法及最佳实践。

插槽基础知识

什么是插槽?

插槽是Vue提供的一种内容分发API,用于将父组件的内容传递到子组件的指定位置。简单来说,插槽允许我们:

  • 在子组件中预留内容位置
  • 在父组件中决定这些位置的具体内容

插槽类型

Vue提供了三种类型的插槽:

  1. 默认插槽:没有名字的插槽,一个组件只能有一个默认插槽
  2. 具名插槽:带有名字的插槽,可以有多个,通过 name 属性区分
  3. 作用域插槽:可以访问子组件数据的插槽

基本语法

子组件中定义插槽:

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div>
<header>
<slot name="header">默认标题</slot>
</header>
<main>
<slot>默认内容</slot>
</main>
<footer>
<slot name="footer">默认底部</slot>
</footer>
</div>
</template>

父组件使用插槽:

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<my-component>
<template #header>
<h1>自定义标题</h1>
</template>

<p>自定义主体内容</p>

<template #footer>
<p>自定义底部</p>
</template>
</my-component>
</template>

实战案例:通用页面布局组件

下面我们通过一个实际案例来展示插槽的强大功能 - 创建一个通用的页面布局组件PageLayout,包含筛选区域和内容区域。

一、PageLayout 组件设计

该组件具有以下特点:

  1. 统一的页面结构和样式
  2. 可配置的筛选区域
  3. 内置的搜索和重置按钮
  4. 内置的分页功能
  5. 通过插槽灵活定制内容

PageLayout组件代码 (src/components/PageLayout/index.vue)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<template>
<div class="page-container">
<!-- 筛选区域 -->
<el-card class="filter-container" v-if="$slots.filter">
<el-form :inline="true" :model="filterModel" class="form-inline">
<slot name="filter"></slot>
<el-form-item class="filter-buttons">
<el-button type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 内容区域 -->
<el-card class="content-container">
<slot></slot>

<!-- 分页区域 -->
<div class="pagination-container" v-if="showPagination">
<pagination
v-show="total > 0"
:total="total"
:page.sync="page"
:limit.sync="limit"
@pagination="handlePagination"
/>
</div>
</el-card>
</div>
</template>

<script>
import Pagination from '@/components/Pagination'

/**
* 页面布局组件
* @component
*/
export default {
name: 'PageLayout',
components: {
Pagination
},
props: {
/**
* 筛选表单数据对象
*/
filterModel: {
type: Object,
default: () => ({})
},
/**
* 是否显示分页
*/
showPagination: {
type: Boolean,
default: true
},
/**
* 数据总数
*/
total: {
type: Number,
default: 0
},
/**
* 当前页码
*/
page: {
type: Number,
default: 1
},
/**
* 每页显示数量
*/
limit: {
type: Number,
default: 20
}
},
methods: {
/**
* 搜索事件处理
*/
handleSearch() {
this.$emit('search')
},
/**
* 重置事件处理
*/
handleReset() {
this.$emit('reset')
},
/**
* 分页事件处理
* @param {Object} pagination 分页信息
*/
handlePagination(pagination) {
this.$emit('pagination', pagination)
}
}
}
</script>

<style scoped>
.page-container {
padding: 20px;
min-height: calc(100vh - 84px);
}

.filter-container {
margin-bottom: 20px;
}

.content-container {
margin-bottom: 20px;
}

.el-card {
border-radius: 4px;
}

.form-inline {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
padding: 10px;
}

.filter-buttons {
margin-left: auto;
}

.pagination-container {
margin-top: 15px;
display: flex;
justify-content: center;
}
</style>

二、使用PageLayout组件

下面是一个具体的应用示例,我们创建一个数据报表页面,使用PageLayout组件:

报表页面代码 (src/views/report/codeData.vue)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
<template>
<page-layout
:filter-model="listQuery"
:total="total"
:page.sync="listQuery.page"
:limit.sync="listQuery.limit"
@search="handleFilter"
@reset="resetQuery"
@pagination="getList"
>
<!-- 筛选区域 -->
<template #filter>
<el-form-item label="应用:">
<el-select
v-model="listQuery.appId"
placeholder="请选择应用"
clearable
style="width: 200px"
@change="handleFilter"
>
<el-option
v-for="item in appOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="广告位类型:">
<el-select
v-model="listQuery.adType"
placeholder="请选择广告位类型"
clearable
style="width: 200px"
@change="handleFilter"
>
<el-option
v-for="item in adTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="日期:">
<el-date-picker
v-model="listQuery.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
:picker-options="pickerOptions"
style="width: 350px"
@change="handleFilter"
/>
</el-form-item>
</template>

<!-- 表格区域(默认插槽) -->
<el-table
v-loading="listLoading"
:data="list"
element-loading-text="加载中..."
border
fit
stripe
highlight-current-row
style="width: 100%"
>
<el-table-column label="日期" align="center" width="180">
<template slot-scope="scope">
<span>{{ scope.row.date }}</span>
</template>
</el-table-column>
<el-table-column label="应用" align="center" width="180">
<template slot-scope="scope">
<span>{{ scope.row.appName }}</span>
</template>
</el-table-column>
<el-table-column label="广告位类型" align="center" width="180">
<template slot-scope="scope">
<span>{{ scope.row.adTypeName }}</span>
</template>
</el-table-column>
<el-table-column label="展示量" align="center">
<template slot-scope="scope">
<span>{{ scope.row.showCount }}</span>
</template>
</el-table-column>
<el-table-column label="点击量" align="center">
<template slot-scope="scope">
<span>{{ scope.row.clickCount }}</span>
</template>
</el-table-column>
<el-table-column label="点击率" align="center">
<template slot-scope="scope">
<span>{{ (scope.row.ctr * 100).toFixed(2) }}%</span>
</template>
</el-table-column>
<el-table-column label="收入(元)" align="center">
<template slot-scope="scope">
<span>{{ scope.row.revenue.toFixed(2) }}</span>
</template>
</el-table-column>
</el-table>
</page-layout>
</template>

<script>
import { getCodeReportData } from '@/api/report'
import PageLayout from '@/components/PageLayout'
import dayjs from 'dayjs'

/**
* 代码数据报表组件
* @component
*/
export default {
name: 'ReportCodeData',
components: {
PageLayout
},
data() {
return {
list: [],
total: 0,
listLoading: true,
// 搜索条件
listQuery: {
page: 1,
limit: 20,
appId: undefined,
adType: undefined,
dateRange: [dayjs().format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')] // 默认今天
},
// 日期选择器配置
pickerOptions: {
shortcuts: [
{
text: '今天',
onClick(picker) {
const today = new Date()
picker.$emit('pick', [today, today])
}
},
{
text: '昨天',
onClick(picker) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
picker.$emit('pick', [yesterday, yesterday])
}
},
{
text: '最近一周',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 7)
picker.$emit('pick', [start, end])
}
},
{
text: '最近一个月',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 1)
picker.$emit('pick', [start, end])
}
}
]
},
// 应用选项
appOptions: [
{ value: '1', label: '应用A' },
{ value: '2', label: '应用B' },
{ value: '3', label: '应用C' }
],
// 广告位类型选项
adTypeOptions: [
{ value: '1', label: '横幅广告' },
{ value: '2', label: '插屏广告' },
{ value: '3', label: '开屏广告' },
{ value: '4', label: '信息流广告' }
]
}
},
created() {
this.getList()
},
methods: {
/**
* 获取数据列表
*/
getList() {
this.listLoading = true
// 处理查询参数
const params = {
page: this.listQuery.page,
limit: this.listQuery.limit,
appId: this.listQuery.appId,
adType: this.listQuery.adType,
startDate: this.listQuery.dateRange ? this.listQuery.dateRange[0] : undefined,
endDate: this.listQuery.dateRange ? this.listQuery.dateRange[1] : undefined
}

getCodeReportData(params).then(response => {
this.list = response.data.items
this.total = response.data.total
this.listLoading = false
}).catch(() => {
this.listLoading = false
})
},
/**
* 处理搜索事件
*/
handleFilter() {
this.listQuery.page = 1
this.getList()
},
/**
* 重置搜索条件
*/
resetQuery() {
this.listQuery = {
page: 1,
limit: 20,
appId: undefined,
adType: undefined,
dateRange: [dayjs().format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')] // 重置为今天
}
this.getList()
}
}
}
</script>

高级应用

条件性渲染插槽

PageLayout 组件中,我们使用了 v-if="$slots.filter" 来条件性渲染筛选区域。这意味着当父组件没有提供筛选内容时,整个筛选区域将不会渲染。

1
2
3
<el-card class="filter-container" v-if="$slots.filter">
<!-- ... -->
</el-card>

组合使用Props和事件

在我们的例子中,PageLayout 组件不仅接收插槽内容,还接收多个 props 并触发相应的事件:

  • 接收数据:通过 props 接收筛选条件、分页信息等数据
  • 发送事件:通过 $emit 触发搜索、重置、分页等事件

这样的设计可以让布局组件与业务组件进行有效的通信,同时保持关注点分离。

最佳实践

通过以上实例,我们可以总结出以下Vue插槽的最佳实践:

1. 提供合理的默认内容

为插槽提供默认内容,确保当父组件没有提供相应内容时,组件仍能正常工作。

1
<slot name="header">默认标题</slot>

2. 使用命名插槽提高可读性

当组件有多个插槽时,使用命名插槽可以明确每个插槽的用途,提高代码可读性。

1
2
3
4
5
<!-- 子组件中 -->
<slot name="filter"></slot>

<!-- 父组件中 -->
<template #filter>...</template>

3. 结合事件系统进行组件通信

插槽主要负责内容分发,而事件系统则负责组件间的通信。合理结合二者可以构建更灵活的组件。

1
2
3
4
5
<!-- 子组件中 -->
<el-button @click="$emit('search')">搜索</el-button>

<!-- 父组件中 -->
<my-component @search="handleSearch">...</my-component>

4. 使用 $slots 检测插槽内容

可以通过 $slots 对象检测是否提供了特定插槽的内容,实现条件性渲染。

1
2
3
<div v-if="$slots.header" class="header">
<slot name="header"></slot>
</div>

5. 将通用样式和布局封装在组件中

像我们的 PageLayout 组件一样,将通用的样式和布局封装在组件中,让使用者只需关注具体内容,大大提高开发效率。

总结

Vue插槽是一种强大的内容分发机制,通过合理使用插槽,我们可以:

  1. 创建高度可复用的组件
  2. 实现灵活的内容定制
  3. 保持关注点分离
  4. 提高开发效率
  5. 保持代码一致性

通过本文的实战案例,我们展示了如何使用插槽创建一个通用的页面布局组件,并在实际报表页面中应用。希望这些内容能帮助你更好地理解和使用Vue插槽,构建出更优雅、可维护的Vue应用。