从 Vue 2 到 Vue 3:一位前端工程师的实战学习笔记
前言
Vue 3 正式发布已经两年多了,很多团队已经完成了从 Vue 2 到 Vue 3 的迁移。作为一个在 Vue 2 上写了三年业务代码的前端工程师,我花了两个月系统学习了 Vue 3 的核心变化,边学边用,踩了不少坑,也收获了很多。
这篇文章是我个人的学习总结,不是官方文档的翻译,而是从实战角度出发梳理 Vue 3 的关键变化和最佳实践。希望能帮到正在学习 Vue 3 的朋友。
一、Composition API:不只是语法糖
为什么需要 Composition API?
Vue 2 的 Options API 在小项目中很好用,但随着组件复杂度上升,你会发现一个问题:同一个逻辑的代码被拆散在不同的 Options 里。
比如一个搜索组件,data 里定义搜索关键词和结果列表,methods 里写搜索方法,watch 里监听输入变化,computed 里处理搜索结果的过滤。一个功能横跨四个配置项,组件大了之后非常难维护。
Composition API 允许你按功能组织代码,而不是按选项类型:
vue
<script setup>
import { ref, computed, watch } from 'vue'
// 搜索功能 - 所有相关代码在一起
const searchQuery = ref('')
const searchResults = ref([])
const filteredResults = computed(() =>
searchResults.value.filter(item => item.active)
)
async function doSearch() {
searchResults.value = await fetch(`/api/search?q=${searchQuery.value}`)
}
watch(searchQuery, (val) => {
if (val.length > 2) doSearch()
})
</script>
setup 函数 vs <script setup>
Vue 3.2 引入了 <script setup> 语法糖,它让 Composition API 的写法更简洁:
vue
<!-- ❌ 旧写法:setup 函数需要 return -->
<script>
import { ref } from 'vue'
export default {
setup() {
const count = ref(0)
return { count }
}
}
</script>
<!-- ✅ 新写法:<script setup> 自动暴露 -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
建议:所有新项目直接用 <script setup>,简洁高效。
ref 和 reactive 怎么选?
这是初学者最容易纠结的问题。我的实践建议:
ref:基础类型 + 需要重新赋值的引用类型(value = newValue)reactive:深层嵌套的对象,不需要整体替换
vue
<script setup>
import { ref, reactive } from 'vue'
// ref - 基础类型
const count = ref(0)
// ref - 引用类型,需要整体替换时
const user = ref(null)
function updateUser(data) {
user.value = data // 整体替换
}
// reactive - 深层对象,不需要整体替换
const form = reactive({
name: '',
email: '',
address: {
city: '',
street: ''
}
})
function resetForm() {
// reactive 不能直接 = {} 赋值
Object.assign(form, { name: '', email: '', address: { city: '', street: '' } })
}
</script>
核心原则: 当你需要重新赋值时用 ref;当你需要深层响应式且不整体替换时用 reactive。更多情况下我用 ref,因为它更灵活。
二、响应式进阶
shallowRef 和 triggerRef
当你有一个大型不可变数据 (比如从 API 获取的列表),每次更新都触发完整的响应式依赖追踪会有性能开销。shallowRef 只追踪 .value 的变化,内部数据变化需要手动触发更新:
vue
<script setup>
import { shallowRef, triggerRef } from 'vue'
const largeList = shallowRef([
{ id: 1, name: 'Item 1', selected: false },
// ... 几百条数据
])
function toggleItem(id) {
const item = largeList.value.find(i => i.id === id)
if (item) {
item.selected = !item.selected
triggerRef(largeList) // 手动触发更新
}
}
</script>
computed 的最佳实践
computed 支持 get 和 set,但不建议滥用 set。复杂逻辑用 methods + ref 更清晰:
vue
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// ✅ 好:只读 computed
const fullName = computed(() => `${firstName.value}${lastName.value}`)
// ⚠️ 可接受:带 set 的 computed
const displayName = computed({
get: () => `${firstName.value}${lastName.value}`,
set: (val) => {
[firstName.value, lastName.value] = val.split('')
}
})
// ❌ 不好:setter 逻辑太复杂
// 不如直接用 methods
</script>
watch 和 watchEffect
watch 监听特定数据变化,watchEffect 自动追踪依赖:
vue
<script setup>
import { ref, watch, watchEffect } from 'vue'
const keyword = ref('')
const page = ref(1)
// watch:监听特定源,需要旧值对比
watch(keyword, (newVal, oldVal) => {
if (newVal !== oldVal) {
page.value = 1 // 搜索词变了,重置页码
}
})
// watchEffect:自动收集依赖,立即执行
watchEffect(() => {
// 这里用到的所有 ref 都会被追踪
console.log(`搜索:${keyword.value},第 ${page.value} 页`)
})
</script>
选择指南:
- 需要旧值 →
watch - 需要延迟执行(
{ immediate: false })→watch - 不需要指定依赖,自动收集 →
watchEffect
三、组件通信
defineProps 和 defineEmits
<script setup> 中无需 import,直接使用:
vue
<!-- Child.vue -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
})
const emit = defineEmits(['update', 'delete'])
function handleClick() {
emit('update', props.count + 1)
}
</script>
<template>
<div @click="handleClick">
{{ title }}: {{ count }}
</div>
</template>
v-model 新语法
Vue 3 支持多个 v-model:
vue
<!-- Parent.vue -->
<Child
v-model:title="title"
v-model:content="content"
/>
<!-- Child.vue -->
<script setup>
defineProps({ title: String, content: String })
defineEmits(['update:title', 'update:content'])
</script>
provide / inject 的响应式
跨层级通信时,最好传 ref 或 reactive,而不是直接传值:
vue
<!-- 祖先组件 -->
<script setup>
import { ref, provide } from 'vue'
const theme = ref('light')
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>
<!-- 子孙组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>
<template>
<div :class="theme">
<button @click="toggleTheme">切换主题</button>
</div>
</template>
四、Teleport 和 Suspense
Teleport
适合弹窗、Toast、下拉菜单等需要挂载到 DOM 特定位置的情况:
vue
<template>
<button @click="showModal = true">打开弹窗</button>
<Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal-content">
<slot />
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
</template>
Suspense(实验性)
虽然还没有正式稳定,但已经在很多项目中使用。适合处理异步组件:
vue
<template>
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
五、TypeScript 集成
Vue 3 对 TypeScript 的支持是一等公民。推荐所有新项目使用 TypeScript:
vue
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
const users = ref<User[]>([])
const userMap = reactive<Map<number, User>>(new Map())
async function fetchUsers() {
users.value = await $fetch<User[]>('/api/users')
}
// 给 props 定义类型
const props = defineProps<{
title: string
items?: string[]
onSelect?: (id: number) => void
}>()
// emit 类型化
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'delete', ids: number[]): void
}>()
</script>
六、性能优化实战
1. 合理使用 v-memo
v-memo 可以避免不必要的虚拟 DOM 比较,适合大型列表:
vue
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.updatedAt]">
{{ item.content }}
</div>
</template>
2. 函数式组件
对于纯展示组件,使用函数式组件减少开销:
vue
<template functional>
<div class="badge" :class="`badge--${type}`">
<slot />
</div>
</template>
3. 使用 defineAsyncComponent 做代码分割
vue
<script setup>
import { defineAsyncComponent } from 'vue'
const HeavyComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
)
</script>
<template>
<HeavyComponent />
</template>
七、常见迁移踩坑
1. v-model 默认值的变更
Vue 3 中 v-model 默认使用 modelValue 和 update:modelValue,Vue 2 的 .sync 修饰符被合并到 v-model。
2. filters 被移除
Vue 3 不再支持 filters,改用 computed 或 methods:
vue
// ❌ Vue 2
{{ price | formatCurrency }}
// ✅ Vue 3
{{ formatCurrency(price) }}
3. $listeners 被移除
Vue 3 中 $attrs 包含了属性和监听器:
vue
// ❌ Vue 2
v-bind="$attrs" v-on="$listeners"
// ✅ Vue 3
v-bind="$attrs"
4. 自定义指令生命周期变化
指令钩子名称和组件生命周期对齐:
js
// Vue 2
directives: {
focus: { inserted(el) { el.focus() } }
}
// Vue 3
directives: {
focus: { mounted(el) { el.focus() } }
}
八、项目推荐实践
基于我这几个月的实践,推荐以下技术栈组合:
dart
Vue 3 + Vite + TypeScript
├── 路由 → Vue Router 4
├── 状态管理 → Pinia(替代 Vuex)
├── UI 组件 → Element Plus / Naive UI
├── 请求 → Axios + Vue Request
├── 测试 → Vitest + Vue Test Utils
└── 构建 → Vite + @vitejs/plugin-vue
Pinia 比起 Vuex 的优势:
- 完整的 TypeScript 支持,无需额外类型包装
- 没有 mutations,只有 actions,少一层概念
- 支持 Composition API 风格的 store
- 更轻量(~1KB)
总结
Vue 3 的学习曲线确实比 Vue 2 陡一些,主要是 Composition API 和 <script setup> 的思维方式需要适应。但一旦上手,你会发现代码的组织性和可维护性提升了一个档次。
对于正在学习的朋友,我的建议是:
- 先学 Composition API,这是 Vue 3 的核心
- 用
<script setup>写组件,生产级体验 - 配上 TypeScript,Vue 3 的 TS 支持真的很好
- 迁移时不要贪快,一个组件一个组件改
- 多读优秀开源项目的源码,比如 Naive UI、Element Plus
希望这篇文章对你的 Vue 3 学习之路有帮助。如果有任何问题,欢迎在评论区交流!