自定义指令:底层 DOM 操作的复用
Q: Vue 已经提供了丰富的内置指令,为什么还需要自定义指令?它和组件、组合式函数的复用有什么区别?
A: Vue 中已经存在三种复用方式,它们各有侧重:
| 复用方式 | 侧重点 |
|---|---|
| 组件 | 对结构、样式、逻辑的复用 |
| 组合式函数 | 对有状态的逻辑的复用 |
| 自定义指令 | 对涉及普通元素的底层 DOM 访问逻辑的复用 |
当需要在多个组件中对原生 DOM 元素执行相同操作(如自动聚焦、权限控制、相对时间显示等),自定义指令是最合适的方式。
快速上手:自动聚焦指令
<template>
<input type="text" v-focus />
</template>
<script setup>
const vFocus = {
mounted: (el) => {
el.focus(); // 元素挂载后自动获取焦点
},
};
</script>
不同写法下的注册方式
在 setup 语法糖中,任何以 v 开头的驼峰式命名的变量都可以直接作为自定义指令使用。
在非 setup 写法中,需要在 directives 选项中注册:
<script>
export default {
directives: {
focus: {
mounted: (el) => el.focus(),
},
},
};
</script>
<template>
<input v-focus />
</template>
全局注册
在应用级别注册的自定义指令可以在所有组件中使用:
// main.js
import { createApp } from 'vue';
const app = createApp(App);
app.directive('focus', {
mounted(el) { el.focus(); },
});
app.mount('#app');
全局注册还有一个简化写法——第二个参数直接传入函数,这会在 mounted 和 updated 时都执行:
app.directive('color', (el, binding) => {
el.style.color = binding.value;
});
指令钩子与参数详解
自定义指令提供了完整的生命周期钩子:
const myDirective = {
created(el, binding, vnode) { /* 绑定元素的 attribute 或事件监听器应用前 */ },
beforeMount(el, binding, vnode) { /* 元素被插入到 DOM 前 */ },
mounted(el, binding, vnode) { /* 父组件及所有子节点挂载完成后 */ },
beforeUpdate(el, binding, vnode, prevVnode) { /* 父组件更新前 */ },
updated(el, binding, vnode, prevVnode) { /* 父组件及所有子节点更新后 */ },
beforeUnmount(el, binding, vnode) { /* 父组件卸载前 */ },
unmounted(el, binding, vnode) { /* 父组件卸载后 */ },
};
钩子函数的参数:
| 参数 | 说明 |
|---|---|
el |
指令绑定到的元素,可直接操作 DOM |
binding |
包含 value、oldValue、arg、modifiers、instance、dir 等属性的对象 |
vnode |
绑定元素的底层 VNode |
prevVnode |
上一次渲染的 VNode(仅 beforeUpdate / updated 可用) |
binding 对象提供了指令使用的详细信息。例如 v-example:foo.bar="baz" 对应的 binding 为:
{
arg: 'foo', // 参数
modifiers: { bar: true }, // 修饰符
value: /* baz 的值 */,
oldValue: /* 上一次更新时 baz 的值 */
}
传递多个值
当需要向指令传递多个值时,可使用对象字面量:
<div v-demo="{ color: 'white', text: 'hello!' }"></div>
app.directive('demo', (el, binding) => {
console.log(binding.value.color); // "white"
console.log(binding.value.text); // "hello!"
});
实战案例
常见自定义指令场景:
- v-permission:根据用户权限列表控制 DOM 元素显示
- v-time:显示相对时间,如「XX 秒前」「XX 分前」「XX 小时前」「20XX-XX-XX」
插件:独立功能模块的封装
Q: Vue 中插件的作用是什么?如何制作和使用一个插件?
A: 插件(Plugin)是一种可选的独立模块,可以添加特定功能或特性而无需修改主程序代码。Vue Router、Pinia、ElementPlus 等都是以插件形式使用的:
const app = createApp();
app.use(router).use(pinia).use(ElementPlus).mount('#app');
制作插件
插件有两种形态:
- 一个拥有
install方法的对象:
const myPlugin = {
install(app, options) {
// 配置此应用
},
};
- 直接是一个安装函数:
const install = function (app, options) {};
安装方法接收两个参数——app(应用实例)和 options(传入的额外选项)。
插件能做什么
Vue 插件可以实现以下增强:
- 通过
app.component和app.directive注册全局组件或自定义指令 - 通过
app.provide注入资源到整个应用 - 向
app.config.globalProperties添加全局实例属性或方法 - 组合上述多种功能(如 vue-router)
实战:自定义组件库
import Button from './Button.vue';
import Card from './Card.vue';
import Alert from './Alert.vue';
const components = [Button, Card, Alert];
const myPlugin = {
install(app, options) {
components.forEach((com) => {
app.component(com.name, com);
});
},
};
export default myPlugin;
实战:全局错误处理插件
企业级应用中,经常需要一个全局错误处理和日志记录插件,目标包括:
- 捕获全局 Vue 错误和未处理的 Promise 错误
- 将错误信息记录到控制台或发送到远程日志服务器
- 提供 Vue 组件用于显示最近的错误日志
Transition:进入和离开的动画
Q: Transition 组件做了什么?不使用它能不能实现动画?
A: 当然可以不使用 Transition。但 Transition 的核心价值在于自动控制一组特定样式类的挂载和移除,让模板保持清爽。虽然具体的动画样式仍需要自己写,但 Transition 负责在正确的时间点挂载和移除样式类。
不使用 Transition 时:需要手动管理 CSS 类的切换,模板和逻辑耦合。
使用 Transition 时:Vue 自动在以下时间点操作样式类:
- 元素插入前:挂载
v-enter-from和v-enter-active - 元素插入后:移除
v-enter-from,挂载v-enter-to - 过渡结束后:移除所有辅助样式类
整个过程对应的 6 个样式类分为两大阶段:
| 阶段 | 样式类 | 作用 |
|---|---|---|
| 进入 | v-enter-from |
进入的开始状态 |
| 进入 | v-enter-active |
进入的过渡过程 |
| 进入 | v-enter-to |
进入的结束状态 |
| 离开 | v-leave-from |
离开的开始状态 |
| 离开 | v-leave-active |
离开的过渡过程 |
| 离开 | v-leave-to |
离开的结束状态 |
基础示例
<template>
<button @click="show = !show">切换</button>
<Transition>
<div v-if="show">
<h1>动画</h1>
<p>淡入淡出</p>
</div>
</Transition>
</template>
<style scoped>
.v-enter-active,
.v-leave-active { transition: opacity 1s; }
.v-enter-from,
.v-leave-to { opacity: 0; }
.v-enter-to,
.v-leave-from { opacity: 1; }
</style>
过渡效果命名
通过 name 属性自定义前缀:
<Transition name="fade">
</Transition>
也可以通过 props 直接指定自定义类名:enter-from-class、enter-active-class、enter-to-class、leave-from-class、leave-active-class、leave-to-class。
常用属性
| 属性 | 作用 |
|---|---|
appear |
初始渲染时也应用过渡 |
mode="out-in" |
旧元素先离开,新元素再进入(推荐) |
mode="in-out" |
新元素先进入,旧元素再离开 |
搭配 animation 使用
也可以在 *-enter/leave-active 中使用 CSS animation:
.bounce-enter-active { animation: bounce-in 1s; }
.bounce-leave-active { animation: bounce-in 1s reverse; }
@keyframes bounce-in {
0% { transform: scale(0); }
50% { transform: scale(1.5); }
100% { transform: scale(1); }
}
使用 key 触发过渡
当仅仅是文本内容变化而非元素的进入/离开时,过渡不会触发。此时添加 key 即可:
<Transition name="fade" mode="out-in">
<p :key="message">{{ message }}</p>
</Transition>
JS 钩子
除了 CSS 方式,Transition 还提供了 JS 钩子,在动画控制上更加灵活:
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<p v-if="show">Hello World</p>
</Transition>
function beforeEnter(el) {
el.style.opacity = 0;
el.style.transform = 'translateY(-20px)';
}
function enter(el, done) {
setTimeout(() => {
el.style.transition = 'all 1s';
el.style.opacity = 1;
el.style.transform = 'translateY(0)';
done(); // 通知 Vue 过渡完成
}, 0);
}
function leave(el, done) {
el.style.transition = 'all 1s';
el.style.opacity = 0;
el.style.transform = 'translateY(-20px)';
setTimeout(() => done(), 1000);
}
done 回调的作用是通知 Vue 过渡已完成,让 Vue 继续处理 DOM 更新,也支持在异步操作完成后再调用。
JS 钩子的优势:
- 精确控制过渡效果
- 处理异步操作
- 动态计算和条件逻辑
- 与第三方动画库(如 GSAP)集成
TransitionGroup:多元素过渡
Q: Transition 已经可以处理单个元素的动画,那 TransitionGroup 解决什么问题?
A: TransitionGroup 用于解决多个元素的过渡问题。Transition 下面只能有一个根元素,而且要求元素有进入/离开行为。对于 v-for 列表的增删操作,Transition 不会生效——因为 Transition 下是稳定的容器元素,进出的是列表内部元素。
只需将 Transition 改为 TransitionGroup:
<TransitionGroup name="fade" tag="ul">
<li v-for="item in items" :key="item" class="box">{{ item }}</li>
</TransitionGroup>
与 Transition 的区别
| 特性 | Transition | TransitionGroup |
|---|---|---|
| 渲染容器元素 | 必须有一个根元素 | 默认不渲染容器,可通过 tag 指定 |
| 过渡模式 mode | 支持 in-out / out-in |
不可用(非互斥元素) |
| key | 可选 | 每个元素必须有一个唯一 key |
| CSS 过渡 class | 应用在容器元素上 | 应用在列表内的每个元素上 |
Teleport:穿越 DOM 的传送门
Q: Teleport 解决什么实际问题?
A: Teleport 可以将组件内部的一部分模板「传送」到该组件 DOM 结构外层的任意位置。最典型的场景是模态框。
模态框的逻辑(开关状态)应该和触发按钮放在同一组件中,但 DOM 结构上应该渲染在 <body> 的最外层以避免样式污染。如果在组件内部渲染模态框,会面临两个问题:
position: fixed的定位会受祖先元素的transform、perspective或filter属性影响z-index受限于容器元素,可能被其他元素覆盖
使用 Teleport 后:
<template>
<button @click="open = true">打开模态框</button>
<Teleport to="body">
<div v-if="open" class="modal">
<p>模态框内容</p>
<button @click="open = false">关闭</button>
</div>
</Teleport>
</template>
通过 to 属性指定传送到哪个 DOM 节点。这样模态框在 DOM 结构上位于 <body> 直接子级,避免了所有祖先元素的样式干扰,同时保持了逻辑的内聚性。
总结
本文覆盖了 Vue3 中的五个进阶特性。自定义指令专注于底层 DOM 操作的复用,配合完整的钩子体系和 binding 参数可实现复杂的指令逻辑;插件提供了独立功能模块的标准封装方式,通过 install 方法注册全局能力;Transition 通过自动管理 6 个 CSS 样式类的挂载和移除来简化动画实现,JS 钩子则提供了更灵活的控制;TransitionGroup 填补了多元素列表过渡的空白,CSS 过渡类直接应用在每个列表元素上而非容器;Teleport 解决了模态框等场景中 DOM 结构受限的问题,将模板渲染到任意位置而不破坏组件逻辑的内聚性。合理组合这些特性可以构建出更优雅的 Vue3 应用。