自定义指令:底层 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');

全局注册还有一个简化写法——第二个参数直接传入函数,这会在 mountedupdated 时都执行:

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 包含 valueoldValueargmodifiersinstancedir 等属性的对象
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');

制作插件

插件有两种形态:

  1. 一个拥有 install 方法的对象:
const myPlugin = {
  install(app, options) {
    // 配置此应用
  },
};
  1. 直接是一个安装函数:
const install = function (app, options) {};

安装方法接收两个参数——app(应用实例)和 options(传入的额外选项)。

插件能做什么

Vue 插件可以实现以下增强:

  1. 通过 app.componentapp.directive 注册全局组件或自定义指令
  2. 通过 app.provide 注入资源到整个应用
  3. app.config.globalProperties 添加全局实例属性或方法
  4. 组合上述多种功能(如 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;

实战:全局错误处理插件

企业级应用中,经常需要一个全局错误处理和日志记录插件,目标包括:

  1. 捕获全局 Vue 错误和未处理的 Promise 错误
  2. 将错误信息记录到控制台或发送到远程日志服务器
  3. 提供 Vue 组件用于显示最近的错误日志

Transition:进入和离开的动画

Q: Transition 组件做了什么?不使用它能不能实现动画?

A: 当然可以不使用 Transition。但 Transition 的核心价值在于自动控制一组特定样式类的挂载和移除,让模板保持清爽。虽然具体的动画样式仍需要自己写,但 Transition 负责在正确的时间点挂载和移除样式类。

不使用 Transition 时:需要手动管理 CSS 类的切换,模板和逻辑耦合。

使用 Transition 时:Vue 自动在以下时间点操作样式类:

  • 元素插入前:挂载 v-enter-fromv-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 离开的结束状态
Transition样式类

基础示例

<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-classenter-active-classenter-to-classleave-from-classleave-active-classleave-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 钩子的优势:

  1. 精确控制过渡效果
  2. 处理异步操作
  3. 动态计算和条件逻辑
  4. 与第三方动画库(如 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> 的最外层以避免样式污染。如果在组件内部渲染模态框,会面临两个问题:

  1. position: fixed 的定位会受祖先元素的 transformperspectivefilter 属性影响
  2. 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 应用。