让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战
让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战博客装了 YoduPlayer 这款背景音乐播放器后，一直有个很破坏体验的问题：音乐正放着，点进一篇文章，页面一刷新，歌就断了。访客每看一篇新文章就要重新点一次播放，背景音乐的"背景"两个字完全名存实亡。这篇文章记录我把这个问题彻底解决的全过程：从原理分析，到方案选型，再到自己动手写一个约 200 行的轻量 PJAX，最后处理掉一串切页后才会暴露的隐藏 Bug。完整代码都在文中，可以直接抄走用。一、先搞清楚：歌为什么会断浏览器里的音频播放依赖一个 DOM 元素 （YoduPlayer 里就是全局的 yaudio 对象）。而传统页面跳转的本质是：点击链接 → 浏览器销毁整个文档 → 请求新页面 → 重新解析 HTML/CSS/JS文档销毁的瞬间，挂在文档里的 元素跟着被销毁，播放自然中断。新页面里插件重新输出了一套播放器 HTML 和 JS，但那是一个全新的 yaudio，播放进度、当前曲目全部归零。所以问题的根源不在插件，而在于整页刷新这种导航方式本身。想让音乐不断，就不能销毁承载播放器的那份文档。二、方案选型：为什么是 PJAX解决思路业界已经很成熟了——局部刷新：导航时只替换页面的内容区域，头部、底部、播放器所在的 DOM 保持不动。实现方式常见的有三种：方案原理缺点iframe把整站套进框架页URL 不变、SEO 灾难、移动端体验差SPA 改造前端框架接管路由Typecho 主题基本要重写，成本过高PJAXAJAX 拉取新页面 + History API 改地址只需主题小幅配合PJAX（PushState + AJAX）的原理一句话就能说清：拦截链接点击 → preventDefault 阻止跳转 → XHR 拉取新页面 HTML → 用 DOMParser 解析 → 只取内容区域替换进当前文档 → pushState 更新地址栏整个过程文档从未销毁， 一直活着，音乐自然不断。同时 URL 会真实变化、浏览器前进后退可用、对搜索引擎完全透明——这是它碾压 iframe 的地方。YoduPlayer 的 README 里也写了"需要主题支持 pjax 或 instantclick"，说明作者早就预留了这条路，缺的只是主题侧的实现。我的主题是 Joe，直接引现成的 PJAX 库和主题代码有各种兼容性小毛病，所以干脆自己写了一个定制版。三、动手实现3.1 确定内容容器边界第一步是划分"哪些 DOM 切页时要换，哪些不能动"。看 Joe 主题的 index.php 结构： <?php $this->footer(); ?> 很清晰：#Joe 是内容容器，切页时替换它的 innerHTML；播放器、脚本初始化代码都在容器外，天生不受影响。如果你的主题结构不同，把这层边界划对是第一件事。3.2 核心 PJAX 脚本在主题里新建 assets/lib/pjax/pjax.js，核心不到 200 行：(function () { 'use strict'; var CONTAINER = '#Joe'; var NO_INSTANT = 'data-no-instant'; var xhr; history.replaceState({ url: location.href }, '', location.href); // 1. 拦截站内链接点击 document.addEventListener('click', function (e) { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var link = e.target.closest('a'); if (!link) return; if (link.target === '_blank' || link.hasAttribute('download')) return; var href = link.getAttribute('href'); if (!href || href.charAt(0) === '#' || href.indexOf('javascript:') === 0) return; var url; try { url = new URL(link.href); } catch (err) { return; } if (url.origin !== location.origin) return; // 外链放行 if (url.pathname.indexOf('/admin/') === 0) return; // 后台放行 if (url.href.split('#')[0] === location.href.split('#')[0]) return; // 沿 DOM 向上检查 data-no-instant，标记了就走原生跳转 var el = link; while (el && el !== document.documentElement) { if (el.hasAttribute && el.hasAttribute(NO_INSTANT)) return; el = el.parentNode; } e.preventDefault(); loadPage(url.href, true); }); // 2. 前进 / 后退 window.addEventListener('popstate', function (e) { loadPage((e.state && e.state.url) || location.href, false); }); // 3. 拉取新页面 function loadPage(url, push) { if (xhr) xhr.abort(); xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.timeout = 15000; xhr.onload = function () { var ct = xhr.getResponseHeader('Content-Type') || ''; if (xhr.status >= 200 && xhr.status < 400 && ct.indexOf('text/html') >= 0) { applyPage(xhr.responseText, url, push); } else { location.href = url; // 兜底：非 HTML 直接真跳转 } }; xhr.onerror = function () { location.href = url; }; xhr.ontimeout = function () { location.href = url; }; xhr.send(); } // 4. 替换内容 + 重执行脚本 function applyPage(html, url, push) { var newDoc = new DOMParser().parseFromString(html, 'text/html'); document.title = newDoc.title; var oldC = document.querySelector(CONTAINER); var newC = newDoc.querySelector(CONTAINER); if (!oldC || !newC) { location.href = url; return; } oldC.innerHTML = newC.innerHTML; // 关键：innerHTML 插入的 3.3 与 YoduPlayer 的分工做完上面这步，音乐其实已经不会断了。剩下的工作是让播放器的 UI 在切页后依然正常。YoduPlayer 的加载分两部分，正好对应两种处理方式：// Plugin.php footer() 中的关键输出 // 音频引擎 + 核心函数：带 data-no-instant，切页不重执行 // UI 层：不带标记，切页后随容器重建 分工非常清晰：player.js + 内联配置：持有 yaudio、musicArr 这些全局状态和 playbtu()、next() 这些核心函数，标记为 data-no-instant，一次加载终身有效；prpr.js：负责播放器界面，重新生成歌单列表 DOM、把播放/切歌按钮绑定到还活着的 yaudio、同步当前曲目和封面。它不带标记，每次 PJAX 切页后都会重新执行一遍，相当于给新 DOM"接上"旧引擎。如果你在给其他播放器做类似改造，把"状态"和"UI"拆开、状态部分标记为不重执行，就是最核心的思路。四、不断歌之后，才轮到真正的坑音乐连续了只是第一步。整页刷新被干掉后，一堆原本"刷新后自然解决"的问题全部浮出水面：4.1 主题初始化不再触发Joe 主题把文章列表懒加载、轮播、各种事件绑定全放在一个 DOMContentLoaded 监听器里。PJAX 换完内容后这个事件不会自己触发，页面看着是换了，交互全是死的。解法就是上面代码里的那句：try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (err) {}手动补发一次，主题的初始化逻辑就会对新内容重跑一遍。4.2 评论表单的安全令牌失效这是最隐蔽的一个。上线后发现：PJAX 切页后发的第一条评论永远失败，报"评论发表失败"。排查后发现 Typecho 有个防spam机制：页面 head 里有一段内联脚本，会在评论表单里动态注入一个 name="_" 的隐藏字段作为令牌，服务端校验它。这段脚本在 里，PJAX 只替换内容容器根本不会碰它，于是新页面里表单是新的、令牌是旧的，校验必然失败。解法是在 applyPage 里补一段：解析新页面 head 中的内联脚本，把包含 name = '_'（防spam）和 TypechoComment（评论回复逻辑，它的 respondId 是每篇文章独有的）的挑出来手动执行：var headInline = newDoc.head.querySelectorAll('script:not([src])'); for (var hi = 0; hi < headInline.length; hi++) { var hcode = headInline[hi].textContent; if (hcode.indexOf("name = '_'") !== -1 || hcode.indexOf('TypechoComment') !== -1) { var hns = document.createElement('script'); hns.textContent = hcode; document.head.appendChild(hns); document.head.removeChild(hns); } }4.3 评论重复提交评论能发之后，又发现每条评论提交了两次。原因是主题的 joe.global.min.js 在补发的 DOMContentLoaded 里又绑定了一次 AJAX 评论处理器，和 PJAX 脚本里的处理器叠加了。解法是绑定评论事件时用 e.stopImmediatePropagation() 抢占，并在每次切页后 $form.off('submit') 清掉旧绑定再重绑，保证表单上永远只有一个处理器。4.4 收起状态记忆失效播放器有个收起/展开状态存在 localStorage 里。切页后新 DOM 是初始展开状态，需要在 PJAX 完成后重新读取并应用。YoduPlayer 的 prpr.js 开头有现成逻辑：if (localStorage.getItem("yoduplayer_collapsed") === "1") { document.getElementById('bgmplayer').classList.remove("bgmon"); }这也侧面验证了前面"UI 层每次重执行"设计的正确性——这类状态同步逻辑放在 UI 层，天然会在每次切页后自动跑一遍。五、总结回头看，整件事的技术含量不在 PJAX 本身（核心逻辑不到 200 行），而在于理解"整页刷新"一直在默默帮你处理什么，并在干掉它之后把这些事情一一接手：脚本重执行、主题重初始化、评论令牌重注入、事件重绑定。总结几条可复用的经验：容器边界划分是 PJAX 改造的第一步，播放器必须在容器外；状态与 UI 分离，状态脚本标 data-no-instant，UI 脚本每次重建后重执行；补发 DOMContentLoaded 让主题的初始化逻辑重跑；异常兜底真跳转，任何失败路径都不能让用户白屏；切页后评论、表单类功能务必手动测一遍，令牌和事件绑定最容易漏。现在博客的背景音乐从进站到离开可以一路连续播放，切页只换内容不换"灵魂"。如果你也在用 Typecho + YoduPlayer，希望这篇能帮你少踩几个坑。插件地址：YoduPlayer - GitHub，感谢作者 Jrotty 的开源贡献。
AB-Admin：给 Typecho 后台换上 Material Design 3 新装
前言Typecho 的默认后台界面，怎么说呢——功能齐全，但长相确实停留在上个时代的审美。白底蓝链接、紧凑的表格、没有圆角没有过渡动画，每次写文章都像在填 Excel 表格。如果你也有同感，那 AB-Admin（Admin Beautify） 可能正是你在找的东西。这是一款基于 Material Design 3 设计体系的 Typecho 后台美化增强插件，号称"可能是史上颜值最高的 Typecho 后台美化插件"。本文结合我博客上实际安装使用的 v2.1.43 版本，从功能介绍、安装配置到实际体验，完整聊聊这个插件。插件概览项目信息插件名AB-Admin（原名 Admin Beautify）作者LHL当前版本v2.1.43设计体系Material Design 3（MD3）Typecho 版本基于 1.3.0 开发，兼容 1.2.1GitHubgithub.com/lhl77/Typecho-Plugin-AdminBeautify配套插件AB Store（AdminBeautifyStore）插件仓库核心功能1. MD3 主题色系统AB-Admin 不是简单换个 CSS 皮，而是注入了一套完整的 Material Design 3 设计 Token 变量。打开插件代码可以看到，它定义了几十组 CSS 自定义属性：:root { --md-primary: #6750A4; --md-on-primary: #FFFFFF; --md-surface: #FFFBFF; --md-on-surface: #1C1B1F; --md-outline: #79747E; /* ... 几十个变量 */ }暗色模式也有一套对应变量（--md-dark-primary、--md-dark-surface 等），切换时自动生效。这意味着插件不仅仅改了配色，而是建立了一套可被其他插件引用的设计系统。插件内置了多种预设配色方案（紫色、红色、蓝色等），也可以自定义主题色。颜色值会根据亮/暗模式自动生成对应的明暗变体。2. 亮暗模式切换通过 标签的 data-theme="light" 或 data-theme="dark" 属性手动切换，不依赖系统偏好。这比 @media (prefers-color-scheme: dark) 更灵活——你可以白天用亮色、晚上切暗色，不管系统设置是什么。对于插件开发者来说，暗色样式建议用 [data-theme="dark"] 选择器覆盖，而不是依赖媒体查询：/* 正确写法 */ .my-card { background: var(--md-surface-container-low, #f7f2fa); } [data-theme="dark"] .my-card { background: var(--md-surface-container-low, #1d1b20); }3. 响应式设计内置移动端适配，在手机上自动切换为顶部折叠菜单模式。后台管理不再只能坐在电脑前操作——手机上也能舒舒服服地审阅评论、管理文章。4. 自定义登录页背景支持设置登录页背景图片 URL，并可调整虚化方式和虚化大小。告别千篇一律的默认登录页，给博客一个有辨识度的后台入口。5. 双布局模式支持侧边栏布局和顶部导航布局两种后台布局，根据个人习惯自由切换。侧边栏模式更传统，顶部导航模式则更接近现代 Web 应用的风格。6. AJAX 页面导航后台页面切换采用 AJAX 方式，不完整重载页面。每次导航后会派发 ab:pageload 事件，让兼容脚本知道页面变了、该重新执行修复了。// 监听 AB-Admin 的 AJAX 导航事件 document.addEventListener('ab:pageload', function(e) { var url = e.detail.url; console.log('页面已切换到:', url); // 在这里重新绑定 DOM 事件、注入样式等 });这个设计直接影响了兼容脚本的工作方式——稍后详细说。开发者 APIAB-Admin 不只是"好看"，它还提供了一套面向插件开发者的 API。横幅通知：showNotice()if (typeof AdminBeautify !== 'undefined') { AdminBeautify.showNotice('操作成功！', { type: 'success' }); }调用前记得判断 AdminBeautify 是否存在，避免插件未启用时报错。对话框 API：alert / confirm / promptAB-Admin 覆写了原生的 alert、confirm、prompt，将它们替换为 MD3 风格的异步弹窗。同时也提供了 Promise 化的 API：// 异步确认框 const result = await AdminBeautify.confirm('确定删除这篇文章？'); if (result) { // 用户点了确认 } // 异步输入框 const name = await AdminBeautify.prompt('请输入分类名称：');踩坑提醒：覆写后，原生 confirm() 和 prompt() 不再阻塞——它们会立即返回 false / null。如果你的老代码依赖同步返回值，逻辑会被打断，需要改用 Promise API。兼容性脚本系统这是 AB-Admin 最有特色的设计之一。插件目录下有个 assets/compat/ 文件夹，专门存放用于修复其他插件排版问题的 JS 脚本：assets/compat/ ├── FuckAdComment.js # 反广告评论增强 ├── Links.js # Links Plus 友链插件兼容 ├── Mirages.js # Mirages 主题兼容 ├── Notice.js # Notice 通知插件兼容 ├── TelegramNotice.js # Telegram 推送插件兼容 ├── TeStore.js # TeStore 插件仓库兼容 ├── Typecho121.js # Typecho 1.2.1 版本兼容 └── README.md # 开发文档工作原理AB-Admin 自动扫描 compat/ 目录下所有 .js 文件每个脚本的元数据（名称、简介、适用插件）展示在设置页面用户可单独启用/禁用每个脚本支持一键从 GitHub 同步最新兼容脚本支持添加外部 JS 链接加载额外兼容脚本脚本开发规范每个兼容脚本需要在头部注释中声明元数据：/** * @name MyPlugin 兼容 * @description 修复 MyPlugin 在 AB-Admin 下的排版问题 * @plugins MyPlugin * @version 1.0.0 * @author YourName */关键注意事项：必须判断页面：通过 URL 或 DOM 确认是否为目标页面，不要影响其他页面监听 ab:pageload：由于 AJAX 导航，脚本只在首次加载执行一次，不监听此事件则跳转后修复不生效保持幂等：修复函数可能被多次调用，避免重复注入；离开目标页面时需清理已注入的样式兼容暗色模式：同时处理 [data-theme="dark"] 下的显示效果AB Store：配套插件仓库AB-Admin 还有一个配套插件 AdminBeautifyStore（AB Store），它是一个 Typecho 插件仓库，可以在后台直接搜索、安装、管理其他插件，不需要再手动下载上传。AB Store 的主要功能：在后台浏览插件市场一键安装/更新插件支持开发者投稿与 AB-Admin 深度集成，安装的插件自动适配兼容脚本两个插件配合使用，Typecho 的插件管理体验直接拉满，从"手动 FTP 时代"进入了"应用商店时代"。安装方法方法一：手动安装前往 GitHub Releases 下载最新版本解压后将 AdminBeautify 文件夹上传至 /usr/plugins/进入后台 → 控制台 → 插件 → 启用 AdminBeautify清除浏览器缓存（重要！）方法二：通过 AB Store 安装如果你已经装了 AB Store，可以直接在插件市场搜索安装。安装后配置启用插件后进入设置页面，可以配置：主题色：选择预设方案或自定义颜色布局模式：侧边栏 / 顶部导航登录页背景：填入图片 URL，调整虚化参数兼容脚本：逐个启用/禁用字体：可选加载 Noto Sans SC外部 JS：添加额外兼容脚本链接实际体验我博客上实际安装的就是 v2.1.43 版本。几个直观感受：界面：MD3 风格确实好看很多，圆角卡片、层次分明的阴影、流畅的过渡动画，写文章时心情都好了。暗色模式在夜间使用非常舒适，不是简单的黑白反转，而是有一套完整的暗色色板。性能：主 JS 文件约 200KB，CSS 约 197KB，体积不算小但可以接受。AJAX 导航让后台切换页面几乎无感，比原生整页刷新快不少。兼容性：内置的兼容脚本覆盖了常见的 Typecho 插件（Notice、Links Plus、TelegramNotice 等），基本开箱即用。如果你用的是不太常见的插件，可能需要自己写兼容脚本，但文档写得很清楚，门槛不高。稳定性：从 2.1.4x 版本起完全不再加密 JS 代码，透明度很高。作者更新也比较勤快，GitHub 上的 issue 响应及时。注意事项清除缓存：启用后如果样式异常，先清浏览器缓存异步弹窗陷阱：confirm() / prompt() 不再阻塞，老代码需要适配1.2.1 兼容性：Typecho 1.2.1 下导航栏可能有 CSS 问题，需在设置中开启 1.2.1 兼容脚本CSS 冲突：如果之前装过其他后台美化插件（如 SimpleAdmin），可能存在样式冲突弹窗 z-index：第三方插件的全局弹窗建议 z-index 设为 9999 以上（AB 侧边栏约 1000）字体继承：自定义组件建议用 font-family: inherit 跟随页面字体总结AB-Admin 是目前 Typecho 生态里完成度最高的后台美化插件，没有之一。它不只是换了一套皮肤，而是建立了一套完整的设计系统——从 MD3 色彩变量到开发者 API，从兼容脚本机制到 AJAX 导航，每个环节都考虑到了。如果你还在用 Typecho 默认后台，强烈建议试试。装完之后你会觉得：原来 Typecho 后台也可以这么好看。GitHub：github.com/lhl77/Typecho-Plugin-AdminBeautifyAB Store：github.com/lhl77/Typecho-Plugin-AdminBeautifyStore作者博客：blog.lhl.one
Vue 3 插槽深度实战：默认、具名与作用域插槽及自定义指令
如果说 props 是组件的"输入参数"，插槽就是组件的"内容参数"。写好一个可复用的布局组件、表格组件、弹窗组件，绕不开插槽；而自定义指令则是 DOM 级复用的利器。这两个主题合起来，是 Vue 中级开发者必须吃透的内容。一、插槽的三种形态1. 默认插槽父组件传入的内容替换子组件的 <slot> 占位： <slot></slot> <Card> 订单详情 共 3 件商品，合计 ¥299.00 </Card>2. 具名插槽：多区域布局组件有多个内容区域时，用 name 区分。典型场景：页头/页身/页脚的布局组件。 <slot name="header"></slot> <slot></slot> <slot name="footer"></slot> <PageLayout> 管理后台 这里是主内容区域 © 2026 My Corp </PageLayout>注意 #header 是 v-slot:header 的缩写，且 v-slot 只能写在 上（默认插槽的简写除外）。3. 作用域插槽：数据反向传递普通插槽的内容编译在父组件作用域，无法访问子组件内部数据。作用域插槽让子组件把数据"抛"给插槽内容： <slot :item="item" :index="index"> {{ item.name }} </slot> #{{ index + 1 }} {{ item.orderNo }} {{ statusText[item.status] }} 这是设计"无头组件"（Headless Component）的核心思想：组件负责数据和逻辑，父组件负责渲染结构。Element Plus 的 Table、el-select 的 option 都大量使用作用域插槽。实战：封装一个通用描述列表组件综合运用具名 + 作用域插槽，封装一个类似 Ant Design Descriptions 的组件： {{ item.label }} <slot :name="item.field" :item="item"> {{ item.value }} </slot> <DescList :items="[ { field: 'name', label: '姓名', value: user.name }, { field: 'avatar', label: '头像', value: user.avatar }, { field: 'status', label: '状态', value: user.status } ]"> <el-tag :type="item.value === 1 ? 'success' : 'danger'"> {{ item.value === 1 ? '正常' : '禁用' }} </el-tag> </DescList>一个组件同时满足了通用性和定制性——这就是插槽设计的精髓。二、自定义指令当复用的是 DOM 行为而不是组件结构时，用指令。指令只在底层操作 DOM，不关心业务。注册与钩子// main.js 全局注册 const app = createApp(App) app.directive('focus', { mounted(el) { el.focus() } })Vue 3 指令的钩子与组件生命周期对齐：钩子触发时机created元素属性和事件监听器设置之前beforeMount挂载之前mounted挂载到 DOM 后beforeUpdate更新前updated更新后beforeUnmount卸载前unmounted卸载后绝大多数指令只需要 mounted 和 updated，可以用简写函数形式：app.directive('color', (el, binding) => { el.style.color = binding.value })实战指令一：v-loading// directives/loading.js export const vLoading = { mounted(el, binding) { const mask = document.createElement('div') mask.className = 'v-loading-mask' mask.innerHTML = ' ' el.style.position = el.style.position || 'relative' el.appendChild(mask) el.__loadingMask = mask toggle(el, binding.value) }, updated(el, binding) { toggle(el, binding.value) }, unmounted(el) { el.__loadingMask?.remove() } } function toggle(el, show) { el.__loadingMask.style.display = show ? 'flex' : 'none' } {{ row.name }} 实战指令二：v-debounce按钮防抖是高频需求，每个地方手写 setTimeout 太啰嗦：export const vDebounce = { mounted(el, binding) { const [fn, delay = 300] = binding.value instanceof Array ? binding.value : [binding.value, 300] let timer = null el.__debounceHandler = (event) => { if (timer) clearTimeout(timer) timer = setTimeout(() => fn(event), delay) } el.addEventListener('click', el.__debounceHandler) }, unmounted(el) { el.removeEventListener('click', el.__debounceHandler) } } 提交订单 实战指令三：v-permission 权限控制import { useUserStore } from '@/stores/user' export const vPermission = { mounted(el, binding) { const userStore = useUserStore() const required = binding.value // 'order:delete' const modifiers = binding.modifiers // { some: true } → v-permission.some let hasPermission if (modifiers.some) { // 任一权限即可 hasPermission = [].concat(required).some(p => userStore.permissions.includes(p)) } else { hasPermission = userStore.permissions.includes(required) } if (!hasPermission) { el.parentNode?.removeChild(el) } } } 删除订单 复合操作 插槽 vs 指令：选型原则维度插槽指令复用内容结构 + 样式DOM 行为典型场景布局、列表渲染定制权限、防抖、拖拽、埋点作用对象组件原生 DOM 元素灵活性高（父级完全接管渲染）中（操作属性和事件）一个简单的判断：需求是"换个样子"用插槽，需求是"加点行为"用指令。总结作用域插槽 = 子组件向插槽内容"回传数据"，是无头组件的基石具名插槽支撑多区域布局，#name 是标准缩写自定义指令只在需要直接操作 DOM 时使用，能用组件解决的别用指令指令钩子与组件生命周期对齐，简写形式覆盖 mounted + updatedv-loading、v-debounce、v-permission 是三个最值得收进工具箱的指令把插槽和指令用好，你封装的组件会从"能用"进化到"好用"。下一篇文章我们聊聊组合式函数（Composables）——Vue 3 逻辑复用的终极形态。
Vue Router 4 路由实战：动态路由、导航守卫与懒加载
任何多页面 SPA 都绕不开 Vue Router。但很多项目对它的使用停留在"能跳转"——路由懒加载没配、守卫写得一团乱麻、动态路由权限方案稀里糊涂。本文以 Vue Router 4（对应 Vue 3）为准，把这几个中级必考点一次讲透。基础配置与懒加载路由懒加载是必选项打包工具默认会把所有页面组件打进一个 bundle，首屏加载动辄几 MB。动态 import() 让每个页面按需加载：// router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('@/views/Home.vue'), children: [ { path: 'orders', component: () => import('@/views/order/List.vue') }, { path: 'orders/:id', component: () => import('@/views/order/Detail.vue'), props: true // 把路由参数作为 props 传入组件 } ] } ] })配合 Vite 的手动分包，可以进一步把公共依赖抽出来：// vite.config.ts export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'] } } } } }props 解耦：别在组件里读 $route 函数模式更灵活：{ path: 'orders/:id', component: OrderDetail, props: route => ({ id: Number(route.params.id), tab: route.query.tab }) }动态路由与权限系统后台管理系统的经典需求：不同角色看到不同菜单。标准方案是前置白名单 + 动态添加路由：第一步：定义静态与动态路由// router/index.ts import { createRouter, createWebHistory } from 'vue-router' // 静态路由：任何人都能访问 export const constantRoutes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/404', component: () => import('@/views/404.vue') } ] // 动态路由：按角色分配，meta.roles 记录可访问角色 export const asyncRoutes = [ { path: '/', component: () => import('@/views/Layout.vue'), children: [ { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '工作台', roles: ['admin', 'operator'] } }, { path: 'system', component: () => import('@/views/system/Index.vue'), meta: { title: '系统管理', roles: ['admin'] } } ] } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes })第二步：登录后过滤并挂载// stores/permission.ts import { defineStore } from 'pinia' import { asyncRoutes, constantRoutes } from '@/router' function hasPermission(roles, route) { return route.meta?.roles ? roles.some(role => route.meta.roles.includes(role)) : true // 没声明 roles 默认放行 } export function filterRoutes(routes, roles) { return routes.reduce((acc, route) => { const tmp = { ...route } if (hasPermission(roles, tmp)) { if (tmp.children) { tmp.children = filterRoutes(tmp.children, roles) } acc.push(tmp) } return acc }, []) } export const usePermissionStore = defineStore('permission', { state: () => ({ accessibleRoutes: [] }), actions: { generateRoutes(roles) { this.accessibleRoutes = [...constantRoutes, ...filterRoutes(asyncRoutes, roles)] return this.accessibleRoutes } } })第三步：addRoute 动态注册// 登录成功后 const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(user.roles) routes.forEach(route => router.addRoute(route))注意：addRoute 后必须用 router.replace 或返回新 location 触发一次重新导航，否则匹配不到刚添加的路由。导航守卫体系Vue Router 4 的守卫分三类，执行顺序必须烂熟于心：导航触发 → beforeEach（全局前置） → beforeEnter（路由独享） → beforeRouteUpdate / beforeRouteEnter（组件内） → afterEach（全局后置）全局前置守卫：登录鉴权标准模板const WHITE_LIST = ['/login', '/404'] router.beforeEach(async (to, from) => { const userStore = useUserStore() // 1. 白名单直接放行 if (WHITE_LIST.includes(to.path)) return true // 2. 未登录跳登录页，带上 redirect 参数 if (!userStore.token) { return { path: '/login', query: { redirect: to.fullPath } } } // 3. 已登录但还没拉取用户信息/动态路由 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(userStore.roles) routes.forEach(r => router.addRoute(r)) // 关键：addRoute 后重新进入当前路由 return { ...to, replace: true } } catch { userStore.logout() return { path: '/login', query: { redirect: to.fullPath } } } } // 4. 正常放行 return true })守卫返回值语义Vue Router 4 的守卫返回值规则：返回值效果undefined / true放行false取消导航路由地址对象 / 字符串重定向Promiseresolve 上述值，reject 则取消并报错afterEach：动态修改页面标题router.afterEach((to) => { document.title = to.meta?.title ? `${to.meta.title} - 管理系统` : '管理系统' })组件内守卫：离开确认表单页防止用户误关，用 onBeforeRouteLeave： 滚动行为与过渡动画const router = createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition // 前进后退恢复位置 if (to.hash) return { el: to.hash, behavior: 'smooth' } // 锚点 return { top: 0 } // 默认回到顶部 } })路由切换配合 <transition> 做页面动画： <router-view v-slot="{ Component, route }"> <transition name="fade" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view> useRoute 与 useRouter 中通过组合式 API 获取路由：import { useRoute, useRouter } from 'vue-router' const route = useRoute() // 当前路由信息（响应式）：params、query、meta const router = useRouter() // 路由实例：push、replace、go // 带参数跳转 router.push({ name: 'order-detail', params: { id: 123 }, query: { tab: 'logs' } }) // query 变化时重新拉数据（同一路由复用时 watch） watch(() => route.query.tab, (tab) => { fetchList(tab) })易错点：route.params 不是深度响应式的可靠来源，路径参数变化而组件复用时（如 /orders/1 → /orders/2），要 watch route.params.id 或给 router-view 加 :key。常见坑速查刷新 404：动态路由方案里，刷新后路由表被重置，必须在守卫里重新 addRoute（见上文模板第 3 步）通配符路由位置：{ path: '/:pathMatch(.*)*' } 必须放在动态路由 addRoute 之后，否则先匹配到 404history 模式 404：服务器需配置所有路径回落到 index.html（Nginx 的 try_files $uri $uri/ /index.html;）循环重定向：守卫里 next() 与返回值混用会导致逻辑混乱，Vue Router 4 统一用返回值风格keep-alive 失效：配合动态路由时 include 需要组件 name，确保组件显式声明了 name（defineOptions({ name: 'OrderList' })）总结懒加载是标配，props: true 让组件与路由解耦权限路由三步走：静态/动态路由分离 → 登录后按角色过滤 → addRoute + 重新导航守卫统一用返回值风格，beforeEach 模板可以直接抄走onBeforeRouteLeave 处理离开确认，scrollBehavior 处理滚动恢复刷新 404 和通配符路由顺序是动态路由方案最常踩的两个坑路由系统是后台管理项目的骨架，把这套方案吃透，遇到再复杂的权限场景都能从容拆解。
Vue 3 + TypeScript 类型化开发实战：从 Props 到泛型组件
Vue 3 是用 TS 重写的，类型支持是其核心卖点。但很多项目只是"用了 TS"，模板里的类型断言满天飞、组件 Props 无类型、第三方库全是 any。本文覆盖 Vue 3 + TS 的核心类型工具，从基础用法一路讲到泛型组件，帮你把类型系统真正用起来。一、Props 类型化的三个层次层次一：运行时校验（纯 JS 风格）defineProps({ id: [Number, String], items: { type: Array, required: true } })有运行时警告，但没有编辑器类型推导，TS 项目不推荐。层次二：类型声明（类型推导最佳） 层次三：withDefaults 提供默认值类型声明语法不支持直接给默认值，需要 withDefaults：const props = withDefaults( defineProps<{ items: Order[] pageSize?: number labels?: Record<string, string> }>(), { pageSize: 20, labels: () => ({}) // 对象/数组默认值必须用工厂函数 } )从接口自动生成 Props（进阶）interface Props { userId: number; compact?: boolean } // 响应式解包后仍是响应式的 const props = defineProps<Props>()props 是被 reactive 包装的对象，解构会丢失响应性。需要解构时用 Vue 3.5+ 的响应式 Props 解构：const { userId, compact = false } = defineProps<Props>() // 3.5+ 编译器自动保持响应性二、emit 与 ref 的类型化emit 类型const emit = defineEmits<{ (e: 'change', value: string): void (e: 'select', id: number, item: Order): void }>() // Vue 3.3+ 更简洁的具名元组语法 const emit2 = defineEmits<{ change: [value: string] select: [id: number, item: Order] }>()父组件在模板上监听时，回调参数类型自动校验。ref 的类型// 基础：自动推导 const count = ref(0) // Ref<number> // 初始值为 null 时必须显式标注 const el = ref<HTMLInputElement | null>(null) onMounted(() => el.value?.focus()) // 复杂对象推荐接口先行 interface FormData { name: string tags: string[] } const form = ref<FormData>({ name: '', tags: [] })模板引用组件实例import FormModal from './FormModal.vue' const modalRef = ref<InstanceType<typeof FormModal> | null>(null) onMounted(() => { modalRef.value?.open() // expose 的方法有完整类型 })InstanceType<typeof Component> 会读取 defineExpose 暴露的成员类型，这是父组件调用子组件方法的类型安全姿势。三、computed 与 watch 的类型const orderList = ref<Order[]>([]) const pendingCount = computed(() => orderList.value.filter(o => o.status === 'pending').length ) // 自动推导 ComputedRef<number> // watch 回调参数类型自动对应数据源 watch( () => props.order.status, (newStatus, oldStatus) => { // newStatus: 'pending' | 'paid' | 'closed' console.log(`${oldStatus} → ${newStatus}`) } ) // watchEffect 不需要指定类型，内部自动收集依赖四、provide / inject 的类型安全// symbols/keys.ts —— 集中管理 InjectionKey import type { InjectionKey, Ref } from 'vue' export interface UserContext { user: Ref<{ id: number; name: string }> refresh: () => Promise<void> } export const UserKey: InjectionKey<UserContext> = Symbol('user')// 祖先组件 provide(UserKey, { user, refresh }) // 后代组件：完整类型推导 + 缺省兜底 const ctx = inject(UserKey) if (!ctx) throw new Error('UserKey 未在祖先组件提供') ctx.user.value.name // string，自动推导五、泛型组件：类型跟着数据走普通组件的 Props 类型是固定的，泛型组件让类型由调用方决定。最典型的场景是列表组件和选择器组件。普通写法的困境// ❌ items 只能声明成 any[]，丢失元素类型 defineProps<{ items: any[]; modelValue: any }>()泛型组件写法 <slot :item="item">{{ item[labelField ?? 'id'] }}</slot> generic="T extends { id: number }" 声明泛型参数，调用方传 Order[] 时所有相关类型自动实例化为 Order：<SelectList v-model="selectedOrderId" :items="orders" label-field="title" @select="(order) => console.log(order.status)" // order: Order，类型完整 />泛型 composable：类型化的 useListexport function useList<T>(initial: T[] = []) { const list = ref<T[]>([...initial]) function add(item: T) { list.value.push(item) } function remove(predicate: (item: T) => boolean) { list.value = list.value.filter(i => !predicate(i)) } function find(predicate: (item: T) => boolean): T | undefined { return list.value.find(predicate) } return { list, add, remove, find } } // 使用：所有方法参数和返回值都有精确类型 const { list, add, find } = useList<Order>([]) add({ id: 1, title: 'x', status: 'pending' }) // OK add({ id: 2, title: 'y' }) // 报错：缺 status六、SFC 与 TS 工程细节defineComponent 与组件类型 外部类型文件的组织src/ ├── types/ │ ├── api.d.ts # 后端接口类型（可由 OpenAPI 生成） │ ├── models.ts # 业务模型 Order、User 等 │ └── global.d.ts # 全局类型扩展 ├── components/api.d.ts 建议用工具从后端 Swagger/OpenAPI 规范生成，杜绝手抄接口字段。常用工具类型速查import type { Ref, ComputedRef, MaybeRef, UnwrapRef } from 'vue' // MaybeRef<T>：参数既可以是 T 也可以是 Ref<T> function useX(source: MaybeRef<string>) { /* ... */ } // UnwrapRef<T>：ref 的解包类型 const state = ref({ nested: { count: 0 } }) // state.value.nested.count 类型是 number（自动解包） // ExtractPropTypes：从运行时 props 选项提取类型 import type { ExtractPropTypes } from 'vue' const propsSchema = { title: String, count: { type: Number, default: 0 } } type Props = ExtractPropTypes<typeof propsSchema>vue-tsc 做模板类型检查vue-tsc 能检查模板中的表达式类型，接入 CI：// package.json { "scripts": { "type-check": "vue-tsc --noEmit" } }模板里 {{ order.statu }} 这类错误会在构建前暴露，而不是上线后白屏。七、别过度类型化类型是工具不是目的，几个克制的建议：联合类型优先于枚举：'pending' | 'paid' 比 enum 更利于 tree-shaking后端接口类型交给代码生成，手写注定跟不上变化第三方库无类型时的最小兜底：declare module 'xxx'，而不是到处 any断言 as 只用于"我确信类型系统不知道的事"，当作逃生舱而非常规操作总结Props 用类型声明语法，默认值交给 withDefaultsdefineEmits 用具名元组语法，参数类型双向校验InstanceType<typeof Comp> 是引用子组件的规范类型InjectionKey<T> 让 provide/inject 摆脱字符串裸奔泛型组件 generic="T" 是封装列表/选择器类组件的杀手锏vue-tsc 进 CI，模板类型错误提前拦截类型系统用到位后，重构敢下手、接口变更立刻报错、新人看类型就能懂用法——这 defensive 能力正是中级向高级进阶的分水岭。
Vue 3 组件通信全解：props、emit、v-model 与 provide/inject
组件通信是 Vue 中级开发的核心分水岭。小型项目里随意用 ref 传递数据也能跑起来，但一旦组件层级变深、业务变复杂，选错通信方式会让代码迅速腐化。本文系统梳理 Vue 3 中所有主流通信手段的适用场景、实现原理与常见陷阱。通信方式全景图按数据流向，Vue 3 的组件通信可以分为四类：类型手段适用关系父传子props / attrs单向数据流子传父emit / expose事件回调双向绑定v-model表单、弹窗跨层级provide / inject深层嵌套全局状态Pinia任意组件一个重要的原则：优先用简单的方向。能用 props 解决的不要上 provide，能局部的不要全局。一、props：父传子的标准姿势props 遵循单向数据流，父组件修改数据会自动向下流动，子组件不允许直接修改 props。 {{ user.name }} 运行时校验与类型推导纯 JS 项目用对象语法做运行时校验；TS 项目更推荐类型声明方式，编辑器推导更完整： 常见陷阱：直接修改 props子组件里改 props 对象的内部属性（如 props.user.name = 'x'）虽然不报错（对象是引用传递），但会污染父组件状态，是最难排查的一类 bug。正确做法是通过 emit 通知父组件修改。二、emit：子传父的事件通道 <ConfirmDialog @confirm="handleConfirm" @cancel="showDialog = false" />声明式 emit 的必要性很多同学习惯不声明直接 emit('xxx')。在 <SearchInput v-model="keyword" />等价于老写法：// Vue 3.0 - 3.3 的写法 const props = defineProps(['modelValue']) const emit = defineEmits(['update:modelValue']) // 子组件更新时 emit('update:modelValue', newValue)多值绑定：具名 v-model一个组件可以绑定多个 v-model，通过参数名区分： 至 <RangePicker v-model:start="query.start" v-model:end="query.end" />修饰符v-model 支持自定义修饰符，通过 defineModel 的返回值读取： 四、provide / inject：跨层级注入当组件层级超过两层还硬用 props 层层传递（俗称"props 钻井"），就该用 provide/inject 了。 切换主题 响应性保持的关键provide 的值如果是 ref，inject 拿到的就是同一个 ref，响应性天然保持。但如果你这样写：// ❌ 错误示范：解构后再 provide，丢失响应性 const theme = ref('dark') provide('theme', theme.value) // 传入的是普通字符串后代组件拿到的是快照值，不会更新。务必传 ref 本身或包含 ref 的对象。只读注入：防止子组件乱改子组件可以直接改注入的 ref，这会破坏数据流向。更安全的模式是只暴露方法：provide('theme', { theme: readonly(theme), // 只读代理 toggleTheme // 修改必须走方法 })子组件试图改 theme.value 会收到警告。TS 类型安全注入provide/inject 的 key 是字符串或 Symbol，容易写错且无类型提示。用 InjectionKey 解决：// keys.ts import type { InjectionKey, Ref } from 'vue' export interface ThemeContext { theme: Ref<'dark' | 'light'> toggleTheme: () => void } export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')// 提供方 provide(ThemeKey, { theme, toggleTheme }) // 注入方：自动推导类型，且默认值类型也被约束 const ctx = inject(ThemeKey) if (!ctx) throw new Error('ThemeKey 未提供')五、attrs 与 expose：两个容易忽略的通道$attrs：透传属性没被声明为 props 的属性会落到 $attrs 上。封装组件时常用它透传原生属性： <slot /> 父组件写 <BaseButton type="submit" :disabled="loading"> 时，type 和 disabled 会自动透传到原生 button 上。注意 inheritAttrs: false 的设置场景：根元素有多个时需要手动绑定到正确元素。expose：受控暴露实例 选型决策清单实际开发中按这个顺序决策：父子相邻 → props + emit，没有例外表单类、弹窗类组件 → v-model（defineModel）三层以上的共享配置（主题、国际化、当前用户） → provide/inject跨页面、跨模块的业务状态 → Pinia任何"绕过去"的冲动（操作 DOM、改 $parent、事件总线）→ 停下来重新设计总结props/emit 是骨架，v-model 是语法糖，provide/inject 是电梯，Pinia 是中枢defineModel() 是 Vue 3.4+ 双向绑定的标准答案provide 必须传 ref 本身才能保持响应性InjectionKey 让 inject 拥有完整类型推导expose 让组件对外 API 显式化，避免黑盒掌握这些通信模式后，你可以应对 90% 的组件设计场景。下一篇我们讲插槽与自定义指令——组件通信之外的另一半中级技能。
组合式函数（Composables）是 Vue 3 逻辑复用的官方答案。它取代了 Vue 2 时代的 mixins，解决了命名冲突、来源不清、类型推导弱三大顽疾。本文从零封装五个高频 Composable，讲透设计要点与避坑细节。为什么是 Composables 而不是 Mixins先看 mixins 的三大原罪：来源不透明：组件里用了一个 this.fetchData()，根本不知道是组件自己的还是某个 mixin 的命名冲突：两个 mixin 定义了同名属性，静默覆盖类型黑洞：TS 无法推导 mixin 注入的内容Composables 是普通函数，数据来源明确（const { data } = useFetch(url)），返回值命名完全由调用方决定，天然解决以上所有问题：const { data: orderList, loading } = useFetch('/api/orders') const { data: userList, loading: userLoading } = useFetch('/api/users') // 命名冲突？重命名就行Composable 的三条铁律命名以 use 开头，这是社区强约定只在 setup 上下文中同步调用，不能在回调、事件处理器里调用（因为内部可能依赖 onMounted 等生命周期钩子，它们要求在 setup 同步调用期间注册）返回值用普通对象包 ref，而不是数组里混 ref 和普通值，方便调用方解构重命名实战一：useRequest —— 通用请求管理这是使用频率最高的 Composable，覆盖请求、竞态、手动触发：// composables/useRequest.ts import { ref, shallowRef, onUnmounted, type Ref, unref, watchEffect } from 'vue' interface UseRequestOptions<T> { immediate?: boolean // 是否立即执行 debounce?: number // 防抖毫秒数 } export function useRequest<T>( url: () => string | Ref<string>, options: UseRequestOptions<T> = {} ) { const { immediate = true, debounce } = options const data = shallowRef<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) let abortController: AbortController | null = null let timer: ReturnType<typeof setTimeout> | null = null async function execute() { // 竞态处理：取消上一次未完成的请求 abortController?.abort() abortController = new AbortController() loading.value = true error.value = null try { const res = await fetch(unref(url), { signal: abortController.signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) data.value = await res.json() } catch (e: any) { if (e.name !== 'AbortError') { error.value = e } } finally { loading.value = false } } function run() { if (debounce) { timer && clearTimeout(timer) timer = setTimeout(execute, debounce) } else { execute() } } // url 是响应式 ref 时，自动重新请求 watchEffect(() => { unref(url) if (immediate) run() }) // 组件卸载时取消进行中的请求，防止内存泄漏 onUnmounted(() => { abortController?.abort() timer && clearTimeout(timer) }) return { data, loading, error, run } }使用起来非常顺手： 几个设计细节值得注意：shallowRef 存 data：大列表数据不必深度响应式，浅层够用且省内存AbortController 防竞态：搜索框快速输入时，只有最后一次请求的结果会生效onUnmounted 清理：Composable 内注册生命周期钩子，自动绑定到调用它的组件上，这是 Composable 相比普通函数的核心优势实战二：useMouse —— 事件监听封装// composables/useMouse.ts import { ref, onMounted, onUnmounted } from 'vue' export function useMouse() { const x = ref(0) const y = ref(0) function update(e: MouseEvent) { x.value = e.pageX y.value = e.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return { x, y } }调用方代码极其干净： 鼠标位置：{{ x }}, {{ y }} 如果希望返回值可以直接当数字用，用 toValue 系列工具或返回 getter：import { computed } from 'vue' const { x, y } = useMouse() const pos = computed(() => `(${x.value}, ${y.value})`)实战三：useLocalStorage —— 持久化响应式状态// composables/useLocalStorage.ts import { ref, watch, type WatchCallback } from 'vue' export function useLocalStorage<T>(key: string, initialValue: T) { // 初始化：优先读缓存 const stored = localStorage.getItem(key) const state = ref<T>( stored ? JSON.parse(stored) : initialValue ) as Ref<T> // 监听变化自动写入，深度监听覆盖对象/数组 watch(state, (val) => { try { localStorage.setItem(key, JSON.stringify(val)) } catch (e) { console.warn('localStorage 写入失败', e) } }, { deep: true }) // 多标签页同步：storage 事件 if (typeof window !== 'undefined') { window.addEventListener('storage', (e) => { if (e.key === key && e.newValue) { state.value = JSON.parse(e.newValue) } }) } function remove() { localStorage.removeItem(key) state.value = initialValue } return { state, remove } }const { state: settings } = useLocalStorage('app-settings', { theme: 'dark', fontSize: 14 }) // 改 settings.value.theme 自动持久化，刷新页面不丢实战四：useToggle —— 布尔状态切换export function useToggle(initial = false) { const state = ref(initial) const toggle = (val?: boolean) => { state.value = val ?? !state.value } const setTrue = () => toggle(true) const setFalse = () => toggle(false) return { state, toggle, setTrue, setFalse } }看着简单，但弹窗、下拉、折叠面板全用得上，能把散落各处的 showXxx = !showXxx 收编成统一模式。实战五：useInterval —— 定时器管理import { ref, onUnmounted } from 'vue' export function useInterval(cb: () => void, delay = 1000, immediate = true) { const timer = ref<ReturnType<typeof setInterval> | null>(null) function start() { if (timer.value) return timer.value = setInterval(cb, delay) } function stop() { if (timer.value) { clearInterval(timer.value) timer.value = null } } if (immediate) start() onUnmounted(stop) // 组件销毁自动清理，杜绝定时器泄漏 return { start, stop } }倒计时验证码是典型场景：const seconds = ref(60) const { start, stop } = useInterval(() => { if (--seconds.value <= 0) stop() }, 1000)响应式输入：ref、getter 还是原始值？设计 Composable 的输入参数时，Vue 3.3+ 提供了统一的 toValue()：import { toValue, type MaybeRefOrGetter } from 'vue' export function useFoo(source: MaybeRefOrGetter<string>) { const value = toValue(source) // ref / getter / 原始值通吃 }这样调用方想传什么就传什么，Composable 内部不需要关心：useFoo('静态字符串') useFoo(computed(() => dynamicUrl.value)) useFoo(() => props.someProp) // getter，保持响应性常见错误与修正错误一：返回响应式丢失// ❌ 返回解构后的值，丢失响应性 return { data: data.value, loading: loading.value } // ✅ 返回 ref 本身 return { data, loading }错误二：异步函数里调用 Composable// ❌ setup 已经执行完，onMounted 无法注册 async function setup() { const { x } = await Promise.resolve().then(() => useMouse()) } // ✅ 同步调用，内部再异步 const { x } = useMouse()错误三：忘记清理副作用事件监听、定时器、WebSocket 连接必须在 onUnmounted 中清理。判断标准很简单：你注册了什么，就要注销什么。总结Composables 用函数作用域取代 mixins 的属性合并，来源清晰、类型完整生命周期钩子可以在 Composable 中注册，自动绑定到调用组件——这是它管理副作用的杀手锏数据用 shallowRef、输入用 MaybeRefOrGetter + toValue()，是高阶设计模式useRequest/useMouse/useLocalStorage 是三个最值得亲手写一遍的练手项目VueUse 已经收录了 200+ 高质量 Composables，先读它的源码再动手，事半功倍把业务里的重复逻辑沉淀成 Composables，是 Vue 项目从"堆页面"走向"有架构"的第一步。
Vite 已成为 Vue 项目的标配构建工具。本文从零搭建一个包含目录规范、请求封装、权限路由、Mock 数据、环境配置和 CI/CD 的企业级 Vue 3 项目。一、项目初始化创建项目# 创建 Vue 3 项目 npm create vue@latest my-app # 交互选择： # ✓ TypeScript # ✓ JSX Support # ✓ Router # ✓ Pinia # ✓ Vitest # ✓ ESLint # ✓ Prettier cd my-app npm install安装核心依赖# UI 组件库（按需选择） npm install element-plus npm install -D unplugin-vue-components unplugin-auto-import # HTTP 请求 npm install axios # 实用工具 npm install dayjs lodash-es @vueuse/core # 图表（按需） npm install echarts二、目录结构规范my-app/ ├── public/ # 静态资源（不参与构建） ├── src/ │ ├── api/ # API 接口定义 │ │ ├── modules/ │ │ │ ├── user.js │ │ │ ├── order.js │ │ │ └── product.js │ │ └── index.js # 统一导出 │ ├── assets/ # 静态资源（参与构建） │ │ ├── images/ │ │ └── styles/ │ ├── components/ # 全局公共组件 │ │ ├── AppTable/ │ │ │ └── index.vue │ │ └── AppDialog/ │ ├── composables/ # 组合式函数 │ │ ├── useAuth.js │ │ ├── usePagination.js │ │ └── useRequest.js │ ├── config/ # 项目配置 │ │ └── index.js │ ├── constants/ # 常量定义 │ │ └── index.js │ ├── directives/ # 自定义指令 │ │ ├── permission.js │ │ └── lazy.js │ ├── layouts/ # 布局组件 │ │ ├── DefaultLayout.vue │ │ └── BlankLayout.vue │ ├── plugins/ # 插件配置 │ │ └── elementPlus.js │ ├── router/ # 路由 │ │ ├── index.js │ │ ├── guards.js │ │ └── routes/ │ │ ├── modules/ # 路由模块 │ │ └── index.js │ ├── stores/ # Pinia Store │ │ ├── user.js │ │ ├── permission.js │ │ └── app.js │ ├── styles/ # 全局样式 │ │ ├── variables.scss │ │ ├── mixins.scss │ │ └── index.scss │ ├── utils/ # 工具函数 │ │ ├── request.js # axios 封装 │ │ ├── storage.js │ │ └── format.js │ ├── views/ # 页面组件 │ │ ├── login/ │ │ ├── dashboard/ │ │ └── system/ │ ├── App.vue │ └── main.js ├── .env # 所有环境通用 ├── .env.development # 开发环境 ├── .env.production # 生产环境 ├── .env.staging # 预发布环境 ├── vite.config.js └── package.json三、Vite 配置详解// vite.config.js import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()) return { plugins: [ vue(), // 自动导入 Vue API AutoImport({ imports: ['vue', 'vue-router', 'pinia'], resolvers: [ElementPlusResolver()], dts: 'src/auto-imports.d.js' }), // 组件自动注册 Components({ resolvers: [ElementPlusResolver()], dts: 'src/components.d.js' }) ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, css: { preprocessorOptions: { scss: { additionalData: `@use "@/styles/variables.scss" as *;` } } }, server: { port: 3000, open: true, proxy: { '/api': { target: env.VITE_API_BASE_URL, changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }, build: { // 输出目录 outDir: 'dist', // 静态资源分包 rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'], 'vendor-utils': ['lodash-es', 'dayjs'] }, // 分包体积警告阈值 chunkFileNames: 'assets/js/[name]-[hash].js', entryFileNames: 'assets/js/[name]-[hash].js', assetFileNames: 'assets/[ext]/[name]-[hash].[ext]' } }, // chunk 大小警告 chunkSizeWarningLimit: 1000, // 生产环境移除 console minify: 'terser', terserOptions: { compress: { drop_console: env.VITE_DROP_CONSOLE === 'true', drop_debugger: true } } } } })四、环境变量配置# .env — 通用配置 VITE_APP_TITLE=企业管理系统 # .env.development VITE_API_BASE_URL=http://localhost:8080 VITE_DROP_CONSOLE=false # .env.production VITE_API_BASE_URL=https://api.example.com VITE_DROP_CONSOLE=true// src/config/index.js export const config = { title: import.meta.env.VITE_APP_TITLE, apiBaseUrl: import.meta.env.VITE_API_BASE_URL, tokenKey: 'access_token', tokenExpireKey: 'token_expire' }五、Axios 封装// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器 service.interceptors.request.use( (config) => { const userStore = useUserStore() // 携带 token if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } // GET 请求防缓存 if (config.method === 'get') { config.params = { ...config.params, _t: Date.now() } } return config }, (error) => Promise.reject(error) ) // 响应拦截器 let isRefreshing = false let pendingRequests = [] service.interceptors.response.use( (response) => { const { code, data, message } = response.data // 业务状态码判断 switch (code) { case 200: return data case 401: handleUnauthorized() return Promise.reject(new Error(message)) default: ElMessage.error(message || '请求失败') return Promise.reject(new Error(message)) } }, async (error) => { const { response } = error if (response) { switch (response.status) { case 401: handleUnauthorized() break case 403: ElMessage.error('没有权限访问') break case 404: ElMessage.error('请求的资源不存在') break case 500: ElMessage.error('服务器错误') break default: ElMessage.error(response.data?.message || '请求失败') } } else if (error.code === 'ECONNABORTED') { ElMessage.error('请求超时') } else { ElMessage.error('网络异常') } return Promise.reject(error) } ) // Token 过期处理 async function handleUnauthorized() { const userStore = useUserStore() if (!isRefreshing) { isRefreshing = true try { await userStore.refreshToken() // 重放失败请求 pendingRequests.forEach(cb => cb()) pendingRequests = [] } catch { userStore.logout() router.push('/login') } finally { isRefreshing = false } } return new Promise((resolve) => { pendingRequests.push(() => resolve(service(error.config))) }) } export default service// src/api/modules/user.js import request from '@/utils/request' export const userApi = { login: (data) => request.post('/auth/login', data), logout: () => request.post('/auth/logout'), getProfile: () => request.get('/user/profile'), updateProfile: (data) => request.put('/user/profile', data), changePassword: (data) => request.put('/user/password', data), // 分页列表 getUsers: (params) => request.get('/users', { params }), createUser: (data) => request.post('/users', data), updateUser: (id, data) => request.put(`/users/${id}`, data), deleteUser: (id) => request.delete(`/users/${id}`) }六、路由与权限// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' import { setupGuards } from './guards' // 基础路由 — 无需权限 export const constantRoutes = [ { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: () => import('@/layouts/DefaultLayout.vue'), redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'), meta: { title: '仪表盘', icon: 'Odometer' } } ] }, { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes }) setupGuards(router) export default router// src/router/guards.js import { useUserStore } from '@/stores/user' import { usePermissionStore } from '@/stores/permission' const whiteList = ['/login'] export function setupGuards(router) { router.beforeEach(async (to, from, next) => { document.title = `${to.meta.title || ''} - 系统名称` const userStore = useUserStore() if (userStore.token) { if (to.path === '/login') { next({ path: '/' }) } else { // 未加载用户信息和动态路由 if (!userStore.user) { try { // 获取用户信息 await userStore.getProfile() // 生成动态路由 const permissionStore = usePermissionStore() const accessRoutes = await permissionStore.generateRoutes() // 动态添加路由 accessRoutes.forEach(route => router.addRoute(route)) // 重新导航，确保路由已注册 next({ ...to, replace: true }) } catch (error) { userStore.resetToken() next(`/login?redirect=${to.path}`) } } else { next() } } } else { // 白名单直接放行 if (whiteList.includes(to.path)) { next() } else { next(`/login?redirect=${to.path}`) } } }) }// src/stores/permission.js import { defineStore } from 'pinia' import api from '@/api' // 视图模块映射 const viewModules = import.meta.glob('@/views/**/*.vue') export const usePermissionStore = defineStore('permission', () => { const routes = ref([]) async function generateRoutes() { // 从后端获取菜单权限 const menus = await api.menu.getUserMenus() // 转换后端菜单为路由 const asyncRoutes = transformRoutes(menus) routes.value = asyncRoutes return asyncRoutes } function transformRoutes(menus, parentPath = '') { return menus.map(menu => { const route = { path: menu.path.startsWith('/') ? menu.path : `${parentPath}/${menu.path}`, name: menu.name, meta: { title: menu.title, icon: menu.icon }, children: [] } // 组件解析 const componentPath = menu.component || 'layouts/DefaultLayout' route.component = viewModules[`/src/views/${componentPath}.vue`] if (menu.children?.length) { route.children = transformRoutes(menu.children, route.path) } return route }) } return { routes, generateRoutes } })七、Mock 数据// vite.config.js 增加 mock 插件 import { viteMockServe } from 'vite-plugin-mock' plugins: [ vue(), viteMockServe({ mockPath: 'mock', localEnabled: true }) ]// mock/user.js export default [ { url: '/api/user/profile', method: 'get', response: () => { return { code: 200, data: { id: 1, name: '张三', roles: ['admin'], permissions: ['system:user:list', 'system:user:create'] } } } }, { url: '/api/users', method: 'get', response: ({ query }) => { const { page = 1, pageSize = 10 } = query const list = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, name: `用户${i + 1}`, email: `user${i + 1}@example.com`, status: i % 2 })) return { code: 200, data: { list: list.slice((page - 1) * pageSize, page * pageSize), total: list.length } } } } ]八、代码规范// eslint.config.js (Flat Config) import js from '@eslint/js' import vue from 'eslint-plugin-vue' import prettier from 'eslint-config-prettier' export default [ js.configs.recommended, ...vue.configs['flat/recommended'], prettier, { rules: { 'vue/multi-word-component-names': 'off', 'no-console': 'warn', 'no-debugger': 'warn' } } ]// .prettierrc { "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "none", "arrowParens": "always" }九、Git 提交规范与 CI// commitlint.config.js export default { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore' ]] } }# .github/workflows/ci.yml name: CI on: push: branches: [main, develop] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install run: npm ci - name: Lint run: npm run lint - name: Test run: npm run test:unit - name: Build run: npm run build - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist十、小结企业级 Vue 项目的工程化核心：规范的目录结构让团队协作有章可循，统一的请求封装处理认证与错误，动态路由实现权限控制，Mock 让前后端并行开发，ESLint + CI 保证代码质量。这套脚手架可以直接作为新项目的起点。本文由 inspirecl.asia 原创，转载请注明出处。
从"能跑"到"跑得快"是前端工程的分水岭。本文从渲染机制出发，系统讲解 Vue 3 的性能优化手段：虚拟列表、组件缓存、懒加载、防抖节流，以及性能分析工具的完整使用。一、性能优化总览性能优化层次 │ ├── 网络层 — 资源体积、加载策略 │ ├── 路由懒加载 │ ├── 组件异步加载 │ └── 图片/资源优化 │ ├── 渲染层 — 减少不必要的渲染 │ ├── v-show vs v-if │ ├── v-memo 缓存 │ ├── KeepAlive 组件缓存 │ └── 虚拟列表 │ ├── 计算层 — 减少计算量 │ ├── computed 缓存 │ ├── 防抖节流 │ └── Web Worker │ └── 监控层 — 持续度量 ├── Performance 面板 ├── Vue DevTools └── 自定义性能指标二、渲染优化v-if vs v-show <HeavyComponent v-if="show" /> <HeavyComponent v-show="show" /> v-memo 缓存子树 {{ item.name }} {{ item.description }} KeepAlive 组件缓存 <KeepAlive include="TabA,TabB" :max="10"> <component :is="tabs[currentTab]" /> </KeepAlive> 异步组件与 Suspense <Suspense> <AsyncDashboard /> <LoadingSpinner /> </Suspense> 虚拟列表（大数据量必备）npm install @tanstack/vue-virtual {{ rows[row.index].name }} 三、计算与交互优化防抖与节流// composables/useDebounce.js import { customRef } from 'vue' // 防抖 ref export function useDebouncedRef(value, delay = 300) { let timeout return customRef((track, trigger) => ({ get() { track() return value }, set(newValue) { clearTimeout(timeout) timeout = setTimeout(() => { value = newValue trigger() }, delay) } })) } 深度 watch 的替代方案// ❌ 深度监听大对象 — 开销大 watch(bigObject, handler, { deep: true }) // ✅ 精确监听目标属性 watch(() => bigObject.targetProp, handler) // ✅ 用 shallowRef + 整体替换 const list = shallowRef([]) list.value = [...list.value, newItem] // 触发更新 // 修改内部属性不触发（性能需要时） // ✅ 数组方法触发 list.value.push(item) // 会触发（Vue 3 拦截数组方法）Web Worker 处理重计算// workers/dataProcessor.js self.onmessage = (e) => { const { data, type } = e.data let result switch (type) { case 'SORT': result = heavySort(data) break case 'STATS': result = calculateStats(data) break } self.postMessage({ result }) } function heavySort(arr) { return [...arr].sort((a, b) => a.value - b.value) }// composables/useWorker.js export function useDataProcessor() { const worker = new Worker( new URL('../workers/dataProcessor.js', import.meta.url), { type: 'module' } ) const result = ref(null) const processing = ref(false) worker.onmessage = (e) => { result.value = e.data.result processing.value = false } function process(type, data) { processing.value = true worker.postMessage({ type, data }) } onUnmounted(() => worker.terminate()) return { result, processing, process } }四、加载优化路由懒加载// router/index.js import { createRouter } from 'vue-router' const routes = [ { path: '/', component: () => import('../views/Home.vue') // 懒加载 }, { path: '/admin', component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue'), // Vite: 单独分包 // component: () => import('../views/Admin.vue'), meta: { requiresAuth: true } } ] // 按组分包（Vite） // vite.config.js export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'], 'vendor-chart': ['echarts'] } } } } }图片优化 骨架屏 五、性能分析工具Vue DevTools 性能面板Vue DevTools → Timeline / Performance - 组件渲染耗时 - 事件处理耗时 - 响应式依赖追踪 Component Inspector: - 查看组件的 props、state、computed - 高亮重新渲染的组件Performance API 自定义测量// main.js — Vue 性能追踪 const app = createApp(App) // 开启性能追踪（开发模式） app.config.performance = true // 自定义性能标记 performance.mark('app-init-start') app.mount('#app') performance.mark('app-init-end') performance.measure('应用初始化', 'app-init-start', 'app-init-end') // 组件级测量 // MyComponent.vue onMounted(() => { performance.mark(`${instance.uid}-mounted`) }) onBeforeMount(() => { performance.mark(`${instance.uid}-before-mount`) })Web Vitals 监控// 监控核心 Web 指标 import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals' function sendToAnalytics(metric) { const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating, id: metric.id }) navigator.sendBeacon('/api/vitals', body) } onLCP(sendToAnalytics) // 最大内容绘制 onFID(sendToAnalytics) // 首次输入延迟 onCLS(sendToAnalytics) // 累积布局偏移 onINP(sendToAnalytics) // 交互到下一次绘制 onTTFB(sendToAnalytics) // 首字节时间渲染性能检查清单// 开发环境全局组件渲染追踪 // main.js if (import.meta.env.DEV) { app.config.warnHandler = (msg, instance, trace) => { console.warn(`[Vue warn]: ${msg}${trace}`) } } // 检测不必要的重新渲染 import { watch } from 'vue' // 在 DevTools 中使用 "Highlight updates" 功能六、优化检查清单类别手段收益首屏路由懒加载 + 组件分包减少首包体积 50%+首屏骨架屏感知性能提升列表虚拟列表万级数据流畅渲染列表v-memo跳过不必要的 diff组件KeepAlive切换零重建成本组件异步组件按需加载重组件交互防抖节流减少触发频率计算computed 缓存避免重复计算计算Web Worker主线程零阻塞图片懒加载 + 响应式节省带宽七、小结性能优化的核心是"度量驱动"：先用 DevTools 和 Web Vitals 找到瓶颈，再对症下药。渲染层关注"减少不必要的更新"（v-memo、KeepAlive、虚拟列表），计算层关注"不阻塞主线程"（防抖、Worker），加载层关注"更快呈现"（懒加载、分包、骨架屏）。本文由 inspirecl.asia 原创，转载请注明出处。
Pinia 是 Vue 官方推荐的状态管理库，以其简洁的 API 和完整的 TypeScript 支持取代了 Vuex。本文系统讲解 Pinia 的核心概念、模块化设计、持久化方案以及从 Vuex 迁移的完整指南。一、为什么选择 PiniaPinia vs Vuex特性Vuex 4PiniaAPI 风格Mutation + Action只有 Action（同步异步统一）TypeScript支持较弱完整类型推导模块化嵌套模块 + namespace扁平 Store，天然模块化代码量较多样板代码极简组合式 API适配一般原生设计DevTools支持支持（时间旅行更佳）SSR支持支持热更新部分完整支持Pinia 移除了什么Mutations：直接修改 state 或在 action 中修改嵌套模块：每个 Store 独立扁平命名空间：Store 天然隔离辅助函数：mapState/mapGetters 不再必要（组合式写法）二、快速上手安装与初始化// main.js import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) app.use(createPinia()) app.mount('#app')定义第一个 Store// stores/counter.js import { defineStore } from 'pinia' // 选项式写法（类似组件的 data/computed/methods） export const useCounterStore = defineStore('counter', { state: () => ({ count: 0, name: '计数器' }), getters: { double: (state) => state.count * 2, doublePlusOne() { // 通过 this 访问其他 getter return this.double + 1 } }, actions: { increment() { this.count++ }, async incrementAsync() { // 直接写异步逻辑，无需 dispatch await new Promise(r => setTimeout(r, 1000)) this.count++ } } })组合式写法（推荐）// stores/counter.js — Setup Store 写法 import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', () => { // state const count = ref(0) const name = ref('计数器') // getters const double = computed(() => count.value * 2) const doublePlusOne = computed(() => double.value + 1) // actions function increment() { count.value++ } async function incrementAsync() { await new Promise(r => setTimeout(r, 1000)) count.value++ } // 必须返回所有使用的属性 return { count, name, double, doublePlusOne, increment, incrementAsync } })在组件中使用 {{ counter.name }}: {{ count }} 双倍: {{ double }} +1 三、核心概念详解State// stores/user.js export const useUserStore = defineStore('user', { state: () => ({ user: null, token: '', permissions: [], settings: { theme: 'light', language: 'zh-CN' } }), actions: { // 重置整个 state 到初始值 resetState() { this.$reset() }, // 批量修改（替代 Vuex 的 mutation） setUser(user) { this.user = user this.permissions = user.permissions }, // $patch 批量更新，性能更好 updateSettings(partial) { this.$patch({ settings: { ...this.settings, ...partial } }) // 函数式写法 this.$patch((state) => { state.settings.theme = 'dark' }) } } })Gettersexport const useCartStore = defineStore('cart', { state: () => ({ items: [] }), getters: { // 基础 getter itemCount: (state) => state.items.length, // 带参数的 getter（返回函数，无缓存优势） itemById: (state) => { return (id) => state.items.find(item => item.id === id) }, // 访问其他 getter subtotal() { return this.items.reduce((sum, item) => sum + item.price * item.qty, 0) }, // 访问其他 Store total() { const userStore = useUserStore() const discount = userStore.user?.discount ?? 1 return this.subtotal * discount } } })Actions// stores/products.js import { defineStore } from 'pinia' import api from '@/api' export const useProductStore = defineStore('products', () => { const products = ref([]) const loading = ref(false) const error = ref(null) // 获取产品列表 async function fetchProducts(params) { loading.value = true error.value = null try { const { data } = await api.get('/products', { params }) products.value = data } catch (e) { error.value = e.message } finally { loading.value = false } } // 跨 Store 调用 async function purchase(productId) { const cartStore = useCartStore() const product = products.value.find(p => p.id === productId) await api.post(`/cart/items`, { productId, qty: 1 }) cartStore.addItem(product) } return { products, loading, error, fetchProducts, purchase } })四、插件系统持久化插件// plugins/persist.js import { watch } from 'vue' export function createPersistPlugin() { return ({ store }) => { // 从 localStorage 恢复 const saved = localStorage.getItem(`pinia-${store.$id}`) if (saved) { store.$patch(JSON.parse(saved)) } // 订阅变化并保存 store.$subscribe((mutation, state) => { localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state)) }) } }// main.js import { createPinia } from 'pinia' import { createPersistPlugin } from './plugins/persist' const pinia = createPinia() pinia.use(createPersistPlugin())使用官方持久化插件npm install pinia-plugin-persistedstate// stores/user.js export const useUserStore = defineStore('user', { state: () => ({ user: null, token: '' }), // 持久化配置 persist: { key: 'my-app-user', storage: localStorage, // 或 sessionStorage // 只持久化部分字段 pick: ['token', 'user.id', 'user.name'] }, actions: { login(credentials) { /* ... */ }, logout() { this.user = null this.token = '' } } })自定义日志插件// plugins/logger.js export function createLoggerPlugin() { return ({ store }) => { store.$subscribe((mutation, state) => { console.log( `[Pinia] ${mutation.storeId} ${mutation.type}:`, mutation.type === 'patch' ? mutation.events : `${mutation.payload}` ) }) store.$onAction(({ name, args, after, onError }) => { const startTime = Date.now() console.log(`[Action] ${store.$id}/${name} 开始`, args) after((result) => { console.log(`[Action] ${store.$id}/${name} 完成 (${Date.now() - startTime}ms)`) }) onError((error) => { console.error(`[Action] ${store.$id}/${name} 失败:`, error) }) }) } }五、Store 组合与模块化Store 之间相互调用// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' import { useProductStore } from './products' export const useCartStore = defineStore('cart', () => { const items = ref([]) const userStore = useUserStore() // 在 setup 内调用 const total = computed(() => { const discount = userStore.user?.discount ?? 1 return items.value.reduce( (sum, item) => sum + item.price * item.qty, 0 ) * discount }) return { items, total } })组合式 Store 设计模式// stores/composables/useEntityCRUD.js // 通用的 CRUD Store 工厂 export function createCrudStore(entityName, apiEndpoint) { return defineStore(entityName, () => { const items = ref([]) const current = ref(null) const loading = ref(false) async function fetchAll(params = {}) { loading.value = true try { const { data } = await api.get(apiEndpoint, { params }) items.value = data } finally { loading.value = false } } async function fetchOne(id) { const { data } = await api.get(`${apiEndpoint}/${id}`) current.value = data } async function create(payload) { const { data } = await api.post(apiEndpoint, payload) items.value.unshift(data) return data } async function update(id, payload) { const { data } = await api.put(`${apiEndpoint}/${id}`, payload) const index = items.value.findIndex(i => i.id === id) if (index > -1) items.value[index] = data return data } async function remove(id) { await api.delete(`${apiEndpoint}/${id}`) items.value = items.value.filter(i => i.id !== id) } return { items, current, loading, fetchAll, fetchOne, create, update, remove } }) } // 使用工厂创建具体的 Store export const usePostStore = createCrudStore('posts', '/api/posts') export const useCategoryStore = createCrudStore('categories', '/api/categories')六、从 Vuex 迁移对照转换// === Vuex 4 === // store/index.js export default createStore({ state: () => ({ count: 0 }), mutations: { increment(state) { state.count++ } }, actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }, getters: { double: (state) => state.count * 2 } }) // === Pinia 等价写法 === export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), // mutation 直接变成 action actions: { increment() { this.count++ }, incrementAsync() { setTimeout(() => this.increment(), 1000) } }, getters: { double: (state) => state.count * 2 } })// === 组件中使用对比 === // Vuex // this.$store.state.count // this.$store.commit('increment') // this.$store.dispatch('incrementAsync') // mapState('counter', ['count']) // mapGetters('counter', ['double']) // Pinia const counter = useCounterStore() counter.count counter.increment() // 同步异步统一调用 counter.incrementAsync() const { count } = storeToRefs(counter)渐进式迁移策略同时安装两个库，逐模块迁移从叶子模块开始（无其他模块依赖的）用组合函数桥接：// 桥接层：让旧 Vuex 代码访问新 Pinia // stores/bridge.js export function useCounterCompat() { const counter = useCounterStore() return { get count() { return counter.count }, increment: counter.increment } }迁移完成移除 Vuex七、SSR 中的注意事项// Nuxt 3 中使用 // stores/counter.js export const useCounterStore = defineStore('counter', () => { const count = ref(0) return { count } }) // 组件中（Nuxt 自动导入 Pinia） const counter = useCounterStore() // SSR 中跨请求状态污染防范 // ✅ 在 setup 内调用 useStore()，而不是模块顶层 // ❌ const store = useCounterStore() // 模块顶层会共享状态八、小结Pinia 以极简的 API 提供了完整的状态管理能力：去掉 mutation 减少样板代码，扁平 Store 天然模块化，完整的 TypeScript 和 DevTools 支持。从 Vuex 迁移成本低，新项目直接上 Pinia 是唯一正确选择。本文由 inspirecl.asia 原创，转载请注明出处。