Vue 3 是一个用于构建用户界面的渐进式 JavaScript 框架。本文以 Composition API、
<script setup>、TypeScript 和 Vite 为主线,从基础语法逐步讲到路由、Pinia、接口封装、工程规范、自动化测试、性能优化和生产部署。本文不锁定 Vue 3 的具体小版本,示例面向当前 Vue 3 官方推荐工具链。
1. Vue 3 是什么
1.1 Vue 的定位
Vue 是一个渐进式前端框架。
所谓"渐进式",是指可以根据项目规模逐步引入 Vue:
- 在传统 HTML 页面中通过 CDN 增强局部交互。
- 使用单文件组件开发中小型单页应用。
- 配合 Vue Router、Pinia 构建大型后台管理系统。
- 配合 Nuxt 等上层框架实现 SSR、SSG 和全栈应用。
Vue 的核心工作可以概括为:
text
数据发生变化
↓
Vue 响应式系统检测变化
↓
重新计算受影响的组件
↓
更新虚拟 DOM
↓
将最小变化同步到真实 DOM
1.2 Vue 3 核心生态
一个常见的 Vue 3 工程通常包含以下技术:
| 技术 | 作用 |
|---|---|
| Vue 3 | 组件系统、响应式系统和页面渲染 |
| Vite | 开发服务器和生产构建工具 |
| TypeScript | 静态类型检查 |
| Vue Router | 前端路由管理 |
| Pinia | 全局状态管理 |
| Vitest | 单元测试和组件测试 |
| Vue Test Utils | Vue 官方底层组件测试工具 |
| Playwright | 端到端测试 |
| ESLint | 代码质量检查 |
| Prettier | 代码格式化 |
1.3 Options API 与 Composition API
Vue 3 同时支持 Options API 和 Composition API。
Options API 按配置类型组织代码:
vue
<script>
export default {
data() {
return {
count: 0
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
}
}
</script>
Composition API 按业务逻辑组织代码:
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
</script>
两种 API 都可以正常使用。对于新建的中大型 TypeScript 项目,通常优先选择 Composition API 与 <script setup>,因为它们更适合逻辑复用、类型推导和模块拆分。
<script setup> 是 Vue 官方推荐的单文件组件 Composition API 写法,能够减少样板代码,并提供良好的类型推导能力。Vue 官方 <script setup> 文档
2. 创建第一个 Vue 3 项目
2.1 环境准备
开始前需要安装:
- 当前仍受维护的 Node.js 版本。
- npm、pnpm、Yarn 或 Bun 中的一种包管理器。
- Visual Studio Code、WebStorm 或其他编辑器。
- Vue - Official 编辑器扩展。
检查本地环境:
bash
node -v
npm -v
2.2 使用 create-vue 创建项目
Vue 官方为新项目提供了 create-vue 脚手架,它会创建一个基于 Vite 的 Vue 3 项目。Vue 官方快速开始
执行:
bash
npm create vue@latest
创建过程中会看到类似选项:
text
Project name: vue3-project
Add TypeScript? Yes
Add JSX Support? No
Add Vue Router? Yes
Add Pinia? Yes
Add Vitest? Yes
Add an End-to-End Testing Solution? Playwright
Add ESLint? Yes
Add Prettier? Yes
对于准备长期维护的业务项目,推荐选择:
| 选项 | 推荐 | 说明 |
|---|---|---|
| TypeScript | Yes | 提高类型安全和可维护性 |
| JSX | 按需 | 普通业务项目通常不需要 |
| Vue Router | Yes | 多页面视图的 SPA 基本都会使用 |
| Pinia | Yes | 存放跨组件共享状态 |
| Vitest | Yes | 单元测试 |
| E2E | 按需 | 关键业务推荐使用 Playwright |
| ESLint | Yes | 发现潜在代码问题 |
| Prettier | Yes | 统一代码格式 |
进入项目并启动:
bash
cd vue3-project
npm install
npm run dev
执行生产构建:
bash
npm run build
构建产物默认位于:
text
dist/
2.3 为什么新项目不再优先使用 Vue CLI
Vue CLI 已处于维护模式。Vue 官方建议新项目使用 create-vue 创建基于 Vite 的工程。
Vite 在开发阶段按需处理模块,通常能提供更快的启动速度和热更新体验。
3. 认识 Vue 项目结构
一个典型项目结构如下:
text
vue3-project/
├─ public/ # 不经过构建转换的静态文件
├─ src/
│ ├─ api/ # 后端接口
│ ├─ assets/ # 参与构建的图片、字体和样式
│ ├─ components/ # 通用组件
│ ├─ composables/ # 可复用组合式逻辑
│ ├─ router/ # 路由配置
│ ├─ stores/ # Pinia Store
│ ├─ types/ # TypeScript 类型
│ ├─ utils/ # 无状态工具函数
│ ├─ views/ # 路由页面
│ ├─ App.vue # 根组件
│ └─ main.ts # 应用入口
├─ tests/ # 测试代码
├─ .env # 通用环境变量
├─ .env.development # 开发环境变量
├─ .env.production # 生产环境变量
├─ index.html # HTML 入口
├─ package.json # 项目依赖和命令
├─ tsconfig.json # TypeScript 配置
└─ vite.config.ts # Vite 配置
3.1 main.ts 的作用
main.ts 是应用入口:
ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
执行流程为:
text
加载 main.ts
↓
创建 Vue 应用
↓
注册 Pinia、Router 等插件
↓
加载根组件 App.vue
↓
挂载到 index.html 的 #app 元素
3.2 App.vue 的作用
App.vue 是整个组件树的根组件:
vue
<script setup lang="ts">
import AppHeader from '@/components/AppHeader.vue'
</script>
<template>
<div class="app">
<AppHeader />
<main>
<RouterView />
</main>
</div>
</template>
RouterView 表示当前路由对应页面的渲染位置。
4. 单文件组件与模板语法
4.1 单文件组件
Vue 单文件组件使用 .vue 扩展名,通常由三部分组成:
vue
<script setup lang="ts">
// 组件逻辑
</script>
<template>
<!-- 组件结构 -->
</template>
<style scoped>
/* 组件样式 */
</style>
完整示例:
vue
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<section class="counter">
<h2>当前计数:{{ count }}</h2>
<button type="button" @click="increment">增加</button>
</section>
</template>
<style scoped>
.counter {
padding: 16px;
}
button {
cursor: pointer;
}
</style>
4.2 文本插值
使用双花括号渲染数据:
vue
<script setup lang="ts">
const username = '小明'
</script>
<template>
<p>欢迎你,{{ username }}</p>
</template>
模板表达式可以执行简单计算:
vue
<template>
<p>{{ username.toUpperCase() }}</p>
<p>{{ 1 + 2 }}</p>
</template>
不要在模板中放置复杂业务逻辑。复杂计算应放在计算属性或普通函数中。
4.3 属性绑定
使用 v-bind 绑定 HTML 属性:
vue
<template>
<img v-bind:src="avatarUrl" v-bind:alt="username" />
</template>
缩写形式:
vue
<template>
<img :src="avatarUrl" :alt="username" />
</template>
动态绑定类名:
vue
<script setup lang="ts">
const active = true
const hasError = false
</script>
<template>
<div :class="{ active, error: hasError }">
状态区域
</div>
</template>
也可以绑定数组:
vue
<template>
<div :class="['card', active ? 'card--active' : '']">
卡片内容
</div>
</template>
绑定样式:
vue
<template>
<div :style="{ color: active ? 'green' : 'gray', fontSize: '16px' }">
动态样式
</div>
</template>
4.4 事件绑定
完整写法:
vue
<button v-on:click="increment">增加</button>
缩写:
vue
<button @click="increment">增加</button>
接收事件对象:
vue
<script setup lang="ts">
function handleInput(event: Event) {
const input = event.target as HTMLInputElement
console.log(input.value)
}
</script>
<template>
<input @input="handleInput" />
</template>
常见事件修饰符:
vue
<template>
<form @submit.prevent="handleSubmit">
<button type="submit">提交</button>
</form>
<button @click.stop="handleClick">阻止冒泡</button>
<a href="/logout" @click.prevent="logout">退出</a>
<input @keyup.enter="search" />
</template>
4.5 条件渲染
使用 v-if:
vue
<template>
<p v-if="loading">加载中......</p>
<p v-else-if="error">加载失败:{{ error }}</p>
<p v-else>加载完成</p>
</template>
使用 v-show:
vue
<template>
<p v-show="visible">可以显示或隐藏的内容</p>
</template>
二者的区别:
| 指令 | 工作方式 | 适用场景 |
|---|---|---|
v-if |
条件不满足时不创建 DOM | 条件很少变化 |
v-show |
DOM 始终存在,通过 CSS 隐藏 | 高频显示和隐藏 |
4.6 列表渲染
vue
<script setup lang="ts">
interface User {
id: string
name: string
}
const users: User[] = [
{ id: 'u1', name: '张三' },
{ id: 'u2', name: '李四' }
]
</script>
<template>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>
key 应满足:
- 在当前列表中唯一。
- 在对象生命周期内保持稳定。
- 优先使用业务主键。
- 不要在可增删、可排序列表中直接使用数组下标。
不要这样写:
vue
<li v-for="(user, index) in users" :key="index">
{{ user.name }}
</li>
当列表排序或插入元素后,索引可能对应另一个对象,导致组件状态被错误复用。
如果列表需要过滤,应先使用计算属性:
vue
<script setup lang="ts">
import { computed } from 'vue'
const visibleUsers = computed(() => {
return users.filter((user) => user.name.includes(keyword.value))
})
</script>
<template>
<li v-for="user in visibleUsers" :key="user.id">
{{ user.name }}
</li>
</template>
5. Vue 3 响应式系统
5.1 ref
ref 可以包装基本类型或对象:
ts
import { ref } from 'vue'
const count = ref(0)
const username = ref('张三')
const loading = ref(false)
在 JavaScript 或 TypeScript 中访问和修改时需要使用 .value:
ts
console.log(count.value)
count.value++
在模板中会自动解包:
vue
<template>
<p>{{ count }}</p>
</template>
5.2 reactive
reactive 用于创建响应式对象:
ts
import { reactive } from 'vue'
const form = reactive({
username: '',
password: '',
rememberMe: false
})
form.username = 'admin'
reactive 更适合结构相对稳定的对象,不适合直接包装字符串、数字等基本类型。
5.3 ref 与 reactive 如何选择
经验上可以遵循以下规则:
- 独立的基本类型优先使用
ref。 - 需要整体替换的数据优先使用
ref。 - 表单对象等字段集合可以使用
reactive。 - 团队也可以统一以
ref为主,减少选择成本。
例如,接口返回的数据经常需要整体替换:
ts
interface User {
id: string
name: string
}
const user = ref<User | null>(null)
user.value = {
id: 'u1',
name: '张三'
}
5.4 reactive 解构丢失响应性
以下写法存在问题:
ts
const state = reactive({
count: 0,
name: 'Vue'
})
const { count, name } = state
这里得到的 count 和 name 是普通值,不会继续跟随原对象变化。
可以使用 toRefs:
ts
import { reactive, toRefs } from 'vue'
const state = reactive({
count: 0,
name: 'Vue'
})
const { count, name } = toRefs(state)
count.value++
5.5 readonly
如果希望使用方只能读取状态:
ts
import { reactive, readonly } from 'vue'
const state = reactive({
count: 0
})
const publicState = readonly(state)
常见于 Composable 或依赖注入:对外暴露只读状态,同时通过明确的方法修改数据。
5.6 shallowRef
当对象很大,而且不需要追踪对象内部每个字段时,可以使用 shallowRef:
ts
import { shallowRef } from 'vue'
const chartInstance = shallowRef<Chart | null>(null)
它适合保存:
- 第三方组件实例。
- 地图实例。
- 图表实例。
- 很大的不可变对象。
- 不希望被深度代理的外部对象。
6. 计算属性、侦听器与生命周期
6.1 computed 计算属性
计算属性用于从现有状态派生新状态:
ts
import { computed, ref } from 'vue'
const price = ref(100)
const quantity = ref(2)
const totalPrice = computed(() => price.value * quantity.value)
模板中直接使用:
vue
<template>
<p>总价:{{ totalPrice }}</p>
</template>
计算属性会根据依赖进行缓存。依赖没有变化时,多次访问通常不会重复计算。
计算属性应尽量保持纯净,不要在内部:
- 修改其他状态。
- 发起接口请求。
- 操作 DOM。
- 写入本地存储。
错误示例:
ts
const total = computed(() => {
loading.value = true
return price.value * quantity.value
})
6.2 watch
watch 用于明确侦听一个或多个数据源:
ts
import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newValue, oldValue) => {
console.log('关键词变化:', oldValue, '->', newValue)
})
侦听响应式对象的某个字段:
ts
watch(
() => form.username,
(username) => {
console.log('用户名:', username)
}
)
侦听多个数据源:
ts
watch(
[page, pageSize],
([newPage, newPageSize]) => {
loadList(newPage, newPageSize)
}
)
立即执行:
ts
watch(
() => props.userId,
(userId) => {
loadUser(userId)
},
{
immediate: true
}
)
6.3 清理过期请求
快速切换查询条件时,旧请求可能比新请求更晚返回,从而覆盖新数据。
可以在侦听器中取消旧请求:
ts
watch(
() => props.userId,
async (userId, _oldUserId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
try {
user.value = await fetchUser(userId, controller.signal)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
throw error
}
},
{
immediate: true
}
)
6.4 watchEffect
watchEffect 会自动收集同步执行期间访问的响应式依赖:
ts
import { ref, watchEffect } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
watchEffect(() => {
console.log(`${firstName.value}${lastName.value}`)
})
选择建议:
- 明确知道依赖项时,优先使用
watch。 - 副作用依赖较多、适合自动收集时使用
watchEffect。 - 纯粹派生数据使用
computed,不要使用watch手动同步。
6.5 生命周期
常用 Composition API 生命周期:
| 生命周期 | 触发时机 |
|---|---|
onBeforeMount |
组件挂载前 |
onMounted |
组件挂载完成 |
onBeforeUpdate |
DOM 更新前 |
onUpdated |
DOM 更新完成 |
onBeforeUnmount |
组件卸载前 |
onUnmounted |
组件卸载完成 |
onErrorCaptured |
捕获后代组件错误 |
示例:
vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
function handleResize() {
console.log(window.innerWidth)
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
组件创建的定时器、事件监听、订阅和第三方实例,应在卸载时清理。
7. Vue 组件化开发
7.1 为什么需要组件
组件可以把复杂页面拆分成独立模块:
text
TaskListView
├─ SearchForm
├─ TaskFilter
├─ TaskList
│ └─ TaskItem
└─ Pagination
一个合理的组件通常具备:
- 单一且清晰的职责。
- 明确的输入和输出。
- 尽量少的外部依赖。
- 可独立测试。
- 可读的组件名称。
7.2 Props:父组件向子组件传值
子组件:
vue
<script setup lang="ts">
interface Props {
title: string
count?: number
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
</script>
<template>
<section>
<h2>{{ props.title }}</h2>
<p>数量:{{ props.count }}</p>
</section>
</template>
父组件:
vue
<template>
<SummaryCard title="待处理任务" :count="10" />
</template>
Props 遵循单向数据流。子组件不应直接修改父组件传入的数据。
错误写法:
ts
props.count++
正确做法是向父组件发出事件。
7.3 Emits:子组件通知父组件
子组件:
vue
<script setup lang="ts">
const emit = defineEmits<{
confirm: [id: string]
cancel: []
}>()
function handleConfirm() {
emit('confirm', 'task-001')
}
</script>
<template>
<button type="button" @click="handleConfirm">确认</button>
<button type="button" @click="emit('cancel')">取消</button>
</template>
父组件:
vue
<template>
<ConfirmDialog
@confirm="handleConfirm"
@cancel="dialogVisible = false"
/>
</template>
7.4 组件 v-model
传统写法是接收 modelValue 并触发 update:modelValue:
vue
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function handleInput(event: Event) {
const input = event.target as HTMLInputElement
emit('update:modelValue', input.value)
}
</script>
<template>
<input :value="props.modelValue" @input="handleInput" />
</template>
父组件:
vue
<CustomInput v-model="username" />
在支持 defineModel 的 Vue 3 版本中,可以简化为:
vue
<script setup lang="ts">
const model = defineModel<string>({
required: true
})
</script>
<template>
<input v-model="model" />
</template>
7.5 Slots 插槽
插槽允许父组件把模板内容传给子组件。
子组件:
vue
<template>
<section class="card">
<header class="card__header">
<slot name="header" />
</header>
<div class="card__body">
<slot />
</div>
<footer class="card__footer">
<slot name="footer" />
</footer>
</section>
</template>
父组件:
vue
<template>
<BaseCard>
<template #header>
<h2>用户信息</h2>
</template>
<p>姓名:张三</p>
<template #footer>
<button type="button">保存</button>
</template>
</BaseCard>
</template>
作用域插槽可以把子组件数据传给父组件模板:
vue
<!-- DataList.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item" />
</li>
</ul>
</template>
vue
<DataList :items="users">
<template #item="{ item }">
{{ item.name }}
</template>
</DataList>
7.6 provide 与 inject
provide 和 inject 适合跨越多层组件传递依赖:
ts
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface CurrentUser {
id: string
name: string
}
export const currentUserKey: InjectionKey<Ref<CurrentUser | null>> =
Symbol('current-user')
祖先组件:
ts
import { provide, ref } from 'vue'
import { currentUserKey } from './keys'
const currentUser = ref(null)
provide(currentUserKey, currentUser)
后代组件:
ts
import { inject } from 'vue'
import { currentUserKey } from './keys'
const currentUser = inject(currentUserKey)
if (!currentUser) {
throw new Error('Current user provider is missing')
}
选择原则:
- 父子组件:Props 和 Emits。
- 模板结构扩展:Slots。
- 跨越多层的局部依赖:provide/inject。
- 跨页面共享的业务状态:Pinia。
8. 表单处理与双向绑定
8.1 基础 v-model
vue
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({
username: '',
age: 18,
role: '',
hobbies: [] as string[],
accepted: false
})
</script>
<template>
<form>
<label>
用户名
<input v-model.trim="form.username" />
</label>
<label>
年龄
<input v-model.number="form.age" type="number" />
</label>
<label>
角色
<select v-model="form.role">
<option value="">请选择</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
</label>
<fieldset>
<legend>爱好</legend>
<label>
<input v-model="form.hobbies" type="checkbox" value="reading" />
阅读
</label>
<label>
<input v-model="form.hobbies" type="checkbox" value="running" />
跑步
</label>
</fieldset>
<label>
<input v-model="form.accepted" type="checkbox" />
同意用户协议
</label>
</form>
</template>
常见修饰符:
| 修饰符 | 作用 |
|---|---|
.trim |
去掉首尾空格 |
.number |
尝试转换为数字 |
.lazy |
在 change 而不是 input 时同步 |
8.2 表单验证
不要把全部验证逻辑直接堆进模板:
ts
import { computed, reactive } from 'vue'
const form = reactive({
username: '',
password: ''
})
const errors = computed(() => {
const result: Record<string, string> = {}
if (!form.username.trim()) {
result.username = '请输入用户名'
}
if (form.password.length < 8) {
result.password = '密码至少需要 8 位'
}
return result
})
const canSubmit = computed(() => {
return Object.keys(errors.value).length === 0
})
提交时仍应再次验证:
ts
async function handleSubmit() {
if (!canSubmit.value) {
return
}
await submitForm({
username: form.username.trim(),
password: form.password
})
}
前端验证用于改善用户体验,不能代替后端验证。
9. Composable 逻辑复用
9.1 什么是 Composable
Composable 是使用 Composition API 封装可复用有状态逻辑的函数,命名通常以 use 开头。Vue 官方 Composable 文档
例如,多个组件都需要监听网络状态:
ts
// src/composables/useOnline.ts
import { onMounted, onUnmounted, ref } from 'vue'
export function useOnline() {
const online = ref(true)
function updateStatus() {
online.value = navigator.onLine
}
onMounted(() => {
updateStatus()
window.addEventListener('online', updateStatus)
window.addEventListener('offline', updateStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateStatus)
window.removeEventListener('offline', updateStatus)
})
return {
online
}
}
组件中使用:
vue
<script setup lang="ts">
import { useOnline } from '@/composables/useOnline'
const { online } = useOnline()
</script>
<template>
<p :class="{ offline: !online }">
{{ online ? '网络正常' : '网络已断开' }}
</p>
</template>
9.2 Composable 设计原则
一个工程化 Composable 应注意:
- 对外暴露尽量少的状态和方法。
- 修改状态的入口应清晰。
- 创建的副作用必须清理。
- 不要依赖隐式全局变量。
- 参数可以接受
Ref、普通值或 getter 时,应明确约定。 - 错误、加载状态和重试行为应可观测。
- 不要把所有业务都抽成 Composable。
9.3 通用异步状态
ts
// src/composables/useRequest.ts
import { ref } from 'vue'
export function useRequest<T>() {
const data = ref<T | null>(null)
const pending = ref(false)
const error = ref('')
let currentRequestId = 0
async function execute(request: () => Promise<T>) {
const requestId = ++currentRequestId
pending.value = true
error.value = ''
try {
const result = await request()
if (requestId === currentRequestId) {
data.value = result
}
return result
} catch (cause) {
if (requestId === currentRequestId) {
error.value =
cause instanceof Error ? cause.message : '请求失败'
}
throw cause
} finally {
if (requestId === currentRequestId) {
pending.value = false
}
}
}
return {
data,
pending,
error,
execute
}
}
这里使用请求编号,避免较早发起的请求覆盖较晚请求的状态。
10. Vue 3 与 TypeScript
Vue 本身使用 TypeScript 编写,官方包包含类型声明。create-vue 可以直接创建支持 TypeScript 的 Vite 项目。Vue 官方 TypeScript 指南
10.1 为 ref 声明类型
ts
import { ref } from 'vue'
interface User {
id: string
name: string
email: string
}
const user = ref<User | null>(null)
const users = ref<User[]>([])
10.2 Props 类型
vue
<script setup lang="ts">
interface User {
id: string
name: string
}
interface Props {
user: User
selected?: boolean
}
const props = withDefaults(defineProps<Props>(), {
selected: false
})
</script>
10.3 Emits 类型
ts
const emit = defineEmits<{
select: [userId: string]
remove: [userId: string, force: boolean]
}>()
10.4 DOM 元素引用
vue
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})
async function focusInput() {
await nextTick()
inputRef.value?.focus()
}
</script>
<template>
<input ref="inputRef" />
</template>
10.5 不要滥用 as
以下写法虽然能让编译通过,却可能隐藏错误:
ts
const user = response.data as User
更稳妥的做法是:
- 为接口响应定义类型。
- 对外部输入进行运行时校验。
- 使用类型守卫缩小
unknown。 - 不确定的数据先保持为
unknown。
ts
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) {
return false
}
const record = value as Record<string, unknown>
return (
typeof record.id === 'string' &&
typeof record.name === 'string' &&
typeof record.email === 'string'
)
}
10.6 Vite 不等于 TypeScript 类型检查
Vite 开发服务器主要负责快速转换和打包,不会自动完成整个项目的类型检查。
因此 CI 中应单独执行:
bash
npm run type-check
npm run build
可以在 package.json 中配置:
json
{
"scripts": {
"dev": "vite",
"type-check": "vue-tsc --build",
"build": "npm run type-check && vite build"
}
}
11. 使用 Vue Router 管理路由
11.1 路由配置
ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/tasks'
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: {
title: '登录'
}
},
{
path: '/tasks',
name: 'tasks',
component: () => import('@/views/TaskListView.vue'),
meta: {
title: '任务管理',
requiresAuth: true
}
},
{
path: '/tasks/:id',
name: 'task-detail',
component: () => import('@/views/TaskDetailView.vue'),
props: true,
meta: {
title: '任务详情',
requiresAuth: true
}
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
meta: {
title: '页面不存在'
}
}
]
})
export default router
路由组件使用动态导入后,会被拆分成独立构建块,在首次访问对应路由时加载。Vue Router 路由懒加载
11.2 声明 RouteMeta 类型
ts
// src/types/router.d.ts
import 'vue-router'
export {}
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
}
}
11.3 路由跳转
模板中:
vue
<template>
<RouterLink :to="{ name: 'task-detail', params: { id: task.id } }">
查看详情
</RouterLink>
</template>
代码中:
ts
import { useRouter } from 'vue-router'
const router = useRouter()
async function openTask(id: string) {
await router.push({
name: 'task-detail',
params: {
id
}
})
}
11.4 获取路由参数
ts
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const taskId = computed(() => String(route.params.id))
如果路由配置了 props: true,页面组件可以直接通过 Props 接收:
vue
<script setup lang="ts">
defineProps<{
id: string
}>()
</script>
这能减少页面组件对路由对象的直接依赖。
11.5 路由守卫
ts
import { useAuthStore } from '@/stores/auth'
router.beforeEach(async (to) => {
const authStore = useAuthStore()
if (!authStore.initialized) {
await authStore.restoreSession()
}
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return {
name: 'login',
query: {
redirect: to.fullPath
}
}
}
if (to.name === 'login' && authStore.isLoggedIn) {
return {
name: 'tasks'
}
}
return true
})
更新页面标题:
ts
router.afterEach((to) => {
document.title = to.meta.title
? `${to.meta.title} - Vue 任务管理`
: 'Vue 任务管理'
})
Vue Router 支持全局、路由级和组件级导航守卫,也支持异步守卫。Vue Router 导航守卫
需要注意:前端路由守卫只能改善用户体验,不能承担真正的权限控制。后端接口仍必须校验用户身份和权限。
12. 使用 Pinia 管理全局状态
12.1 什么时候使用 Pinia
适合放入 Pinia 的数据:
- 当前登录用户。
- 权限信息。
- 跨页面共享的购物车。
- 全局通知。
- 多个页面共享的业务缓存。
- 需要 DevTools 跟踪的业务状态。
不适合放入 Pinia 的数据:
- 只在一个组件内部使用的弹窗状态。
- 临时输入框内容。
- 能通过 Props 轻松传递的局部数据。
- 可以直接通过计算属性得到的数据副本。
12.2 创建 Store
ts
// src/stores/counter.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
function reset() {
count.value = 0
}
return {
count,
doubleCount,
increment,
reset
}
})
组件中使用:
vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
const { count, doubleCount } = storeToRefs(counterStore)
const { increment, reset } = counterStore
</script>
<template>
<p>计数:{{ count }}</p>
<p>双倍:{{ doubleCount }}</p>
<button type="button" @click="increment">增加</button>
<button type="button" @click="reset">重置</button>
</template>
直接解构 Store 中的响应式属性可能丢失响应性,因此状态和 getter 应使用 storeToRefs;Action 可以直接解构。
Pinia Store 的主要组成是 State、Getter 和 Action。Pinia 官方文档
12.3 登录状态 Store
ts
// src/stores/auth.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { request } from '@/api/http'
interface User {
id: string
name: string
}
interface LoginRequest {
username: string
password: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const initialized = ref(false)
const isLoggedIn = computed(() => user.value !== null)
async function login(command: LoginRequest) {
user.value = await request<User>('/session/login', {
method: 'POST',
json: command
})
}
async function restoreSession() {
if (initialized.value) {
return
}
try {
user.value = await request<User>('/session')
} catch {
user.value = null
} finally {
initialized.value = true
}
}
async function logout() {
try {
await request<void>('/session/logout', {
method: 'POST'
})
} finally {
user.value = null
}
}
return {
user,
initialized,
isLoggedIn,
login,
restoreSession,
logout
}
})
这个示例假设后端使用 Cookie 会话。具体认证方式应根据后端协议调整。
13. 封装 HTTP 请求与接口层
13.1 为什么不要在页面中直接堆请求
下面的写法在小 Demo 中可以使用,但不适合大型项目:
ts
const response = await fetch('/api/tasks')
const result = await response.json()
如果每个页面都这样写,会重复处理:
- 接口基础地址。
- 请求头。
- Cookie 或 Token。
- 响应协议。
- HTTP 错误。
- 业务错误码。
- 超时和请求取消。
- 日志与链路标识。
13.2 定义后端响应协议
假设后端统一返回:
json
{
"code": 0,
"message": "success",
"data": {}
}
定义类型:
ts
// src/types/api.ts
export interface ApiResponse<T> {
code: number
message: string
data: T
}
这只是示例项目的后端约定,不是 Vue 的框架标准。
13.3 封装 request
ts
// src/api/http.ts
import type { ApiResponse } from '@/types/api'
interface RequestOptions extends Omit<RequestInit, 'body'> {
json?: unknown
}
export class HttpError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code?: number
) {
super(message)
this.name = 'HttpError'
}
}
const API_BASE_URL = (
import.meta.env.VITE_API_BASE_URL || '/api'
).replace(/\/$/, '')
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const {
json,
headers: originalHeaders,
...requestInit
} = options
const headers = new Headers(originalHeaders)
if (json !== undefined) {
headers.set('Content-Type', 'application/json')
}
headers.set('Accept', 'application/json')
const response = await fetch(`${API_BASE_URL}${path}`, {
...requestInit,
headers,
body: json === undefined ? undefined : JSON.stringify(json),
credentials: 'include'
})
const payload = await response
.json()
.catch(() => null) as ApiResponse<T> | null
if (!response.ok) {
throw new HttpError(
payload?.message || `HTTP 请求失败:${response.status}`,
response.status,
payload?.code
)
}
if (!payload) {
throw new HttpError('接口未返回有效 JSON', response.status)
}
if (payload.code !== 0) {
throw new HttpError(
payload.message || '业务处理失败',
response.status,
payload.code
)
}
return payload.data
}
这个封装假设所有成功接口都返回统一 JSON。文件下载、流式响应、上传和 204 No Content 应使用单独的请求函数或扩展响应解析策略。
13.4 业务接口层
ts
// src/api/task.ts
import { request } from '@/api/http'
import type { Task } from '@/types/task'
export interface CreateTaskCommand {
title: string
}
export const taskApi = {
list(signal?: AbortSignal) {
return request<Task[]>('/tasks', {
signal
})
},
create(command: CreateTaskCommand) {
return request<Task>('/tasks', {
method: 'POST',
json: command
})
},
updateStatus(id: string, completed: boolean) {
return request<Task>(`/tasks/${encodeURIComponent(id)}/status`, {
method: 'PUT',
json: {
completed
}
})
},
remove(id: string) {
return request<void>(`/tasks/${encodeURIComponent(id)}`, {
method: 'DELETE'
})
}
}
组件只关心业务语义:
ts
const tasks = await taskApi.list()
而不关心:
text
URL 拼接、请求头、Cookie、错误码和 JSON 解析
13.5 开发代理
ts
// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
开发代理可以:
- 避免浏览器跨域限制。
- 让前端始终请求
/api。 - 减少开发环境与生产环境的调用差异。
14. Vue 工程目录与环境配置
14.1 分层目录
中小型项目可以按技术职责分层:
text
src/
├─ api/
├─ assets/
├─ components/
├─ composables/
├─ router/
├─ stores/
├─ types/
├─ utils/
└─ views/
大型项目可以进一步按业务域组织:
text
src/
├─ app/
│ ├─ router/
│ └─ providers/
├─ shared/
│ ├─ api/
│ ├─ components/
│ ├─ composables/
│ ├─ types/
│ └─ utils/
└─ features/
├─ auth/
│ ├─ api/
│ ├─ components/
│ ├─ stores/
│ ├─ types/
│ └─ views/
└─ task/
├─ api/
├─ components/
├─ stores/
├─ types/
└─ views/
选择标准不是"目录越多越专业",而是:
- 开发者能否快速找到代码。
- 模块边界是否清晰。
- 是否避免循环依赖。
- 删除一个业务功能时,影响范围是否可控。
14.2 环境变量
开发环境:
dotenv
# .env.development
VITE_API_BASE_URL=/api
VITE_APP_TITLE=Vue 任务管理开发环境
生产环境:
dotenv
# .env.production
VITE_API_BASE_URL=/api
VITE_APP_TITLE=Vue 任务管理
读取:
ts
const title = import.meta.env.VITE_APP_TITLE
Vite 只会把符合公开前缀规则的变量暴露给客户端,默认前缀是 VITE_。这些值会进入前端构建产物,因此绝不能存放数据库密码、私钥或真正的服务端密钥。Vite 环境变量文档
14.3 环境变量类型
ts
// src/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
14.4 代码命名建议
| 对象 | 示例 |
|---|---|
| Vue 组件 | TaskList.vue |
| 路由页面 | TaskListView.vue |
| Composable | useTaskQuery.ts |
| Pinia Store | useTaskStore |
| API 对象 | taskApi |
| 布尔值 | isLoading、hasPermission |
| 事件函数 | handleSubmit、handleRemove |
| 数据请求 | fetchTasks、loadUser |
| 类型 | Task、CreateTaskCommand |
14.5 组件边界
出现以下信号时,应考虑拆分组件:
- 单个组件承担多个独立业务职责。
- 文件已经很难快速读懂。
- 一段 UI 在多个地方重复。
- 某部分需要独立测试。
- 子区域拥有独立输入和输出。
- 页面逻辑、接口逻辑和展示逻辑高度耦合。
不要只根据文件行数机械拆分。一个 250 行但职责单一的组件,可能比五个互相传递大量状态的小组件更容易维护。
15. 实战:任务管理模块
下面用一个任务管理模块串联 TypeScript、API、Pinia 和组件。
15.1 定义 Task 类型
ts
// src/types/task.ts
export interface Task {
id: string
title: string
completed: boolean
createdAt: string
}
15.2 创建任务 Store
ts
// src/stores/task.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { taskApi } from '@/api/task'
import type { Task } from '@/types/task'
export const useTaskStore = defineStore('task', () => {
const tasks = ref<Task[]>([])
const pending = ref(false)
const error = ref('')
const unfinishedCount = computed(() => {
return tasks.value.filter((task) => !task.completed).length
})
async function fetchTasks() {
pending.value = true
error.value = ''
try {
tasks.value = await taskApi.list()
} catch (cause) {
error.value = getErrorMessage(cause)
throw cause
} finally {
pending.value = false
}
}
async function createTask(title: string) {
const normalizedTitle = title.trim()
if (!normalizedTitle) {
throw new Error('任务标题不能为空')
}
const createdTask = await taskApi.create({
title: normalizedTitle
})
tasks.value.unshift(createdTask)
}
async function toggleTask(id: string) {
const target = tasks.value.find((task) => task.id === id)
if (!target) {
return
}
const previousCompleted = target.completed
target.completed = !previousCompleted
try {
const updatedTask = await taskApi.updateStatus(
target.id,
target.completed
)
Object.assign(target, updatedTask)
} catch (cause) {
target.completed = previousCompleted
error.value = getErrorMessage(cause)
throw cause
}
}
async function removeTask(id: string) {
const index = tasks.value.findIndex((task) => task.id === id)
if (index < 0) {
return
}
const [removedTask] = tasks.value.splice(index, 1)
try {
await taskApi.remove(id)
} catch (cause) {
tasks.value.splice(index, 0, removedTask)
error.value = getErrorMessage(cause)
throw cause
}
}
function clearError() {
error.value = ''
}
return {
tasks,
pending,
error,
unfinishedCount,
fetchTasks,
createTask,
toggleTask,
removeTask,
clearError
}
})
function getErrorMessage(cause: unknown) {
return cause instanceof Error ? cause.message : '操作失败'
}
toggleTask 和 removeTask 使用了乐观更新:
text
先更新界面
↓
发送后端请求
↓
请求成功:保留结果
请求失败:恢复旧状态
乐观更新可以提高操作反馈速度,但必须实现失败回滚。对于余额、库存、支付等高风险业务,不应在缺少明确一致性设计的情况下直接使用。
15.3 任务列表页面
vue
<!-- src/views/TaskListView.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useTaskStore } from '@/stores/task'
const taskStore = useTaskStore()
const { tasks, pending, error, unfinishedCount } = storeToRefs(taskStore)
const title = ref('')
const submitting = ref(false)
onMounted(() => {
void taskStore.fetchTasks()
})
async function handleCreate() {
if (!title.value.trim() || submitting.value) {
return
}
submitting.value = true
try {
await taskStore.createTask(title.value)
title.value = ''
} finally {
submitting.value = false
}
}
async function handleToggle(id: string) {
try {
await taskStore.toggleTask(id)
} catch {
// Store 已记录可展示的错误信息
}
}
async function handleRemove(id: string) {
if (!window.confirm('确定删除这个任务吗?')) {
return
}
try {
await taskStore.removeTask(id)
} catch {
// Store 已记录可展示的错误信息
}
}
</script>
<template>
<main class="task-page">
<header class="task-page__header">
<div>
<h1>任务管理</h1>
<p>还有 {{ unfinishedCount }} 个任务未完成</p>
</div>
</header>
<form class="task-form" @submit.prevent="handleCreate">
<label for="task-title">任务标题</label>
<div class="task-form__controls">
<input
id="task-title"
v-model.trim="title"
autocomplete="off"
placeholder="请输入任务标题"
/>
<button
type="submit"
:disabled="!title || submitting"
>
{{ submitting ? '创建中......' : '创建任务' }}
</button>
</div>
</form>
<p
v-if="error"
class="error-message"
role="alert"
>
{{ error }}
<button type="button" @click="taskStore.clearError">
关闭
</button>
</p>
<p v-if="pending" aria-live="polite">
正在加载任务......
</p>
<p v-else-if="tasks.length === 0">
暂无任务,可以创建第一条任务。
</p>
<ul v-else class="task-list">
<li
v-for="task in tasks"
:key="task.id"
class="task-item"
>
<label>
<input
type="checkbox"
:checked="task.completed"
@change="handleToggle(task.id)"
/>
<span :class="{ completed: task.completed }">
{{ task.title }}
</span>
</label>
<button
type="button"
:aria-label="`删除任务:${task.title}`"
@click="handleRemove(task.id)"
>
删除
</button>
</li>
</ul>
</main>
</template>
<style scoped>
.task-page {
width: min(720px, calc(100% - 32px));
margin: 40px auto;
}
.task-form {
margin: 24px 0;
}
.task-form__controls {
display: flex;
gap: 12px;
margin-top: 8px;
}
.task-form__controls input {
flex: 1;
min-width: 0;
padding: 10px 12px;
}
.task-list {
display: grid;
gap: 12px;
padding: 0;
list-style: none;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
border: 1px solid #dcdfe6;
border-radius: 8px;
}
.completed {
color: #909399;
text-decoration: line-through;
}
.error-message {
padding: 12px;
color: #b42318;
background: #fef3f2;
border-radius: 8px;
}
</style>
15.4 根组件
vue
<!-- src/App.vue -->
<template>
<RouterView />
</template>
15.5 业务状态放在哪里
可以用以下判断流程:
text
状态只属于一个组件吗?
├─ 是:放在组件内部
└─ 否
↓
只在父子组件间共享吗?
├─ 是:Props / Emits
└─ 否
↓
只在一个局部组件树中共享吗?
├─ 是:provide / inject 或局部 Composable
└─ 否
↓
跨页面、跨模块共享吗?
└─ 是:考虑 Pinia
16. 异常处理与用户体验
16.1 不要静默吞掉错误
下面的代码会让问题难以排查:
ts
try {
await save()
} catch {
// 什么都不做
}
应该至少做到:
- 为用户展示可理解的信息。
- 为开发环境保留原始错误。
- 为生产环境接入错误监控。
- 避免展示后端堆栈和敏感信息。
- 明确用户是否可以重试。
16.2 区分错误类型
可以按以下维度处理:
| 错误 | 示例处理 |
|---|---|
| 表单错误 | 在字段附近提示 |
| 未登录 | 跳转登录页 |
| 无权限 | 显示无权限页面 |
| 资源不存在 | 显示 404 或空状态 |
| 请求超时 | 提示重试 |
| 网络断开 | 显示离线状态 |
| 服务端异常 | 显示统一错误并记录监控 |
| 业务冲突 | 展示后端提供的可理解原因 |
16.3 防止重复提交
ts
const submitting = ref(false)
async function submit() {
if (submitting.value) {
return
}
submitting.value = true
try {
await save()
} finally {
submitting.value = false
}
}
模板:
vue
<button :disabled="submitting">
{{ submitting ? '提交中......' : '提交' }}
</button>
前端按钮禁用只能降低重复提交概率。对于订单、支付等操作,后端还应实现幂等控制。
16.4 全局错误处理
ts
// main.ts
app.config.errorHandler = (error, instance, info) => {
console.error('Vue error:', error)
console.error('Component:', instance)
console.error('Info:', info)
// 在此调用生产错误监控 SDK
}
路由错误:
ts
router.onError((error) => {
console.error('Router error:', error)
})
全局处理不应取代业务代码中的局部错误恢复。
16.5 加载、空状态和错误状态
一个列表页面至少要考虑:
text
初始状态
加载状态
加载成功且有数据
加载成功但无数据
加载失败
刷新状态
分页加载状态
部分操作失败
不要只实现"有数据时的正常页面"。
17. Vue 项目自动化测试
Vue 官方建议根据场景组合使用单元测试、组件测试和端到端测试。Vite 项目可以使用 Vitest,Vue 组件可以使用 Vue Test Utils,关键业务流程可以使用 Playwright。Vue 官方测试指南
17.1 测试分层
text
E2E 测试
少量,覆盖关键用户流程
▲
组件测试
覆盖组件公开行为和交互
▲
单元测试
大量,覆盖工具函数和业务规则
17.2 单元测试
业务函数:
ts
// src/utils/task.ts
export function normalizeTaskTitle(title: string) {
return title.trim().replace(/\s+/g, ' ')
}
export function validateTaskTitle(title: string) {
const normalized = normalizeTaskTitle(title)
if (!normalized) {
return '任务标题不能为空'
}
if (normalized.length > 100) {
return '任务标题不能超过 100 个字符'
}
return ''
}
测试:
ts
// src/utils/task.spec.ts
import { describe, expect, it } from 'vitest'
import {
normalizeTaskTitle,
validateTaskTitle
} from './task'
describe('task utilities', () => {
it('normalizes spaces', () => {
expect(normalizeTaskTitle(' 学习 Vue 3 '))
.toBe('学习 Vue 3')
})
it('rejects blank title', () => {
expect(validateTaskTitle(' '))
.toBe('任务标题不能为空')
})
it('accepts valid title', () => {
expect(validateTaskTitle('学习 Vue 3'))
.toBe('')
})
})
执行:
bash
npm run test:unit
17.3 组件测试
组件:
vue
<!-- src/components/TaskItem.vue -->
<script setup lang="ts">
import type { Task } from '@/types/task'
defineProps<{
task: Task
}>()
const emit = defineEmits<{
toggle: [id: string]
}>()
</script>
<template>
<label>
<input
data-testid="task-checkbox"
type="checkbox"
:checked="task.completed"
@change="emit('toggle', task.id)"
/>
<span>{{ task.title }}</span>
</label>
</template>
测试:
ts
// src/components/TaskItem.spec.ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import TaskItem from './TaskItem.vue'
describe('TaskItem', () => {
it('renders task title', () => {
const wrapper = mount(TaskItem, {
props: {
task: {
id: 'task-1',
title: '学习组件测试',
completed: false,
createdAt: '2026-01-01T00:00:00Z'
}
}
})
expect(wrapper.text()).toContain('学习组件测试')
})
it('emits toggle event', async () => {
const wrapper = mount(TaskItem, {
props: {
task: {
id: 'task-1',
title: '学习组件测试',
completed: false,
createdAt: '2026-01-01T00:00:00Z'
}
}
})
await wrapper
.get('[data-testid="task-checkbox"]')
.trigger('change')
expect(wrapper.emitted('toggle')).toEqual([
['task-1']
])
})
})
组件测试应优先验证公开行为:
- Props 是否正确展示。
- 用户操作是否触发预期事件。
- 加载、错误、禁用状态是否正确。
- 键盘交互是否可用。
不要过度依赖组件内部变量和私有实现细节。
17.4 E2E 测试
ts
// e2e/task.spec.ts
import { expect, test } from '@playwright/test'
test('user can create a task', async ({ page }) => {
await page.goto('/tasks')
await page
.getByLabel('任务标题')
.fill('学习 Vue Router')
await page
.getByRole('button', { name: '创建任务' })
.click()
await expect(
page.getByText('学习 Vue Router')
).toBeVisible()
})
E2E 测试适合覆盖:
- 登录。
- 创建订单。
- 支付前确认。
- 权限切换。
- 关键表单提交。
- 页面刷新后的恢复行为。
- 路由跳转。
- 前后端真实集成。
17.5 推荐脚本
json
{
"scripts": {
"dev": "vite",
"type-check": "vue-tsc --build",
"lint": "eslint .",
"format": "prettier --write src/",
"test:unit": "vitest",
"test:unit:run": "vitest run",
"test:e2e": "playwright test",
"build": "npm run type-check && vite build"
}
}
18. Vue 3 性能优化
性能优化应先测量,再定位,最后修改。不要为了"看起来更快"提前增加大量缓存和复杂逻辑。
Vue 官方将性能重点分为首屏加载性能和更新性能,并建议根据应用类型选择 SPA、SSR 或 SSG 等架构。Vue 官方性能指南
18.1 路由懒加载
ts
{
path: '/reports',
component: () => import('@/views/ReportView.vue')
}
这样不会把全部页面都放进首屏 JavaScript。
18.2 异步组件
ts
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(
() => import('@/components/HeavyChart.vue')
)
适合:
- 富文本编辑器。
- 大型图表。
- 地图。
- 低频弹窗。
- 复杂配置面板。
18.3 保持 Props 稳定
不推荐让每个子组件都接收一个会频繁变化的全局值,再自行判断:
vue
<UserItem
v-for="user in users"
:key="user.id"
:user="user"
:active-id="activeId"
/>
可以直接传递稳定的布尔值:
vue
<UserItem
v-for="user in users"
:key="user.id"
:user="user"
:active="user.id === activeId"
/>
这样只有 active 真正变化的子组件需要更新。
18.4 避免无意义的深度侦听
谨慎使用:
ts
watch(
form,
() => {
saveDraft()
},
{
deep: true
}
)
大对象的深度遍历可能带来额外开销,也容易产生过于频繁的保存请求。
可以侦听必要字段:
ts
watch(
() => [form.title, form.description],
() => {
saveDraft()
}
)
并配合防抖。
18.5 大列表虚拟化
当页面同时渲染几万条数据时,即使数据处理很快,大量 DOM 节点也会拖慢浏览器。
可以采用:
- 分页。
- 无限滚动。
- 虚拟列表。
- 服务端过滤和排序。
- 只展示必要列。
18.6 控制依赖体积
引入第三方库前检查:
- 是否支持按需导入。
- 是否包含大量不用的语言包。
- 是否有体积更小的替代方案。
- 是否能用原生 API 完成。
- 是否会同时引入重复依赖。
18.7 避免重复请求
可以根据业务一致性要求采用:
- 请求合并。
- 短期缓存。
- 路由级预取。
- Store 缓存。
- 数据请求库。
- 服务端缓存。
缓存必须明确:
text
缓存键是什么
数据多久过期
什么时候主动失效
并发请求如何合并
用户切换后如何隔离
失败结果是否缓存
18.8 选择合适的渲染架构
对于后台管理系统,SPA 通常足够。
对于重视首屏速度、SEO 和公开内容的站点,应评估:
- 服务端渲染 SSR。
- 静态站点生成 SSG。
- 服务端直接输出 HTML。
- 只在局部区域使用 Vue 增强交互。
19. 安全性与无障碍设计
19.1 不要使用不可信模板
Vue 模板会被编译为 JavaScript,因此不能把用户输入直接当作 Vue 模板执行。
绝对不要这样做:
ts
createApp({
template: userProvidedTemplate
})
Vue 官方最重要的安全原则之一,就是不要使用不可信内容作为模板。Vue 官方安全指南
19.2 谨慎使用 v-html
普通插值会对 HTML 进行转义:
vue
<p>{{ userContent }}</p>
v-html 会直接渲染 HTML:
vue
<div v-html="userContent"></div>
如果 userContent 来自用户或外部系统,可能导致 XSS。
确实需要渲染富文本时,应:
- 使用可信的 HTML 清洗库。
- 只允许白名单标签和属性。
- 禁止脚本、事件属性和危险 URL。
- 在后端再次校验或清洗。
- 配置合理的 Content Security Policy。
19.3 前端不能保存真正的密钥
以下内容不能放进 VITE_ 环境变量:
- 数据库密码。
- 云服务私钥。
- 支付平台私钥。
- 后端签名密钥。
- 可以绕过服务端权限的令牌。
前端代码和环境变量最终都会被发送到用户浏览器。
19.4 登录凭证
如果使用 Cookie 会话,通常需要后端正确配置:
HttpOnly。Secure。SameSite。- Cookie 作用域。
- CSRF 防护。
- 会话过期。
- 登录设备管理。
如果使用访问令牌,也应评估:
- 令牌存储位置。
- XSS 风险。
- 过期和刷新策略。
- 多标签页同步。
- 退出登录后的失效。
- 令牌泄漏后的撤销能力。
这些不是 Vue 能自动解决的问题,需要前后端共同设计。
19.5 无障碍语义
优先使用语义化 HTML:
vue
<button type="button" @click="submit">
提交
</button>
不要用没有键盘行为的 div 模拟按钮:
vue
<div @click="submit">
提交
</div>
表单应关联标签:
vue
<label for="email">邮箱</label>
<input id="email" v-model="email" type="email" />
错误提示可以使用:
vue
<p role="alert">
邮箱格式不正确
</p>
异步状态可以使用:
vue
<p aria-live="polite">
正在保存......
</p>
还应检查:
- 是否可以只用键盘操作。
- 焦点是否可见。
- 弹窗关闭后焦点是否回到触发按钮。
- 颜色对比度是否足够。
- 图片是否有合适的
alt。 - 标题层级是否连续。
- 动画是否尊重减少动态效果的系统设置。
更多内容可参考 Vue 官方无障碍指南。
20. 构建、部署与持续集成
20.1 本地生产构建
bash
npm run type-check
npm run test:unit:run
npm run build
本地预览:
bash
npm run preview
preview 适合检查本地构建结果,不应直接当作正式生产服务器。
20.2 Nginx 部署
Vue Router 使用 History 模式时,直接访问 /tasks/123 会请求服务器上的对应路径。
服务器需要把找不到的前端路径回退到 index.html:
nginx
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
注意 proxy_pass 是否包含尾部 / 会影响路径转发结果,必须根据后端实际接口路径验证。
20.3 子目录部署
如果应用部署在:
text
https://example.com/admin/
需要配置 Vite 的 base:
ts
export default defineConfig({
base: '/admin/'
})
路由应继续使用:
ts
createWebHistory(import.meta.env.BASE_URL)
Nginx 的静态目录和回退规则也必须与 /admin/ 对应。
20.4 构建时配置与运行时配置
Vite 环境变量通常在构建时写入产物:
text
开发环境构建
→ 写入开发配置
生产环境构建
→ 写入生产配置
如果希望同一个镜像部署到多个环境,可以考虑:
- 由服务器提供
/config.json。 - 在容器启动时生成配置文件。
- 通过后端 HTML 模板注入配置。
- 让前端始终使用相对路径
/api,由网关决定真实后端。
运行时配置需要定义:
- 配置文件加载失败怎么办。
- 配置是否需要签名。
- 哪些字段可以公开。
- 初始化完成前是否允许渲染应用。
20.5 GitHub Actions 示例
yaml
name: Vue CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit:run
- name: Build
run: npm run build
项目应提交 .nvmrc,并固定团队验证过的 Node.js 主版本:
text
22
是否使用该版本,应根据项目当前 Vite 版本和团队维护周期调整。
20.6 上线检查
上线前至少确认:
text
[ ] 依赖安装使用锁文件
[ ] ESLint 检查通过
[ ] TypeScript 类型检查通过
[ ] 单元测试通过
[ ] 关键 E2E 流程通过
[ ] 生产构建成功
[ ] 环境变量正确
[ ] API 地址正确
[ ] History 路由回退正确
[ ] 静态资源路径正确
[ ] 登录和权限验证正常
[ ] 异常监控正常
[ ] Source Map 策略符合安全要求
[ ] index.html 缓存策略正确
[ ] 哈希静态资源可以长期缓存
[ ] 具备明确回滚方案
21. 常见问题与排查思路
21.1 修改 ref 后页面没有变化
错误:
ts
count = 1
正确:
ts
count.value = 1
模板中不需要 .value:
vue
<p>{{ count }}</p>
21.2 reactive 解构后不再响应
错误:
ts
const { count } = reactive({
count: 0
})
正确:
ts
const state = reactive({
count: 0
})
const { count } = toRefs(state)
21.3 Pinia 解构后状态不更新
错误:
ts
const { count } = counterStore
正确:
ts
const { count } = storeToRefs(counterStore)
Action 可以直接解构:
ts
const { increment } = counterStore
21.4 直接修改 Props
错误:
ts
props.user.name = '李四'
更合理的方式:
ts
emit('update-user', {
...props.user,
name: '李四'
})
对于对象 Props,还要注意子组件修改其内部字段仍会影响父组件持有的同一个对象。
21.5 v-for 使用 index 作为 key
错误:
vue
<li v-for="(task, index) in tasks" :key="index">
正确:
vue
<li v-for="task in tasks" :key="task.id">
21.6 用 watch 保存派生状态
不推荐:
ts
const total = ref(0)
watch([price, quantity], () => {
total.value = price.value * quantity.value
})
推荐:
ts
const total = computed(() => {
return price.value * quantity.value
})
21.7 页面刷新后出现 404
原因通常是:
- 使用了
createWebHistory。 - 用户直接访问了深层路由。
- 服务器没有回退到
index.html。
需要配置:
nginx
try_files $uri $uri/ /index.html;
21.8 开发正常,生产接口地址错误
重点检查:
.env.production。VITE_API_BASE_URL。- Vite
base。 - Nginx 反向代理。
- 前后端路径是否重复
/api。 - 构建产物是否来自正确环境。
- CDN 是否缓存了旧的
index.html。
21.9 TypeScript 没报错,但构建失败
开发服务器通常只负责快速转换,不代表整个项目类型正确。
提交前执行:
bash
npm run type-check
npm run build
21.10 请求结果互相覆盖
典型场景:
text
用户搜索 A
↓
请求 A 发出
用户很快搜索 B
↓
请求 B 发出并先返回
请求 A 最后返回
↓
错误地覆盖 B 的结果
解决方案:
- 使用
AbortController取消旧请求。 - 使用请求编号,只接收最新结果。
- 使用支持请求缓存和并发管理的数据请求库。
21.11 组件越来越大
可以检查组件是否同时承担:
- 接口请求。
- 状态转换。
- 表单验证。
- 多个独立区域的展示。
- 弹窗控制。
- 权限判断。
- 数据格式化。
拆分方向:
text
页面组件:协调路由、数据和页面状态
业务组件:实现独立业务交互
基础组件:提供通用 UI
Composable:复用有状态逻辑
Store:管理跨页面共享状态
API 层:封装后端接口
Utils:保存无状态纯函数
22. Vue 3 学习路线与工程检查清单
22.1 第一阶段:JavaScript 基础
需要掌握:
let、const。- 数组和对象。
- 解构、展开运算符。
- 模块化导入导出。
- Promise。
async、await。- DOM 事件。
- Fetch API。
- TypeScript 基础类型。
22.2 第二阶段:Vue 基础
需要掌握:
- 单文件组件。
- 文本插值。
v-bind。v-on。v-if、v-show。v-for和key。v-model。ref、reactive。computed。watch。- 生命周期。
22.3 第三阶段:组件化
需要掌握:
- Props。
- Emits。
- Slots。
- 组件
v-model。 - provide/inject。
- Composable。
- 组件职责划分。
- 受控状态与单向数据流。
22.4 第四阶段:工程化
需要掌握:
- Vite。
- TypeScript。
- Vue Router。
- Pinia。
- API 分层。
- 环境变量。
- ESLint。
- Prettier。
- Git。
- 生产构建。
22.5 第五阶段:质量保障
需要掌握:
- 单元测试。
- 组件测试。
- E2E 测试。
- 错误监控。
- 性能分析。
- 安全基础。
- 无障碍设计。
- CI/CD。
- 灰度和回滚。
22.6 组件检查清单
text
[ ] 组件职责是否单一
[ ] Props 是否有明确类型
[ ] Emits 是否有明确类型
[ ] 是否直接修改了 Props
[ ] 列表 key 是否稳定
[ ] 是否清理事件监听和定时器
[ ] 异步请求是否处理竞态
[ ] 是否存在无意义的深度侦听
[ ] 加载、空数据和错误状态是否完整
[ ] 表单是否防止重复提交
[ ] 按钮和表单是否支持键盘操作
[ ] 关键行为是否有测试
22.7 Store 检查清单
text
[ ] 状态是否真的需要全局共享
[ ] Getter 是否保持纯净
[ ] Action 是否有清晰的业务语义
[ ] 请求失败后状态是否一致
[ ] 乐观更新是否支持回滚
[ ] 用户退出后是否清理敏感状态
[ ] 是否错误地直接解构响应式属性
[ ] Store 之间是否出现循环依赖
22.8 接口层检查清单
text
[ ] 是否统一处理基础地址
[ ] 是否统一处理响应协议
[ ] 是否区分 HTTP 错误和业务错误
[ ] 是否支持取消过期请求
[ ] 是否避免重复提交
[ ] 是否对路径参数编码
[ ] 是否处理上传、下载和 204 响应
[ ] 是否避免在日志中输出敏感信息
[ ] 是否有明确的认证过期处理
23. 总结
Vue 3 的基础 API 并不复杂,真正决定项目质量的是如何组织和约束这些能力。
从入门到工程实践,可以形成以下主线:
text
模板语法
↓
响应式状态
↓
组件化
↓
Composable 逻辑复用
↓
TypeScript 类型约束
↓
Vue Router 页面组织
↓
Pinia 全局状态
↓
API 和业务分层
↓
测试、性能、安全
↓
构建、部署和持续集成
实际项目中应坚持几条原则:
- 局部状态优先保留在组件内部。
- 派生数据优先使用计算属性。
- Props 保持单向流动。
- Store 只保存真正需要跨模块共享的状态。
- 页面组件不要直接承担全部接口和业务逻辑。
- 所有异步流程都要考虑加载、失败、重试和竞态。
- 前端权限控制不能代替后端鉴权。
- Vite 环境变量不是秘密存储。
- 性能优化前先进行测量。
- 构建成功不代表项目已经通过类型检查和测试。
掌握 Vue 3 的目标不只是"让页面运行起来",而是建立一个边界清晰、能够测试、容易维护、可以安全发布的前端工程。