主题
自定义指令(二)
本页对应 自定义指令,共 6 段视频,约 1 小时 8 分。
学习目标
- 能够使用表格组件完成商品列表的动态渲染,正确绑定
list数据,并配置标题、作者、点赞、浏览量、更新日期、操作等列及列宽。 - 能够使用默认插槽和作用域插槽自定义表格最后一列,接收
row数据并获取当前行ID,为查看、编辑、删除操作绑定事件。 - 能够配置分页组件的完整功能,绑定当前页、每页条数、总条数和布局,并实现页码或每页条数变化时重新请求列表数据。
- 能够使用抽屉组件控制显示与隐藏,配置标题、打开方向、尺寸及关闭前回调,实现添加、预览、编辑共用同一个抽屉。
- 能够通过传参区分添加、预览、编辑操作类型,并使用计算属性动态生成对应的抽屉标题。
- 能够说明具名插槽、作用域插槽及
v-model在 MyTag、MyTable 组件封装中的作用,并完成组件抽离、显示隐藏控制与数据动态渲染。
知识点
从默认插槽的局限说起
为什么需要: 一个组件内部可能有多处结构都要由外部传入标签来定制。笔记里的弹框就是典型:弹框中有三处不同位置都需要外部决定内容,但默认插槽只能定制一个位置,无法同时照顾多处。

怎么想: 既然一个默认插槽不够用,就要给每个可定制位置起一个名字,让父组件能明确说明“这段内容放到哪个位置”。这就是具名插槽的思路:先命名,再按名字分发。
怎么做: 子组件里写多个 <slot>,用 name 区分;父组件用 <template v-slot:名字> 把内容分发到对应插槽。默认插槽可以保留为没有 name 的那个。
html
<!-- 子组件内部:多个插槽用 name 区分 -->
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>易错点: 不要把多个插槽都写成没有 name 的默认插槽;父组件的 v-slot:名字 必须和子组件 slot name="名字" 完全一致,拼写不一致时内容不会落到预期位置。
具名插槽的语法:先命名,再分发
为什么需要: 默认插槽只能接收一份内容,遇到弹框头部、内容区、底部按钮区都要定制时,必须让插槽有名字,才能分别接收不同标签。

怎么想: 把组件内部“不确定的结构”列出来,每一处定义一个 name;父组件写内容时,不是直接塞进去,而是用 template 包住,并标明 v-slot:对应名字。

怎么做: 子组件定义具名插槽;父组件用 template 配合 v-slot:名字 分发。没有写 name 的插槽就是默认插槽,父组件中不写 v-slot 的内容会进入默认插槽。
html
<!-- 父组件:按名字分发 -->
<my-dialog>
<template v-slot:header>
<h3>标题区</h3>
</template>
<p>默认内容区</p>
<template v-slot:footer>
<button>确定</button>
</template>
</my-dialog>易错点: v-slot 一般写在 <template> 上或组件标签上,不要随便写在普通 div 上;父组件里多个 template 的顺序不等于插槽顺序,真正决定位置的是插槽名。默认插槽也可以显式写成 v-slot:default,但普通内容直接写也能进入默认插槽。
v-slot 的简写与调试过程
为什么需要: v-slot:header 写起来较长,多处使用时不够简洁,Vue 提供了 # 简写。
怎么想: 简写只是把 v-slot: 换成 #,后面的插槽名不变。调试时不要一上来就写复杂结构,可以先放文字占位,确认每个命名插槽到底落在哪里,再替换成真实标签。
怎么做:
html
<template #header>
<h3>标题区</h3>
</template>
<template #footer>
<button>确定</button>
</template>转写稿里处理表格最后一列时,也是先给插槽传一个“删除”文字占位,确认位置生效后再换成图标;当不知道 Element UI 给插槽传什么时,先写方法把参数打印出来,看到 row 后再解构 row.id。这个“先占位、再确认、后替换”的调试过程,同样适用于具名插槽:先在每个命名插槽里放不同文字,确认三处分别落到哪里,再换成真实结构。
易错点: 简写时 # 和插槽名要写清楚,如 #header;不要把 v-slot:header 简写成不存在的写法。名字仍要和子组件 slot name 对应。若发现内容没出现,先检查插槽名,再检查 template 是否写在了正确父级里。
回到需求:三处定制如何落地
为什么需要: 弹框三处不同,如果只用默认插槽,只能定制一个位置,另外两处无法由外部决定。
怎么想: 按弹框结构把需要定制的位置分别命名,例如头部、内容区、底部;实际名称以组件设计为准。父组件用多个 template 按名分发,默认内容放默认插槽。
怎么做: 子组件保留默认插槽并增加具名插槽;父组件用 v-slot:名字 或 #名字 分发。这样同一个组件内多处结构都能被外部定制。
易错点: 不要把所有内容都塞进默认插槽;具名插槽的内容要用 template 包住并写对名字;默认插槽和具名插槽混用时,先确认哪一段进入默认插槽,避免结构错位。
小结
组件内有多处不确定结构时,用具名插槽:多个 <slot> 用 name 区分。语法是子组件写 slot name="名字",父组件用 template v-slot:名字 分发。v-slot:插槽名 可以简写成 #插槽名。调试时先放文字占位确认位置,再替换真实标签,能减少插槽名写错、内容放错位置的问题。
作用域插槽的定位:不是第三种插槽
为什么需要:默认插槽解决“内容放哪”,具名插槽解决“多个位置分别放哪”,但它们都不解决“子组件把当前数据交给父组件写的插槽内容”。封装表格时尤其明显:表格结构在子组件里,操作按钮由使用方决定,而删除按钮又必须拿到当前行 id。
怎么想:插槽内容写在父组件,数据却在子组件。要让父组件拿到子组件数据,就让子组件在 <slot> 上以属性方式“传值”。所以笔记强调:插槽只有默认插槽、具名插槽两种,作用域插槽不属于插槽分类,它是“给插槽绑定数据”的能力。
怎么做:在 slot 标签上添加属性:
vue
<slot :id="item.id" msg="测试文本"></slot>易错点:不要把作用域插槽说成第三种插槽;不要只写按钮却忘了把当前行 id 传出去。
场景:封装表格组件
为什么需要:表格组件若把“操作”列写死,使用方就无法放查看、编辑、删除等不同按钮。删除还必须知道删哪一行,所以需要“插槽内容由父组件写,行数据由子组件给”。

怎么想:子组件只负责渲染表格和提供当前行数据;父组件通过插槽决定操作列长什么样。子组件把 item.id、msg 等属性挂到 slot 上,Vue 会把它们收集成一个对象,例如:
vue
{ id: 3, msg: '测试文本' }怎么做:父组件在 template 上用 #插槽名="obj" 接收,默认插槽名为 default:
vue
<MyTable :list="list">
<template #default="obj">
<button @click="del(obj.id)">删除</button>
</template>
</MyTable>易错点:默认插槽接收名是 default;obj 是接收对象,里面才有 id、msg,不能直接写 obj 当 id 用。
使用步骤:绑定、收集、接收
为什么需要:明确“谁传、传成什么、谁接”。子组件在 slot 上绑定属性;Vue 收集为对象;父组件通过插槽作用域变量接收。
怎么想:先确定父组件需要什么数据,再在子组件 slot 上暴露什么属性。比如删除需要 id,就绑定 :id="item.id"。
怎么做:结合笔记代码,子组件模板中关键位置如下:
vue
<slot :id="item.id" msg="测试文本"></slot>父组件:
vue
<template #default="obj">
<button @click="del(obj.id)">删除</button>
</template>易错点:slot 上的属性名要和接收对象里的属性名对应;父组件插槽内容必须写在组件标签内部,不能写到组件标签外面。
代码示例:MyTable 与 App
为什么需要:用完整示例看清“子组件暴露数据、父组件消费数据”的关系。笔记中的 MyTable.vue 负责表格结构,App.vue 传入数据并使用插槽。
怎么做:保留笔记中的核心代码结构,样式部分按原笔记保持不变。
vue
<!-- MyTable.vue -->
<template>
<table class="my-table">
<thead>
<tr>
<th>序号</th><th>姓名</th><th>年纪</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>赵小云</td>
<td>19</td>
<td>
<slot :id="item.id" msg="测试文本"></slot>
</td>
</tr>
<!-- 其余行结构同理,按笔记原样重复 -->
</tbody>
</table>
</template>
<script>
export default {
props: {
data: Array
}
}
</script>
<!-- 样式同笔记原样,略 -->vue
<!-- App.vue -->
<template>
<div>
<MyTable :data="list"></MyTable>
<MyTable :data="list2"></MyTable>
</div>
</template>
<script>
import MyTable from './components/MyTable.vue'
export default {
data () {
return {
list: [
{ id: 1, name: '张小花', age: 18 },
{ id: 2, name: '孙大明', age: 19 },
{ id: 3, name: '刘德忠', age: 17 },
],
list2: [
{ id: 1, name: '赵小云', age: 18 },
{ id: 2, name: '刘蓓蓓', age: 19 },
{ id: 3, name: '姜肖泰', age: 17 },
]
}
},
components: { MyTable }
}
</script>易错点:笔记示例中 slot 的 id 需要来自当前行数据;实际封装时要保证 slot 在循环行内,能拿到对应 item。
调试推导:不知道插槽传出什么就先打印
为什么需要:真实项目里常封装第三方表格组件,父组件并不确定默认插槽传出的对象结构。转写稿中的调试思路是:先接收,再打印,确认结构后再取值。
怎么想:不知道 obj 长什么样,就写一个方法把它输出。转写稿中打印后发现对象里有 row,row 表示当前行数据,column 表示列;要删除就取 row.id。
怎么做:可先写:
vue
<template #default="obj">
<button @click="del(obj)">删除</button>
</template>在方法中打印 obj,确认结构后再改为解构 row,用 row.id。删除方法接收 id,点击不同行就能拿到对应行 id。
易错点:不要凭感觉猜第三方组件传出的字段;先打印确认。转写稿里的“肉”是 row,“colum”是 column,不是数据字段名乱写。作用域插槽的关键就是:子组件把数据绑到 slot 上,父组件通过插槽作用域接收并使用。
需求拆解:先明确 MyTag 要解决什么

为什么需要:商品表格的“标签”列里,每一行都有相同的展示结构和交互需求。如果每行都复制一份 .my-tag,后续改样式、改交互会重复改很多处。抽成 MyTag 组件,是为了让表格只负责渲染数据,标签的显示与编辑交给组件。
怎么想:按笔记需求,MyTag 要覆盖四件事:双击显示输入框并聚焦、失焦隐藏输入框、回显标签信息、输入后回车修改标签信息。也就是说组件内部至少有“展示态”和“编辑态”两种状态;父组件传入标签值,组件在提交时把新值通知父组件。
怎么做:先不要急着写交互,按笔记先创建 MyTag.vue 骨架,把原来表格单元格里的 .my-tag 结构和样式搬进去;再在父组件中引入、注册、替换原来的单元格内容。这样先保证“抽离后页面不变”,再补交互。
易错点:不要一开始把“茶具”“男靴”写死在组件里;input 和展示文本应互斥显示;样式要跟着组件走,尤其是 scoped 下父组件样式不一定作用到子组件内部;回车提交和失焦隐藏要考虑是否会重复触发。
原始表格代码准备:先保留可运行基线
为什么需要:抽组件前要先有一个能渲染的表格基线。否则一旦页面报错,分不清是原表格问题还是组件抽离问题。
怎么想:笔记里的 TableCase 已经给出完整表格结构、goods 数据和样式。转写稿里同一综合案例反复强调“先用调试工具确认数据有没有拿到、变量名有没有对上”,这个调试习惯同样适用于本节:先确认 goods 能正常渲染,再动结构。
怎么做:保留下面的原始代码作为基线。注意笔记中标签列还是普通 div,input 处于注释状态,说明后续才进入编辑态。
vue
<template>
<div class="table-case">
<table class="my-table">
<thead>
<tr>
<th>编号</th>
<th>名称</th>
<th>图片</th>
<th width="100px">标签</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>梨皮朱泥三绝清代小品壶经典款紫砂壶</td>
<td>
<img src="https://yanxuan-item.nosdn.127.net/f8c37ffa41ab1eb84bff499e1f6acfc7.jpg" />
</td>
<td>
<div class="my-tag">
<!-- <input
class="input"
type="text"
placeholder="输入标签"
/> -->
<div class="text">
茶具
</div>
</div>
</td>
</tr>
<tr>
<td>1</td>
<td>梨皮朱泥三绝清代小品壶经典款紫砂壶</td>
<td>
<img src="https://yanxuan-item.nosdn.127.net/221317c85274a188174352474b859d7b.jpg" />
</td>
<td>
<div class="my-tag">
<!-- <input
ref="inp"
class="input"
type="text"
placeholder="输入标签"
/> -->
<div class="text">
男靴
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
export default {
name: 'TableCase',
components: {},
data() {
return {
goods: [
{
id: 101,
picture:
'https://yanxuan-item.nosdn.127.net/f8c37ffa41ab1eb84bff499e1f6acfc7.jpg',
name: '梨皮朱泥三绝清代小品壶经典款紫砂壶',
tag: '茶具',
},
{
id: 102,
picture:
'https://yanxuan-item.nosdn.127.net/221317c85274a188174352474b859d7b.jpg',
name: '全防水HABU旋钮牛皮户外徒步鞋山宁泰抗菌',
tag: '男鞋',
},
{
id: 103,
picture:
'https://yanxuan-item.nosdn.127.net/cd4b840751ef4f7505c85004f0bebcb5.png',
name: '毛茸茸小熊出没,儿童羊羔绒背心73-90cm',
tag: '儿童服饰',
},
{
id: 104,
picture:
'https://yanxuan-item.nosdn.127.net/56eb25a38d7a630e76a608a9360eec6b.jpg',
name: '基础百搭,儿童套头针织毛衣1-9岁',
tag: '儿童服饰',
},
],
}
},
}
</script>
<style lang="less" scoped>
.table-case {
width: 1000px;
margin: 50px auto;
img {
width: 100px;
height: 100px;
object-fit: contain;
vertical-align: middle;
}
.my-table {
width: 100%;
border-spacing: 0;
img {
width: 100px;
height: 100px;
object-fit: contain;
vertical-align: middle;
}
th {
background: #f5f5f5;
border-bottom: 2px solid #069;
}
td {
border-bottom: 1px dashed #ccc;
}
td,
th {
text-align: center;
padding: 10px;
transition: all 0.5s;
&.red {
color: red;
}
}
.none {
height: 100px;
line-height: 100px;
color: #999;
}
}
.my-tag {
cursor: pointer;
.input {
appearance: none;
outline: none;
border: 1px solid #ccc;
width: 100px;
height: 40px;
box-sizing: border-box;
padding: 10px;
color: #666;
&::placeholder {
color: #666;
}
}
}
}
</style>易错点:表格里的图片地址、goods 字段名不要抄错;scoped 样式只作用于当前组件,抽离后 .my-tag 相关样式要迁到 MyTag.vue,否则可能失效。
抽离 MyTag 组件:结构、样式一起搬
为什么需要:标签列是重复结构,抽成组件后,父组件只传标签值、接收修改事件,职责更清晰。
怎么想:先把“展示结构”搬走,不改变页面效果。笔记中的 MyTag.vue 保留 .my-tag、input 注释和 .text 展示区,并迁移 .my-tag 样式。
怎么做:创建 components/MyTag.vue:
vue
<template>
<div class="my-tag">
<!-- <input
class="input"
type="text"
placeholder="输入标签"
/> -->
<div
class="text">
茶具
</div>
</div>
</template>
<script>
export default {
}
</script>
<style lang="less" scoped>
.my-tag {
cursor: pointer;
.input {
appearance: none;
outline: none;
border: 1px solid #ccc;
width: 100px;
height: 40px;
box-sizing: border-box;
padding: 10px;
color: #666;
&::placeholder {
color: #666;
}
}
}
</style>易错点:此时组件里的“茶具”只是骨架占位,后续必须改为由父组件传入并回显;不要忘记把 cursor: pointer 等样式一起迁移。
父组件接入:引入、注册、替换
为什么需要:组件创建后不会自动可用,必须在父组件中引入并注册。
怎么想:把原来 <td> 里的 .my-tag 整块替换成 <MyTag></MyTag>,并补 import 和 components。
怎么做:按笔记修改 App.vue:
vue
<template>
...
<tbody>
<tr>
....
<td>
<MyTag></MyTag>
</td>
</tr>
</tbody>
...
</template>
<script>
import MyTag from './components/MyTag.vue'
export default {
name: 'TableCase',
components: {
MyTag,
},
....
</script>易错点:组件名大小写、路径要一致;忘记 components 注册会在模板中不识别;替换后检查表格宽度和单元格对齐是否仍正常。
调试与后续交互提示
为什么需要:抽离只是第一步,真正难点是双击、失焦、回车提交。转写稿里多次出现“不知道组件传出来什么就写方法打印测试”“事件后面必须有对应方法”的调试思路,这里同样适用。
怎么想:先在 MyTag 内部用状态控制 input 和 .text 的显示;双击进入编辑并让输入框聚焦;失焦退出;回车提交新值。父组件通过属性传入原标签,通过自定义事件接收修改结果。
怎么做:本节笔记只给到抽离骨架,交互代码未在笔记中展开,不能凭空补完整实现。按需求在 MyTag 内补状态、ref 聚焦和事件即可;提交后让父组件更新对应 goods 项的 tag,实现回显。
易错点:回车提交后失焦可能再次触发隐藏,注意避免重复提交;输入框值要从传入标签初始化;不要直接把 goods 写进子组件。转写稿中“作用异差小”应理解为“作用域插槽”,“艾Y”指 Element UI,这类同音字在整理笔记时要按真实术语纠正。
本节目标:用 isEdit 控制两种显示状态
这个案例要做的是一个“标签”小组件:默认显示文字“茶具”,双击文字后变成输入框,输入框失焦后又回到文字状态。核心不是样式,而是用布尔状态控制显示隐藏。
为什么需要:标签既要能展示,又要能编辑。如果一直放一个 input,界面不像标签;如果只放文字,又没法编辑。所以需要两种形态切换。
怎么想:准备一个 isEdit,false 表示展示文字,true 表示展示输入框。模板里用 v-if / v-else 二选一。转写稿里反复提醒“调试工具今天一定要用起来”,这里同样适用:先打开 Vue Devtools 看 isEdit 有没有从 false 变 true,再判断模板有没有切换。
怎么做:先保留笔记中的完整组件代码:
vue
<template>
<div class="my-tag">
<input
v-if="isEdit"
v-focus
ref="inp"
class="input"
type="text"
placeholder="输入标签"
@blur="isEdit = false"
/>
<div
v-else
@dblclick="handleClick"
class="text">
茶具
</div>
</div>
</template>
<script>
export default {
data () {
return {
isEdit: false
}
},
methods: {
handleClick () {
this.isEdit = true
}
}
}
</script>易错点:v-else 必须紧跟 v-if,中间不要插别的元素;isEdit 初始值应为 false,否则一进页面就是输入框;ref="inp" 在当前代码里没有使用,可以删掉,不影响功能。
双击进入编辑、失焦退出编辑
为什么需要:如果单击就进入编辑,用户正常点击标签时容易误触;双击更符合“进入编辑”的交互习惯。退出编辑用失焦,用户点页面空白处就自然结束。
怎么想:双击文字时把 isEdit 改成 true;输入框失焦时把 isEdit 改回 false。这样状态驱动视图,不需要手动操作 DOM 的显示隐藏。
怎么做:文字 div 上绑定 @dblclick="handleClick",方法里 this.isEdit = true;input 上绑定 @blur="isEdit = false"。调试时可以在 handleClick 里先 console.log(this.isEdit),双击后看控制台和 Devtools 是否变成 true;再点空白处,看是否变回 false。
易错点:不要写成 @click,否则单击就进编辑;blur 是失去焦点时触发,不是值改变时触发;如果以后要保存输入内容,应该在 blur 时一并处理,而不是只改 isEdit。
封装全局指令 v-focus 让输入框自动聚焦
为什么需要:输入框是 v-if 动态插入的,刚出现时不会自动获得焦点。用户双击后还要再点一下输入框,体验不好。
怎么想:需要在 input 被插入到页面后调用 el.focus()。Vue 自定义指令的 inserted 钩子正好在元素插入页面时触发,所以封装一个 v-focus 全局指令最合适。转写稿里把“封装”说成“公装/工装”,这里按“封装全局指令”理解。
怎么做:在 main.js 中注册全局指令:
js
// 封装全局指令 focus
Vue.directive('focus', {
// 指令所在的dom元素,被插入到页面中时触发
inserted (el) {
el.focus()
}
})模板中直接写 v-focus 即可。这样任何组件里的输入框只要加了 v-focus,插入后都会自动聚焦。
易错点:Vue.directive 要写在 new Vue(...) 之前,否则组件解析时指令还没注册;inserted 不要写成 bind,bind 时元素还不一定插入页面;v-focus 后面不要加冒号或等号;如果指令没生效,先检查 main.js 是否引入、注册顺序是否正确。
调试与验证:先让状态可见
转写稿里讲表格时反复强调“先输出看看、看到就真实”,这个思路可以迁移到这里:不要只凭页面猜,先让 isEdit 可见。打开 Vue Devtools,选中 MyTag 组件,观察 isEdit;双击“茶具”,它应从 false 变 true,页面出现 input;点击空白处,它应从 true 变 false,input 消失并回到文字。若 input 没自动聚焦,就在 inserted 里打印 el,确认拿到的是 DOM 元素。易错点:blur 后元素马上被 v-if 移除,控制台日志要写在改状态之前;全局指令注册后要刷新页面再测。
先明确本节目标:让 MyTag 像表单控件一样使用 v-model
父组件里写 <MyTag v-model="tempText"></MyTag>,本质是希望“子组件展示父组件的数据,子组件修改后再同步回父组件”。Vue2 中 v-model 是语法糖,等价于:
vue
<MyTag :value="tempText" @input="tempText = $event"></MyTag>所以子组件必须做两件事:用 props 接收 value,修改时用 $emit('input', 新值) 通知父组件。为什么不能直接改 this.value?因为 props 是父组件的数据,子组件直接改会破坏单向数据流,也会让数据来源不清晰。易错点是:父组件 data 必须 return 对象。笔记里 data(){ tempText:'水杯' } 写法不完整,应改为:
vue
<script>
export default {
data () {
return {
tempText: '水杯'
}
}
}
</script>子组件声明 value,并在回车时回传 input
MyTag 中先声明:
js
props: {
value: String
}模板中展示态用 {{ value }},编辑态输入框用 :value="value"。为什么输入框不写 v-model="value"?因为不能直接改 prop。正确思路是:输入框自己先收集用户输入,等用户按回车确认后,再通过 this.$emit('input', e.target.value) 把新值交给父组件。父组件收到 input 事件后,tempText 更新,子组件的 value 也随之更新。
handleEnter 里先做非空处理:
js
if (e.target.value.trim() === '') return alert('标签内容不能为空')
this.$emit('input', e.target.value)
this.isEdit = false易错点:判断用了 trim(),但提交的是 e.target.value 原值;如果业务要求去掉首尾空格,应提交 e.target.value.trim()。另外事件名必须是 input,因为父组件用的是 v-model。
双击进入编辑、回车提交、失焦退出
展示态和编辑态由 isEdit 控制:
vue
<input v-if="isEdit" ... />
<div v-else @dblclick="handleClick" class="text">
{{ value }}
</div>handleClick 把 isEdit 设为 true,输入框出现;handleEnter 提交成功后设回 false;@blur="isEdit = false" 让输入框失焦时退出编辑。为什么需要失焦退出?因为用户可能点别处放弃编辑。易错点:回车提交后输入框会失焦,blur 和 enter 可能都触发;当前代码因为先 emit 再 isEdit=false,通常能关闭,但空值时 return 不会关闭,随后失焦仍会关闭,可能让“空值提示”后编辑态消失。若要求空值必须继续编辑,应在 blur 里也做非空判断,而不是简单关闭。
v-focus 与 ref 的注意点
笔记里写了 v-focus 和 ref="inp",目的是进入编辑后自动聚焦。为什么需要?双击后如果还要再点输入框,体验差。怎么做?可以注册自定义指令 v-focus,也可以在 handleClick 中:
js
this.isEdit = true
this.$nextTick(() => {
this.$refs.inp.focus()
})易错点:v-focus 不是 Vue 内置指令,未注册会报错;v-if 切换后 DOM 不是立刻存在,要用 $nextTick;当前代码 ref="inp" 没有实际使用,属于预留。
完整代码与调试检查
保留本节 MyTag 代码:
vue
<template>
<div class="my-tag">
<input
v-if="isEdit"
v-focus
ref="inp"
class="input"
type="text"
placeholder="输入标签"
:value="value"
@blur="isEdit = false"
@keyup.enter="handleEnter"
/>
<div
v-else
@dblclick="handleClick"
class="text">
{{ value }}
</div>
</div>
</template>
<script>
export default {
props: {
value: String
},
data () {
return {
isEdit: false
}
},
methods: {
handleClick () {
this.isEdit = true
},
handleEnter (e) {
// 非空处理
if (e.target.value.trim() === '') return alert('标签内容不能为空')
this.$emit('input', e.target.value)
// 提交完成,关闭输入状态
this.isEdit = false
}
}
}
</script>调试时按顺序检查:父组件 tempText 是否初始为“水杯”;双击是否出现输入框;输入新内容回车后父组件值是否变化;输入空值是否弹提示;失焦是否退出编辑。转写稿主要记录表格、分页、抽屉等案例,其中“公装API、托作、历史、赵太”等是“公共 API、total、list、state”一类同音转写,但不属于本节 MyTag 内容,因此不混入本节代码。
先想清楚:为什么要封装 MyTable
表格页面里,thead 的结构、边框、图片尺寸这些样式,往往每个列表页都差不多;真正变化的只是“这一页显示哪些行、每行字段是什么”。如果每个页面都把 <table> 从头写一遍,结构重复、样式难统一,改一个边框要改很多文件。
所以思路是:把表格的“壳”封装成 MyTable,表头固定,表体由父组件传进来的数据动态渲染。父组件只关心“传什么数据”,子组件负责“怎么把数据铺成表格”。
做法就是笔记里的两个文件:App.vue 里写 <MyTable :data="goods"></MyTable>,MyTable.vue 通过 props 接收 data,并且 type: Array、required: true。这样父组件不传数据,控制台会直接警告,比表格悄悄空白要好排查。
易错点:props 的名字必须和父组件绑定的属性名一致。转写稿里就出现过类似问题:页面报“tableData 没找到”,原因是变量已经换成了 list,模板里还在用旧名字。遇到表格空白,先看控制台报错,再回到父组件确认数据变量名和 :data 后面写的是不是同一个。
数据先确认拿到,再谈渲染
转写稿里反复强调“调试工具今天一定要用起来”:调完接口后,先把数据存到 list 和 total,然后打开 Vue Devtools,看 list 里有没有数据、total 有没有变。确认数据真的拿到了,再去改模板。
本节案例同理:App.vue 的 data 里要先准备好 goods,数组里每项至少要有 id、name、picture、tag 这些字段。不要一边接口没通、一边调样式,最后分不清是数据问题还是模板问题。
易错点:required: true 只保证“传了”,不保证“传的是有内容的数组”。父组件传 null 或空数组时,表格可能不报错但一片空白,所以先看数据源。
表头写死,表体用 v-for 动态渲染
表头是固定的四列:编号、名称、图片、标签,直接写在 thead 里。表体行数不固定,所以 v-for 要写在 <tr> 上:
vue
<tr v-for="(item, index) in data" :key="item.id">这里 index 用来显示序号,{{ index + 1 }} 从 1 开始;不要用 item.id 当序号,因为 id 可能不连续。key 优先用 item.id,比用 index 稳定,后续删除、排序时不容易出现复用错行。
每一列再绑定当前行的字段:{{ item.name }}、:src="item.picture"。图片这里必须用 : 动态绑定,不能写 src="{{ item.picture }}",Vue 的属性插值不成立。
易错点:v-for 写在 <tr> 上,不是写在 <tbody> 上;key 不要漏,否则控制台会提示,而且更新时可能出问题。
图片和样式的细节
图片列除了 :src,样式里还写了:
less
img {
width: 100px;
height: 100px;
object-fit: contain;
vertical-align: middle;
}object-fit: contain 是让图片在固定宽高里完整显示、不变形;vertical-align: middle 是让图片和同一行的文字居中对齐。表格用 border-spacing: 0 去掉单元格间隙,th 加背景和下边框,td 加虚线下边框。scoped 保证样式只作用当前组件,不会污染别的表格。
易错点:图片没给宽高时,大图会把表格撑开;scoped 下想改子组件内部样式,普通选择器可能不生效,这是另一个话题,本节先保证表格自身样式写全。
最后一列为什么留插槽
笔记里标签列现在写的是“标签内容”,下面注释了:
vue
<!-- <MyTag v-model="item.tag"></MyTag> -->这说明这一列不是纯文本,而是一个业务组件。如果直接把 MyTag 写死在 MyTable 里,MyTable 就和“标签业务”绑死了;以后操作列要放删除、编辑按钮,又得改组件。更好的做法是:MyTable 只负责画出单元格,单元格里放什么,交给使用方决定,也就是用插槽。
转写稿视频2讲操作列时就是这个思路:先不急着放图标,先在组件标签中间传一个“删除”两个字测试,确认插槽位置通了,再把 Element UI 的图标拿过来。图标默认挤在一起,就外面套一个 div,加 flex 类分开。这个“先传简单内容验证插槽,再换真实内容”的过程,比一上来就写复杂结构好调试。
易错点:插槽内容是在父组件作用域里编译的,不能直接写 item.name 去访问 MyTable 内部 v-for 的 item。想用当前行数据,必须用作用域插槽把数据传出来。
作用域插槽:把当前行数据传给父组件
父组件要放 <MyTag v-model="item.tag">,就需要当前行的 item。做法是在 MyTable 的插槽上把 item 传出去,例如 <slot :row="item"></slot>;父组件用 v-slot 或 #default="scope" 接收,再用 scope.row.tag。
转写稿里有一段很实用的调试过程:不知道子组件插槽传出来的是什么,就写一个方法,把插槽参数传给这个方法,在方法里 console.log 打印。打印后发现传出来的是 { row, column, $index } 这样的对象,真正的行数据在 row 里,所以要用 row.id,而不是直接 id。这个“先打印、看结构、再解构”的办法,比猜参数名可靠。
易错点:以为插槽传出来就是 item 本身,直接写 scope.id;实际要先看打印结果,通常要 scope.row.id。解构时写 { row },不要写 { item }。
本节代码回顾
App.vue:
vue
<template>
<div class="table-case">
<MyTable :data="goods"></MyTable>
</div>
</template>
<script>
import MyTable from './components/MyTable.vue'
export default {
name: 'TableCase',
components: {
MyTable
},
data(){
return {
....
}
},
}
</script>MyTable.vue:
vue
<template>
<table class="my-table">
<thead>
<tr>
<th>编号</th>
<th>名称</th>
<th>图片</th>
<th width="100px">标签</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.name }}</td>
<td>
<img
:src="item.picture"
/>
</td>
<td>
标签内容
<!-- <MyTag v-model="item.tag"></MyTag> -->
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
props: {
data: {
type: Array,
required: true
}
}
};
</script>
<style lang="less" scoped>
.my-table {
width: 100%;
border-spacing: 0;
img {
width: 100px;
height: 100px;
object-fit: contain;
vertical-align: middle;
}
th {
background: #f5f5f5;
border-bottom: 2px solid #069;
}
td {
border-bottom: 1px dashed #ccc;
}
td,
th {
text-align: center;
padding: 10px;
transition: all .5s;
&.red {
color: red;
}
}
.none {
height: 100px;
line-height: 100px;
color: #999;
}
}
</style>当前版本已经能把 goods 动态渲染成表格行,标签列还是占位。下一步就是把标签列改成插槽,让父组件决定放 MyTag 还是别的组件;如果插槽内容需要当前行数据,就再补作用域插槽,把 item 传出去。
课件图示








代码示例
示例一:具名插槽 —— 一个弹框定制三处结构
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>具名插槽 - 弹框</title>
<style>
.dialog { width: 420px; margin: 30px auto; border: 1px solid #ccc; border-radius: 6px; overflow: hidden; font-size: 14px; }
.dialog-header { background: #1f74ff; color: #fff; padding: 10px 16px; }
.dialog-main { padding: 16px; min-height: 60px; }
.dialog-footer { padding: 10px 16px; text-align: right; border-top: 1px solid #eee; }
.dialog-footer button { margin-left: 8px; }
</style>
</head>
<body>
<div id="app">
<!-- 使用组件时,用 template + v-slot:名字 分发对应标签 -->
<MyDialog>
<template #header>
<h3>提示</h3>
</template>
<template #main>
<p>确定要删除这条记录吗?</p>
</template>
<template #footer>
<button @click="del">删除</button>
<button>取消</button>
</template>
</MyDialog>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script>
const MyDialog = {
// 组件内用 name 属性区分多个 slot
template: `
<div class="dialog">
<div class="dialog-header">
<slot name="header"></slot>
</div>
<div class="dialog-main">
<slot name="main"></slot>
</div>
<div class="dialog-footer">
<slot name="footer"></slot>
</div>
</div>
`
}
new Vue({
el: '#app',
components: { MyDialog },
methods: {
del () { alert('执行删除') }
}
})
</script>
</body>
</html>关键行与易错点
<slot name="header"></slot>:组件内有多处不确定结构时,用name区分不同插槽;不写name就是默认插槽default。<template #header>:#header是v-slot:header的简写,template本身不会渲染成真实标签,只负责“分发”。- 易错点 1:
v-slot只能写在<template>上或组件标签上,不能随便写在普通 div 上。 - 易错点 2:
#后面不能有空格,写成# header会报错。 - 易错点 3:如果只用默认插槽,三处结构只能定制一处,所以才需要具名插槽。
示例二:作用域插槽 —— 封装表格,子组件把行数据“回传”给父组件
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>作用域插槽 - MyTable</title>
<style>
.my-table { width: 450px; text-align: center; border: 1px solid #ccc; font-size: 18px; margin: 30px auto; border-spacing: 0; }
.my-table thead { background-color: #1f74ff; color: #fff; }
.my-table thead th { font-weight: normal; }
.my-table thead tr { line-height: 40px; }
.my-table th, .my-table td { border-bottom: 1px solid #ccc; border-right: 1px solid #ccc; padding: 6px; }
.my-table td:last-child { border-right: none; }
.my-table tr:last-child td { border-bottom: none; }
.my-table button { width: 65px; height: 35px; font-size: 16px; border: 1px solid #ccc; border-radius: 3px; cursor: pointer; background: #fff; }
</style>
</head>
<body>
<div id="app">
<MyTable :data="list">
<!-- 通过 #插槽名="obj" 接收,默认插槽名为 default -->
<template #default="obj">
<button @click="del(obj.id)">删除</button>
</template>
</MyTable>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script>
const MyTable = {
props: {
data: { type: Array, required: true }
},
template: `
<table class="my-table">
<thead>
<tr>
<th>序号</th>
<th>姓名</th>
<th>年纪</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.name }}</td>
<td>{{ item.age }}</td>
<td>
<!-- 给 slot 以添加属性的方式传值,所有属性会被收集成一个对象 -->
<slot :id="item.id" msg="测试文本"></slot>
</td>
</tr>
</tbody>
</table>
`
}
new Vue({
el: '#app',
components: { MyTable },
data () {
return {
list: [
{ id: 1, name: '张小花', age: 18 },
{ id: 2, name: '孙大明', age: 19 },
{ id: 3, name: '刘德忠', age: 17 }
]
}
},
methods: {
del (id) {
this.list = this.list.filter(item => item.id !== id)
}
}
})
</script>
</body>
</html>关键行与易错点
<slot :id="item.id" msg="测试文本"></slot>:插槽上绑定属性即可传值,子组件把所有属性收集成一个对象{ id: 3, msg: '测试文本' }。<template #default="obj">:obj就是那个对象,所以删除按钮里写obj.id,再@click="del(obj.id)"把 id 交给父组件的方法。- 作用域插槽不是插槽的第三种分类(分类只有默认插槽、具名插槽),它只是“插槽传值”的能力。
- 易错点 1:接收时的名字必须是
#default(默认插槽),写成#obj之类收不到值。 - 易错点 2:不知道子组件传了什么时,可以先在接收处打印一下,或把接收对象传给方法输出,看清结构再用(转写稿里 Element UI 的
row就是这样试出来的)。 - 易错点 3:
#default="obj"的解构写法#default="{ id }"也可用,但要与 slot 上的属性名完全一致。
示例三:MyTag 组件 —— 自定义指令 focus + 组件 v-model
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>MyTag - v-model 与自定义指令</title>
<style>
.my-tag { cursor: pointer; display: inline-block; }
.my-tag .input { appearance: none; outline: none; border: 1px solid #ccc; width: 100px; height: 40px; box-sizing: border-box; padding: 10px; color: #666; }
.my-tag .input::placeholder { color: #666; }
.my-tag .text { height: 40px; line-height: 40px; }
</style>
</head>
<body>
<div id="app">
<MyTag v-model="tempText"></MyTag>
<p>当前标签:{{ tempText }}</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script>
// 封装全局指令 focus:指令所在的 dom 元素被插入页面时触发
Vue.directive('focus', {
inserted (el) {
el.focus()
}
})
const MyTag = {
props: {
value: String
},
data () {
return {
isEdit: false
}
},
template: `
<div class="my-tag">
<input
v-if="isEdit"
v-focus
class="input"
type="text"
placeholder="输入标签"
:value="value"
@blur="isEdit = false"
@keyup.enter="handleEnter"
/>
<div
v-else
@dblclick="handleClick"
class="text"
>{{ value }}</div>
</div>
`,
methods: {
handleClick () {
this.isEdit = true
},
handleEnter (e) {
// 非空处理
if (e.target.value.trim() === '') return alert('标签内容不能为空')
this.$emit('input', e.target.value)
// 提交完成,关闭输入状态
this.isEdit = false
}
}
}
new Vue({
el: '#app',
components: { MyTag },
data () {
return {
tempText: '水杯'
}
}
})
</script>
</body>
</html>关键行与易错点
v-if="isEdit"+v-else:双击.text把isEdit置为true显示输入框,失焦@blur="isEdit = false"隐藏输入框。v-focus:全局指令在inserted里el.focus(),因为输入框是v-if动态渲染出来的,只有插入 DOM 那一刻才能拿到并聚焦。:value="value"+this.$emit('input', ...):组件上的v-model="tempText"会被展开成:value+@input,所以子组件必须声明value这个 prop 并$emit('input')才能双向绑定。- 易错点 1:输入框里用
:value而不是v-model,否则会直接修改 prop(单向数据流被破坏)。 - 易错点 2:回车后
isEdit = false会让 input 消失,随后还会触发一次blur,两者都只是关闭编辑态,不要在里面再重复提交。 - 易错点 3:空内容要
return拦掉,避免把空字符串 emit 出去覆盖原标签。
常见错误
1. 作用域插槽里取不到删除用的 id,点击后 undefined
- 现象:
<template #default="obj">中写obj.id打印undefined,删除功能失效。 - 原因:默认按自己的想象假设子组件传出的对象结构,而 Element UI 实际传出的是
{ row, column, $index },id 在obj.row.id上。 - 修法:不要猜。先在插槽里挂一个临时事件把形参整体
console.log出来,看清结构再解构,例如#default="{ row }"后使用row.id。
2. 分页组件一交互就报 xxx is not a function
- 现象:点页码或切换每页条数时控制台报错,页面数据不动。
- 原因:
@size-change、@current-change只写了事件名,methods里没有对应方法;或方法名拼写不一致。 - 修法:模板中每个
@绑定都必须在methods里有同名函数;写完模板先自查“变量有没有、方法有没有”这两张清单。
3. 切换每页条数后,列表还是旧数据
- 现象:下拉框从 10 条改成 20 条,表格仍显示 10 条。
- 原因:只更新了
pageSize这个视图变量,没有把它作为请求参数重新调用获取数据的方法;或先请求后改参数,顺序反了。 - 修法:在事件回调里先改
pageSize/currentPage,再调用一次列表接口,并考虑把页码重置为 1,避免页码越界。
4. MyTag 回车后父组件数据没变,标签回显还是旧的
- 现象:输入框里改了内容,回车后标签又变回原来的文字。
- 原因:子组件只做了
isEdit = false,没有$emit('input', 新值),父组件的v-model值从未更新。 - 修法:
props.value只读,子组件想改值必须$emit('input', e.target.value);同时补非空判断,空串不提交。
5. 双击后输入框出现但没有光标
- 现象:
v-focus加了,输入框仍然要手动点一下才能输入。 - 原因:指令写在
bind钩子里,此时元素还没插入 DOM,el.focus()无效。 - 修法:用
inserted钩子;并确认指令注册在main.js(全局)而不是某个组件的directives里,否则别的组件用不到。
6. 抽屉点关闭按钮关不掉,一直提示
- 现象:点 ×、点遮罩都没反应,只能刷新页面。
- 原因:用了
before-close却只弹提示、没有调用回调参数done()。 - 修法:确认逻辑里必须执行
done();若要取消关闭,则不调用done,并保证visible状态与之一致。
7. 表格一片空白但接口有数据
- 现象:
list在调试工具里明明有值,页面没有行。 - 原因:
props里声明的字段名(如tableData)和父组件传的:data不一致;或模板里仍写着旧的变量名。 - 修法:父传
:data,子就props: ['data'],模板里也统一用data;改完立刻在调试工具中确认子组件 props 已收到值。
练习
具名插槽改造弹框:写一个
MyDialog组件,包含标题区、内容区、底部按钮区三个插槽,父组件只自定义底部按钮(用#footer),其余用默认内容。要求:父组件不传内容时组件仍能正常显示。作用域插槽自测:给
MyTable的每一行通过<slot :row="item" :index="index">传出数据,父组件用#default="{ row, index }"渲染“序号 + 删除按钮”,删除时打印该行 id。先用console.log确认结构再写业务代码。MyTag 完整闭环:实现双击进入编辑、失焦退出、回车提交、内容不能为空的标签组件,父组件用
v-model绑定。补充验证:输入空白回车时提示且不关闭输入框;修改成功后父组件数组中的对应项同步变化。分页与列表联动:基于
list接口封装分页,要求切换页码、切换每页条数都能重新请求数据;切换条数时页码回到第 1 页;在页面上显示“共 X 条”。抽屉复用:用同一个抽屉组件承载“添加 / 预览 / 编辑”三种操作,点击三个按钮时分别传入不同标识,抽屉标题随标识变化;点击关闭时先弹确认,确认后才真正关闭。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 渲染表格 | 05:31 |
| 2 | 处理表格最后一列 | 10:14 |
| 3 | 分页组件的使用 | 16:53 |
| 4 | 完成分页 | 08:56 |
| 5 | 抽屉的使用 | 18:32 |
| 6 | 抽屉的标题 | 08:50 |
小结
- 插槽解决的是“结构由父组件定、数据由子组件出”的分工问题;位置固定用默认插槽,多处不同位置用具名插槽,需要子组件数据回流到父组件用作用域插槽。
- 作用域插槽传出的对象结构不由你决定,由组件库决定——先打印、再解构,是比背 API 更可靠的工程习惯。
- 组件封装的判断标准是“可变的部分抽出去,稳定的部分留下来”:表头、单元格、标题、内容都属于可变部分,应交给插槽。
v-model在组件上本质是:value+@input的语法糖,子组件永远不要直接改props,只能通过事件通知父组件。- 模板里每一个
:后面必须有变量或表达式,每一个@后面必须有已定义的方法;写完模板先做这两项自查,能挡掉绝大多数运行时报错。 - 分页不是独立组件,它与列表数据是联动关系:页码和每页条数是“请求参数”,不是纯粹的展示状态,改了参数就必须重新取数。
- 弹层类组件(抽屉/对话框)的核心状态只有一个“显隐布尔值”,标题、内容、按钮行为都应由调用方传入的操作类型派生,而不是复制三份组件。
- 本组件的复用程度越高,越要警惕“共用一个方法但无法区分场景”——传参区分操作类型,是复用前的必要一步。