Vue3 自定义指令与插槽(Slot)技术详解

文章目录


一、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>

这种方式有两个明显局限:

  1. 作用域局限 :ref 仅作用于单个标签,每个需要操作 DOM 的标签都要单独定义 ref 变量,代码无法跨组件复用。
  2. 耦合度高 :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>

执行流程

  1. 页面首次渲染 → 触发 mounted → 文字变红 → 控制台输出 1-mounted
  2. colorStr.value 变为 'blue' → 触发 updated → 文字变蓝 → 控制台输出 2-updated

关键点mounted 只在元素插入 DOM 时执行一次,后续数据变化不会再次触发;updated 则在每次绑定值变化时执行。两者配合才能覆盖"首次渲染 + 后续更新"的完整生命周期。

简写形式

mountedupdated 的逻辑完全一致时(这是最常见的情况),可以使用简写语法:

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>

原理解析

  1. 每张 <img> 初始不设置 src,避免立即发起网络请求。
  2. IntersectionObserver 监视每张图片是否进入浏览器可视区域。
  3. 当图片滚入视口 → entry.isIntersectingtrue → 将 binding.value(真实图片 URL)赋值给 el.src → 浏览器开始下载并渲染图片。
  4. 加载完成后调用 unobserve + disconnect 释放监视资源,避免内存泄漏。

注意 :这里用了简写形式,因为 IntersectionObserver 的回调本身是异步的,不需要区分 mountedupdated------每次指令绑定时都重新创建监视器即可。


二、插槽(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>

关键注意事项

  1. template 顺序自由 :外部写 #body 在前还是 #title 在前都无所谓,内容会被分发到对应 name<slot> 位置。
  2. 可混用默认插槽 + 具名插槽 :组件中同时存在无 name<slot>(默认插槽)和有 name<slot>(具名插槽),分别按对应语法传入即可。
  3. 语法区分
    • 具名插槽只有一种写法template #插槽名
    • 默认插槽有两种写法 :直接写内容 / template #default

2.4 作用域插槽

作用域插槽是插槽功能的终极形态 ,它在具名插槽的基础上增加了一个关键能力:子组件可以将自身数据通过 slot 传递给父组件使用

核心能力

  1. 外部可自定义组件内部的标签结构(继承自默认/具名插槽)
  2. 子组件的 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 最常用)
简写条件 mountedupdated 逻辑完全一致时可用简写
纯一次性操作 只写 mounted,如 v-jujiao 自动聚焦
图片懒加载 IntersectionObserver + 自定义指令的经典结合
默认插槽 <slot> 占位,双标签间传内容,有默认值机制
具名插槽 <slot name="xxx"> + template #xxx,多位置分发
作用域插槽 <slot :数据名="值"> + template #xxx="obj",子传父数据
抽象组件 <slot><template> 都不会出现在真实 DOM 中

常见问题 / 避坑指南

  1. 作用域插槽的解构命名要注意什么?

接收数据时支持 ES6 解构:

html 复制代码
<!-- 完整接收 -->
<template #default="obj">{{ obj.n1 }}</template>

<!-- 解构接收(推荐) -->
<template #default="{ n1, n2 }">{{ n1 }} - {{ n2 }}</template>

注意 :不要写成 #default="{ n1, n2 }" 时忘记外层引号,这是 Vue 模板语法,不是 JS 表达式。

  1. 具名插槽和默认插槽能同时用吗?

可以。组件中 <slot>(无 name)和 <slot name="xxx"> 可以共存:

html 复制代码
<!-- 子组件 -->
<slot></slot>          <!-- 默认插槽 -->
<slot name="footer"></slot>  <!-- 具名插槽 -->
html 复制代码
<!-- 父组件 -->
<my-component>
  <p>这是默认插槽内容</p>
  <template #footer>
    <p>这是具名插槽内容</p>
  </template>
</my-component>
  1. 自定义指令注册在局部 vs 全局?什么时候用哪个?
  • 全局注册app.directive):通用性强的 DOM 操作(懒加载、权限指令、防抖按钮等),推荐全局注册。
  • 局部注册 (组件 directives 选项):仅在个别组件使用的特殊指令,避免全局污染。
js 复制代码
// 局部注册
export default {
  directives: {
    focus: {
      mounted(el) { el.focus() }
    }
  }
}
相关推荐
Csvn1 小时前
🎯 Flex 布局的 `min-width: auto` 陷阱:为什么内容总是撑破容器?
前端
果然_2 小时前
纯前端图片压缩怎么做?不用上传服务器,浏览器里跑完所有逻辑
前端
英勇无比的消炎药2 小时前
TinyRobot DropdownMenu 下拉菜单:三种触发方式与实现原理
vue.js
Csvn2 小时前
🖼️ OffscreenCanvas:把 Canvas 绘制搬出主线程,动画卡顿的终极解药
前端
用户69371750013842 小时前
了解一下 Agent Harness
android·前端·后端
swipe2 小时前
16|(前端转全栈)前端人排查后端问题:curl、traceId、日志、MySQL、Redis 怎么用?
前端·后端·面试
anyup2 小时前
uni-app 没有根组件?仅需几行代码实现全局 Toast 和 Modal
前端·架构·uni-app
并不喜欢吃鱼2 小时前
一.前端web开发:零基础吃透 HTML5 核心知识
前端·html·html5