文章目录
-
- [一、Vue3 自定义指令](#一、Vue3 自定义指令)
-
- [1.1 内置指令与自定义指令定义](#1.1 内置指令与自定义指令定义)
- [1.2 与 ref 操作 DOM 的区别](#1.2 与 ref 操作 DOM 的区别)
- [1.3 基础使用步骤](#1.3 基础使用步骤)
-
- [第一步:在 `main.js` 中定义](#第一步:在
main.js中定义) - 第二步:在组件中使用
- [第一步:在 `main.js` 中定义](#第一步:在
- [1.4 绑定数据 --- `v-color="表达式"`](#1.4 绑定数据 —
v-color="表达式")
- 二、插槽(Slot)
-
- [2.1 基础概念](#2.1 基础概念)
- [2.2 默认插槽](#2.2 默认插槽)
- [2.3 具名插槽](#2.3 具名插槽)
- [2.4 作用域插槽](#2.4 作用域插槽)
- 全文总结
- 核心知识点复盘
- [常见问题 / 避坑指南](#常见问题 / 避坑指南)
一、Vue3 自定义指令
1.1 内置指令与自定义指令定义
在 Vue3 中,指令(Directive)是以 v- 开头的特殊属性,用于在模板中直接操作 DOM。
内置指令是 Vue3 开箱即用的能力,常见的包括:
| 指令 | 功能 | 示例 |
|---|---|---|
v-model |
双向数据绑定 | <input v-model="name" /> |
v-if / v-show |
条件渲染 | <div v-if="visible">...</div> |
v-for |
列表渲染 | <li v-for="item in list">...</li> |
v-bind(简写 :) |
动态绑定属性 | <img :src="url" /> |
v-on(简写 @) |
事件监听 | <button @click="handle">...</button> |
这些内置指令覆盖了大部分日常开发场景,但当我们需要封装一段可复用的 DOM 操作逻辑时(比如自动聚焦、图片懒加载、权限控制等),内置指令就力不从心了------这就是自定义指令的用武之地。
自定义指令 由开发者自行编写,核心作用一句话概括:封装可复用的 DOM 操作代码。
1.2 与 ref 操作 DOM 的区别
很多同学会有疑问:"ref 不也能拿到 DOM 元素操作吗?为什么还要自定义指令?"
先看一段 ref 操作 DOM 的典型代码:
js
// 组件内通过 ref 获取 DOM
import { ref, onMounted } from 'vue'
const divRef = ref(null)
onMounted(() => {
// ref 拿到 DOM 后直接操作
divRef.value.style.color = 'red'
})
html
<template>
<div ref="divRef">Some Text...</div>
</template>
这种方式有两个明显局限:
- 作用域局限 :ref 仅作用于单个标签,每个需要操作 DOM 的标签都要单独定义 ref 变量,代码无法跨组件复用。
- 耦合度高 :DOM 操作逻辑散落在各个组件的
onMounted中,当多个组件需要相同的 DOM 行为(如点击外部关闭、拖拽、懒加载)时,只能复制粘贴。
再看自定义指令的方式:
js
// main.js 中全局定义一次
app.directive('color', (el, binding) => {
el.style.color = binding.value
})
html
<!-- 任意组件中直接使用,一行搞定 -->
<div v-color="'red'">Text A</div>
<div v-color="'blue'">Text B</div>
对比总结:
| 维度 | ref | 自定义指令 |
|---|---|---|
| 作用范围 | 单个标签 | 任意标签,全局/局部复用 |
| 代码复用 | 不可复用,需重复编写 | 一次定义,多处使用 |
| 适用场景 | 组件内部临时 DOM 操作 | 通用 DOM 逻辑封装 |
一句话总结:ref 是"手动挡",自己拿到 DOM 自己操作;自定义指令是"自动挡",定义好规则后自动执行。
1.3 基础使用步骤
自定义指令的使用分两步:定义 + 使用。
第一步:在 main.js 中定义
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 定义全局自定义指令
// 第一个参数:指令名(使用时加 v- 前缀)
// 第二个参数:配置对象,包含生命周期钩子
app.directive('jujiao', {
// mounted:指令所在元素插入到真实 DOM 后自动执行一次
mounted(el) {
// el 就是指令绑定的原生 DOM 元素
// 拿到 el 之后,可以做任何原生 DOM 操作
el.focus()
}
})
app.mount('#app')
第二步:在组件中使用
html
<template>
<!-- 使用 v-指令名 即可,页面加载后自动聚焦 -->
<input type="text" v-jujiao />
</template>
执行流程 :页面渲染 → input 元素插入 DOM → Vue 检测到 v-jujiao 指令 → 调用 mounted(el) 钩子 → el.focus() 让输入框自动获得焦点。
1.4 绑定数据 --- v-color="表达式"
实际开发中,指令往往需要绑定动态数据 。Vue3 通过钩子函数的 binding 参数来获取指令绑定的表达式值。
完整写法
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.directive('color', {
// 元素首次插入 DOM 时执行
mounted(el, binding) {
// el: 指令所在元素
// binding: 指令绑定的信息对象
// binding.value → 指令表达式的结果(如 "red")
// binding.arg → 指令参数(如 v-color:xxx 中的 xxx)
// binding.modifiers → 指令修饰符对象
el.style.color = binding.value
console.log('1-mounted')
},
// 绑定表达式的值每次变化时执行
updated(el, binding) {
el.style.color = binding.value
console.log('2-updated')
}
})
app.mount('#app')
html
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
// 响应式颜色数据
const colorStr = ref('red')
</script>
<template>
<!-- 绑定响应式数据,colorStr 变化时自动更新颜色 -->
<div v-color="colorStr">Some Text</div>
</template>
执行流程:
- 页面首次渲染 → 触发
mounted→ 文字变红 → 控制台输出1-mounted colorStr.value变为'blue'→ 触发updated→ 文字变蓝 → 控制台输出2-updated
关键点 :
mounted只在元素插入 DOM 时执行一次,后续数据变化不会再次触发;updated则在每次绑定值变化时执行。两者配合才能覆盖"首次渲染 + 后续更新"的完整生命周期。
简写形式
当 mounted 和 updated 的逻辑完全一致时(这是最常见的情况),可以使用简写语法:
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 简写:直接传入一个函数,等价于同时设置了 mounted 和 updated
app.directive('color', (el, binding) => {
el.style.color = binding.value
})
app.mount('#app')
html
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
const colorStr = ref('red')
</script>
<template>
<div v-color="colorStr">Some Text</div>
</template>
简写形式等价于:
js
app.directive('color', {
mounted(el, binding) { el.style.color = binding.value },
updated(el, binding) { el.style.color = binding.value }
})
何时用简写?何时用完整写法?
| 场景 | 写法选择 | 示例 |
|---|---|---|
| 需要绑定响应式数据 | 简写(更简洁) | v-color="colorStr" |
| 无需数据绑定,仅执行一次性操作 | 完整写法 (只写 mounted) |
v-jujiao 自动聚焦 |
自动聚焦指令 ------典型的只需 mounted 的场景:
js
app.directive('jujiao', {
mounted(el) {
el.focus()
}
})
html
<input v-jujiao type="text" />
无需简写,因为聚焦操作只做一次,不存在"后续数据变化需要更新"的情况。
实战案例:图片懒加载指令
下面是一个完整的图片懒加载自定义指令,利用浏览器原生 IntersectionObserver API:
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.directive('lazyload', (el, binding) => {
// el: <img> 标签元素
// binding.value: 图片的真实 src 地址
// 核心思路:判断 img 是否出现在可视区,出现了才设置 src
// 创建交叉监视器实例
const io = new IntersectionObserver(([entry]) => {
// entry.isIntersecting:元素是否与可视区交叉
if (entry.isIntersecting) {
// 图片进入可视区,设置真实 src 触发加载
el.src = binding.value
// 监听加载失败
el.addEventListener('error', (error) => {
console.log('图片加载失败, ', error)
})
// 加载完成后停止监视该元素
io.unobserve(el)
// 关闭监视器,释放资源
io.disconnect()
}
})
// 开始监视当前元素
io.observe(el)
})
app.mount('#app')
html
<!-- App.vue -->
<script setup>
const imgList = [
'https://img1.baidu.com/it/u=14492133,1259363498&fm=253&fmt=auto&app=138&f=JPEG?w=667&h=500',
'https://img0.baidu.com/it/u=1764531212,1643995922&fm=253&fmt=auto&app=120&f=JPEG?w=750&h=500',
// ... 更多图片 URL
]
</script>
<template>
<div class="container">
<img
v-for="(url, index) in imgList"
:key="index"
v-lazyload="url"
width="600"
height="320"
/>
</div>
</template>
原理解析:
- 每张
<img>初始不设置src,避免立即发起网络请求。 IntersectionObserver监视每张图片是否进入浏览器可视区域。- 当图片滚入视口 →
entry.isIntersecting为true→ 将binding.value(真实图片 URL)赋值给el.src→ 浏览器开始下载并渲染图片。 - 加载完成后调用
unobserve+disconnect释放监视资源,避免内存泄漏。
注意 :这里用了简写形式,因为
IntersectionObserver的回调本身是异步的,不需要区分mounted和updated------每次指令绑定时都重新创建监视器即可。
二、插槽(Slot)
2.1 基础概念
插槽 是 Vue 内置的 <slot> 组件,用于在组件内部占位,让外部使用者自定义组件内部的标签结构。
简单理解:组件就像一个"填空题模板",开发者把固定结构写在组件里,把不固定的位置用 <slot> 挖空,使用组件时再"填入"具体内容。
Vue3 的插槽分为三个层次:
默认插槽(匿名插槽)
↓ 功能升级
具名插槽(多位置定制)
↓ 功能再升级
作用域插槽(多位置定制 + 数据跨组件传递)
2.2 默认插槽
默认插槽(也叫匿名插槽)是最基础的形式------组件中有一处不确定的结构,用 <slot> 占位。
使用步骤
1)占位 :在组件内不确定结构的地方写 <slot>。
2)传内容:使用组件时写双标签,中间填入自定义结构。
完整示例
子组件 panel.vue:
html
<script setup>
import { ref } from 'vue'
const visible = ref(false)
</script>
<template>
<div class="panel">
<div class="title"
:style="{ borderBottomColor: visible ? 'transparent' : '#ccc' }">
<h4>自由与爱情</h4>
<span class="btn" @click="visible = !visible">收起</span>
</div>
<div class="container" v-show="visible">
<!-- slot:Vue 内置组件,抽象组件(不会出现在真实 DOM 树中) -->
<slot></slot>
</div>
</div>
</template>
父组件 App.vue:
html
<script setup>
import Panel from './components/panel.vue'
</script>
<template>
<h3>折叠面板</h3>
<!-- 第一次使用:传一首诗 -->
<panel>
<p>生命诚可贵,</p>
<p>爱情价更高。</p>
<p>若为自由故,</p>
<p>两者皆可抛。</p>
</panel>
<!-- 第二次使用:传两张图片 -->
<panel>
<div class="img-box">
<img src="./assets/lq.png" alt="lq" />
<img src="./assets/sy.png" alt="sy" />
</div>
</panel>
</template>
效果 :同一个 panel 组件,外部传入不同内容(诗/图片),展示不同的内部结构。
关键点 :
<slot>是一个抽象组件,它只存在于 Vue 的虚拟 DOM 中,最终渲染时会被传入的真实内容完全替换,不会在页面上生成任何额外标签。
插槽的默认值
<slot> 标签内部可以写默认内容------当使用者没有传入任何内容时,自动展示默认值:
html
<!-- bit-panel.vue -->
<slot>
<!-- 下面四行是默认值,外部没传内容时展示 -->
<p>白日依山尽,</p>
<p>黄河入海流。</p>
<p>欲穷千里目,</p>
<p>更上一层楼。</p>
</slot>
html
<!-- 使用组件时不传内容 → 展示默认诗 -->
<panel />
<!-- 使用组件时传内容 → 替换默认值,展示实际传入的内容 -->
<panel>
<div class="img-box">
<img src="./assets/lq.png" />
<img src="./assets/sy.png" />
</div>
</panel>
默认插槽的两种传值语法
语法一:直接写在双标签之间(推荐,简洁直观)
html
<bit-panel>
<p>直接写内容即可</p>
</bit-panel>
语法二:template #default(与具名插槽语法统一)
html
<bit-panel>
<!-- v-slot:default 简写为 #default -->
<template #default>
<p>通过 template 包裹的内容</p>
</template>
</bit-panel>
v-slot:可简写为#,能简写推荐简写。template同样是抽象组件,仅起包裹作用,不会被渲染到 DOM。
2.3 具名插槽
当组件内有 ≥ 2 处不确定的结构时,默认插槽就不够用了------你需要告诉 Vue"这段内容填到哪个位置"。这就是具名插槽。
定义方式
给 <slot> 添加 name 属性,让它拥有一个专属名称。
子组件 panel.vue:
html
<script setup>
import { ref } from 'vue'
const visible = ref(false)
</script>
<template>
<div class="panel">
<div class="title"
:style="{ borderBottomColor: visible ? 'transparent' : '#ccc' }">
<h4>
<!-- 具名插槽:title -->
<slot name="title">
<span>自由与爱情</span>
</slot>
</h4>
<span class="btn" @click="visible = !visible">收起</span>
</div>
<div class="container" v-show="visible">
<!-- 具名插槽:body -->
<slot name="body">
<p>生命诚可贵,</p>
<p>爱情价更高。</p>
<p>若为自由故,</p>
<p>两者皆可抛。</p>
</slot>
</div>
</div>
</template>
父组件 App.vue:
html
<script setup>
import BitPanel from './components/panel.vue'
</script>
<template>
<h3>折叠面板</h3>
<!-- 不传内容:两处插槽都展示默认值 -->
<panel />
<!-- 传内容:用 #插槽名 指定每段内容的目标位置 -->
<panel>
<template #title>
<span>登鹳雀楼</span>
</template>
<template #body>
<p>白日依山尽,</p>
<p>黄河入海流。</p>
<p>欲穷千里目,</p>
<p>更上一层楼。</p>
</template>
</panel>
<!-- 第三次使用:注意 template 顺序不受 slot 位置限制 -->
<panel>
<template #body>
<!-- body 的内容写在前面,照样渲染到正确位置 -->
<div class="img-box">
<img src="./assets/lq.png" />
<img src="./assets/sy.png" />
</div>
</template>
<template #title>
<b>两个女明星</b>
</template>
</panel>
</template>
关键注意事项
template顺序自由 :外部写#body在前还是#title在前都无所谓,内容会被分发到对应name的<slot>位置。- 可混用默认插槽 + 具名插槽 :组件中同时存在无
name的<slot>(默认插槽)和有name的<slot>(具名插槽),分别按对应语法传入即可。 - 语法区分 :
- 具名插槽只有一种写法 :
template #插槽名 - 默认插槽有两种写法 :直接写内容 /
template #default
- 具名插槽只有一种写法 :
2.4 作用域插槽
作用域插槽是插槽功能的终极形态 ,它在具名插槽的基础上增加了一个关键能力:子组件可以将自身数据通过 slot 传递给父组件使用。
核心能力
- 外部可自定义组件内部的标签结构(继承自默认/具名插槽)
- 子组件的 slot 绑定的数据,可以传递给父组件(核心升级)
完整语法
子组件绑定数据:
html
<!-- 子组件 -->
<slot name="body" :n1="name1" :n2="name2">
<p>默认内容...</p>
</slot>
父组件接收数据:
html
<!-- 父组件 -->
<template #body="obj">
<!-- obj 是一个对象,包含子组件 slot 上绑定的所有数据 -->
<!-- obj = { n1: '李沁', n2: '宋轶' } -->
</template>
实战示例
子组件 panel.vue:
html
<script setup>
import { ref } from 'vue'
const visible = ref(false)
// 子组件自己的数据
const name1 = ref('李沁')
const name2 = ref('宋轶')
</script>
<template>
<div class="panel">
<div class="title"
:style="{ borderBottomColor: visible ? 'transparent' : '#ccc' }">
<h4>
<slot name="title">
<span>自由与爱情</span>
</slot>
</h4>
<span class="btn" @click="visible = !visible">收起</span>
</div>
<div class="container" v-show="visible">
<!-- 通过 slot 属性绑定,将子组件数据暴露给父组件 -->
<slot name="body" :n1="name1" :n2="name2">
<p>生命诚可贵,</p>
<p>爱情价更高。</p>
<p>若为自由故,</p>
<p>两者皆可抛。</p>
</slot>
</div>
</div>
</template>
父组件 App.vue:
html
<script setup>
import BitPanel from './components/panel.vue'
</script>
<template>
<h3>折叠面板</h3>
<panel>
<template #title>
<b>两个女明星</b>
</template>
<!-- #body="obj" 接收子组件传来的数据对象 -->
<template #body="obj">
<div class="img-box">
<!-- obj.n1 → '李沁',obj.n2 → '宋轶' -->
<h4>{{ obj.n1 }}</h4>
<img src="./assets/lq.png" />
<h4>{{ obj.n2 }}</h4>
<img src="./assets/sy.png" />
</div>
</template>
</panel>
</template>
数据流图:

综合案例:陕西美食表格组件
下面是一个典型的作用域插槽实战场景------封装一个通用表格组件,操作列由父组件自定义。
子组件 table.vue:
html
<script setup>
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
</script>
<template>
<table class="bit-table" border="1" cellspacing="0" cellpadding="0">
<thead>
<tr>
<th>序号</th>
<th>名称</th>
<th>价格</th>
<th>数量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in props.data" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.name }}</td>
<td>{{ item.price }}</td>
<td>{{ item.num }}</td>
<td>
<!-- 将当前行索引 i 暴露给父组件,父组件自定义操作按钮 -->
<slot :i="index"></slot>
</td>
</tr>
</tbody>
</table>
</template>
父组件 App.vue:
html
<script setup>
import BitTable from './components/table.vue'
import { ref } from 'vue'
const tableData1 = ref([
{ id: 139, name: '肉夹馍', price: 11, num: 2 },
{ id: 340, name: '魏家凉皮', price: 10, num: 1 },
{ id: 397, name: 'biangbiang面', price: 14, num: 2 },
{ id: 262, name: '冰峰', price: 10, num: 2 }
])
const tableData2 = ref([
{ id: 340, name: '葫芦头', price: 14, num: 4 },
{ id: 118, name: '羊肉泡馍', price: 18, num: 3 },
{ id: 273, name: '黑色大窑', price: 5, num: 2 }
])
const preview = (index) => {
alert(JSON.stringify(tableData1.value[index]))
}
const del = (index) => {
if (window.confirm('确认删除么?')) {
tableData2.value.splice(index, 1)
}
}
</script>
<template>
<div class="main">
<h2>欢迎来到陕西美食</h2>
<!-- 第一张表:操作列为"查看" -->
<table :data="tableData1">
<template #default="{ i }">
<a href="javascript:;" @click="preview(i)">查看</a>
</template>
</table>
<br /><br />
<!-- 第二张表:操作列为"删除" -->
<table :data="tableData2">
<template #default="{ i }">
<button @click="del(i)">删除</button>
</template>
</table>
</div>
</template>
设计思路:
- 组件只负责表头渲染 + 数据遍历,操作列完全开放给父组件定义。
- 通过
<slot :i="index">将当前行索引传给父组件,父组件用#default="{ i }"接收。 - 同一个表格组件,第一张表展示"查看"链接,第二张表展示"删除"按钮------这就是作用域插槽的威力。
全文总结
本文系统讲解了 Vue3 中两大复用机制------自定义指令 和插槽,它们分别解决不同层面的复用问题:
自定义指令 解决的是 DOM 操作复用 问题。当多个组件需要相同的 DOM 行为(自动聚焦、懒加载、拖拽、权限控制等),将逻辑封装为指令,一处定义、全局使用,远比在每个组件里写 onMounted + ref 更优雅。
插槽 解决的是组件结构定制问题,功能逐级递进:
默认插槽 → 组件内 1 处位置不固定
具名插槽 → 组件内 ≥2 处位置不固定
作用域插槽 → 组件内 ≥2 处位置不固定 + 子→父数据传递
核心知识点复盘
| 知识点 | 核心要点 |
|---|---|
| 自定义指令定义 | app.directive('指令名', { mounted, updated }) |
| 钩子参数 | el(DOM元素)、binding(绑定信息,.value 最常用) |
| 简写条件 | mounted 和 updated 逻辑完全一致时可用简写 |
| 纯一次性操作 | 只写 mounted,如 v-jujiao 自动聚焦 |
| 图片懒加载 | IntersectionObserver + 自定义指令的经典结合 |
| 默认插槽 | <slot> 占位,双标签间传内容,有默认值机制 |
| 具名插槽 | <slot name="xxx"> + template #xxx,多位置分发 |
| 作用域插槽 | <slot :数据名="值"> + template #xxx="obj",子传父数据 |
| 抽象组件 | <slot> 和 <template> 都不会出现在真实 DOM 中 |
常见问题 / 避坑指南
- 作用域插槽的解构命名要注意什么?
接收数据时支持 ES6 解构:
html
<!-- 完整接收 -->
<template #default="obj">{{ obj.n1 }}</template>
<!-- 解构接收(推荐) -->
<template #default="{ n1, n2 }">{{ n1 }} - {{ n2 }}</template>
注意 :不要写成 #default="{ n1, n2 }" 时忘记外层引号,这是 Vue 模板语法,不是 JS 表达式。
- 具名插槽和默认插槽能同时用吗?
可以。组件中 <slot>(无 name)和 <slot name="xxx"> 可以共存:
html
<!-- 子组件 -->
<slot></slot> <!-- 默认插槽 -->
<slot name="footer"></slot> <!-- 具名插槽 -->
html
<!-- 父组件 -->
<my-component>
<p>这是默认插槽内容</p>
<template #footer>
<p>这是具名插槽内容</p>
</template>
</my-component>
- 自定义指令注册在局部 vs 全局?什么时候用哪个?
- 全局注册 (
app.directive):通用性强的 DOM 操作(懒加载、权限指令、防抖按钮等),推荐全局注册。 - 局部注册 (组件
directives选项):仅在个别组件使用的特殊指令,避免全局污染。
js
// 局部注册
export default {
directives: {
focus: {
mounted(el) { el.focus() }
}
}
}