木灵鱼儿 - vue 项目实战
type: channel
link: https://www.mulingyuer.com/category/vue-%E9%A1%B9%E7%9B%AE%E5%AE%9E%E6%88%98/
items: 从零开始：手把手教你封装一个企业级 Axios 请求模块
items: 03-Vue Query 高级进阶：应对复杂业务场景的硬核套路
items: 02-Vue Query 快速入门：从零构建你的第一个声明式查询
items: 01-异步状态管理新范式：为什么在 Vue 3 中使用 vue-query？
items: 生产部署时动态导入 Chunk 失效的实用回退方案
items: 如何在 Vite 项目中优雅地展示用户协议？(Markdown 转 Vue 组件方案)
items: Vue 路由守卫进阶：用策略模式告别 if-else
items: 手把手实现 SWR 缓存策略，彻底告别页面闪烁
items: 在 Vue 项目中实现 github-markdown-css 的亮色与暗色模式动态切换
items: Vue 3 进阶实战：基于 Promise 的通用弹窗管理器（命令式调用）

从零开始：手把手教你封装一个企业级 Axios 请求模块
type: item
date: 2026-06-23T16:40:00+08:00
description: 前言在现代前端开发中，一个健壮、易用且与业务解耦的请求模块是项目成功的基石。本教程将带你一步步实现项目中现有的 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…

03-Vue Query 高级进阶：应对复杂业务场景的硬核套路
type: item
date: 2026-06-23T16:21:01+08:00
description: 前言在前两篇文章中，我们完成了 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 限制：在非…

02-Vue Query 快速入门：从零构建你的第一个声明式查询
type: item
date: 2026-06-23T16:19:56+08:00
description: 前言在上一篇文章中，我们探讨了为什么要引入 @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. Quer…

01-异步状态管理新范式：为什么在 Vue 3 中使用 vue-query？
type: item
date: 2026-06-23T16:08:00+08:00
description: 前言在现代 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 即可在任意组件间共享数据，降低了状态管理的复杂度。项目实战：它们是如何在项目中优雅配合的？在实际项目中，我们通过将…

生产部署时动态导入 Chunk 失效的实用回退方案
type: item
date: 2026-03-16T23:06:00+08:00
description: 前言与痛点在单页应用（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 加载目标路…

如何在 Vite 项目中优雅地展示用户协议？(Markdown 转 Vue 组件方案)
type: item
date: 2026-03-05T14:33:00+08:00
description: 前言在日常的前端开发中，我们经常需要在界面上向用户出示“用户协议”、“隐私政策”和“操作指引”等长文本内容。如果将大段文字直接写死在 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") );优点：首屏不会加载这部分文字，只有在用户真正打开弹窗时，浏览器才会去单独请求下载这份协议文件，大大优化了页面包体积和加载速度。实战建议：针对“用户协议”这类动辄几千字的长篇大论，强烈推荐使用方…

Vue 路由守卫进阶：用策略模式告别 if-else
type: item
date: 2026-03-04T14:34:18+08:00
description: 前言在我们开发 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" // 必须实名…

手把手实现 SWR 缓存策略，彻底告别页面闪烁
type: item
date: 2026-01-06T11:32:43+08:00
description: 前言在开发移动端 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…

在 Vue 项目中实现 github-markdown-css 的亮色与暗色模式动态切换
type: item
date: 2026-01-04T17:36:00+08:00
description: 前言最近项目中需要接入用户协议与隐私政策，法务部门提供的是 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 /> 总结通过…

Vue 3 进阶实战：基于 Promise 的通用弹窗管理器（命令式调用）
type: item
date: 2025-11-27T15:19:17+08:00
description: 🚀 前言在 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 | ((reaso…