从零开始：手把手教你封装一个企业级 Axios 请求模块
前言在现代前端开发中，一个健壮、易用且与业务解耦的请求模块是项目成功的基石。本教程将带你一步步实现项目中现有的 Axios 封装方案。我们将实现以下企业级核心功能：核心与业务解耦：核心请求机制不依赖任何 UI 框架或状态管理库，可在任何 JS 环境中复用。高内聚装配层：通过一个装配文件，将项目特有的 Token 获取、路由跳转、Toast 提示等业务依赖注入到核心机制中。无感 Token 刷新：当遇到 401 Unauthorized 错误时，自动暂停后续请求，刷新 Token 成功后无感重发原请求。失败自动重试：集成 axios-retry，在网络波动或超时时自动重试，并提供重试上限的兜底通知。响应自动解包与业务错误判定：自动剥离 Axios 包装，直接返回业务数据，并统一拦截非 200 的业务错误。[hide]第一步：设计目录结构为了保证代码的清晰度与可维护性，我们将请求模块划分为 core（纯净核心）和外层（业务装配）两部分：src/request/ ├── index.ts # 统一导出入口 ├── setup.ts # 项目装配层（高内聚业务逻辑，注入项目依赖） ├── types.ts # 业务响应结构定义 ├── axios.d.ts # Axios 类型扩展（声明自定义配置项） └── core/ # 核心请求机制（与具体业务解耦） ├── create-request.ts # 请求客户端工厂函数 ├── error-utils.ts # 错误判定与处理工具函数 ├── token-refresher.ts# 并发 Token 刷新去重队列 └── types.ts # 核心类型定义第二步：扩展 Axios 类型定义Axios 默认的 AxiosRequestConfig 无法满足我们对“是否重试”、“是否解包”、“是否弹窗”等精细化控制的需求。我们需要通过 TypeScript 的模块声明合并（Module Resolution）来扩展它。新建 src/request/axios.d.ts：import "axios"; declare module "axios" { export interface AxiosRequestConfig { /** 是否启用失败重试；默认不重试 */ enableRetry?: boolean; /** 发生错误时是否弹出 Toast 错误通知；默认为 true */ showErrorMessage?: boolean; /** 主动取消请求时是否弹出错误通知；默认为 false */ showCancelErrorMessage?: boolean; /** 是否自动解包响应，直接返回 data.data；默认为 false */ unpack?: boolean; /** 是否自动注入 Bearer Token 请求头；默认为 false */ bearerAuth?: boolean; /** 是否跳过 401 自动刷新流程；默认为 false */ skipTokenRefresh?: boolean; /** 内部标记：强制注入最新 Bearer token，覆盖已有的 Authorization 头 */ _forceBearerAuth?: boolean; } }第三步：定义核心类型契约在编写核心代码前，我们需要先定义好核心机制与业务装配层之间的“契约”（接口与类型）。这能确保我们的核心代码不依赖任何具体的 UI 库或状态管理库。新建 src/request/core/types.ts：import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; import type { IAxiosRetryConfig } from "axios-retry"; /** 错误通知分类 */ export type NotifyKind = "http" | "business" | "retry"; /** 错误通知载荷 */ export interface NotifyPayload { title: string; message: string; kind: NotifyKind; } /** 通知函数契约：由业务侧注入，统一对接 toast / message 等 UI */ export type NotifyFn = (payload: NotifyPayload) => void; /** 错误消息提取函数契约：从任意错误中得到一条可展示的文本 */ export type ErrorMessageGetter = (error: unknown) => string; /** 响应解包函数契约 */ export type Unpacker = <T = any>(response: AxiosResponse, config: AxiosRequestConfig) => T; /** 鉴权 token 提取函数契约 */ export type AuthTokenGetter = (config: AxiosRequestConfig) => string | null | undefined; /** 业务错误判定结果 */ export interface BusinessErrorResult { ok: boolean; message?: string; } /** 业务错误判定函数契约 */ export type BusinessErrorChecker = (response: AxiosResponse) => BusinessErrorResult; /** 401 / 鉴权失效回调契约 */ export type OnUnauthorized = (error: AxiosError, refreshFailed?: boolean) => void; /** 刷新 token 配置契约 */ export interface TokenRefreshConfig { doRefresh: () => Promise<boolean>; } /** 创建请求客户端的参数约束 */ export type CreateRequestOptions = | { defaults: AxiosRequestConfig & { bearerAuth: true }; getAuthToken: AuthTokenGetter; // 默认开启鉴权时，必须提供 Token 获取器 retry?: Omit<IAxiosRetryConfig, "onMaxRetryTimesExceeded"> & { onMaxRetryTimesExceeded?: (error: AxiosError, retryCount: number, ctx: any) => void; }; notify?: NotifyFn; getErrorMessage?: ErrorMessageGetter; unpacker?: Unpacker; isBusinessError?: BusinessErrorChecker; onUnauthorized?: OnUnauthorized; tokenRefresh?: TokenRefreshConfig; } | { defaults?: AxiosRequestConfig & { bearerAuth?: false }; getAuthToken?: AuthTokenGetter; retry?: Omit<IAxiosRetryConfig, "onMaxRetryTimesExceeded"> & { onMaxRetryTimesExceeded?: (error: AxiosError, retryCount: number, ctx: any) => void; }; notify?: NotifyFn; getErrorMessage?: ErrorMessageGetter; unpacker?: Unpacker; isBusinessError?: BusinessErrorChecker; onUnauthorized?: OnUnauthorized; tokenRefresh?: TokenRefreshConfig; }; /** 导出的客户端接口 */ export interface RequestClient { request<T>(config: AxiosRequestConfig): Promise<T>; raw: AxiosInstance; }第四步：实现并发 Token 刷新去重队列当系统中有多个并发请求同时失效返回 401 时，我们不希望向服务器发送多次刷新 Token 的请求。我们需要一个“去重队列”，让所有并发的 401 请求复用同一个刷新 Promise。新建 src/request/core/token-refresher.ts：export interface TokenRefresher { refresh(): Promise<boolean>; } export function createTokenRefresher(doRefresh: () => Promise<boolean>): TokenRefresher { // 缓存当前正在进行的刷新 Promise let inflight: Promise<boolean> | null = null; return { refresh() { // 如果已经有刷新请求在进行，直接复用它 if (inflight) return inflight; // 发起新的刷新请求 inflight = doRefresh() .catch(() => false) .finally(() => { // 请求结束（无论成功或失败），清空缓存，以便下次需要时重新发起 inflight = null; }); return inflight; } }; }第五步：编写核心错误判定工具函数我们需要一些纯函数来判定错误的类型（是否是网络错误、是否是重试中间错误、是否应该弹窗提示等）。新建 src/request/core/error-utils.ts：import axios, { AxiosError } from "axios"; import { isNetworkOrIdempotentRequestError } from "axios-retry"; /** 常见网络错误代码集合 */ export const NETWORK_ERROR_CODES = new Set([ "ECONNABORTED", "ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "ETIMEDOUT", "EHOSTUNREACH", "ERR_NETWORK", "ERR_INTERNET_DISCONNECTED", "ERR_NETWORK_CHANGED", "ERR_CONNECTION_TIMED_OUT", "ERR_NAME_NOT_RESOLVED" ]); /** 是否是可重试的错误 */ export function isRetryableError(error: unknown): boolean { if (!(error instanceof AxiosError)) return false; const isEnabled = error.config?.enableRetry === true; return isEnabled && isNetworkOrIdempotentRequestError(error); } /** 是否是重试过程中的中间错误（重试未结束时，这类错误应该被静默吞掉） */ export function isRetryError(error: unknown): boolean { return isRetryableError(error); } /** 是否是主动取消的请求 */ export function isCancelError(error: unknown): boolean { return axios.isCancel(error); } /** 是否应向用户展示错误通知 */ export function shouldShowErrorMessage(error: unknown): boolean { const cfg = (error as AxiosError)?.config; if (cfg?.showErrorMessage === false) return false; if (axios.isCancel(error)) { return cfg?.showCancelErrorMessage === true; } return true; }第六步：编写核心请求客户端工厂函数这是整个封装的灵魂。我们将在这里创建 Axios 实例，装配 axios-retry，并编写请求拦截器（注入 Token）和响应拦截器（处理业务错误、401 自动刷新、统一弹窗）。新建 src/request/core/create-request.ts：import axios, { type AxiosError, type AxiosRequestConfig } from "axios"; import axiosRetry, { isNetworkOrIdempotentRequestError } from "axios-retry"; import { isRetryError, shouldShowErrorMessage } from "./error-utils"; import { createTokenRefresher } from "./token-refresher"; import type { CreateRequestOptions, ErrorMessageGetter, RequestClient } from "./types"; const defaultGetErrorMessage: ErrorMessageGetter = (error) => { if (axios.isCancel(error)) return (error as Error).message ?? "请求被取消"; if (error instanceof Error) return error.message; return "未知错误"; }; export function createRequest(options: CreateRequestOptions): RequestClient { const { defaults, retry, unpacker, getAuthToken, notify, isBusinessError, onUnauthorized, tokenRefresh } = options; const getErrorMessage = options.getErrorMessage ?? defaultGetErrorMessage; // 1. 创建 Axios 实例 const instance = axios.create(defaults); // 2. 创建 Token 刷新队列 const refresher = tokenRefresh ? createTokenRefresher(tokenRefresh.doRefresh) : null; // 3. 装配失败自动重试 axiosRetry(instance, { retries: 3, retryDelay: (n) => n * 800, retryCondition: (error) => { return error.config?.enableRetry === true && isNetworkOrIdempotentRequestError(error); }, ...retry, onMaxRetryTimesExceeded: (error, retryCount) => { if (retry?.onMaxRetryTimesExceeded) { retry.onMaxRetryTimesExceeded(error, retryCount, { notify, getErrorMessage }); } else if (notify && shouldShowErrorMessage(error)) { notify({ title: "请求失败", message: getErrorMessage(error), kind: "retry" }); } } }); // 4. 请求拦截器：自动注入 Bearer Token instance.interceptors.request.use((config) => { const { bearerAuth, headers, _forceBearerAuth } = config; if (bearerAuth === true && getAuthToken) { // 如果是强制覆盖，或者当前没有手动设置 Authorization，则注入 Token if (_forceBearerAuth === true || !headers.Authorization) { const token = getAuthToken(config); if (token) { config.headers.set("Authorization", `Bearer ${token}`); } } } return config; }); // 5. 响应拦截器：处理业务成功/失败、401 刷新、网络错误 instance.interceptors.response.use( (response) => { const { config } = response; // 判定业务错误 if (isBusinessError && notify && config.showErrorMessage !== false) { const result = isBusinessError(response); if (!result.ok) { const message = result.message ?? "系统处理失败"; notify({ title: "业务提示", message, kind: "business" }); return Promise.reject(new Error(message)); } } // 自动解包：直接返回 unpacker 处理后的数据 if (config.unpack === true && unpacker) { return unpacker(response, config); } return response; }, async (error) => { // 如果是重试过程中的中间错误，直接 reject，不弹窗，等待最终重试结果 if (isRetryError(error)) return Promise.reject(error); const status = error?.response?.status; const cfg = error?.config as AxiosRequestConfig | undefined; // 处理 401 未授权错误 if (status === 401) { if (refresher && cfg && cfg.skipTokenRefresh !== true) { // 尝试刷新 Token const success = await refresher.refresh(); if (success) { // 刷新成功：用新 Token 重新发起原请求 const hadAuthHeader = !!cfg.headers?.Authorization; const retryConfig: AxiosRequestConfig = { ...cfg, skipTokenRefresh: true, // 避免死循环 _forceBearerAuth: hadAuthHeader }; return instance.request(retryConfig); } // 刷新失败：触发退登流程 if (onUnauthorized) onUnauthorized(error, true); return Promise.reject(error); } // 未启用刷新或显式跳过刷新：直接触发退登流程 if (onUnauthorized) onUnauthorized(error, false); return Promise.reject(error); } // 其他网络/HTTP 错误：统一弹窗提示 if (notify && shouldShowErrorMessage(error)) { notify({ title: "网络错误", message: getErrorMessage(error), kind: "http" }); } return Promise.reject(error); } ); return { request: <T>(config: AxiosRequestConfig) => instance.request(config) as Promise<T>, raw: instance }; }第七步：在项目装配层中注入业务依赖现在，我们的核心机制已经写好了。接下来，我们在 setup.ts 中将项目特有的依赖（如 Pinia Store、Vue Router、Vuetify Toast 等）注入进去。关键设计：所有项目依赖必须通过回调函数在运行时“懒解析”。如果在文件顶部直接调用 useUserStore()，在模块加载期 Pinia 尚未挂载时会直接报错。通过回调函数，只有在真正发起请求时才会解析依赖，完美避开了初始化顺序和循环依赖问题。新建 src/request/setup.ts：import router from "@/router"; import { useUserStore } from "@/stores"; import { useAuth } from "@/composables/useAuth"; import { toast } from "@/composables/useToast"; import { createRequest } from "./core/create-request"; import axios, { AxiosError, type AxiosRequestConfig } from "axios"; import type { RequestResult } from "./types"; /** 401 处理防抖锁：防止并发请求触发多次退登弹窗 */ let isHandlingUnauthorized = false; export const { request, raw: axiosInstance } = createRequest({ // 1. 默认配置 defaults: { baseURL: import.meta.env.VITE_APP_API_BASE_URL, timeout: 15000, unpack: true, // 默认开启解包 bearerAuth: true, // 默认开启 Token 注入 enableRetry: true // 默认开启失败重试 }, // 2. 运行时获取 Token（懒解析） getAuthToken: () => useUserStore().token, // 3. 统一对接项目的 Toast 组件 notify: ({ title, message }) => { toast.error(`${title}: ${message}`); }, // 4. 错误消息提取 getErrorMessage: (error) => { if (axios.isCancel(error)) return "请求被取消"; if (error instanceof AxiosError) return error.response?.data?.msg ?? error.message; return "未知错误"; }, // 5. 响应解包逻辑 unpacker: (response) => { return response.data.data; }, // 6. 业务错误判定（例如 code !== 200 视为失败） isBusinessError: (response) => { const { unpack } = response.config; if (unpack !== true) return { ok: true }; const resData = response.data as RequestResult; if (!resData || resData.code !== 200) { return { ok: false, message: resData?.msg ?? "系统处理失败" }; } return { ok: true }; }, // 7. 401 未授权处理（清除 Token 并重定向至登录页） onUnauthorized: (error, refreshFailed) => { if (isHandlingUnauthorized) return; isHandlingUnauthorized = true; if (error.config) { error.config.showErrorMessage = false; // 阻止后续并发请求重复弹窗 } const message = refreshFailed ? "登录已过期，请重新登录" : "请先登录以访问该资源"; toast.error(message); useAuth() .logout() .finally(() => { router.replace({ name: "Login", query: { redirect: router.currentRoute.value.fullPath } }); isHandlingUnauthorized = false; }); }, // 8. 无感刷新 Token 的具体实现 tokenRefresh: { doRefresh: async () => { const userStore = useUserStore(); const refreshToken = userStore.refreshToken; if (!refreshToken) return false; try { // 发起刷新请求，注意：必须配置 skipTokenRefresh: true 避免死循环 const result = await request<{ token: string; refreshToken: string; tokenExpireTime: number; }>({ url: "/user/refresh-token", method: "POST", data: { refreshToken }, skipTokenRefresh: true, bearerAuth: false, showErrorMessage: false }); // 更新本地 Token 缓存 userStore.setAuth({ token: result.token, refreshToken: result.refreshToken, tokenExpireTime: result.tokenExpireTime }); return true; } catch { return false; } } } }); /** * 快捷方法：无鉴权请求 * 默认关闭 Token 注入和 401 自动刷新，适用于登录、注册等公开接口。 */ export function publicRequest<T>(config: AxiosRequestConfig) { return request<T>({ bearerAuth: false, skipTokenRefresh: true, ...config }); }最后，在 src/request/index.ts 中统一导出：export { request, publicRequest, axiosInstance } from "./setup"; export { createRequest } from "./core/create-request"; export * from "./core/error-utils"; export type * from "./core/types"; export type * from "./types";第八步：业务开发中的实战使用指南1. 基础数据请求（自动注入 Token + 自动解包）import { request } from "@/request"; export interface UserInfo { id: number; name: string; } // 只需要声明返回的业务数据类型 UserInfo export function getUserInfo() { return request<UserInfo>({ url: "/user/info", method: "GET" }); } // 使用时：直接拿到解包后的 UserInfo 数据，且有完整的 TS 类型提示 const user = await getUserInfo(); console.log(user.name);2. 登录/注册请求（无鉴权）import { publicRequest } from "@/request"; export function login(data: LoginParams) { return publicRequest<LoginResult>({ url: "/auth/login", method: "POST", data }); }3. 静默请求（不弹出错误 Toast）export function saveDraft(data: any) { return request<void>({ url: "/draft/save", method: "POST", data, showErrorMessage: false // 即使接口报错，也不会弹出 Toast 干扰用户 }); }4. 获取原始响应（如文件下载）export function downloadReport() { return request<AxiosResponse<Blob>>({ url: "/report/download", method: "GET", responseType: "blob", unpack: false // 关闭自动解包，返回完整的 AxiosResponse }); }[/hide]
03-Vue Query 高级进阶：应对复杂业务场景的硬核套路
前言在前两篇文章中，我们完成了 Vue Query 的思维转变并掌握了基础实战。然而，在真实的中大型项目中，我们往往会遇到许多刁钻、复杂的业务需求。今天，我们将分享 4 个应对复杂场景的高级套路，帮助你彻底玩转 Vue Query。[hide]优雅的 Key 层次设计与全局缓存失效业务场景在需要登录鉴权的项目中，当用户点击“退出登录”时，我们必须立即清空所有与该用户相关的敏感接口缓存。否则，当下一个用户登录时，可能会短暂看到上一个用户的数据，造成严重的数据串线和安全隐患。如果一个一个去手动清理 user-detail、notice-list、billing-history，不仅代码极其繁琐，而且后续新增接口时极易遗漏。解决方案：多级 Key 继承与前缀匹配Vue Query 的缓存失效机制（invalidateQueries）是基于前缀匹配的。我们可以利用这一特性，设计一套优雅的 Key 层次结构。首先，定义一个基础键：/** 必须登录的查询基础 key */ export const REQUIRED_QUERY_KEY = ["required"];然后，让所有需要登录鉴权的接口 Key 都继承这个基础键：用户信息 Key：[...REQUIRED_QUERY_KEY, "user-detail"]公告列表 Key：[...REQUIRED_QUERY_KEY, "notice-list"]API 地址 Key：[...REQUIRED_QUERY_KEY, "api-address"]当用户登出时，我们只需要一行代码：import { queryClient } from "@/plugins/tanstack/vue-query"; import { REQUIRED_QUERY_KEY } from "./query-keys"; /** 将所有需要登录的查询标记为过期并清空 */ export function invalidateRequiredQueries() { return queryClient.invalidateQueries({ queryKey: REQUIRED_QUERY_KEY }); }原理解析：由于 Vue Query 会匹配所有以 ["required"] 开头的 Key，因此调用 invalidateRequiredQueries 会自动、一次性地让所有敏感接口的缓存全部失效并重新拉取。这极大地提升了系统的安全性和可维护性。分页与过滤：如何消除页面切换的闪烁感？业务场景在公告列表、账单日志等分页表格中，当用户点击“下一页”时，由于新一页的数据正在网络传输中，页面通常会瞬间变为空白，或者显示一个全屏的 Loading 遮罩。等数据加载完后，表格再重新渲染。这种频繁的“空白 -> 渲染”切换会产生强烈的闪烁感，用户体验非常粗糙。解决方案：placeholderData: keepPreviousDataVue Query 提供了 keepPreviousData 配置项，专门用来解决分页切换的闪烁问题。import { computed, toValue, type Ref } from "vue"; import { keepPreviousData, useQuery } from "@tanstack/vue-query"; import { getNoticeList, type GetNoticeListParams } from "@/api/notice"; export const NOTICE_LIST_KEY = ["notice-list"]; export function useGetNoticeList(params: Ref<GetNoticeListParams>) { return useQuery({ queryKey: computed(() => [...NOTICE_LIST_KEY, toValue(params)]), queryFn: () => getNoticeList(toValue(params)), // 核心配置：在请求新数据时，保持显示上一页的数据 placeholderData: keepPreviousData }); }原理解析：当用户切换到第 2 页时，Vue Query 会在后台发起第 2 页的请求。在第 2 页数据返回之前，页面上依然显示第 1 页的数据，表格不会变空，避免了布局闪烁。你可以配合 isFetching 状态，在表格上方显示一个轻量级的加载条（如进度条），提示用户正在加载。一旦第 2 页数据返回，Vue Query 会瞬间将表格内容替换为第 2 页数据，实现无缝平滑过渡。突破 Setup 限制：在非组件上下文中同步读取缓存业务场景在路由跳转前（如 Vue Router 的全局前置守卫 beforeEach 中），我们需要判断用户是否已经实名认证。如果未认证，则拦截并跳转到认证页面。路由守卫是一个普通的 TS 函数，不属于 Vue 组件，无法使用 useQuery 等 Composable 函数。我们该如何获取缓存的用户信息？解决方案：直接操作全局 queryClient 单例我们可以直接通过全局导出的 queryClient 实例，使用 getQueryData 同步读取内存中的缓存。// src/router/auth-guard.ts import { queryClient } from "@/plugins/tanstack/vue-query"; import { USER_DETAIL_KEY } from "@/queries/user/useUserDetail"; import type { UserDetailResult } from "@/api/user"; router.beforeEach((to, from, next) => { // 1. 同步读取已有的用户详情缓存（无网络开销，直接读内存） const userDetail = queryClient.getQueryData<UserDetailResult>(USER_DETAIL_KEY); // 2. 读取实名认证状态 const isRealNameVerified = userDetail?.user?.realAuth ?? false; if (to.meta.requiresAuth && !isRealNameVerified) { // 3. 未实名认证，拦截并跳转 next({ name: "RealNameAuth" }); } else { next(); } });通过 queryClient.getQueryData，我们可以在任何非 Setup 环境下，同步、无开销地读取内存中的缓存数据，完美解决了非组件环境下的状态共享问题。命令式异步获取：ensureQueryData 的妙用业务场景在某些弹窗或异步工作流中，我们需要获取某项配置数据（例如 API 物理地址）。我们希望：如果缓存里有，就直接拿来用；如果缓存里没有，就立刻发起网络请求，并等待请求完成后返回结果。解决方案：queryClient.ensureQueryDataVue Query 提供了 ensureQueryData 方法，它能完美地将“声明式缓存”与“命令式工作流”结合在了一起。// src/queries/api-keys/useApiAddress.ts import { queryClient } from "@/plugins/tanstack/vue-query"; import { getApiAddress } from "@/api/api-keys"; export const API_ADDRESS_KEY = ["api-address"]; const getApiAddressQueryOptions = () => ({ queryKey: API_ADDRESS_KEY, queryFn: () => getApiAddress(), staleTime: 30 * 60 * 1000 // 30分钟有效期 }); /** 命令式获取数据：如果缓存有效则直接返回，否则发起请求并等待 */ export function getApiAddressAsync() { return queryClient.ensureQueryData(getApiAddressQueryOptions()); }在弹窗组件或异步工作流中，你可以像调用普通异步函数一样使用它：import { getApiAddressAsync } from "@/queries/api-keys/useApiAddress"; const handleExport = async () => { // 1. 命令式获取 API 地址（有缓存读缓存，无缓存发请求并等待） const apiAddress = await getApiAddressAsync(); // 2. 执行后续的导出逻辑 await executeExport(apiAddress); };依赖查询：如何处理有先后顺序的串行请求？业务场景在实际业务中，我们经常遇到“请求 B 依赖请求 A 的结果”的场景。例如：我们需要先获取“当前用户的组织 ID（OrgId）”，然后再根据这个“组织 ID”去获取“该组织的权限列表”。如果使用传统的 onMounted，我们需要在第一个请求的 then 回调中去触发第二个请求，代码很容易陷入回调地狱。解决方案：enabled 动态控制在 Vue Query 中，我们可以通过将前一个请求的返回结果作为后一个请求的 enabled 条件，优雅地实现串行依赖查询。import { computed } from "vue"; import { useQuery } from "@tanstack/vue-query"; import { getUserProfile, getOrgPermissions } from "@/api/user"; export function useOrgPermissions() { // 1. 第一个查询：获取用户个人资料（包含组织 ID） const userQuery = useQuery({ queryKey: ["user-profile"], queryFn: getUserProfile }); // 2. 提取组织 ID const orgId = computed(() => userQuery.data.value?.orgId); // 3. 第二个查询：依赖第一个查询的结果 const permissionsQuery = useQuery({ queryKey: computed(() => ["org-permissions", orgId.value]), queryFn: () => getOrgPermissions(orgId.value!), // 核心配置：只有当 orgId 存在时，才启用第二个查询 enabled: computed(() => !!orgId.value) }); return { userQuery, permissionsQuery }; }原理解析：页面加载时，orgId 为 undefined，因此 permissionsQuery 的 enabled 为 false，处于 pending 状态，不会发送请求。当 userQuery 请求成功并返回数据后，计算属性 orgId 自动更新。permissionsQuery 监听到 enabled 变为 true，且 queryKey 自动更新为最新的 ["org-permissions", actualOrgId]，随即自动触发网络请求。整个过程完全是声明式的，没有任何嵌套的回调函数，代码极其优雅。总结通过这三篇文章，我们完成了 @tanstack/vue-query 的全方位探索：第一阶段：我们转变了思维，理解了“传输层”与“异步状态管理层”的解耦协同。第二阶段：我们快速入门，掌握了声明式查询、响应式 Key 联动以及轻量级缓存更新。第三阶段：我们深入底层，通过多级 Key 继承、分页防抖、非 Setup 同步读取、命令式异步获取以及依赖查询，优雅地解决了各种刁钻的实战难题。希望这套实战指南能帮助你在项目中写出更优雅、更健壮、体验更极致的代码！[/hide]
02-Vue Query 快速入门：从零构建你的第一个声明式查询
前言在上一篇文章中，我们探讨了为什么要引入 @tanstack/vue-query 以及它与 Axios 的协同关系。今天，我们将正式进入实战阶段，手把手教你如何在 Vue 3 项目中搭建 Vue Query 的骨架，掌握其最核心的 API 与配置，并深入探讨如何利用调试工具和轻量级缓存更新策略提升开发效率。[hide]基础配置：搭建 Vue Query 的骨架在 Vue 3 项目中集成 Vue Query 非常简单，主要分为两步：初始化 QueryClient 和注册插件。第一步：初始化 QueryClient我们在项目根目录的 src/plugins/tanstack/vue-query.ts 中创建并配置 QueryClient 实例。import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query"; import type { App } from "vue"; // 实例化 QueryClient，并导出为全局单例 export const queryClient = new QueryClient({ defaultOptions: { queries: { // 1. 因为我们的 Axios 拦截器已经实现了请求重试，所以这里关闭 Vue Query 的自动重试 retry: false, // 2. 默认关闭窗口聚焦时自动刷新（按需在单个接口开启，省流量） refetchOnWindowFocus: false } } });架构小贴士：为什么要把 queryClient 导出为全局单例？因为在实际项目中，我们经常需要在非 Setup 上下文（如路由守卫、Pinia Store、Axios 拦截器或普通 JS/TS 工具函数）中操作缓存。通过导出单例，我们可以在任何地方直接 import { queryClient } 并调用其 API（如 queryClient.setQueryData）。第二步：注册 VueQueryPlugin 与调试工具配置在同一个文件中，我们定义一个注册函数。对于接口数据管理工具而言，可视化调试能力至关重要。如果没有方便的可视化操作，调试缓存状态、查看数据是否过期会变得极其麻烦。根据 Vue Query 官方 Devtools 文档，官方目前提供了三种主流的调试方式：官方 Devtools 控制组件：通过在项目中引入 @tanstack/vue-query-devtools 组件，在页面右下角渲染一个悬浮的调试面板。集成到 vite-plugin-vue-devtools：Vue Query 可以无缝集成到 Vue 官方的 Vite 插件中，在 Vite 开发者面板中直接查看 Query 状态。浏览器插件（Chrome/Firefox Extension）：安装官方提供的浏览器独立扩展程序。在实际开发中，由于控制组件会侵入项目代码，而 Vite 插件面板有时不够直观，使用浏览器独立插件是许多开发者最习惯、最清爽的选择。为了让浏览器插件能够识别到我们项目中的 queryClient 实例，我们需要在开发环境下将其挂载到全局：export const registerVueQuery = (app: App) => { // 配合浏览器插件 Devtools 使用，且只在开发环境暴露 if (import.meta.env.DEV) { window.__TANSTACK_QUERY_CLIENT__ = queryClient; } app.use(VueQueryPlugin, { queryClient }); };挂载后，打开 Chrome 的 Vue Query Devtools 插件，即可完美识别并可视化操作我们应用中的所有缓存数据。最后，在 main.ts 中引入并调用 registerVueQuery(app) 即可完成初始化。核心 API：useQuery 的基本用法useQuery 是 Vue Query 中最常用的 API，用于执行“读”操作（获取数据）。我们以一个标准的用户信息接口为例来讲解。import { useQuery } from "@tanstack/vue-query"; import { getUserDetail } from "@/api/user"; export const USER_DETAIL_KEY = ["user-detail"]; export function useUserDetail() { return useQuery({ // 1. QueryKey：缓存的唯一标识，必须是数组 queryKey: USER_DETAIL_KEY, // 2. QueryFn：一个返回 Promise 的异步请求函数 queryFn: getUserDetail, // 3. StaleTime：数据有效期（5分钟） staleTime: 5 * 60 * 1000 }); }核心参数解析queryKey：缓存的唯一标识。Vue Query 内部会以这个 Key 为键，将请求结果存入内存。只要 Key 不变，后续调用 useQuery 都会直接读取同一份缓存。queryFn：数据获取函数。它必须返回一个 Promise。这里我们直接传入了 Axios 封装的 getUserDetail。staleTime（失效时间）：在 staleTime 时间内，数据被认为是“新鲜的（Fresh）”。此时如果组件重新挂载，Vue Query 会直接从缓存中读取数据，不会发起网络请求。超过 staleTime 后，数据变为“过期的（Stale）”。此时如果组件重新挂载，Vue Query 会先返回缓存数据，同时在后台静默发起请求刷新数据。进阶控制：条件触发与响应式 Key在实际业务中，我们经常需要根据某些状态来控制请求的触发时机，或者根据参数的变化自动重新请求。条件触发（enabled）很多接口需要登录后才能请求。如果用户未登录，直接请求会导致 401 错误。我们可以通过 enabled 属性来控制：import { ref } from "vue"; export function useUserDetail(isLogin: Ref<boolean>) { return useQuery({ queryKey: USER_DETAIL_KEY, queryFn: getUserDetail, // 只有当 isLogin 为 true 时，才会自动发起请求 enabled: isLogin, staleTime: 5 * 60 * 1000 }); }响应式 Key（参数联动）当请求参数（如分页、搜索关键字）发生变化时，我们希望 Vue Query 自动重新发起请求。秘诀在于：将响应式变量绑定到 queryKey 中。以列表查询为例：import { computed, toValue, type Ref } from "vue"; import { getNoticeList, type GetNoticeListParams } from "@/api/notice"; export const NOTICE_LIST_KEY = ["notice-list"]; export function useGetNoticeList(params: Ref<GetNoticeListParams>) { return useQuery({ // 将响应式的 params 传入 queryKey 数组中 // 当 params 发生变化时，queryKey 也会变化，Vue Query 会自动触发重新请求 queryKey: computed(() => [...NOTICE_LIST_KEY, toValue(params)]), queryFn: () => getNoticeList(toValue(params)) }); }数据更新与缓存失效：Mutation 与命令式更新在处理“写”操作（增删改）时，如何优雅地更新本地缓存，是决定项目架构是否清爽的关键。Vue Query 提供了声明式的 useMutation，同时也支持通过 QueryClient 进行命令式的缓存操作。这两种模式各有其适用场景。官方推荐用法：useMutation 声明式封装Vue Query 官方推荐使用 useMutation 来处理数据修改。它将“发送修改请求”与“成功后使缓存失效”封装在一起，形成逻辑闭环。以下是一个完整的 useMutation 封装与组件内使用的示例，它与我们之前的 useUserDetail 缓存无缝关联：// src/queries/user/useUpdateNickname.ts import { useMutation, useQueryClient } from "@tanstack/vue-query"; import { updateNickname } from "@/api/user"; import { USER_DETAIL_KEY } from "./useUserDetail"; export function useUpdateNickname() { const queryClient = useQueryClient(); return useMutation({ // 1. 绑定修改数据的 API 请求函数 mutationFn: (newNickname: string) => updateNickname(newNickname), // 2. 修改成功后的回调 onSuccess: () => { // 3. 使之前的用户信息缓存失效，触发 useUserDetail 自动在后台静默刷新 queryClient.invalidateQueries({ queryKey: USER_DETAIL_KEY }); } }); }在 Vue 组件中的具体使用方式如下： 当前昵称：{{ userDetail?.nickname }} {{ isPending ? "保存中..." : "保存" }} 修改失败：{{ error.message }} 适用场景：强 UI 状态关联：当你的按钮需要根据请求状态展示“保存中...”，或者需要直接在模板中渲染修改失败的 error 提示时，useMutation 提供的响应式状态（isPending、error）能极大简化组件代码。灵活的“Axios 异步修改 + 命令式缓存更新”模式除了声明式的 useMutation，在实际开发中，我们也可以采用更符合传统直觉的“Axios 异步修改 + 命令式缓存更新”模式。我们不使用 useMutation，而是直接暴露普通的异步函数，并在其中操作全局的 queryClient 单例。模式 1：手动写入缓存（setQueryData）当用户登录成功后，后端会直接返回用户信息。此时我们不需要再发一次请求去获取详情，而是可以直接将数据写入缓存：import { queryClient } from "@/plugins/tanstack/vue-query"; import { USER_DETAIL_KEY } from "./useUserDetail"; /** 传入数据直接更新缓存 */ export function setUserDetail(newData: UserDetailResult) { queryClient.setQueryData(USER_DETAIL_KEY, newData); } // 在登录逻辑（如 useAuth.ts）中直接调用： // const loginResult = await login(credentials); // setUserDetail(loginResult.userDetail); // 瞬间写入缓存，全局共享！模式 2：使缓存失效（invalidateQueries）当用户修改了个人资料（如昵称）后，我们需要让本地的缓存失效，促使 Vue Query 自动去后端拉取最新数据：import { queryClient } from "@/plugins/tanstack/vue-query"; import { USER_DETAIL_KEY } from "./useUserDetail"; /** 将数据标记为过期，触发自动静默刷新 */ export function invalidateUserDetail() { return queryClient.invalidateQueries({ queryKey: USER_DETAIL_KEY }); } // 在普通的修改昵称函数中调用： async function handleUpdateNickname(newNickname: string) { await updateNickname(newNickname); // 1. 发送 Axios 请求修改数据 invalidateUserDetail(); // 2. 命令式使缓存失效，页面上的昵称会自动静默刷新！ }适用场景：极简的样样板代码：不需要在组件中引入 useMutation 并处理繁琐的解构，直接使用原生的 async/await 配合普通的 Axios 请求，符合大多数开发者的直觉。突破 Setup 限制：由于 queryClient 是全局单例，我们可以在 Pinia Store、路由守卫、甚至 Axios 拦截器等非 Setup 上下文中随时随地调用 setUserDetail 或 invalidateUserDetail，而不需要受 Vue 组合式函数必须在 setup 中执行的限制。总结今天我们掌握了 Vue Query 的基础实战套路：通过全局单例 queryClient 结合浏览器 Devtools 插件搭建高效的调试骨架。使用 useQuery 声明式获取数据，并利用 staleTime 控制缓存有效期。利用 enabled 实现条件触发，利用响应式 queryKey 实现参数联动。对比了官方 useMutation 声明式封装与更灵活的“Axios + 命令式缓存更新”模式，并明确了各自的适用场景。在下一篇文章中，我们将挑战一些更刁钻的业务场景，看看 Vue Query 的高级进阶用法（如多级 Key 继承、分页防抖、非 Setup 上下文调用等）是如何帮我们优雅破局的。[/hide]
前言在现代 Web 应用开发中，API 数据请求与状态同步是前端架构的核心环节。在 Vue 3 项目中，开发者通常习惯于使用 Axios 结合组件生命周期钩子（如 onMounted）来获取数据，并手动维护 loading、error 等响应式状态。然而，随着应用复杂度的提升，这种命令式的数据管理模式在性能、代码复用性以及用户体验上面临诸多挑战。本文将从架构设计的角度，探讨如何通过引入 @tanstack/vue-query 实现从“命令式数据获取”向“声明式异步状态管理”的范式转变，并阐述其与 Axios 的协同工作机制。传统前端请求的痛点在没有引入专门的异步状态管理工具前，一个典型的 Vue 3 数据请求组件通常采用如下模式： 加载中... 出错了：{{ error.message }} 用户：{{ data.name }} 这种命令式写法在简单场景下直观易懂，但在中大型项目中会暴露以下架构缺陷：状态冗余与样板代码：每个需要请求数据的组件都必须重复定义 loading、error、data 这一套响应式变量，并编写相似的 try-catch-finally 逻辑，导致代码冗余。重复请求与带宽浪费：若页面中多个独立挂载的子组件依赖同一份数据（例如当前登录用户信息），它们各自挂载时会触发多次相同的 API 请求，缺乏统一的请求合并机制。缓存缺失与页面闪烁：用户在不同路由间切换时，组件的销毁与重新挂载会频繁触发网络请求，导致页面出现“空白 -> 加载中 -> 渲染”的闪烁，无法提供即时响应的体验。数据过期与同步困难：客户端难以感知服务器端数据的实时变化。当用户在 A 页面修改了数据，B 页面缓存的数据无法自动或优雅地同步更新。核心概念：Axios 与 Vue Query 的本质区别要解决上述痛点，首先需要理清网络传输与状态管理在前端架构中的职责边界。Axios 的定位：传输层（Transport Layer）它是一个优秀的 HTTP 客户端，专注于“如何安全、正确地执行网络通信”。其职责包括：请求/响应拦截、Token 刷新、超时处理、请求重试、数据序列化等网络细节。它不关心数据在客户端的生命周期、共享范围以及缓存策略。Vue Query 的定位：异步状态管理层（Asynchronous State Management）它是一个强大的状态管理器，专注于“如何管理、同步和缓存客户端的服务器状态”。其职责包括：数据缓存生命周期管理、请求去重、后台静默刷新、条件触发、垃圾回收等。它不关心底层的网络请求是通过 Axios、Fetch 还是 WebSocket 实现的。协同工作模式在优秀的架构设计中，两者并非竞争关系，而是互补的。Axios 负责发送请求，Vue Query 负责管理请求结果。graph LR Component[Vue 组件] -->|useQuery| VueQuery[Vue Query 缓存层] VueQuery -->|未命中缓存/已过期| Axios[Axios 传输层] Axios -->|发起 HTTP 请求| Server[后端服务器] Server -->|返回数据| Axios Axios -->|交付数据| VueQuery VueQuery -->|响应式更新| Component引入 Vue Query 带来的核心收益声明式数据获取开发者不再需要手动维护 loading、error 状态。Vue Query 的 useQuery 会自动解构出这些状态，使组件逻辑更加聚焦于 UI 渲染：const { data, isLoading, isError, error } = useQuery({ queryKey: ["user-detail"], queryFn: getUserDetail });自动请求去重（Deduplication）当多个组件在同一时刻调用相同的 useQuery（拥有相同的 queryKey）时，Vue Query 会将它们合并。底层的网络请求只会发送一次，所有组件共享同一个响应结果。开箱即用的缓存与后台静默刷新（SWR 机制）Vue Query 采用了经典的 SWR (Stale-While-Revalidate) 缓存失效策略：当组件挂载时，如果缓存中已有数据，Vue Query 会立即返回缓存数据，让用户瞬间看到内容。与此同时，它会在后台静默发起请求，获取最新数据。当最新数据返回后，它会静默更新缓存并响应式地渲染页面，确保数据最终一致性。极简的全局状态共享无需通过 Pinia 编写繁琐的 action 和 state，直接通过相同的 QueryKey 即可在任意组件间共享数据，降低了状态管理的复杂度。项目实战：它们是如何在项目中优雅配合的？在实际项目中，我们通过将 Axios 实例与 Vue Query 的全局配置进行解耦，实现了职责分离。统一的初始化与职责分离在项目初始化阶段，我们配置全局的 QueryClient。由于底层的 Axios 实例已经处理了网络层面的请求重试和异常拦截（我自己封装的axios），我们在 Vue Query 层进行了相应的适配：import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query"; export const queryClient = new QueryClient({ defaultOptions: { queries: { // 1. 底层 Axios 已经实现了请求重试机制，所以这里关闭自动重试，避免双重重试 retry: false, // 2. 默认关闭窗口聚焦时自动刷新，按需在单个接口开启，节省服务器流量 refetchOnWindowFocus: false } } });声明式查询封装示例在业务开发中，我们将 API 请求函数与 Vue Query 的缓存配置封装为统一的 Composable 函数。以下是一个标准的用户详情查询封装：import { useQuery } from "@tanstack/vue-query"; import { getUserDetail } from "@/api/user"; // 封装好的 Axios 请求函数 export const USER_DETAIL_KEY = ["user-detail"]; export function useUserDetail(isLogin: Ref<boolean>) { return useQuery({ queryKey: USER_DETAIL_KEY, queryFn: getUserDetail, // 只有在用户登录后，才允许发起请求（条件触发） enabled: isLogin, // 5分钟内，数据被认为是“新鲜”的，不需要重复请求 staleTime: 5 * 60 * 1000 }); }通过这种设计，底层的 Axios 专注于处理网络传输细节，而上层的 useUserDetail 则专注于处理缓存有效期、登录依赖等业务状态逻辑，实现了清晰的关注点分离。总结从“命令式 Axios 请求”到“声明式 Vue Query 缓存管理”，改变的不仅是几行代码，更是前端架构思维的转变。Axios 解决了“如何通信”的问题。Vue Query 解决了“如何同步状态”的问题。在下一篇文章中，我们将正式进入实战，手把手教你如何在项目中从零开始使用 Vue Query，并掌握其最核心的 API 与配置。
生产部署时动态导入 Chunk 失效的实用回退方案
前言与痛点在单页应用（SPA）的日常开发与运维中，我们经常会遇到因旧版本文件失效导致的用户访问异常。一个典型的场景如下：10:00 —— 部署了版本 V1，用户正常访问并停留在页面 A。11:00 —— 部署了版本 V2。此时，服务器上的 JS 文件带有全新的 Hash 后缀，V1 版本的旧 JS 文件已被新版本覆盖或被清理。11:05 —— 停留在页面 A 的用户尝试点击路由跳转到页面 B。此时浏览器尝试通过 import() 动态加载页面 B 对应的 chunk 文件，但服务器上该文件已不存在，导致请求返回 404。此时，基于 ESM 构建的工具（如 Vite）在控制台通常会抛出以下错误，导致前端路由完全卡死，页面毫无反应：TypeError: Failed to fetch dynamically imported module: https://.../assets/xxxx.abc123.js在频繁迭代、敏捷部署的业务环境中，如果不妥善处理此类问题，将会导致用户访问失败，严重影响用户体验。[hide]常见解决方案对比为了解决这个问题，前端社区通常有以下几种解决方案：增量部署（保留旧文件）每次发布不删除旧的静态资源，而是保留若干个历史版本的产物。优点： 用户无感知，体验最好。缺点： 随时间推移，服务器存储压力剧增；且如果是大版本更新（涉及 API 废弃等），停留在旧版页面的用户可能依然会遇到接口报错。轮询版本号文件前端在打包时生成一个 version.json，应用在后台定时轮询或在路由跳转前请求该文件。若发现版本变更，则弹窗提示用户刷新页面。缺点： 增加了额外的网络请求开销，且弹窗打断了用户的操作流，体验较重。WebSocket 实时通知服务端在发布新版后，通过长链接主动推送更新消息，前端接收到指令后提示刷新。缺点： 架构复杂度高，需要服务端配合，成本对于中小型项目而言过重。动态拉取 Manifest 文件构建时输出包含文件映射关系的 manifest 文件。客户端在路由加载失败后，主动重新请求 manifest 文件，获取新的文件 Map 信息后重新加载进入最新页面。缺点： 需要定制打包配置与拦截逻辑，实现起来较为繁琐。综合考虑实现成本与体验，最近在查看开源 UI 库 Vuetify 官方提供的项目模板 (template) 时，我发现了一种非常巧妙且极简的处理方式：在页面加载失败时，直接重载页面自动获取最新文件。Vite 社区也有一个关于此问题的 issue 讨论：《TypeError: Failed to fetch dynamically imported module》极简回退方案我们可以利用 Vue Router 的全局错误守卫 router.onError 来捕获动态导入失败的异常，并在探测到该异常时，主动触发页面的重新加载（Reload）。核心代码如下：import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ // ... 路由配置 }) // 监听路由异常 router.onError((err, to) => { // 匹配 Vite 的 ESM 动态导入失败报错 或 Webpack 的 ChunkLoadError：Importing a module script failed const isDynamicImportError = err?.message?.includes('Failed to fetch dynamically imported module') || err?.message?.includes('Importing a module script failed') || err?.name === 'ChunkLoadError'; if (isDynamicImportError) { // 采用 sessionStorage 避免多 Tab 污染和死循环 const reloadKey = 'dynamic-import-reload'; if (sessionStorage.getItem(reloadKey)) { console.error('Dynamic import error, reloading page did not fix it', err); } else { console.warn('Reloading page to fix dynamic import error'); sessionStorage.setItem(reloadKey, 'true'); // 使用 location.assign 加载目标路由的完整路径，确保获取最新 HTML const targetPath = `${import.meta.env.BASE_URL}${to.fullPath.replace(/^\//, "")}`; location.assign(targetPath); } } else { // 处理其他常规路由错误 console.error(err); } }); // 路由就绪后清理标记 router.isReady().then(() => { sessionStorage.removeItem('dynamic-import-reload'); }); export default router;方案原理解析精准拦截错误：Failed to fetch dynamically imported module 这个错误并不是 Vite 独有的，它是由浏览器抛出的标准规范。当 import() promise 触发 reject 时，Chrome/Edge/Firefox 都会输出这个标准 TypeError。通过字符串匹配，我们能精准定位到旧文件失效的场景（代码中也顺便兼容了 Webpack 的报错）。防止死循环：判断本地存储中是否已经有一个标记（推荐使用 sessionStorage 存储，防止多 Tab 页互相干扰）。如果已经有标记，说明之前已经重载过一次但依然失败（可能是网络断开或服务器宕机），此时不再重载，直接输出错误；如果没有标记，则设置标记并执行页面重载。正确重载页面：重载页面通过 location.assign(to.fullPath) 来实现，而不是用 location.reload()。如果你的项目设置了 base，需要加上 base 前缀。使用 assign 的好处是可以直接让浏览器以新路由的地址向服务器请求最新的 HTML 和文件。状态复原：当路由准备就绪（isReady）后，移除这个标记。这样下次应用长期停留后再次遇到版本更新时，依旧可以正常触发重载逻辑。补充内容在使用这种“异常捕获 + 自动重载”的方案时，还有两点需要特别注意：确保 index.html 绝对不被缓存此方案成立的前提是：重载页面后能拿到最新的 index.html。因此，必须在 Nginx 或服务器配置中，对 index.html 设置严格的强缓存禁用：location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; }如果 index.html 被浏览器或 Service Worker 缓存，location.assign 依然会加载旧的 Chunk Map，导致回退方案失效。状态丢失的权衡 (Trade-off)本方案的本质是硬刷新（Hard Reload）。这意味着用户当前在页面 A 的内存状态（如 Vuex/Pinia 中的临时数据、未提交的表单）将会丢失。相比于“强硬阻断的报错页面”，这是一种合理的降级妥协。如果你的业务对表单数据极其敏感，建议结合本地持久化（如把草稿存入 localStorage）来配合使用。总结不管是 Vue 还是 React，只要使用了构建工具的代码分割（Code Splitting）和动态导入（Dynamic Import）特性，都可以采用这种“捕获异常 + 路由重定向并重载”的思想。这种方案代码侵入性极低，无需服务端改动，是目前中小型项目处理版本更新报错性价比最高的回退策略。[/hide]
如何在 Vite 项目中优雅地展示用户协议？(Markdown 转 Vue 组件方案)
前言在日常的前端开发中，我们经常需要在界面上向用户出示“用户协议”、“隐私政策”和“操作指引”等长文本内容。如果将大段文字直接写死在 Vue 模板里或者存放到变量中，不仅代码会变得非常臃肿，而且极不利于后续的更新与维护（比如法务或运营要求修改个别字眼时，我们在成堆的代码里找起来非常头疼）。一种优雅且主流的解决方案是：独立维护纯文本的 Markdown（.md）格式的协议文件，然后借助构建工具的支持，直接将它们转换为 Vue 组件在弹窗中引入并渲染。本文将手把手带你了解如何利用 unplugin-vue-markdown 这一 Vite 插件，结合大家常用的 Element Plus UI 框架，在项目中快速实现协议的优雅展示。1. 安装核心依赖首先，我们需要安装能让 Vite 将 Markdown 解析为 Vue 组件的插件。在终端中运行以下命令：npm install unplugin-vue-markdown -D # 或者使用 pnpm / yarn注：该插件依赖于 @vitejs/plugin-vue，对于常规的 Vite+Vue3 项目通常已经默认安装好该核心依赖2. 配置 Vite 插件安装完成后，我们需要修改项目的构建配置。打开项目根目录下的 vite.config.ts 文件，进行如下调整：import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; // 引入 Markdown 插件 import Markdown from "unplugin-vue-markdown/vite"; export default defineConfig({ plugins: [ vue({ // 核心配置 1：让 Vue 的解析器同时处理 .vue 和 .md 后缀的文件 include: [/\.vue$/, /\.md$/] }), // 核心配置 2：注册 Markdown 解析器 Markdown({}), ] });解决 TypeScript 文件声明报错如果你现在直接引入md文件，大概率会遇到找不到模块“xxxx.md”或其相应的类型声明。的报错提示。为什么会这样？ 因为 TypeScript 默认只认识 .ts、.js 等代码文件，它并不理解 .md 文件是什么，也不知道它导出来的是什么东西。虽然 Vite 能正常打包，但编辑器里的红线实在影响心情和代码规范。如何解决？ 非常简单，我们只需要“告诉” TypeScript，所有的 .md 文件都可以当作普通的 Vue 组件来对待。在项目的 src 目录下，找到（或新建）一个类型声明文件，通常叫 env.d.ts、vite-env.d.ts 或专门新建一个 markdown.d.ts。在里面添加如下声明：// src/env.d.ts 或 src/vite-env.d.ts declare module "*.md" { import type { ComponentOptions } from "vue"; const Component: ComponentOptions; export default Component; }保存后，你会发现编辑器里的红灯报错神奇地消失了！完成这一步后，你的项目中就可以像引入 Vue 组件那样直接引入 Markdown 文件了。3. 引入 Markdown 的两种方式：静态与异步在准备好诸如 user-agreement.md 这样的 Markdown 协议文件后，我们有两种主要方式将它引入到我们的 Vue 页面中。方式一：直接静态导入（适合轻便短小的文本）import UserAgreement from "@/assets/agreements/user-agreement.md";优点：代码最直接，使用起来和普通组件完全一样。 缺点：会将文件的长文本直接打包进主 JS 文件里。如果协议特别长，会导致首屏加载变慢。方式二：异步动态导入（强烈推荐，适合长篇协议）这种方式利用了 Vue 3 的 defineAsyncComponent API，实现按需加载。import { defineAsyncComponent } from 'vue'; const UserAgreementAsync = defineAsyncComponent(() => import("@/assets/agreements/user-agreement.md") );优点：首屏不会加载这部分文字，只有在用户真正打开弹窗时，浏览器才会去单独请求下载这份协议文件，大大优化了页面包体积和加载速度。实战建议：针对“用户协议”这类动辄几千字的长篇大论，强烈推荐使用方式二进行异步加载。4. 封装协议展示弹窗 (基于 Element Plus)协议往往需要在一个弹窗中向用户展示。下面我们使用 Element Plus 的 el-dialog 来封装一个专门用于阅读协议的展示组件AgreementModal.vue <el-dialog v-model="visible" title="用户服务协议" width="50%" center > <Suspense> <component :is="AgreementContent" /> </Suspense> </el-dialog> 5. 项目中的完整调用示例现在，我们有了一个非常好用的只用于展示的弹窗组件。接下来我们在一个常见的“登录注册页面”里，将它引入并使用： 欢迎登录 <el-checkbox v-model="isCheck"> 已阅读并同意 </el-checkbox> 《用户服务协议》 <el-button type="primary" class="login-btn">登录</el-button> <AgreementModal v-model="showAgreement" /> 只要点击蓝色的《用户服务协议》，控制显示的变量 showAgreement 变为 true，AgreementModal 弹窗便会立刻弹起，顺滑过渡出你要向用户展示的长文本。6. 进阶：如何美化 Markdown 渲染后的样式？经过以上的步骤，你的 Markdown 已经成功渲染在页面上了！但是，你会发现文字可能挤在一起，缺少层次感，图片和表格也没什么高级感。为什么会这样？因为 unplugin-vue-markdown 插件在底层仅仅是把你的 md 文件解析成了类似下面这样的普通 HTML 标签骨架，并且最外层通常会被包裹在一个固定的 CSS Class 内（插件默认设定为 .markdown-body）： 用户服务协议 欢迎您使用本应用！ 第一条 隐私条款 原生的 HTML 标签（如 h1, p, table）是没有经过“精修”的新手默认样式的。你需要在项目中额外引入 CSS 去装饰它们。业界推荐的解决方案：为了不重复造轮子（自己从头手写所有的间距和字号排版非常繁琐），业界的标准做法是直接引入现成的 Markdown 第三方 CSS 主题包。在这里非常推荐使用 Github 官方的经典样式包：github-markdown-css。步骤一：安装样式包npm install github-markdown-css # 或者使用 pnpm / yarn步骤二：在弹窗组件中引入，并运用到外层容器上修改刚才的 AgreementModal.vue： <el-dialog ...> <Suspense> ... </Suspense> </el-dialog> 仅仅是多了两行代码，你的协议内容立刻就会变得和在 Github 或掘金等技术社区上看文章一样排版规整、段落分明、极其舒适了！如果你有定制化的主题颜色等要求，也可以利用 .markdown-body 这个 class 在自己的代码里手写 CSS 针对性覆盖重写。如果你的项目是支持亮色和暗色模式的，这个样式库也是支持的，你可以阅读我之前的文章：《在 Vue 项目中实现 github-markdown-css 的亮色与暗色模式动态切换》。
前言在我们开发 Vue 应用时，路由守卫（router.beforeEach）是无法避开的核心环节。我们需要在这里判断用户是否登录、是否完成实名认证、是否有权限访问当前页面……随着业务逻辑的不断增加，我们写出的路由守卫往往会变成下面这种难以维护的“面条代码”：router.beforeEach((to, from, next) => { // 从状态管理库获取用户的当前状态 const isLogin = store.isLogin; // 是否已登录 const isRealName = store.isRealNameVerified; // 是否已实名认证 // 判断目标页面是否必须登录才能访问 if (to.meta.requiresAuth) { if (!isLogin) { // 情况1：目标页面需要登录，但用户未登录 -> 拦截并跳转到登录页 next('/login'); } else { // 判断目标页面是否不仅需要登录，还需要“实名认证”才能访问 if (to.meta.requiresRealName && !isRealName) { // 情况2：已登录但未实名 -> 拦截并跳转到实名认证页 next('/realname'); } else { // 情况3：已登录，且（不需要实名 或 已经完成了实名） -> 正常放行 next(); } } } else if (to.meta.isGuest) { // 目标页面是“访客专属”页面（例如：登录页、注册页） if (isLogin) { // 情况4：用户已经登录了，就不该再访问登录页了 -> 强制送回首页 next('/'); } else { // 情况5：用户确实未登录，正常访问登录页 -> 放行 next(); } } else { // 情况6：无需任何权限的完全公开页面 -> 直接放行 next(); } });可以看到，在这段代码中出现了一层套一层的 if-else。随着条件越来越复杂，这段代码将变得非常难以阅读，只要稍微改错一个条件判断，或者漏写一个 next()，页面死循环崩溃是常有的事。那么，有没有一种方法能让路由鉴权变得清晰、优雅且易于扩展呢？答案就是使用策略模式。第一步：扩展路由 Meta 属性首先，我们需要在配置路由的时候，明确告诉路由守卫某个页面需要使用哪些策略。因此我们需要扩展 vue-router 的 RouteMeta 接口 的类型定义，增加一个 auth 数组，用于存放该页面对应的所有策略标识。在你的全局类型文件（如 types.d.ts 或 router.d.ts ）中添加如下代码：import 'vue-router' import type { AuthType } from "@/router/router-auth"; declare module "vue-router" { // 路由配置项 interface RouteMeta { // 路由鉴权类型数组，后续策略会按照数组中填写的顺序依次查验 auth?: AuthType[]; } } export {};这里的作用就是告诉 TypeScript，RouteMeta 中新增了一个 auth 属性，它是一个可选的数组，数组中的元素类型为 AuthType。这样在配置路由时，我们就可以在 meta 中使用 auth 来指定该路由需要的鉴权策略。如果你没有这个文件，请自行创建一个，并确保它被 tsconfig.json 的 include 包含。设置完毕后，我们在定义路由时就可以这样用了：meta: { auth: ["required", "real-name-verified"] }第二步：定义类型和策略接口为了让所有的策略能统一规范，我们需要定义一个通用接口。只要符合这个接口的类，就是一个标准的“策略”。新建 src/router/router-auth/types.ts：import { RouterAuthContext } from "./context"; // 策略模式接口规范 export interface AuthStrategy { // 每个策略都必须实现 execute 方法，返回一个布尔值表示：当前策略是否通过验证 execute(context: RouterAuthContext): boolean; } // 支持的鉴权类型（后续如果新增了权限规则，只需要在这里追加即可） export type AuthType = | "public" // 公开路由 | "guest" // 访客路由（已登录不能访问） | "required" // 必须登录 | "real-name-verified" // 必须实名认证 | "real-name-pending"; // 必须是未实名认证状态才能访问 第三步：打包公共的上下文数据（Context）在执行各项鉴权逻辑时，几乎每个策略都会用到当前用户的 isLogin（是否登录）状态，以及 Vue Router 本身提供的 to, from, next 函数。如果每次都当做参数繁琐地传来传去很不方便，所以我们把它们打包放进了一个“上下文（Context）”对象中集中管理。新建 src/router/router-auth/context.ts：import { useUserStore } from "@/stores"; // pinia 状态管理库 import type { NavigationGuardNext, RouteLocationNormalized } from "vue-router"; // 路由鉴权数据上下文 export class RouterAuthContext { // 存放当前用户的状态数据 isLogin: boolean; isRealNameVerified: boolean; constructor( public to: RouteLocationNormalized, public form: RouteLocationNormalized, public next: NavigationGuardNext ) { // 实例化 Context 对象时自动去 Pinia 取出用户状态，供后面的所有策略使用 const userStore = useUserStore(); this.isLogin = userStore.isLogin; this.isRealNameVerified = userStore.isRealNameVerified; } }第四步：编写具体的策略现在，我们可以把曾经那些乱糟糟的 if-else 分离出来，变成独立且专注的策略文件。我们来看两个最常用的策略例子：必须登录的策略 (router/strategy/required-strategy.ts)import { RouterAuthContext } from "../context"; import type { AuthStrategy } from "../types"; export class RequiredStrategy implements AuthStrategy { execute(context: RouterAuthContext): boolean { // 1. 从上下文中取出我们需要的数据 const { isLogin, to, next } = context; if (!isLogin) { // 2. 发现未登录：直接拦截，跳转登录页，并带上原本想要去的页面路径以备将来跳转回来 next({ name: "Login", query: { redirect: to.fullPath } }); return false; // 返回 false，告诉系统当前策略未通过，让系统停止校验后续其他策略 } // 3. 发现已登录：验证通过，允许放行 return true; } }访客专属的策略 (router/strategy/guest-strategy.ts) (比如：登录页面，只允许未曾登录过的纯访客访问)import { RouterAuthContext } from "../context"; import type { AuthStrategy } from "../types"; export class GuestStrategy implements AuthStrategy { execute(context: RouterAuthContext): boolean { const { isLogin, next } = context; if (isLogin) { // 已经登录的用户访问访客专属页面（如企图再次访问 /login ），强制拦截并送回首页 next({ name: "Dashboard" }); return false; } // 未登录的访客，允许通过 return true; } }以此类推，无论未来你的系统里蹦出什么样的奇葩权限要求，你只需要新建一个策略类实现它的逻辑，所有的老代码都可以做到一行不碰，互不干扰！第五步：组装策略执行引擎所有的零配件都有了，我们需要一个调度中心，根据每个路由 meta.auth 中配置的字符串，把对应的各种具体策略串联起来运行。新建 src/router/router-auth/index.ts：import { RouterAuthContext } from "./context"; import type { AuthType, AuthStrategy } from "./types"; import { RequiredStrategy } from "./strategy/required-strategy"; import { GuestStrategy } from "./strategy/guest-strategy"; // 根据需要继续引入其他的鉴权策略... export { RouterAuthContext }; export type * from "./types"; // 1. 将策略的字符串名称，与对应的策略实例做成映射表（字典） const StrategyMap: Record<AuthType, AuthStrategy> = { // 当路由中写了 "guest" 权限时，就执行 GuestStrategy 的实例逻辑 "guest": new GuestStrategy(), // 当路由写了 "required" 权限时，就执行 RequiredStrategy 的实例逻辑 "required": new RequiredStrategy(), // ... 其他你实例化的策略 }; // 2. 策略执行引擎 (批量运行策略) export function executeStrategies(strategies: AuthType[], context: RouterAuthContext): boolean { for (const strategyType of strategies) { const strategy = StrategyMap[strategyType]; if (!strategy) { console.warn(`未知的策略类型: ${strategyType}`); continue; } // 执行到具体的策略校验逻辑中 const passed = strategy.execute(context); if (!passed) { // 只要有一个策略返回了 false，说明在这个策略内部已经做出了 next() 拦截跳转。 // 这个时候必须 return false 以立刻中止掉整个循环，防止后面别的策略再去调用一次 next() 引发错误。 return false; } } // 所以策略执行完毕没有触发半路拦截，代表全部平稳通过 return true; }第六步：简化全局的路由守卫现在我们回到负责注册路由的 index.ts 或主体代码。看看使用策略模式后，最核心的 router.beforeEach 变得有多么清爽和简单：import { createRouter, createWebHistory } from "vue-router"; import { executeStrategies, RouterAuthContext } from "./router-auth"; const router = createRouter({ history: createWebHistory(), routes: [ // ...这里是你的路由配置 ] }); // 全局前置路由守卫 router.beforeEach((to, from, next) => { // 1. 获取当前路由配置的鉴权策略数组。如果没有配置 auth，默认按 ["required"]（必须登录）处理 const strategies = to.meta.auth ?? ["required"]; // 2. 实例化鉴权上下文，将常用数据统一打包 const context = new RouterAuthContext(to, from, next); // 3. 执行策略链！将复杂的 if-else 判断工作全权委托给策略执行引擎 const passed = executeStrategies(strategies, context); // 4. 判断结果 if (passed) { // 如果 passed 为 true，说明所有的策略验证均已通过 next(); } // 注意重点：如果 passed 是 false，不用写 else 语句！ // 因为在某一个未通过的策略内部（例如 RequiredStrategy 内部），它早已主动调用了 next('/login') 处理了重定向。 // 所以这个时候我们什么都不用管，策略内部自己完美闭环！ }); export default router;总结使用了策略模式后，我们彻底改变了对项目的掌控力：向 if-else 说再见： 无论逻辑增长有多复杂，主路由守卫里面的代码永远只有上面那清爽的几行，看着十分舒心。极佳的可扩展性： 某天产品经理要求临时加一个“仅限老用户的特权页面”？没问题，我们只要新建一个 OldUserStrategy 专属策略文件映射进去就好了，原有的代码你一行都不需要改！分工作业互不干扰： 每个鉴权规则独立成一个文件，非常有利于前端多人协作开发，再也不用担心你动了一个小逻辑影响到所有人的整个系统路由结构。
手把手实现 SWR 缓存策略，彻底告别页面闪烁
前言在开发移动端 H5 项目时，我们经常面临一个棘手的用户体验问题：页面切换时的加载闪烁。当用户在 Tabbar 之间切换时，页面组件通常会重新渲染并触发 API 请求。即使后端响应迅速，用户依然会看到短暂的“白屏”或“骨架屏”，随后内容突然跳出。这种反复的 Loading 状态打断了用户的操作流，体验并不友好。为了解决这一痛点，通常的思路是进行接口缓存。即：将数据缓存在本地，再次进入页面时优先读取缓存。但如果完全依赖缓存，又会导致数据更新不及时。如何在“极速渲染”和“数据新鲜度”之间找到平衡？业界有一个成熟的方案——SWR (Stale-While-Revalidate)。本文将带你在 Vue 3 项目中从零实现一套轻量级的 SWR 缓存机制。什么是 SWR？SWR 全称是 Stale-While-Revalidate，其核心理念可以概括为：“优先返回陈旧数据（Stale），同时后台发起请求（Revalidate），数据返回后更新 UI。”这种策略将网络请求与 UI 渲染解耦，让应用能够瞬间响应用户操作，而不必等待网络请求完成。为什么选择 SWR？相比传统的请求模式，SWR 带来了质的飞跃：极致的性能体验：优先读取缓存，用户切换页面时数据几乎“秒开”，完全感知不到加载过程。消除加载闪烁：UI 始终有内容展示，告别了反复出现的 Loading 动画或骨架屏。减轻服务器压力：配合 TTL（Time To Live，有效期）机制，在有效期内的重复访问可以直接拦截请求，降低后端负载。响应式与自动化：结合 Vue 3 的响应式系统，由于数据是 Ref，一旦后台请求返回新数据，视图会自动更新。核心实现解析我们的实现分为两层架构：底层的缓存管理工具和上层的 Vue Composable (Hooks)。1. 缓存管理工具 (src/utils/cache)为了满足不同场景的需求，我们设计了一个支持内存缓存和持久化缓存的双层缓存工具。内存缓存：使用 Map，刷新页面即失效，适合临时数据。持久化缓存：借助 vueuse 的 useLocalStorage，适合长期存储（如字典数据、用户信息）。首先定义类型接口：// src/utils/cache/types.ts /** 缓存项结构 */ export interface CacheItem<T = any> { data: T; timestamp: number; // 存入时间，用于计算过期 } export interface GetCacheOptions { key: string; ttl: number; // 有效期 (ms) persist?: boolean; // 是否持久化 } export interface SetCacheOptions<T> { key: string; data: T; persist?: boolean; }接下来是具体的实现逻辑，核心在于 getCache 时的 TTL 过期判定：// src/utils/cache/index.ts import { useLocalStorage } from "@vueuse/core"; import type { CacheItem, GetCacheOptions, SetCacheOptions } from "./types"; export type * from "./types"; // 内存缓存堆 const memoryCache = new Map<string, CacheItem>(); // 持久化缓存 (基于 LocalStorage) const persistentCache = useLocalStorage<Record<string, CacheItem>>("swr-cache-storage", {}); /** 读取缓存 */ export function getCache<T>(options: GetCacheOptions): T | null { const { key, ttl, persist = false } = options; if (!key) return null; let item: CacheItem | undefined; if (persist) { item = persistentCache.value[key]; } else { item = memoryCache.get(key); } // 判断ttl if (item && Date.now() - item.timestamp < ttl) { return item.data; } // 过期清理 removeCache(key, persist); return null; } /** 写入缓存 */ export function setCache<T>(options: SetCacheOptions<T>): void { const { key, data, persist = false } = options; if (!key) return; const item: CacheItem = { data, timestamp: Date.now() }; if (persist) { persistentCache.value[key] = item; } else { memoryCache.set(key, item); } } /** 删除缓存 */ export function removeCache(key: string, persist = false): void { if (!key) return; if (persist) { delete persistentCache.value[key]; } else { memoryCache.delete(key); } } /**清空所有缓存（调试用）*/ export function clearAllCache(): void { memoryCache.clear(); persistentCache.value = {}; }2. useSWR 组合式函数 (src/composables/useSWR)这是业务逻辑的封装层，它负责协调“读取缓存”和“发起请求”的竞态关系，并处理 Vue 的响应式状态。// src/composables/useSWR/types.ts export interface SWROptions { /** 缓存时间，单位毫秒，5分钟 = 5 * 60 * 1000 */ ttl?: number; /** */ auto?: boolean; /** 是否持久化（必须配合有效 key） */ persist?: boolean; /** 默认值 */ defaultData?: any; }import { getCache, setCache } from "@/utils/cache"; import type { SWROptions } from "./types"; export type * from "./types"; /** 默认缓存5分钟 */ const DEFAULT_TTL = 5 * 60 * 1000; export function useSWR<T>( key: string | Ref<string>, fetcher: (key: string) => Promise<T>, options: SWROptions = {} ) { const { ttl = DEFAULT_TTL, auto = true, persist = false, defaultData } = options; const data = shallowRef<T | null>(defaultData ?? null); const error = ref<Error | null>(null); const loading = ref(false); const currentKey = computed(() => (typeof key === "string" ? key : key.value)); const fetchData = async (isRefresh = false) => { const k = currentKey.value; if (!k) return; // 重置错误 error.value = null; // 读取缓存 const cached = getCache<T>({ key: k, ttl, persist }); // loading 策略 const shouldSetLoading = isRefresh || !cached; if (shouldSetLoading) { loading.value = true; } // 命中缓存 if (!isRefresh && cached) { data.value = cached; } try { // 静默刷新 const result = await fetcher(k); // 竞态锁：如果在等待 fetcher 结果期间，key 发生了变化，则放弃此次结果 if (currentKey.value !== k) return; // 更新缓存和数据 setCache({ key: k, data: result, persist }); data.value = result; return result; } catch (err) { error.value = err as Error; console.error(`[useSWR] Error for key "${k}":`, err); } finally { loading.value = false; } }; /** 重新获取数据 */ const refresh = () => fetchData(true); // 是否自动获取数据 if (auto) { if (typeof key === "string") { fetchData(); } else { watch( currentKey, (newKey, oldKey) => { if (newKey && newKey !== oldKey) { fetchData(); } }, { immediate: true } ); } } return { data, loading, error, refresh }; } 使用示例以常见的“首页分类列表”为例，这类数据变动频率低，非常适合 SWR。 <van-tabs v-model:active="activeTab"> <van-tab v-for="category in categoriesSWR.data.value" :key="category.id" :title="category.name" > <CategoryAppList :category-id="category.id" /> </van-tab> </van-tabs> 在这种实现下，用户第一次打开 App 可能需要加载；但当用户去其他页面溜达一圈再回来，或者关闭 App 再打开（如果开启了 persist），首页分类是瞬间展示的，体验极其丝滑。总结通过手写这个轻量级的 useSWR，我们解决了移动端 H5 项目中两个核心痛点：用户体验：消除了页面切换时的加载闪烁，实现了类似原生应用的流畅感。开发效率：将缓存逻辑从业务代码中抽离，只需一行代码即可复用。当然，这个实现还有优化的空间，例如添加请求去重（防止短时间内重复请求同一接口）、聚焦窗口重新验证（Window Focus Revalidation）等高级特性。但在大多数中小型项目中，目前的实现已经足以带来显著的体验提升。如果不想封装，也可以尝试使用更成熟的第三方库，比如：VueRequest，这个库支持分页SWR等更多复杂场景。
在 Vue 项目中实现 github-markdown-css 的亮色与暗色模式动态切换
前言最近项目中需要接入用户协议与隐私政策，法务部门提供的是 Word 文档。为了方便维护和在项目中直接渲染，我将文档转换为 Markdown 格式，并通过 Vite 的 unplugin-vue-markdown 插件将其作为 Vue 组件直接引入： <Privacy /> 内容渲染所在的容器通常带有一个 markdown-body 的类名。由于浏览器默认样式较为简陋，我决定引入 github-markdown-css 来复刻 GitHub 优雅的阅读体验。首先安装依赖：pnpm i github-markdown-css然而，在对接项目现有的亮/暗色模式切换机制时，我遇到了一些样式冲突的问题。本文将分享如何优雅地解决这一问题。项目中的暗色模式实现机制在介绍解决方案前，先简要说明项目中现有的主题切换方案。通常我们使用 vueuse 库中的 useDark 和 useToggle 来管理：import { useDark, useToggle } from "@vueuse/core"; const isDark = useDark({ valueDark: "dark", valueLight: "light" }); const toggleDark = useToggle(isDark);为了方便全局调用，这部分逻辑通常封装在 Pinia Store 中。该方案会自动监听系统主题变化，或通过用户手动切换，在 html 标签上动态添加或移除 dark 类名： ... 配合 CSS 变量，我们可以轻松实现主题切换：:root { --bg: #fff; } :root.dark { --bg: #000; } body { background-color: var(--bg); }遇到的问题：样式适配冲突在引入 github-markdown-css 时，最直接的方式是在 main.ts 中全局引入：import "github-markdown-css";问题在于：默认引入的样式文件是通过 CSS 媒体查询 @media (prefers-color-scheme: dark) 来适配系统主题的，它并不识别我们在 html 标签上添加的 .dark 类名。这导致当用户在项目中手动切换主题时，Markdown 的样式无法随之切换。解决方案：动态加载样式文件为了解决上述冲突，我们需要根据当前的模式（Light/Dark）动态加载对应的 CSS 文件。在 github-markdown-css 5.0 及以上版本中，作者提供了独立的文件：github-markdown-light.css 和 github-markdown-dark.css。需要注意的是，这两个文件不能同时引入，否则样式规则会发生冲突。因此，最佳实践是使用 标签引入样式，并根据主题状态动态修改其 href 属性。在 Vue 项目中，我们可以利用 @unhead/vue 来优雅地管理头部标签。题外话：如果你为了更好的响应体验，我觉得可以使用两个link标签，分别对应亮色和暗色模式，然后通过 disabled 属性来控制启用状态。不过本文不展开讨论这一点。1. 安装依赖pnpm i @unhead/vue安装完成后，在 main.ts 中注册插件：// main.ts import { createHead } from '@unhead/vue/client' import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) const head = createHead() app.use(head) app.mount('#app')如果使用了自动引入插件（如 unplugin-auto-import），可以在 vite.config.ts 中添加：import AutoImport from "unplugin-auto-import/vite"; import { unheadVueComposablesImports } from "@unhead/vue"; export default defineConfig(() => { return { plugins: [ AutoImport({ imports: [ unheadVueComposablesImports ] }) ] } });2. 实现动态切换逻辑我们可以在根组件 App.vue 或布局组件中编写如下逻辑。利用 Vite 的 ?url 后缀，我们可以获取 CSS 文件的静态资源路径，而不是将其作为样式直接注入到 JS bundle 中。 <router-view /> 总结通过 ?url 引入资源路径配合 useHead 的响应式处理，我们成功实现了 github-markdown-css 样式跟随项目主题的丝滑切换。这种方式既保留了 GitHub 的原生样式体验，又完美兼容了基于类名的暗色模式方案。
Vue 3 进阶实战：基于 Promise 的通用弹窗管理器（命令式调用）
🚀 前言在 Vue 项目开发中，弹窗（Dialog/Modal/Drawer）是交互的核心载体。然而，随着业务复杂度的攀升，传统的声明式弹窗管理逐渐暴露出“样板代码多”、“状态分散”、“逻辑割裂”等痛点。本文将介绍一种基于 Promise 驱动的命令式弹窗管理方案，它能让你像调用 API 一样从 JS/TS 中直接唤起弹窗，并获取用户操作结果，彻底告别繁琐的 visible 变量。😫 痛点分析：传统模式的“四宗罪”看看这段典型的父组件代码，你是否感到熟悉且头疼？ <UserEditDialog v-model="showUserEdit" :id="curId" @success="refresh" /> <FilePicker v-model="showFilePicker" @confirm="onSelect" /> <ConfirmDialog v-model="showDelete" @confirm="doDelete" /> 核心痛点：状态爆炸：每增加一个弹窗，就要增加一套 ref 和 props 变量。模板污染：组件即使未被使用，也必须在 中预先挂载，影响 DOM 结构清晰度。复用困难：想在 Pinia 或纯 JS 文件中唤起弹窗？传统方式几乎做不到。流程割裂：触发逻辑与回调逻辑在代码空间上分离，难以阅读。🛠️ 解决方案：Promise 驱动的命令式调用我们借鉴 Element Plus ElMessageBox 的设计思想，实现一套通用的管理器。调用体验对比：// ✅ 优化后：像 await 异步函数一样流畅 const handleEdit = async (user) => { try { // 一行代码唤起，直到用户操作才继续往下执行 const formData = await modal.open({ component: UserEditDialog, props: { userId: user.id } }); // 拿到结果直接调用 API await updateUser(formData); Message.success('更新成功'); } catch (e) { console.log('用户取消了操作'); } }🧠 硬核解析：useModal 核心实现原理为了让技术实现更透彻，我们需要像“手术刀”一样剖析 useModal 内部到底发生了什么。1. 核心设计图解整个管理器可以看作是一个 "Promise 句柄劫持中心"。调用 open()：创建一个 Promise，但暂时不 resolve。劫持句柄：将 resolve 和 reject 函数提取出来，连同组件信息（Props, Component）挂载到一个响应式数组 modals 中。渲染层响应：全局挂载的渲染组件监测到 modals 变化，自动渲染对应的弹窗组件。用户交互：用户在弹窗点击“确定”，触发渲染层的 confirm 方法。归还句柄：管理器找到对应的 resolve 函数并执行，Promise 状态翻转，await 结束，父组件拿到数据。2. 代码实现 (Hooks)先定义类型// src/hooks/useModal/types.ts import type { Component } from "vue"; /** 弹窗持久化配置 */ export interface PersistentOptions { /** 手动指定唯一 key（推荐） */ key?: string; /** 根据 props 动态生成 key */ keyGenerator?: (props: Record<string, any>) => string; /** 全局单例模式（整个应用只有一个实例，无论 props 是什么） */ singleton?: boolean; } /** 打开弹窗参数 */ export interface OpenOptions { /** 弹窗组件 */ component: Component; /** props */ props?: Record<string, any>; /** 是否常驻 */ persistent?: boolean | PersistentOptions; } /** 弹窗实例 */ export type ModalInstance<T = any> = { modalId: symbol; visible: boolean; component: Component; props?: Record<string, any>; resolve: undefined | ((value: T) => void); reject: undefined | ((reason?: any) => void); }; /** 受控的弹窗组件基础props */ export interface BaseModalProps { modalId: symbol; } /** 受控的弹窗组件基础emit事件 */ export interface BaseModalEmit { confirm: [val: any]; cancel: [reason?: any]; }接着定义核心逻辑// src/hooks/useModal/index.ts import type { ModalInstance, OpenOptions, PersistentOptions } from "./types"; export type * from "./types"; interface InternalModal<T = any> extends ModalInstance<T> { persistent?: boolean | PersistentOptions; _key?: string | symbol; // 内部计算出的唯一 key } class ModalManager { private modals = ref<InternalModal[]>([]); private componentIds = new WeakMap<Component, symbol | string>(); /** 打开弹窗 */ public open<T = any>(options: OpenOptions): Promise<T> { const { component, props, persistent } = options; // 计算唯一 key，用于复用 const key = this.computeKey(options); // 查看是否已经存在可复用弹窗 const existing = key ? this.modals.value.find((m) => m._key === key && m.persistent) : null; if (existing) { existing.props = props; existing.visible = true; // 必须重置 Promise 句柄，因为上一次的 promise 早已结束 return new Promise((resolve, reject) => { existing.resolve = resolve; existing.reject = reject; }); } // 创建弹窗 return new Promise((resolve, reject) => { const modalId = Symbol("modal"); const _key = key ?? modalId; const instance: InternalModal<T> = { modalId, visible: false, component: markRaw(component), props, // 关键点：将 Promise 的控制权暴露给外部 resolve, reject, persistent, _key }; this.modals.value.push(instance); // 记录组件引用（用于单例模式） this.componentIds.set(component, _key); // HACK: Element Plus 弹窗的open事件会在 “由关闭 → 打开” 的状态变化中触发，所以这里等下一个 tick 时设置为true // 确保组件先挂载(v-if/v-for生效)，下一帧再设置 visible=true， // 这样才能正确触发 ElementPlus/AntDesign 等 UI 库的进入动画 nextTick().finally(() => { const modal = this.getByModalId(modalId); if (!modal) return; modal.visible = true; }); }); } /** 确认关闭（resolve） */ public confirm<T>(modalId: symbol, value?: T) { const modal = this.getByModalId(modalId); if (!modal) return; modal.resolve?.(value as T); modal.resolve = void 0; modal.reject = void 0; // 关闭弹窗，不销毁 modal.visible = false; } /** 取消关闭（reject） */ public cancel(modalId: symbol, reason?: any) { const modal = this.getByModalId(modalId); if (!modal) return; modal.reject?.(reason); modal.resolve = void 0; modal.reject = void 0; // 关闭弹窗，不销毁 modal.visible = false; } /** 关闭弹窗的回调方法，用于销毁非常驻弹窗 */ public closed(modalId: symbol) { const modal = this.getByModalId(modalId); if (!modal) return; if (typeof modal.reject === "function") { modal.reject(new Error("用户取消了")); modal.resolve = void 0; modal.reject = void 0; } // 非常驻弹窗，销毁 if (!modal.persistent) { this.removeByModalId(modal.modalId); } } /** 获取当前弹窗栈（仅供渲染层使用） */ public get list() { return this.modals.value as readonly ModalInstance[]; } /** 手动销毁常驻弹窗实例 */ public destroyPersistent(options: OpenOptions) { if (!options.persistent) return; const key = this.computeKey(options); if (!key) return; this.modals.value = this.modals.value.filter((m) => m._key !== key); this.componentIds.delete(options.component); } /** 销毁全部弹窗 */ public destroyAll() { this.modals.value = []; } /** 计算唯一 key */ private computeKey(options: OpenOptions): symbol | string | undefined { const { component, props = {}, persistent } = options; if (!persistent) return void 0; const config = typeof persistent === "boolean" ? {} : persistent; // 情况1：手动指定 key if (config.key) return config.key; // 情况2：动态生成 key if (config.keyGenerator) return config.keyGenerator(props); // 情况3：单例模式 const key = this.componentIds.get(component); return key; } /** 通过modalId获取弹窗实例 */ private getByModalId(modalId: symbol): InternalModal | undefined { return this.modals.value.find((m) => m.modalId === modalId); } /** 通过modalId删除弹窗实例 */ private removeByModalId(modalId: symbol) { this.modals.value = this.modals.value.filter((m) => m.modalId !== modalId); } } // 单例导出 const modalManager = new ModalManager(); export function useModal() { return modalManager; }渲染层实现 (The Renderer)渲染层需要处理一个关键问题：Props 透传与事件监听。// src/components/GlobalModalRenderer.vue <component :is="item.component" :modal-id="item.modalId" v-bind="item.props" v-model="item.visible" @confirm="(val: any) => modal.confirm(item.modalId, val)" @cancel="(reason?: any) => modal.cancel(item.modalId, reason)" @closed="() => modal.closed(item.modalId)" /> 渲染层主要处理以下事项：:modal-id="item.modalId" 自动注入唯一 ID。v-bind="item.props" 批量绑定业务 Props。v-model="item.visible" 双向绑定 visible，控制显示隐藏事件绑定，监听业务事件，桥接到 Promise。最后我们在App.vue中挂载这个组件：// App.vue <GlobalModalRenderer /> 💻 场景实战：更复杂的业务场景光有理论不够，我们来看几个真实的业务场景代码。场景一：带表单验证的用户编辑（数据回传）这是最常见的场景：打开弹窗 -> 填表 -> 校验 -> 点击确定 -> 返回数据。UserEditModal.vue <el-dialog v-model="visible" title="编辑用户" width="500px"> <el-form ref="formRef" :model="formData" :rules="rules"> <el-form-item label="用户名" prop="name"> <el-input v-model="formData.name" /> </el-form-item> </el-form> <el-button @click="visible = false">取消</el-button> <el-button type="primary" @click="handleConfirm">保存</el-button> </el-dialog> 调用方：const openEdit = async () => { try { // 等待弹窗关闭并拿到 formData const formData = await modal.open({ component: UserEditModal, props: { userInfo: { name: 'Jack' } } }); // 只有校验通过才会走到这里 console.log('用户提交的数据:', formData); await api.updateUser(formData); } catch (e) { console.log('用户取消或关闭了弹窗'); } }场景二：弹窗内部处理异步逻辑（Loading 状态）有时我们希望在弹窗内部点击“确定”后，先调用 API，成功后再关闭弹窗。AsyncActionModal.vue <el-dialog v-model="visible" title="异步操作"> 确定要执行敏感操作吗？ <el-button type="primary" :loading="loading" @click="handleConfirm"> 确定执行 </el-button> </el-dialog> 💾 进阶：持久化 (Persistence) 详解弹窗的创建和销毁是有性能开销的。对于地图选择器、富文本编辑器等重型组件，我们希望它关闭后只是 v-show="false" 隐藏，而不是销毁 DOM。配置解析：全局单例 (Singleton) 无论调用多少次，整个应用生命周期内该组件只有一个实例。modal.open({ component: HeavyEditorModal, persistent: { singleton: true } // 与 singleton: true 等价 });动态 Key (Key Generator) 根据传入的参数决定是否复用。例如：聊天窗口，同一个人复用一个窗口，不同人创建新窗口。modal.open({ component: ChatWindow, props: { userId: 101 }, persistent: { // 返回相同的 string 就会复用实例 keyGenerator: (props) => `chat-${props.userId}` } });指定 Key (推荐) 如果你需要复用的弹窗组件引用不变，但是参数不同，又希望他们能区分复用，比如参数A的弹窗一个实例，参数B的弹窗一个实例，虽然都基于SimpleDialog，但是两个都是独立的，那么使用指定key的方式来实现。// 场景 A：选择源目录（状态独立） modal.open({ component: FilePicker, persistent: { key: "source-dir-picker" }, props: { title: "请选择源目录" } }); // 场景 B：选择输出目录（状态独立） modal.open({ component: FilePicker, persistent: { key: "output-dir-picker" }, props: { title: "请选择输出目录" } });手动销毁实例持久化的弹窗不会自动销毁（只会隐藏）。如果需要释放内存或重置状态，可以使用 destroyPersistent。// 销毁指定配置的持久化实例 modal.destroyPersistent({ component: FilePicker, persistent: { key: "source-dir-picker" } // persistent的配置也必须和open时传入的配置相同 }); // 或者暴力销毁所有 modal.destroyAll();📝 总结通过 useModal，我们成功将弹窗管理重构为Promise 驱动的命令式服务。它的核心优势在于：清洁的代码：父组件 0 状态变量，逻辑线性流畅。极致的解耦：弹窗组件与调用方彻底解耦，甚至可以在非 Vue 组件文件（如 axios 拦截器）中唤起登录弹窗。强大的扩展：天然支持 TS 类型推导，支持持久化缓存，支持多种 UI 库。