在Vue 3的生态体系中,Hooks(通常指基于组合式API的自定义函数)已成为提升代码复用性与可维护性的核心工具。尽管Vue官方文档并未直接使用"Hooks"这一术语,但其倡导的组合式API设计理念,与React Hooks在逻辑复用的核心思想上高度契合。本文将深入解析Vue3 Hooks的本质、优势、使用方法及实战场景,从Vben-admin提供的例子里学习和理解Hooks。
一、Vue3 Hooks的核心概念
- 本质与定义
Vue3 Hooks本质上是遵循特定命名规范(以use开头)的JavaScript函数,用于封装组件中可复用的状态逻辑、副作用处理及生命周期管理。通过调用Vue的响应式API(如ref、reactive)和生命周期钩子(如onMounted、onUnmounted),Hooks能够与Vue的响应式系统无缝集成,其内部状态变化可自动触发视图更新。
- 与Vue2 Mixin的对比
在Vue 2时代,逻辑复用主要依赖混入(Mixin),然而,这些方案存在明显缺陷:Mixin易引发命名冲突,且逻辑来源不清晰,导致调试复杂。而Vue3 Hooks通过函数式封装实现逻辑复用,职责单一,组件可通过解构赋值明确获取所需状态和方法,有效规避了传统方案的痛点。
- 与公共组件的对比
Hooks和Vue公共组件功能类似,但还是有本质区别。举个简单的例子,我们现在有一个文件预览功能,用公共组件的方式实现,就是一个FilePreview组件,传入FileList,并且用一个变量控制预览弹窗的打开和关闭,这个变量一般是写在业务里,谁用谁定义,而Hooks的思路,封装的不仅仅是预览组件,还有组件的调用方式。后面的例子会详细说明。
二、Vben-Admin里面的一些Hooks
- useRefs
ts
import type { Ref } from 'vue';
import { ref, onBeforeUpdate } from 'vue';
export function useRefs(): [Ref<HTMLElement[]>, (index: number) => (el: HTMLElement) => void] {
const refs = ref([]) as Ref<HTMLElement[]>;
onBeforeUpdate(() => {
refs.value = [];
});
const setRefs = (index: number) => (el: HTMLElement) => {
refs.value[index] = el;
};
return [refs, setRefs];
}
Vue3中,针对非遍历的ref,可以直接访问到,但是很难在Vue3中访问v-for动态渲染出来的ref。
ts
const testRef = ref(null) // 直接就能在onMounted之后访问到
我们通过TS的定义,可以看到Ref是一个什么结构:
ts
export declare type VNodeProps = {
key?: string | number | symbol;
ref?: VNodeRef;
ref_for?: boolean;
ref_key?: string;
onVnodeBeforeMount?: VNodeMountHook | VNodeMountHook[];
onVnodeMounted?: VNodeMountHook | VNodeMountHook[];
onVnodeBeforeUpdate?: VNodeUpdateHook | VNodeUpdateHook[];
onVnodeUpdated?: VNodeUpdateHook | VNodeUpdateHook[];
onVnodeBeforeUnmount?: VNodeMountHook | VNodeMountHook[];
onVnodeUnmounted?: VNodeMountHook | VNodeMountHook[];
};
declare type VNodeRef = string | Ref | ((ref: object | null, refs: Record<string, any>) => void);
我们的useRefs可以直接在v-for的时候,只需传入索引,就能自动把我们需要的DOM元素放到refs变量里:
ts
//......
<li
:ref="setRefs(index)"
v-for="(item, index) in list"
</li>
//......
import { useRefs } from '/@/hooks/core/useRefs';
const [refs, setRefs] = useRefs();
// refs 就是我们需要拿到的DOM
特别说明:Vue3.5新增:useTemplateRef 可以更方便地去操作ref。
- useWindowSizeFn
ts
import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
import { useDebounceFn } from '@vueuse/core';
interface WindowSizeOptions {
once?: boolean;
immediate?: boolean;
listenerOptions?: AddEventListenerOptions | boolean;
}
export function useWindowSizeFn<T>(fn: Fn<T>, wait = 150, options?: WindowSizeOptions) {
let handler = () => {
fn();
};
const handleSize = useDebounceFn(handler, wait);
handler = handleSize;
const start = () => {
if (options && options.immediate) {
handler();
}
window.addEventListener('resize', handler);
};
const stop = () => {
window.removeEventListener('resize', handler);
};
tryOnMounted(() => {
start();
});
tryOnUnmounted(() => {
stop();
});
return [start, stop];
}
正常在一个Vue页面里监听屏幕尺寸变化,我们是怎么写的?
ts
import { debounce } from 'lodash-es';
import { onMounted, onUnmounted } from 'vue'
let handler = () => {
myChart && myChart.resize() // 举个例子,屏幕尺寸变化,我们让echarts适应屏幕
};
const handleSize = debounce(handler, 300);
onMounted(() => {
window.addEventListener('resize', handleSize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleSize);
});
用hooks之后:
ts
import { useWindowSizeFn } from '/@/hooks/event/useWindowSizeFn';
let handler = () => {
myChart && myChart.resize() // 举个例子,屏幕尺寸变化,我们让echarts适应屏幕
};
useWindowSizeFn(handler, 300, { immediate: false });
// 如果需要手动start或stop
// const [start, stop] = useWindowSizeFn(handler, 300, { immediate: false });
我们注意到,hook 把监听、卸载、防抖逻辑全部封装了,使用起来十分方便,且不容易遗漏逻辑。
三、自己写一个Hooks
Vben-admin里的附件预览组件如下:
html
<template>
<BasicModal
width="800px"
:title="t('component.upload.preview')"
wrapClassName="upload-preview-modal"
v-bind="$attrs"
@register="register"
:showOkBtn="false"
>
<FileList :dataSource="fileListRef" :columns="columns" :actionColumn="actionColumn" />
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, watch, ref } from 'vue';
// import { BasicTable, useTable } from '/@/components/Table';
import FileList from './FileList.vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { previewProps } from './props';
import { PreviewFileItem } from './typing';
import { downloadByUrl } from '/@/utils/file/download';
import { createPreviewColumns, createPreviewActionColumn } from './data';
import { useI18n } from '/@/hooks/web/useI18n';
import { isArray } from '/@/utils/is';
export default defineComponent({
components: { BasicModal, FileList },
props: previewProps,
emits: ['list-change', 'register', 'delete'],
setup(props, { emit }) {
const [register, { closeModal }] = useModalInner();
const { t } = useI18n();
const fileListRef = ref<PreviewFileItem[]>([]);
watch(
() => props.value,
(value) => {
if (!isArray(value)) value = [];
fileListRef.value = value
.filter((item) => !!item)
.map((item) => {
return {
url: item,
type: item.split('.').pop() || '',
name: item.split('/').pop() || '',
};
});
},
{ immediate: true },
);
// 删除
function handleRemove(record: PreviewFileItem) {
const index = fileListRef.value.findIndex((item) => item.url === record.url);
if (index !== -1) {
const removed = fileListRef.value.splice(index, 1);
emit('delete', removed[0].url);
emit(
'list-change',
fileListRef.value.map((item) => item.url),
);
}
}
// 下载
function handleDownload(record: PreviewFileItem) {
const { url = '' } = record;
downloadByUrl({ url });
}
return {
t,
register,
closeModal,
fileListRef,
columns: createPreviewColumns() as any[],
actionColumn: createPreviewActionColumn({ handleRemove, handleDownload }) as any,
};
},
});
</script>
针对这个组件,我们是这样使用的:
ts
import { ref, shallowRef } from 'vue';
import { useModal } from '/@/components/Modal';
import UploadPreviewModal from '/@/components/Upload/src/UploadPreviewModal.vue';
const [registerPreviewModal, { openModal: openPreviewModal }] = useModal();
const curFileList = shallowRef([]);
const openFileModal = list => {
curFileList.value = list
openPreviewModal(true, {})
}
改写成Hooks:
js
import { ref, shallowRef } from 'vue';
import UploadPreviewModal from '/@/components/Upload/src/UploadPreviewModal.vue';
import { useModal } from '/@/components/Modal';
const [registerPreviewModal, { openModal: openPreviewModal }] = useModal();
export function useFilePreview() {
const curFileList = shallowRef([]);
const openFileModal = list => {
curFileList.value = list
openPreviewModal(true, {})
}
return { openFileModal, UploadPreviewModal, registerPreviewModal, openPreviewModal };
}
使用:
ts
import { useFilePreview } from '/@/hooks/component/useFilePreview';
const { openFileModal, registerPreviewModal, UploadPreviewModal } = useFilePreview(); // 附件预览
openFileModal([
// 附件信息
])
封装后,我们只需要在template里直接定义:
html
<UploadPreviewModal
:value="fileList"
@register="registerPreviewModal"
/>
然后用openFileModal方法直接打开弹窗。
我们清楚地看到,公共组件和hooks对于同一个功能的使用情况的差别。实际上,这个hooks也不是随随便便写的,而是因为有大量的按公共组件的方式使用的场景,我们发现每次都需要在业务组件里定义变量和引用组件,就会想到把curFileList和UploadPreviewModal写到hooks里面,从而进一步封装成hooks。
四、总结
Vue3 Hooks作为组合式API的核心实践,为组件逻辑的复用与组织提供了现代化的解决方案。通过将复杂逻辑拆分为职责单一的函数模块,开发者能够构建出结构清晰、易于维护的代码体系。