Vue 3 从入门到工程实践:基础语法、组件化、路由、状态管理与项目部署

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

这里得到的 countname 是普通值,不会继续跟随原对象变化。

可以使用 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

provideinject 适合跨越多层组件传递依赖:

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 应注意:

  1. 对外暴露尽量少的状态和方法。
  2. 修改状态的入口应清晰。
  3. 创建的副作用必须清理。
  4. 不要依赖隐式全局变量。
  5. 参数可以接受 Ref、普通值或 getter 时,应明确约定。
  6. 错误、加载状态和重试行为应可观测。
  7. 不要把所有业务都抽成 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
布尔值 isLoadinghasPermission
事件函数 handleSubmithandleRemove
数据请求 fetchTasksloadUser
类型 TaskCreateTaskCommand

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 : '操作失败'
}

toggleTaskremoveTask 使用了乐观更新:

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 基础

需要掌握:

  • letconst
  • 数组和对象。
  • 解构、展开运算符。
  • 模块化导入导出。
  • Promise。
  • asyncawait
  • DOM 事件。
  • Fetch API。
  • TypeScript 基础类型。

22.2 第二阶段:Vue 基础

需要掌握:

  • 单文件组件。
  • 文本插值。
  • v-bind
  • v-on
  • v-ifv-show
  • v-forkey
  • v-model
  • refreactive
  • 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 和业务分层
    ↓
测试、性能、安全
    ↓
构建、部署和持续集成

实际项目中应坚持几条原则:

  1. 局部状态优先保留在组件内部。
  2. 派生数据优先使用计算属性。
  3. Props 保持单向流动。
  4. Store 只保存真正需要跨模块共享的状态。
  5. 页面组件不要直接承担全部接口和业务逻辑。
  6. 所有异步流程都要考虑加载、失败、重试和竞态。
  7. 前端权限控制不能代替后端鉴权。
  8. Vite 环境变量不是秘密存储。
  9. 性能优化前先进行测量。
  10. 构建成功不代表项目已经通过类型检查和测试。

掌握 Vue 3 的目标不只是"让页面运行起来",而是建立一个边界清晰、能够测试、容易维护、可以安全发布的前端工程。


24. 参考资料

相关推荐
掘金酱1 小时前
TRAE Work 实战帮征文 | 获奖名单公示
前端·人工智能·后端
陆枫Larry1 小时前
Chromium和Chrome到底是什么关系?
前端
a1117761 小时前
模仿刀剑神域风格 作品集网页
前端·开源
a1117761 小时前
Elemental Sandbox(元素沙盒)-Three.js 游戏技能特效
前端·开源
用户69371750013841 小时前
前阵子刷屏的 Ox-Alpha 真身揭晓!GLM-5.3-Flash 上线,这价格真的杀疯了
前端·后端·github
四千岁1 小时前
抓包LangChain-了解LangChain的原理
前端·javascript·后端
南雨北斗1 小时前
TS方式构建Vue3 toast组件(一)
前端
IT小白杨1 小时前
2026游戏工作室环境隔离指南:从设备指纹到移动IP的部署实践
前端·经验分享·网络协议·tcp/ip·游戏·安全架构·指纹浏览器
用户298698530142 小时前
使用 JavaScript 在 React 中实现 Word 转 PDF
前端·javascript·react.js