- 前端
【免费下载链接】v3-admin-vite
☀️ AI-friendly Vue3 admin template | Vue Admin | Vue Template | Vue3 Admin | Vue3 Template | Vue 后台 | Vue 模板 | Vue3 后台 | Vue3 模板
v3-admin-vite 在src/common/composables目录下内置了一套通用的组合式函数(Composables),统一通过路径别名@@/composables/导入。它们覆盖了后台管理系统最常用的场景:设备检测、异步下拉、全屏加载、分页、路由监听、主题切换、动态标题、水印、灰度模式与布局切换。阅读完本文,你将掌握每个组合式函数的调用签名、参数含义、源码实现原理,以及如何将它们与 Element Plus 组件、Pinia Store 组合到自己的页面中,从而告别重复造轮子、直接复用项目现成的能力。
内置组合式函数总览与导入约定
所有内置组合式函数统一存放在 src/common/composables 目录下,共有 11 个文件:
useDevice.ts:设备类型检测(移动端 / 桌面端)useFetchSelect.ts:下拉选择器异步数据加载useFullscreenLoading.ts:函数执行期间的全屏 LoadingusePagination.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 文档"使用原则"一节):
- 优先使用这些内置组合式函数,不要重复造轮子;
- 组合式函数内部已处理生命周期(如
onBeforeUnmount自动清理),无需手动管理; - 需要新增通用组合式函数时,在
src/common/composables目录下创建,命名以use开头; - 页面私有的组合式函数应放在对应页面目录的
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 />默认分页参数
| 参数 | 默认值 |
|---|---|
total | 0 |
currentPage | 1 |
pageSizes | [10, 20, 50] |
pageSize | 10 |
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) }可用主题
| name | title |
|---|---|
"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()完整配置项
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
defense | boolean | true | 防御模式,能防御水印被删除或隐藏,但可能有性能损耗 |
color | string | "#c0c4cc" | 文本颜色 |
opacity | number | 0.5 | 文本透明度 |
size | number | 16 | 文本字体大小 |
family | string | "serif" | 文本字体(SKILL 示例未列出,源码默认值为serif) |
angle | number | -20 | 文本倾斜角度 |
width | number | 300 | 单个水印所占宽度,数值越大水印密度越低 |
height | number | 200 | 单个水印所占高度,数值越大水印密度越低 |
挂载到指定容器
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的实现可以拆成三层来看:
- 渲染层:用
canvas绘制单个水印文本(配置color、opacity、size、family、angle),通过canvas.toDataURL()生成 base64 背景图,铺到watermarkEl上并以left top repeat平铺;挂载在body上时水印元素使用position: fixed,挂载在普通容器上时使用position: absolute,同时会把容器设为position: relative作为定位上下文; - 防御层:开启
defense后,用两个MutationObserver分别观察水印元素(属性变动,防止被 CSS 隐藏或修改)和容器元素(子节点变动,防止被删除)。一旦检测到水印被移除,立即用appendChild把水印元素加回容器;检测到属性被篡改,则触发updateWatermark重新生成水印。这些回调都经过lodash-es的debounce(100ms)防止频繁触发; - 自适应层:用
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 文档中给出的四条使用原则,它们是项目约定、也是协作时的规范:
- 优先复用:能用内置组合式函数解决的场景不要重复造轮子;
- 生命周期已托管:组合式函数内部已处理生命周期(如
onBeforeUnmount自动清理),无需手动管理; - 通用函数放公共目录:新增通用组合式函数时,在
src/common/composables目录下创建,命名以use开头,并统一用@@/composables/别名导入; - 私有函数放页面目录:页面私有的组合式函数应放在对应页面目录的
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 模板
相关推荐
V3 Admin Vite 内置组合式函数完全清单:useWatermark 防删除水印、usePagination 分页神器 10+ 实战技巧
V3 Admin Vite 内置组合式函数完全清单:useWatermark 防删除水印、usePagination 分页神器 10+ 实战技巧 V3 Admi
前端从 io.js 周报到 nodejs.org 博客:解读 2015-02-13 期 Weekly Update 的历史与内容管线
从 io.js 周报到 nodejs.org 博客:解读 2015 02 13 期 Weekly Update 的历史与内容管线 2015 年 2 月 13 日
前端sccache 完整入门指南:5 分钟装好,让编译器缓存从本地跑到团队共享
sccache 完整入门指南:5 分钟装好,让编译器缓存从本地跑到团队共享 改一个文件,整条链路重新编译,盯着进度条等到烦——写 C/C++ 和 Rust 的人
开发工具构建工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考