技术栈:Vue 3 + Vite + TypeScript + Pinia + Vue Router 4 适合人群:前端开发者、有 Vue 2 基础的团队、想系统掌握 Vue 3 的工程师 更新日期:2026-05-16
1. Vue 3 新特性概览
1.1 为什么选择 Vue 3?
特性 Vue 2 Vue 3 提升 核心架构 Options API Composition API 代码组织更灵活 响应式系统 defineProperty Proxy 支持深层对象、数组 TypeScript 支持 勉强支持 原生 TypeScript 类型推断更准确 打包体积 较大 减小约 30% 首屏加载更快 虚拟 DOM VDOM 最优解(PatchFlag) 渲染性能提升 插槽机制 slot/scope-slot 统一 v-slot API 更简洁 Teleport ❌ ✅ 内置 模态框/弹窗更易实现 Fragments ❌ ✅ 组件可返回多个根节点 Suspense ❌ ✅ 异步组件加载体验更好
1.2 Vue 3 架构图
┌─────────────────────────────────────────────────────────┐
│ Vue 3 应用 │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Vue Router │ │ Pinia │ │ VueUse │ │
│ │ (路由管理) │ │ (状态管理) │ │ (Composables) │ │
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
│ │ │ │ │
│ ┌──────┴────────────────┴──────────────────┴──────┐ │
│ │ Composition API │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ setup() │ │ ref() │ │ computed() │ │ │
│ │ │ reactive │ │ watch │ │ lifecycle │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴──────────────────────────┐ │
│ │ 响应式系统 (Proxy) │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ getter │ │ setter │ │ scheduler │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴──────────────────────────┐ │
│ │ 渲染器 (Renderer) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Patch │ │ Hooks │ │ PatchFlag │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
2. 项目初始化:Vite + Vue 3 + TypeScript
2.1 环境要求
# Node.js 版本要求
node >= 18.12.0 (推荐 v20 LTS)
# 检查版本
node -v
npm -v
2.2 创建项目
# 方式一:交互式创建(推荐新手)
npm create vue@latest my-vue-app
# 方式二:使用 Vite 快速创建
npm create vite@latest my-vue-app -- --template vue-ts
# 进入项目目录
cd my-vue-app
# 安装依赖
npm install
# 启动开发服务器
npm run dev
2.3 项目结构详解
my-vue-app/
├── public/ # 静态资源(不会被构建)
│ └── favicon.ico
├── src/
│ ├── assets/ # 资源文件(会被构建)
│ │ └── logo.svg
│ ├── components/ # 公共组件
│ │ ├── HelloWorld.vue
│ │ └── TheWelcome.vue
│ ├── composables/ # 组合式函数(VueUse 风格)
│ │ └── useCounter.ts
│ ├── router/ # 路由配置
│ │ └── index.ts
│ ├── stores/ # Pinia 状态管理
│ │ └── counter.ts
│ ├── types/ # TypeScript 类型定义
│ │ └── index.ts
│ ├── views/ # 页面组件
│ │ ├── HomeView.vue
│ │ └── AboutView.vue
│ ├── App.vue # 根组件
│ ├── main.ts # 入口文件
│ └── style.css # 全局样式
├── index.html # HTML 入口
├── package.json
├── tsconfig.json # TypeScript 配置
├── tsconfig.app.json # 应用专用 TS 配置
├── tsconfig.node.json # Node 环境 TS 配置
├── vite.config.ts # Vite 构建配置
└── README.md
2.4 Vite 配置详解
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
// 路径别名
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@views': resolve(__dirname, 'src/views'),
},
},
// 开发服务器配置
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
// 构建配置
build: {
target: 'es2020',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
// CSS 配置
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/assets/variables.scss";`,
},
},
},
})
2.5 TypeScript 配置
// tsconfig.app.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* 模块解析 */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* 路径别名 */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@views/*": ["src/views/*"]
},
/* 严格类型检查 */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Vue3 类型支持 */
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
3. 核心概念:响应式系统
3.1 Vue 3 响应式原理
Vue 3 使用 ES6 Proxy 实现响应式,相比 Vue 2 的 Object.defineProperty 有以下优势:
┌────────────────────────────────────────────────────────────────┐
│ Vue 3 响应式原理 │
├────────────────────────────────────────────────────────────────┤
│ │
│ const original = { name: 'Vue', version: 3 } │
│ │
│ const proxy = new Proxy(original, { │
│ get(target, key) { │
│ console.log(`读取 ${key}`) │
│ return Reflect.get(target, key) │
│ }, │
│ set(target, key, value) { │
│ console.log(`设置 ${key} = ${value}`) │
│ return Reflect.set(target, key, value) │
│ }, │
│ deleteProperty(target, key) { │
│ console.log(`删除 ${key}`) │
│ return Reflect.deleteProperty(target, key) │
│ } │
│ }) │
│ │
│ proxy.name // 触发 get → "读取 name" │
│ proxy.version = 4 // 触发 set → "设置 version = 4" │
│ │
└────────────────────────────────────────────────────────────────┘
3.2 ref() vs reactive() 对比
特性 ref()reactive()适用类型 基本类型 + 对象 仅对象/数组 访问方式 需要 .value 直接访问 解构丢失响应式 会丢失(需 toRefs) 会丢失(需 toRef) TypeScript 类型 Ref<T>Reactive<T>重新赋值 支持(整个替换) 不支持(需使用 ref)
3.3 ref() 详解
import { ref, isRef } from 'vue'
// 基本类型
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
// 对象类型
const user = ref({
name: '张三',
age: 25
})
console.log(user.value.name) // 张三
// 模板中自动解包(不需要 .value)
// <template>
// <div>{{ count }}</div> <!-- 自动访问 .value -->
// </template>
// 判断是否为 ref
console.log(isRef(count)) // true
console.log(isRef(user)) // true
// 泛型类型
const str = ref<string>('Hello')
const num = ref<number>(100)
const arr = ref<string[]>(['a', 'b', 'c'])
3.4 reactive() 详解
import { reactive, readonly, isReactive } from 'vue'
// 对象响应式
const state = reactive({
count: 0,
user: {
name: '李四',
address: {
city: '北京',
district: '朝阳区'
}
},
tags: ['Vue', 'TypeScript', 'Vite']
})
// 直接访问属性(无需 .value)
state.count++
state.user.name = '王五'
// 数组操作
state.tags.push('React')
state.tags.length = 0 // 清空数组
// 深层次响应式
state.user.address.city = '上海' // 深层变更仍然响应
// 只读响应式
const readonlyState = readonly(state)
readonlyState.count++ // 警告!不能修改只读属性
// 判断是否为 reactive 对象
console.log(isReactive(state)) // true
3.5 toRef() 与 toRefs()
import { reactive, toRef, toRefs } from 'vue'
// ❌ 解构会丢失响应式
const state = reactive({ name: '张三', age: 25 })
const { name, age } = state // 丢失响应式!
// ✅ 使用 toRefs 保持响应式
const state = reactive({ name: '张三', age: 25 })
const { name, age } = toRefs(state)
// 修改响应式数据
name.value = '李四' // 会修改 state.name
age.value = 30 // 会修改 state.age
// ✅ 单个属性转换
const nameRef = toRef(state, 'name')
console.log(nameRef.value) // 张三
3.6 computed() 计算属性
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// 只读计算属性
const fullName = computed(() => firstName.value + lastName.value)
console.log(fullName.value) // 张三
// 可写计算属性
const user = ref({
firstName: 'John',
lastName: 'Doe'
})
const fullNameWritable = computed({
get: () => `${user.value.firstName} ${user.value.lastName}`,
set: (val) => {
const [first, last] = val.split(' ')
user.value.firstName = first
user.value.lastName = last
}
})
fullNameWritable.value = 'Jane Smith'
console.log(user.value.firstName) // Jane
// 计算属性类型
interface UserInfo {
name: string
age: number
emails: string[]
}
const users = ref<UserInfo[]>([
{ name: '张三', age: 25, emails: ['zhang@example.com'] },
{ name: '李四', age: 30, emails: ['li@example.com'] }
])
// 泛型计算属性
const userNames = computed<string[]>(() =>
users.value.map(u => u.name)
)
// 带条件的计算
const adultUsers = computed(() =>
users.value.filter(u => u.age >= 18)
)
3.7 watch() 监听器
import { ref, watch, watchEffect, WatchStopHandle } from 'vue'
// 基础监听
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})
count.value++
// 监听多个数据源
const firstName = ref('张')
const lastName = ref('三')
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`${oldFirst} -> ${newFirst}, ${oldLast} -> ${newLast}`)
})
// 监听深层对象
const user = ref({
profile: {
address: {
city: '北京'
}
}
})
watch(user, (newUser) => {
console.log('用户信息变化:', newUser)
}, { deep: true })
// 立即执行
watch(user, (newUser) => {
console.log('立即执行:', newUser)
}, { immediate: true })
// 只监听特定路径
watch(() => user.value.profile.address.city, (newCity) => {
console.log('城市变化:', newCity)
})
// 停止监听
const stop = watch(count, (newVal) => {
console.log('监听中...')
})
stop() // 停止监听
// watchEffect - 自动收集依赖
const id = ref(1)
const userData = ref(null)
watchEffect(async () => {
// 自动追踪 id.value 的使用
const response = await fetch(`/api/users/${id.value}`)
userData.value = await response.json()
console.log('用户数据更新')
})
// watchEffect 清理
watchEffect((onCleanup) => {
const timer = setTimeout(() => {
console.log('定时器执行')
}, 1000)
onCleanup(() => {
clearTimeout(timer)
console.log('清理定时器')
})
})
// 返回停止句柄
const stopEffect = watchEffect(() => {
// ...
})
stopEffect()
4. Composition API 全面解析
4.1 setup() 组件选项
<script setup lang="ts">
// setup() 是 Composition API 的入口点
// 在 beforeCreate 钩子之前执行,此时组件实例还未创建
import { ref, computed, onMounted } from 'vue'
// 响应式状态
const message = ref('Hello Vue 3!')
const count = ref(0)
// 计算属性
const doubled = computed(() => count.value * 2)
// 方法
const increment = () => {
count.value++
}
// 生命周期钩子
onMounted(() => {
console.log('组件挂载完成')
})
// 返回的值将暴露给模板
return {
message,
count,
doubled,
increment
}
</script>
<template>
<div>
<h1>{{ message }}</h1>
<p>Count: {{ count }}</p>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">增加</button>
</div>
</template>
4.2 <script setup> 语法糖
<!-- MyComponent.vue -->
<script setup lang="ts">
// <script setup> 是 setup() 的语法糖
// 所有顶層变量自动暴露给模板
import { ref, computed, defineProps, defineEmits, defineExpose } from 'vue'
// 定义 Props
interface Props {
title: string
count?: number
items?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
})
// 定义 Emits
const emit = defineEmits<{
(e: 'update', value: number): void
(e: 'delete', id: number): void
}>()
// 响应式数据
const localCount = ref(props.count)
const inputValue = ref('')
// 计算属性
const itemCount = computed(() => props.items.length)
// 方法
const handleUpdate = () => {
emit('update', localCount.value)
}
const handleDelete = (id: number) => {
emit('delete', id)
}
// 暴露给父组件(通过 ref 访问)
defineExpose({
localCount,
reset: () => {
localCount.value = 0
}
})
</script>
<template>
<div class="my-component">
<h2>{{ title }}</h2>
<p>Items: {{ itemCount }}</p>
<input v-model="inputValue" />
<button @click="handleUpdate">更新</button>
</div>
</template>
<style scoped>
.my-component {
padding: 1rem;
border: 1px solid #ddd;
}
</style>
4.3 组合式函数(Composables)
// composables/useCounter.ts
import { ref, computed, Ref } from 'vue'
interface UseCounterOptions {
min?: number
max?: number
step?: number
}
interface UseCounterReturn {
count: Ref<number>
increment: () => void
decrement: () => void
reset: () => void
isAtMin: Ref<boolean>
isAtMax: Ref<boolean>
}
export function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
const { min = 0, max = Infinity, step = 1 } = options
const count = ref(initialValue)
const isAtMin = computed(() => count.value <= min)
const isAtMax = computed(() => count.value >= max)
const increment = () => {
if (count.value < max) {
count.value = Math.min(count.value + step, max)
}
}
const decrement = () => {
if (count.value > min) {
count.value = Math.max(count.value - step, min)
}
}
const reset = () => {
count.value = initialValue
}
return {
count,
increment,
decrement,
reset,
isAtMin,
isAtMax
}
}
// composables/useFetch.ts
import { ref, Ref, watchEffect } from 'vue'
interface UseFetchOptions {
immediate?: boolean
refetch?: Ref<boolean>
}
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
execute: () => Promise<void>
}
export function useFetch<T>(
url: string | Ref<string>,
options: UseFetchOptions = {}
): UseFetchReturn<T> {
const { immediate = true, refetch } = options
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
const currentUrl = typeof url === 'string' ? url : url.value
const response = await fetch(currentUrl)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
} finally {
loading.value = false
}
}
if (immediate) {
watchEffect(() => {
execute()
})
}
if (refetch) {
watchEffect(() => {
if (refetch.value) {
execute()
}
})
}
return { data, error, loading, execute }
}
// composables/useLocalStorage.ts
import { ref, watch, Ref } from 'vue'
export function useLocalStorage<T>(
key: string,
defaultValue: T
): Ref<T> {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
watch(
data,
(newValue) => {
if (newValue === null || newValue === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
},
{ deep: true }
)
return data
}
// 使用示例
// const theme = useLocalStorage('theme', 'light')
// theme.value = 'dark' // 自动保存到 localStorage
<!-- 组件中使用 Composable -->
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
import { useFetch } from '@/composables/useFetch'
// 计数器
const { count, increment, decrement, reset } = useCounter(0, {
min: 0,
max: 100,
step: 5
})
// 数据获取
const userId = ref(1)
const { data: user, loading, error, execute } = useFetch<User>(
() => `/api/users/${userId.value}`,
{ immediate: false }
)
// 手动触发
const fetchNextUser = () => {
userId.value++
execute()
}
</script>
<template>
<div>
<h2>计数器: {{ count }}</h2>
<button @click="decrement">-</button>
<button @click="reset">重置</button>
<button @click="increment">+</button>
<hr />
<button @click="fetchNextUser">加载用户</button>
<div v-if="loading">加载中...</div>
<div v-else-if="error">{{ error.message }}</div>
<div v-else-if="user">{{ user.name }}</div>
</div>
</template>
4.4 Provide / Inject 跨组件通信
// ParentComponent.vue
<script setup lang="ts">
import { provide, ref } from 'vue'
// 提供简单值
provide('appName', 'My Vue App')
// 提供响应式数据
const theme = ref('light')
provide('theme', theme)
// 提供对象(包含多个值)
const userContext = {
name: '张三',
role: 'admin'
}
provide('user', userContext)
// 提供函数
const updateTheme = (newTheme: string) => {
theme.value = newTheme
}
provide('updateTheme', updateTheme)
</script>
<template>
<div class="parent">
<h1>父组件 - 主题: {{ theme }}</h1>
<ChildComponent />
</div>
</template>
<!-- GrandChildComponent.vue (深层嵌套) -->
<script setup lang="ts">
import { inject, computed } from 'vue'
// 注入简单值
const appName = inject<string>('appName')
// 注入响应式数据
const theme = inject<Ref<string>>('theme')
// 注入带默认值的响应式数据
const config = inject('config', { apiUrl: 'http://localhost:3000' })
// 注入函数
const updateTheme = inject<(theme: string) => void>('updateTheme')
// 注入并处理不存在的情况
const analytics = inject('analytics', () => console.log('no analytics'))
</script>
<template>
<div class="grand-child">
<h3>孙组件</h3>
<p>应用名称: {{ appName }}</p>
<p>当前主题: {{ theme }}</p>
<button @click="updateTheme?.('dark')">切换到深色</button>
</div>
</template>
4.5 nextTick 与异步更新
import { ref, nextTick } from 'vue'
const count = ref(0)
const listRef = ref<HTMLElement>()
// 更新后获取 DOM
const updateAndGetDOM = async () => {
count.value++
// DOM 更新是异步的,需要等待
await nextTick()
console.log(listRef.value?.innerHTML) // 此时 DOM 已更新
}
// 批量更新
const batchUpdate = async () => {
// 多次修改只触发一次更新
count.value++
count.value++
count.value++
// 此时还未更新 DOM
await nextTick()
// DOM 现在已更新为最终值
}
5. TypeScript 与 Vue 3 深度集成
5.1 组件 Props 类型定义
// types/components.ts
// 基础类型定义
interface BaseProps {
title: string
visible: boolean
}
// 可选属性
interface OptionalProps {
width?: number
height?: number
class?: string
}
// 带验证的 Props
interface ValidatedProps {
count: {
type: NumberConstructor
required: true
default: 0
validator: (val: number) => val >= 0
}
}
// 联合类型 Props
type ButtonVariant = 'primary' | 'secondary' | 'danger'
type IconPosition = 'left' | 'right'
interface ButtonProps {
variant: ButtonVariant
iconPosition?: IconPosition
disabled?: boolean
}
// 泛型 Props
interface ListProps<T> {
items: T[]
selected?: T
onSelect?: (item: T) => void
}
// 事件类型
interface CustomEvents {
(e: 'update', value: string): void
(e: 'delete', id: number): void
(e: 'click', event: MouseEvent): void
}
// 暴露类型
interface ExposedMethods {
reset(): void
refresh(): Promise<void>
scrollToTop(): void
}
<!-- TypedProps.vue -->
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
title: string
count: number
variant?: 'primary' | 'secondary' | 'danger'
items?: string[]
onUpdate?: (value: number) => void
}
// 定义 Props
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
items: () => []
})
// Props 只读
// props.title = '新标题' // 错误!Props 是只读的
// 使用 Props
const titleClass = computed(() => `title-${props.variant}`)
</script>
<template>
<div :class="titleClass">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</div>
</template>
5.2 事件类型定义
<!-- EventEmitting.vue -->
<script setup lang="ts">
import { defineEmits } from 'vue'
// 方式一:使用类型化 emits
const emit = defineEmits<{
(e: 'update', value: string): void
(e: 'delete', id: number): void
(e: 'custom', payload: { action: string; data: unknown }): void
}>()
// 触发事件
const handleSubmit = () => {
emit('update', 'new value')
}
const handleDelete = (id: number) => {
emit('delete', id)
}
</script>
<template>
<button @click="handleSubmit">更新</button>
<button @click="handleDelete(1)">删除</button>
</template>
<!-- ParentComponent.vue -->
<script setup lang="ts">
const handleUpdate = (value: string) => {
console.log('更新:', value)
}
const handleDelete = (id: number) => {
console.log('删除 ID:', id)
}
</script>
<template>
<!-- 类型安全的事件处理 -->
<EventEmitting
@update="handleUpdate"
@delete="handleDelete"
/>
</template>
5.3 模板引用类型
<!-- TemplateRef.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
// DOM 元素引用
const containerRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const canvasRef = ref<HTMLCanvasElement>()
// 组件引用
import ChildComponent from './ChildComponent.vue'
const childRef = ref<InstanceType<typeof ChildComponent>>()
onMounted(() => {
// DOM 操作
console.log(containerRef.value?.className)
inputRef.value?.focus()
// 组件方法
childRef.value?.reset()
childRef.value?.refresh()
})
// 数组引用
const itemRefs = ref<HTMLElement[]>([])
const setItemRef = (el: HTMLElement | null, index: number) => {
if (el) {
itemRefs.value[index] = el
}
}
</script>
<template>
<div ref="containerRef">
<input ref="inputRef" type="text" />
<canvas ref="canvasRef" width="800" height="600"></canvas>
<ChildComponent ref="childRef" />
<!-- 动态引用 -->
<div
v-for="(item, index) in items"
:ref="(el) => setItemRef(el as HTMLElement, index)"
>
{{ item }}
</div>
</div>
</template>
5.4 泛型组件
<!-- GenericTable.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
import { ref, computed } from 'vue'
interface Props<T> {
data: T[]
columns: {
key: keyof T
label: string
}[]
selectable?: boolean
}
const props = withDefaults(defineProps<Props<T>>(), {
selectable: false
})
const emit = defineEmits<{
(e: 'select', item: T): void
(e: 'row-click', item: T): void
}>()
const selectedItem = ref<T | null>(null)
const handleSelect = (item: T) => {
selectedItem.value = item
emit('select', item)
}
</script>
<template>
<table class="generic-table">
<thead>
<tr>
<th v-if="selectable">选择</th>
<th v-for="col in columns" :key="String(col.key)">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in data"
:key="item.id"
@click="emit('row-click', item)"
>
<td v-if="selectable">
<input
type="radio"
:checked="selectedItem?.id === item.id"
@change="handleSelect(item)"
/>
</td>
<td v-for="col in columns" :key="String(col.key)">
{{ item[col.key] }}
</td>
</tr>
</tbody>
</table>
</template>
<!-- 使用泛型组件 -->
<script setup lang="ts">
import GenericTable from './GenericTable.vue'
interface User {
id: number
name: string
email: string
role: string
}
const users = ref<User[]>([
{ id: 1, name: '张三', email: 'zhang@example.com', role: 'admin' },
{ id: 2, name: '李四', email: 'li@example.com', role: 'user' }
])
const columns = [
{ key: 'name', label: '姓名' },
{ key: 'email', label: '邮箱' },
{ key: 'role', label: '角色' }
]
</script>
<template>
<GenericTable
:data="users"
:columns="columns"
selectable
@row-click="(user) => console.log('点击:', user)"
/>
</template>
5.5 装饰器与 Vue 3
⚠️ 注意:Vue 3 推荐使用 <script setup> 语法糖,不推荐 Vue 2 的 Class 装饰器写法。以下仅供参考。
// 如果必须使用 Class 风格,可以使用 vue-property-decorator
import { Component, Vue, Prop, Watch, Emit } from 'vue-property-decorator'
@Component
export default class MyClassComponent extends Vue {
@Prop({ type: String, required: true })
title!: string
@Prop({ default: 0 })
count!: number
private message = 'Hello'
@Watch('count')
onCountChange(newVal: number, oldVal: number) {
console.log(`count 从 ${oldVal} 变为 ${newVal}`)
}
@Emit('update')
handleUpdate() {
return this.message
}
}
6. Vue Router 4 路由管理
6.1 安装与基础配置
npm install vue-router@4
// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
// 路由配置类型
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
// 路由元信息
meta: { title: '首页', requiresAuth: false }
},
{
path: '/about',
name: 'about',
component: () => import('@/views/AboutView.vue'),
meta: { title: '关于我们' }
},
{
path: '/user/:id',
name: 'user-profile',
component: () => import('@/views/UserProfile.vue'),
props: true // 将路由参数作为 props 传递
},
{
path: '/products/:category/:id?',
name: 'products',
component: () => import('@/views/Products.vue'),
props: (route) => ({
category: route.params.category,
id: route.params.id
})
}
]
// 创建路由实例
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
return { top: 0 }
}
})
// 路由守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title as string || 'Vue App'
// 权限检查
if (to.meta.requiresAuth && !isAuthenticated()) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next()
}
})
export default router
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
6.2 嵌套路由
// router/index.ts
const routes: RouteRecordRaw[] = [
{
path: '/dashboard',
component: () => import('@/layouts/DashboardLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: 'overview'
},
{
path: 'overview',
name: 'dashboard-overview',
component: () => import('@/views/dashboard/Overview.vue'),
meta: { title: '总览' }
},
{
path: 'analytics',
name: 'dashboard-analytics',
component: () => import('@/views/dashboard/Analytics.vue'),
meta: { title: '数据分析' }
},
{
path: 'reports',
name: 'dashboard-reports',
component: () => import('@/views/dashboard/Reports.vue'),
meta: { title: '报告' }
}
]
}
]
<!-- DashboardLayout.vue -->
<script setup lang="ts">
import { RouterView } from 'vue-router'
import DashboardSidebar from '@/components/DashboardSidebar.vue'
import DashboardHeader from '@/components/DashboardHeader.vue'
</script>
<template>
<div class="dashboard-layout">
<DashboardSidebar />
<div class="dashboard-content">
<DashboardHeader />
<!-- 嵌套路由出口 -->
<RouterView />
</div>
</div>
</template>
6.3 编程式导航
import { useRouter, useRoute } from 'vue-router'
import { onMounted } from 'vue'
const router = useRouter()
const route = useRoute()
// 导航到指定路径
router.push('/user/123')
// 导航到命名路由
router.push({ name: 'user-profile', params: { id: 123 } })
// 带查询参数
router.push({ path: '/search', query: { q: 'vue' } })
// 替换当前历史记录
router.replace('/home')
// 前进/后退
router.forward() // 前进
router.back() // 后退
router.go(-2) // 后退两步
// 获取路由信息
onMounted(() => {
const id = route.params.id // 路由参数
const query = route.query // 查询参数
const hash = route.hash // 锚点
const path = route.path // 路径
const name = route.name // 路由名称
console.log('当前路由:', { id, query, hash, path, name })
})
6.4 路由元信息与权限控制
// types/router.ts
import type { RouteRecordRaw } from 'vue-router'
interface Meta {
title: string
requiresAuth: boolean
roles?: string[]
keepAlive?: boolean
}
declare module 'vue-router' {
interface RouteMeta extends Meta {}
}
const routes: RouteRecordRaw[] = [
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
children: [
{
path: 'users',
component: () => import('@/views/admin/Users.vue'),
meta: { title: '用户管理', requiresAuth: true, roles: ['admin'] }
}
]
}
]
// router/guards.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes: []
})
// 权限检查函数
const checkPermission = (to: RouteLocationNormalized) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.roles && !hasRole(to.meta.roles as string[])) {
return { name: 'forbidden' }
}
return true
}
const hasRole = (roles: string[]): boolean => {
const userStore = useUserStore()
return roles.some(role => userStore.roles.includes(role))
}
router.beforeEach(async (to, from, next) => {
// 设置标题
document.title = to.meta.title || 'Vue App'
// 权限检查
const result = checkPermission(to)
if (result === true) {
next()
} else {
next(result)
}
})
export default router
6.5 路由懒加载
// 方式一:动态导入(推荐)
const Home = () => import('./views/Home.vue')
// 方式二:带命名 chunk 的动态导入
const About = () => import(/* webpackChunkName: "about" */ './views/About.vue')
// 方式三:路由懒加载 + 预加载
const User = () => import(/* webpackChunkName: "user", webpackPrefetch: true */ './views/User.vue')
// 方式四:组合式函数封装
import { defineAsyncComponent } from 'vue'
import Loading from '@/components/Loading.vue'
import ErrorComponent from '@/components/ErrorComponent.vue'
export function useAsyncComponent(loader: () => Promise<any>) {
return defineAsyncComponent({
loader,
loadingComponent: Loading,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
}
7. Pinia 状态管理
7.1 安装与配置
npm install pinia
// stores/index.ts
import { createPinia } from 'pinia'
export const pinia = createPinia()
export * from './user'
export * from './cart'
export * from './settings'
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { pinia } from './stores'
const app = createApp(App)
app.use(pinia)
app.mount('#app')
7.2 定义 Store
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User, UserCredentials } from '@/types'
export const useUserStore = defineStore('user', () => {
// ===== State =====
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const isLoading = ref(false)
const error = ref<string | null>(null)
// ===== Getters =====
const isLoggedIn = computed(() => !!token.value && !!user.value)
const userName = computed(() => user.value?.name || 'Guest')
const userInitials = computed(() => {
if (!user.value?.name) return 'G'
return user.value.name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
})
const isAdmin = computed(() => user.value?.role === 'admin')
// ===== Actions =====
const login = async (credentials: UserCredentials) => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!response.ok) {
throw new Error('登录失败')
}
const data = await response.json()
token.value = data.token
user.value = data.user
localStorage.setItem('token', data.token)
return data.user
} catch (e) {
error.value = e instanceof Error ? e.message : '登录失败'
throw e
} finally {
isLoading.value = false
}
}
const logout = () => {
user.value = null
token.value = null
localStorage.removeItem('token')
}
const updateProfile = async (updates: Partial<User>) => {
if (!user.value) return
const response = await fetch(`/api/users/${user.value.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.value}`
},
body: JSON.stringify(updates)
})
if (response.ok) {
const updated = await response.json()
user.value = { ...user.value, ...updated }
}
}
return {
// State
user,
token,
isLoading,
error,
// Getters
isLoggedIn,
userName,
userInitials,
isAdmin,
// Actions
login,
logout,
updateProfile
}
})
// stores/cart.ts - 使用 Options API 风格
import { defineStore } from 'pinia'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
isOpen: boolean
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: [],
isOpen: false
}),
getters: {
totalItems: (state) =>
state.items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: (state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
isEmpty: (state) => state.items.length === 0
},
actions: {
addItem(item: Omit<CartItem, 'quantity'>) {
const existing = this.items.find(i => i.id === item.id)
if (existing) {
existing.quantity++
} else {
this.items.push({ ...item, quantity: 1 })
}
},
removeItem(id: string) {
const index = this.items.findIndex(i => i.id === id)
if (index > -1) {
this.items.splice(index, 1)
}
},
updateQuantity(id: string, quantity: number) {
const item = this.items.find(i => i.id === id)
if (item) {
if (quantity <= 0) {
this.removeItem(id)
} else {
item.quantity = quantity
}
}
},
clearCart() {
this.items = []
},
toggleCart() {
this.isOpen = !this.isOpen
}
}
})
7.3 在组件中使用 Store
<!-- UserProfile.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
const userStore = useUserStore()
const cartStore = useCartStore()
// 使用 storeToRefs 保持响应式
const { user, isAdmin, userName } = storeToRefs(userStore)
// 直接解构 actions(不需要 storeToRefs)
const { logout } = userStore
// 购物车
const { totalItems, totalPrice, isEmpty } = storeToRefs(cartStore)
const { addItem, removeItem, clearCart } = cartStore
</script>
<template>
<div class="user-profile">
<h1>欢迎, {{ userName }}</h1>
<p v-if="isAdmin">管理员用户</p>
<div class="user-info" v-if="user">
<p>邮箱: {{ user.email }}</p>
<p>角色: {{ user.role }}</p>
</div>
<button @click="logout">退出登录</button>
<hr />
<div class="cart-summary">
<p>购物车共 {{ totalItems }} 件商品</p>
<p>总价: ¥{{ totalPrice.toFixed(2) }}</p>
<button v-if="!isEmpty" @click="clearCart">清空购物车</button>
</div>
</div>
</template>
7.4 Store 持久化
// stores/plugins/persist.ts
import { watch } from 'pinia'
import type { PiniaPluginContext } from 'pinia'
interface PersistOptions {
key?: string
storage?: Storage
paths?: string[]
}
declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
persist?: boolean | PersistOptions
}
}
export function persistPlugin({ store, options }: PiniaPluginContext) {
const persistOptions = options.persist
if (!persistOptions) return
const config = typeof persistOptions === 'boolean'
? { key: store.$id }
: { key: store.$id, ...persistOptions }
const { key, storage = localStorage, paths } = config
// 恢复状态
const savedState = storage.getItem(key)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// 监听变化并保存
watch(
() => store.$state,
(state) => {
const toSave = paths
? paths.reduce((acc, path) => ({ ...acc, [path]: state[path] }), {})
: state
storage.setItem(key, JSON.stringify(toSave))
},
{ deep: true }
)
}
// stores/index.ts
import { createPinia } from 'pinia'
import { persistPlugin } from './plugins/persist'
export const pinia = createPinia()
pinia.use(persistPlugin)
export { useUserStore } from './user'
export { useCartStore } from './cart'
// 使用持久化
export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(null)
// ...
return { token }
}, {
persist: {
key: 'user-store',
paths: ['token', 'user'] // 只持久化这些字段
}
})
7.5 Store 间交互
// stores/order.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useCartStore } from './cart'
export const useOrderStore = defineStore('order', () => {
const userStore = useUserStore()
const cartStore = useCartStore()
const orders = ref<Order[]>([])
const createOrder = async () => {
if (!userStore.isLoggedIn) {
throw new Error('请先登录')
}
const order: Order = {
id: generateId(),
userId: userStore.user!.id,
items: [...cartStore.items],
total: cartStore.totalPrice,
status: 'pending',
createdAt: new Date().toISOString()
}
orders.push(order)
cartStore.clearCart()
return order
}
return { orders, createOrder }
})
8. 组件化开发进阶
8.1 插槽(Slots)
<!-- BaseCard.vue -->
<script setup lang="ts">
defineProps<{
title?: string
shadow?: 'none' | 'sm' | 'md' | 'lg'
}>()
</script>
<template>
<div class="card" :class="`shadow-${shadow || 'md'}`">
<div v-if="$slots.header || title" class="card-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
</div>
<div class="card-body">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<!-- 使用插槽 -->
<BaseCard title="用户信息">
<!-- 默认插槽 -->
<p>用户名:张三</p>
<p>邮箱:zhang@example.com</p>
<!-- 具名插槽 -->
<template #footer>
<button>编辑</button>
<button>删除</button>
</template>
</BaseCard>
<!-- 作用域插槽 -->
<!-- BaseList.vue -->
<script setup lang="ts">
defineProps<{
items: string[]
}>()
defineEmits<{
(e: 'select', item: string): void
}>()
</script>
<template>
<ul>
<li
v-for="(item, index) in items"
:key="index"
@click="$emit('select', item)"
>
<!-- 作用域插槽:传递数据给父组件 -->
<slot :item="item" :index="index" />
</li>
</ul>
</template>
<!-- 使用作用域插槽 -->
<BaseList :items="['苹果', '香蕉', '橙子']" @select="handleSelect">
<template #default="{ item, index }">
<span class="item">{{ index + 1 }}. {{ item }}</span>
</template>
</BaseList>
8.2 Teleport 传送门
<!-- Modal.vue -->
<script setup lang="ts">
import { Teleport } from 'vue'
defineProps<{
show: boolean
}>()
defineEmits<{
(e: 'close'): void
}>()
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="$emit('close')">
<div class="modal-content">
<slot />
<button @click="$emit('close')">关闭</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
}
/* 过渡动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
8.3 Suspense 异步组件
<!-- AsyncComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
// 异步加载的数据
const data = await fetchData()
const result = ref(data)
</script>
<template>
<div>{{ result }}</div>
</template>
<!-- 使用 Suspense -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import ErrorBoundary from '@/components/ErrorBoundary.vue'
const AsyncUserList = defineAsyncComponent(() =>
import('./UserList.vue')
)
</script>
<template>
<Suspense>
<template #default>
<AsyncUserList />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
<!-- 带错误处理 -->
<ErrorBoundary>
<Suspense>
<template #default>
<AsyncUserList />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</ErrorBoundary>
</template>
8.4 自定义指令
// directives/focus.ts
import type { Directive, DirectiveBinding } from 'vue'
export const vFocus: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
// 聚焦
el.focus()
// 支持 v-focus:method.arg
if (binding.arg === 'method') {
binding.value(el)
}
}
}
// v-permission 指令示例
export const vPermission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const userStore = useUserStore()
const requiredPermission = binding.value
if (!userStore.hasPermission(requiredPermission)) {
el.style.display = 'none'
// 或者移除元素
// el.remove()
}
}
}
// main.ts 注册全局指令
import { createApp } from 'vue'
import { vFocus, vPermission } from '@/directives'
const app = createApp(App)
app.directive('focus', vFocus)
app.directive('permission', vPermission)
app.mount('#app')
<!-- 使用自定义指令 -->
<script setup lang="ts">
// 局部注册
import { vFocus } from '@/directives/focus'
</script>
<template>
<input v-focus />
<button v-permission="'admin'">管理面板</button>
</template>
9. 生命周期与Hooks
9.1 生命周期图解
┌─────────────────────────────────────────────────────────────────┐
│ Vue 3 生命周期钩子 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 创建阶段 (Creation) │
│ ────────────────── │
│ beforeCreate ──→ setup() ──→ created │
│ ↑ │
│ setup() 是 Composition API 的入口 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 挂载阶段 (Mounting) │
│ ────────────────── │
│ onBeforeMount ──→ 编译模板 → 生成虚拟DOM ──→ onMounted │
│ │
│ onBeforeMount : DOM 即将挂载,可访问 this │
│ onMounted : DOM 已挂载,可进行 DOM 操作 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 更新阶段 (Updating) │
│ ────────────────── │
│ onBeforeUpdate ──→ 虚拟DOM对比(Patch) ──→ onUpdated │
│ │
│ onBeforeUpdate : 数据变化后,DOM 更新前 │
│ onUpdated : DOM 更新完成 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 卸载阶段 (Unmounting) │
│ ──────────────────── │
│ onBeforeUnmount ──→ 卸载组件 ──→ onUnmounted │
│ │
│ onBeforeUnmount : 实例仍可用,可清理定时器/事件监听 │
│ onUnmounted : 完全卸载,清理完成 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 错误处理 │
│ ────────── │
│ onErrorCaptured : 子组件错误时触发 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 调试钩子 (Dev Only) │
│ ───────────────── │
│ onRenderTracked : 响应式依赖被追踪时 │
│ onRenderTriggered : 响应式依赖触发更新时 │
│ │
└─────────────────────────────────────────────────────────────────┘
9.2 组合式 API 生命周期钩子
import {
onMounted,
onBeforeMount,
onUpdated,
onBeforeUpdate,
onUnmounted,
onBeforeUnmount,
onErrorCaptured,
onRenderTracked,
onRenderTriggered
} from 'vue'
export default {
setup() {
// ===== 挂载阶段 =====
onBeforeMount(() => {
console.log('组件即将挂载')
})
onMounted(() => {
console.log('组件已挂载')
// 获取 DOM
const el = document.querySelector('.my-element')
// 初始化第三方库
// initChart(el)
})
// ===== 更新阶段 =====
onBeforeUpdate(() => {
console.log('组件即将更新')
})
onUpdated(() => {
console.log('组件已更新')
})
// ===== 卸载阶段 =====
onBeforeUnmount(() => {
console.log('组件即将卸载')
// 清理定时器
clearInterval(timerId)
// 移除事件监听
window.removeEventListener('resize', handleResize)
})
onUnmounted(() => {
console.log('组件已卸载')
})
// ===== 错误处理 =====
onErrorCaptured((err, instance, info) => {
console.error('捕获到错误:', err)
console.error('错误信息:', info)
return false // 阻止错误传播
})
// ===== 调试钩子 (开发模式) =====
onRenderTracked((event) => {
console.log('依赖被追踪:', event)
// event: { target, type, key, newValue, oldValue }
})
onRenderTriggered((event) => {
console.log('依赖触发更新:', event)
})
return {}
}
}
9.3 常用 Hooks 封装
// composables/useEventListener.ts
import { onMounted, onUnmounted, Ref } from 'vue'
export function useEventListener(
target: Window | HTMLElement | Ref<Window | HTMLElement | null>,
event: string,
handler: EventListener
) {
const addEvent = () => {
const el = unref(target)
el?.addEventListener(event, handler)
}
const removeEvent = () => {
const el = unref(target)
el?.removeEventListener(event, handler)
}
onMounted(addEvent)
onUnmounted(removeEvent)
}
// composables/useInterval.ts
import { ref, onUnmounted } from 'vue'
export function useInterval(callback: () => void, delay: number) {
const intervalId = ref<number | null>(null)
const start = () => {
if (intervalId.value === null) {
intervalId.value = window.setInterval(callback, delay)
}
}
const stop = () => {
if (intervalId.value !== null) {
window.clearInterval(intervalId.value)
intervalId.value = null
}
}
onUnmounted(stop)
return { start, stop }
}
// composables/useResizeObserver.ts
import { ref, onMounted, onUnmounted, watch, Ref } from 'vue'
import type { ResizeObserverEntry } from '@vueuse/core'
export function useResizeObserver(
target: Ref<HTMLElement | null>,
callback: (entries: ResizeObserverEntry[]) => void
) {
let observer: ResizeObserver | null = null
const observe = () => {
if (target.value && observer) {
observer.observe(target.value)
}
}
const disconnect = () => {
observer?.disconnect()
}
onMounted(() => {
observer = new ResizeObserver(callback)
observe()
})
onUnmounted(disconnect)
watch(target, (newTarget, oldTarget) => {
if (oldTarget) {
observer?.unobserve(oldTarget)
}
if (newTarget) {
observer?.observe(newTarget)
}
})
}
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue'
export function useIntersectionObserver(
target: Ref<HTMLElement | null>,
options: IntersectionObserverInit = {}
) {
const isIntersecting = ref(false)
const intersectionRatio = ref(0)
let observer: IntersectionObserver | null = null
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
const entry = entries[0]
isIntersecting.value = entry.isIntersecting
intersectionRatio.value = entry.intersectionRatio
}
onMounted(() => {
observer = new IntersectionObserver(handleIntersect, options)
if (target.value) {
observer.observe(target.value)
}
})
onUnmounted(() => {
observer?.disconnect()
})
return { isIntersecting, intersectionRatio }
}
10. Composition API vs Options API 对比
10.1 代码组织对比
<!-- Options API 写法 -->
<script>
export default {
data() {
return {
count: 0,
message: 'Hello',
users: [],
loading: false
}
},
computed: {
doubledCount() {
return this.count * 2
},
filteredUsers() {
return this.users.filter(u => u.active)
}
},
methods: {
increment() {
this.count++
},
async fetchUsers() {
this.loading = true
try {
const res = await fetch('/api/users')
this.users = await res.json()
} finally {
this.loading = false
}
}
},
watch: {
count(newVal) {
console.log('count 变化:', newVal)
}
},
mounted() {
this.fetchUsers()
}
}
</script>
<!-- Composition API 写法 (<script setup>) -->
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
// 相关逻辑放在一起
const count = ref(0)
const doubledCount = computed(() => count.value * 2)
const increment = () => count.value++
// 用户相关逻辑独立成块
const users = ref<User[]>([])
const loading = ref(false)
const filteredUsers = computed(() => users.value.filter(u => u.active))
const fetchUsers = async () => {
loading.value = true
try {
const res = await fetch('/api/users')
users.value = await res.json()
} finally {
loading.value = false
}
}
// 生命周期钩子
watch(count, (newVal) => {
console.log('count 变化:', newVal)
})
onMounted(() => {
fetchUsers()
})
</script>
10.2 逻辑复用对比
// Options API - Mixins
// mixins/counterMixin.js
export const counterMixin = {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
}
}
// 使用
export default {
mixins: [counterMixin]
// 可能存在命名冲突、来源不清晰的问题
}
// Composition API - Composables
// composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
return { count, increment }
}
// 使用
<script setup>
import { useCounter } from '@/composables/useCounter'
// 每个组件都是独立的实例
const { count, increment } = useCounter(10)
const { count: cartCount } = useCounter(0)
// 来源清晰,可读性强
</script>
10.3 性能对比
场景 Options API Composition API 说明 首屏加载 正常 略优 Composition API 减少约 10% 包体积 响应式性能 相同 相同 底层都是 Proxy 大列表渲染 正常 更优 可精确控制更新范围 代码分割 需整体引入 可按需导入 hooks Tree-shaking 更友好 TypeScript 支持 一般 优秀 更好的类型推断
11. 实战技巧与最佳实践
11.1 响应式数据最佳实践
// ❌ 避免:将普通对象直接赋值给 ref
const obj = { name: '张三', age: 25 }
const state = ref(obj)
obj.age = 30 // 不会触发更新!
// ✅ 正确:使用 reactive 或 .value
const state = ref({ name: '张三', age: 25 })
state.value.age = 30
// 或使用 reactive
const state = reactive(obj)
state.age = 30 // 响应式更新
// ❌ 避免:解构响应式对象丢失响应式
const { name, age } = state
// ✅ 正确:使用 toRefs
const { name, age } = toRefs(state)
// ❌ 避免:数组索引直接赋值
items.value[0] = newItem // 不响应!
// ✅ 正确:使用 splice 或整个替换
items.value.splice(0, 1, newItem)
// 或
items.value = [...items.value.slice(0, 0), newItem, ...items.value.slice(1)]
// ❌ 避免:给响应式对象的属性重新赋值整个对象
state.user = { name: '李四' } // 不响应
// ✅ 正确:使用 Object.assign
Object.assign(state.user, { name: '李四' })
11.2 性能优化技巧
// 1. 使用 shallowRef 优化大对象
import { shallowRef, triggerRef } from 'vue'
// 适用于数据量大的场景
const bigData = shallowRef<DataType>([])
const updateBigData = async () => {
bigData.value = await fetchLargeData()
triggerRef(bigData) // 手动触发更新
}
// 2. 使用 markRaw 标记不需要响应式的数据
import { reactive, markRaw } from 'vue'
const chart = markRaw(new ChartInstance())
const state = reactive({
data: [1, 2, 3],
chart: chart // 不会被代理
})
// 3. 使用 readonly 保护状态
import { readonly, reactive } from 'vue'
const state = reactive({
count: 0
})
const readonlyState = readonly(state)
// 4. 合理使用 watchEffect 的 flush 选项
watchEffect(() => {
// 默认 'pre' - 组件更新前
}, { flush: 'post' }) // 组件更新后执行
// 5. 使用 v-memo 优化列表渲染
<div v-for="item in items" :key="item.id" v-memo="[item.id, item.name]">
<!-- 只有 item.id 或 item.name 变化时才更新 -->
</div>
11.3 TypeScript 最佳实践
// 1. 善用泛型约束
function fetchData<T extends { id: string | number }>(id: T['id']): Promise<T> {
return fetch(`/api/${id}`).then(res => res.json())
}
// 2. 使用 satisfies 运算符(TypeScript 4.9+)
const config = {
db: { host: 'localhost', port: 5432 },
cache: { maxAge: 3600 }
} satisfies Config
// 3. 组件 Props 类型
interface Props {
// 使用泛型
items: string[]
// 使用联合类型
variant: 'primary' | 'secondary' | 'danger'
// 使用函数类型
onClick: (e: MouseEvent) => void
// 使用可选链
user?: { name: string }
}
// 4. 暴露类型给父组件
import type { ExtractProps, ExtractEmits } from 'vue'
type ButtonProps = ExtractProps<typeof import('./Button.vue')>
type ButtonEmits = ExtractEmits<typeof import('./Button.vue')>
11.4 组件设计模式
// 1. 展示组件 vs 容器组件
// PresentationalComponent.vue - 只负责展示
<script setup lang="ts">
defineProps<{
title: string
content: string
}>()
</script>
// ContainerComponent.vue - 负责数据和逻辑
<script setup lang="ts">
import { ref } from 'vue'
import PresentationalComponent from './PresentationalComponent.vue'
const data = ref({ title: '标题', content: '内容' })
</script>
<template>
<PresentationalComponent
:title="data.title"
:content="data.content"
/>
</template>
// 2. 高阶组件模式 (Render Props)
interface Props {
render: (scope: { items: string[] }) => any
}
defineProps<Props>()
// 3. 依赖注入模式
// provide/inject + symbol key
const STORAGE_KEY = Symbol('storage')
// 在父组件
import { provide } from 'vue'
provide(STORAGE_KEY, {
get: () => localStorage.getItem('key'),
set: (val: string) => localStorage.setItem('key', val)
})
// 在子组件
import { inject } from 'vue'
const storage = inject(STORAGE_KEY)
12. 性能优化策略
12.1 首屏加载优化
// vite.config.ts
export default defineConfig({
build: {
// 代码分割
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'vue-router', 'pinia'],
'element-plus': ['element-plus']
}
}
},
// 压缩配置
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
},
// 预加载
preload: {
include: ['src/**/*.ts', 'src/**/*.vue']
}
})
<!-- index.html - 预连接关键资源 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://api.example.com">
12.2 路由懒加载
// router/index.ts
const routes = [
// 使用动态导入
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
},
{
path: '/dashboard',
component: () => import(/* webpackChunkName: "dashboard", webpackPrefetch: true */ '@/views/Dashboard.vue')
}
]
12.3 虚拟列表
<!-- VirtualList.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
items: any[]
itemHeight: number
visibleCount?: number
}>()
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
const containerHeight = ref(0)
const visibleCount = computed(() =>
props.visibleCount || Math.ceil(containerHeight.value / props.itemHeight) + 2
)
const startIndex = computed(() =>
Math.floor(scrollTop.value / props.itemHeight)
)
const visibleItems = computed(() =>
props.items.slice(startIndex.value, startIndex.value + visibleCount.value)
)
const offsetY = computed(() =>
startIndex.value * props.itemHeight
)
const totalHeight = computed(() =>
props.items.length * props.itemHeight
)
const handleScroll = (e: Event) => {
scrollTop.value = (e.target as HTMLElement).scrollTop
}
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight
}
})
</script>
<template>
<div
ref="containerRef"
class="virtual-list"
@scroll="handleScroll"
>
<div class="virtual-list-spacer" :style="{ height: totalHeight + 'px' }">
<div
class="virtual-list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="(item, index) in visibleItems"
:key="item.id"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="startIndex + index" />
</div>
</div>
</div>
</div>
</template>
12.4 依赖按需加载
// 完整导入
import { ref, computed, watch, onMounted } from 'vue'
// 按需导入 - 更好的 Tree-shaking
import ref from 'vue'
import computed from 'vue'
// 或使用自动导入插件
12.5 图片优化
<!-- ImageWithLazyLoad.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{
src: string
alt: string
placeholder?: string
}>()
const imageRef = ref<HTMLImageElement>()
const isLoaded = ref(false)
const error = ref(false)
const observer = ref<IntersectionObserver | null>(null)
onMounted(() => {
observer.value = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage()
observer.value?.unobserve(entry.target)
}
})
})
if (imageRef.value) {
observer.value.observe(imageRef.value)
}
})
const loadImage = () => {
const img = new Image()
img.onload = () => {
isLoaded.value = true
}
img.onerror = () => {
error.value = true
}
img.src = props.src
}
</script>
<template>
<div ref="imageRef" class="image-wrapper">
<img
v-if="isLoaded && !error"
:src="src"
:alt="alt"
class="loaded"
/>
<img
v-else-if="placeholder"
:src="placeholder"
class="placeholder"
/>
<div v-else class="loading"></div>
</div>
</template>
13. 常见问题与排错
13.1 响应式问题
问题现象 原因 解决方案 修改数据视图不更新 数组/对象直接用索引赋值 使用 splice() 或完整替换 解构后数据不响应 丢失响应式代理 使用 toRefs 包装 深层对象不响应 对象属性重新赋值 使用 reactive 或 Object.assign props 修改报错 props 本身只读 在组件内部创建本地副本
// 排错命令
import { isRef, isReactive, isProxy } from 'vue'
console.log(isRef(count)) // 检查是否为 ref
console.log(isReactive(state)) // 检查是否为 reactive
console.log(isProxy(state)) // 检查是否为代理对象
13.2 TypeScript 问题
问题 解决方案 defineProps 类型错误确保使用 TypeScript 3.8+,使用 withDefaults 提供默认值 defineEmits 类型推断失败显式声明 emits 类型 组件实例类型 使用 InstanceType<typeof Component> template ref 类型 使用泛型 <HTMLElement>
// 常见 TS 错误修复
// 错误:Cannot find name 'User'
// 解决:确保导入类型
import type { User } from '@/types'
// 错误:Property 'xxx' does not exist
// 解决:添加类型断言或使用 satisfies
const data = res.json() as User[]
// 错误:Type 'Ref<string>' is not assignable to type 'string'
// 解决:在模板外使用 .value
13.3 性能问题排查
// 使用 Vue DevTools Profiler
// 1. 安装 Vue DevTools 浏览器扩展
// 2. 打开开发者工具 → Vue 面板
// 3. 点击 Performance 录制
// 添加调试标记
import { devtools } from 'vue'
devtools.emit('custom-inspector-action', { name: 'myAction' })
// 检查不必要的重新渲染
import { renderTracked, renderTriggered } from 'vue'
watch(someRef, () => {
console.log('重新渲染')
}, { onTrigger: (e) => console.log('触发:', e) })
13.4 常见报错与解决
错误信息 原因 解决方案 Cannot read property of undefined访问 undefined 属性 使用可选链 ?. Promise is not defined异步在同步上下文中 确保 await/async 正确使用 Module not found路径别名未配置 检查 vite/tsconfig 配置 Hydration completed but contains mismatchesSSR 与客户端不一致 检查初始数据生成 Too many re-renders渲染中修改状态 使用防抖/节流,避免循环更新
// 调试工具
import { dumpRef, dumpReactive } from 'vue'
// 打印 ref 值
console.log('count:', dumpRef(countRef))
// 打印 reactive 对象
console.log('state:', dumpReactive(stateReactive))
14. 从 Vue 2 迁移指南
14.1 主要变化速查
Vue 2 Vue 3 说明 Vue.setProxy 直接赋值即可 Vue.deletedelete使用原生 delete $childrenuseTemplateRef获取子组件引用 filters函数调用 移除,推荐方法或计算属性 mixinsComposables 更清晰的复用方式 $listeners$attrs合并到 $attrs scopedSlotsv-slot统一插槽语法
14.2 Vue 2 迁移示例
// Vue 2 写法
export default {
data() {
return {
message: 'Hello'
}
},
computed: {
upperMessage() {
return this.message.toUpperCase()
}
},
methods: {
greet() {
this.message = 'Hi'
}
},
mounted() {
console.log('mounted')
}
}
// Vue 3 写法
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const message = ref('Hello')
const upperMessage = computed(() => message.value.toUpperCase())
const greet = () => {
message.value = 'Hi'
}
onMounted(() => {
console.log('mounted')
})
</script>
14.3 迁移工具
# 使用 Vue 2to3 迁移工具
npm install -g @vue-migration/vue-codemod
# 运行迁移
npx vue-codemod src/**/*.{vue,js,ts}
# 或者使用 Volar 的 Vue Language Features
# 在 VS Code 中安装 Volar 扩展获得更好的 Vue 3 支持
14.4 兼容性配置
// vite.config.ts
export default defineConfig({
plugins: [
vue({
// 启用 Vue 3 兼容性模式
compatibility: {
COMPAT: true
}
})
]
})
// 全局配置兼容 Vue 2 API
import Vue from 'vue'
import VueCompositionAPI from '@vue/composition-api'
Vue.use(VueCompositionAPI)
15. 附录:快速命令速查表
15.1 项目创建命令
# 创建项目
npm create vue@latest my-app # 完整项目(推荐)
npm create vite@latest my-app -- --template vue-ts # 基础模板
# 安装依赖
npm install
npm install vue-router@4 pinia
npm install -D @vue/tsconfig
15.2 常用脚本
# 开发
npm run dev # 启动开发服务器
npm run dev -- --port 3000 # 指定端口
# 构建
npm run build # 生产构建
npm run build -- --mode staging # 指定环境
# 代码检查
npm run lint # ESLint 检查
npm run lint:fix # 自动修复
npm run type-check # TypeScript 类型检查
# 预览
npm run preview # 预览生产构建
15.3 Vue 3 Composition API 速查
// 响应式
ref() // 基本类型响应式
reactive() // 对象响应式
toRef() // 提取响应式属性
toRefs() // 解构保持响应式
readonly() // 只读代理
shallowRef() // 浅层响应式
// 计算
computed() // 计算属性
watchEffect() // 立即执行并收集依赖
watch() // 精确监听
// 生命周期
onMounted() // 挂载完成
onUpdated() // 更新完成
onUnmounted() // 卸载完成
onBeforeMount() // 挂载前
onBeforeUpdate() // 更新前
onBeforeUnmount() // 卸载前
onErrorCaptured() // 错误捕获
// 工具
nextTick() // 等待更新完成
isRef() // 检查 ref
isReactive() // 检查 reactive
isProxy() // 检查代理
isReadonly() // 检查只读
// 组件
defineProps() // 定义 props
defineEmits() // 定义 emits
defineExpose() // 暴露方法
useSlots() // 使用插槽
useAttrs() // 使用 attrs
15.4 VS Code 推荐配置
// .vscode/extensions.json
{
"recommendations": [
"vue.volar",
"vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
// .vscode/settings.json
{
"editor.formatOnSave": true,
"[vue]": {
"editor.defaultFormatter": "Vue.volar"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"vue.features.templatesLanguageServer": true
}
暂无评论内容