前言

Vue.js 是国内前端开发者最熟悉的框架之一。2020年9月发布的 Vue 3 带来了翻天覆地的变化——全新的 Composition API、基于 Proxy 的响应式系统、更好的 TypeScript 支持,以及性能上的大幅提升。

本文将从一个 Vue 2 开发者的视角,带你全面了解 Vue 3 的核心变化,并通过大量代码示例帮助你快速上手。无论你是刚接触 Vue 的新手,还是从 Vue 2 迁移的老手,这篇文章都能帮你建立起 Vue 3 的知识框架。

Vue 3 从发布至今已经经历了多个迭代版本,目前是构建用户界面的首选框架之一。它的核心理念始终保持不变:渐进式框架——你可以根据自己的项目需求,选择性地使用它的不同功能模块。这意味着你可以从一个简单的 CDN 脚本开始,逐步扩展到完整的工程化项目,这一点对初学者非常友好。

在开始之前,建议你使用 Vite 作为构建工具来创建 Vue 3 项目,它比 Webpack 快十倍以上:

# 创建 Vue 3 + TypeScript 项目
npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install
npm run dev

本文所有的代码示例都可以在这样一个新创建的项目中直接运行。好了,让我们正式开始 Vue 3 的学习之旅吧!


一、Options API vs Composition API:思维模式的转变

1.1 Options API 的痛点

在 Vue 2 中,我们习惯用 options(选项)来组织组件逻辑:

// Vue 2 Options API
export default {
  data() {
    return {
      count: 0,
      message: 'Hello',
      users: [] as User[],
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    },
    async fetchUsers() {
      const res = await api.getUsers()
      this.users = res.data
    }
  },
  mounted() {
    this.fetchUsers()
  }
}

当组件变得复杂时,同一个功能的代码(比如 count 的相关逻辑)散落在 data、computed、methods、watch 各个选项中,维护起来非常痛苦。这就是我们常说的碎片化问题。

1.2 Composition API 的解决思路

Vue 3 的 Composition API 允许我们按功能组织代码,而不是按选项类型:

// Vue 3 Composition API
import { ref, computed, onMounted } from 'vue'

export default {
  setup() {
    // 计数器功能 —— 所有相关代码在一起
    const count = ref(0)
    const doubleCount = computed(() => count.value * 2)
    function increment() {
      count.value++
    }

    // 用户列表功能 —— 独立且内聚
    const users = ref<User[]>([])
    async function fetchUsers() {
      const res = await api.getUsers()
      users.value = res.data
    }
    onMounted(fetchUsers)

    return { count, doubleCount, increment, users }
  }
}

更有用的是,我们可以把逻辑提取到独立的函数中(后面会讲 Composables)。

1.3 <script setup> 语法糖

Vue 3.2 引入了 <script setup>,让 Composition API 的写法更加简洁:

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
  count.value++
}

const users = ref<User[]>([])
async function fetchUsers() {
  const res = await api.getUsers()
  users.value = res.data
}
onMounted(fetchUsers)
</script>

在 <script setup> 中,顶层变量和函数可以直接在模板中使用,不再需要 return。


二、响应式原理:从 Object.defineProperty 到 Proxy

2.1 Vue 2 的局限性

Vue 2 使用 Object.defineProperty 实现响应式,存在几个硬伤:

  • 无法检测属性的新增和删除——必须用 Vue.set() / Vue.delete()
  • 无法直接检测数组索引和长度变化——必须用 splice 等变异方法
  • 初始化时递归遍历所有属性——对象越深,性能开销越大
// Vue 2 的痛点
const vm = new Vue({
  data: { user: { name: 'Alice' } }
})

vm.user.age = 25           // ❌ 不是响应式的
vm.user = { name: 'Bob' }  // ❌ 新对象丢失响应
delete vm.user.name         // ❌ 不是响应式的
vm.arr[0] = 'x'            // ❌ 数组索引不响应

2.2 Vue 3 的 Proxy 方案

Vue 3 改用 Proxy 代理整个对象,从根本上解决了上述问题。Proxy 是 ES6 中引入的特性,它可以在目标对象之外构建一层"拦截层",所有对对象的操作都会先经过这层拦截。这意味着我们可以捕获到属性的读取、赋值、删除、枚举等各种操作,从而实现全面的响应式追踪。

简单来说,Object.defineProperty 是在对象已有的属性上"动手脚",而 Proxy 是在对象外面"罩了一层网",任何操作都逃不过这张网。这也是为什么 Vue 3 能够检测到新增属性和数组索引变更的根本原因。

下面是一个 Proxy 响应式的简化实现示意:

// Vue 3 Proxy 响应式简化示意
const handler = {
  get(target, key, receiver) {
    track(target, key)        // 依赖收集
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    trigger(target, key)      // 触发更新
    return result
  },
  deleteProperty(target, key) {
    const result = Reflect.deleteProperty(target, key)
    trigger(target, key)
    return result
  }
}

优势明显:

特性 Vue 2 Vue 3
新增/删除属性 ❌ 需 Vue.set/delete ✅ 自动追踪
数组索引变更 ❌ 不响应 ✅ 自动追踪
数组 length 变化 ❌ 不响应 ✅ 自动追踪
初始化性能 递归遍历所有层级 懒递归,按需代理
支持 Map/Set ❌ ✅
// Vue 3 —— 一切皆响应
import { reactive } from 'vue'

const user = reactive({ name: 'Alice' })

user.age = 25            // ✅ 自动响应式
user.address = {}        // ✅ 新属性也是响应式的
delete user.name         // ✅ 触发视图更新

const list = reactive([1, 2, 3])
list[0] = 99             // ✅ 数组索引赋值
list.length = 1          // ✅ length 变更

三、ref / reactive / computed / watch 完整讲解

3.1 ref() —— 包装基本类型

ref 用于包装基本类型(number、string、boolean)或单个值,返回一个带有 .value 属性的响应式对象:

import { ref } from 'vue'

const count = ref(0)
const name = ref('Vue 3')
const isReady = ref(false)

// 读取和修改需要 .value
console.log(count.value)   // 0
count.value++
console.log(count.value)   // 1

为什么需要 .value? 因为 JavaScript 基本类型是按值传递的,不包装成对象就无法追踪引用。而在模板中,Vue 会自动解包 ref,所以不需要写 .value。

ref 也支持传入复杂类型,但内部实际上会调用 reactive 来处理:

const state = ref({ count: 0, list: [1, 2, 3] })
state.value.count++        // 深层也是响应式的
state.value.list.push(4)

3.2 reactive() —— 包装对象

reactive 用于包装对象(包括数组、Map、Set),返回一个深层的响应式代理:

import { reactive } from 'vue'

interface Todo {
  id: number
  title: string
  done: boolean
}

const state = reactive({
  todos: [] as Todo[],
  filter: 'all' as 'all' | 'active' | 'completed',
  searchQuery: '',
})

// 直接操作,无需 .value
state.todos.push({ id: 1, title: 'Learn Vue 3', done: false })
state.filter = 'active'

ref vs reactive 怎么选?

场景 推荐
基本类型(number/string/boolean) ref
对象/数组,需要重新赋值 ref(obj.value = newObj)
深层嵌套对象,直接修改属性 reactive
从组合式函数返回 ref(解构不丢失响应)

⚠️ 重要提示:reactive 的对象如果解构或赋值给新变量,会丢失响应性。此时要用 toRefs 或转用 ref。

const state = reactive({ x: 1, y: 2 })

// ❌ 丢失响应性
const { x, y } = state

// ✅ 保持响应性
const { x, y } = toRefs(state)
// 或者直接用 state.x

3.3 computed() —— 派生状态

computed 基于其他响应式数据计算出新的值,有缓存特性:

import { ref, computed } from 'vue'

const todos = ref([
  { id: 1, title: 'A', done: false },
  { id: 2, title: 'B', done: true },
])

// 只读计算属性
const activeTodos = computed(() =>
  todos.value.filter(t => !t.done)
)

// 可读写计算属性(传入 getter + setter)
const allDone = computed({
  get: () => todos.value.every(t => t.done),
  set: (value: boolean) => {
    todos.value.forEach(t => { t.done = value })
  }
})

allDone.value = true   // 全部标记完成
console.log(allDone.value) // true

3.4 watch() —— 侦听变化

Vue 3 的 watch 支持侦听单个或多个数据源:

import { ref, watch, reactive } from 'vue'

const count = ref(0)
const name = ref('Vue')

// 侦听单个 ref
watch(count, (newVal, oldVal) => {
  console.log(`count: ${oldVal} -> ${newVal}`)
})

// 侦听 reactive 对象的某个属性(需要传 getter 函数)
const state = reactive({ x: 0, y: 0 })
watch(
  () => state.x,
  (val) => console.log('x changed:', val)
)

// 侦听多个数据源
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
  console.log('count or name changed')
})

// 深度侦听
watch(
  () => state,
  (newVal) => console.log('state changed'),
  { deep: true, immediate: true }
)

watchEffect —— 自动追踪依赖:

import { ref, watchEffect } from 'vue'

const keyword = ref('')

// 回调中使用了哪些响应式数据,就自动侦听哪些
watchEffect(() => {
  // 自动追踪 keyword.value
  console.log(`搜索关键词: ${keyword.value}`)
})

watchEffect 与 watch 的区别:

watch watchEffect
需明确指定侦听源 自动追踪回调中的依赖
可获取旧值 只能获取新值
懒执行(默认) 立即执行一次
支持 immediate / deep 等选项 简洁但配置少

四、生命周期钩子的变化

Vue 3 的生命周期钩子全面重命名,同时 Composition API 提供了对应的函数形式:

Vue 2 (Options API) Vue 3 (Options API) Vue 3 (Composition API)
beforeCreate beforeCreate setup()
created created setup()
beforeMount beforeMount onBeforeMount
mounted mounted onMounted
beforeUpdate beforeUpdate onBeforeUpdate
updated updated onUpdated
beforeDestroy beforeUnmount onBeforeUnmount
destroyed unmounted onUnmounted
errorCaptured errorCaptured onErrorCaptured
— renderTracked onRenderTracked
— renderTriggered onRenderTriggered
<script setup lang="ts">
import {
  onMounted,
  onUnmounted,
  onBeforeMount,
  onUpdated,
  onErrorCaptured,
} from 'vue'

onBeforeMount(() => {
  console.log('组件挂载前')
})

onMounted(() => {
  console.log('组件已挂载')
  window.addEventListener('resize', handleResize)
})

onUpdated(() => {
  console.log('组件更新')
})

onUnmounted(() => {
  console.log('组件已卸载')
  window.removeEventListener('resize', handleResize)
})

onErrorCaptured((err) => {
  console.error('子组件异常:', err)
  return false   // 阻止错误继续传播
})
</script>

🧠 在 setup() / <script setup> 中,beforeCreate 和 created 不需要显式调用——setup() 本身就是围绕这两个钩子运行的。


五、模板语法与常用指令

5.1 插值与绑定

<template>
  <!-- 文本插值 -->
  <p>{{ message }}</p>

  <!-- v-bind 绑定属性(简写 :) -->
  <img :src="imageUrl" :alt="altText" />
  <button :disabled="isLoading">提交</button>

  <!-- class / style 增强绑定 -->
  <div :class="['box', { active: isActive }]">Class 绑定</div>
  <div :style="{ color: themeColor, fontSize: '14px' }">Style 绑定</div>
</template>

5.2 条件渲染:v-if / v-show

<template>
  <!-- v-if 系列:真正的条件渲染 -->
  <div v-if="status === 'loading'">加载中...</div>
  <div v-else-if="status === 'error'">加载失败</div>
  <div v-else>加载完成,共 {{ items.length }} 条</div>

  <!-- v-show:仅切换 display 属性 -->
  <div v-show="isVisible">始终渲染,仅切换 CSS</div>
</template>

v-if 是惰性的(条件为假时不渲染元素),v-show 总是渲染但切换 CSS display。

5.3 列表渲染:v-for

<template>
  <!-- 遍历数组 -->
  <ul>
    <li v-for="(todo, index) in todos" :key="todo.id">
      {{ index + 1 }}. {{ todo.title }}
    </li>
  </ul>

  <!-- 遍历对象 -->
  <div v-for="(value, key, index) in user" :key="key">
    {{ index }}. {{ key }}: {{ value }}
  </div>

  <!-- 遍历数字范围 -->
  <span v-for="n in 10" :key="n">{{ n }}</span>
</template>

⚠️ 永远给 v-for 提供 :key,帮助 Vue 高效复用和重排 DOM 节点。

5.4 双向绑定:v-model

Vue 3 的 v-model 有了重大改进,支持多个 v-model 和自定义修饰符:

<template>
  <!-- 基本用法 -->
  <input v-model="search" />

  <!-- 指定属性名(默认是 modelValue) -->
  <input v-model:title="pageTitle" />

  <!-- 多个 v-model -->
  <UserForm
    v-model:name="userName"
    v-model:email="userEmail"
    v-model:age="userAge"
  />

  <!-- 自定义修饰符 -->
  <input v-model.capitalize="text" />
</template>

子组件接收多个 v-model:

<!-- UserForm.vue -->
<script setup lang="ts">
defineProps<{
  name: string
  email: string
  age: number
}>()

const emit = defineEmits<{
  'update:name': [value: string]
  'update:email': [value: string]
  'update:age': [value: number]
}>()
</script>

六、组件通信方式

6.1 Props —— 父传子

<!-- Parent.vue -->
<script setup lang="ts">
import Child from './Child.vue'

const user = { name: 'Alice', age: 25 }
</script>

<template>
  <Child title="Hello" :user="user" />
</template>
<!-- Child.vue -->
<script setup lang="ts">
// 方式一:类型声明
defineProps<{
  title: string
  user: { name: string; age: number }
}>()

// 方式二:运行时声明(支持默认值)
defineProps({
  title: { type: String, required: true },
  user: { type: Object, default: () => ({ name: '', age: 0 }) },
})
</script>

<template>
  <h2>{{ title }}</h2>
  <p>{{ user.name }},{{ user.age }}岁</p>
</template>

6.2 Emit —— 子传父

<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits<{
  confirm: [message: string]
  cancel: []
}>()

function handleConfirm() {
  emit('confirm', '用户确认了操作')
}
</script>

<template>
  <button @click="handleConfirm">确认</button>
  <button @click="emit('cancel')">取消</button>
</template>
<!-- Parent.vue -->
<template>
  <Child @confirm="onConfirm" @cancel="onCancel" />
</template>

6.3 Slots —— 内容分发

<!-- Card.vue -->
<template>
  <div class="card">
    <!-- 默认插槽 -->
    <div class="card-body">
      <slot />
    </div>

    <!-- 具名插槽 -->
    <div class="card-header">
      <slot name="header" />
    </div>

    <!-- 作用域插槽:子传数据给父 -->
    <div class="card-footer">
      <slot name="footer" :count="items.length">
        <!-- 默认内容 -->
        <span>共 {{ items.length }} 项</span>
      </slot>
    </div>
  </div>
</template>
<!-- 使用 Card -->
<template>
  <Card>
    <template #header>
      <h2>标题</h2>
    </template>

    <p>正文内容</p>

    <template #footer="{ count }">
      <span>总计: {{ count }} 项</span>
    </template>
  </Card>
</template>

6.4 Provide / Inject —— 跨层级通信

适用于祖孙组件或深层嵌套的场景,避免 props 层层传递:

<!-- Ancestor.vue -->
<script setup lang="ts">
import { ref, provide } from 'vue'

const theme = ref<'light' | 'dark'>('light')
const user = ref({ name: 'Admin', role: 'admin' })

// 提供数据
provide('theme', theme)
provide('user', user)
</script>
<!-- DeepChild.vue -->
<script setup lang="ts">
import { inject } from 'vue'

// 注入数据(支持默认值和类型)
const theme = inject('theme', ref('light'))
const user = inject('user', ref({ name: '', role: '' }))

// 孙组件可以直接修改(如果允许)
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>

<template>
  <p>当前主题: {{ theme }}</p>
  <p>当前用户: {{ user.name }} ({{ user.role }})</p>
  <button @click="toggleTheme">切换主题</button>
</template>

✅ 使用 Symbol 作为注入 key 可以避免命名冲突:

export const THEME_KEY = Symbol('theme')
provide(THEME_KEY, theme)

七、组合式函数(Composables):逻辑复用的最佳实践

Composables 是 Vue 3 中取代 Mixins 的逻辑复用方案。它以 use 开头的函数封装有状态的逻辑。

7.1 基础示例:useMouse

// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

在组件中使用:

<script setup lang="ts">
import { useMouse } from './composables/useMouse'

const { x, y } = useMouse()
</script>

<template>
  <p>鼠标位置: ({{ x }}, {{ y }})</p>
</template>

7.2 带参数的 Composables:useCounter

// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initialValue }

  return { count, double, increment, decrement, reset }
}

7.3 异步 Composables:useFetch

// composables/useFetch.ts
import { ref, type Ref } from 'vue'

export function useFetch<T>(url: string | Ref<string>) {
  const data = ref<T | null>(null)
  const error = ref<string | null>(null)
  const loading = ref(false)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      const target = typeof url === 'string' ? url : url.value
      const res = await fetch(target)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e: any) {
      error.value = e.message
    } finally {
      loading.value = false
    }
  }

  execute()

  return { data, error, loading, refresh: execute }
}

7.4 Composables 的使用原则

在实际开发中,编写 Composables 时有一些值得遵守的"最佳实践":

  1. 命名以 use 开头 —— 这是一个约定俗成的规范,让其他开发者一眼就知道这是一个组合式函数
  2. 返回值为对象,不要使用数组 —— 对象解构更清晰,且避免了顺序依赖
  3. 接收 ref 参数时,用 toValue 统一处理 —— 保证函数既支持普通值也支持 ref
  4. 善用 watchEffect 和 watch 处理副作用 —— 自动清理是 Composition API 的一大优势
  5. 不要修改传入的参数 —— 保持函数的纯粹性和可预测性
  6. 优先返回 ref 而非 reactive —— ref 在解构后不会丢失响应性,更灵活
// 好的实践:接收 ref 或普通值
import { toValue, type MaybeRef } from 'vue'

export function useGreeting(name: MaybeRef<string>) {
  const greeting = computed(() => `你好, ${toValue(name)}!`)
  return { greeting }
}

// 使用时既支持普通值也支持 ref
const { greeting } = useGreeting('Alice')
const nameRef = ref('Bob')
const { greeting: greeting2 } = useGreeting(nameRef)

7.5 Composables vs Mixins:为什么 Composables 更好?

在 Vue 2 时代,Mixins 是主要的逻辑复用手段,但它有很多问题。让我们对比一下:

对比维度 Mixins Composables
命名冲突 ❌ 多个 mixin 的属性名可能冲突,Vue 的合并策略会覆盖 ✅ 解构时可以任意重命名,不存在冲突
来源清晰 ❌ 模板中的属性不知道来自哪个 mixin,只能翻代码 ✅ 显式调用函数,每个变量都知道来源
循环依赖 ❌ 不支持 ✅ 函数之间可以互相调用组合
类型推断 ❌ 很难做 TypeScript 类型推导 ✅ 天然支持完整的 TypeScript 泛型和类型推断
Tree-shaking ❌ 即使只用 mixin 的一部分,整个 mixin 也会被打包 ✅ 只打包用到的函数,按需引入

从 Mixins 迁移到 Composables 并不难,本质上就是把 mixin 的选项对象改成一个函数,把 data 变成 ref,把 methods 变成普通函数,把 mounted 变成 onMounted。这样既保留了逻辑复用的能力,又避免了 Mixins 带来的各种问题。


八、实战:待办事项应用

现在我们把学到的知识整合起来,实现一个完整的待办事项应用。

8.1 Composables

// composables/useTodos.ts
import { ref, computed, watch } from 'vue'

export type FilterType = 'all' | 'active' | 'completed'

export interface Todo {
  id: number
  title: string
  done: boolean
  createdAt: Date
}

const STORAGE_KEY = 'vue3-todos'

export function useTodos() {
  // 从 localStorage 恢复数据
  const todos = ref<Todo[]>(loadTodos())
  const filter = ref<FilterType>('all')
  const searchQuery = ref('')

  // 持久化
  watch(todos, (val) => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
  }, { deep: true })

  // 计算属性
  const filteredTodos = computed(() => {
    let list = todos.value

    if (searchQuery.value) {
      const q = searchQuery.value.toLowerCase()
      list = list.filter(t => t.title.toLowerCase().includes(q))
    }

    switch (filter.value) {
      case 'active':
        return list.filter(t => !t.done)
      case 'completed':
        return list.filter(t => t.done)
      default:
        return list
    }
  })

  const activeCount = computed(() =>
    todos.value.filter(t => !t.done).length
  )

  // 操作方法
  function addTodo(title: string) {
    const trimmed = title.trim()
    if (!trimmed) return

    todos.value.push({
      id: Date.now(),
      title: trimmed,
      done: false,
      createdAt: new Date(),
    })
  }

  function toggleTodo(id: number) {
    const todo = todos.value.find(t => t.id === id)
    if (todo) todo.done = !todo.done
  }

  function removeTodo(id: number) {
    todos.value = todos.value.filter(t => t.id !== id)
  }

  function clearCompleted() {
    todos.value = todos.value.filter(t => !t.done)
  }

  return {
    todos,
    filter,
    searchQuery,
    filteredTodos,
    activeCount,
    addTodo,
    toggleTodo,
    removeTodo,
    clearCompleted,
  }
}

function loadTodos(): Todo[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    return raw ? JSON.parse(raw) : []
  } catch {
    return []
  }
}

8.2 App.vue

<script setup lang="ts">
import { useTodos } from './composables/useTodos'
import TodoItem from './TodoItem.vue'

const {
  filter,
  searchQuery,
  filteredTodos,
  activeCount,
  addTodo,
  toggleTodo,
  removeTodo,
  clearCompleted,
} = useTodos()

const newTodoTitle = ref('')

function handleSubmit() {
  addTodo(newTodoTitle.value)
  newTodoTitle.value = ''
}
</script>

<template>
  <div class="todo-app">
    <h1>📋 待办事项</h1>

    <!-- 输入框 -->
    <form @submit.prevent="handleSubmit" class="input-area">
      <input
        v-model="newTodoTitle"
        placeholder="输入新待办..."
        class="todo-input"
      />
      <button type="submit" class="btn-add">添加</button>
    </form>

    <!-- 搜索 & 筛选 -->
    <div class="toolbar">
      <input v-model="searchQuery" placeholder="搜索..." class="search-input" />
      <select v-model="filter" class="filter-select">
        <option value="all">全部</option>
        <option value="active">未完成</option>
        <option value="completed">已完成</option>
      </select>
    </div>

    <!-- 待办列表 -->
    <ul class="todo-list">
      <TodoItem
        v-for="todo in filteredTodos"
        :key="todo.id"
        :todo="todo"
        @toggle="toggleTodo(todo.id)"
        @remove="removeTodo(todo.id)"
      />
    </ul>

    <!-- 统计信息 -->
    <div class="footer">
      <span>{{ activeCount }} 项未完成</span>
      <button
        v-if="filteredTodos.some(t => t.done)"
        @click="clearCompleted"
        class="btn-clear"
      >
        清除已完成
      </button>
    </div>
  </div>
</template>

8.3 TodoItem.vue

<script setup lang="ts">
import type { Todo } from './composables/useTodos'

defineProps<{
  todo: Todo
}>()

const emit = defineEmits<{
  toggle: []
  remove: []
}>()
</script>

<template>
  <li class="todo-item" :class="{ completed: todo.done }">
    <input type="checkbox" :checked="todo.done" @change="emit('toggle')" />
    <span class="todo-title">{{ todo.title }}</span>
    <button @click="emit('remove')" class="btn-delete">🗑</button>
  </li>
</template>

<style scoped>
.todo-item.completed .todo-title {
  text-decoration: line-through;
  color: #999;
}
</style>

💡 完整的样式代码可以自由发挥,以上是核心逻辑部分。


九、总结

Vue 3 并不是对 Vue 2 的推翻重来,而是一次渐进式的进化。你可以:

  1. 继续使用 Options API(Vue 3 完全兼容)
  2. 在新组件中逐步尝试 Composition API
  3. 用 <script setup> 获得最简洁的开发体验
  4. 用 Composables 替代 Mixins 实现优雅的逻辑复用
  5. 享受 Proxy 带来的完整响应式能力

学习建议:

  • 先掌握 ref / reactive / computed / watch 四个核心 API
  • 理解 setup() 的时机和生命周期
  • 从小功能开始提取 Composables
  • 多看官方文档的示例和 TypeScript 类型推断

Vue 3 的生态已经非常成熟(Vite、Pinia、Vue Router 4、Nuxt 3 等),现在正是全面学习和使用 Vue 3 的最佳时机。


本文作者是一名热爱分享的前端开发者。如果你觉得文章有帮助,欢迎点赞和收藏,也欢迎在评论区讨论你在学习 Vue 3 过程中遇到的问题!