Vue 3 插槽深度实战:默认、具名与作用域插槽及自定义指令

Vue 3 插槽深度实战:默认、具名与作用域插槽及自定义指令

admin
2026-07-14 / 0 评论 / 5 阅读

如果说 props 是组件的"输入参数",插槽就是组件的"内容参数"。写好一个可复用的布局组件、表格组件、弹窗组件,绕不开插槽;而自定义指令则是 DOM 级复用的利器。这两个主题合起来,是 Vue 中级开发者必须吃透的内容。

一、插槽的三种形态

1. 默认插槽

父组件传入的内容替换子组件的 <slot> 占位:

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-body">
      <slot></slot>
    </div>
  </div>
</template>
<!-- 使用 -->
<Card>
  <h3>订单详情</h3>
  <p>共 3 件商品,合计 ¥299.00</p>
</Card>

2. 具名插槽:多区域布局

组件有多个内容区域时,用 name 区分。典型场景:页头/页身/页脚的布局组件。

<!-- PageLayout.vue -->
<template>
  <div class="page">
    <header class="page-header">
      <slot name="header"></slot>
    </header>
    <main class="page-main">
      <slot></slot> <!-- 默认插槽 -->
    </main>
    <footer class="page-footer">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>
<!-- 使用:template + v-slot 指定区域 -->
<PageLayout>
  <template #header>
    <h1>管理后台</h1>
  </template>

  <p>这里是主内容区域</p>

  <template #footer>
    <span>© 2026 My Corp</span>
  </template>
</PageLayout>

注意 #headerv-slot:header 的缩写,且 v-slot 只能写在 <template> 上(默认插槽的简写除外)。

3. 作用域插槽:数据反向传递

普通插槽的内容编译在父组件作用域,无法访问子组件内部数据。作用域插槽让子组件把数据"抛"给插槽内容:

<!-- DataList.vue -->
<script setup>
const props = defineProps({
  items: { type: Array, required: true }
})
</script>

<template>
  <ul class="data-list">
    <li v-for="(item, index) in items" :key="item.id">
      <slot :item="item" :index="index">
        <!-- 后备内容:父组件不传插槽时的默认渲染 -->
        {{ item.name }}
      </slot>
    </li>
  </ul>
</template>
<!-- 父组件决定每一行怎么渲染 -->
<DataList :items="orders">
  <template #default="{ item, index }">
    <div class="order-row">
      <span>#{{ index + 1 }}</span>
      <span>{{ item.orderNo }}</span>
      <span :class="item.status">{{ statusText[item.status] }}</span>
    </div>
  </template>
</DataList>

这是设计"无头组件"(Headless Component)的核心思想:组件负责数据和逻辑,父组件负责渲染结构。Element Plus 的 Table、el-select 的 option 都大量使用作用域插槽。

实战:封装一个通用描述列表组件

综合运用具名 + 作用域插槽,封装一个类似 Ant Design Descriptions 的组件:

<!-- DescList.vue -->
<script setup>
defineProps({
  items: { type: Array, required: true }
})
</script>

<template>
  <dl class="desc-list">
    <template v-for="item in items" :key="item.field">
      <dt>{{ item.label }}</dt>
      <dd>
        <!-- 命名插槽接管某个字段的渲染,否则显示原始值 -->
        <slot :name="item.field" :item="item">
          {{ item.value }}
        </slot>
      </dd>
    </template>
  </dl>
</template>
<!-- 使用方 -->
<DescList :items="[
  { field: 'name', label: '姓名', value: user.name },
  { field: 'avatar', label: '头像', value: user.avatar },
  { field: 'status', label: '状态', value: user.status }
]">
  <template #avatar="{ item }">
    <img :src="item.value" class="avatar" />
  </template>
  <template #status="{ item }">
    <el-tag :type="item.value === 1 ? 'success' : 'danger'">
      {{ item.value === 1 ? '正常' : '禁用' }}
    </el-tag>
  </template>
</DescList>

一个组件同时满足了通用性和定制性——这就是插槽设计的精髓。

二、自定义指令

当复用的是 DOM 行为而不是组件结构时,用指令。指令只在底层操作 DOM,不关心业务。

注册与钩子

// main.js 全局注册
const app = createApp(App)

app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

Vue 3 指令的钩子与组件生命周期对齐:

钩子触发时机
created元素属性和事件监听器设置之前
beforeMount挂载之前
mounted挂载到 DOM 后
beforeUpdate更新前
updated更新后
beforeUnmount卸载前
unmounted卸载后

绝大多数指令只需要 mountedupdated,可以用简写函数形式:

app.directive('color', (el, binding) => {
  el.style.color = binding.value
})

实战指令一:v-loading

// directives/loading.js
export const vLoading = {
  mounted(el, binding) {
    const mask = document.createElement('div')
    mask.className = 'v-loading-mask'
    mask.innerHTML = '<div class="v-loading-spinner"></div>'
    el.style.position = el.style.position || 'relative'
    el.appendChild(mask)
    el.__loadingMask = mask
    toggle(el, binding.value)
  },
  updated(el, binding) {
    toggle(el, binding.value)
  },
  unmounted(el) {
    el.__loadingMask?.remove()
  }
}

function toggle(el, show) {
  el.__loadingMask.style.display = show ? 'flex' : 'none'
}
<div class="panel" v-loading="fetching">
  <p v-for="row in rows" :key="row.id">{{ row.name }}</p>
</div>

实战指令二:v-debounce

按钮防抖是高频需求,每个地方手写 setTimeout 太啰嗦:

export const vDebounce = {
  mounted(el, binding) {
    const [fn, delay = 300] = binding.value instanceof Array
      ? binding.value
      : [binding.value, 300]

    let timer = null
    el.__debounceHandler = (event) => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => fn(event), delay)
    }
    el.addEventListener('click', el.__debounceHandler)
  },
  unmounted(el) {
    el.removeEventListener('click', el.__debounceHandler)
  }
}
<button v-debounce="[saveOrder, 500]">提交订单</button>

实战指令三:v-permission 权限控制

import { useUserStore } from '@/stores/user'

export const vPermission = {
  mounted(el, binding) {
    const userStore = useUserStore()
    const required = binding.value // 'order:delete'
    const modifiers = binding.modifiers // { some: true } → v-permission.some

    let hasPermission
    if (modifiers.some) {
      // 任一权限即可
      hasPermission = [].concat(required).some(p => userStore.permissions.includes(p))
    } else {
      hasPermission = userStore.permissions.includes(required)
    }

    if (!hasPermission) {
      el.parentNode?.removeChild(el)
    }
  }
}
<button v-permission="'order:delete'">删除订单</button>
<button v-permission.some="['a', 'b']">复合操作</button>

<script setup> 中局部注册

指令也支持在 SFC 内直接定义,变量名以 v 开头即可自动注册:

<script setup>
// 变量名 vFocus 自动映射为 v-focus 指令
const vFocus = {
  mounted: (el) => el.focus()
}
</script>

<template>
  <input v-focus />
</template>

插槽 vs 指令:选型原则

维度插槽指令
复用内容结构 + 样式DOM 行为
典型场景布局、列表渲染定制权限、防抖、拖拽、埋点
作用对象组件原生 DOM 元素
灵活性高(父级完全接管渲染)中(操作属性和事件)

一个简单的判断:需求是"换个样子"用插槽,需求是"加点行为"用指令

总结

  • 作用域插槽 = 子组件向插槽内容"回传数据",是无头组件的基石
  • 具名插槽支撑多区域布局,#name 是标准缩写
  • 自定义指令只在需要直接操作 DOM 时使用,能用组件解决的别用指令
  • 指令钩子与组件生命周期对齐,简写形式覆盖 mounted + updated
  • v-loadingv-debouncev-permission 是三个最值得收进工具箱的指令

把插槽和指令用好,你封装的组件会从"能用"进化到"好用"。下一篇文章我们聊聊组合式函数(Composables)——Vue 3 逻辑复用的终极形态。

0

评论 (0)

取消
0:00