Vue3 Composition API实战教程:像搭积木一样构建组件

Vue3 Composition API实战教程封面 - 组合式函数构建组件指南

引言

我认识一个朋友,之前写了两年Vue2,最近开始用Vue3重构项目。第一次看到Composition API的时候,他发了条朋友圈:”这不就是把options API拆开写吗?”

说实话,我一开始也是这么想的。但当我用它写了两个实际项目之后,发现这玩意儿确实香。

为什么?因为以前写组件,所有逻辑都堆在data、methods、computed、watch这四个选项里。同一个功能相关的代码被拆得七零八落,找起来头疼,改起来更头疼。Composition API把同一个功能的代码聚在一起,逻辑清晰多了。

这篇文章就是把我从”看不懂Composition API”到”真香”的过程记录下来,帮你少走弯路。

Vue3响应式系统配图 - setup函数与组合式函数演示

环境准备:搭建Vue3项目

使用Vite创建项目

Vite是Vue官方推荐的构建工具,比Vue CLI快很多。

bash

# 创建项目
npm create vite@latest my-vue-app -- --template vue

# 进入项目目录
cd my-vue-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

项目结构大概是这样的:

plaintext

my-vue-app/
├── src/
│   ├── assets/
│   ├── components/
│   ├── App.vue
│   └── main.js
├── index.html
├── package.json
└── vite.config.js

Vue3组件的基本结构

打开App.vue,你会看到这样的代码:

vue

<script setup>
import { ref, computed, onMounted } from 'vue'

// 响应式数据
const count = ref(0)

// 计算属性
const doubleCount = computed(() => count.value * 2)

// 方法
function increment() {
  count.value++
}

// 生命周期钩子
onMounted(() => {
  console.log('组件挂载完成')
})
</script>

<template>
  <div>
    <p>计数:{{ count }}</p>
    <p>双倍:{{ doubleCount }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<style scoped>
button {
  padding: 8px 16px;
  background: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

这就是Vue3 Composition API的基本写法。注意<script setup>语法,这是官方推荐的写法,比普通<script>更简洁。

响应式系统:Vue3的核心

响应式是Vue的灵魂。数据变化,界面自动更新,不需要手动操作DOM。

ref和reactive

Vue3有两种创建响应式数据的方式:

ref:基础类型的响应式

vue

<script setup>
import { ref } from 'vue'

// ref用于基础类型(string、number、boolean等)
const count = ref(0)
const name = ref('小明')
const isActive = ref(false)

// 访问值用.value
function increment() {
  count.value++
}

function changeName() {
  name.value = '小红'
}
</script>

<template>
  <div>
    <p>{{ name }} 点击了 {{ count }} 次</p>
    <button @click="increment">点我</button>
    <p v-if="isActive">显示中</p>
  </div>
</template>

reactive:对象的响应式

vue

<script setup>
import { reactive } from 'vue'

// reactive用于对象和数组
const user = reactive({
  name: '张三',
  age: 25,
  email: 'zhangsan@example.com'
})

function birthday() {
  user.age++
}

function updateEmail() {
  user.email = 'new_' + user.email
}
</script>

<template>
  <div>
    <p>姓名:{{ user.name }}</p>
    <p>年龄:{{ user.age }}</p>
    <p>邮箱:{{ user.email }}</p>
    <button @click="birthday">过生日</button>
  </div>
</template>

什么时候用ref,什么时候用reactive?

我的经验是:

  • 基础类型用ref
  • 对象、数组用reactive
  • 或者统一用ref,因为ref也可以包装对象:const user = ref({...})

深度响应式

默认情况下,ref和reactive都是深度响应式的:

vue

<script setup>
import { ref, reactive } from 'vue'

const obj = reactive({
  nested: { count: 0 },
  arr: ['foo', 'bar']
})

function mutateDeeply() {
  // 这些修改都会触发响应式更新
  obj.nested.count++
  obj.arr.push('baz')
}

const nestedRef = ref({
  value: { count: 0 }
})

function mutateNestedRef() {
  // ref包装的对象,修改时需要用.value
  nestedRef.value.count++
}
</script>

computed计算属性

计算属性根据其他响应式数据计算得出,有缓存特性:

vue

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 只读计算属性
const fullName = computed(() => {
  return `${lastName.value}${firstName.value}`
})

// 可写计算属性
const fullNameWritable = computed({
  get: () => `${lastName.value}${firstName.value}`,
  set: (value) => {
    // "李四" -> lastName="李", firstName="四"
    if (value.length >= 2) {
      lastName.value = value[0]
      firstName.value = value.slice(1)
    }
  }
})

function updateName() {
  fullNameWritable.value = '王五'
}
</script>

<template>
  <div>
    <p>姓名:{{ fullName }}</p>
    <button @click="updateName">改为王五</button>
  </div>
</template>

watch监听器

当响应式数据变化时执行某些操作:

vue

<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('')

// 监听单个响应式数据
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.includes('?')) {
    answer.value = '思考中...'
    // 模拟异步请求
    setTimeout(() => {
      answer.value = '这是我的答案'
    }, 1000)
  }
})

// 监听多个数据
const first = ref('')
const second = ref('')

watch([first, second], ([newFirst, newSecond], [oldFirst, oldSecond]) => {
  console.log(`从 ${oldFirst},${oldSecond} 变为 ${newFirst},${newSecond}`)
})
</script>

<template>
  <div>
    <input v-model="question" placeholder="输入问题..." />
    <p>{{ answer }}</p>
  </div>
</template>

watchEffect是另一个选择,它会自动追踪依赖:

vue

<script setup>
import { ref, watchEffect } from 'vue'

const url = ref('https://api.example.com/data')

watchEffect(async () => {
  // 这里的代码会自动追踪url.value的变化
  const response = await fetch(url.value)
  const data = await response.json()
  console.log('获取到数据:', data)
})

// 改变url会自动触发上面的代码重新执行
</script>

生命周期钩子

每个组件从创建到销毁会经历多个阶段,生命周期钩子让你在各个阶段执行代码。

vue

<script setup>
import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount,
  onActivated,
  onDeactivated,
  onErrorCaptured
} from 'vue'

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

// 组件挂载后(DOM已创建)
onMounted(() => {
  console.log('组件已挂载')
  // 适合:获取DOM、绑定事件、发送请求
})

// 组件更新前
onBeforeUpdate(() => {
  console.log('组件即将更新')
})

// 组件更新后(DOM已更新)
onUpdated(() => {
  console.log('组件已更新')
  // 适合:获取更新后的DOM
})

// 组件卸载前
onBeforeUnmount(() => {
  console.log('组件即将卸载')
})

// 组件卸载后
onUnmounted(() => {
  console.log('组件已卸载')
  // 适合:清理定时器、取消订阅、解绑事件
})

// 错误捕获(Vue3新增)
onErrorCaptured((err, instance, info) => {
  console.error('捕获到错误:', err)
  console.error('错误信息:', info)
  return false // 阻止错误继续传播
})
</script>

对比Vue2的选项和Vue3的Composition API写法:

Vue2选项Vue3钩子函数说明
beforeCreatesetup()替代
createdsetup()替代
beforeMountonBeforeMount挂载前
mountedonMounted挂载后
beforeUpdateonBeforeUpdate更新前
updatedonUpdated更新后
beforeDestroyonBeforeUnmount卸载前
destroyedonUnmounted卸载后

组合式函数:Composition API的精髓

组合式函数(Composables)是Composition API最强大的特性。它让你把相关逻辑抽离成可复用的函数。

什么是组合式函数

简单说,组合式函数就是一个用Composition API写的有复用价值的函数:

javascript

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

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const doubleCount = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  }
}

在组件中使用组合式函数

vue

<!-- Counter.vue -->
<script setup>
import { useCounter } from './useCounter'

// 在组件中使用
const { count, doubleCount, increment, decrement, reset } = useCounter(10)
</script>

<template>
  <div>
    <p>计数:{{ count }}</p>
    <p>双倍:{{ doubleCount }}</p>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
    <button @click="reset">重置</button>
  </div>
</template>

现在任何组件想用计数器功能,只需调用这个函数,不需要继承、不需要mixin,代码清晰多了。

实战:封装一个请求数据的组合式函数

javascript

// useFetch.js
import { ref, watchEffect, isRef } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)
  
  async function doFetch() {
    loading.value = true
    error.value = null
    
    try {
      // 支持传入ref或普通字符串
      const urlToFetch = isRef(url) ? url.value : url
      const response = await fetch(urlToFetch)
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  // 如果url是ref,自动追踪变化并重新请求
  if (isRef(url)) {
    watchEffect(doFetch)
  } else {
    doFetch()
  }
  
  return { data, error, loading, refresh: doFetch }
}

使用这个函数:

vue

<script setup>
import { ref } from 'vue'
import { useFetch } from './useFetch'

// 直接传字符串
const { data: users, loading: usersLoading, error: usersError } = useFetch('/api/users')

// 传ref,当id变化时自动重新请求
const userId = ref('1')
const { data: userDetail, loading, error } = useFetch(
  computed(() => `/api/users/${userId.value}`)
)

function changeUser() {
  userId.value = String(parseInt(userId.value) + 1)
}
</script>

<template>
  <div>
    <button @click="changeUser">切换用户</button>
    
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">出错了:{{ error.message }}</div>
    <div v-else>
      <p>用户详情:{{ userDetail }}</p>
    </div>
  </div>
</template>

实战:封装一个本地存储的组合式函数

javascript

// useStorage.js
import { ref, watch } from 'vue'

export function useStorage(key, defaultValue) {
  // 从localStorage读取初始值
  const storedValue = localStorage.getItem(key)
  const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
  
  // 监听变化,自动保存到localStorage
  watch(data, (newValue) => {
    if (newValue === null || newValue === undefined) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(newValue))
    }
  }, { deep: true })
  
  return data
}

使用:

vue

<script setup>
import { useStorage } from './useStorage'

// 第一个参数是localStorage的key
// 第二个参数是默认值
const username = useStorage('username', '')
const theme = useStorage('theme', 'light')
const preferences = useStorage('preferences', { fontSize: 14, language: 'zh' })

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>

<template>
  <div>
    <input v-model="username" placeholder="输入用户名" />
    <p>当前主题:{{ theme }}</p>
    <button @click="toggleTheme">切换主题</button>
    <p>首选项:{{ preferences }}</p>
  </div>
</template>

依赖注入:跨层级传递数据

props层层传递很麻烦?依赖注入帮你解决。

provide和inject

父组件提供数据,后代组件直接获取:

vue

<!-- Parent.vue -->
<script setup>
import { provide, ref } from 'vue'

const theme = ref('light')
const user = ref({ name: '张三', role: 'admin' })

// 提供给所有后代组件
provide('theme', theme)
provide('user', user)

// 也可以提供函数
provide('toggleTheme', () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
})
</script>

<template>
  <div :class="theme">
    <ChildComponent />
  </div>
</template>

vue

<!-- GrandChild.vue(不需要父传子的props) -->
<script setup>
import { inject } from 'vue'

// 注入数据
const theme = inject('theme')
const user = inject('user')
const toggleTheme = inject('toggleTheme')
</script>

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

带默认值的注入

vue

<script setup>
import { inject } from 'vue'

// 第二个参数是默认值
const config = inject('config', { apiUrl: '/api', timeout: 5000 })

// 或者用函数提供默认值(适合引用类型)
const theme = inject('theme', () => ref('light'), true)
</script>

模板引用:操作DOM

虽然Vue推崇数据驱动DOM,但有些时候还是需要直接操作DOM元素。

ref绑定元素

vue

<script setup>
import { ref, onMounted } from 'vue'

// 创建ref
const inputRef = ref(null)
const canvasRef = ref(null)

onMounted(() => {
  // 访问DOM元素
  inputRef.value.focus()
  
  // 操作Canvas
  const ctx = canvasRef.value.getContext('2d')
  ctx.fillStyle = 'green'
  ctx.fillRect(0, 0, 100, 100)
})
</script>

<template>
  <div>
    <!-- 绑定ref -->
    <input ref="inputRef" type="text" />
    <canvas ref="canvasRef" width="200" height="200"></canvas>
  </div>
</template>

ref绑定组件实例

vue

<!-- ChildComponent.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function getCount() {
  return count.value
}

// 暴露方法给父组件
defineExpose({
  count,
  increment,
  getCount
})
</script>

vue

<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref(null)

function handleClick() {
  console.log('子组件count:', childRef.value.count)
  childRef.value.increment()
  console.log('调用后count:', childRef.value.getCount())
}
</script>

<template>
  <div>
    <ChildComponent ref="childRef" />
    <button @click="handleClick">操作子组件</button>
  </div>
</template>

实战:Todo应用

学完上面的知识,来写个完整的Todo应用练练手。

项目结构

plaintext

src/
├── components/
│   ├── TodoList.vue
│   ├── TodoItem.vue
│   ├── TodoInput.vue
│   └── TodoFilters.vue
├── composables/
│   └── useTodos.js
├── App.vue
└── main.js

useTodos.js:封装Todo逻辑

javascript

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

export function useTodos() {
  // 状态
  const todos = ref([])
  const filter = ref('all') // all, active, completed
  
  // 本地存储
  const STORAGE_KEY = 'vue3-todos'
  
  // 初始化时从localStorage读取
  const storedTodos = localStorage.getItem(STORAGE_KEY)
  if (storedTodos) {
    try {
      todos.value = JSON.parse(storedTodos)
    } catch (e) {
      console.error('读取本地存储失败:', e)
    }
  }
  
  // 保存到localStorage
  watch(todos, (newTodos) => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(newTodos))
  }, { deep: true })
  
  // 计算属性
  const filteredTodos = computed(() => {
    if (filter.value === 'active') {
      return todos.value.filter(t => !t.completed)
    } else if (filter.value === 'completed') {
      return todos.value.filter(t => t.completed)
    }
    return todos.value
  })
  
  const activeCount = computed(() => 
    todos.value.filter(t => !t.completed).length
  )
  
  const completedCount = computed(() => 
    todos.value.filter(t => t.completed).length
  )
  
  // 方法
  function addTodo(title) {
    if (!title.trim()) return
    
    todos.value.push({
      id: Date.now(),
      title: title.trim(),
      completed: false,
      createdAt: new Date().toISOString()
    })
  }
  
  function toggleTodo(id) {
    const todo = todos.value.find(t => t.id === id)
    if (todo) {
      todo.completed = !todo.completed
    }
  }
  
  function deleteTodo(id) {
    const index = todos.value.findIndex(t => t.id === id)
    if (index !== -1) {
      todos.value.splice(index, 1)
    }
  }
  
  function editTodo(id, newTitle) {
    const todo = todos.value.find(t => t.id === id)
    if (todo && newTitle.trim()) {
      todo.title = newTitle.trim()
    }
  }
  
  function clearCompleted() {
    todos.value = todos.value.filter(t => !t.completed)
  }
  
  function setFilter(newFilter) {
    filter.value = newFilter
  }
  
  return {
    todos,
    filter,
    filteredTodos,
    activeCount,
    completedCount,
    addTodo,
    toggleTodo,
    deleteTodo,
    editTodo,
    clearCompleted,
    setFilter
  }
}

TodoInput.vue:输入组件

vue

<script setup>
import { ref } from 'vue'

const emit = defineEmits(['add'])

const inputValue = ref('')

function handleSubmit() {
  if (inputValue.value.trim()) {
    emit('add', inputValue.value)
    inputValue.value = ''
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit" class="todo-input">
    <input 
      v-model="inputValue"
      type="text"
      placeholder="输入新任务..."
      class="input-field"
    />
    <button type="submit" class="add-btn">添加</button>
  </form>
</template>

<style scoped>
.todo-input {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.input-field {
  flex: 1;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
}

.input-field:focus {
  outline: none;
  border-color: #42b883;
}

.add-btn {
  padding: 12px 24px;
  background: #42b883;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 16px;
}

.add-btn:hover {
  background: #3aa876;
}
</style>

TodoItem.vue:单个任务组件

vue

<script setup>
import { ref } from 'vue'

const props = defineProps({
  todo: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['toggle', 'delete', 'edit'])

const isEditing = ref(false)
const editValue = ref('')

function startEdit() {
  editValue.value = props.todo.title
  isEditing.value = true
}

function saveEdit() {
  if (editValue.value.trim()) {
    emit('edit', props.todo.id, editValue.value)
  }
  isEditing.value = false
}

function cancelEdit() {
  isEditing.value = false
}
</script>

<template>
  <li class="todo-item" :class="{ completed: todo.completed }">
    <div v-if="isEditing" class="edit-mode">
      <input 
        v-model="editValue"
        @keyup.enter="saveEdit"
        @keyup.escape="cancelEdit"
        class="edit-input"
      />
      <button @click="saveEdit" class="save-btn">保存</button>
      <button @click="cancelEdit" class="cancel-btn">取消</button>
    </div>
    <div v-else class="view-mode">
      <input 
        type="checkbox"
        :checked="todo.completed"
        @change="$emit('toggle', todo.id)"
        class="checkbox"
      />
      <span class="title">{{ todo.title }}</span>
      <button @click="startEdit" class="edit-btn">编辑</button>
      <button @click="$emit('delete', todo.id)" class="delete-btn">删除</button>
    </div>
  </li>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  padding: 12px;
  background: #f9f9f9;
  border-radius: 6px;
  margin-bottom: 8px;
}

.todo-item.completed .title {
  text-decoration: line-through;
  color: #999;
}

.checkbox {
  width: 20px;
  height: 20px;
  margin-right: 12px;
  cursor: pointer;
}

.title {
  flex: 1;
  font-size: 16px;
}

button {
  padding: 6px 12px;
  margin-left: 8px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.edit-btn {
  background: #e0e0e0;
  color: #333;
}

.delete-btn {
  background: #ff4444;
  color: white;
}

.edit-mode {
  display: flex;
  width: 100%;
}

.edit-input {
  flex: 1;
  padding: 8px;
  border: 1px solid #42b883;
  border-radius: 4px;
  font-size: 16px;
}

.save-btn {
  background: #42b883;
  color: white;
  margin-left: 8px;
}

.cancel-btn {
  background: #e0e0e0;
  color: #333;
}
</style>

TodoFilters.vue:筛选组件

vue

<script setup>
defineProps({
  filter: {
    type: String,
    required: true
  },
  activeCount: {
    type: Number,
    required: true
  },
  completedCount: {
    type: Number,
    required: true
  }
})

const emit = defineEmits(['setFilter', 'clearCompleted'])

const filters = [
  { key: 'all', label: '全部' },
  { key: 'active', label: '进行中' },
  { key: 'completed', label: '已完成' }
]
</script>

<template>
  <div class="filters">
    <div class="filter-buttons">
      <button
        v-for="f in filters"
        :key="f.key"
        @click="emit('setFilter', f.key)"
        :class="{ active: filter === f.key }"
        class="filter-btn"
      >
        {{ f.label }}
      </button>
    </div>
    
    <div class="stats">
      <span>进行中:{{ activeCount }}</span>
      <span>已完成:{{ completedCount }}</span>
      <button 
        v-if="completedCount > 0"
        @click="emit('clearCompleted')"
        class="clear-btn"
      >
        清除已完成
      </button>
    </div>
  </div>
</template>

<style scoped>
.filters {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 0;
  border-top: 1px solid #eee;
}

.filter-buttons {
  display: flex;
  gap: 8px;
}

.filter-btn {
  padding: 6px 12px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 4px;
  cursor: pointer;
}

.filter-btn.active {
  background: #42b883;
  color: white;
  border-color: #42b883;
}

.stats {
  display: flex;
  gap: 16px;
  align-items: center;
  font-size: 14px;
  color: #666;
}

.clear-btn {
  padding: 4px 8px;
  background: #ff4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}
</style>

TodoList.vue:整合所有组件

vue

<script setup>
import { useTodos } from '../composables/useTodos'
import TodoInput from './TodoInput.vue'
import TodoItem from './TodoItem.vue'
import TodoFilters from './TodoFilters.vue'

const {
  filter,
  filteredTodos,
  activeCount,
  completedCount,
  addTodo,
  toggleTodo,
  deleteTodo,
  editTodo,
  clearCompleted,
  setFilter
} = useTodos()
</script>

<template>
  <div class="todo-list">
    <h1 class="title">Vue3 Todo应用</h1>
    
    <TodoInput @add="addTodo" />
    
    <ul class="todos">
      <TodoItem
        v-for="todo in filteredTodos"
        :key="todo.id"
        :todo="todo"
        @toggle="toggleTodo"
        @delete="deleteTodo"
        @edit="editTodo"
      />
    </ul>
    
    <div v-if="filteredTodos.length === 0" class="empty">
      暂无任务
    </div>
    
    <TodoFilters
      :filter="filter"
      :active-count="activeCount"
      :completed-count="completedCount"
      @set-filter="setFilter"
      @clear-completed="clearCompleted"
    />
  </div>
</template>

<style scoped>
.todo-list {
  max-width: 600px;
  margin: 40px auto;
  padding: 24px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.title {
  text-align: center;
  color: #42b883;
  margin-bottom: 24px;
}

.todos {
  list-style: none;
  padding: 0;
  margin: 0;
}

.empty {
  text-align: center;
  padding: 40px;
  color: #999;
}
</style>

App.vue:主组件

vue

<script setup>
import TodoList from './components/TodoList.vue'
</script>

<template>
  <div class="app">
    <TodoList />
  </div>
</template>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background: #f5f5f5;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.app {
  min-height: 100vh;
  padding: 20px;
}
</style>

运行效果:

bash

npm run dev

打开浏览器访问http://localhost:5173,一个完整的Todo应用就完成了。

总结

好了,这篇Vue3 Composition API教程就到这里。让我回顾一下今天学的内容:

  1. 环境搭建:Vite创建Vue3项目,基本组件结构
  2. 响应式系统:ref和reactive的区别,深度响应式
  3. 计算属性和监听器:computed、watch、watchEffect
  4. 生命周期钩子:从创建到销毁的各个阶段
  5. 组合式函数:封装可复用的逻辑
  6. 依赖注入:provide和inject跨层级传递数据
  7. 模板引用:ref绑定DOM和组件
  8. 实战项目:完整的Todo应用

说实话,Composition API确实比Options API更灵活、更强大。它让你能够:

  • 把相关逻辑放在一起,不用在data、methods、computed之间跳来跳去
  • 抽离出可复用的组合式函数,比mixin清晰多了
  • 更好地组织TypeScript代码(虽然本教程没用TypeScript,但Vue3对TS的支持很好)

如果你之前用Vue2,可以渐进式迁移。新项目建议直接上Composition API,老项目可以逐步把新组件改成新写法。

关于内链方面,你可以继续学习TypeScript入门完全指南,TypeScript+Vue3是绝配。或者学习RESTful API接口设计规范,了解如何设计后端API配合Vue3前端使用。

常见问题

Q:Composition API和Options API哪个好?

A:Composition API更好。它让代码逻辑更集中,更容易复用,也更适合TypeScript。新项目建议用Composition API。

Q:需要完全放弃Vue2吗?

A:不需要。Vue3完全兼容Options API语法,老项目可以慢慢迁移。可以先在新组件使用Composition API,老组件保持Options API写法。

Q:什么时候用ref,什么时候用reactive?

A:建议统一用ref。reactive有一些坑(比如解构后失去响应式),ref没有这个问题,而且语法更一致。

Q:组合式函数为什么要用”use”开头?

A:这是一个约定俗成的命名方式,类似于React的”hooks”命名。它让代码更易读,一看就知道这是个组合式函数,可以自动追踪依赖。

Q:Vue3适合做大型项目吗?

A:非常适合。Composition API让代码更容易组织和复用,TypeScript支持也很好。我见过不少几百个组件的大型Vue3项目,代码依然保持很好的可维护性。

如果你有任何问题或建议,欢迎在评论区交流!

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注