Composition API 是 Vue 3 最重要的架构变革。它将组件的逻辑按"关注点"组织(而非按 Options API 的 data/computed/methods 分散),使复杂组件更易维护、逻辑更易复用。本文是 Vue Composition API 的完整参考手册。
一、<script setup>:Composition API 的语法糖
<script setup> 是 Vue 3.2+ 推荐的单文件组件写法,编译时自动将组件的逻辑转化为 setup() 函数。
<script setup>
// 所有变量和函数自动暴露给模板
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
等价于:
<script>
import { ref } from 'vue'
export default {
setup() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
},
}
</script>
二、ref 与 reactive:响应式基础
2.1 ref:值的响应式包装
import { ref } from 'vue'
const count = ref(0) // Ref<number>
const name = ref('Alice') // Ref<string>
const user = ref({ age: 25 }) // Ref<{ age: number }>
// 读取/修改都需要 .value
console.log(count.value) // 0
count.value++ // 触发更新
console.log(count.value) // 1
// 模板中自动解包,无需 .value
// <template><p>{{ count }}</p></template>
2.2 reactive:对象的深度响应式
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Alice', age: 25 },
items: [],
})
state.count++ // 直接修改,无需 .value
state.user.name = 'Bob' // 深度响应式
state.items.push('apple') // 数组方法正常工作
2.3 ref vs reactive 选型表
| 场景 | 推荐 | 原因 |
|---|---|---|
| 基本类型 | ref | reactive 不支持基本类型 |
| 对象字面量 | reactive 或 ref | reactive 代码更简洁 |
| 需要解构赋值 | ref | reactive 解构会丢失响应性 |
| 作为函数返回值 | ref | 避免 reactive 的展开陷阱 |
| 表单数据 | reactive | 属性访问方便 |
| Props 传递 | ref | 包装值更安全 |
三、computed:计算属性
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// 只读计算属性
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// 可写计算属性
const displayName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => {
const [first, last] = val.split(' ')
firstName.value = first
lastName.value = last
},
})
// 使用
// displayName.value = 'Jane Smith' // 自动拆分赋值
computed vs method:
computed有缓存——依赖不变时不会重新计算method每次渲染都会执行- 需要缓存的结果用
computed,不需要缓存的操作用function
四、watch 与 watchEffect:侦听器
4.1 watch:显式追踪
import { ref, watch } from 'vue'
const query = ref('')
const results = ref([])
// 监听单个 ref
watch(query, async (newVal, oldVal) => {
results.value = await searchApi(newVal)
}, { immediate: false }) // immediate: 创建时立即执行一次
// 监听多个源
watch([query, page], async ([newQuery, newPage]) => {
results.value = await searchApi(newQuery, newPage)
})
// 监听深层对象(默认只监听引用变化)
watch(
() => state.user, // getter 返回要监听的对象
(newVal, oldVal) => { /* ... */ },
{ deep: true } // 深度监听
)
// 获取旧值(对象类型需注意结构问题)
watch(user, (newVal, oldVal) => {
console.log(`${oldVal?.name} → ${newVal?.name}`)
})
4.2 watchEffect:自动追踪
import { ref, watchEffect } from 'vue'
const count = ref(0)
const multiplier = ref(2)
// 自动追踪所有响应式依赖
watchEffect(() => {
console.log(count.value * multiplier.value) // 追踪 count 和 multiplier
})
// 带清理
watchEffect((onCleanup) => {
const timer = setInterval(() => {
console.log(count.value)
}, 1000)
onCleanup(() => {
clearInterval(timer) // 下一次执行或组件卸载时清理
})
})
watch vs watchEffect:
| 特性 | watch | watchEffect |
|---|---|---|
| 依赖追踪 | 显式声明 | 自动追踪 |
| 旧值访问 | ✅ 可以 | ❌ 不可以 |
| 立即执行 | 需要配置 immediate | ✅ 默认执行 |
| 适用 | 精确控制 | 副作用集成 |
五、生命周期钩子
import {
onBeforeMount, onMounted,
onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted,
onErrorCaptured,
} from 'vue'
// 挂载
onBeforeMount(() => console.log('组件挂载前'))
onMounted(() => console.log('组件已挂载'))
// 更新
onBeforeUpdate(() => console.log('组件更新前'))
onUpdated(() => console.log('组件已更新'))
// 卸载
onBeforeUnmount(() => console.log('组件卸载前'))
onUnmounted(() => console.log('组件已卸载'))
// 错误捕获
onErrorCaptured((err, instance, info) => {
console.error('捕获到错误:', err, info)
return false // true: 继续向上传播,false: 阻止传播
})
六、组件通信
6.1 Props + Emits
<!-- Parent.vue -->
<script setup>
import Child from './Child.vue'
const title = 'Hello'
const handleUpdate = (val) => console.log(val)
</script>
<template>
<Child :title="title" @update="handleUpdate" />
</template>
<!-- Child.vue -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'delete'])
const notifyParent = () => {
emit('update', 'new value')
}
</script>
6.2 自定义 v-model
<!-- CustomInput.vue -->
<script setup>
const model = defineModel() // Vue 3.4+ 简化写法
// 等价于:const props = defineProps(['modelValue'])
// const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input v-model="model" />
</template>
<!-- 使用 -->
<CustomInput v-model="text" />
6.3 provide / inject
// 祖先组件
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
provide('toggleTheme', () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
})
// 后代组件
import { inject } from 'vue'
const theme = inject('theme', 'light') // 默认值 'light'
const toggleTheme = inject('toggleTheme')
七、自定义 Composable:逻辑复用
7.1 Composable 规范
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
watch(data, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
}, { deep: true })
return data // 返回 ref,方便解构
}
// 使用
const userPref = useLocalStorage('user-pref', { theme: 'dark', lang: 'zh' })
userPref.value.theme = 'light' // 自动同步到 localStorage
7.2 常用 Composable 示例
// useAsync.ts —— 异步数据获取
export function useAsync<T>(fn: () => Promise<T>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
data.value = await fn()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
return { data, error, loading, execute }
}
// useEventListener.ts —— 事件监听
export function useEventListener(
target: EventTarget,
event: string,
callback: EventListener
) {
onMounted(() => target.addEventListener(event, callback))
onUnmounted(() => target.removeEventListener(event, callback))
}
八、完整组件示例
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
// ========== Props / Emits ==========
const props = defineProps<{
initialQuery: string
pageSize: number
}>()
const emit = defineEmits<{
search: [results: SearchResult[]]
}>()
// ========== State ==========
const query = ref(props.initialQuery)
const page = ref(1)
const results = ref<SearchResult[]>([])
const loading = ref(false)
const error = ref('')
// ========== Computed ==========
const hasNextPage = computed(() => results.value.length >= props.pageSize)
// ========== Methods ==========
async function fetchResults() {
loading.value = true
error.value = ''
try {
const data = await searchApi(query.value, page.value, props.pageSize)
results.value = data
emit('search', data)
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
function nextPage() {
page.value++
}
// ========== Watchers ==========
watch(query, () => {
page.value = 1 // 查询变化时重置页码
fetchResults()
}, { debounce: 300 }) // 防抖(需实现或使用第三方库)
watch(page, fetchResults)
// ========== Lifecycle ==========
onMounted(() => {
fetchResults()
})
</script>
<template>
<div class="search">
<input v-model="query" placeholder="Search..." />
<p v-if="loading">Loading...</p>
<p v-else-if="error" class="error">{{ error }}</p>
<ul v-else>
<li v-for="item in results" :key="item.id">{{ item.name }}</li>
</ul>
<button v-if="hasNextPage" @click="nextPage" :disabled="loading">
Next Page
</button>
</div>
</template>
九、深入响应式原理:Proxy vs Object.defineProperty
Vue 3 的响应式系统基于 ES6 Proxy,与 Vue 2 的 Object.defineProperty 有本质区别:
| 特性 | Vue 2 (defineProperty) | Vue 3 (Proxy) |
|---|---|---|
| 拦截能力 | 仅 getter/setter | get/set/has/ownKeys/deleteProperty 等 13 种操作 |
| 新增/删除属性 | 检测不到(需 Vue.set/Vue.delete) | 自动检测 |
| 数组索引/length | 部分支持(改写 7 个变异方法) | 原生支持 |
| Map/Set/WeakMap | 不支持响应式 | 原生支持 |
| 性能 | 初始化时遍历所有属性定义 getter/setter | 懒拦截——访问时才建立依赖 |
// Vue 3 的 Proxy 实现简化示意
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key) // 收集依赖(当前活跃的 effect)
return isObject(result) ? reactive(result) : result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (oldValue !== value) {
trigger(target, key) // 触发依赖更新
}
return result
}
})
}
Proxy 的懒拦截特性意味着:只有被模板或计算属性访问到的属性才是响应式的。一个 1000 个字段的对象,如果页面只用其中 3 个,Vue 3 只为这 3 个建立依赖追踪——Vue 2 则需要遍历全部。
shallowRef 与 shallowReactive
对于不需要深度响应式的大对象(如第三方库实例、DOM 元素),避免无意义的递归代理开销:
import { shallowRef, shallowReactive } from 'vue'
// shallowRef:值替换时触发更新,但内部属性不是响应式
const bigObject = shallowRef({
nested: { deep: { value: 1 } } // nested.deep.value 不是响应式
})
bigObject.value = { ... } // ✅ 替换整个对象触发更新
bigObject.value.nested.deep.value = 2 // ❌ 不会触发更新
// shallowReactive:顶层属性响应式,嵌套对象不做代理
const state = shallowReactive({
title: 'Hello', // ✅ 响应式
config: { theme: 'dark' } // ❌ config.theme 不是响应式
})
适用场景:集成 Three.js 场景对象、ECharts 实例、或者从后端一次性加载的参考数据(不需要细粒度响应式修改)。
effectScope:批量控制副作用生命周期
高级场景下需要手动控制多个 watch/watchEffect/computed 的批量创建与销毁:
import { effectScope, watch, ref } from 'vue'
const scope = effectScope()
scope.run(() => {
const count = ref(0)
watch(count, (v) => console.log(v))
// 这里的 watch 和 computed 都被绑定到 scope
})
// 组件卸载或条件变化时,一键停止所有副作用
scope.stop() // 比逐个调用 stop 更高效
effectScope 在选项卡切换、弹窗打开关闭、条件渲染等场景下尤为有用——避免已隐藏的组件残留的副作用继续运行。
十、扩展组合:Teleport、Suspense 与 Composable
<Teleport> 跨层级渲染
将组件的内容渲染到 DOM 的其他位置,常用于模态框、下拉菜单、Toast 通知:
<script setup>
const showModal = ref(false)
</script>
<template>
<button @click="showModal = true">打开</button>
<!-- Teleport:内容实际渲染到 body 末尾,避免 z-index 层级问题 -->
<Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal">
<h2>确认操作</h2>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
</template>
| 场景 | 是否用 Teleport | 原因 |
|---|---|---|
| 全屏模态框 | ✅ | 避免被父级 overflow: hidden 裁剪 |
| Toast 通知 | ✅ | 需要固定在视口顶层 |
| 下拉菜单 | 可选 | 简单场景 CSS absolute 足够 |
| 表单控件 | ❌ | 保持 DOM 层级一致性 |
<Suspense> 异步依赖等待
等待异步组件或异步 setup 完成后再渲染:
<template>
<Suspense>
<template #default>
<!-- 异步组件加载完成后显示 -->
<AsyncDashboard />
</template>
<template #fallback>
<!-- 加载状态 -->
<div class="skeleton-loader">正在加载仪表盘...</div>
</template>
</Suspense>
</template>
<!-- AsyncDashboard.vue -->
<script setup>
// 顶层 await:组件等待数据加载完成再渲染
const data = await fetchDashboardData()
</script>
注意:Suspense 目前(Vue 3.4+)是实验性稳定功能(experimental → stable),但错误边界处理仍需手动包装。
十一、性能优化战术
v-memo:细粒度跳过更新
<template>
<div v-memo="[item.id, item.status]">
<!-- 只有当 id 或 status 变化时才重新渲染 -->
<ComplexComponent :data="item" />
</div>
</template>
大数据列表(> 1000 行)配合 v-memo 可显著减少无关更新。与 v-for 联用:
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.score]">
{{ item.name }} - {{ item.score }}
</div>
markRaw 与 readonly:明确响应式边界
import { markRaw, readonly } from 'vue'
// markRaw:对象永远不变成响应式代理(适合外部库实例)
const chart = markRaw(new EChartsInstance())
// readonly:响应式但只读(传递给子组件防止意外修改)
const readonlyState = readonly(reactive({ count: 0 }))
// readonlyState.count++ // ❌ 运行时 warning,开发时即被拒绝
computed 缓存策略
computed 只在依赖变化时重新计算。但如果依赖是一个对象且内部属性频繁变化,可以考虑拆解为多个细粒度的 computed:
// ❌ 任何 user 内部变化都触发 fullProfile 重算
const fullProfile = computed(() => {
return `${user.value.name} | ${user.value.age} | ${user.value.bio}`
})
// ✅ 每个字段独立缓存,只有对应字段变化才触发
const displayName = computed(() => user.value.name)
const displayAge = computed(() => user.value.age)
十二、Composition API vs Options API:深度对比
| 维度 | Options API | Composition API |
|---|---|---|
| 代码组织 | 按选项(data/computed/methods) | 按逻辑关注点(useFeatureA, useFeatureB) |
| 逻辑复用 | Mixins(命名冲突、来源不明) | Composable(显式导入、函数级复用) |
| TypeScript 支持 | 需 defineComponent 泛型 | <script setup> + 纯类型推断 |
| 新手友好度 | 更直观(看选项名就知道是什么) | 陡峭一些,但更灵活 |
| 大型组件 | 逻辑分散在多个选项中 | 逻辑内聚,维护性更强 |
| ** tree-shaking** | 组件整体 | 可按函数级别 |
选型建议:
- 新项目 → Composition API(Vue 3 默认推荐)
- 小/中型组件(< 200 行)→ 两者皆可
- 需要大量逻辑复用 → Composition API(Composable)
- 团队刚从 Vue 2 迁移 → 可保留 Options API,逐步迁移
十三、与 Pinia 的配合模式
Pinia 是 Vue 官方推荐的状态管理库,与 Composition API 无缝集成:
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0)
const history = ref<number[]>([])
// getters
const doubleCount = computed(() => count.value * 2)
// actions
function increment() {
history.value.push(count.value)
count.value++
}
function reset() {
count.value = 0
history.value = []
}
return { count, doubleCount, history, increment, reset }
})
<!-- Component.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const store = useCounterStore()
// storeToRefs:只解构出响应式状态(actions 直接取用)
const { count, doubleCount } = storeToRefs(store)
const { increment } = store
</script>
| Pinia 特性 | Composition API 配合 |
|---|---|
| Setup Store | 与 ref/computed 完全一致,无需学习新 API |
| $reset | 自定义 reset() action,比 Vuex 更灵活 |
| 插件系统 | 可编写 pinia-plugin-persistedstate 替代 useLocalStorage composable |
| DevTools | Vue DevTools 中对 store 状态的时轴调试 |
常见反模式
| 反模式 | 问题 | 修正 |
|---|---|---|
在 Pinia store 外直接修改 store.count | 失去追踪和 DevTools 支持 | 始终通过 action 修改 |
| 过度使用全局 store | 全局状态越多,耦合越重 | 优先用 props/emit,局部状态优先 |
在 store 中放入 UI 状态(如 isModalOpen) | 污染业务逻辑 | UI 状态留在组件,业务状态进 store |
常见问题(FAQ)
<script setup> 里怎么定义组件名?
<script setup>
defineOptions({ name: 'MyComponent' })
</script>
ref 和 reactive 可以混用吗?
可以。常见模式是用 reactive 管理局部状态,ref 管理需要传递或解构的值。
为什么 watch 监听 reactive 对象必须用 getter?
const state = reactive({ count: 0 })
// ❌ 无效:监听的是 state 的引用,不会触发
watch(state, () => {})
// ✅ 有效:getter 返回内部值
watch(() => state.count, (newVal) => {})
// ✅ 或用 deep: true
watch(() => state, handler, { deep: true })
相关阅读
- Vue 详解 — 核心概念与渐进式理念
- Vue Router 完全指南 — 路由守卫、懒加载
- Pinia 状态管理指南 — Vue 官方推荐的状态管理
- Vue + TypeScript 实战指南 — Composition API 的类型安全
- Vue 测试深度指南 — Vitest + Vue Test Utils
- Nuxt.js 完全指南 — 全栈 Vue 框架
- React 详解 — Vue 与 React 核心差异
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。