☰
v3-admin-vite 内置组合式函数(Composables)完全使用指南:从设备检测到水印防御的 11 个通用工具
2026/10/4 1:48:14 网站建设 项目流程
  • 前端

【免费下载链接】v3-admin-vite

☀️ AI-friendly Vue3 admin template | Vue Admin | Vue Template | Vue3 Admin | Vue3 Template | Vue 后台 | Vue 模板 | Vue3 后台 | Vue3 模板

项目地址:https://gitcode.com/gh_mirrors/v3a/v3-admin-vite
点击查看免费下载

v3-admin-vite 在src/common/composables目录下内置了一套通用的组合式函数(Composables),统一通过路径别名@@/composables/导入。它们覆盖了后台管理系统最常用的场景:设备检测、异步下拉、全屏加载、分页、路由监听、主题切换、动态标题、水印、灰度模式与布局切换。阅读完本文,你将掌握每个组合式函数的调用签名、参数含义、源码实现原理,以及如何将它们与 Element Plus 组件、Pinia Store 组合到自己的页面中,从而告别重复造轮子、直接复用项目现成的能力。

内置组合式函数总览与导入约定

所有内置组合式函数统一存放在 src/common/composables 目录下,共有 11 个文件:

  • useDevice.ts:设备类型检测(移动端 / 桌面端)
  • useFetchSelect.ts:下拉选择器异步数据加载
  • useFullscreenLoading.ts:函数执行期间的全屏 Loading
  • usePagination.ts:分页状态与操作封装
  • useRouteListener.ts:基于发布订阅的路由变化监听
  • useTheme.ts:主题切换(支持 View Transition 动画)
  • useTitle.ts:浏览器标签页动态标题
  • useWatermark.ts:页面水印与防删除/隐藏防御
  • useGreyAndColorWeakness.ts:灰色模式与色弱模式
  • useLayoutMode.ts:布局模式管理与判断
  • usePany.ts:项目相关信息(如作者、仓库地址等元数据)

导入方式统一使用路径别名@@/composables/,例如:

import { useDevice } from "@@/composables/useDevice"

该别名在 vite.config.ts 与 tsconfig.json 中配置,指向src/common目录,因此@@/composables/xxx实际解析到src/common/composables/xxx。

使用原则上有四条约定(详见 Skill 文档"使用原则"一节):

  1. 优先使用这些内置组合式函数,不要重复造轮子;
  2. 组合式函数内部已处理生命周期(如onBeforeUnmount自动清理),无需手动管理;
  3. 需要新增通用组合式函数时,在src/common/composables目录下创建,命名以use开头;
  4. 页面私有的组合式函数应放在对应页面目录的composables子目录下,而非src/common/composables。

接下来按功能逐一讲解每个组合式函数。

设备检测 useDevice

useDevice用于判断当前设备是移动端还是桌面端,内部基于appStore.device提供响应式计算属性,源码见 src/common/composables/useDevice.ts。

import { useDevice } from "@@/composables/useDevice" const { isMobile, isDesktop } = useDevice() // 在模板或逻辑中使用 if (isMobile.value) { // 移动端逻辑 }

源码实现原理

从源码可以看到,模块顶层持有一个appStore单例,然后定义了两个computed:

const isMobile = computed(() => appStore.device === DeviceEnum.Mobile) const isDesktop = computed(() => appStore.device === DeviceEnum.Desktop)

DeviceEnum定义在 src/common/constants/app-key.ts:

export enum DeviceEnum { Mobile, Desktop }

这意味着device的值由 Pinia 的appStore维护,任何地方修改appStore.device(例如在窗口 resize 监听中切换设备类型),isMobile/isDesktop都会自动响应更新。useDevice只返回这两个响应式引用,不负责写入设备类型,属于纯读取型工具。

异步下拉选择器 useFetchSelect

useFetchSelect封装了下拉选择器的异步数据加载逻辑:组件挂载时自动调用接口获取选项,并对外暴露loading、options、value三个状态,源码见 src/common/composables/useFetchSelect.ts。

import { useFetchSelect } from "@@/composables/useFetchSelect" import { getSelectDataApi } from "./apis/xxx" const { loading, options, value } = useFetchSelect({ api: getSelectDataApi // 直接传函数引用,返回 ApiResponseData<SelectOption[]> })

在模板中配合 Element Plus 使用:

<el-card v-loading="loading"> <el-select v-model="value" filterable> <el-option v-for="item in options" v-bind="item" :key="item.value" placeholder="请选择" /> </el-select> </el-card>

入参与返回结构

接口返回的数据格式(即选项对象的结构):

interface SelectOption { value: string | number label: string disabled?: boolean }

入参只需一个api字段,类型为返回 Promise 的函数:

interface FetchSelectProps { api: () => Promise<ApiData> } // ApiData = ApiResponseData<SelectOption[]>

源码行为细节

从源码可以看到loadData的具体流程:

const loadData = () => { loading.value = true options.value = [] api().then((res) => { options.value = res.data }).finally(() => { loading.value = false }) } onMounted(() => { loadData() })

需要注意的行为特点:

  • 在onMounted阶段自动触发首次请求,无需手动调用;
  • 每次加载前会先清空options,避免展示上一次的旧数据;
  • 接口成功后将res.data(即SelectOption[])整体赋给options,因此在模板中可以直接v-for="item in options"配合v-bind="item"渲染,value/label/disabled都会自动透传到el-option上;
  • 当前实现没有对接口失败做额外处理(异常会向上抛出),由调用方自行捕获。

全屏加载 useFullscreenLoading

useFullscreenLoading包装一个函数,在其执行期间自动显示全屏 Loading,函数执行完毕后自动关闭(无论成功还是失败都会关闭),源码见 src/common/composables/useFullscreenLoading.ts。

import { useFullscreenLoading } from "@@/composables/useFullscreenLoading" // 方式一:行内调用(推荐简单场景) const res = await useFullscreenLoading(getSuccessApi)([1, 2, 3]) // 方式二:自定义 Loading 配置 try { await useFullscreenLoading(getErrorApi, { text: "删除中...", background: "#F56C6C20" })() } catch (error) { ElMessage.error((error as Error).message) } // 方式三:先赋值再调用(适合多次复用) const submitWithLoading = useFullscreenLoading(submitApi) await submitWithLoading(formData)

源码实现原理

核心实现非常精简,是一个高阶函数包装器:

const DEFAULT_OPTIONS = { lock: true, text: "加载中..." } export const useFullscreenLoading: UseFullscreenLoading = (fn, options = {}) => { let loadingInstance: LoadingInstance return async (...args) => { try { loadingInstance = ElLoading.service({ ...DEFAULT_OPTIONS, ...options }) return await fn(...args) } finally { loadingInstance.close() } } }

理解要点:

  • useFullscreenLoading(fn, options)返回一个新函数,新函数接收原函数的所有参数,并返回一个 Promise;
  • 内部使用 Element Plus 的ElLoading.service()开启全屏加载(默认lock: true锁定滚动、text: "加载中..."),你传入的options会覆盖默认配置(LoadingOptions类型,来自element-plus);
  • 关键在finally块:无论fn成功还是抛出异常,Loading 都会被关闭,因此你不需要担心 Loading 卡死不消失;
  • 若被包装的函数抛错,错误会继续向外传播,所以方式二里用try / catch捕获后通过ElMessage.error提示,是标准的配套写法。

三种调用方式的适用场景:行内调用适合一次性操作;带自定义配置适合需要提示文案(如"删除中...")的敏感操作;先赋值再调用适合表单提交等多次复用的场景。

分页 usePagination

usePagination封装分页状态与操作,适配 Element Plus 的el-pagination组件,源码见 src/common/composables/usePagination.ts。

import { usePagination } from "@@/composables/usePagination" function getTableData() { // 请求表格数据,并更新 paginationData.total } const { paginationData, resetCurrentPage, watchPagination } = usePagination({ callback: getTableData, pageSize: 20, pageSizes: [10, 20, 50, 100] }) // 初始化加载,并在 currentPage / pageSize 变化时重新请求 watchPagination() function handleSearch() { resetCurrentPage() }

在模板中使用:

<el-pagination v-model:current-page="paginationData.currentPage" v-model:page-size="paginationData.pageSize" :page-sizes="paginationData.pageSizes" :total="paginationData.total" :layout="paginationData.layout" background />

默认分页参数

参数默认值
total0
currentPage1
pageSizes[10, 20, 50]
pageSize10
layout"total, sizes, prev, pager, next, jumper"

源码行为细节

const paginationData = reactive({ ...DEFAULT_PAGINATION_DATA, ...initPaginationData }) const resetCurrentPage = () => { paginationData.currentPage === 1 ? callback?.() : (paginationData.currentPage = 1) } const watchPagination = (options: WatchOptions = { immediate: true }) => { watch( [() => paginationData.currentPage, () => paginationData.pageSize], () => callback?.(), options ) }

几个关键的源码级细节:

  • paginationData是一个reactive对象,由默认参数与你的传入项浅合并生成,模板里所有el-pagination属性都直接绑定它;
  • watchPagination()默认immediate: true,所以调用一次即完成初始化加载(首次请求),并会在currentPage或pageSize变化时自动执行callback重新请求数据;
  • resetCurrentPage用于搜索场景:如果当前已在第 1 页,则直接执行回调;否则把currentPage重置为 1,交给监听触发。这样设计避免了"搜索时同时改页码又手动请求"造成的重复请求;
  • 此外还返回了handleCurrentChange与handleSizeChange两个事件处理函数,可直接绑定到el-pagination的@current-change与@size-change事件上(不过由于模板中使用了v-model:current-page与v-model:page-size,通常不再需要它们);
  • callback内部通常需要自行把接口返回的total写入paginationData.total,分页数据本身不需要手动改。

路由监听 useRouteListener

useRouteListener基于发布订阅模式(内部使用mitt事件总线)监听路由变化,相比直接watch路由性能更好,且组件卸载时自动移除监听,源码见 src/common/composables/useRouteListener.ts。

import { useRouteListener } from "@@/composables/useRouteListener" const { listenerRouteChange, removeRouteListener } = useRouteListener() // 监听路由变化 listenerRouteChange((route) => { console.log("路由变化了:", route.path) }) // 立即执行一次(获取当前路由信息) listenerRouteChange((route) => { activeMenu.value = route.path }, true) // 第二个参数 immediate = true

源码实现原理

模块顶层创建了一个模块级(单例)的mitt事件发射器和一个Symbol("ROUTE_CHANGE")作为事件 key:

const emitter = mitt() const key = Symbol("ROUTE_CHANGE") let latestRoute: RouteLocationNormalizedGeneric

同时导出了一个配套函数setRouteChange,路由守卫在路由切换时调用它来触发事件并缓存最新路由:

export function setRouteChange(to: RouteLocationNormalizedGeneric) { emitter.emit(key, to) latestRoute = to }

useRouteListener内部维护组件自己的回调集合callbackList,并做了三件事:

  • listenerRouteChange(callback, immediate):把回调加入集合并注册到事件总线;若immediate为true且已有latestRoute,立即用当前路由调用一次回调,方便在初始化时就拿到当前路由信息;
  • removeRouteListener(callback):从事件总线移除指定回调;
  • onBeforeUnmount钩子遍历callbackList自动移除所有监听,避免内存泄漏。

为什么要用发布订阅而不是watch?源码注释给出了两点理由:一是单独用watch监听路由会浪费渲染性能;二是发布订阅模式更便于多组件分发管理。注意setRouteChange需要在路由守卫中调用(本项目在 src/router/guard.ts 中接入),才能保证事件被正确触发。

主题切换 useTheme

useTheme管理主题切换,支持 View Transition 动画效果(从鼠标点击位置以圆形扩散过渡),源码见 src/common/composables/useTheme.ts。

import { useTheme } from "@@/composables/useTheme" const { themeList, activeThemeName, initTheme, setTheme } = useTheme() // 初始化主题(应用启动时调用一次) initTheme() // 切换主题(需要传入鼠标事件以实现过渡动画) function handleThemeChange(event: MouseEvent, themeName: ThemeName) { setTheme(event, themeName) }

可用主题

nametitle
"normal"默认
"dark"黑暗
"dark-blue"深蓝

ThemeName类型为DefaultThemeName | "dark" | "dark-blue",其中"normal"是必填的默认主题。

源码实现原理

模块内部维护themeList、activeThemeName(初始值从 localStorage 读取,见 src/common/utils/local-storage.ts 的getActiveThemeName)以及三个核心函数:

  • setTheme({ clientX, clientY }, value):以鼠标点击位置为圆心,计算到视口四角的最远距离作为扩散半径,把--v3-theme-x、--v3-theme-y、--v3-theme-r三个 CSS 变量写入根元素(通过setCssVar,见 src/common/utils/css.ts),然后调用document.startViewTransition(存在时)包裹主题名切换,从而产生圆形扩散的 View Transition 动画;不支持startViewTransition的浏览器会直接切换;
  • initTheme():通过watchEffect收集副作用——每当activeThemeName变化,就移除html根元素上其他主题的 class、添加当前主题的 class,并同步写回 localStorage。因此应用启动时调用一次即可,之后每次setTheme都会自动触发 class 切换与持久化;
  • 主题的视觉样式由 src/common/assets/styles/theme 下的normal/dark/dark-blue三套 SCSS 变量与样式驱动,注册入口在 src/common/assets/styles/theme/register.scss。

注意setTheme要求第一个参数是鼠标事件(用于计算动画圆心),所以在模板中通常写成handleThemeChange($event, "dark")的形式。

动态标题 useTitle

useTitle动态设置浏览器标签页标题,格式为项目名 | 页面名,源码见 src/common/composables/useTitle.ts。

import { useTitle } from "@@/composables/useTitle" const { setTitle } = useTitle() // 设置标题为 "V3 Admin Vite | 用户管理" setTitle("用户管理") // 重置为项目默认标题 setTitle()

源码实现原理

const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE ?? "V3 Admin Vite" const dynamicTitle = ref<string>("") function setTitle(title?: string) { dynamicTitle.value = title ? `${VITE_APP_TITLE} | ${title}` : VITE_APP_TITLE } watch(dynamicTitle, (value, oldValue) => { if (document && value !== oldValue) { document.title = value } })

理解要点:

  • 项目标题VITE_APP_TITLE来自环境变量(import.meta.env.VITE_APP_TITLE),未配置时回退为"V3 Admin Vite",因此可以通过在.env文件中配置VITE_APP_TITLE来改变前缀;
  • setTitle内部只是写入响应式ref,实际修改document.title由watch统一完成,避免了直接操作 DOM 的分散;
  • 不传参数调用setTitle()会重置为纯项目标题。典型用法是在路由守卫中根据当前路由 meta 的标题调用setTitle(route.meta.title),实现"页面标题跟随路由"。

水印 useWatermark

useWatermark为页面或指定容器添加水印,内置防御机制(防止用户通过控制台删除或隐藏水印),组件卸载时自动清除,源码见 src/common/composables/useWatermark.ts。

import { useWatermark } from "@@/composables/useWatermark" // 默认挂载到 body const { setWatermark, clearWatermark } = useWatermark() // 设置水印 setWatermark("机密文件") // 自定义配置 setWatermark("内部使用", { defense: true, // 开启防御(默认 true) color: "#c0c4cc", // 文本颜色 opacity: 0.5, // 透明度 size: 16, // 字体大小 angle: -20, // 倾斜角度 width: 300, // 单个水印宽度(越大密度越低) height: 200 // 单个水印高度(越大密度越低) }) // 清除水印 clearWatermark()

完整配置项

配置项类型默认值说明
defensebooleantrue防御模式,能防御水印被删除或隐藏,但可能有性能损耗
colorstring"#c0c4cc"文本颜色
opacitynumber0.5文本透明度
sizenumber16文本字体大小
familystring"serif"文本字体(SKILL 示例未列出,源码默认值为serif)
anglenumber-20文本倾斜角度
widthnumber300单个水印所占宽度,数值越大水印密度越低
heightnumber200单个水印所占高度,数值越大水印密度越低

挂载到指定容器

useWatermark接受一个Ref<HTMLElement | null>作为可选参数,默认挂载到body;传入容器元素引用后,水印会以该容器为边界:

<script setup lang="ts"> import { useWatermark } from "@@/composables/useWatermark" const localRef = useTemplateRef("localRef") const { setWatermark, clearWatermark } = useWatermark(localRef) onMounted(() => { setWatermark("仅限内部") }) </script> <template> <div ref="localRef"> <!-- 内容 --> </div> </template>

注意:源码中setWatermark在容器还未挂载(parentEl.value为空)时会console.warn("请在 DOM 挂载完成后再调用 setWatermark 方法设置水印"),因此传容器时务必在onMounted之后调用。

源码实现原理(Canvas 平铺 + MutationObserver 防御)

useWatermark的实现可以拆成三层来看:

  1. 渲染层:用canvas绘制单个水印文本(配置color、opacity、size、family、angle),通过canvas.toDataURL()生成 base64 背景图,铺到watermarkEl上并以left top repeat平铺;挂载在body上时水印元素使用position: fixed,挂载在普通容器上时使用position: absolute,同时会把容器设为position: relative作为定位上下文;
  2. 防御层:开启defense后,用两个MutationObserver分别观察水印元素(属性变动,防止被 CSS 隐藏或修改)和容器元素(子节点变动,防止被删除)。一旦检测到水印被移除,立即用appendChild把水印元素加回容器;检测到属性被篡改,则触发updateWatermark重新生成水印。这些回调都经过lodash-es的debounce(100ms)防止频繁触发;
  3. 自适应层:用ResizeObserver监听容器大小变化(500ms 防抖),容器尺寸改变时同步更新水印元素的宽高;onBeforeUnmount时自动执行clearWatermark,移除所有监听并删除水印元素。

防御模式有性能开销(源码注释明确说明),如果对性能敏感且不需要防篡改,可以把defense设为false,此时会跳过 mutation 监听。

灰色模式与色弱模式 useGreyAndColorWeakness

useGreyAndColorWeakness初始化灰色模式和色弱模式,基于settingsStore的配置自动切换 HTML 根元素的 CSS 类名,源码见 src/common/composables/useGreyAndColorWeakness.ts。

import { useGreyAndColorWeakness } from "@@/composables/useGreyAndColorWeakness" const { initGreyAndColorWeakness } = useGreyAndColorWeakness() // 应用启动时调用一次即可,后续会自动响应 Store 变化 initGreyAndColorWeakness()

源码实现原理

const GREY_MODE = "grey-mode" const COLOR_WEAKNESS = "color-weakness" function initGreyAndColorWeakness() { const settingsStore = useSettingsStore() watchEffect(() => { classList.toggle(GREY_MODE, settingsStore.showGreyMode) classList.toggle(COLOR_WEAKNESS, settingsStore.showColorWeakness) }) }

关键点:

  • 通过watchEffect建立settingsStore.showGreyMode/settingsStore.showColorWeakness与根元素 class 之间的响应式映射,两个 Store 配置任一变化,html根元素上的grey-mode/color-weakness类都会自动增删;
  • 因此只需在应用启动时调用一次initGreyAndColorWeakness(),之后修改设置面板中的开关即可生效,无需在页面里重复调用;
  • 对应的 CSS 样式在 src/common/assets/styles/index.scss 中定义(例如对根元素应用filter: grayscale/filter: invert等),调用方无需关心实现细节。

布局模式 useLayoutMode

useLayoutMode管理系统布局模式(左侧菜单 / 顶部菜单 / 左侧 + 顶部混合菜单),源码见 src/common/composables/useLayoutMode.ts。

import { useLayoutMode } from "@@/composables/useLayoutMode" import { LayoutModeEnum } from "@@/constants/app-key" const { isLeft, isTop, isLeftTop, setLayoutMode } = useLayoutMode() // 判断当前布局 if (isLeft.value) { // 左侧菜单布局 } // 切换布局 setLayoutMode(LayoutModeEnum.Top)

源码实现原理

const isLeft = computed(() => settingsStore.layoutMode === LayoutModeEnum.Left) const isTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.Top) const isLeftTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.LeftTop) function setLayoutMode(mode: LayoutModeEnum) { settingsStore.layoutMode = mode }

LayoutModeEnum同样定义在 src/common/constants/app-key.ts:

export enum LayoutModeEnum { Left = "left", Top = "top", LeftTop = "left-top" }

与useDevice一样,useLayoutMode是对settingsStore.layoutMode的响应式投影:三个computed用于判断当前布局,setLayoutMode负责写入 Store。项目对应的三种布局组件分别位于 src/layouts/modes/LeftMode.vue、TopMode.vue 与 LeftTopMode.vue,布局切换由 src/layouts/index.vue 根据isLeft / isTop / isLeftTop动态渲染。

组合使用示例:一个完整的表格搜索页

将以上函数组合起来,可以得到一个典型的后台列表页:usePagination负责分页、useFetchSelect负责筛选下拉、useFullscreenLoading负责提交/导出 Loading、useDevice负责移动端适配、useTitle负责页面标题:

<script setup lang="ts"> import { usePagination } from "@@/composables/usePagination" import { useFetchSelect } from "@@/composables/useFetchSelect" import { useFullscreenLoading } from "@@/composables/useFullscreenLoading" import { useTitle } from "@@/composables/useTitle" import { getUserListApi, getUserStatusOptionsApi, exportUserApi } from "./apis" const { setTitle } = useTitle() setTitle("用户管理") const { loading, options: statusOptions, value: statusValue } = useFetchSelect({ api: getUserStatusOptionsApi }) function getUserList() { const res = await getUserListApi({ currentPage: paginationData.currentPage, pageSize: paginationData.pageSize }) paginationData.total = res.data.total tableData.value = res.data.list } const { paginationData, resetCurrentPage, watchPagination } = usePagination({ callback: getUserList, pageSize: 20 }) watchPagination() function handleSearch() { resetCurrentPage() // 已在第 1 页时直接请求,否则重置页码触发监听 } const exportData = useFullscreenLoading(exportUserApi, { text: "导出中..." }) async function handleExport() { await exportData({ status: statusValue.value }) } </script>

这个示例体现了组合式函数的两大优点:状态(分页、下拉、Loading)由各函数独立管理、互不干扰;watchPagination、onMounted、onBeforeUnmount等生命周期逻辑全部封装在函数内部,页面代码只需关心业务本身。

使用原则与扩展规范

最后再次强调 Skill 文档中给出的四条使用原则,它们是项目约定、也是协作时的规范:

  1. 优先复用:能用内置组合式函数解决的场景不要重复造轮子;
  2. 生命周期已托管:组合式函数内部已处理生命周期(如onBeforeUnmount自动清理),无需手动管理;
  3. 通用函数放公共目录:新增通用组合式函数时,在src/common/composables目录下创建,命名以use开头,并统一用@@/composables/别名导入;
  4. 私有函数放页面目录:页面私有的组合式函数应放在对应页面目录的composables子目录下,而非src/common/composables,避免公共目录被无关逻辑污染。

遵循这些约定,可以让整个项目的状态逻辑保持"公共可复用、页面私有着"的清晰分层。如果需要在仓库中查看这些函数的最新实现与配套用法,可直接前往 src/common/composables 目录,并参考 src/pages/demo/composable-demo 下的示例页面(其中包含use-fetch-select、use-fullscreen-loading、use-watermark的 Vue 演示与对应 API 定义,见 apis/use-fetch-select.ts 与 apis/use-fullscreen-loading.ts)。

  • 前端

【免费下载链接】v3-admin-vite

☀️ AI-friendly Vue3 admin template | Vue Admin | Vue Template | Vue3 Admin | Vue3 Template | Vue 后台 | Vue 模板 | Vue3 后台 | Vue3 模板

项目地址:https://gitcode.com/gh_mirrors/v3a/v3-admin-vite
点击查看免费下载

相关推荐

上一篇:Recaf中的路径节点:PathNode如何表示代码结构层次
下一篇:Go语言网络编程:learning-golang分布式系统开发终极指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询