4.1 Vue Router在SSR中的应用
4.1.1 路由器配置差异
在SSR环境中,路由器的配置需要考虑服务端和客户端的不同需求:
// src/router/index.js
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import { isServer } from '@/utils/env'
// 路由配置
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: {
title: '首页',
description: '欢迎访问我们的网站',
keywords: 'vue, ssr, 首页'
}
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue'),
meta: {
title: '关于我们',
description: '了解更多关于我们的信息'
}
},
{
path: '/blog',
name: 'Blog',
component: () => import('@/views/Blog.vue'),
children: [
{
path: '',
name: 'BlogList',
component: () => import('@/views/BlogList.vue')
},
{
path: ':id',
name: 'BlogPost',
component: () => import('@/views/BlogPost.vue'),
meta: {
requiresData: true
}
}
]
},
{
path: '/user',
name: 'User',
component: () => import('@/views/User.vue'),
meta: {
requiresAuth: true
},
children: [
{
path: 'profile',
name: 'UserProfile',
component: () => import('@/views/UserProfile.vue')
},
{
path: 'settings',
name: 'UserSettings',
component: () => import('@/views/UserSettings.vue')
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: {
title: '页面未找到'
}
}
]
// 创建路由器工厂函数
export function createRouter() {
const router = createRouter({
// 服务端使用内存历史,客户端使用Web历史
history: isServer ? createMemoryHistory() : createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
// 只在客户端执行滚动行为
if (!isServer) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
}
})
return router
}
4.1.2 路由元信息管理
// src/utils/meta.js
export function updateMetaInfo(route) {
if (typeof document === 'undefined') return
const { meta } = route
// 更新页面标题
if (meta.title) {
document.title = `${meta.title} - Vue SSR App`
}
// 更新meta标签
updateMetaTag('description', meta.description)
updateMetaTag('keywords', meta.keywords)
// 更新Open Graph标签
updateMetaTag('og:title', meta.title, 'property')
updateMetaTag('og:description', meta.description, 'property')
updateMetaTag('og:image', meta.image, 'property')
// 更新Twitter Card标签
updateMetaTag('twitter:title', meta.title, 'name')
updateMetaTag('twitter:description', meta.description, 'name')
updateMetaTag('twitter:image', meta.image, 'name')
}
function updateMetaTag(name, content, attribute = 'name') {
if (!content) return
let element = document.querySelector(`meta[${attribute}="${name}"]`)
if (element) {
element.setAttribute('content', content)
} else {
element = document.createElement('meta')
element.setAttribute(attribute, name)
element.setAttribute('content', content)
document.head.appendChild(element)
}
}
// 服务端meta信息生成
export function generateMetaTags(route) {
const { meta } = route
const tags = []
if (meta.description) {
tags.push(`<meta name="description" content="${meta.description}">`)
}
if (meta.keywords) {
tags.push(`<meta name="keywords" content="${meta.keywords}">`)
}
if (meta.title) {
tags.push(`<meta property="og:title" content="${meta.title}">`)
}
if (meta.description) {
tags.push(`<meta property="og:description" content="${meta.description}">`)
}
if (meta.image) {
tags.push(`<meta property="og:image" content="${meta.image}">`)
}
return tags.join('\n')
}
4.2 嵌套路由与布局
4.2.1 嵌套路由配置
// src/router/nested.js
export const nestedRoutes = [
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true
},
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/views/admin/Dashboard.vue')
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/Users.vue')
},
{
path: 'posts',
name: 'AdminPosts',
component: () => import('@/views/admin/Posts.vue'),
children: [
{
path: '',
name: 'AdminPostsList',
component: () => import('@/views/admin/PostsList.vue')
},
{
path: 'create',
name: 'AdminPostsCreate',
component: () => import('@/views/admin/PostsCreate.vue')
},
{
path: ':id/edit',
name: 'AdminPostsEdit',
component: () => import('@/views/admin/PostsEdit.vue')
}
]
}
]
},
{
path: '/shop',
component: () => import('@/layouts/ShopLayout.vue'),
children: [
{
path: '',
name: 'ShopHome',
component: () => import('@/views/shop/Home.vue')
},
{
path: 'category/:categoryId',
name: 'ShopCategory',
component: () => import('@/views/shop/Category.vue'),
children: [
{
path: '',
name: 'ShopCategoryList',
component: () => import('@/views/shop/CategoryList.vue')
},
{
path: 'product/:productId',
name: 'ShopProduct',
component: () => import('@/views/shop/Product.vue')
}
]
}
]
}
]
4.2.2 布局组件设计
<!-- src/layouts/AdminLayout.vue -->
<template>
<div class="admin-layout">
<AdminHeader />
<div class="admin-content">
<AdminSidebar />
<main class="admin-main">
<router-view />
</main>
</div>
<AdminFooter />
</div>
</template>
<script>
import AdminHeader from '@/components/admin/AdminHeader.vue'
import AdminSidebar from '@/components/admin/AdminSidebar.vue'
import AdminFooter from '@/components/admin/AdminFooter.vue'
export default {
name: 'AdminLayout',
components: {
AdminHeader,
AdminSidebar,
AdminFooter
},
async asyncData({ store, route }) {
// 预取管理员权限信息
await store.dispatch('auth/checkAdminPermissions')
}
}
</script>
<style scoped>
.admin-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-content {
flex: 1;
display: flex;
}
.admin-main {
flex: 1;
padding: 20px;
background-color: #f5f5f5;
}
</style>
<!-- src/layouts/ShopLayout.vue -->
<template>
<div class="shop-layout">
<ShopHeader />
<ShopNavigation />
<div class="shop-content">
<aside class="shop-sidebar" v-if="showSidebar">
<ShopFilters />
</aside>
<main class="shop-main">
<router-view />
</main>
</div>
<ShopFooter />
</div>
</template>
<script>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import ShopHeader from '@/components/shop/ShopHeader.vue'
import ShopNavigation from '@/components/shop/ShopNavigation.vue'
import ShopFilters from '@/components/shop/ShopFilters.vue'
import ShopFooter from '@/components/shop/ShopFooter.vue'
export default {
name: 'ShopLayout',
components: {
ShopHeader,
ShopNavigation,
ShopFilters,
ShopFooter
},
setup() {
const route = useRoute()
const showSidebar = computed(() => {
return ['ShopCategory', 'ShopCategoryList'].includes(route.name)
})
return {
showSidebar
}
}
}
</script>
4.3 动态路由与参数
4.3.1 动态路由匹配
// src/router/dynamic.js
export const dynamicRoutes = [
{
// 用户资料页面
path: '/user/:userId(\\d+)',
name: 'UserProfile',
component: () => import('@/views/UserProfile.vue'),
props: true,
meta: {
requiresData: true
}
},
{
// 博客文章页面
path: '/blog/:year(\\d{4})/:month(\\d{2})/:slug',
name: 'BlogPost',
component: () => import('@/views/BlogPost.vue'),
props: route => ({
year: parseInt(route.params.year),
month: parseInt(route.params.month),
slug: route.params.slug
})
},
{
// 产品页面,支持可选的变体参数
path: '/product/:productId/:variant?',
name: 'Product',
component: () => import('@/views/Product.vue'),
props: route => ({
productId: route.params.productId,
variant: route.params.variant || 'default'
})
},
{
// 多级分类页面
path: '/category/:categories+',
name: 'Category',
component: () => import('@/views/Category.vue'),
props: route => ({
categories: route.params.categories.split('/')
})
}
]
4.3.2 路由参数验证
// src/utils/routeValidation.js
export const routeValidators = {
userId: (value) => {
const id = parseInt(value)
return !isNaN(id) && id > 0
},
year: (value) => {
const year = parseInt(value)
return year >= 2000 && year <= new Date().getFullYear()
},
month: (value) => {
const month = parseInt(value)
return month >= 1 && month <= 12
},
slug: (value) => {
return /^[a-z0-9-]+$/.test(value)
}
}
// 路由守卫中使用验证
export function validateRouteParams(to, from, next) {
const { params } = to
for (const [key, value] of Object.entries(params)) {
const validator = routeValidators[key]
if (validator && !validator(value)) {
next({ name: 'NotFound' })
return
}
}
next()
}
4.3.3 查询参数处理
// src/utils/queryParams.js
export function parseQueryParams(query) {
const parsed = {}
for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
parsed[key] = value
} else if (value === 'true') {
parsed[key] = true
} else if (value === 'false') {
parsed[key] = false
} else if (!isNaN(value) && value !== '') {
parsed[key] = Number(value)
} else {
parsed[key] = value
}
}
return parsed
}
export function buildQueryString(params) {
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') {
if (Array.isArray(value)) {
value.forEach(v => searchParams.append(key, v))
} else {
searchParams.append(key, value)
}
}
}
return searchParams.toString()
}
// 在组件中使用
export function useQueryParams() {
const route = useRoute()
const router = useRouter()
const queryParams = computed(() => parseQueryParams(route.query))
const updateQuery = (newParams) => {
const query = { ...route.query, ...newParams }
router.push({ query })
}
return {
queryParams,
updateQuery
}
}
4.4 导航守卫详解
4.4.1 全局守卫
// src/router/guards.js
import { useAuthStore } from '@/stores/auth'
import { updateMetaInfo } from '@/utils/meta'
export function setupGlobalGuards(router) {
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
console.log('Navigation from', from.path, 'to', to.path)
// 1. 验证路由参数
if (!validateRouteParams(to)) {
next({ name: 'NotFound' })
return
}
// 2. 检查认证状态
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({
name: 'Login',
query: { redirect: to.fullPath }
})
return
}
// 3. 检查管理员权限
if (to.meta.requiresAdmin && !authStore.isAdmin) {
next({ name: 'Forbidden' })
return
}
// 4. 检查用户权限
if (to.meta.permissions) {
const hasPermission = to.meta.permissions.some(permission =>
authStore.hasPermission(permission)
)
if (!hasPermission) {
next({ name: 'Forbidden' })
return
}
}
next()
})
// 全局解析守卫
router.beforeResolve(async (to, from, next) => {
// 预取数据
if (to.meta.requiresData) {
try {
await prefetchRouteData(to)
} catch (error) {
console.error('Failed to prefetch data:', error)
next({ name: 'Error', params: { error: error.message } })
return
}
}
next()
})
// 全局后置钩子
router.afterEach((to, from, failure) => {
if (failure) {
console.error('Navigation failed:', failure)
return
}
// 更新页面标题和meta信息
updateMetaInfo(to)
// 发送页面访问统计
if (typeof gtag !== 'undefined') {
gtag('config', 'GA_MEASUREMENT_ID', {
page_path: to.fullPath,
page_title: to.meta.title
})
}
// 滚动到顶部(客户端)
if (typeof window !== 'undefined') {
window.scrollTo(0, 0)
}
})
}
function validateRouteParams(route) {
// 实现路由参数验证逻辑
return true
}
async function prefetchRouteData(route) {
// 实现数据预取逻辑
const matchedComponents = route.matched
.flatMap(record => Object.values(record.components || {}))
const promises = matchedComponents
.filter(component => component.asyncData)
.map(component => component.asyncData({ route }))
await Promise.all(promises)
}
4.4.2 路由独享守卫
// src/router/routeGuards.js
export const adminGuard = async (to, from, next) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
next({ name: 'Login' })
return
}
if (!authStore.isAdmin) {
next({ name: 'Forbidden' })
return
}
// 检查管理员会话是否过期
try {
await authStore.validateAdminSession()
next()
} catch (error) {
next({ name: 'Login' })
}
}
export const userProfileGuard = async (to, from, next) => {
const authStore = useAuthStore()
const userId = to.params.userId
// 检查用户是否有权限访问该资料页
if (authStore.currentUser.id !== userId && !authStore.isAdmin) {
next({ name: 'Forbidden' })
return
}
next()
}
// 在路由配置中使用
const routes = [
{
path: '/admin',
component: AdminLayout,
beforeEnter: adminGuard,
children: [...]
},
{
path: '/user/:userId',
component: UserProfile,
beforeEnter: userProfileGuard
}
]
4.4.3 组件内守卫
<!-- src/views/BlogPost.vue -->
<template>
<article class="blog-post">
<header>
<h1>{{ post.title }}</h1>
<div class="meta">
<span>作者: {{ post.author }}</span>
<span>发布时间: {{ formatDate(post.publishedAt) }}</span>
</div>
</header>
<div class="content" v-html="post.content"></div>
</article>
</template>
<script>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useBlogStore } from '@/stores/blog'
export default {
name: 'BlogPost',
setup() {
const route = useRoute()
const router = useRouter()
const blogStore = useBlogStore()
const post = ref(null)
return {
post,
formatDate: (date) => new Date(date).toLocaleDateString()
}
},
// 组件内前置守卫
async beforeRouteEnter(to, from, next) {
try {
const blogStore = useBlogStore()
const post = await blogStore.fetchPost(to.params.id)
if (!post) {
next({ name: 'NotFound' })
return
}
// 检查文章是否已发布
if (!post.published && !blogStore.canViewDraft) {
next({ name: 'Forbidden' })
return
}
next(vm => {
vm.post = post
})
} catch (error) {
next({ name: 'Error' })
}
},
// 组件内更新守卫
async beforeRouteUpdate(to, from, next) {
if (to.params.id !== from.params.id) {
try {
const blogStore = useBlogStore()
this.post = await blogStore.fetchPost(to.params.id)
next()
} catch (error) {
next({ name: 'Error' })
}
} else {
next()
}
},
// 组件内离开守卫
beforeRouteLeave(to, from, next) {
if (this.hasUnsavedChanges) {
const answer = window.confirm('您有未保存的更改,确定要离开吗?')
if (answer) {
next()
} else {
next(false)
}
} else {
next()
}
}
}
</script>
4.5 路由懒加载与代码分割
4.5.1 基础懒加载
// src/router/lazy.js
// 基础懒加载
const Home = () => import('@/views/Home.vue')
const About = () => import('@/views/About.vue')
// 带有加载状态的懒加载
const BlogPost = () => import(
/* webpackChunkName: "blog" */
'@/views/BlogPost.vue'
)
// 预加载(在空闲时间加载)
const UserProfile = () => import(
/* webpackChunkName: "user" */
/* webpackPreload: true */
'@/views/UserProfile.vue'
)
// 条件懒加载
const AdminDashboard = () => {
if (process.env.NODE_ENV === 'development') {
return import('@/views/admin/Dashboard.vue')
} else {
return import(
/* webpackChunkName: "admin" */
'@/views/admin/Dashboard.vue'
)
}
}
4.5.2 智能代码分割
// src/router/chunks.js
// 按功能模块分割
const blogRoutes = [
{
path: '/blog',
component: () => import(
/* webpackChunkName: "blog" */
'@/views/Blog.vue'
)
},
{
path: '/blog/:id',
component: () => import(
/* webpackChunkName: "blog" */
'@/views/BlogPost.vue'
)
}
]
// 按用户角色分割
const adminRoutes = [
{
path: '/admin',
component: () => import(
/* webpackChunkName: "admin" */
'@/layouts/AdminLayout.vue'
),
children: [
{
path: 'dashboard',
component: () => import(
/* webpackChunkName: "admin" */
'@/views/admin/Dashboard.vue'
)
},
{
path: 'users',
component: () => import(
/* webpackChunkName: "admin-users" */
'@/views/admin/Users.vue'
)
}
]
}
]
// 按访问频率分割
const commonRoutes = [
{
path: '/',
component: () => import(
/* webpackChunkName: "common" */
'@/views/Home.vue'
)
},
{
path: '/about',
component: () => import(
/* webpackChunkName: "common" */
'@/views/About.vue'
)
}
]
const rareRoutes = [
{
path: '/privacy',
component: () => import(
/* webpackChunkName: "legal" */
'@/views/Privacy.vue'
)
},
{
path: '/terms',
component: () => import(
/* webpackChunkName: "legal" */
'@/views/Terms.vue'
)
}
]
4.5.3 加载状态处理
<!-- src/components/AsyncRouteWrapper.vue -->
<template>
<div class="async-route-wrapper">
<Suspense>
<template #default>
<router-view />
</template>
<template #fallback>
<div class="loading-container">
<div class="loading-spinner"></div>
<p>正在加载页面...</p>
</div>
</template>
</Suspense>
</div>
</template>
<script>
export default {
name: 'AsyncRouteWrapper'
}
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
4.6 路由过渡动画
4.6.1 基础过渡效果
<!-- src/App.vue -->
<template>
<div id="app">
<Header />
<router-view v-slot="{ Component, route }">
<transition
:name="getTransitionName(route)"
mode="out-in"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
>
<component :is="Component" :key="route.path" />
</transition>
</router-view>
<Footer />
</div>
</template>
<script>
import { useRoute } from 'vue-router'
export default {
name: 'App',
setup() {
const route = useRoute()
const getTransitionName = (route) => {
// 根据路由元信息决定过渡效果
if (route.meta.transition) {
return route.meta.transition
}
// 根据路由深度决定过渡方向
const depth = route.path.split('/').length
return depth > 2 ? 'slide-left' : 'fade'
}
const onBeforeEnter = (el) => {
console.log('Before enter transition')
}
const onEnter = (el, done) => {
console.log('Enter transition')
done()
}
const onLeave = (el, done) => {
console.log('Leave transition')
done()
}
return {
getTransitionName,
onBeforeEnter,
onEnter,
onLeave
}
}
}
</script>
<style>
/* 淡入淡出效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 滑动效果 */
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
}
.slide-left-leave-to {
transform: translateX(-100%);
}
/* 缩放效果 */
.scale-enter-active,
.scale-leave-active {
transition: transform 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.8);
}
</style>
4.6.2 高级过渡效果
// src/utils/transitions.js
export const transitionMixin = {
data() {
return {
transitionName: 'fade'
}
},
watch: {
'$route'(to, from) {
const toDepth = to.path.split('/').length
const fromDepth = from.path.split('/').length
if (toDepth < fromDepth) {
this.transitionName = 'slide-right'
} else if (toDepth > fromDepth) {
this.transitionName = 'slide-left'
} else {
this.transitionName = 'fade'
}
}
}
}
// 页面级过渡效果
export const pageTransitions = {
// 首页特殊效果
home: {
enterClass: 'animate__animated animate__fadeInUp',
leaveClass: 'animate__animated animate__fadeOutDown'
},
// 管理页面滑动效果
admin: {
enterClass: 'slide-in-right',
leaveClass: 'slide-out-left'
},
// 模态框效果
modal: {
enterClass: 'zoom-in',
leaveClass: 'zoom-out'
}
}
4.7 路由缓存策略
4.7.1 组件缓存
<!-- src/components/CachedRouterView.vue -->
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedComponents" :max="maxCacheSize">
<component :is="Component" :key="getCacheKey(route)" />
</keep-alive>
</router-view>
</template>
<script>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
export default {
name: 'CachedRouterView',
setup() {
const route = useRoute()
const maxCacheSize = 10
// 需要缓存的组件列表
const cachedComponents = ref([
'BlogList',
'UserProfile',
'ProductList'
])
// 生成缓存键
const getCacheKey = (route) => {
if (route.meta.cacheKey) {
return typeof route.meta.cacheKey === 'function'
? route.meta.cacheKey(route)
: route.meta.cacheKey
}
return route.fullPath
}
return {
cachedComponents,
maxCacheSize,
getCacheKey
}
}
}
</script>
4.7.2 智能缓存管理
// src/utils/routeCache.js
class RouteCache {
constructor(maxSize = 20) {
this.cache = new Map()
this.maxSize = maxSize
this.accessOrder = []
}
get(key) {
if (this.cache.has(key)) {
// 更新访问顺序
this.updateAccessOrder(key)
return this.cache.get(key)
}
return null
}
set(key, value) {
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
// 删除最少使用的缓存
const lru = this.accessOrder.shift()
this.cache.delete(lru)
}
this.cache.set(key, value)
this.updateAccessOrder(key)
}
updateAccessOrder(key) {
const index = this.accessOrder.indexOf(key)
if (index > -1) {
this.accessOrder.splice(index, 1)
}
this.accessOrder.push(key)
}
clear() {
this.cache.clear()
this.accessOrder = []
}
delete(key) {
this.cache.delete(key)
const index = this.accessOrder.indexOf(key)
if (index > -1) {
this.accessOrder.splice(index, 1)
}
}
}
export const routeCache = new RouteCache()
// 缓存中间件
export function setupRouteCache(router) {
router.beforeEach((to, from, next) => {
// 检查是否有缓存的数据
const cacheKey = to.fullPath
const cachedData = routeCache.get(cacheKey)
if (cachedData && to.meta.useCache) {
to.meta.cachedData = cachedData
}
next()
})
router.afterEach((to) => {
// 缓存页面数据
if (to.meta.shouldCache) {
const cacheKey = to.fullPath
const dataToCache = {
timestamp: Date.now(),
data: to.meta.pageData
}
routeCache.set(cacheKey, dataToCache)
}
})
}
4.8 本章小结
4.8.1 核心要点
- 路由配置:服务端使用内存历史,客户端使用Web历史
- 嵌套路由:合理设计布局组件和子路由结构
- 动态路由:支持参数验证和查询参数处理
- 导航守卫:实现权限控制和数据预取
- 懒加载:按需加载组件,优化性能
- 过渡动画:提升用户体验
- 缓存策略:智能缓存组件和数据
4.8.2 最佳实践
- 合理使用路由元信息管理页面属性
- 实现完善的权限控制系统
- 优化代码分割策略
- 设计流畅的页面过渡效果
- 建立智能的缓存机制
4.8.3 下章预告
下一章我们将学习状态管理与数据流: - Vuex/Pinia在SSR中的应用 - 服务端状态同步 - 数据预取策略 - 状态持久化 - 实时数据更新
练习作业:
- 创建一个包含嵌套路由的管理后台
- 实现动态路由参数验证
- 配置路由懒加载和代码分割
- 添加页面过渡动画效果
- 实现智能的路由缓存策略
下一章: 状态管理与数据流