同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、为什么要写这篇文章?
Vue3 已经是官方默认推荐版本,但很多团队的存量项目仍然在 Vue2 上跑。即便你已经开始用 Vue3 了,也很可能是"Options API 的写法 + <script setup> 的壳"——形式换了,思维没换。
这篇文章不讲玄学的底层原理,只讲一个核心问题:
日常写代码到底该怎么选、为什么这么选、踩坑会踩在哪?
我们会把 Vue2 的 data / props / computed / methods / watch / 生命周期 和 Vue3 的 Composition API 做一次逐项对照,每一项都给出完整的代码示例和踩坑说明。
二、先建立一个全局视角:Options API vs Composition API
在动手对比之前,先花 30 秒看一张对照表,心里有个全貌:
| 关注点 |
Vue2(Options API) |
Vue3(Composition API / <script setup>) |
| 响应式数据 |
data() |
ref() / reactive()
|
| 接收外部参数 |
props 选项 |
defineProps() |
| 计算属性 |
computed 选项 |
computed() 函数 |
| 方法 |
methods 选项 |
普通函数声明 |
| 侦听器 |
watch 选项 |
watch() / watchEffect()
|
| 生命周期 |
created / mounted … |
onMounted / onUnmounted … |
| 模板访问 |
this.xxx |
直接用变量名(<script setup> 自动暴露) |
一句话总结:Vue2 按"选项类型"组织代码(数据放一块、方法放一块);Vue3 按"逻辑关注点"组织代码(一个功能的数据+方法+侦听可以放在一起)。
三、逐项对比 + 完整示例 + 踩坑点
3.1 响应式数据:data() → ref() / reactive()
Vue2 写法
<template>
<div>
<p>{{ count }}</p>
<p>{{ user.name }} - {{ user.age }}</p>
<button @click="add">+1</button>
</div>
</template>
<script>
export default {
data() {
return {
count: 0,
user: {
name: '张三',
age: 25
}
}
},
methods: {
add() {
this.count++
this.user.age++
}
}
}
</script>
Vue2 里一切都挂在 this 上,data() 返回的对象会被 Vue 内部用 Object.defineProperty 做递归劫持,所以你只要 this.count++,视图就会更新。简单粗暴,上手友好。
Vue3 写法(<script setup>)
<template>
<div>
<p>{{ count }}</p>
<p>{{ user.name }} - {{ user.age }}</p>
<button @click="add">+1</button>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
// 基本类型 → 用 ref
const count = ref(0)
// 对象类型 → 用 reactive
const user = reactive({
name: '张三',
age: 25
})
function add() {
count.value++ // ← 注意:ref 在 JS 里要 .value
user.age++ // ← reactive 对象不需要 .value
}
</script>
踩坑重灾区
坑 1:ref 的 .value 到底什么时候要加?
这是从 Vue2 转过来最高频的困惑,记住一个口诀:
模板里不加,JS 里要加。
<template>
<!-- 模板中直接用,Vue 会自动解包 -->
<p>{{ count }}</p>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
// JS 中必须 .value
console.log(count.value) // 0
count.value++
</script>
为什么模板里不用加?因为 Vue 的模板编译器遇到 ref 时会自动帮你插入 .value,这是编译期的语法糖。但在 <script> 里你是在写原生 JS,Vue 管不到,所以必须手动 .value。
坑 2:ref 和 reactive 到底选哪个?
这是社区吵了很久的问题。我的实战建议(也是 Vue 官方文档推荐的倾向):
| 场景 |
推荐 |
原因 |
| 基本类型(number / string / boolean) |
ref() |
reactive() 不支持基本类型 |
| 对象/数组,且不会被整体替换 |
reactive() |
不用到处写 .value,更清爽 |
| 对象/数组,但可能被整体替换 |
ref() |
reactive() 整体替换会丢失响应性 |
| 拿不准的时候 |
ref() |
全部用 ref 不会出错,reactive 有限制 |
坑 3:reactive 的解构陷阱 —— 这个真的会坑到你
<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '张三', age: 25 })
// ❌ 错误:解构后变量失去响应性!
let { name, age } = user
age++ // 视图不会更新,因为 age 现在只是一个普通的数字 25
// ✅ 正确做法1:不解构,直接用
user.age++
// ✅ 正确做法2:用 toRefs 解构
import { toRefs } from 'vue'
const { name: nameRef, age: ageRef } = toRefs(user)
ageRef.value++ // 视图会更新(注意变成了 ref,需要 .value)
</script>
为什么会这样?因为 reactive 的响应性是挂在对象的属性访问上的(基于 Proxy),一旦你把属性值解构出来赋给一个新变量,那个新变量只是一个普通的 JS 值,和原来的 Proxy 对象已经没有关系了。
坑 4:reactive 整体替换会丢失响应性
<script setup>
import { reactive, ref } from 'vue'
let state = reactive({ list: [1, 2, 3] })
// ❌ 错误:整体替换,模板拿到的还是旧的那个对象
state = reactive({ list: [4, 5, 6] })
// 此时模板绑定的引用还指向旧对象,视图不会更新
// ✅ 正确做法1:修改属性而不是替换对象
state.list = [4, 5, 6] // 这样是OK的
// ✅ 正确做法2:需要整体替换的场景,改用 ref
const state2 = ref({ list: [1, 2, 3] })
state2.value = { list: [4, 5, 6] } // 没问题,视图正常更新
</script>
这也是我建议"拿不准就用 ref"的原因——ref 不存在这个问题,因为你永远是通过 .value 赋值,Vue 能追踪到。
3.2 Props:props 选项 → defineProps()
Vue2 写法
<!-- 子组件 UserCard.vue -->
<template>
<div class="card">
<h3>{{ name }}</h3>
<p>年龄:{{ age }}</p>
<p>是否VIP:{{ isVip ? '是' : '否' }}</p>
</div>
</template>
<script>
export default {
props: {
name: {
type: String,
required: true
},
age: {
type: Number,
default: 18
},
isVip: {
type: Boolean,
default: false
}
},
mounted() {
// 通过 this 访问
console.log(this.name, this.age)
}
}
</script>
<!-- 父组件中使用 -->
<UserCard name="李四" :age="30" is-vip />
Vue3 写法(<script setup>)
<!-- 子组件 UserCard.vue -->
<template>
<div class="card">
<h3>{{ name }}</h3>
<p>年龄:{{ age }}</p>
<p>是否VIP:{{ isVip ? '是' : '否' }}</p>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
// defineProps 是编译器宏,不需要 import
const props = defineProps({
name: {
type: String,
required: true
},
age: {
type: Number,
default: 18
},
isVip: {
type: Boolean,
default: false
}
})
onMounted(() => {
// 不再有 this,直接用 props 对象
console.log(props.name, props.age)
})
</script>
如果你用 TypeScript,还可以用纯类型声明的写法,更加简洁:
<script setup lang="ts">
interface Props {
name: string
age?: number
isVip?: boolean
}
const props = withDefaults(defineProps<Props>(), {
age: 18,
isVip: false
})
</script>
踩坑重灾区
坑 1:defineProps 不需要 import,但 IDE 可能会报红
defineProps、defineEmits、defineExpose 这些都是编译器宏(compiler macro),在编译阶段就被处理掉了,运行时并不存在。所以不需要 import。
如果你的 ESLint 报 'defineProps' is not defined,那是 ESLint 配置问题,需要在 .eslintrc 里配置:
// .eslintrc.js
module.exports = {
env: {
'vue/setup-compiler-macros': true
}
}
或者升级到较新版本的 eslint-plugin-vue(v9+),它默认已经支持了。
坑 2:Props 解构也会丢失响应性(Vue 3.2 及以前)
<script setup>
const props = defineProps({ count: Number })
// ❌ Vue 3.2及以前:解构会丢失响应性
const { count } = props // count 变成普通值,父组件更新后这里不会变
// ✅ 保持响应性的做法
import { toRefs } from 'vue'
const { count: countRef } = toRefs(props)
// 或者直接用 props.count
</script>
好消息:Vue 3.5+ 引入了响应式 Props 解构(Reactive Props Destructure),如果你的项目版本够新,可以直接解构:
<script setup>
// Vue 3.5+ 可以直接解构,自动保持响应性
const { count = 0 } = defineProps({ count: Number })
// count 是响应式的,可以直接在模板中用
</script>
但如果你的项目还在 3.4 或更早版本上,老老实实用 props.count 或 toRefs 是最稳的。
3.3 Computed:computed 选项 → computed() 函数
Vue2 写法
<template>
<div>
<p>原价:{{ price }} 元</p>
<p>折后价:{{ discountedPrice }} 元</p>
<input v-model="fullName" />
</div>
</template>
<script>
export default {
data() {
return {
price: 100,
discount: 0.8,
firstName: '张',
lastName: '三'
}
},
computed: {
// 只读计算属性
discountedPrice() {
return (this.price * this.discount).toFixed(2)
},
// 可读可写计算属性
fullName: {
get() {
return this.firstName + this.lastName
},
set(val) {
// 假设第一个字是姓,后面是名
this.firstName = val.charAt(0)
this.lastName = val.slice(1)
}
}
}
}
</script>
Vue3 写法
<template>
<div>
<p>原价:{{ price }} 元</p>
<p>折后价:{{ discountedPrice }} 元</p>
<input v-model="fullName" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const discount = ref(0.8)
const firstName = ref('张')
const lastName = ref('三')
// 只读计算属性 —— 传一个 getter 函数
const discountedPrice = computed(() => {
return (price.value * discount.value).toFixed(2)
})
// 可读可写计算属性 —— 传一个对象
const fullName = computed({
get: () => firstName.value + lastName.value,
set: (val) => {
firstName.value = val.charAt(0)
lastName.value = val.slice(1)
}
})
</script>
踩坑重灾区
坑 1:computed 里千万别做"副作用"操作
这条 Vue2 和 Vue3 都一样,但很多人还是会犯:
// ❌ 错误示范:在 computed 里修改别的状态、发请求、操作 DOM
const total = computed(() => {
otherState.value = 'changed' // 副作用!
fetch('/api/log') // 副作用!
return items.value.reduce((sum, item) => sum + item.price, 0)
})
// ✅ computed 应该是纯函数,只根据依赖算出一个值
const total = computed(() => {
return items.value.reduce((sum, item) => sum + item.price, 0)
})
computed 的设计初衷就是"根据已有状态派生出新状态",它有缓存机制——只有依赖变了才重新计算。如果你往里面塞副作用,会导致不可预测的执行时机和执行次数。
坑 2:别把 computed 和 methods 搞混了
Vue2 老手可能觉得"computed 和 method 返回的值不是一样吗",但核心区别是缓存:
<script setup>
import { ref, computed } from 'vue'
const list = ref([1, 2, 3, 4, 5])
// computed:有缓存,list 不变就不会重新执行
const total = computed(() => {
console.log('computed 执行了')
return list.value.reduce((a, b) => a + b, 0)
})
// 普通函数:每次模板渲染都会重新执行
function getTotal() {
console.log('function 执行了')
return list.value.reduce((a, b) => a + b, 0)
}
</script>
<template>
<!-- 假设模板里用了3次 -->
<p>{{ total }} {{ total }} {{ total }}</p>
<!-- computed 只会打印1次 log,函数会打印3次 -->
<p>{{ getTotal() }} {{ getTotal() }} {{ getTotal() }}</p>
</template>
结论:需要缓存、依赖响应式数据派生值的用 computed;需要执行某个动作(点击事件等)的用普通函数。
3.4 Methods:methods 选项 → 普通函数
Vue2 写法
<template>
<div>
<p>{{ count }}</p>
<button @click="increment">+1</button>
<button @click="incrementBy(5)">+5</button>
<button @click="reset">重置</button>
</div>
</template>
<script>
export default {
data() {
return { count: 0 }
},
methods: {
increment() {
this.count++
},
incrementBy(n) {
this.count += n
},
reset() {
this.count = 0
this.logAction('reset') // 方法之间互相调用
},
logAction(action) {
console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
}
}
}
</script>
Vue2 的 methods 是一个选项对象,所有方法平铺在里面,互相调用要通过 this。
Vue3 写法
<template>
<div>
<p>{{ count }}</p>
<button @click="increment">+1</button>
<button @click="incrementBy(5)">+5</button>
<button @click="reset">重置</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
function incrementBy(n) {
count.value += n
}
function logAction(action) {
console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
}
function reset() {
count.value = 0
logAction('reset') // 直接调用,不需要 this
}
</script>
关键差异说明
Vue3 里没有 methods 这个概念了——就是普通的 JavaScript 函数。在 <script setup> 中声明的函数会自动暴露给模板,不需要额外 return。
这带来几个实质性的好处:
-
不再需要
this:函数直接闭包引用变量,没有 this 指向问题
-
可以用箭头函数:Vue2 的 methods 里不建议用箭头函数(会导致
this 指向错误),Vue3 随意用
-
方法可以和相关数据放在一起:不用再在
data 和 methods 之间跳来跳去
<script setup>
import { ref } from 'vue'
// ———— 计数器相关逻辑 ————
const count = ref(0)
const increment = () => count.value++ // 箭头函数完全OK
const reset = () => (count.value = 0)
// ———— 用户信息相关逻辑 ————
const username = ref('')
const updateUsername = (name) => (username.value = name)
</script>
看到没?数据和操作数据的方法紧挨在一起,按"功能"而不是按"类型"组织。这就是 Composition API 的核心思想——当组件逻辑复杂的时候,不用在 data、computed、methods、watch 之间反复横跳。
3.5 Watch:watch 选项 → watch() / watchEffect()
Vue2 写法
<script>
export default {
data() {
return {
keyword: '',
user: { name: '张三', age: 25 }
}
},
watch: {
// 基础用法
keyword(newVal, oldVal) {
console.log(`搜索词变了:${oldVal} → ${newVal}`)
this.doSearch(newVal)
},
// 深度侦听
user: {
handler(newVal) {
console.log('user 变了', newVal)
},
deep: true,
immediate: true // 创建时立即执行一次
}
},
methods: {
doSearch(kw) { /* ... */ }
}
}
</script>
Vue3 写法
<script setup>
import { ref, reactive, watch, watchEffect } from 'vue'
const keyword = ref('')
const user = reactive({ name: '张三', age: 25 })
// ——— watch:和 Vue2 类似,显式指定侦听源 ———
// 侦听 ref
watch(keyword, (newVal, oldVal) => {
console.log(`搜索词变了:${oldVal} → ${newVal}`)
doSearch(newVal)
})
// 侦听 reactive 对象的某个属性(注意:要用 getter 函数)
watch(
() => user.age,
(newAge, oldAge) => {
console.log(`年龄变了:${oldAge} → ${newAge}`)
}
)
// 侦听整个 reactive 对象(自动深度侦听)
watch(user, (newVal) => {
console.log('user 变了', newVal)
})
// 加选项:立即执行
watch(keyword, (newVal) => {
doSearch(newVal)
}, { immediate: true })
// ——— watchEffect:自动收集依赖,不用指定侦听源 ———
watchEffect(() => {
// 回调里用到了哪些响应式数据,就自动侦听哪些
console.log(`当前搜索词:${keyword.value},用户:${user.name}`)
})
function doSearch(kw) { /* ... */ }
</script>
watch vs watchEffect 怎么选?
| 特性 |
watch |
watchEffect |
| 需要指定侦听源 |
是 |
否(自动收集依赖) |
| 能拿到 oldValue |
能 |
不能 |
| 默认是否立即执行 |
否(可设 immediate: true) |
是(创建时立即执行一次) |
| 适合场景 |
需要精确控制"侦听谁"、需要新旧值对比 |
"用到啥就侦听啥",简化写法 |
我的实战建议:大多数场景用 watch,因为它意图更明确——看代码就知道你在侦听什么。watchEffect 适合那种"把几个数据凑一起做点事、不关心谁变了"的简单场景。
踩坑重灾区
坑 1:侦听 reactive 对象的属性,必须用 getter 函数
const user = reactive({ name: '张三', age: 25 })
// ❌ 错误:直接写 user.age,这只是传了个数字 25 进去
watch(user.age, (val) => { /* 永远不会触发 */ })
// ✅ 正确:传一个 getter 函数
watch(() => user.age, (val) => { console.log(val) })
原因很简单:user.age 在传参时就已经求值了,得到数字 25——一个普通的数字不是响应式的,Vue 没法侦听它。用 () => user.age 则是传了一个函数,Vue 每次执行这个函数时都会触发 Proxy 的 get 拦截,从而建立依赖追踪。
坑 2:watch 的清理——组件卸载后还在跑?
// 在 <script setup> 顶层调用的 watch 会自动与组件绑定
// 组件卸载时自动停止,不用手动处理
watch(keyword, (val) => { /* ... */ })
// 但如果你在异步回调或条件语句里创建 watch,就需要手动停止
let stop
setTimeout(() => {
stop = watch(keyword, (val) => { /* ... */ })
}, 1000)
// 需要停止时调用
// stop()
</script>
3.6 生命周期:选项式 → 组合式
对照表
| Vue2(Options API) |
Vue3(Composition API) |
说明 |
beforeCreate |
不需要(setup 本身就是) |
<script setup> 的代码就运行在这个时机 |
created |
不需要(setup 本身就是) |
同上 |
beforeMount |
onBeforeMount() |
DOM 挂载前 |
mounted |
onMounted() |
DOM 挂载后 |
beforeUpdate |
onBeforeUpdate() |
数据变了、DOM 更新前 |
updated |
onUpdated() |
DOM 更新后 |
beforeDestroy |
onBeforeUnmount() |
卸载前(注意改名了!) |
destroyed |
onUnmounted() |
卸载后(注意改名了!) |
完整示例
<!-- Vue2 -->
<script>
export default {
data() {
return { timer: null }
},
created() {
console.log('created: 可以访问数据了')
this.fetchData()
},
mounted() {
console.log('mounted: DOM 准备好了')
this.timer = setInterval(() => {
console.log('tick')
}, 1000)
},
beforeDestroy() {
clearInterval(this.timer)
console.log('beforeDestroy: 清理定时器')
}
}
</script>
<!-- Vue3 -->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const timer = ref(null)
// <script setup> 中的顶层代码 ≈ created
console.log('setup: 可以访问数据了')
fetchData()
onMounted(() => {
console.log('onMounted: DOM 准备好了')
timer.value = setInterval(() => {
console.log('tick')
}, 1000)
})
onBeforeUnmount(() => {
clearInterval(timer.value)
console.log('onBeforeUnmount: 清理定时器')
})
async function fetchData() { /* ... */ }
</script>
踩坑重灾区
坑 1:beforeDestroy → onBeforeUnmount,名字改了!
Vue3 把 destroy 相关的钩子全部改名为 unmount:
-
beforeDestroy → onBeforeUnmount
-
destroyed → onUnmounted
如果你用 Options API 写 Vue3 组件(是的,Vue3 也支持 Options API),那对应的选项名也变了:beforeUnmount 和 unmounted。
坑 2:不要在 setup 顶层做 DOM 操作
<script setup>
// ❌ 这里 DOM 还没挂载!
document.querySelector('.my-el') // null
// ✅ DOM 操作要放到 onMounted 里
import { onMounted } from 'vue'
onMounted(() => {
document.querySelector('.my-el') // OK
})
</script>
<script setup> 的顶层代码执行时机等同于 beforeCreate + created,这时候 DOM 还不存在。
3.7 Emits:this.$emit() → defineEmits()
Vue2 写法
<!-- 子组件 -->
<script>
export default {
methods: {
handleClick() {
this.$emit('update', { id: 1, name: '新名称' })
this.$emit('close')
}
}
}
</script>
<!-- 父组件 -->
<ChildComponent @update="onUpdate" @close="onClose" />
Vue3 写法
<!-- 子组件 -->
<script setup>
const emit = defineEmits(['update', 'close'])
// 或者带类型校验(TypeScript)
// const emit = defineEmits<{
// (e: 'update', payload: { id: number; name: string }): void
// (e: 'close'): void
// }>()
function handleClick() {
emit('update', { id: 1, name: '新名称' })
emit('close')
}
</script>
<!-- 父组件(用法不变) -->
<ChildComponent @update="onUpdate" @close="onClose" />
Vue3 要求显式声明组件会触发哪些事件。这不仅仅是规范,还有一个实际好处:Vue3 会把未声明的事件名当作原生 DOM 事件处理。如果你不声明 emits,给组件绑定 @click,这个 click 会直接穿透到子组件的根元素上。
四、一个完整的实战对比:Todo List
最后,用一个麻雀虽小五脏俱全的 Todo List,把上面所有知识点串起来。
Vue2 版本
<template>
<div class="todo-app">
<h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
<div class="input-bar">
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="输入待办事项..."
/>
<button @click="addTodo" :disabled="!canAdd">添加</button>
</div>
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">删除</button>
</li>
</ul>
<div class="filters">
<button @click="filter = 'all'">全部</button>
<button @click="filter = 'active'">未完成</button>
<button @click="filter = 'completed'">已完成</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
newTodo: '',
nextId: 1,
filter: 'all',
todos: []
}
},
computed: {
canAdd() {
return this.newTodo.trim().length > 0
},
activeCount() {
return this.todos.filter(t => !t.done).length
},
filteredTodos() {
if (this.filter === 'active') return this.todos.filter(t => !t.done)
if (this.filter === 'completed') return this.todos.filter(t => t.done)
return this.todos
}
},
watch: {
todos: {
handler(newTodos) {
localStorage.setItem('todos', JSON.stringify(newTodos))
},
deep: true
}
},
created() {
const saved = localStorage.getItem('todos')
if (saved) {
this.todos = JSON.parse(saved)
this.nextId = this.todos.length
? Math.max(...this.todos.map(t => t.id)) + 1
: 1
}
},
methods: {
addTodo() {
if (!this.canAdd) return
this.todos.push({
id: this.nextId++,
text: this.newTodo.trim(),
done: false
})
this.newTodo = ''
},
removeTodo(id) {
this.todos = this.todos.filter(t => t.id !== id)
}
}
}
</script>
Vue3 版本
<template>
<div class="todo-app">
<h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
<div class="input-bar">
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="输入待办事项..."
/>
<button @click="addTodo" :disabled="!canAdd">添加</button>
</div>
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">删除</button>
</li>
</ul>
<div class="filters">
<button @click="filter = 'all'">全部</button>
<button @click="filter = 'active'">未完成</button>
<button @click="filter = 'completed'">已完成</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
// ———— 状态 ————
const newTodo = ref('')
const filter = ref('all')
const todos = ref([])
let nextId = 1
// ———— 初始化(等同于 created) ————
const saved = localStorage.getItem('todos')
if (saved) {
todos.value = JSON.parse(saved)
nextId = todos.value.length
? Math.max(...todos.value.map(t => t.id)) + 1
: 1
}
// ———— 计算属性 ————
const canAdd = computed(() => newTodo.value.trim().length > 0)
const activeCount = computed(() => {
return todos.value.filter(t => !t.done).length
})
const filteredTodos = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.done)
if (filter.value === 'completed') return todos.value.filter(t => t.done)
return todos.value
})
// ———— 侦听器 ————
watch(todos, (newTodos) => {
localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })
// ———— 方法 ————
function addTodo() {
if (!canAdd.value) return
todos.value.push({
id: nextId++,
text: newTodo.value.trim(),
done: false
})
newTodo.value = ''
}
function removeTodo(id) {
todos.value = todos.value.filter(t => t.id !== id)
}
</script>
对比两个版本你会发现:模板部分完全一样,变化全在 <script> 里。这也是 Vue3 设计的一个巧妙之处——模板语法几乎没有 breaking change,迁移成本主要在 JS 逻辑层。
五、迁移时的高频"懵圈"清单
最后汇总一下,从 Vue2 迁到 Vue3,最容易懵的点:
| 序号 |
懵圈点 |
一句话解惑 |
| 1 |
ref 的 .value 什么时候加? |
模板里不加,JS 里加 |
| 2 |
ref 还是 reactive? |
拿不准就全用 ref,不会出错 |
| 3 |
reactive 解构丢失响应性 |
用 toRefs() 解构,或者不解构 |
| 4 |
this 去哪了? |
没有了,<script setup> 里直接用变量和函数 |
| 5 |
defineProps / defineEmits 要 import 吗? |
不用,它们是编译器宏 |
| 6 |
beforeDestroy 不生效了? |
改名了,叫 onBeforeUnmount
|
| 7 |
created 里的逻辑放哪? |
直接写在 <script setup> 顶层 |
| 8 |
watch 侦听 reactive 属性无效? |
要用 getter 函数 () => obj.prop
|
| 9 |
watch 和 watchEffect 选哪个? |
大多数场景用 watch,意图更清晰 |
| 10 |
组件暴露方法给父组件怎么办? |
用 defineExpose({ methodName })
|
六、结语
Vue3 的 Composition API 不是为了"炫技"而存在的,它解决的是一个非常现实的问题:当组件逻辑变复杂后,Options API 的代码会像面条一样——数据在上面,方法在下面,watch 在中间,改一个功能要上下反复跳。
Composition API 让你可以按逻辑关注点把代码组织在一起,甚至抽成可复用的 composables(组合式函数),这才是它真正的威力所在。
但说实话,不需要一步到位。Vue3 完全兼容 Options API,你可以:
- 新组件用
<script setup> + Composition API
- 老组件维护时逐步迁移
- 复杂逻辑才抽 composables,简单组件怎么顺手怎么来
技术服务于业务,够用、好维护,就是最好的选择。
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~