跳到主要内容
预计阅读 85 分钟

路由与状态管理

当你在浏览器地址栏敲入一个 URL 并按下回车,浏览器向服务器发送请求,服务器返回一整页 HTML——这是 Web 诞生之初延续至今的经典模型。但在单页应用(SPA)的世界里,这套”一个 URL 对应一次完整请求”的模型被彻底重写了:浏览器只在首次加载时获取一个几乎空白的 HTML 壳,此后所有的”页面切换”都由前端 JavaScript 在本地完成。前端路由就是驱动这一切的核心引擎——它本质上是一套URL 到组件的映射算法。而当多个页面需要共享数据时,状态管理则充当了应用级的”中央数据库”。本章将从路由匹配的底层原理出发,逐步构建一个完整的在线博客平台的路由与状态体系。


📋 开篇自测

在正式学习之前,请先检验自己的当前认知水平。如果以下三个问题你都能准确回答,可以快速浏览本章;如果有任何迟疑,请认真逐节研读。

  1. Hash 模式和 History 模式在 URL 变更检测的底层事件机制上有什么区别? 各自的服务端部署要求是什么?
  2. Vue Router 的路由匹配器是如何将路径字符串转换为正则表达式,并按优先级排序的? 动态路由参数 :id 和通配符 /:pathMatch(.*)* 的匹配权重有何不同?
  3. Pinia 相比 Vuex 4 在架构设计上做了哪些根本性的简化? 为什么 Pinia 不再需要 Mutation?

一、单页应用与前端路由——URL 映射的算法本质

1.1 从多页到单页:请求模型的范式转移

在 Web 诞生后的头二十年里,“页面”这个概念是和一次完整的网络请求深度绑定的。你点击一个链接,浏览器向服务器发送 HTTP GET 请求,服务器查询数据库、拼装 HTML、返回完整文档,浏览器销毁旧页面、解析新文档、加载 CSS 和图片——整个流程走完,你才看到新内容。传统的多页应用(MPA)中,每一次页面跳转都会触发这样一个完整的 HTTP 请求-响应周期。这就像每次要换一首歌,都得把唱片机的电源拔掉、换一张唱片、重新通电启动——笨重但可靠。

单页应用(SPA)的出发点是一个简单的观察:既然大部分页面共享相同的导航栏、侧边栏和底部信息,为什么每次切换都要把整个文档推倒重来?更高效的方式是:只加载一次完整的应用框架,此后的”页面切换”只替换中间的内容区域。

整个应用只有一个 HTML 入口文件:

<!DOCTYPE html>
<html>
<head><title>博客平台</title></head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

所有页面组件——文章列表、文章详情、用户中心、登录页——都由 JavaScript 动态创建 DOM 节点并挂载到 <div id="app"> 下。页面切换不再经历浏览器的文档卸载-加载循环,而是由前端路由接管:监听 URL 变化,匹配对应组件,执行组件替换。

这带来了三个核心优势:

  • 无刷新切换:页面过渡更流畅,可以加入动画效果。
  • 状态保持:JavaScript 上下文在切换过程中不会丢失,内存中的数据(如用户登录态、表单草稿)天然保留。
  • 按需加载:只在需要时才下载对应页面的代码,首屏体积大幅缩减。

1.2 两种 URL 管理模式的底层机制

前端路由的核心难题是:如何在不触发浏览器全量请求的前提下,改变地址栏 URL 并感知这种变化? 浏览器提供了两套原生机制来解决这个问题。

Hash 模式——基于片段标识符

URL 中 # 后面的部分称为片段标识符(fragment identifier)。浏览器在处理片段标识符变化时,不会向服务器发送请求——这是 HTTP 规范的明确规定。同时,浏览器提供了 hashchange 事件来监听片段标识符的变化。

hashchange 能够捕获以下三种场景下的 URL 变化:用户点击带有 # 锚点的链接、浏览器前进/后退操作改变了 hash 值、以及通过 JavaScript 直接修改 window.location.hash。这三种场景覆盖了用户与路由交互的所有方式。

我们用一段精简的代码来演示 Hash 路由的核心逻辑:

// 极简 Hash 路由实现——理解核心算法
class MiniHashRouter {
  constructor(routes) {
    // routes 是一个路径到渲染函数的映射表
    this.routes = new Map(routes.map(r => [r.path, r.render]))
    this.container = document.getElementById('app')

    // 监听 hash 变化:涵盖 a 标签点击、前进后退、手动修改 URL
    window.addEventListener('hashchange', () => this.match())
    // 首次加载时也需要执行一次匹配
    window.addEventListener('DOMContentLoaded', () => this.match())
  }

  match() {
    // 提取 hash 中的路径部分,去掉 # 前缀
    const path = location.hash.slice(1) || '/'
    const render = this.routes.get(path)
    if (render) {
      this.container.innerHTML = render()
    } else {
      this.container.innerHTML = '<h1>404 - 页面未找到</h1>'
    }
  }
}

// 使用
new MiniHashRouter([
  { path: '/', render: () => '<h1>博客首页</h1><p>最新文章列表</p>' },
  { path: '/article', render: () => '<h1>文章详情</h1>' },
  { path: '/profile', render: () => '<h1>用户中心</h1>' }
])

Hash 模式的优势在于零配置部署——因为 # 后面的内容不会发送给服务器,所以无论用户访问 https://blog.com/#/article 还是 https://blog.com/#/profile,服务器收到的始终是 https://blog.com/ 的请求,只需要正常返回 index.html 即可。

History 模式——基于 HTML5 History API

HTML5 引入了 history.pushState()history.replaceState() 两个 API。这两个方法有一个极为特殊的能力:它们可以修改浏览器地址栏的 URL 而不触发页面刷新,也不向服务器发送任何请求。这使得 URL 可以像传统多页应用一样呈现为干净的路径格式(/article/42),而非 Hash 模式的 /#/article/42pushState 会在浏览器的历史记录栈中压入一条新记录,replaceState 则替换当前记录而不新增——前者对应”导航到新页面”,后者对应”重定向”。

但 History API 有一个关键的不对称性需要牢记:pushStatereplaceState 修改 URL 时不会触发任何事件,只有浏览器的前进/后退按钮操作才会触发 popstate 事件。也就是说,如果你在代码中调用 pushState 把地址改成了 /article/42,你还得自己主动去执行路由匹配逻辑——浏览器不会帮你做这件事。这意味着路由库需要自行拦截所有导航行为,包括链接点击和编程式跳转。

// 极简 History 路由实现——注意与 Hash 模式的差异
class MiniHistoryRouter {
  constructor(routes) {
    this.routes = new Map(routes.map(r => [r.path, r.render]))
    this.container = document.getElementById('app')

    // popstate 只在前进/后退时触发
    window.addEventListener('popstate', () => this.match())

    // 必须手动拦截所有链接点击
    document.addEventListener('click', (e) => {
      const anchor = e.target.closest('a[href]')
      if (anchor && anchor.getAttribute('href').startsWith('/')) {
        e.preventDefault()
        const href = anchor.getAttribute('href')
        // pushState 改变 URL 但不触发事件,需要手动调用 match
        history.pushState(null, '', href)
        this.match()
      }
    })

    window.addEventListener('DOMContentLoaded', () => this.match())
  }

  match() {
    const path = location.pathname
    const render = this.routes.get(path)
    this.container.innerHTML = render
      ? render()
      : '<h1>404 - 页面未找到</h1>'
  }
}

History 模式的服务端配置要求是它的主要部署成本:当用户直接访问 https://blog.com/article/42 或在该 URL 按下刷新时,浏览器会向服务器请求 /article/42 这个路径。服务器上并不存在这个实际文件,如果不做额外配置就会返回 404。解决方案是配置服务器将所有路径的请求都回退(fallback)到 index.html

# Nginx 配置示例
location / {
  try_files $uri $uri/ /index.html;
}

1.3 两种模式的选型决策

维度Hash 模式History 模式
URL 外观/#/article/42(含 #/article/42(干净路径)
底层事件hashchangepopstate + 手动拦截
服务端配置无需任何配置需要配置 fallback
SEO 友好度较差(搜索引擎对 # 后内容支持有限)良好
兼容性所有浏览器所有现代浏览器(Vue 3 已不支持 IE)
典型场景后台管理系统、内部工具面向用户的公开网站、博客

关键结论:对于在线博客这样需要 SEO 的场景,History 模式是首选。对于企业内部管理系统等不关心 URL 美观性的场景,Hash 模式的零配置优势更有吸引力。


二、Vue Router 核心——从路由表到组件渲染

2.1 安装与基础配置

Vue Router 是 Vue 的官方路由库,当前最新主版本为 Vue Router 5(将文件路由功能合并进了核心包)。对于不使用文件路由的项目,从 Vue Router 4 升级到 5 没有破坏性变更。让我们以在线博客平台为场景,构建一个完整的路由系统。

npm install vue-router

首先定义路由配置文件:

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import BlogHome from '../views/BlogHome.vue'

const routes = [
  {
    path: '/',
    name: 'blogHome',
    component: BlogHome
  },
  {
    path: '/article/:slug',
    name: 'articleDetail',
    // 路由级别的代码分割——稍后详解
    component: () => import('../views/ArticleDetail.vue')
  },
  {
    path: '/category/:categoryId',
    name: 'categoryPosts',
    component: () => import('../views/CategoryPosts.vue')
  },
  {
    path: '/user/:userId/profile',
    name: 'userProfile',
    component: () => import('../views/UserProfile.vue')
  },
  {
    path: '/login',
    name: 'loginPage',
    component: () => import('../views/LoginPage.vue')
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

export default router

在应用入口注册路由:

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(router)  // 注册路由插件,注入 $router 和 $route
app.mount('#app')

在根组件中使用 <router-view> 作为路由出口:

<!-- src/App.vue -->
<template>
  <nav class="blog-nav">
    <router-link to="/">首页</router-link>
    <router-link :to="{ name: 'categoryPosts', params: { categoryId: 'frontend' } }">
      前端专栏
    </router-link>
    <router-link to="/login">登录</router-link>
  </nav>
  <main>
    <router-view />
  </main>
</template>

<router-link> 在渲染时会生成一个 <a> 标签,但它拦截了默认的跳转行为,内部调用 router.push() 来实现无刷新导航。当链接匹配当前路由时,Vue Router 会自动为其添加 .router-link-active.router-link-exact-active CSS 类,便于高亮当前导航项。两个类的区别在于:.router-link-active 在路径包含匹配时生效(/user/101/articles 也会使 /user/101 的链接激活),.router-link-exact-active 则要求精确匹配。

理解这两个全局组件的关系很重要:<router-link> 负责触发导航<router-view> 负责渲染结果。前者是输入,后者是输出。Vue Router 在内部维护着一个响应式的 currentRoute 对象——每当导航发生,currentRoute 更新,<router-view> 检测到变化后重新渲染匹配的组件。

2.2 动态路由——参数化的路径匹配

在线博客的文章详情页需要根据不同的文章标识展示不同的内容。这就是动态路由的典型场景:

{
  path: '/article/:slug',  // :slug 是动态参数
  name: 'articleDetail',
  component: () => import('../views/ArticleDetail.vue')
}

路由参数通过 useRoute() 获取:

<!-- src/views/ArticleDetail.vue -->
<script setup>
import { ref, watchEffect } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const articleContent = ref(null)
const isLoading = ref(true)

// watchEffect 会在 route.params.slug 变化时自动重新执行
watchEffect(async () => {
  isLoading.value = true
  const slug = route.params.slug
  // 模拟从 API 获取文章数据
  const response = await fetch(`/api/articles/${slug}`)
  articleContent.value = await response.json()
  isLoading.value = false
})
</script>

<template>
  <div v-if="isLoading" class="loading">正在加载文章...</div>
  <article v-else-if="articleContent">
    <h1>{{ articleContent.title }}</h1>
    <div class="meta">
      <span>{{ articleContent.author }}</span>
      <time>{{ articleContent.publishedAt }}</time>
    </div>
    <div class="content" v-html="articleContent.body"></div>
  </article>
</template>

这里有一个重要的细节:当用户从 /article/vue-reactivity 导航到 /article/vue-router-guide 时,Vue Router 不会销毁再重建 ArticleDetail 组件,而是复用同一个组件实例并更新路由参数。这就是为什么我们使用 watchEffect 来响应参数变化,而非仅在 onMounted 中加载数据。

多参数与可选参数

// 多个动态段
{ path: '/user/:userId/article/:articleId', name: 'userArticle' }
// 对应 URL: /user/101/article/35
// route.params → { userId: '101', articleId: '35' }

// 可选参数(通过正则约束)
{ path: '/articles/:page(\\d+)?' }
// 匹配 /articles 和 /articles/2,但不匹配 /articles/abc

2.3 嵌套路由——组件层级的树状映射

博客平台的用户中心通常包含多个子页面:个人资料、我的文章、账号设置。这些页面共享用户中心的外层布局(侧边栏、头部信息),只有内容区域不同。嵌套路由正是为此设计的:

{
  path: '/user/:userId',
  component: () => import('../views/UserCenter.vue'),
  children: [
    {
      path: '',  // 空路径——默认子路由
      name: 'userProfile',
      component: () => import('../views/user/ProfileTab.vue')
    },
    {
      path: 'articles',
      name: 'userArticles',
      component: () => import('../views/user/ArticlesTab.vue')
    },
    {
      path: 'settings',
      name: 'userSettings',
      component: () => import('../views/user/SettingsTab.vue'),
      meta: { requiresAuth: true }
    }
  ]
}

父级组件通过 <router-view> 提供子路由出口:

<!-- src/views/UserCenter.vue -->
<script setup>
import { useRoute } from 'vue-router'

const route = useRoute()
</script>

<template>
  <div class="user-center">
    <aside class="sidebar">
      <h2>用户中心</h2>
      <nav>
        <router-link :to="{ name: 'userProfile', params: { userId: route.params.userId } }">
          个人资料
        </router-link>
        <router-link :to="{ name: 'userArticles', params: { userId: route.params.userId } }">
          我的文章
        </router-link>
        <router-link :to="{ name: 'userSettings', params: { userId: route.params.userId } }">
          账号设置
        </router-link>
      </nav>
    </aside>
    <section class="content-area">
      <!-- 子路由出口:ProfileTab / ArticlesTab / SettingsTab 在此渲染 -->
      <router-view />
    </section>
  </div>
</template>

嵌套路由的层级关系与组件的渲染层级一一对应。访问 /user/101/articles 时,UserCenter 组件渲染在 App.vue<router-view> 中,ArticlesTab 组件渲染在 UserCenter.vue<router-view> 中——这是一种树状映射结构。你可以将嵌套路由理解为一棵树:根节点是 App.vue 中的 <router-view>,每一层 children 对应一级子树,每一层都需要一个 <router-view> 来承载渲染结果。

有一个常见的陷阱需要注意:如果父路由配置了 children 但没有定义空路径 path: '' 的默认子路由,那么当用户访问父路由路径(如 /user/101)时,父组件会正常渲染,但其内部的 <router-view> 将是空的——因为没有任何子路由被匹配。

2.4 编程式导航与参数传递

除了 <router-link> 的声明式导航,Vue Router 提供了编程式导航 API:

<script setup>
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()  // 路由器实例——包含导航方法
const route = useRoute()    // 当前路由信息——包含参数、查询等

// 路径导航 + 查询参数
function searchArticles(keyword) {
  router.push({
    path: '/search',
    query: { q: keyword, page: 1 }
  })
  // URL: /search?q=vue&page=1
  // 目标组件通过 route.query.q 获取
}

// 命名路由导航 + 路径参数(params 必须搭配 name 使用)
function viewArticle(slug) {
  router.push({
    name: 'articleDetail',
    params: { slug }
  })
  // ❌ 注意:params 不能与 path 同时使用,否则 params 会被忽略
  // router.push({ path: '/article', params: { slug } }) // params 被静默忽略!
}

// 替换当前历史记录(不产生新的后退记录)
function redirectAfterLogin() {
  router.replace({ name: 'blogHome' })
}

// 历史记录前进/后退
function goBack() {
  router.go(-1)
}
</script>

queryparams 的核心区别:

维度queryparams(需配合动态路由段)
URL 呈现/search?q=vue&page=1/article/vue-guide
刷新后保持保持(序列化在 URL 中)保持(编码在路径中)
使用方式可搭配 pathname必须搭配 name
适用场景筛选条件、分页、搜索词资源标识符(ID、slug)

三、导航守卫——路由生命周期的拦截链

导航守卫是 Vue Router 提供的一组钩子函数,允许你在路由切换的不同阶段插入逻辑。如果把路由导航比作一趟列车从 A 站开往 B 站,那么导航守卫就是沿途的检查站——每个检查站都有权力决定:放行、改道(重定向到另一个路由)、或者拦截(中止导航)。

这套机制的设计模式类似于后端框架中的中间件(Middleware):守卫按照特定顺序依次执行,形成一条拦截链。任何一个守卫都可以中断后续流程。

3.1 全局守卫:应用级的路由策略

beforeEach——全局前置守卫

每次路由切换前都会执行,是实现登录拦截的最佳位置:

// src/router/index.js
router.beforeEach((to, from) => {
  const isAuthenticated = !!localStorage.getItem('auth_token')

  // 目标路由要求登录,但用户未认证
  if (to.meta.requiresAuth && !isAuthenticated) {
    // 重定向到登录页,并携带原始目标路径
    return {
      name: 'loginPage',
      query: { redirect: to.fullPath }
    }
  }

  // 已登录用户访问登录页,重定向到首页
  if (to.name === 'loginPage' && isAuthenticated) {
    return { name: 'blogHome' }
  }

  // 不返回任何值或返回 true,表示放行
})

afterEach——全局后置守卫

导航完成后执行,常用于页面标题更新、页面访问统计等不影响导航流程的操作:

router.afterEach((to) => {
  // 根据路由元信息更新页面标题
  document.title = to.meta.title
    ? `${to.meta.title} - 博客平台`
    : '博客平台'
})

beforeResolve——全局解析守卫

beforeEach 之后、导航确认之前执行。与 beforeEach 的区别在于,它在所有组件内守卫和异步路由组件加载完毕后才触发,适合执行依赖组件数据的检查:

router.beforeResolve(async (to) => {
  if (to.meta.requiresArticleCheck) {
    try {
      // 在导航确认前验证文章是否存在
      await verifyArticleExists(to.params.slug)
    } catch {
      return { name: 'notFound' }
    }
  }
})

3.2 路由独享守卫与组件内守卫

路由独享守卫——直接定义在路由配置上:

{
  path: '/user/:userId/settings',
  name: 'userSettings',
  component: () => import('../views/user/SettingsTab.vue'),
  meta: { requiresAuth: true },
  beforeEnter: (to) => {
    // 仅当目标是当前用户自己的设置页时放行
    const currentUserId = localStorage.getItem('current_user_id')
    if (to.params.userId !== currentUserId) {
      return { name: 'blogHome' }
    }
  }
}

组件内守卫——在 <script setup> 中通过组合式 API 使用:

<!-- src/views/ArticleEditor.vue -->
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const hasUnsavedChanges = ref(false)
const draftContent = ref('')

// 用户离开编辑页前检查是否有未保存的内容
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const confirmed = window.confirm('你有未保存的草稿,确定要离开吗?')
    if (!confirmed) {
      return false  // 中止导航
    }
  }
})
</script>

3.3 守卫的完整执行链路

一次导航触发时,守卫按以下顺序依次执行:

  1. beforeRouteLeave(离开组件的组件内守卫)
  2. beforeEach(全局前置守卫)
  3. beforeRouteUpdate(复用组件的组件内守卫,仅在组件复用时触发)
  4. beforeEnter(路由独享守卫)
  5. 解析异步路由组件
  6. beforeRouteEnter(进入组件的组件内守卫,注意:在 <script setup> 中不可用,替代方案是使用路由配置中的 beforeEnter 或全局 beforeEach
  7. beforeResolve(全局解析守卫)
  8. 导航确认
  9. afterEach(全局后置守卫)
  10. DOM 更新

理解这个链路对调试导航问题至关重要。当导航行为不符合预期时,可以在每个环节添加日志,逐步定位是哪个守卫中止或重定向了导航。一个实用的调试技巧是在开发环境中注册一个”全链路日志守卫”:

if (import.meta.env.DEV) {
  router.beforeEach((to, from) => {
    console.log(`[Router] beforeEach: ${from.fullPath} → ${to.fullPath}`)
  })
  router.beforeResolve((to) => {
    console.log(`[Router] beforeResolve: ${to.fullPath}`)
  })
  router.afterEach((to) => {
    console.log(`[Router] afterEach: ${to.fullPath} ✓`)
  })
}

这样,当某次导航”消失”了——用户点击链接但页面没有变化——你可以在控制台中看到导航在哪个阶段被中断。

3.4 典型场景:博客平台的权限拦截体系

// src/router/guards.js
export function setupGuards(router) {
  // 全局前置:认证检查
  router.beforeEach((to) => {
    const token = localStorage.getItem('auth_token')
    const isAuthenticated = !!token

    if (to.meta.requiresAuth && !isAuthenticated) {
      return { name: 'loginPage', query: { redirect: to.fullPath } }
    }

    // 角色检查
    // ⚠️ 注意:localStorage 中的角色信息可被用户篡改,仅适用于前端 UI 级别的权限控制。
    // 生产环境中,关键权限校验必须在服务端完成。
    if (to.meta.requiredRole) {
      const userRole = localStorage.getItem('user_role')
      if (userRole !== to.meta.requiredRole) {
        return { name: 'forbidden' }
      }
    }
  })

  // 全局后置:页面标题 + 访问统计
  router.afterEach((to) => {
    document.title = to.meta.title
      ? `${to.meta.title} - 博客平台`
      : '博客平台'

    // 上报页面访问事件
    if (typeof analytics !== 'undefined') {
      analytics.trackPageView(to.fullPath)
    }
  })
}

路由配置中通过 meta 字段标注权限需求:

{
  path: '/editor/new',
  name: 'createArticle',
  component: () => import('../views/ArticleEditor.vue'),
  meta: {
    requiresAuth: true,
    requiredRole: 'author',
    title: '撰写新文章'
  }
}

四、路由进阶——性能优化与用户体验

4.1 路由懒加载——按需分割代码

在前面的示例中,我们已经使用了动态 import() 语法:

component: () => import('../views/ArticleDetail.vue')

这条语句利用了 ES Module 的动态 import() 规范。构建工具(Vite / Webpack)在打包时会识别这个动态导入边界,将 ArticleDetail.vue 及其依赖打包成一个独立的 chunk 文件(通常命名为 ArticleDetail-[hash].js)。这个 chunk 文件在应用启动时不会被下载,只有当用户首次访问 /article/:slug 路由时,Vue Router 才会调用这个工厂函数,触发浏览器去下载对应的 chunk。对于包含十几个页面的博客平台,这可以将首屏的 JavaScript 体积减少 60% 以上。

这种策略的本质是将路由层级作为代码分割的天然边界。每个路由组件是一个独立的功能入口,用户可能只会访问其中的几个——没有理由让所有页面的代码都在首屏加载。

如果使用 Webpack 构建,可以通过魔法注释对 chunk 进行命名分组。如果使用 Vite,则应通过 rollupOptions.output.manualChunks 实现分包(魔法注释在 Vite 中不生效):

// Webpack:通过魔法注释将用户中心的三个子页面打包到同一个 chunk
{
  path: '',
  component: () => import(/* webpackChunkName: "user-center" */ '../views/user/ProfileTab.vue')
},
{
  path: 'articles',
  component: () => import(/* webpackChunkName: "user-center" */ '../views/user/ArticlesTab.vue')
},
{
  path: 'settings',
  component: () => import(/* webpackChunkName: "user-center" */ '../views/user/SettingsTab.vue')
}

4.2 滚动行为控制

默认情况下,SPA 页面切换后滚动条位置不会重置。Vue Router 提供了 scrollBehavior 配置来解决这个问题:

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 浏览器前进/后退时恢复之前的滚动位置
    if (savedPosition) {
      return savedPosition
    }

    // 跳转到锚点(如 /article/vue-guide#summary)
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth'  // 平滑滚动
      }
    }

    // 默认滚动到页面顶部
    return { top: 0 }
  }
})

对于文章列表这类需要保持滚动位置的场景,还可以结合 <KeepAlive> 缓存组件状态:

<template>
  <router-view v-slot="{ Component }">
    <KeepAlive include="BlogHome,CategoryPosts">
      <component :is="Component" />
    </KeepAlive>
  </router-view>
</template>

4.3 路由元信息(meta)的系统化运用

meta 字段是附着在路由记录上的自定义数据,Vue Router 不会对其做任何处理,完全由开发者定义和消费。在实际项目中,meta 通常承载以下信息:

{
  path: '/article/:slug',
  name: 'articleDetail',
  component: () => import('../views/ArticleDetail.vue'),
  meta: {
    title: '文章详情',           // 页面标题
    requiresAuth: false,        // 是否需要认证
    transition: 'slide-left',   // 页面过渡动画名称
    keepAlive: false,           // 是否缓存组件
    breadcrumb: [               // 面包屑导航数据
      { label: '首页', to: '/' },
      { label: '文章详情' }
    ]
  }
}

4.4 404 与通配路由

// 必须放在路由表的最后——匹配优先级最低
{
  path: '/:pathMatch(.*)*',
  name: 'notFound',
  component: () => import('../views/NotFound.vue')
}

:pathMatch(.*)* 使用了自定义正则,* 后缀表示参数可重复(匹配多段路径)。当用户访问任何未定义的路径时,都会匹配到这条路由。


五、Pinia 状态管理——从 Vuex 到下一代方案

5.1 为什么从 Vuex 迁移到 Pinia

Vuex 是 Vue 2 时代的官方状态管理库,它借鉴 Flux 架构,将状态变更限制在 Mutation 中以确保可追踪性。但随着 Vue 3 和 Composition API 的到来,Vuex 的几个设计决策变得越来越笨重:

痛点Vuex 4 的表现Pinia 的解决方案
Mutation 和 Action 的二元分割同步逻辑写 Mutation,异步写 Action;Action 中还得 commit Mutation只有 Action,同步异步统一处理
模块嵌套与命名空间namespaced: true,访问路径冗长:store.commit('user/auth/setToken')每个 Store 天然独立,无嵌套概念
TypeScript 支持类型推导困难,需要大量手动声明完整的类型推导,开箱即用
代码组织必须使用 state/getters/mutations/actions 固定结构支持选项式和组合式两种写法

Pinia 在 2021 年被 Vue 团队正式纳为官方推荐的状态管理方案。它不是 Vuex 5,而是一个全新设计的、与 Composition API 深度集成的状态管理库。Pinia 的名字来源于西班牙语的”菠萝”(pina),它的设计哲学可以用三个词概括:扁平、直觉、类型安全

先回答一个核心问题:为什么 Pinia 不需要 Mutation? Vuex 中 Mutation 的存在是为了让 DevTools 能够追踪每一次状态变更——因为 Mutation 必须是同步的,DevTools 可以在 Mutation 前后各捕获一次状态快照,形成完整的时间旅行记录。值得注意的是,Vuex 4(Vue 3 对应版本)同样使用了 Proxy,但仍保留了 Mutation。Pinia 移除 Mutation 更多是一个设计理念上的简化决策:Pinia 的作者认为 Mutation/Action 的二元分割增加了不必要的复杂度,而现代 DevTools 完全可以通过 Proxy 拦截和 $subscribe API 来追踪变更,不再需要 Mutation 这个”中间人”。因此 Pinia 直接移除了 Mutation 这一层,Action 可以自由地同步或异步修改 State——代码量减少了,心智负担也轻了。

5.2 安装与配置

npm install pinia
// src/main.js
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')

5.3 定义 Store——选项式与组合式

Pinia 支持两种 Store 定义方式。

选项式 Store(类似 Vuex 的风格,适合从 Vuex 迁移):

// src/stores/articleStore.js
import { defineStore } from 'pinia'

export const useArticleStore = defineStore('article', {
  state: () => ({
    blogPosts: [],
    currentPost: null,
    isLoading: false,
    pagination: {
      currentPage: 1,
      totalPages: 0,
      perPage: 10
    }
  }),

  getters: {
    // Getter 接收 state 作为参数,支持自动缓存
    publishedPosts(state) {
      return state.blogPosts.filter(post => post.status === 'published')
    },
    // Getter 可以引用其他 Getter
    publishedCount() {
      return this.publishedPosts.length
    },
    // 返回函数的 Getter——用于接收参数查询(注意:此形式不会缓存)
    postsByCategory(state) {
      return (categoryId) =>
        state.blogPosts.filter(post => post.categoryId === categoryId)
    }
  },

  actions: {
    // Action 中可以直接修改 state,无需 Mutation
    async fetchBlogPosts(page = 1) {
      this.isLoading = true
      try {
        const response = await fetch(`/api/posts?page=${page}&limit=${this.pagination.perPage}`)
        const data = await response.json()
        this.blogPosts = data.posts
        this.pagination.currentPage = page
        this.pagination.totalPages = data.totalPages
      } catch (error) {
        console.error('获取文章列表失败:', error)
        throw error
      } finally {
        this.isLoading = false
      }
    },

    async fetchPostBySlug(slug) {
      this.isLoading = true
      try {
        const response = await fetch(`/api/posts/${slug}`)
        this.currentPost = await response.json()
      } finally {
        this.isLoading = false
      }
    }
  }
})

组合式 Store(与 <script setup> 风格一致,推荐在新项目中使用):

// src/stores/authStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  // ref() 对应 state
  const token = ref(localStorage.getItem('auth_token') || '')
  const userInfo = ref(null)
  const loginLoading = ref(false)

  // computed() 对应 getters
  const isAuthenticated = computed(() => !!token.value)
  const displayName = computed(() =>
    userInfo.value?.nickname || userInfo.value?.username || '匿名用户'
  )

  // 普通函数对应 actions
  async function login(credentials) {
    loginLoading.value = true
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      })
      if (!response.ok) throw new Error('登录失败')

      const data = await response.json()
      token.value = data.token
      userInfo.value = data.user
      localStorage.setItem('auth_token', data.token)
    } finally {
      loginLoading.value = false
    }
  }

  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('auth_token')
  }

  async function fetchUserProfile() {
    if (!token.value) return
    const response = await fetch('/api/auth/profile', {
      headers: { Authorization: `Bearer ${token.value}` }
    })
    userInfo.value = await response.json()
  }

  return {
    token,
    userInfo,
    loginLoading,
    isAuthenticated,
    displayName,
    login,
    logout,
    fetchUserProfile
  }
})

两种写法的选择标准很简单:如果团队已经习惯 Composition API,就用组合式 Store;如果从 Vuex 迁移或者团队更熟悉选项式风格,就用选项式 Store。两者在功能上几乎完全等价,但有一个细微差异:选项式 Store 自带 $reset() 方法可以将 State 恢复到初始值,组合式 Store 则需要手动实现这个功能(因为 Pinia 无法从工厂函数中自动推断初始值)。

5.4 State、Getters、Actions 的底层运作

State 的响应式本质

Pinia 的 State 在底层通过 reactive() 进行响应式包装。这意味着 State 对象上的任何属性修改都会被 Vue 的响应式系统捕获并触发视图更新。

修改 State 有三种方式:

const articleStore = useArticleStore()

// 方式一:直接修改(最直观)
articleStore.isLoading = true

// 方式二:$patch 对象合并(一次修改多个属性,只触发一次更新)
articleStore.$patch({
  isLoading: false,
  blogPosts: newPosts,
  pagination: { ...articleStore.pagination, currentPage: 2 }
})

// 方式三:$patch 函数形式(适合复杂修改逻辑,如数组操作)
articleStore.$patch((state) => {
  state.blogPosts.push(newPost)
  state.pagination.totalPages = Math.ceil(
    (state.blogPosts.length) / state.pagination.perPage
  )
})

Getters 的缓存机制

Getters 本质上是 computed 属性。它们的返回值会被缓存,只在依赖的 State 发生变化时才重新计算。这使得即使在模板中多次引用同一个 Getter,计算逻辑也只执行一次。

Actions 的统一模型

与 Vuex 将同步修改(Mutation)和异步操作(Action)强制分离不同,Pinia 的 Actions 同时处理同步和异步逻辑。在 Vuex 中,一个典型的数据请求流程是:组件 dispatch Action -> Action 中 fetch 数据 -> Action 内部 commit Mutation -> Mutation 修改 State。四步链条中,Mutation 这一步几乎只是 state.xxx = payload 这样的赋值语句,却必须单独声明为一个函数。Pinia 将这四步缩减为两步:组件调用 Action -> Action 中直接修改 State。代码量减少了,数据流的可读性也显著提升。

5.5 在组件中使用 Store

<!-- src/views/BlogHome.vue -->
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useArticleStore } from '../stores/articleStore'
import { useAuthStore } from '../stores/authStore'

const articleStore = useArticleStore()
const authStore = useAuthStore()

// storeToRefs:保持响应式的解构
// 只提取 state 和 getters,不提取 actions
const { blogPosts, isLoading, publishedPosts, pagination } = storeToRefs(articleStore)
const { isAuthenticated, displayName } = storeToRefs(authStore)

// actions 直接从 store 解构即可(函数不需要响应式)
const { fetchBlogPosts } = articleStore

onMounted(() => {
  fetchBlogPosts()
})

function handlePageChange(page) {
  fetchBlogPosts(page)
}
</script>

<template>
  <div class="blog-home">
    <header v-if="isAuthenticated">
      欢迎回来,{{ displayName }}
    </header>

    <div v-if="isLoading" class="loading">正在加载文章...</div>

    <ul v-else class="post-list">
      <li v-for="post in publishedPosts" :key="post.id" class="post-card">
        <router-link :to="{ name: 'articleDetail', params: { slug: post.slug } }">
          <h2>{{ post.title }}</h2>
          <p>{{ post.excerpt }}</p>
        </router-link>
      </li>
    </ul>

    <div class="pagination">
      <button
        v-for="page in pagination.totalPages"
        :key="page"
        :class="{ active: page === pagination.currentPage }"
        @click="handlePageChange(page)"
      >
        {{ page }}
      </button>
    </div>
  </div>
</template>

注意 storeToRefs 的使用——这是一个容易踩坑的地方。Pinia 的 Store 实例本质上是一个 reactive 对象。第四章我们学过,对 reactive 对象进行解构会切断响应式连接——解构得到的是一个普通值的副本,后续 Store 内部的变更不会反映到组件中。storeToRefs 的作用是将 Store 中的每个 state 属性和 getter 属性转换为独立的 ref,这些 ref 内部仍然指向 Store 的原始数据,因此响应式链路完整保留。

但要注意,storeToRefs 只转换 state 和 getters,不转换 actions——它会自动过滤掉非 ref/reactive/computed 的属性。Actions 是普通函数,不需要响应式包装,直接从 Store 解构即可。


六、Pinia 实战——模块化、持久化与开发工具

6.1 模块化 Store 设计

一个真实的博客平台通常需要多个 Store:

src/stores/
  ├── authStore.js        # 认证状态:token、用户信息、登录/登出
  ├── articleStore.js     # 文章状态:文章列表、当前文章、分页
  ├── categoryStore.js    # 分类状态:分类列表、当前分类
  └── uiStore.js          # 界面状态:主题、侧边栏开关、全局通知

Pinia 天然以扁平结构组织 Store——每个 Store 都是独立的,没有 Vuex 的模块嵌套层级。在 Vuex 中,你可能会写出 store.state.user.auth.token 这样层层嵌套的访问路径;在 Pinia 中,每个 Store 通过 useXxxStore() 独立获取,互不依赖。这使得 Store 之间的边界清晰,Tree-shaking 也更加友好——如果某个 Store 没有被任何组件引用,打包工具可以直接将其从最终产物中移除。

Store 的划分原则是按业务域而非技术层。不要创建一个巨大的 useAppStore 塞入所有状态,也不要按照”getter 放一个 Store、action 放一个 Store”来拆分。正确的做法是每个业务域一个 Store:认证是一个域,文章管理是一个域,UI 偏好是一个域。

6.2 Store 间的相互引用

当一个 Store 需要访问另一个 Store 的数据时,直接在 Action 或 Getter 内部调用对方的 useXxxStore() 即可:

// src/stores/articleStore.js
import { defineStore } from 'pinia'
import { useAuthStore } from './authStore'

export const useArticleStore = defineStore('article', {
  // ...state, getters 省略

  actions: {
    async publishArticle(articleData) {
      const authStore = useAuthStore()  // 在 Action 内部获取其他 Store

      if (!authStore.isAuthenticated) {
        throw new Error('请先登录')
      }

      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${authStore.token}`
        },
        body: JSON.stringify({
          ...articleData,
          authorId: authStore.userInfo.id
        })
      })

      if (!response.ok) throw new Error('发布失败')

      const newPost = await response.json()
      this.blogPosts.unshift(newPost)
      return newPost
    }
  }
})

注意:不要在 Store 的顶层作用域(statedefineStore 回调函数的顶层)调用其他 Store,因为这可能导致循环依赖。始终在 Getter 或 Action 内部按需调用。

6.3 状态持久化

浏览器刷新后,Pinia Store 中的状态会回到初始值。对于需要跨会话保持的数据(如认证 token、用户偏好设置),可以使用 pinia-plugin-persistedstate 插件(注意 v3 和 v4 的 API 有差异,如筛选字段 v3 用 paths,v4 用 pick,本文示例基于 v4):

npm install pinia-plugin-persistedstate@^4
// src/main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

在 Store 中启用持久化:

// src/stores/authStore.js(选项式写法)
export const useAuthStore = defineStore('auth', {
  state: () => ({
    token: '',
    userInfo: null
  }),
  // ...getters, actions 省略
  persist: {
    key: 'blog-auth',                    // localStorage 中的键名
    storage: localStorage,               // 存储引擎(也可用 sessionStorage)
    pick: ['token', 'userInfo']          // 只持久化指定字段(v4+ 语法,v3 使用 paths)
  }
})
// 组合式写法中启用持久化
export const useAuthStore = defineStore('auth', () => {
  const token = ref('')
  const userInfo = ref(null)

  // ...其他逻辑

  return { token, userInfo /* ... */ }
}, {
  persist: {
    key: 'blog-auth',
    pick: ['token', 'userInfo']
  }
})

持久化插件的原理是:在 Store 初始化时从 Storage 中读取数据并合并到 State;在 State 变化时通过 $subscribe 监听器将数据写入 Storage。$subscribe 是 Pinia 提供的状态订阅 API,类似于 Vue 的 watch,但粒度在 Store 级别——任何 State 变更都会触发回调。

需要注意的是,不要把敏感信息(如密码)存入 localStorage。Token 可以存储,因为它本身就是设计为在客户端保存的凭证。此外,localStorage 的存储容量通常限制在 5-10MB,对于大型数据集(如缓存的文章列表),可能需要考虑 IndexedDB 等更大容量的存储方案。

6.4 与 Vue DevTools 集成

Pinia 深度集成 Vue DevTools,提供以下调试能力:

  • Store 面板:实时查看所有已激活 Store 的 State 值,支持直接编辑。
  • 时间线:Action 的调用记录按时间轴排列,包含调用参数和持续时间。
  • 状态快照:可以手动”时间旅行”——回到某个历史状态查看彼时的应用表现。

这些能力在调试复杂状态流时极为有用。当文章发布后列表没有更新时,你可以在 DevTools 中检查 articleStore.blogPosts 是否确实被修改了,Action 是否成功执行——定位问题的效率远高于在代码中打 console.log

安装方式也很简单:只要你使用了 Vue DevTools 浏览器扩展(Chrome 或 Firefox),并且在应用中正确注册了 Pinia 实例(app.use(pinia)),Pinia 面板就会自动出现在 DevTools 中,无需任何额外配置。在生产环境构建中,DevTools 集成代码会被自动移除,不会影响线上性能。

6.5 Store 的组合实战:登录流程完整闭环

将路由守卫与 Pinia Store 结合,实现完整的登录流程:

<!-- src/views/LoginPage.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/authStore'

const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()

const formData = ref({
  username: '',
  password: ''
})
const errorMessage = ref('')

async function handleLogin() {
  errorMessage.value = ''
  try {
    await authStore.login(formData.value)

    // 登录成功后跳转到原始目标页(如果有),否则回首页
    const redirectPath = route.query.redirect || '/'
    router.replace(redirectPath)
  } catch (err) {
    errorMessage.value = '用户名或密码错误,请重试'
  }
}
</script>

<template>
  <div class="login-page">
    <h1>登录博客平台</h1>
    <form @submit.prevent="handleLogin">
      <div class="field">
        <label>用户名</label>
        <input v-model="formData.username" type="text" required />
      </div>
      <div class="field">
        <label>密码</label>
        <input v-model="formData.password" type="password" required />
      </div>
      <p v-if="errorMessage" class="error">{{ errorMessage }}</p>
      <button type="submit" :disabled="authStore.loginLoading">
        {{ authStore.loginLoading ? '登录中...' : '登录' }}
      </button>
    </form>
  </div>
</template>

整个闭环是这样运作的:

  1. 用户访问受保护路由 /editor/new
  2. beforeEach 守卫检测到未认证,重定向到 /login?redirect=/editor/new
  3. 用户提交凭证,authStore.login() 发送请求并更新 State。
  4. 持久化插件将 token 写入 localStorage
  5. 登录成功后 router.replace(redirectPath) 跳转到 /editor/new
  6. beforeEach 再次执行,此时 token 存在,放行。

🤔 思考题

请在学习完本章后,认真思考以下问题:

  1. 路由匹配的边界情况:如果同时定义了 /article/:slug/article/featured 两条路由,当用户访问 /article/featured 时会匹配哪一条?Vue Router 的优先级排序算法是如何处理静态段和动态段冲突的?

  2. 守卫与异步的协作:在 beforeEach 中调用一个耗时的异步接口(如验证 token 有效性),页面在等待期间会呈现什么状态?如何设计一个全局 Loading 状态来改善用户体验?

  3. Pinia 的 $reset() 方法只在选项式 Store 中可用。如果你使用组合式 Store,如何手动实现等价的 $reset() 功能?请给出代码示例。

  4. Store 的生命周期:Pinia Store 实例是在 useXxxStore() 首次调用时创建的。如果一个 Store 只在某个路由页面中使用,当用户离开该页面后,Store 实例和其中的状态会被销毁吗?这对内存管理有什么影响?

  5. SSR 场景下的状态污染:在服务端渲染中,如果多个请求共享同一个 Pinia 实例,会发生什么?Pinia 的 SSR 方案是如何解决这个问题的?


📝 结尾自测

完成本章学习后,请独立回答以下五个问题,验证你的掌握程度。

1. Hash 模式与 History 模式在”用户手动刷新页面”这个场景下的行为有何根本不同?

提示:从浏览器发送的 HTTP 请求路径出发分析,为什么 History 模式需要服务端 fallback 配置。

2. 以下路由配置存在什么问题?当用户访问 /user/101 时会发生什么?

const routes = [
  {
    path: '/user/:userId',
    component: UserCenter,
    children: [
      {
        path: 'articles',
        component: ArticlesTab
      },
      {
        path: 'settings',
        component: SettingsTab
      }
    ]
  }
]

答案方向:缺少默认子路由(空路径子路由)会导致子路由出口 <router-view> 渲染为空。

3. 在组件中直接解构 Pinia Store 对象为什么会丢失响应式?storeToRefs 做了什么来解决这个问题?

提示:Store 本质上是一个 reactive 对象,回忆第四章中 reactive 解构丢失响应式的原因。

4. 下面这段导航守卫代码可能导致什么严重问题?如何修复?

router.beforeEach((to) => {
  if (!isAuthenticated()) {
    return { name: 'loginPage' }
  }
})

答案方向:当用户未登录时访问登录页,会触发无限重定向循环(loginPage 本身也不满足认证条件)。

5. Pinia 组合式 Store 中的 refcomputed 与普通组件中的 refcomputed 在行为上有区别吗?它们的响应式机制是否一致?

提示:Pinia 组合式 Store 的工厂函数在底层被 effectScope 包裹,思考 effectScope 对副作用管理的影响。


本章从前端路由的底层事件机制出发,系统梳理了 Hash 模式与 History 模式的实现原理差异;通过构建在线博客平台的路由体系,掌握了 Vue Router 的路由配置、动态路由、嵌套路由和编程式导航;在导航守卫部分,理解了守卫的完整执行链路及其在权限控制中的实战应用;在性能优化部分,掌握了路由懒加载、滚动行为控制和路由元信息的系统化运用;最后从 Vuex 的设计局限出发,深入学习了 Pinia 的 Store 定义、State/Getters/Actions 的底层运作机制、模块化组织、Store 间引用、状态持久化以及 DevTools 集成。下一章我们将进入「组件设计模式」,学习容器组件与展示组件、递归组件、动态组件、Composable 抽象等六大核心模式,掌握在不同工程场景下做出正确的组件架构决策。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90