从 Vue2 到 Vue3:语法差异与迁移时最容易懵的点

同学们好,我是 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 写法

vue 复制代码
<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>

vue 复制代码
<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 里要加。

vue 复制代码
<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:refreactive 到底选哪个?

这是社区吵了很久的问题。我的实战建议(也是 Vue 官方文档推荐的倾向):

场景 推荐 原因
基本类型(number / string / boolean) ref() reactive() 不支持基本类型
对象/数组,且不会被整体替换 reactive() 不用到处写 .value,更清爽
对象/数组,但可能被整体替换 ref() reactive() 整体替换会丢失响应性
拿不准的时候 ref() 全部用 ref 不会出错,reactive 有限制

坑 3:reactive 的解构陷阱 ------ 这个真的会坑到你

vue 复制代码
<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 整体替换会丢失响应性

vue 复制代码
<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 写法

vue 复制代码
<!-- 子组件 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>
vue 复制代码
<!-- 父组件中使用 -->
<UserCard name="李四" :age="30" is-vip />

Vue3 写法(<script setup>

vue 复制代码
<!-- 子组件 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,还可以用纯类型声明的写法,更加简洁:

vue 复制代码
<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 可能会报红

definePropsdefineEmitsdefineExpose 这些都是编译器宏(compiler macro) ,在编译阶段就被处理掉了,运行时并不存在。所以不需要 import

如果你的 ESLint 报 'defineProps' is not defined,那是 ESLint 配置问题,需要在 .eslintrc 里配置:

js 复制代码
// .eslintrc.js
module.exports = {
  env: {
    'vue/setup-compiler-macros': true
  }
}

或者升级到较新版本的 eslint-plugin-vue(v9+),它默认已经支持了。

坑 2:Props 解构也会丢失响应性(Vue 3.2 及以前)

vue 复制代码
<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),如果你的项目版本够新,可以直接解构:

vue 复制代码
<script setup>
// Vue 3.5+ 可以直接解构,自动保持响应性
const { count = 0 } = defineProps({ count: Number })
// count 是响应式的,可以直接在模板中用
</script>

但如果你的项目还在 3.4 或更早版本上,老老实实用 props.counttoRefs 是最稳的。


3.3 Computed:computed 选项computed() 函数

Vue2 写法

vue 复制代码
<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 写法

vue 复制代码
<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 都一样,但很多人还是会犯:

js 复制代码
// ❌ 错误示范:在 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 返回的值不是一样吗",但核心区别是缓存

vue 复制代码
<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 写法

vue 复制代码
<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 写法

vue 复制代码
<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。

这带来几个实质性的好处:

  1. 不再需要 this :函数直接闭包引用变量,没有 this 指向问题
  2. 可以用箭头函数 :Vue2 的 methods 里不建议用箭头函数(会导致 this 指向错误),Vue3 随意用
  3. 方法可以和相关数据放在一起 :不用再在 datamethods 之间跳来跳去
vue 复制代码
<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 的核心思想------当组件逻辑复杂的时候,不用在 datacomputedmethodswatch 之间反复横跳。


3.5 Watch:watch 选项watch() / watchEffect()

Vue2 写法

vue 复制代码
<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 写法

vue 复制代码
<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 函数

js 复制代码
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 的清理------组件卸载后还在跑?

js 复制代码
// 在 <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() 卸载后(注意改名了!)

完整示例

vue 复制代码
<!-- 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>
vue 复制代码
<!-- 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:beforeDestroyonBeforeUnmount,名字改了!

Vue3 把 destroy 相关的钩子全部改名为 unmount

  • beforeDestroyonBeforeUnmount
  • destroyedonUnmounted

如果你用 Options API 写 Vue3 组件(是的,Vue3 也支持 Options API),那对应的选项名也变了:beforeUnmountunmounted

坑 2:不要在 setup 顶层做 DOM 操作

vue 复制代码
<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 写法

vue 复制代码
<!-- 子组件 -->
<script>
export default {
  methods: {
    handleClick() {
      this.$emit('update', { id: 1, name: '新名称' })
      this.$emit('close')
    }
  }
}
</script>

<!-- 父组件 -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 写法

vue 复制代码
<!-- 子组件 -->
<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 版本

vue 复制代码
<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 版本

vue 复制代码
<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 watchwatchEffect 选哪个? 大多数场景用 watch,意图更清晰
10 组件暴露方法给父组件怎么办? defineExpose({ methodName })

六、结语

Vue3 的 Composition API 不是为了"炫技"而存在的,它解决的是一个非常现实的问题:当组件逻辑变复杂后,Options API 的代码会像面条一样------数据在上面,方法在下面,watch 在中间,改一个功能要上下反复跳。

Composition API 让你可以按逻辑关注点把代码组织在一起,甚至抽成可复用的 composables(组合式函数),这才是它真正的威力所在。

但说实话,不需要一步到位。Vue3 完全兼容 Options API,你可以:

  1. 新组件用 <script setup> + Composition API
  2. 老组件维护时逐步迁移
  3. 复杂逻辑才抽 composables,简单组件怎么顺手怎么来

技术服务于业务,够用、好维护,就是最好的选择。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

相关推荐
鼓浪屿1 小时前
vue3:组件中,v-model的区别(新版)
前端
Leon2 小时前
新手引导 intro.js 的使用
前端·javascript·vue.js
Zeros2 小时前
Claude Code 使用心得 - 从尝鲜到日常的进阶之路
前端
我是何平2 小时前
js中,什么是线性查找?
前端
我是何平2 小时前
🧠 用 JavaScript 理解算法复杂度:时间复杂度与空间复杂度详解
前端
SuperEugene2 小时前
接口类型管理:从 any 到有组织的 api.d.ts
前端·面试·typescript
喝咖啡的女孩2 小时前
React Hook & Class
前端
小呆呆_小乌龟2 小时前
同样是定义对象,为什么 TS 里有人用 interface,有人用 type?
前端·react.js
Forever7_2 小时前
仅用一个技巧,让 JavaScript 性能提速 500%!
前端·vue.js