如果说 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>注意 #header 是 v-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 | 卸载后 |
绝大多数指令只需要 mounted 和 updated,可以用简写函数形式:
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-loading、v-debounce、v-permission是三个最值得收进工具箱的指令
把插槽和指令用好,你封装的组件会从"能用"进化到"好用"。下一篇文章我们聊聊组合式函数(Composables)——Vue 3 逻辑复用的终极形态。
评论 (0)