前言

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 的相关逻辑)散落在 datacomputedmethodswatch 各个选项中,维护起来非常痛苦。这就是我们常说的碎片化问题

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.definePropertyProxy

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
对象/数组,需要重新赋值 refobj.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}`)
})

watchEffectwatch 的区别:

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> 中,beforeCreatecreated 不需要显式调用——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. 善用 watchEffectwatch 处理副作用 —— 自动清理是 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 过程中遇到的问题!