Skip to content
概述
单文件组件(.vue)在浏览器中无法直接运行,需要通过编译器将模板、脚本和样式转换成标准的 JavaScript 与 CSS。Vite 抹掉了传统打包工具的启动开销——开发服务器基于浏览器原生 ES 模块,按需编译,构建阶段则使用 Rollup,这与 Vue 3 的编译模型天然契合。
将 Vue Router 用于页面导航、Pinia 管理跨组件状态,就能得到一个可直接用于实际项目的 Vue 3 工程骨架。本篇从项目创建开始,逐步把这三部分串接起来,最终实现一个带登录态校验和主题切换的多页面应用,同时涉及路由懒加载和代码拆分的思路。
使用 Vite 搭建 Vue 3 工程
官方脚手架已经将 Router 和 Pinia 集成到了创建流程里。初始化命令勾选对应模块后,就不需要再手动安装及配置基础结构。
bash
npm create vue@latest交互选项中选择 Vue Router 和 Pinia,其余按需勾选(TypeScript、JSX 等)。完成后进入项目目录安装依赖:
bash
cd <project-name>
npm install生成的项目目录大致如下:
src/
main.ts
App.vue
router/
index.ts
stores/
counter.ts
views/
HomeView.vue
AboutView.vue
components/
...
vite.config.tsvite.config.ts 默认内容很简洁:
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()]
})@vitejs/plugin-vue 在开发环境中的工作方式是:当浏览器请求一个 .vue 文件时,该插件将其编译为一个 JavaScript 模块返回。这个模块中已经将 <template> 编译为 render 函数、将 <style> 处理为注入样式的代码,因此浏览器只需一次请求即可得到完整的组件逻辑。在构建阶段,该插件作为 Rollup 插件完成同样的编译,并将产物合并到最终的 chunk 中。
单文件组件的工程化意义
在工程化之前,可能会用字符串模板或 <script type="text/x-template"> 定义组件,样式是全局 CSS,脚本放在 JS 文件里。维护需要来回跳转不同类型的文件,且样式没有隔离,容易相互污染。
单文件组件的三个块 <template>、<script>、<style> 各自有编译器处理,它们之间通过 Vue 的内部编译器建立关联。默认情况下,<style scoped> 会在编译阶段为每个选择器添加 [data-v-xxxxxx] 属性选择器,同时模板根元素也会携带该属性,从而实现样式隔离。这种隔离在组件级别生效,大幅降低大型项目中样式冲突的概率,但代价是父组件无法直接控制子组件内部元素的样式,除非使用 :deep() 穿透。
<script setup> 是 Vue 3.2 加入的编译期语法糖。它允许直接在顶层编写组合式 API 代码,编译器会在编译时将这些声明转换为 setup() 函数的返回内容,省去了手动 return。从工程角度看,<script setup> 带来的好处并不是少写几行代码,而是让 TypeScript 的类型推断链路更加直接——因为 import 的变量、定义的响应式数据都直接在模板作用域中可用,编辑器能够准确推断出模板中表达式的类型。
Vue Router 路由配置
路由映射与页面导航
脚手架生成的路由文件 src/router/index.ts 已包含基础配置:
ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
}
]
})
export default router路由的 component 选项可以是直接 import 的组件,也可以是一个返回 Promise 的函数——后者就是懒加载的入口,Vite 会据此将组件拆成独立的 chunk。
在 App.vue 里使用 <router-view> 和 <router-link>:
vue
<template>
<nav>
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
</nav>
<router-view />
</template>每次路由匹配发生变化时,<router-view> 内部渲染的组件就会切换。这个切换是组件粒度的销毁与重建,关联的响应式效果、生命周期钩子都会正常触发。
动态路由、嵌套路由与路由守卫
动态路由允许路径包含占位段,例如 /users/:id。在组件内通过 useRoute() 获取参数:
vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const userId = route.params.id
</script>
<template>
</template>嵌套路由适合页面局部内容需要根据路由变化而切换的场景。父路由组件内再放一个 <router-view>,子路由配置写在 children 数组中:
ts
{
path: '/user/:id',
component: UserLayout,
children: [
{ path: '', component: UserProfile },
{ path: 'posts', component: UserPosts }
]
}导航守卫可以拦截路由跳转。最常见的 beforeEach 用来做权限校验:
ts
import { useAuthStore } from '@/stores/auth'
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
next('/login')
} else {
next()
}
})这里 useAuthStore() 的调用时机需要注意:Pinia 实例必须在 app.use(pinia) 之后才能正常使用。因为路由守卫在 router.beforeEach 注册时并不会执行,真正触发回调是在用户导航时,此时 pinia 已经完成安装,直接调用没有问题。但如果在模块顶层调用 useAuthStore() 就可能报错,因为模块导入阶段 pinia 还没有初始化。
Pinia 状态管理
选项式与组合式 store
Pinia 允许用两种方式定义 store。选项式写法接近 Vuex:
ts
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as string | null,
token: null as string | null
}),
getters: {
isLoggedIn: (state) => !!state.token
},
actions: {
login(username: string, password: string) {
// 模拟登录请求
this.token = 'fake-token'
this.user = username
},
logout() {
this.token = null
this.user = null
}
}
})组合式写法与 setup() 中的代码风格一致,利用 ref、computed 等组合式 API 构建状态:
ts
export const useThemeStore = defineStore('theme', () => {
const theme = ref<'light' | 'dark'>('light')
const isDark = computed(() => theme.value === 'dark')
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
return { theme, isDark, toggleTheme }
})两种写法在响应性和模块间的互相引用上没有区别。组合式写法对于原本就惯用 setup() 的开发者而言,只是把组件内的逻辑提取到了 store 文件中。此外,组合式 store 更容易将部分逻辑拆成可复用的 composable,再在多个 store 之间复用。
在组件中读写状态
在组件中读取 store 的行为与使用普通的响应式变量一致——store 实例本身是响应式的:
vue
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
const auth = useAuthStore()
const theme = useThemeStore()
</script>
<template>
<div v-if="auth.isLoggedIn">
欢迎,{{ auth.user }}
<button @click="auth.logout()">退出</button>
</div>
<div>
当前主题:{{ theme.theme }}
<button @click="theme.toggleTheme()">切换主题</button>
</div>
</template>模板里直接写 auth.isLoggedIn 或调用 auth.logout() 是可行的,因为 Pinia 的 store 是 reactive 包裹过的对象,.value 的解包在模板中自动进行。当 token 被置为 null 时,isLoggedIn 这个 getter 会随之变化,路由守卫也能立即感知到。
状态共享实战:用户认证与主题切换
上面的两个 store 已经可以覆盖用户认证和主题切换的典型需求。
在导航守卫中已演示了 auth store 的使用。登录页面只需调用 action 然后跳转:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const username = ref('')
const password = ref('')
const router = useRouter()
const auth = useAuthStore()
async function login() {
auth.login(username.value, password.value)
await router.push('/')
}
</script>主题切换的影响面更广——通常会在 App.vue 里根据 theme 值设置 document.documentElement 的 class 或属性:
vue
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
import { watchEffect } from 'vue'
const theme = useThemeStore()
watchEffect(() => {
document.documentElement.setAttribute('data-theme', theme.theme)
})
</script>这里使用 watchEffect 可以保证主题改变时立即同步到 DOM,并且任何组件调用 toggleTheme() 后,所有读取 theme.theme 的地方都会刷新,包括这个 watchEffect。
完整功能演练:登录态与路由守卫联动
把上面各部分串起来,看一个端到端的流程。
- 用户访问
/about,路由守卫检查auth.isLoggedIn为false,并且to.meta.requiresAuth为true(需要在路由中设置meta),调用next('/login'),页面重定向到登录页。 - 登录页填写用户名密码,点击登录调用
auth.login(),设置 token 和 user 信息。 router.push('/')触发导航,守卫再次检查,此时isLoggedIn为 true,next()通过,首页渲染。- 在任意页面点击“退出”,调用
auth.logout()清除状态。路由守卫不会再主动拦截后续导航,但可以手动用router.push('/login')触发跳转。
为了触发需要认证的路由,定义路由时添加 meta:
ts
{
path: '/profile',
name: 'profile',
component: () => import('../views/ProfileView.vue'),
meta: { requiresAuth: true }
}守卫逻辑通过 to.meta.requiresAuth 来判定。需要注意:meta 并不继承父路由的配置,使用嵌套路由时子路由需要单独声明。也可以在守卫中自行实现向上遍历父路由的 meta 合并。
路由懒加载与代码分割
前面的路由示例中,AboutView 和 ProfileView 都使用了动态 import()。Vite 在这种写法下会将组件编译为独立的异步 chunk,构建产物里会出现形如 AboutView-[hash].js 的文件。
懒加载对首屏加载时间有直接影响——首页需要的 JavaScript 体积不再包含未访问的页面代码。但 chunk 过多也会让请求数变多,HTTP/2 多路复用能够承受的数量级通常足以应对几十个页面。如果业务页面非常细碎,可以考虑通过 manualChunks 配置进行分组,这属于构建策略层面,不是路由本身的问题。
路由懒加载的一个微小副作用是:组件加载过程中 <router-view> 区域是空的(或者显示前一个组件直到新组件解析完成)。如果异步 chunk 加载延迟明显,可以在路由配置中指定 loadingComponent 或使用导航的 Suspense 方案。不过对于本地构建产物,网络延迟通常很低,这个空白间隔可以忽略。
注意点
- Pinia 实例与 router 的加载顺序:
main.ts中常见的写法是app.use(pinia).use(router).mount('#app'),这能保证路由守卫运行时 pinia 已经就绪。如果在 router 文件顶层尝试创建 store 实例并保存引用,会因为 pinia 尚未安装而失败。守卫内部按需调用useXxxStore()则没有这个问题。 - 动态路由参数的监听:
useRoute()返回的对象是响应式的,但直接解构const { id } = route.params会失去响应性。如果需要单独监听参数变化,用watch(() => route.params.id, callback)或toRefs()。 <router-link>的to属性:当使用对象形式:to="{ name: 'user', params: { id: 1 } }"时,必须提供所有必要的 params 和 query,否则会抛出警告。命名路由比路径字符串更少出错,重构路径时也更安全。- SFC
<style scoped>与子组件根元素:scoped 样式会给子组件的根元素打上同样的属性选择器,也就是说父组件的 scoped 样式可以影响子组件根节点的样式,但无法深入子组件内部。如果确实需要控制,使用:deep(.child-class),编译后属性选择器会加在.child-class之前而不是后面。 - 刷新后状态丢失:Pinia 的 store 数据存放在内存中,页面刷新后全部清空。用户再次访问时会被路由守卫拦截到登录页。如果需要保持登录态,需要借助 cookie 或 token 持久化,并在应用启动时同步到 store(比如通过接口校验 token 有效性),这一步超出了本篇范围。基本思路是在
App.vue的 onMounted 中调用一个initAuthaction,从 localStorage 恢复 token,或者发请求校验。
