Vue 3组合式API精讲:从响应式基础到组件通信实战

📅 发布时间:2026/10/15 19:04:26
Vue 3组合式API精讲:从响应式基础到组件通信实战
如果你是从 JavaScript 语法课一路跟过来的到这一节基本算是站在了“能写页面”和“会写应用”的分水岭上。前面二十节把变量、函数、闭包、原型链、异步这些地基夯得差不多了现在要接触的是真正的前端工程化主角——Vue 3 组合式 API。这篇内容就是帮你把框架思维理顺搞清楚组合式 API 到底解决了什么痛点、怎么写才算用对以及新手最容易在哪儿翻车。网上关于 Vue 3 的教程很多但大多要么直接把官方文档复述一遍要么上来就甩一堆代码让人照抄。我这篇会用讲课、敲代码、踩坑三条线揉在一起的方式把 setup、ref、reactive、computed、watch、生命周期、组件通信和自定义组合式函数都过一遍。适合刚学完 JavaScript 基础、准备上手框架的同学也适合写过 Vue 2 但还没彻底转过弯来的老朋友。1. 为什么建议直接从组合式 API 入门而不是从选项式过渡很多老教程会告诉你“先学选项式 API再学组合式 API”因为 Vue 2 时代全是选项式写法。但在我看来如果你是全新入门大可不必绕这段路。组合式 API 不是 Vue 3 里的炫技功能而是对组件代码组织方式的一次重构直接用它起步反而能少走弯路。1.1 选项式 API 的痛点逻辑分散选项式 API 就是把同一个功能相关的代码拆到 data、computed、methods、watch 这些固定的“格子”里。组件小的时候这样挺清爽数据写在 data方法写在 methods计算属性写在 computed各归各位。但组件一旦超过两三百行问题就来了——一个完整的业务功能会被拆得七零八落。举个例子。你做一个用户列表页面需要加载数据、筛选、排序、选中、删除。在选项式 API 里加载数据的逻辑会散落在 created 钩子里一部分、data 里一部分、methods 里一部分而筛选和排序可能又会使用另外几个独立的数据字段和方法。你读代码的时候得在 data、methods、watch、created 之间来回跳转才能拼凑出一个完整功能的拼图。代码行数越多这种“跳转式阅读”成本越高维护起来也越痛苦。更麻烦的是逻辑复用。选项式时代想复用一段带响应式状态的逻辑通常得靠 mixin。mixin 能把公共逻辑混入组件但它有非常致命的问题你不知道 mixin 里定义了哪些字段、覆盖了哪些生命周期钩子。一个组件里混入两三个 mixin冲突和命名覆盖几乎是必然的排查起来让人头大。1.2 组合式 API 的回应按逻辑聚合组合式 API 的核心思路很简单把同一个业务逻辑相关的响应式数据、计算属性、方法、侦听器、生命周期钩子写在一起。你可以把这一段逻辑看成一个“功能块”它自己包含状态、行为和副作用。组件本质上变成多个功能块拼装起来的外壳。我管这种方式叫“按逻辑切片而不是按选项分类”。同样是用户列表页面你可以把加载、筛选、选中、删除分别抽成独立的小模块每个模块内部自成一体。读代码时不用上下来回跳转直接看这一个功能块的上下文就够了维护体验完全不一样。我见过不少从 Vue 2 转过来的开发者对新语法本能排斥觉得选项式“规规矩矩挺好”。但真实项目里组件复杂度上来后组合式 API 的按逻辑聚合几乎是对可维护性的刚需。你自己写几个 500 行左右的组件对比一下会明显感受到差异。这也是为什么 Vue 3 官方把组合式 API 定位为推荐主用而非可选插件。2. 五大核心概念讲透响应式基础组合式 API 的地基是 setup 这个函数凡是跟响应式数据、计算属性、侦听器、生命周期相关的能力都要在 setup 内展开。这节我把最核心的五个概念拆开讲清楚并且解释每个 API 背后的机制避免只会照抄不会选型。2.1 setup一切从这里开始在script setup语法糖普及之前你需要在组件里手动写一个 setup 函数所有组合式功能都在它内部定义最后把需要在模板里用的数据和方法 return 出去。现在用script setup之后setup 被隐式创建顶层变量和函数可以直接被模板使用少了一大堆 return 代码日常开发也更清爽。但 setup 的本质我们需要理解清楚。setup 是在组件实例创建之前同步执行的也就是说此时你是拿不到 this 的。传统选项式写法里this 指向组件实例可以在任意方法里访问 data 里的字段但在 setup 里 this 没有意义响应式数据靠的是 ref 和 reactive 创建的引用对象。还要记住一个顺序问题。setup 比 options API 里的 data、methods、created 这些都要更早初始化。这也是为什么通常把数据加载逻辑放在 onMounted 里而不是直接在 setup 里同步执行——setup 阶段组件还没挂载到 DOM某些操作如访问 DOM 节点是拿不到的。2.2 ref 和 reactive 怎么选这是组合式 API 最常见的选型纠结。ref 用于声明一个响应式的基本类型值或某个独立值底层是把值包进一个带 value 属性的对象里reactive 则用于声明一个嵌套较深的响应式对象。ref 实际使用中需要留意在 JavaScript 逻辑里必须通过.value来访问和修改值但在模板里 Vue 会自动解包直接写变量名就行。这一点新手特别容易懵——为什么 JS 里要加 .value模板里又不加我把规则概括成一句除了模板和响应式对象内部会自动解包其他场景都需要手动 .value。reactive 适合整个对象都是响应式状态的情况。它基于 Proxy 实现能深度拦截对象属性的读写。但有两个天生的坑需要提前知道第一对 reactive 返回的对象进行解构赋值会让解构出来的变量失去响应性第二把 reactive 对象重新赋值给另一个变量如果直接整个替换也会导致响应性丢失。这两个问题后面第三节会讲具体解决方案。我自己习惯的选型标准单个基本类型数据、表单输入的双向绑定值用 ref 简单直接需要一整块深层对象管理比如接口返回的嵌套数据、配置项集合用 reactive 更顺手。两者可以混用不要死守一种。2.3 computed 和 watch 的用途辨识计算属性和侦听器是响应式体系里的两个高频工具但很多新手会把它们混为一谈。可以简单这样理解computed 是从现有数据推导出新数据它本身能缓存而且不修改原数据watch 是当某个数据变化时执行一段副作用逻辑它在“等待”变化。computed 的典型场景是购物车总价、筛选后的列表、根据状态拼接文字这类“数据加工”。它依赖的数据变它就自动重算依赖没变多次访问直接读缓存。大多数情况下能用 computed 解决的问题不要用 watch 去手动同步代码会更声明式、更不容易出错。watch 则适合异步场景或副作用操作比如监听搜索关键词变化然后防抖请求接口、监听路由参数变化重新加载数据、监听某个表单字段变化后联动修改其他字段。它支持监听 ref、reactive 对象以及 getter 函数。watch 的另一个实用参数是 immediate设置为 true 时会在创建侦听器后立刻执行一次回调适合“初始化时就要执行一遍”的加载逻辑。不过注意如果监听的是 reactive 对象回调里的新值和旧值其实都指向同一个引用你没法直接拿到变化前的深拷贝数据这是由引用类型特性决定的后面踩坑部分会重点说。2.4 生命周期在 setup 里的写法Vue 2 的选项式生命周器有 beforeCreate、created、beforeMount、mounted 等。到了 setup 里beforeCreate 和 created 直接被 setup 本身替代其余钩子改名且统一加上了 on 前缀比如 mounted 变成 onMountedbeforeDestroy 对应 onBeforeUnmount。在script setup里直接引入这些方法即可import { onMounted, onBeforeUnmount, onUpdated } from vue onMounted(() { // 组件挂载后执行请求数据、操作 DOM、注册事件监听等 console.log(组件已挂载) }) onBeforeUnmount(() { // 组件卸载前执行清理定时器、移除事件监听、取消请求等 console.log(组件即将卸载) })这一个语法转换看起来简单但背后的思维方式要转变组合式 API 不看“组件在哪个生命周期阶段”而是看“这段逻辑需要哪个生命周期钩子配合”。同一个功能块的加载逻辑、副作用清理逻辑可以写在同一段代码块内而不是分散在不同位置。3. 从零写一个可复用的 TodoList组合式 API 实操理论讲再多不如动手敲一个完整例子。我用 TodoList 这个经典场景把 ref、computed、watch、组合式函数都串起来展示一个从需求拆解到代码落地的完整过程。这个例子也能直接套用到大多数管理后台的“列表 筛选 新增 删除”场景。3.1 需求拆分思路一个基础 TodoList 需要满足这几个功能显示任务列表新增任务删除任务按状态筛选任务显示未完成任务数量本地存储持久化。在动手写代码前先把数据结构和状态设计好这步比写代码本身更重要。任务项的数据结构可以这样定id、标题、是否完成。列表本身是响应式数组筛选条件是一个字符串。所有状态都围绕这三个核心数据展开。持久化用 localStorage 即可不引入额外库。3.2 数据声明与核心操作实现先创建项目使用 Vite 初始化然后只修改一个组件的代码来跑通全流程。第一步是声明响应式数据。import { ref, reactive, computed, watch, onMounted } from vue // 任务列表使用 ref 管理数组 const todos ref([ { id: 1, title: 学习 Vue3 组合式 API, done: false }, { id: 2, title: 完成 TodoList 练习, done: false }, ]) // 新增任务的输入框内容 const newTodo ref() // 筛选条件all / active / completed const filter ref(all)这里我选择用 ref 存数组而不是 reactive是因为 ref 能允许后续整个数组重新赋值为新数组而 reactive 只能对已有数组进行 push、splice 等操作。这也呼应了前面说的选型逻辑。新增任务和删除任务的方法// 新增任务 function addTodo() { const title newTodo.value.trim() if (!title) return todos.value.push({ id: Date.now(), title, done: false, }) newTodo.value } // 删除任务 function removeTodo(id) { todos.value todos.value.filter((todo) todo.id ! id) }使用Date.now()生成 id 在简单示例中可行但真实项目里如果操作频繁且需要并发安全最好用自增计数器或后端生成的唯一 ID。这个细节新手常忽略后面扩展时要注意。筛选逻辑用 computed 实现// 筛选后的任务列表 const filteredTodos computed(() { if (filter.value active) { return todos.value.filter((todo) !todo.done) } if (filter.value completed) { return todos.value.filter((todo) todo.done) } return todos.value }) // 未完成任务数量 const remainingCount computed(() { return todos.value.filter((todo) !todo.done).length })这两个 computed 都没有直接修改源数据属于典型的“从数据推导视图状态”非常符合声明式思路。3.3 本地持久化与副作用管理现在引入 watch 来做本地存储。当 todos 发生变化时自动把最新列表写入 localStorage。因为是监听 ref 数组的深层变化要加上 deep: true。watch( todos, (newTodos) { localStorage.setItem(todos, JSON.stringify(newTodos)) }, { deep: true } )这里有个经验教训如果你第一版写的 watch 没有加 deep 选项然后发现刷新页面后数据没有持久化那不是 Vue 的 bug而是 ref 数组内部元素变化不会触发浅层监听。加 deep 后就能捕获数组项的增删和属性变动了。初始数据加载放在 onMountedonMounted(() { const stored localStorage.getItem(todos) if (stored) { try { todos.value JSON.parse(stored) } catch (e) { console.error(本地存储数据解析失败, e) } } })把 JSON.parse 包在 try/catch 里是一个好习惯。localStorage 里的数据可能被用户手动改坏、或者上一次写入时产生意外损坏直接解析会导致整个应用崩溃。真实项目中这种外部数据源的数据一定不要轻易信任。3.4 将逻辑提取为组合式函数上面这段代码已经可以在一个组件里完整工作但它还停留在“把逻辑写在组件里”的阶段。真正体现组合式 API 优势的是把整块逻辑抽成组合式函数这样多个组件可以共用彻底摆脱 mixin 的阴影。新建一个useTodo.js文件把从数据声明到存储持久化的全部逻辑搬进去// composables/useTodo.js import { ref, computed, watch, onMounted } from vue export function useTodo() { const todos ref([]) const newTodo ref() const filter ref(all) const filteredTodos computed(() { /* 同上面逻辑 */ }) const remainingCount computed(() { /* 同上面逻辑 */ }) function addTodo() { /* 同上面逻辑 */ } function removeTodo(id) { /* 同上面逻辑 */ } watch(todos, (newTodos) { localStorage.setItem(todos, JSON.stringify(newTodos)) }, { deep: true }) onMounted(() { /* 同上面逻辑 */ }) return { todos, newTodo, filter, filteredTodos, remainingCount, addTodo, removeTodo, } }在组件里使用import { useTodo } from ../composables/useTodo const { todos, newTodo, filter, filteredTodos, remainingCount, addTodo, removeTodo, } useTodo()这样一来组件模板里的数据绑定不用变但逻辑已经转移到一个可复用单元中。以后想做另一个页面只要这个页面也需要任务列表能力直接引入 useTodo 即可。这其实就是 VueUse 这类社区库的底层套路把通用逻辑封装成带 use 前缀的函数谁用谁引。组合式函数的命名规范建议统一用use开头这是社区约定俗成的规则看到名字就知道这是个组合式函数而且还让 IDE 的自动导入插件更智能地工作。4. 组件通信与逻辑传递defineProps、defineEmits、provide/inject上一篇 TodoList 只有一个组件真实项目不可能这样。一旦组件拆开了数据怎么在父子组件之间流动就成了绕不开的问题。这里重点讲清楚script setup下组件通信的三种主要方式以及各自适用的边界。4.1 父传子 props 的正确姿势父组件给子组件传递数据最常见的就是 props。在script setup中接收 props 直接用defineProps宏即可它不需要额外导入// 子组件 TodoItem.vue const props defineProps({ todo: { type: Object, required: true, }, index: { type: Number, default: 0, }, })在模板里可以直接使用todo.title。在脚本逻辑里则要通过props.todo访问。新手刚上手时容易犯一个错误在子组件里直接修改 props 的值。这会触发 Vue 的警告因为 props 是单向数据流子组件不应该直接篡改父组件传下来的数据。正确做法是当子组件需要控制某个内部状态时要么把 props 的值作为初始值拷贝到本地再改要么通过事件告知父组件去修改原数据。常见的解决手法是为 props 声明一个同名的本地 refconst props defineProps({ initialCount: { type: Number, default: 0 }, }) // 本地可变状态 const count ref(props.initialCount)但注意这种方式不会自动同步父组件的后续值。如果确实需要同步一般配合 watch 监听 props 变化再赋值或者直接考虑 v-model。4.2 子传父事件与 v-model 绑定子组件需要通知父组件数据变化时用defineEmits。比如 TodoItem 里完成状态变化不应该自己直接修改 todo.done而是把事件抛给父组件const emit defineEmits([toggle, remove]) function handleToggle() { emit(toggle, props.todo.id) } function handleRemove() { emit(remove, props.todo.id) }父组件这样监听TodoItem v-fortodo in filteredTodos :keytodo.id :todotodo toggletoggleTodo removeremoveTodo /这个模式比直接改 props 健康得多数据的唯一修改入口留在父组件内部子组件只负责“报告发生了什么”。更高级的用法是 v-model 双向绑定。在 Vue 3 中组件上的 v-model 是通过“名为 modelValue 的 prop 名为 update:modelValue 的事件”来实现的所以子组件可以这样封装一个基础输入组件const props defineProps({ modelValue: { type: String, default: }, }) const emit defineEmits([update:modelValue]) function onInput(event) { emit(update:modelValue, event.target.value) }input :valuemodelValue inputonInput /这样父组件v-modelkeyword就能和子组件内部输入天然打通。我还见过有人去覆盖 modelValue 的值这种行为等于直接破坏单向数据流后续越改越乱。记住props 只读修改它的唯一方式是 emit 事件让父组件改。4.3 跨层级 provide/inject 速成当组件层级很深比如三层四层嵌套每一层都要通过 props 转发数据会很累。此时用 provide/inject 更合适。在父组件中import { provide, ref } from vue const user ref({ name: 悟空, role: admin }) provide(user, user)在任意深度的子组件中import { inject } from vue const user inject(user)inject 拿到的是同一份响应式引用修改它时同样会影响提供方但多数情况下建议只读使用或者由提供方暴露修改方法来保证数据流清晰。provide 还可以传函数这样子组件就可以通过调用提供的函数回调来通知上层更新。它的适用边界要把握清楚频繁用于跨多层的全局状态或者用于主题、当前用户信息等“全局上下文”。如果只是父子两层关系老老实实用 props 和 emit 更好不要为了炫技而引入 provide/inject那样反而让组件关系变得隐晦。5. 新手最容易踩的 5 个坑与排查技巧最后这部分是实战过程中最容易让人崩溃的报错和诡异行为。我把带过的项目、带过的新人踩过的坑总结成五类附上排查思路和解决代码每个都能直接定位。5.1 忘写.value与自动解包的边界新手第一次用 ref 时十有八九会在 JavaScript 逻辑里直接写todos []而不是todos.value []或者写count而不是count.value。结果是页面不更新或者控制台报错说“todos is not defined”。排除方法可以这样记忆ref 本身是一个带 value 属性的对象在模板中 Vue 会在渲染时自动解包在普通 JavaScript 逻辑中则不会你必须亲手访问 .value 属性。比喻来说ref 就是个包装盒模板里自动拆开让你直接看到里面的宝贝JS 里得自己动手拆。但要注意一个例外当 ref 作为对象属性被 reactive 对象包含时也会自动解包。比如const outer reactive({ count: ref(0) }) outer.count // 这里直接操作可以这种自动解包规则很容易让人迷糊建议调试阶段在关键位置打印一下变量确认它到底是 ref 对象还是已经解包的值。5.2 reactive 解构丢失响应性的原理与解法reactive 返回的对象基于 Proxy响应性能正常工作的前提是你访问的是这个 Proxy 对象本身。一旦你结构解构得到的是原始对象的普通副本Proxy 拦截自然失效。const state reactive({ list: [], loading: false }) // 错误示范 const { list, loading } state list.push(1) // 视图不会更新 // 正确做法直接通过 state.list 操作 state.list.push(1)如果确实需要解构可以用toRefs把每个属性转换成 ref这样解构出来的变量仍然保持响应性import { reactive, toRefs } from vue const state reactive({ list: [], loading: false }) const { list, loading } toRefs(state) // 此时 list 和 loading 是 ref模板里可以直接用JS 里要用 .value这个坑在接手别人代码时尤其多。我见过不少同事把 reactive 对象解构后浑然不知地还在频繁修改属性页面纹丝不动排查了半天才发现。5.3 watch 监听 reactive 对象时的引用陷阱当你watch(reactiveObj, callback)时回调里能拿到的 newVal 和 oldVal 其实是同一个对象引用。因为 Proxy 不会为每次变化创建新快照你无法直接从回调里获取变化前的完整数据。如果需要对比变化前后的差异有两个办法第一用 getter 函数形式监听具体字段比如watch(() state.page, (newPage, oldPage) ...)这样新值和旧值就是两个基本类型值对比非常方便。第二如果需要监听整个对象的前后状态快照可以配合 JSON 深拷贝或者 Vue 3.4 里的watch内部浅拷贝机制但一般业务场景不建议这么做成本不低。大多数情况下按字段监听即可这也是更精准的做法。5.4 计算属性被误认为可写computed 默认是只读的你写computedValue.value xxx会直接报错。它存在的意义是“由依赖推算出结果”改它就等于绕过数据流从设计上就说不通。但也存在需要可写计算属性的场景比如双向绑定到某个由多个字段推导出的值。此时要在 computed 里传入 getter 和 setter 两个函数const fullName computed({ get() { return ${firstName.value} ${lastName.value} }, set(newValue) { const parts newValue.split( ) firstName.value parts[0] lastName.value parts[1] || }, })这种写法仍然保持“数据单向流动”外部设置 fullName 会反向拆解到两个基础字段基础字段变化又驱动模板重新渲染。不过可写计算属性容易让逻辑变复杂除非确有必要否则建议少用。5.5 组合式函数命名与副作用管理自定义组合式函数必须以use开头这不是强制语法而是社区规范。不遵循规范也能跑但 IDE 插件如 unplugin-auto-import 不会自动识别导入同事阅读代码也会觉得别扭。副作用管理方面组合式函数内部如果注册了全局事件监听或定时器一定要提供销毁机制。在组件中使用时可通过 onScopeDispose 来清理副作用它在组件卸载时自动执行类似 onBeforeUnmount 的作用import { onScopeDispose } from vue export function useMousePosition() { const x ref(0) const y ref(0) function onMouseMove(event) { x.value event.clientX y.value event.clientY } window.addEventListener(mousemove, onMouseMove) onScopeDispose(() { window.removeEventListener(mousemove, onMouseMove) }) return { x, y } }如果你发现页面切到下一个路由后某些数据还在疯狂变化或者定时器还在执行多半就是组合式函数里的副作用没清理。这属于隐蔽性很强的内存泄漏不报错但影响性能越早养成清理习惯越好。再补充一个调试技巧Vue 3 的官方 devtools 对组合式 API 的调试体验比 Vue 2 时代友好很多。打开组件面板能直接看到当前组件里所有 ref 和 reactive 状态还能观测计算属性依赖关系。当你的页面渲染结果和预期不一致时先看 devtools 里的响应式状态是否正确再顺藤摸瓜排查模板绑定比打一堆 console.log 效率高得多。最后说两句我自己的长期使用体会。组合式 API 刚出来时我也怀疑它是不是为了变而变但真正在几个中大型项目里验证之后我确认它是 Vue 3 最值得迁移的理由。尤其是当你需要维护别人写的复杂组件时按逻辑聚合的代码读起来舒服太多了。如果你正在 JavaScript 基础学完后的迷茫期直接把 Vue 3 组合式 API 作为第一个正式框架来学不必回头啃选项式它会帮你从第一天就建立起“状态驱动视图”“逻辑可复用”的工程化思维。