讲解
内置指令(v-if、v-for、v-model)覆盖的是通用场景,自定义指令留给「需要直接操作 DOM 的可复用行为」:v-focus 自动聚焦、v-click-outside 点击元素外部时触发、v-copy 一键复制、v-lazy 图片懒加载。这些行为的共性是:跟业务数据无关、需要摸真实 DOM、多个组件都会用——抽成指令比在每个组件里写 onMounted 干净得多。
自定义指令是一组钩子函数:mounted(元素插入 DOM 时)、updated(绑定值变化后)、unmounted(元素移除时)最常用。指令通过 binding 拿参数:binding.value 是 v-copy="text" 里的 text,binding.arg 是 v-tooltip:top 里的 'top',binding.modifiers 是 v-lazy.once 里的 { once: true }。
在 <script setup> 里局部注册指令有个约定:定义一个名为 vXxx 的 const,模板里就能用 v-xxx:const vFocus = { mounted: (el) => el.focus() }。全局注册则在 app 上:app.directive('copy', {...})。指令的生命周期钩子里拿到的 el 是真实 DOM 元素,可以自由调用 DOM API——这是 Vue 官方留的命令式出口,但请只用在「纯 DOM 行为」上,业务逻辑仍然属于组件。
示例
实现 v-click-outside 的核心判定逻辑(「点击目标是否在元素内部」),这是下拉菜单/弹窗组件的基石(在本教程构建时被真实执行):
import assert from 'node:assert/strict';
// 迷你 DOM 树节点
function el(tag, children = []) {
const node = { tag, children, parent: null };
for (const c of children) c.parent = node;
return node;
}
// contains:target 是否在 container 内部(含自身)
function contains(container, target) {
let node = target;
while (node) {
if (node === container) return true;
node = node.parent;
}
return false;
}
// 构造:body > dropdown > menu > item;body > outside
const item = el('li');
const menu = el('ul', [item]);
const dropdown = el('div', [menu]);
const outside = el('p');
const body = el('body', [dropdown, outside]);
// 点击菜单内部 → 不关闭;点击外部 → 关闭
let closed = false;
function onDocumentClick(target) {
if (!contains(dropdown, target)) closed = true;
}
onDocumentClick(item); // 点了菜单项
assert.strictEqual(closed, false);
onDocumentClick(outside); // 点了外部
assert.strictEqual(closed, true);
// 指令参数解析演示:v-tooltip:top.once
const binding = { value: '提示内容', arg: 'top', modifiers: { once: true } };
assert.strictEqual(binding.arg, 'top');
assert.strictEqual(binding.modifiers.once, true);
console.log('点击菜单内部 → 保持打开;点击外部 → closed =', closed);
console.log('指令参数解析: 位置 =', binding.arg, ', 修饰符 =', Object.keys(binding.modifiers).join(','));
两个真实指令:
<script setup>
import { ref } from 'vue';
// <script setup> 里:名为 vXxx 的 const 即局部指令 v-xxx
const vFocus = {
mounted: (el) => el.focus(),
};
const vClickOutside = {
mounted(el, binding) {
el._handler = (e) => {
if (!el.contains(e.target)) binding.value(); // 点外部时调用传入的函数
};
document.addEventListener('click', el._handler);
},
unmounted(el) {
document.removeEventListener('click', el._handler); // 记得清理
},
};
const open = ref(false);
</script>
<template>
<input v-focus placeholder="自动聚焦" />
<div v-if="open" v-click-outside="() => (open = false)">
点我外部就关闭
</div>
<button @click="open = true">打开</button>
</template>
常见坑
- 指令里泄漏监听:mounted 里 addEventListener,unmounted 里必须对称 removeEventListener,否则每次挂载多一份监听。
- 把业务逻辑塞进指令:指令管 DOM 行为,「复制成功后发请求记录」这类逻辑留在组件里,指令只抛事件。
- updated 钩子里无限更新:指令的 updated 里改绑定值相关的状态可能触发循环,注意出口条件。
- 忘记 binding.value 是表达式结果:
v-copy="text"传的是 text 的值,不是字符串 'text';想监听变化要在 updated 里比对 oldValue。 - 给 v-for 的子项用指令却不处理更新:列表重排时 updated 触发时机微妙,复杂场景改用组件封装更稳。
小结
自定义指令 = 可复用的 DOM 行为(钩子 + binding 三件套);局部注册用 vXxx 命名约定;业务逻辑别进指令。下一章关注应用长大后的头等大事:性能优化。