模块三 · 工程与体验
路由与状态:把页面与共享状态放对位置
路由解决"页面在哪",状态解决"数据归谁"。这一节讲清三种路由模式、守卫执行顺序、Pinia 的结构,以及什么时候不该用全局状态。
读完这一节,你能做到
- 说清 history、hash、内存三种路由模式的差异与部署要求。
- 排出路由守卫的执行顺序,并知道该在哪一层做鉴权。
- 用 Pinia 组织 state/getters/actions,而不是把所有东西塞进一个 store。
- 判断一个状态该放路由、组件还是全局。
一、三种路由模式
| 模式 | URL | 部署要求 | 适用 |
|---|---|---|---|
| history | /user/1 | 服务端需回退到 index.html | 大多数 Web 应用 |
| hash | /#/user/1 | 无 | 静态托管、兼容旧环境 |
| memory | 不写 URL | 无 | SSR、测试、非浏览器环境 |
history 模式刷新 404 是最常见的问题:不是路由坏了,而是服务器没配置 fallback。
二、动态路由与参数
const routes = [
{ path: "/user/:id", component: User, props: true },
{ path: "/post/:slug?", component: Post },
];
props: true把参数解耦成组件 props,避免组件强依赖$route。- 参数变化不会重新创建组件,需要在
watch或onBeforeRouteUpdate里响应。 - 通配路由
/:pathMatch(.*)*放在最后,做 404 兜底。
三、守卫的执行顺序
失活组件 beforeRouteLeave
→ 全局 beforeEach
→ 复用组件 beforeRouteUpdate
→ 路由 beforeEnter
→ 解析异步路由组件
→ 激活组件 beforeRouteEnter
→ 全局 beforeResolve
→ 导航确认 → 全局 afterEach
→ DOM 更新 → beforeRouteEnter 的 next 回调
省略 beforeRouteUpdate 与“异步组件解析”两步,是面试里最常见的错误:参数变化复用组件时触发的是 beforeRouteUpdate,不是 beforeEnter;而路由懒加载的 chunk 是在 beforeEnter 之后才解析的。
实践建议:
- 鉴权放全局 beforeEach,只做"有没有权限"的判断。
- 数据预取放路由 beforeEnter 或组件内,避免全局守卫变成请求中心。
- 离开确认放 beforeRouteLeave,比如表单未保存提示。
- 守卫里返回
false取消导航,返回路由地址则重定向;异步守卫要await,否则不会等待。
一个常见的鉴权写法:
router.beforeEach(async (to) => {
if (!to.meta.auth) return true;
const user = await fetchUser();
if (!user) return { path: "/login", query: { from: to.fullPath } };
if (!user.roles.includes(to.meta.role)) return { path: "/403" };
return true;
});
注意两点:取用户信息要有缓存,否则每次跳转都发请求;未登录与无权限要跳不同页面,否则用户不知道自己是被登出还是被拒绝。
四、Pinia 的结构
export const useCartStore = defineStore("cart", {
state: () => ({ items: [] }),
getters: {
count: (state) => state.items.length,
total: (state) => state.items.reduce((s, i) => s + i.price, 0),
},
actions: {
add(item) {
this.items.push(item);
},
},
});
- state 必须用函数返回,避免多实例共享。
- getters 是派生状态,不要在里面做副作用。
- actions 可以异步,也可以调用其他 store。
- 需要持久化时用插件,而不是在每个 action 里手写 localStorage。
store 的拆分粒度
按“业务域”拆,不按“页面”拆:用户、购物车、偏好设置各一个 store。页面级状态留在组件里,避免 store 变成全局变量池。
跨 store 调用要克制:A store 直接读 B store 的 state,会让依赖关系变成网状;必要时通过 action 组合,而不是互相 import。
五、什么时候不该用全局状态
| 场景 | 更合适的位置 |
|---|---|
| 弹窗开关 | 页面组件内部 |
| 表单输入 | 表单组件内部 |
| 列表筛选条件 | 路由 query(可分享、可回退) |
| 登录态、购物车 | 全局 store |
| 主题、语言 | 全局 store + 持久化 |
判断标准:这个状态是否需要跨页面、跨层级、跨会话存在。只是“多个组件用”并不构成全局状态的理由。
路由与 URL 设计
URL 是应用对外的接口。列表页用 query 表达筛选与分页(?status=open&page=2),详情页用 params 表达资源(/orders/:id)。不要把筛选条件做成路径段,否则每换一个筛选都会新增一个“页面”,面包屑与回退都会乱。
一个列表页案例
需求:订单列表,支持状态筛选、分页、关键词搜索,刷新后还原。做法:状态全部进 query;组件内 watch(() => route.query, load);切换筛选时用 router.replace 避免污染历史;分页越界时回退到第一页。好处:链接可分享、浏览器回退可用、刷新不丢状态。
常见坑补充
- 守卫里做重定向要返回路由对象,不要 push 后再 return true。
- 动态路由的权限变化后要重置路由表,否则旧权限仍然可访问。
- KeepAlive 缓存的组件不会重新执行 onMounted,需要在 activated 里刷新数据。
- 全局守卫越多,导航越难排查;能放组件内解决的就不要放全局。
- 菜单从路由表生成,避免两套配置。
路由守卫不是万能钩子:把鉴权做成一个函数,守卫只负责调用;路由表是权限的单一来源。URL 设计先于组件设计,好的 URL 自己会说话。路由是页面的地址,也是状态的容器;先设计 URL,再设计组件。把权限规则写成表,守卫只做查表;能回退的 URL 才是好 URL,刷新能还原才算做对,分享链接不丢状态——这三点是列表页的底线。别急着写组件,做完再检查一遍。URL 是产品的骨架,先想清楚再写。
路由数据获取的三种模式
| 模式 | 做法 | 优缺点 |
|---|---|---|
| 导航前取数 | beforeEnter 里请求 | 页面渲染时数据已就绪,但导航会变慢 |
| 组件内取数 | onMounted/watch 参数 | 首屏快,需要处理加载态 |
| 路由 loader | 框架提供的数据加载 | 体验好,依赖框架能力 |
中小项目用“组件内取数 + 骨架屏”最稳;需要 SEO 或强一致时,才考虑导航前取数。
一个权限案例
后台系统有三类角色:管理员、运营、客服。做法是路由 meta.roles 声明可访问角色,全局守卫比对;菜单由同一份路由表过滤生成,避免“菜单能点但页面 403”。按钮级权限用权限码指令控制,权限码从用户信息里取,不在前端硬编码角色。
六、常见坑
- history 模式部署没配 fallback,刷新 404。
- 用
watch($route)处理所有参数变化,忘记清理副作用。 - 守卫里做重请求,导致导航变慢。
- Pinia 的 state 直接整体替换,丢失响应式引用。
- 把筛选条件放全局 store,导致返回页面时状态错乱。
七、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 给一个列表页加筛选条件,把条件同步到 URL query 并支持刷新还原。
- 写一个全局鉴权守卫,区分"未登录"和"无权限"两种跳转。
- 把购物车拆成 state/getters/actions 三层,并加一个持久化插件。
② 让 AI 写同一题
把第 2 题(路由守卫 + 共享状态)交给 AI,补一句 刷新后状态要能从 URL 或 storage 恢复。
③ 对比要盯的点
- 守卫里它处理了“未登录跳登录、登录后回原地址”吗?还是只做了一次跳转?
- Pinia 它用 setup store 还是 option store?和项目既有写法一致吗?
- 筛选条件它放 store 还是放 URL?放 store 的,刷新就丢。
④ 一句话取舍
它给的状态,刷新一次页面之后还剩下什么?
八、自测
- history 与 hash 的差异是什么?
- 守卫的完整执行顺序是什么?
- 参数变化时组件为什么会复用?
- Pinia 的 getters 与 computed 有什么关系?
- 什么状态不该放全局?
参考答案要点:URL 与部署;leave→beforeEach→beforeRouteUpdate→beforeEnter→解析组件→beforeRouteEnter→beforeResolve→afterEach;同一路由记录复用;都是派生缓存;生命周期不属于应用的状态。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★★ hash 模式和 history 模式的区别是什么?各自原理是什么?
- ★★★★☆ 导航守卫的完整执行顺序是怎样的?
- ★★★★☆ Pinia 相比 Vuex 有哪些改进?
- ★★★★☆ Vite 为什么冷启动快?Vue 项目怎么做多环境配置?
提示:读完可以顺手做几道对应题目,检验是否真的理解