ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue Router 4与Pinia协作实战:路由守卫与状态管理最佳实践

Vue Router 4与Pinia协作实战:路由守卫与状态管理最佳实践 在 Vue 3 生态里Vue Router 4 和 Pinia 算是组合起来最舒服的一对搭档了。Vue Router 4 负责页面跳转和路由结构Pinia 负责跨页面共享数据两者在官方定位上各管一摊没有直接依赖。但实际项目一跑起来你会发现它们之间的边界远没有文档里画得那么清晰。路由守卫里要读用户信息、多个页面共用一份列表状态、页面刷新后还要恢复筛选条件……这些场景一旦多起来路由和状态管理就会不可避免地相互纠缠。如果只是各写各的代码会越来越拧巴但如果能理解清楚两者的协作方式你会发现很多“不好搞”的问题本身就是没把关系理顺。这篇文章我就从实际问题出发聊聊 Vue Router 4 和 Pinia 在真实项目里怎么配合踩过哪些坑以及最终我沉淀下来的使用套路。适合已经会用 Vue 3 写组件、但对路由和状态管理还停留在“各用各的”阶段的开发者。1. 先捋清楚路由和状态管理各自的职责边界1.1 路由是“页面结构”状态是“页面数据”很多初学者最容易犯的错就是把路由当成万能容器什么数据都想往 query、params、meta 里塞。比如有人会把用户的搜索历史、表单填写进度甚至接口返回的列表数据一次性编码进 URL结果一刷新页面URL 长到能绕地球一圈而且 JSON 序列化后塞进 query转义和解析的成本也高得离谱。我的理解其实很简单路由负责的是“用户在哪个页面、URL 是什么、进入页面用什么布局和组件”它本质上是页面级导航的描述。而 Pinia 负责的是“当前应用在运行期间非 URL 可表达的数据状态”比如登录态、购物车、列表缓存、主题配置等。数据库表结构设计里“职责单一”这种原则放到这里也是一样的。怎么判断某个东西该放进 query 还是放进 Pinia一个比较直接的判断方式把当前页面 URL 复制下来发给另一个人对方打开 URL 后看到的关键内容是不是和你在页面上看到的一致如果一致那这部分内容就适合由路由承担。比如一个商品详情页商品 ID 必须放在路由参数里不然别人拿到链接根本不知道打开的是哪个商品。但如果是商品列表的筛选条件里那种 20 多页的复杂过滤表单全塞 URL 会非常痛苦这种更适合放在 Pinia 里做中间态必要的时候再挑一两个核心条件同步到 query 上。1.2 Vue Router 4 在项目里真正承担的三层职责在真实项目中我认为 Vue Router 4 承担的事情有三层缺一不可。第一是路径解析与注册也就是把 URL 映射到对应的组件上这是框架替我们做的事基本不需要自己写逻辑。第二是导航守卫调度即 beforeEach、beforeResolve、afterEach 这一套完整的钩子链。这里才是项目真正开始有“灵魂”的地方——我们通常在这个环节做登录校验、页面权限过滤、异步路由表注入。第三是路由元信息meta驱动页面渲染这个是我自己在后期才开始重视的。比如页面布局类型侧边栏还是顶部导航、是否需要缓存、标题是写死在代码里还是从接口返回这些统统可以放进 meta由路由统一管理。之前刚开始用 Vue Router 4 时我总想自己在上层封装一层“页面管理器”来处理这些逻辑后来绕了一圈发现完全是画蛇添足。Vue Router 4 本身已经足够灵活导航守卫 meta 的组合就能覆盖绝大多数业务需求。你要做的不是构建一个替代层而是理解它的钩子触发顺序然后把手里的逻辑顺势放进去。1.3 Pinia 不只是 useStore 那么简单Pinia 在官网上的定位是 Vue 状态管理库它把 Vuex 的 mutation 去掉了TypeScript 支持也做得更自然。但在项目里Pinia 承担的事情比“状态管理”这几个字要具体得多。我把项目里的 Pinia store 按角色分成了三种类型。第一类是会话型 store比如 user store保存当前登录用户信息、token 刷新状态这类 store 的显著特点是生命周期伴随整个应用几乎所有页面和接口都要用到它。第二类是页面型 store比如商品列表页的 filter store、订单详情页的 detail store这类 store 和路由页面关联紧密进入页面时初始化离开页面后可能需要重置。第三类是全局 UI 型 store比如主题色、语言、侧边栏折叠状态这类 store 的特点是跨页面、跨组件但业务侵入性低。如果不做这个分类容易出现一个问题项目里的 store 越来越多时间一长没人说得清哪个 store 是给谁用的。做了分类之后路由守卫里该访问哪一类 store、组件里该注入哪一类 store就很清楚了。开发效率提升层面反而是次要收获脑子清楚才是最重要的。2. 环境准备从零搭一个 Vue Router 4 Pinia 骨架2.1 工具链依赖说明开始写代码之前先确认环境。我是用 Vite 来创建 Vue 3 项目的这套组合目前已经很成熟。npm create vitelatest vue-router-pinia-demo -- --template vue-ts cd vue-router-pinia-demo npm install npm install vue-router4 pinia如果项目用的是 JavaScript 而不是 TypeScript模板换成--template vue就好。安装完成后在src目录下新建router/index.ts和stores/index.ts两个入口文件。路径没有强制规定但统一入口会让后面参照起来更省心。2.2 注册路由与 Pinia 实例因为这块直接决定 index.ts 里 app.use 的顺序很多人没细想过以为就是按文档抄两行代码。其实这里面有一个微妙但关键的细节。先看标准写法// src/main.ts import { createApp } from vue import { createPinia } from pinia import App from ./App.vue import router from ./router const app createApp(App) const pinia createPinia() app.use(pinia) app.use(router) app.mount(#app)在 Vue 3 里Pinia 不需要像 Vuex 那样依赖 Vue Router 插件所以理论上先注册谁都可以。但经验上我推荐先注册 Pinia后注册 Router。原因在于如果某个组件在初始化过程中访问了某个 store但 Pinia 实例还没被 app.use会得到一个很奇怪的警告。后续我们会把路由守卫写成独立模块守卫函数内部访问 Pinia store如果 Pinia 注册晚于路由初始化某些边缘场景会触发 “getActivePinia() was called but there was no active Pinia” 这样的报错。所以顺序问题不值得赌直接固定为先 Pinia 后 Router。2.3 项目目录结构规划对于中小型项目我习惯一种能容纳后续扩展的目录结构。核心思路是 route 目录和 store 目录各自按 feature 拆分子模块避免所有页面路由堆在一个.ts文件里最后膨胀到上千行。src/ |-- main.ts |-- App.vue |-- router/ | |-- index.ts | |-- routes.ts | |-- guards.ts |-- stores/ | |-- index.ts | |-- modules/ | |-- user.ts | |-- product.ts |-- views/ | |-- home/index.vue | |-- product/detail.vueroutes.ts 只放静态的路由映射表guard.ts 统一管理导航守卫逻辑index.ts 只负责创建 router 实例并挂载 guards。stores 目录同理modules 下每个 store 一个文件。这样划分后路由表和状态的定义都变得可以独立测试和检索。3. 路由配置从基础路由到动态权限路由3.1 基础路由的几种模式与跳转技巧Vue Router 4 最明显的变化是强制使用 history API 模式不再有 hash 模式的默认选项。除非你的应用部署在无法配置 nginx 回退的静态文件服务上否则我建议优先使用createWebHistory。原因是 URL 更干净后端也能正确识别路由路径。如果一定要用 hash 模式createWebHashHistory依然存在但你需要接受 URL 里带#的现状。// src/router/index.ts import { createRouter, createWebHistory } from vue-router import { routes } from ./routes const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition } return { top: 0 } } }) export default router这里有个细节容易忽略import.meta.env.BASE_URL。Vite 项目在vite.config.ts里配置的 base 路径会有一致性衔接。如果项目部署在子目录/admin/下而这里硬编码成了/生产环境刷新页面就会 404。这个问题在本地开发时完全暴露不出来等部署到服务器上才炸锅排查成本很高。页面跳转方面Vue Router 4 还支持在组件内使用useRouter、useRoute组合式函数script setup langts import { useRouter, useRoute } from vue-router import { useProductStore } from /stores/modules/product const router useRouter() const route useRoute() function goToDetail(id: string) { router.push({ name: ProductDetail, params: { id } }) } function goBackWithState() { // 返回上一页但通过 query 带一个标识 router.back() } /script有跳转需求的页面尽量不要直接把$router从模板里拿出来用。在script setup里组合式函数就是正统写法模板里只需要router.push或从函数返回的变量。3.2 动态路由与权限控制的常规玩法动态路由是权限控制的核心。通常在登录之后后端会返回当前用户可访问的路由权限表前端根据这张表用router.addRoute动态注册路由再router.replace到目标页面。整体逻辑可以放在全局前置守卫里// src/router/guards.ts import { useUserStore } from /stores/modules/user const WHITE_LIST [/login] export function setupRouterGuards(router) { router.beforeEach(async (to) { const userStore useUserStore() // 没有登录态且目标页不在白名单内重定向到登录页 if (!userStore.token !WHITE_LIST.includes(to.path)) { return { path: /login, query: { redirect: to.fullPath } } } // 已经登录且访问的是登录页直接回首页 if (userStore.token to.path /login) { return { path: / } } // 异步路由未加载动态注册后再进入 if (userStore.token !userStore.routesLoaded) { const accessRoutes await userStore.fetchUserRoutes() accessRoutes.forEach(route router.addRoute(route)) userStore.routesLoaded true return { ...to, replace: true } } }) }这里有几个关键点值得展开。第一useUserStore()必须在router.beforeEach执行回调内被调用不能在模块顶层调用因为 Pinia 实例还没有被激活。第二动态注册路由后一定要return { ...to, replace: true }这是因为addRoute执行过之后原来的to对应的路由记录还是旧的匹配结果直接放行可能依然命中 404 页面。这种“重新导航一次”的手法在项目里很常见属于动态路由这个玩法的标准操作。当然你还要给动态路由设计展平结构。后端返回的数据往往是嵌套的路由树但router.addRoute需要逐条处理嵌套关系。一个常见做法是把嵌套路由都当作“父级路由的子路由”注册而不是每条都扁平化。这里不是一两句话能说完的后续可以单独写一篇。3.3 路由 Meta 与守卫把页面权限配置集中管理把页面权限、布局方式、缓存状态都放进 meta 的做法在项目变大之后的效果尤其明显。路由是天然层次化地描述页面内容的数据源利用 meta 做一些声明式配置比业务代码里去判断“当前页面是否需要布局 A”要干净得多。// src/router/routes.ts import Layout from /layout/index.vue export const routes [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { title: 登录, layout: blank } }, { path: /, component: Layout, redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 工作台, icon: dashboard, affix: true } }, { path: product, name: Product, component: () import(/views/product/index.vue), meta: { title: 商品管理, roles: [admin, editor] } } ] } ]roles数组可以用来做按钮级或页面级权限控制不一定在守卫里显式读取页面组件内也可以读取。我习惯在路由配置的 meta 里约法三章title 必须写、layout 默认继承父级、roles 不写则代表所有登录用户可访问。这套约定省去了大量的 if-else。在模板里动态生成面包屑时也能受益script setup langts import { useRoute } from vue-router const route useRoute() const matched route.matched.filter(item item.meta item.meta.title) /script template el-breadcrumb el-breadcrumb-item v-foritem in matched :keyitem.path {{ item.meta.title }} /el-breadcrumb-item /el-breadcrumb /template路由 meta 是响应式数据当路由切换时route.meta会同步更新因此面包屑这类依赖当前路由信息的组件可以做到零手工维护。4. 三种 store 定义方式选型与业务建模4.1 为什么 Pinia 比 Vuex 用起来更顺手从 Vuex 迁移到 Pinia最先感受到的变化是开发体验上的不再需要写 mutation。很多时候业务场景是“组件里触发 action - 修改 state”Vuex 要求这个过程中的 state 修改必须通过 mutation这意味着每次加一个操作都要维护两个函数。Pinia 的 action 里可以直接this.count对代码规模小的团队来说省掉的不仅是行数还有来回跳转的烦躁感。还有一点是 TypeScript 支持。Vuex 4 的 TypeScript 支持算不上差但为了类型推导你需要写一堆辅助类型。Pinia 定义 store 直接defineStore(counter, () { ... })state 的各类属性包括 getter 和 action 的 this 类型都能被准确推断出来不需要额外的声明。这一点在我换到大型项目时体会很深接口返回的数据结构一旦调整整个项目里用到该字段的位置会高亮报错而不是运行到某个分支才崩。To be fairVuex 并不是没有优势——它的插件生态和严格模式在复杂协作场景下依然能派上用场。但如果你是自己开新项目而不是维护老项目Pinia 确实是默认选项。毕竟连官方文档都在推荐未来的生态资源也会倾向于它。4.2 Options Store 与 Setup Store 的适用场景Pinia 提供了两种定义方式。Options store 和 Vuex 的模块长得很像有state、getters、actions三个字段对于从 Vuex 迁移过度的团队比较友好。Setup store 则更像 Vue 3 组合式函数的风格你可以在里面使用ref、computed、watch等 API。我个人的选型标准是简单数据仓库如 UI 状态、主题配置用 Options store逻辑复杂且依赖其他 store 或需要watch的用 Setup store。举个例子一个用户登录 store 如果用 Setup 风格写可以这样// src/stores/modules/user.ts import { defineStore } from pinia import { ref, computed } from vue import { loginApi, getUserInfoApi } from /api/user import type { LoginParams, UserInfo } from /types/user export const useUserStore defineStore(user, () { const token refstring() const userInfo refUserInfo | null(null) const isLoggedIn computed(() !!token.value) const displayName computed(() userInfo.value?.nickname || userInfo.value?.username || 未登录) async function login(params: LoginParams) { const res await loginApi(params) token.value res.token localStorage.setItem(token, res.token) } async function fetchUserInfo() { userInfo.value await getUserInfoApi() } function logout() { token.value userInfo.value null localStorage.removeItem(token) } return { token, userInfo, isLoggedIn, displayName, login, fetchUserInfo, logout } })为什么用 Setup store 写复杂业务更顺手因为当几个 store 之间需要互相引用时Options 风格的写法要借助useUserStore()在 action 内部调用而 Setup 风格可以直接把另一个 store 的实例写在初始化逻辑中。另外如果 store 内部有定时器或者需要监听某个外部响应式值的变化Setup store 里可以使用watch和onScopeDispose来管理逻辑内聚度高很多。4.3 Store 之间的嵌套引用与模块拆分原则Pinia 里 store 调用另一个 store 是常规需求。比如订单 store 需要读到用户 store 里的 userId购物车 store 需要读取商品 store 的价格这些都是跨领域的状态协作。在 Setup store 里可以轻松实现// src/stores/modules/cart.ts import { defineStore } from pinia import { ref, computed } from vue import { useProductStore } from ./product export const useCartStore defineStore(cart, () { const items refArray{ productId: string; quantity: number }([]) const productStore useProductStore() const totalPrice computed(() { return items.value.reduce((sum, item) { const product productStore.products.find(p p.id item.productId) return sum (product?.price || 0) * item.quantity }, 0) }) function addItem(productId: string, quantity: number) { const existing items.value.find(item item.productId productId) if (existing) { existing.quantity quantity } else { items.value.push({ productId, quantity }) } } return { items, totalPrice, addItem } })拆模块的原则也很简单不按页面拆按领域拆。商品领域、用户领域、订单领域各自独立。页面组件是消费方而不是 store 的“所有者”。你经常在项目里看到的useHomeStore、useDetailStore这样的命名本质上是把页面组件内部状态挪到了全局 store这在多页面共享数据时会遇到困难——Home 页的数据为什么要全局共享如果不共享那它为什么不干脆用组件内部的ref或者provide/inject很多时候是因为开发者觉得“有个 store 很专业”但专业不代表合适。5. 项目中的黄金搭档路由守卫串联 Pinia store5.1 登录态、Token 刷新与 Pinia 持久化从进入应用的第一秒开始路由守卫里就在和 store 打交道。判断用户是否登录、是否需要动态拉取权限表、访问目标页是否需要特定角色这些信息的载体大部分都放在 user store 中。通常我们会做 token 持久化不然一刷新页面 store 里的 token 就丢失了——这正是很多踩坑现场。简单做法是手动 localStorage 读写复杂项目建议续接pinia-plugin-persistedstate插件。选型的核心考量是插件可以自动做序列化和反序列化store 里某个字段更新后自动写入 localStorage避免每个 action 里都手动执行一次setItem。我目前的项目里会单独维护一个session.ts模块来封装存储逻辑核心原因是想留一个替换的点。当前用 localStorage将来如果产品要求跨标签页实时同步可以无缝切到 sessionStorage 配合storage事件或者用 cookie 方案都不需要大规模改代码。// src/utils/storage.ts const TOKEN_KEY access_token export function getToken() { return localStorage.getItem(TOKEN_KEY) } export function setToken(token: string) { localStorage.setItem(TOKEN_KEY, token) } export function clearToken() { localStorage.removeItem(TOKEN_KEY) }这里有一个经验之谈不要在 store 的模块顶层直接读 localStorage 赋初始值这会导致测试环境下 store 状态被污染。更规范的做法是让token.value初始为空然后在应用启动阶段调用一次initUserStore()方法。这个方法可以从 localStorage 恢复数据也可以请求/user/profile接口验证 token 有效性。调用这个 init 的时机可以放在main.ts挂载前也可以放在第一个路由守卫里。我放到路由守卫里的原因是有些页面不需要登录如果强制等待用户请求完成反而拖慢了首屏。5.2 在守卫里动态注册路由后 Pinia 数据怎么同步动态路由和 pinia 之间的数据同步常见的问题是路由表是后端返回的有些页面组件本身还需要依赖路由信息才能展示。比如某个菜单高亮状态、某个权限按钮是否可见。我采用的套路是后端返回权限数据后不仅调router.addRoute动态注册还会把路由权限树平铺一份到 user store 的permissionRoutes字段。这样页面组件可以从 store 中读取权限结构来渲染侧边栏菜单而不用自己去 parse 路由表从而两者天然保持一致。这个设计带来的一个直接好处是刷新页面时路由守卫的逻辑很干净用户 refresh 页面 - 读取持久化 token - token 存在 - 查看userStore.routesLoaded- 为 false 就重新拉取权限路由并注册 - replace 到目标页面。整个过程完全不会出现页面闪一下再去 404 的情况。// src/router/guards.ts 代码片段进阶版 router.beforeEach(async (to) { const userStore useUserStore() if (userStore.token) { if (to.path /login) { return { path: / } } if (!userStore.routesLoaded) { const { routes, permissions } await userStore.fetchUserPermissions() routes.forEach(route router.addRoute(route)) userStore.setPermissionRoutes(routes) userStore.routesLoaded true // 这里重新导航确保新注册的路由能正确匹配 return { ...to, replace: true } } } else { if (!WHITE_LIST.includes(to.path)) { return { path: /login, query: { redirect: to.fullPath } } } } })5.3 组件内 store 与路由参数的联动逻辑除了守卫之外页面组件内部也高频地让 store 与路由参数联动。最常见的是列表页点击详情跳转到详情页详情页根据路由参数 id 去 store 里取数据。这中间如果不小心很容易产生“进错房间”的 Bug。原因是同一套 store 实例在应用全局是单例的。如果详情页有 A、B 两个商品用户从商品 A 的详情页切出去再进商品 B 的详情页如果 store 里没有根据 id 区分当前数据那么这个 store 很可能会残留上一次的商品 A 数据展示在商品 B 的页面上。解决方式有二要么在 store 实例内部用一个 Map 按 id 缓存不同数据要么在路由切换的 watch 里重置 store。Map 方案是首选。把详情数据按 id 存起来既解决了缓存问题也天然支持用户快速返回上次看过的商品。// src/stores/modules/product.ts import { defineStore } from pinia import { ref } from vue import { getProductDetailApi } from /api/product export const useProductStore defineStore(product, () { const detailMap refRecordstring, any({}) async function fetchDetail(id: string) { if (detailMap.value[id]) { return detailMap.value[id] } const data await getProductDetailApi(id) detailMap.value[id] data return data } return { detailMap, fetchDetail } })组件里通过watch监听路由参数变化然后调用 store 的 actionscript setup langts import { watch } from vue import { useRoute } from vue-router import { useProductStore } from /stores/modules/product const route useRoute() const productStore useProductStore() const productId computed(() route.params.id as string) watch(productId, async (id) { if (id) { await productStore.fetchDetail(id) } }, { immediate: true }) /script这里面immediate: true很关键保证了组件首次渲染时也能正确响应路由参数。如果直接在外面调用fetchDetail(productId.value)当参数还是异步字符串或者组件被 keep-alive 缓存后可能出现漏加载的情况。6. 页面级数据预取Vue Router 4 与 Pinia 的深水区玩法6.1 传统“组件挂载后请求数据”的痛点最常见的业务模式是进入列表页后调用接口等数据返回再展示列表。组件里简单写一下是这样的script setup langts import { ref, onMounted } from vue import { getListApi } from /api/list const loading ref(false) const list ref([]) onMounted(async () { loading.value true try { list.value await getListApi() } finally { loading.value false } }) /script页面一多问题就暴露出来了。用户从列表页点击进入一个表单页编辑完再通过浏览器返回键返回列表页时浏览器默认会用 bfcache 尝试恢复上一个页面。如果列表数据是存在组件内部的 ref 中组件销毁重建时数据是全空的你必须重新请求接口白屏和 loading 都重新来一遍用户体感就是“返回后闪了一下白屏”。如果列表很大、接口很慢体验更差。即使不用 bfcache组件被 keep-alive 包裹后重新激活时走onActivated而不是onMounted也容易产生重复请求的问题。6.2 用路由守卫配合星标 store 实现预取针对列表页返回这种高频场景我采用的是“路由守卫 Pinia store 缓存”的数据预取方案思路很简单把列表数据和筛选条件提升到 Pinia store 里保存路由离开时不销毁再次进入时优先读 store 缓存只有 cache 失效时才重新请求后端。// src/stores/modules/productList.ts import { defineStore } from pinia import { ref } from vue import { getProductListApi } from /api/product export const useProductListStore defineStore(productList, () { const list refArrayany([]) const total ref(0) const loading ref(false) const lastFetchAt ref(0) const cacheDuration 5 * 60 * 1000 // 5分钟缓存有效期 async function fetchProductList(force false) { const now Date.now() if (!force list.value.length now - lastFetchAt.value cacheDuration) { return } loading.value true try { const { list: items, total: count } await getProductListApi() list.value items total.value count lastFetchAt.value now } finally { loading.value false } } function clear() { list.value [] total.value 0 lastFetchAt.value 0 } return { list, total, loading, fetchProductList, clear } })然后在商品列表页的路由记录上加一个 beforeEnter 守卫或者在全局路由守卫里做判断。只针对特定页面、只做一次预取效率和简洁的平衡点最好。我习惯放到页面组件内部的 watch 无法满足的核心拦截需求时再写到路由配置里// src/router/routes.ts补充商品列表 import { useProductListStore } from /stores/modules/productList export const productRoutes [ { path: /product-list, name: ProductList, component: () import(/views/product/product-list.vue), // beforeEnter 只对直接进入该路由生效 // 但如果列表页和详情页之间通过 router.push 互相跳转它依然能拦到 beforeEnter: async () { const store useProductListStore() await store.fetchProductList() } } ]你可能会疑惑在路由配置文件中使用useProductListStore会不会有问题编译打包阶段路由文件会被组件引用而 Pinia 的 useXxx 方法执行时要求有活跃的 pinia 实例。因为我在 2.2 节强调了“先 app.use(pinia) 再 app.use(router)”而且守卫回调的触发时间一定在应用挂载之后所以这里实际是安全的。真正危险的是在 module 顶层执行useProductStore()那肯定会炸。6.3 预取成功但组件白屏排查思路有一种容易让人觉得“预取方案有问题”的场景用户从 A 页跳到 B 页B 页路由守卫里预取数据然后 B 页组件挂载后 loading 状态不更新。这是因为列表 store 是全局单例组件初始化时从 store 中读取loading如果预取已经在进行中组件可能拿到的是loadingtrue而预取完成后 store 更新了loadingfalse但组件没有正确订阅响应式状态。解决方案比较土但很有效组件模板里保持对 store 的响应式引用不要解构赋值。用storeToRefs解构字段来保住响应性这在 Pinia 3 中也适用。如果已经知道预取时机和组件挂载时机存在重叠也可以让组件与 preload 函数约定参数由组件统一渲染。script setup langts import { storeToRefs } from pinia import { useProductListStore } from /stores/modules/productList const productListStore useProductListStore() const { list, total, loading } storeToRefs(productListStore) // 组件直接使用 ref 渲染 /script这里要专门提一下storeToRefs的必要性。直接const { list, total } productListStore会把 store 上的 getter/state 属性按值拷贝数据页面上的响应性就断了。list只是当时值的一个快照store 里变化了它永远不会知道。这是初学者常踩的坑务必记住。7. 进阶模式让路由和 Pinia 在复杂场景里协作更丝滑7.1 多标签页缓存与 Keep-Alive 的配合逻辑中后台系统最典型的需求是标签页Tabs类似浏览器的多标签切换。点击左侧菜单打开页面顶部多一个标签切换标签页面不销毁保留滚动位置、表单已填内容。Vue Router 的keep-alive机制配合 include 可以实现基本效果而这个 include 列表恰恰适合交给 Pinia store 管理。我的方案是把打开的标签页列表、当前激活的标签、每个标签对应组件名缓存都放在tabsStore中。路由在标签页关闭时是通过router.removeRoute还是直接不渲染实际上对于 keep-alive include 来说我们不需要 remove route只需要动态更新 include 数组把保留的组件名传给KeepAlive。!-- src/App.vue 或 layout 内部 -- script setup langts import { useTabsStore } from /stores/modules/tabs import { storeToRefs } from pinia const tabsStore useTabsStore() const { cachedViews } storeToRefs(tabsStore) /script template router-view v-slot{ Component } keep-alive :includecachedViews component :isComponent / /keep-alive /router-view /template一个页面要想被 keep-alive 正确缓存组件必须有名字。Vue 3 的script setup文档说默认组件名取的是文件名生产环境建议显式添加defineOptions({ name: ProductList })来确保名字稳定。include 数组里存的就是这个名字而不是路由 name千万不要混淆。当关闭一个标签页时除了要从 tabs 数组里删掉这条数据也要同步从cachedViews中删除组件名。这个动作在 Vue 内部会触发缓存组件的销毁next 次重新打开时会走 mount 流程。如果你的页面里有一些需要在关闭标签页立即释放的事件监听或定时器可以在组件的onDeactivated钩子里处理避免关闭了还在跑这也是我项目中一个不小的教训。7.2 跨页面数据筛选条件保持query 同步策略在列表页里用户设置了一堆筛选条件、切换了页码然后跳详情看数据再返回时希望筛选条件和页码还回原样——这种需求的实现和路由参数策略是紧密相关的。我的建议是“分层保存”影响分享的关键条件同步到 query其余冗余条件放 Pinia。举一个不太合适的极端反例如果界面有 10 个筛选字段全部塞 URL queryURL 会长得可怕而且别人复制链接打开失去这些字段也不会影响到核心定位反而是一种噪音。反过来如果只有 1 个“品类 ID”字段且是核心定位那不放 query 也影响别人精确打开页面。实践经验核心条件 2-3 个放 query比如 searchText、status、page完整筛选对象放 Pinia。组件初始化时优先从 query 初始化一次用户点击搜索时把 payload 写入 pinia 和 query 两者。当用户点击浏览器后退按钮改变 query 时组件中通过 watch 监听route.query同步到列表筛选条件。核心代码如下watch(() route.query, (query) { const searchParams { page: Number(query.page || 1), pageSize: Number(query.pageSize || 20), keyword: String(query.keyword || ), status: String(query.status || ) } productListStore.updateQuery(searchParams) productListStore.fetchProductList() })如果用这样一套机制页面内部不管跳转和 URL 变化怎么发生列表控件展示的条件始终与 URL、Pinia store 三方保持一致。用户返回时使用缓存 query 生成一样的数据请求页面不出现“条件还在但表格刷新为空”的尴尬。7.3 多仓库并存的项目中用 Pinia 做数据中枢现在很多团队在维护老项目时不会全量迁移而是让 Vue 3 新模块与原有的其他 MVVM 框架模块并存。页面路由可以分享同一个域名某些功能可能分布在不同的技术栈实现中。这种场景中Vue Router 负责的“地理位置”定位可以共享但数据状态却无法直接从一个框架流向另一个框架。一种做法是 pinia store 与一个事件总线接口比如浏览器原生 CustomEvent联动。Vue 3 部分的 store 数据发生变化时使用window.dispatchEvent(new CustomEvent(store:update, { detail }))通知外部外部框架更新了状态需要 Vue 部分识别时可以通过window.addEventListener收到事件后写入 store。// src/stores/modules/globalBridge.ts import { defineStore } from pinia import { ref } from vue export const useGlobalBridgeStore defineStore(globalBridge, () { const externalData refRecordstring, any({}) function listenExternalUpdate() { window.addEventListener(external:update, (event: Event) { const customEvent event as CustomEvent externalData.value customEvent.detail || {} }) } function notifyExternalChange() { window.dispatchEvent(new CustomEvent(internal:update, { detail: { timestamp: Date.now() } })) } return { externalData, listenExternalUpdate, notifyExternalChange } })这套机制能解决跨技术栈状态同步但要注意避免死循环。只有明确“事件发起端不同”时才 dispatch否则两个框架互相通知浏览器会被事件风暴炸掉。既然状态分属两个不同体系更稳妥的方案是统一存储源把高频共用字段放在 cookie 或 localStorage两边分别监听变化。简单够用不需要每个系统都为此升级到状态机。8. 常见问题与排查技巧项目里踩过的那些坑8.1 “getActivePinia() was called but there was no active Pinia”几乎每个新项目都会碰到这个错误翻译过来是“调用 useStore 时 Pinia 还没启动”。我总结出三个最容易触发它的位置第一在模块顶层代码里直接执行useUserStore()同文件底部导出的变量初始化时 Pinia 实例尚未创建。第二在一个非组件模块如工具函数文件、纯 ts 模块中先调用了 store调用时机在app.use(pinia)之前。第三单元测试中没有创建并安装 pinia 就尝试调用某个 store。解决思路是延迟调用。把 store 的获取放进函数内部或者确保外层调用发生在组件 setup 中、路由守卫回调里、Pinia 插件安装后。如果在非组件环境下确实需要访问 store可以自行创建一个 pinia 实例import { createPinia, setActivePinia } from pinia // 在测试或工具函数中 const pinia createPinia() setActivePinia(pinia)但这只是权宜之计。正式项目的路由守卫是同步串行逻辑通常不需要刻意 import createPinia只要保证 useStore 在守卫回调里执行即可。8.2 刷新页面后 Pinia 状态丢失但路由没有 404刷新时 Pinia 状态必然全部重置这是它的设计内存型状态管理。如果你的 store 数据没有持久化首页显示的数据会消失。排查顺序如下先确认是不是把请求放到了 onMounted 里但 onMounted 没被触发通常这和 keep-alive include 配置有关。其次检查 localStorage 持久化是否在恢复阶段有异步竞态比如启动时同时读取 token 和用户信息两个请求都完成之后才决定路由方向如果竞态出错用户看到了空页面。最后检查守卫里有没有正常触发“重新获取权限表”的逻辑如果没有也会出现刷新后 store 存在 token但动态路由没有重新注册导致的空白。这个问题的本质是“路由恢复”与“状态恢复”必须在一个全局流程中按顺序完成。我一般用一个小型启动控制器把顺序固定住读取 token - 调用户信息接口可选 - 拉取权限路由 - addRoute - 跳转。避免在许多页面组件里拼凑恢复逻辑。8.3 路由跳转成功但页面不更新状态排查 keep-alive 缓存误伤router-view里加keep-alive之后页面跳转到同组件不同参数比如从/product/detail/1跳到/product/detail/2组件不会重新触发 created / mounted。因为组件实例被缓存了只是参数变了。对于这种情况不能依赖生命周期钩子来重置数据而应该在组件里用watch监听路由参数变化主动触发数据加载。类似地如果你在 store 里缓存了detail需要每次 watch 时做一次 id 比对发现当前路线 id 与 store 里的 currentId 不一致就重新拉取。我自己踩过的一个真坑是在商品列表页用 keep-alive 缓存了组件然后关闭标签页时从 include 数组中移除组件名本以为组件会被销毁但实测某些版本的 Vue 3.3 下缓存视图仍会在内存中保留一帧。解决方式是在关闭 tab 时手动清除对应 store 中的数据。别赌编译器行为直接对数据负责。8.4 使用技巧速查表现象排查方向推荐解法刷新后页面空白无接口请求动态路由未注册守卫中检查 routesLoaded重新拉取并 addRoute刷新后 login 路由跳到了首页token 从 localStorage 被错误恢复确认持久化恢复逻辑在守卫之前正确执行store 数据变了但组件视图不更新解构 store 导致响应丢失改用 storeToRefs详情页跳转后显示上一个内容组件被 keep-alive 缓存生命周期未触发watch route.params.id 重新拉取动态路由权限变更后旧路由还在只 addRoute 没 removeRoute新增 removeRoute 记录异步可移除路由列表页前进返回后从顶部空白页面缓存和列表 store 缓存不一致使用 6.2 节的数据提升和控制缓存9. 扩展可能和一些建议Vue Router 4 与 Pinia 的组合当然不局限于中后台页面。在做移动端应用或大型 C 端站点时这套组合一样可以用到底部 Tab 切换页面时保留列表状态用户点击文章详情后返回保持滚动位置。与 7.1 节的标签页方案原理一致只是把 tabs 换成了固定的几个 tab 页面。项目做到后期还有一个常见诉求把路由配置和 store 定义进行 code splitting做到按路由懒加载 store。这个在 Pinia 中是推荐的。用 Vite 的import()配合动态导入只有访问该模块时才加载相关 store 代码。拆模块之后初始包体积能明显下降首屏性能会得到提升。如果你对这套方案感兴趣下一步可以试着封装一个小的组合式函数把页面级列表交互封装成通用usePageList内部统一管理路由 query、store 读取、loading、刷新、重置等操作减少重复代码。这个过程会促使你更深刻地理解路由和状态之间的边界。我个人实际做项目的一大感受是Vue Router 4 和 Pinia 各自都不复杂但把它们组织成一个成体系的协作模式需要一些项目经验的沉淀。以上这些思路和方案都是我在若干项目里反复打磨出来的阶段性版本不一定是每个场景的最优解但如果你正被“路由和状态到底该谁管”的问题困扰应该能提供一些行之有效的思路。最后再分享一个小技巧在开发时尽量保持一个 store 里只有领域数据与领域动作通用的页面交互状态交给组件内部。这个原则坚持下来你的 store 文件规模会一直稳定可控维护体验会好很多。
返回列表