VueUse createDisposableDirective 深度解析:编写自动销毁的 Vue 3 自定义指令
VueUse createDisposableDirective 深度解析:编写自动销毁的 Vue 3 自定义指令
createDisposableDirective 是 VueUse 共享工具库中专门用于编写"可自动销毁指令"(disposable directive)的工厂函数:它接收一个普通的 Vue 自定义指令定义,并将其 mounted 钩子中创建的响应式副作用(事件监听、watch、组合式函数产生的内部 effect 等)自动纳入一个 EffectScope 作用域,当指令随元素卸载(unmounted)时统一停止并释放。本文以 createDisposableDirective/index.md 为骨架,结合 createDisposableDirective/index.ts 的源码实现与仓库内多个真实指令用例,讲解其原理、API 用法与实战技巧。读完本文,你将掌握如何用一行工厂函数封装出"零手动清理"的 Vue 3 自定义指令。
一、背景:自定义指令中的副作用泄漏问题
Vue 3 自定义指令通过生命周期钩子与 DOM 交互,最常见的是 mounted 与 unmounted。直接编写时,开发者常常需要在 mounted 中创建事件监听器、ResizeObserver、IntersectionObserver 或调用 VueUse 的组合式函数(如 useMouse、useScroll),并在 unmounted 中手动一一清除:
const vExample = {
mounted(el, binding) {
const handler = () => { /* ... */ }
window.addEventListener('scroll', handler)
el._handler = handler // 需要手动记住引用
},
unmounted(el) {
window.removeEventListener('scroll', el._handler) // 手动清理,容易遗漏
},
}
这种模式存在两个痛点:
- 清理极易遗漏:每新增一个副作用,就要在
unmounted中对应添加一行清理代码,钩子越多越容易漏; - 组合式函数内部副作用不可见:VueUse 的组合式函数(如
useMouse、useScroll)内部会创建watch、事件监听等 effect,外部无法直接"看到"它们来手动清理。
createDisposableDirective 正是为解决这两点而生的工具,它利用 Vue 3 的 EffectScope 机制,把 mounted 中产生的所有响应式副作用自动登记、统一销毁。
二、核心原理:EffectScope 作用域自动跟踪
2.1 Vue 3 的 EffectScope
effectScope 是 Vue 3.2+ 提供的响应式作用域 API,用于将一批响应式 effect(watch、computed、watchEffect 等)收集到同一个"作用域"中,随后通过 scope.stop() 一次性全部停止。这正是组合式函数"自动清理"能力的基础。
2.2 createDisposableDirective 的实现
源码位于 createDisposableDirective/index.ts,核心逻辑非常精炼:
import type { DirectiveBinding, EffectScope, FunctionDirective, ObjectDirective, VNode } from 'vue'
import { effectScope } from 'vue'
type originDirective<H, V, A> = FunctionDirective<H, V, string, A> | ObjectDirective<H, V, string, A>
export function createDisposableDirective<H extends HTMLElement, V, A = any>(origin: originDirective<H, V, A> = {}): originDirective<H, V, A> {
function isFunc(fn: unknown) {
return typeof fn === 'function'
}
const normalisedOrigin = isFunc(origin) ? { mounted: origin, updated: origin } : origin
const { mounted, unmounted } = normalisedOrigin
if (!isFunc(mounted))
return origin
const scopeWeakMap = new WeakMap<H, EffectScope>()
return {
...normalisedOrigin,
mounted(el, binding, vNode, prevNode) {
const scope = scopeWeakMap.get(el) ?? effectScope()
scopeWeakMap.set(el, scope)
scope.run(() => {
mounted?.(el, binding, vNode, prevNode)
})
},
unmounted(el, binding, vNode, prevNode) {
scopeWeakMap.get(el)?.stop()
scopeWeakMap.delete(el)
if (isFunc(unmounted)) {
unmounted(el, binding, vNode, prevNode)
}
},
}
}
关键设计点(对应源码行号 index.ts#L25-L43):
scopeWeakMap(WeakMap<H, EffectScope>):以指令绑定的 DOM 元素为键,保存其专属的EffectScope。使用WeakMap而非普通Map,元素被 GC 回收时 scope 引用也会随之释放,不会造成内存泄漏;mounted钩子:获取(不存在则创建)元素对应的 scope,并在scope.run(...)内部调用原始mounted逻辑。这样,mounted中执行的所有watch、useMouse()等产生的 effect 都自动归属于该 scope;unmounted钩子:调用scope.stop()一次性停止该元素上全部副作用,随后scopeWeakMap.delete(el)清理引用,再转发给原始unmounted(如果有)。这正是原文档中"useMouse的事件监听会在指令卸载时自动移除"一句的底层机制。
2.3 对两种指令形式的归一化
Vue 指令既可以是对象形式(ObjectDirective),也可以是纯函数形式(FunctionDirective,此时函数同时作为 mounted 与 updated)。源码通过 normalisedOrigin(index.ts#L18)统一处理:
- 若传入的是函数,自动展开为
{ mounted: origin, updated: origin }; - 若传入的对象没有
mounted钩子,则原样返回(此时无副作用需要管理,直接透传,见 index.ts#L22-L23); - 返回的对象通过
...normalisedOrigin保留了updated、beforeUnmount等所有原始钩子,只对mounted与unmounted做了包装增强。
三、API 签名与参数说明
根据 index.md 的类型声明 与源码,完整签名如下:
type originDirective<H, V, A> =
FunctionDirective<H, V, string, A> | ObjectDirective<H, V, string, A>
export declare function createDisposableDirective<
H extends HTMLElement,
V,
A = any,
>(origin?: originDirective<H, V, A>): originDirective<H, V, A>
| 参数/类型 | 说明 |
|---|---|
origin(可省略,默认 {}) |
普通 Vue 指令定义,可为对象指令或函数指令;函数形式会被视为 mounted + updated |
H extends HTMLElement |
绑定元素的类型,默认约束为 HTMLElement |
V |
binding.value 的值类型(如回调函数或 [handler, options] 元组) |
A = any |
指令参数(binding.arg)类型 |
| 返回值 | 与 origin 同类型的指令定义,额外增强 mounted / unmounted |
该函数标记了 @__NO_SIDE_EFFECTS__(见 index.ts#L11),声明其调用本身不产生副作用,便于打包器进行 tree-shaking 优化。
四、基础用法:原文档示例
原文档给出了最小可用示例:创建一个在元素挂载后跟踪鼠标位置、并在卸载时自动清理的指令。
import { useMouse } from '@vueuse/core'
import { createDisposableDirective } from '@vueuse/shared'
export const VDirective = createDisposableDirective({
mounted(el, binding) {
const value = binding.value
if (typeof value === 'function') {
// `useMouse` event listener will be removed automatically when directive is unmounted
const { x, y } = useMouse()
watch(x, val => value(val))
}
}
})
要点解读:
binding.value通常是模板中传入的回调函数,这里先做类型守卫(typeof value === 'function')再使用;useMouse()内部创建的全局鼠标事件监听、watch(x, ...)注册的响应式依赖,都会在指令卸载时被scope.stop()一并销毁——无需手写任何清理代码;- 指令导出后即可在组件中注册使用(见下文第六节)。
五、仓库内真实用例:VueUse 核心指令是如何封装出来的
createDisposableDirective 不是孤立的玩具工具,它正是 VueUse 核心包中多个公开指令的底层骨架。从源码中可以找到大量"一行工厂调用"封装出的指令:
5.1 vMouseInElement
useMouseInElement/directive.ts 将 useMouseInElement 的状态以响应式对象形式推送给回调:
export const vMouseInElement = createDisposableDirective<
HTMLElement,
BindingValueFunction | BindingValueArray
>(
{
mounted(el, binding) {
const [handler, options] = (typeof binding.value === 'function' ? [binding.value, {}] : binding.value) as BindingValueArray
const state = reactiveOmit(reactive(useMouseInElement(el, options)), 'stop')
watch(state, val => handler(val))
},
},
)
这里同时展示了指令值支持两种形态的常见约定:直接传回调函数,或传 [handler, options] 元组(便于携带额外配置)。
5.2 vScroll
useScroll/directive.ts 中,useScroll 的滚动/停止回调被桥接到绑定值传入的处理函数:
const state = useScroll(el, {
onScroll() {
handler(state)
},
onStop() {
handler(state)
},
})
指令卸载时 useScroll 注册的滚动监听会被自动移除。
5.3 vElementBounding 与 vIntersectionObserver
- useElementBounding/directive.ts:
watch元素的height/bottom/left/right/top/width/x/y九个尺寸量,任一变化即回调一次; - useIntersectionObserver/directive.ts:直接把
binding.value作为IntersectionObserver回调透传给useIntersectionObserver,元素卸载时观察器自动断开。
这些用例印证了该工具的设计范式:在 mounted 里放心地创建任何响应式副作用,销毁交给 createDisposableDirective 统一处理。
六、在组件模板中使用指令
以仓库内实际存在的 vScroll 为例,指令经过 createDisposableDirective 包装后与普通指令的注册方式完全一致:
// 注册(局部)
import { vScroll } from '@vueuse/core'
export default {
directives: { scroll: vScroll },
}
<template>
<div v-scroll="handleScroll">
<!-- 滚动状态通过回调实时更新 -->
</div>
</template>
<script setup>
function handleScroll(state) {
// state.x / state.y 等滚动信息
}
</script>
当承载指令的元素被移除(v-if 切换、路由跳转导致组件销毁等)时,Vue 会触发 unmounted 钩子,作用域内所有副作用被一次停止。
七、测试验证:行为契约由单测锁定
该工具的对外行为在 createDisposableDirective/index.test.ts 中有明确约束:
- 对象指令:返回的指令同时拥有
mounted与unmounted两个自有属性; - 函数指令:会被归一化为同时拥有
mounted与updated的指令(因为函数形式同时承担两个钩子职责); - 以上即"应返回 disposable directive"与"应返回普通指令"两组断言的核心,保证了即使传入的函数指令也能获得作用域管理能力。
八、适用场景与注意事项
推荐场景:
- 编写需要在
mounted中调用 VueUse 组合式函数(useMouse、useScroll、useIntersectionObserver、useElementBounding等)的指令; - 需要大量
watch、事件监听,且不愿在unmounted中逐一清理的指令; - 希望指令定义保持声明式、可读、可复用的团队组件库开发。
注意事项:
- 仅跟踪
mounted中的副作用:scope.run只包裹了原始mounted执行过程。若你在updated、beforeMount等其他钩子中创建副作用,需要自行管理(源码 index.ts#L29-L43 的包装范围决定了这一点); - 同一元素作用域复用:
mounted每次执行会先取已存在的 scope 再复用,而非重复创建(scopeWeakMap.get(el) ?? effectScope()),多个挂载周期之间不会产生作用域泄漏; - 透传行为:没有
mounted钩子的指令会原样返回,不引入任何额外开销; - 引入路径:该工具属于
@vueuse/shared包,从 packages/shared/index.ts 统一导出,同时可经@vueuse/core间接使用。
九、小结
createDisposableDirective 用极小的 API 面(一个工厂函数、两个类型参数)解决了自定义指令生命周期管理的普遍难题:它以 EffectScope + WeakMap 为底层实现,把"副作用创建"与"副作用销毁"彻底解耦,让指令作者专注业务逻辑、把清理工作交给框架。无论是独立封装业务指令,还是像 vScroll、vMouseInElement 这样构建可复用的 VueUse 风格指令,它都是值得放进工具箱的实用工具。