返回正文图片懒加载指令
Are you an LLM? You can read better optimized documentation at /zh-CN/blog/2025-08/vue-directive.md for this page in Markdown format
Vue3自定义指令的探究与实践应用
自定义指令的本质与基础实现
Vue3自定义指令本质上是一种特殊的代码复用机制,专注于直接操作DOM。与组件系统不同,自定义指令更加低级,它直接与DOM元素交互。
基础示例:实现一个自动聚焦的指令
ts
// 在setup函数外部定义自定义指令
const vFocus = {
mounted: (el) => el.focus()
}
// 在组件中注册局部指令
export default {
setup() {
// ...
},
directives: {
focus: vFocus // v-focus
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
全局注册方式:
ts
const app = createApp({})
// 全局注册
app.directive('focus', { // v-focus
mounted: (el) => el.focus()
})
1
2
3
4
5
2
3
4
5
自定义指令的生命周期钩子函数
Vue3的自定义指令完全重写了API,与Vue2相比有显著变化,钩子函数与组件生命周期保持一致:
created
: 在元素的attribute
或事件监听器应用之前调用beforeMount
: 在元素被挂载到DOM
之前调用mounted
: 元素被插入到父元素,且父元素也mounted
之后调用(保证元素在文档中)beforeUpdate
: 在包含组件的VNode
更新之前调用updated
: 包含组件的VNode
及其子组件的VNode
更新后调用beforeUnmount
: 在卸载元素之前调用unmounted
: 元素卸载后调用
深层示例:使用多个钩子实现拖拽功能
ts
app.directive('drag', {
created(el, binding) {
// 可在这里初始化必要的数据
el._dragData = {
dragging: false,
startX: 0,
startY: 0,
startLeft: 0,
startTop: 0
}
// 确保元素可定位
if (getComputedStyle(el).position === 'static') {
el.style.position = 'relative'
}
// 根据binding.value动态设置行为
// binding.value 可以是一个对象,例如 { restrictVertical: true }
el._restrictVertical = binding.value?.restrictVertical || false
},
mounted(el) {
const dragData = el._dragData
// 鼠标按下事件处理
el._onMousedown = function(e) {
if (e.button !== 0) return // 仅处理左键点击
dragData.dragging = true
dragData.startX = e.clientX
dragData.startY = e.clientY
// parseFloat gracefully handles "px" and other units
dragData.startLeft = parseFloat(el.style.left) || 0
dragData.startTop = parseFloat(el.style.top) || 0
document.addEventListener('mousemove', el._onMousemove)
document.addEventListener('mouseup', el._onMouseup)
// 阻止默认行为和冒泡
e.preventDefault()
e.stopPropagation()
}
// 鼠标移动事件处理
el._onMousemove = function(e) {
if (!dragData.dragging) return
const dx = e.clientX - dragData.startX
const dy = e.clientY - dragData.startY
// 根据限制条件应用位移
if (!el._restrictVertical) {
el.style.left = `${dragData.startLeft + dx}px`
}
el.style.top = `${dragData.startTop + dy}px`
}
// 鼠标释放事件处理
el._onMouseup = function() {
if (!dragData.dragging) return; // 避免重复执行
dragData.dragging = false
document.removeEventListener('mousemove', el._onMousemove)
document.removeEventListener('mouseup', el._onMouseup)
}
el.addEventListener('mousedown', el._onMousedown)
// 添加额外安全措施:如果鼠标在文档范围外松开(例如移出浏览器窗口再松开)
// 'mouseleave' 监听 document 可能过于宽泛,且在某些情况下可能不会如预期触发
// 更好的方式是确保 mouseup 在 document 上总是能移除 mousemove
// 此处保留原逻辑,但提示开发者注意其适用场景和潜在问题
document.addEventListener('mouseleave', el._onMouseup)
},
updated(el, binding) {
// 当绑定值变化时更新配置
if (binding.value && binding.oldValue && binding.value.restrictVertical !== binding.oldValue.restrictVertical) {
el._restrictVertical = binding.value.restrictVertical || false
}
},
beforeUnmount(el) {
// 清理所有事件监听器
el.removeEventListener('mousedown', el._onMousedown)
document.removeEventListener('mousemove', el._onMousemove) // 确保即使拖拽未结束也被移除
document.removeEventListener('mouseup', el._onMouseup) // 确保即使拖拽未结束也被移除
document.removeEventListener('mouseleave', el._onMouseup) // 清理mouseleave监听
// 清理自定义属性,帮助垃圾回收
delete el._dragData
delete el._restrictVertical
delete el._onMousedown
delete el._onMousemove
delete el._onMouseup
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
指令参数的深入理解与应用
自定义指令钩子接收以下参数:
el
: 指令绑定的元素binding
: 包含多个属性的对象value
: 传递给指令的值oldValue
: 先前的值,仅在beforeUpdate
和updated
中可用arg
: 传递给指令的参数 (例如v-mydirective:foo
中的foo
)modifiers
: 包含修饰符的对象 (例如v-mydirective.bar
中的{ bar: true }
)instance
: 使用该指令的组件实例dir
: 指令定义对象
实际业务案例:权限控制指令
ts
// 建议:权限标识通常定义为常量或枚举,以提高可维护性
// const PERMISSIONS = { USER_EDIT: 'user:edit', USER_VIEW: 'user:view' };
app.directive('permission', {
created(el, binding, vnode) {
// 获取当前用户权限 - 此处假设已经定义了userpermissionStore
// const permissionStore = userpermissionStore();
// 或者通过 provide/inject 获取
const { instance } = binding;
const userPermissions = instance?.userPermissions || ['user:view', 'article:read']; // 示例权限列表
// 分析指令参数
const { value, arg, modifiers } = binding;
const requiredPermission = value; // 例如 'user:edit'
const mode = arg || 'visible'; // 默认为可见性控制,可选 'disable', 'remove'
const { strict } = modifiers; // 是否启用严格匹配模式
// 检查权限逻辑
let hasPermission;
if (strict) {
hasPermission = userPermissions.includes(requiredPermission);
} else {
// 非严格模式下,'user' 可能匹配 'user:edit', 'user:delete' 等
hasPermission = userPermissions.some(p => p.startsWith(requiredPermission));
}
if (!hasPermission) {
// 根据模式采取不同措施
if (mode === 'visible') {
el.style.display = 'none';
} else if (mode === 'disable') {
el.disabled = true;
el.classList.add('disabled-permission'); // 添加特定类名以便样式化
// 可以考虑设置 aria-disabled 属性
el.setAttribute('aria-disabled', 'true');
} else if (mode === 'remove') {
// 标记为稍后删除,不能在created阶段直接删除,因为元素可能还未完全插入DOM
el._scheduleRemoveByPermission = true;
}
}
},
mounted(el, binding) {
// 处理需要从DOM中移除的元素
if (el._scheduleRemoveByPermission) {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
delete el._scheduleRemoveByPermission; // 清理标记
}
},
// 可选:如果权限或指令参数可能动态变化,则需要 updated 钩子
updated(el, binding) {
// 如果 requiredPermission, mode, strict 或 userPermissions 可能变化,
// 则需要在此处重新执行权限检查和相应的DOM操作。
// 为简化示例,此处未完全实现动态更新逻辑。
// 注意:频繁的DOM操作(如重新创建或删除元素)应谨慎处理,考虑性能影响。
const { value, oldValue, arg, modifiers, instance } = binding;
if (value !== oldValue) { // 简化的检查,实际可能更复杂
// 重新执行 created 中的逻辑或类似逻辑
// console.log('Permission directive updated, re-evaluating...');
// 注意:直接调用 created 中的逻辑可能不完全适用,需要适配 updated 场景
// 这里需要重新获取权限、比较新旧值,并应用变更
// 例如,如果之前隐藏了,现在有权限了,就需要显示
// 如果之前移除了,则无法再操作 el,除非重新创建,这通常不推荐在指令中做
}
}
})
// 使用示例
// <button v-permission:disable.strict="'user:edit'">编辑用户</button>
// // <div v-permission="'user:view'">查看区域</div>
// <div v-permission:remove="'admin:settings'">管理员设置(无权限则移除)</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
自定义指令与组件的关系及使用限制
为什么内置指令可用于组件而自定义指令不建议?
Vue3中,像 v-if
、v-for
这样的内置指令可以在组件上使用,而自定义指令在组件上使用会触发警告。这背后有深层次的技术原因:
内置指令的特殊处理
内置指令如 v-if
、v-for
、v-model
在Vue的编译阶段被特殊处理:
- 在编译阶段,Vue的模板编译器将这些指令转换为特定的渲染函数代码
- 对于
v-if
,编译器生成条件渲染逻辑 - 对于
v-for
,编译器生成列表渲染逻辑 - 对于
v-model
,编译器生成双向绑定的特殊代码
这些转换发生在编译层面,而非运行时指令处理机制。
自定义指令的处理机制
自定义指令则完全依赖运行时指令处理机制:
- 自定义指令主要针对DOM元素操作设计
- 当应用到组件时,指令会被应用到组件的根元素上
- 由于组件可能有多个根元素(Vue3 支持片段
Fragments
),或者根元素可能因条件渲染而变化,这会导致指令行为不一致或难以预测。
以下是Vue3自定义指令在组件上使用的源码处理部分(简化版示意逻辑):
ts
// Vue 3源码中处理自定义指令的简化逻辑示意
function applyDirectives(vnode, directives) {
// 检查是否是组件
if (vnode.shapeFlag & ShapeFlags.COMPONENT) { // 使用 shapeFlag 判断是否为组件
// 如果是组件并且有自定义指令
if (directives.length > 0) {
// Vue 3.2.20+ 对自定义指令用于组件的情况进行了调整,
// 默认情况下,自定义指令会被应用到组件的根HTML元素上 (如果只有一个根HTML元素)。
// 但对于多根节点组件或Fragment根节点的组件,行为可能不符合预期,
// 且官方依然不推荐这种用法,因为它破坏了组件的封装性。
console.warn(
`Custom directive used on component. Directives are ideally for DOM manipulation. ` +
`Consider alternative approaches like props or slots for component interaction. ` +
`Behavior on components with fragments or multiple root nodes can be unpredictable.`
);
}
// 即使发出警告,指令仍会尝试应用到组件的根DOM节点(如果明确的话)
// 但最佳实践是避免在组件上使用自定义指令
return;
}
// 处理元素节点上的指令
for (const directive of directives) {
// 应用指令逻辑...
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
深入分析自定义指令在组件上使用的问题
当自定义指令用于组件时,存在几个关键问题:
- 多根节点不确定性:Vue3支持多根节点组件 (
Fragments
)。此时指令应该作用于哪个根元素?行为变得不可预测。 - 生命周期管理复杂:组件有自己的生命周期和更新机制。指令的钩子函数直接操作DOM,可能与组件的更新机制冲突或产生非预期的副作用。
- 封装性被破坏:组件应视为一个黑盒,其内部DOM结构和行为应由组件自身管理。自定义指令直接操作组件内部的DOM元素,违反了组件的封装原则。
- 响应式更新隐患:当组件因为内部状态变化重新渲染时,特别是如果根元素发生变化,自定义指令可能无法正确更新或清理,导致内存泄漏或行为异常。
替代方案:更好的组件通信方式
针对需要在组件级别应用逻辑的场景,Vue提供了更合适的方案:
- Props: 通过父组件向子组件传递数据和行为配置。
- Slots: 允许父组件向子组件分发内容和结构,提供了更灵活的组合方式。
- Provide/Inject: 用于深层组件通信,允许祖先组件向其所有后代组件注入依赖。
- Composables (组合式函数): 是Vue3中复用有状态逻辑的首选方式,尤其适用于与DOM无关的逻辑,或需要跨组件共享的复杂行为。
实例比较:
ts
// 不推荐:使用自定义指令控制组件行为
// <MyComponent v-my-directive="config" />
// 推荐:通过props传递行为配置
// MyComponent.vue
// export default {
// props: {
// config: Object // 或者更具体的类型
// },
// setup(props) {
// // 根据 props.config 应用逻辑
// }
// }
// <MyComponent :config="config" />
// 或者使用composable实现复杂逻辑复用
// composables/useMyFeature.js
// import { ref, onMounted } from 'vue';
// export function useMyFeature(config) {
// const someState = ref('');
// onMounted(() => { /* ...基于config的逻辑... */ });
// return { someState };
// }
// 在组件中使用
// MyComponent.vue
// import { useMyFeature } from '@/composables/useMyFeature';
// export default {
// props: { config: Object },
// setup(props) {
// const { someState } = useMyFeature(props.config);
// return { someState };
// }
// }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
高级技巧:指令与响应式系统的结合
Vue3 的 Composition API
为指令提供了更强大的能力,可以在指令内部使用响应式API。
ts
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
// 创建一个使用Vue响应式系统的复杂指令
app.directive('responsive-height', {
created(el, binding) {
// 创建响应式引用
const height = ref(0) // 用于存储观察到的高度
// 将响应式数据和清理函数存储到元素上,确保在其他钩子中可访问
// 考虑使用Symbol作为键来避免潜在的命名冲突
const responsiveDataSymbol = Symbol('responsiveData');
el[responsiveDataSymbol] = {
height,
observer: null, // ResizeObserver实例将在mounted中创建
stopWatch: null // watch的停止函数
};
// 使用watch观察height响应式引用的变化
// 当 height.value 改变时,会执行回调
const stopWatch = watch(height, (newHeight) => {
// 触发用户通过binding.value提供的回调函数
if (typeof binding.value === 'function') {
binding.value(newHeight); // 将新的高度传递给回调
}
// 也可以直接在指令内部根据高度做一些事情,例如应用样式
// el.style.minHeight = `${newHeight}px`; // 示例:根据观察到的高度设置最小高度
});
// 存储watch的停止函数,以便在unmounted时清理
el[responsiveDataSymbol].stopWatch = stopWatch;
},
mounted(el, binding) {
const responsiveDataSymbol = Object.getOwnPropertySymbols(el).find(s => s.description === 'responsiveData');
if (!responsiveDataSymbol || !el[responsiveDataSymbol]) return;
const responsiveState = el[responsiveDataSymbol];
// 创建ResizeObserver实例来监听元素尺寸变化
responsiveState.observer = new ResizeObserver(entries => {
for (const entry of entries) {
// 更新响应式引用 height 的值
// 这会自动触发上面 watch 中的回调
responsiveState.height.value = entry.contentRect.height;
}
});
// 开始观察目标元素
responsiveState.observer.observe(el);
// 初始时也可以手动触发一次回调,如果需要的话
// if (typeof binding.value === 'function') {
// binding.value(el.offsetHeight);
// }
},
beforeUnmount(el) {
const responsiveDataSymbol = Object.getOwnPropertySymbols(el).find(s => s.description === 'responsiveData');
if (!responsiveDataSymbol || !el[responsiveDataSymbol]) return;
const responsiveState = el[responsiveDataSymbol];
// 停止观察
if (responsiveState.observer) {
responsiveState.observer.disconnect();
}
// 停止watch
if (responsiveState.stopWatch) {
responsiveState.stopWatch();
}
// 清理存储在元素上的数据
delete el[responsiveDataSymbol];
}
});
// 使用示例:
// <div v-responsive-height="handleHeightChange">...</div>
// methods: {
// handleHeightChange(newHeight) {
// console.log('Element height changed to:', newHeight);
// }
// }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
实际业务场景中的自定义指令应用与最佳实践
图片懒加载指令 v-lazy
ts
app.directive('lazy', {
created(el, binding) {
// 默认配置
const defaultConfig = {
loading: 'loading.gif', // 默认加载中占位图
error: 'error.jpg', // 默认加载失败占位图
noFallbackOnError: false // 加载失败时是否不显示错误图片
};
let lazyConfig = { ...defaultConfig };
if (typeof binding.value === 'string') {
lazyConfig.src = binding.value;
} else if (typeof binding.value === 'object' && binding.value !== null) {
lazyConfig = { ...defaultConfig, ...binding.value };
} else {
console.warn('[v-lazy] binding.value should be a string (image src) or an object with configuration.');
return; // 无效的绑定值,提前退出
}
el._lazy = {
...lazyConfig,
loaded: false, // 图片是否已加载
observer: null // IntersectionObserver 实例
};
// 初始设置加载中占位图
el.setAttribute('src', el._lazy.loading);
},
mounted(el) {
if (!el._lazy || !el._lazy.src) return; // 如果created中提前退出或src无效
const loadImage = () => {
if (el._lazy.loaded) return; // 防止重复加载
const img = new Image();
img.onload = () => {
el.src = el._lazy.src;
el._lazy.loaded = true;
// 可选:加载成功后移除或修改某些class
el.classList.remove('lazy-loading');
el.classList.add('lazy-loaded');
};
img.onerror = () => {
el._lazy.loaded = false; // 标记为未成功加载
if (!el._lazy.noFallbackOnError && el._lazy.error) {
el.src = el._lazy.error;
}
// 可选:加载失败后移除或修改某些class
el.classList.remove('lazy-loading');
el.classList.add('lazy-error');
};
img.src = el._lazy.src; // 开始加载真实图片
el.classList.add('lazy-loading'); // 添加加载中样式类
};
// 使用IntersectionObserver实现视口检测
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !el._lazy.loaded) {
loadImage();
// 图片开始加载后即可停止观察该元素
if (el._lazy.observer) { // 确保 observer 存在
el._lazy.observer.unobserve(el);
}
}
});
}, {
rootMargin: binding.arg || '50px' // 可通过 arg 配置 rootMargin,例如 v-lazy:100px
});
observer.observe(el);
el._lazy.observer = observer;
},
updated(el, binding) {
// 处理绑定值变化的情况
let newSrc = null;
if (typeof binding.value === 'string') {
newSrc = binding.value;
} else if (typeof binding.value === 'object' && binding.value !== null) {
newSrc = binding.value.src;
}
if (newSrc && newSrc !== el._lazy.src) {
// src 变化,重置状态并重新观察
el._lazy.src = newSrc;
el._lazy.loaded = false;
el.setAttribute('src', el._lazy.loading); // 重置为加载中图片
el.classList.remove('lazy-loaded', 'lazy-error');
// 如果observer存在且元素未被观察,则重新观察
if (el._lazy.observer) {
// 先取消之前的观察,再重新观察,确保使用的是最新的状态
el._lazy.observer.unobserve(el);
el._lazy.observer.observe(el);
}
}
// 也可以在这里处理 loading, error 图片等配置的动态更新
},
beforeUnmount(el) {
// 清理observer
if (el._lazy && el._lazy.observer) {
el._lazy.observer.unobserve(el); // 确保停止观察
el._lazy.observer.disconnect();
}
delete el._lazy; // 清理自定义属性
}
});
// 使用示例:
// <img v-lazy="'https://example.com/image.jpg'" />
// <img v-lazy="{ src: 'image.jpg', loading: 'custom-loader.gif', error: 'custom-error.png' }" />
// <img v-lazy:200px="{ src: 'image.jpg' }" /> ```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
点击外部关闭指令
ts
app.directive('click-outside', {
beforeMount(el, binding) {
if (typeof binding.value !== 'function') {
console.warn('[v-click-outside] binding.value must be a function.');
return;
}
el._clickOutsideHandler = (event) => {
// 检查点击是否在元素外部
// 同时确保 el 仍然在文档中,以防 el 被移除但事件监听器未清理
if (document.body.contains(el) && !(el === event.target || el.contains(event.target))) {
// 调用绑定值(函数)
binding.value(event);
}
};
// 使用捕获阶段以确保在某些情况下能优先触发
// 例如,如果内部元素阻止了事件冒泡
// 但要注意捕获阶段可能影响其他事件处理
// 对于大多数场景,冒泡阶段 (第三个参数为 false 或省略) 已足够
document.addEventListener('click', el._clickOutsideHandler, true);
},
unmounted(el) {
if (el._clickOutsideHandler) {
document.removeEventListener('click', el._clickOutsideHandler, true);
delete el._clickOutsideHandler;
}
}
});
// 使用示例:
// <div v-click-outside="closeDropdown">...</div>
// methods: {
// closeDropdown() { /* 关闭逻辑 */ }
// }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
无限滚动指令
ts
app.directive('infinite-scroll', {
mounted(el, binding) {
let options = {
distance: 10, // 触发回调的底部距离阈值 (px)
disabled: false, // 是否禁用无限滚动
callback: null, // 滚动到底部时触发的回调函数
// 可以增加 immediateCheck: true, // 是否在挂载时立即检查一次
// scrollTarget: el // 默认滚动目标是el自身,也可以指定父级可滚动元素
};
if (typeof binding.value === 'function') {
options.callback = binding.value;
} else if (typeof binding.value === 'object' && binding.value !== null) {
// 进行类型检查,确保 binding.value 是对象
options = { ...options, ...binding.value };
} else {
console.warn('[v-infinite-scroll] binding.value should be a function (callback) or an options object.');
return;
}
if (typeof options.callback !== 'function') {
console.warn('[v-infinite-scroll] options.callback must be a function.');
return;
}
const scrollTarget = options.scrollTarget || el; // 确定滚动事件监听的目标
const loadMore = () => {
// 如果指令已禁用或回调正在执行中 (通过外部控制的loading状态等)
if (options.disabled) return;
const targetEl = scrollTarget === window || scrollTarget === document.documentElement ? document.documentElement : scrollTarget;
const viewPortHeight = targetEl === document.documentElement ? window.innerHeight : targetEl.clientHeight;
const { scrollHeight, scrollTop } = targetEl;
// 当滚动条接近底部指定距离时触发回调
if (scrollHeight - scrollTop - viewPortHeight < options.distance) {
options.callback();
}
};
// 绑定滚动事件到指定的滚动目标
scrollTarget.addEventListener('scroll', loadMore);
// 存储引用和处理函数,用于更新和卸载
el._infiniteScroll = {
loadMore,
options,
scrollTarget // 存储滚动目标,以便正确移除监听器
};
// 可选:如果 immediateCheck 为 true,则在挂载后立即执行一次检查
// if (options.immediateCheck) {
// loadMore();
// }
},
updated(el, binding) {
if (!el._infiniteScroll) return;
const currentOptions = el._infiniteScroll.options;
let newOptions = {};
if (typeof binding.value === 'function') {
newOptions.callback = binding.value;
} else if (typeof binding.value === 'object' && binding.value !== null) {
newOptions = { ...binding.value };
} else {
// binding.value 类型错误,可以选择忽略更新或警告
return;
}
// 更新配置,特别是 disabled 和 callback
Object.assign(currentOptions, newOptions);
// 如果 scrollTarget 变化了(虽然不常见,但理论上可能),需要重新绑定事件
// 此处简化,假设 scrollTarget 不变
},
unmounted(el) {
if (el._infiniteScroll) {
const { loadMore, scrollTarget } = el._infiniteScroll;
scrollTarget.removeEventListener('scroll', loadMore);
delete el._infiniteScroll;
}
}
});
// 使用示例:
// <div v-infinite-scroll="loadMoreData" style="height:300px; overflow-y: auto;">...items...</div>
// <div v-infinite-scroll="{ callback: loadMoreData, distance: 50, disabled: isLoading }" style="..."></div>
// methods: {
// loadMoreData() { /* 加载更多数据的逻辑 */ }
// }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
自定义指令性能优化策略
避免频繁DOM操作
ts
// 低效方式:每次更新直接操作DOM
app.directive('highlight-inefficient', {
updated(el, binding) {
// 即使binding.value没有改变,此钩子也会在组件更新时触发
el.style.backgroundColor = binding.value;
}
});
// 优化方式:使用RAF和比对避免不必要更新
app.directive('highlight-optimized', {
mounted(el, binding) {
// 将数据和更新函数存储在el上,避免在updated中重复创建
el._highlightData = {
currentValue: binding.value, // 存储当前值
rafId: null, // requestAnimationFrame的ID
updateStyle: () => { // 实际更新DOM的函数
el.style.backgroundColor = el._highlightData.currentValue;
el._highlightData.rafId = null; // 清除ID
}
};
// 初始应用一次样式
el._highlightData.updateStyle();
},
updated(el, binding) {
// 仅当绑定值实际发生变化时才计划更新
if (binding.value !== binding.oldValue && binding.value !== el._highlightData.currentValue) {
el._highlightData.currentValue = binding.value;
// 使用requestAnimationFrame批量处理DOM更新
// 如果已有计划中的更新,先取消它
if (el._highlightData.rafId) {
cancelAnimationFrame(el._highlightData.rafId);
}
// 安排新的更新
el._highlightData.rafId = requestAnimationFrame(el._highlightData.updateStyle);
}
},
beforeUnmount(el) {
// 组件卸载前,取消任何待处理的requestAnimationFrame回调
if (el._highlightData && el._highlightData.rafId) {
cancelAnimationFrame(el._highlightData.rafId);
}
delete el._highlightData; // 清理存储的数据
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
减少内存泄漏风险
ts
// 存在内存泄漏风险的指令(示意)
app.directive('risky-listener', {
mounted(el) {
// 假设 handleGlobalEvent 是一个在指令外部定义的函数,或者是一个匿名函数
// 如果没有正确存储并移除监听器,会导致内存泄漏
const handleGlobalEvent = () => {
if (el.parentElement) { // 检查el是否还在DOM中
// console.log('Global event triggered on element:', el);
}
};
// 添加全局事件监听,但未在unmounted中移除
window.addEventListener('resize', handleGlobalEvent);
// el._someData = new Array(1000000).fill('large data'); // 大对象也可能导致问题
// 如果 handleGlobalEvent 是匿名函数或没有存储其引用,将无法在unmounted中移除
// el._handler = handleGlobalEvent; // 至少需要存储引用
}
// 缺少unmounted钩子进行清理,或者清理不当
});
// 优化后的指令,确保清理
app.directive('safe-listener', {
mounted(el) {
// 定义事件处理函数
el._safeHandlerData = {
handleResize: function() {
// 确保el仍然在文档中,或者根据业务逻辑决定是否处理
if (!document.body.contains(el)) {
// 如果元素已不在DOM中,可以考虑移除监听器(尽管unmounted是主要清理点)
// window.removeEventListener('resize', el._safeHandlerData.boundResize);
return;
}
// console.log('Safe resize event on element:', el.offsetWidth);
// 按需创建或使用数据
// if (!this.data) { this.data = /* ... */; }
}
};
// 绑定this并存储引用,以便能正确移除监听器
// 使用 .bind(el._safeHandlerData) 可以确保 handleResize 内部的 this 指向 el._safeHandlerData
// 或者,如果 handleResize 不需要特定的 this 上下文,可以直接传递函数引用
el._safeHandlerData.boundResize = el._safeHandlerData.handleResize.bind(el._safeHandlerData);
// 添加事件监听
window.addEventListener('resize', el._safeHandlerData.boundResize);
},
unmounted(el) {
// 清理事件监听
if (el._safeHandlerData && el._safeHandlerData.boundResize) {
window.removeEventListener('resize', el._safeHandlerData.boundResize);
}
// 清理存储在元素上的数据
delete el._safeHandlerData;
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
构建可复用的指令工厂函数
ts
// 指令工厂:创建可配置的节流(throttle)指令
function createThrottleDirective(defaultOptions = { delay: 300, event: 'click' }) {
return {
// 使用 mounted 钩子,因为通常事件监听在元素挂载后添加
mounted(el, binding) {
let handlerFunction = binding.value; // 事件处理函数
let directiveOptions = { ...defaultOptions };
// 处理不同的传值方式
if (typeof binding.value === 'object' && binding.value !== null) {
handlerFunction = binding.value.callback;
directiveOptions = { ...defaultOptions, ...binding.value };
}
if (typeof handlerFunction !== 'function') {
console.warn(`[v-throttle] Directive value or 'callback' in options object must be a function.`);
return; // 如果没有有效的处理函数,则不执行后续操作
}
const eventType = binding.arg || directiveOptions.event; // 事件类型,优先使用arg
const delay = directiveOptions.delay;
let lastCallTime = 0;
let throttledInProgress = false; // 标记节流是否正在等待执行
el._throttleData = {
// 节流处理函数
throttledHandler: function(event) {
if (throttledInProgress) return; // 如果已经在节流等待中,则忽略
const now = Date.now();
if (now - lastCallTime >= delay) {
lastCallTime = now;
handlerFunction.call(this, event); // 使用 call 保持 this 指向
} else {
// 如果时间未到,可以设置一个setTimeout来确保最后一次调用被执行(更接近debounce)
// 对于纯throttle,通常不处理这种情况,或者在第一次触发时立即执行,然后等待
// 此处为简单节流:在时间窗口内只执行一次
}
},
// 也可以使用另一种节流实现:确保在delay后执行
// throttledHandlerWithTimeout: function(event) {
// if (throttledInProgress) return;
// const now = Date.now();
// if (now - lastCallTime >= delay) {
// lastCallTime = now;
// handlerFunction.call(this, event);
// } else {
// throttledInProgress = true;
// setTimeout(() => {
// lastCallTime = Date.now();
// handlerFunction.call(this, event);
// throttledInProgress = false;
// }, delay - (now - lastCallTime));
// }
// }
eventType, // 存储事件类型,方便更新和移除
originalHandler: handlerFunction, // 存储原始处理函数,方便更新
options: directiveOptions // 存储选项
};
// 绑定事件
el.addEventListener(eventType, el._throttleData.throttledHandler);
},
// 当绑定值(处理函数或选项)更新时
updated(el, binding) {
if (!el._throttleData) return;
let newHandlerFunction = binding.value;
let newOptions = { ...el._throttleData.options }; // 保留旧选项
if (typeof binding.value === 'object' && binding.value !== null) {
newHandlerFunction = binding.value.callback;
newOptions = { ...newOptions, ...binding.value };
}
if (typeof newHandlerFunction === 'function' && newHandlerFunction !== el._throttleData.originalHandler) {
el._throttleData.originalHandler = newHandlerFunction; // 更新处理函数
// 注意:throttledHandler 内部闭包引用的 handlerFunction 也需要更新
// 简单起见,可以重新绑定事件,或修改 throttledHandler 使其动态读取 originalHandler
// 为简单,此处假设 throttledHandler 内部能访问到更新后的 el._throttleData.originalHandler
// 实际上,由于闭包,mounted 中定义的 throttledHandler 捕获的是当时的 handlerFunction
// 正确做法是让 throttledHandler 每次都从 el._throttleData.originalHandler 获取最新的回调
// 或者在更新时,如果回调变了,就先移除旧监听器,再用新回调创建新节流处理器并添加监听
}
el._throttleData.options = newOptions; // 更新选项
// 如果事件类型通过 arg 变化了
const newEventType = binding.arg || newOptions.event;
if (newEventType !== el._throttleData.eventType) {
el.removeEventListener(el._throttleData.eventType, el._throttleData.throttledHandler);
el._throttleData.eventType = newEventType;
el.addEventListener(newEventType, el._throttleData.throttledHandler);
}
},
unmounted(el) {
if (el._throttleData) {
el.removeEventListener(el._throttleData.eventType, el._throttleData.throttledHandler);
delete el._throttleData;
}
}
};
}
// 使用工厂函数创建不同的节流指令
const vThrottleClick = createThrottleDirective({ delay: 500, event: 'click' });
const vThrottleScroll = createThrottleDirective({ delay: 200, event: 'scroll' });
// 注册指令
// app.directive('throttle-click', vThrottleClick);
// app.directive('throttle-scroll', vThrottleScroll);
// 或者直接使用工厂返回的对象
app.directive('throttle', createThrottleDirective());
// 使用示例:
// <button v-throttle="handleClick">点击 (默认300ms click节流)</button>
// <button v-throttle:customEvent.500="handleCustomEvent">自定义事件与延迟</button>
// <div v-throttle="{ callback: onScroll, delay: 150, event: 'scroll' }">滚动区域</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
结论
通过本文的探讨,我们深入了解了Vue3自定义指令的工作原理、实现细节和应用场景。以下是使用自定义指令的最佳实践:
- 明确职责边界:自定义指令应专注于直接的DOM操作和封装与DOM相关的行为。避免在指令中处理过多的业务逻辑,这些逻辑更适合放在组件或组合式函数中。
- 避免在组件上使用:遵循Vue的建议,对于组件间的交互和逻辑复用,优先使用
Props
、Slots
、Provide/Inject
或Composables
。自定义指令用于组件时可能导致封装性破坏和行为不确定。 - 妥善清理资源:在
unmounted
(对应 Vue2 的unbind
)钩子中,务必移除所有通过指令添加的事件监听器、定时器、观察者 (Observers
) 以及对DOM的引用或存储在元素上的自定义数据,以防止内存泄漏。 - 保持简单高效:指令本身应该是轻量级的。如果指令逻辑变得非常复杂,考虑将其核心功能抽离到可复用的JavaScript模块或组合式函数中,指令本身仅作为桥接。
- 优化DOM操作:对于可能频繁触发的DOM更新(如响应鼠标移动、滚动或窗口大小调整),应使用性能优化技术,如防抖 (
Debounce
)、节流 (Throttle
) 和requestAnimationFrame
,以避免性能瓶颈。 - 善用钩子参数:充分利用指令钩子函数提供的
el
,binding (value, oldValue, arg, modifiers)
,vnode
, 和prevVnode
(在updated
钩子中) 参数,以实现灵活和动态的指令行为。 - 使用工厂函数:对于功能相似但配置可能不同的指令,可以创建指令工厂函数来生成可配置的指令定义,提高代码的复用性和可维护性。
- 考虑可访问性 (A11y):在进行DOM操作时,应考虑对可访问性的影响。例如,如果指令改变了元素的可见性或可用状态,应确保相关的
ARIA
属性得到正确更新。 - 清晰的命名和文档:为自定义指令选择清晰、表意明确的名称。如果指令接受复杂的参数或有特定的使用方式,提供相应的文档或注释非常重要。
VN/A |
本站访客数
--次 本站总访问量
--人次