想象一家老餐馆的后厨:食材堆在仓库(data),菜刀挂在墙上(methods),灶台在最里面(computed)。做一道红烧肉,你需要在两三个区域之间来回跑,食材、调料、步骤散落各处。如果同时出十道菜,后厨立刻乱成一锅粥。 Vue3 的 Composition API 就是一次“厨房革命”。它把后厨重新装修成“料理台模式”——每一道菜,从备菜到出锅的全套家伙都摆在同一张台面上,按“菜”(功能)聚类,而不是按“工具类型”分类。你要做红烧肉,就让红烧肉的所有步骤都在眼前;做酸菜鱼,就换一张台面。ref、reactive、computed、watch、自定义 Hook、provide/inject 这些新工具,就是新厨房里一个个顺手的小物件。 这篇文章从零开始,带你把组件写法从“老厨房”搬到“新厨房”,并讲清楚每个工具在实战中怎么用、什么时候用、为什么好用。
掌握 Composition API 是 Vue3 实战开发的分水岭,也是 AI 辅助编程时代高效描述业务逻辑、与团队协作的基本功。
可视化演示
展示Composition API三大核心:响应式系统数据流(ref/reactive→effect→DOM更新)、自定义Hook组合复用模式、以及组件生命周期钩子时序。交互切换不同机制视角。
本节配有交互动效(进入视口后自动加载,约 3KB)。
响应式系统数据流 ref/reactive/computed 依赖收集→effect触发→DOM更新 effect = () => { track(ref); render(DOM) } Proxy拦截实现依赖追踪与自动更新
逻辑重组革命
回到那家老厨房。你收到的订单是“一份糖醋排骨、一份清蒸鲈鱼”。老规矩要求:所有食材按类型放——糖和醋要去调料库取,排骨要去冷藏室拿,锅铲挂在工具墙。于是你做一道菜,要在仓库、工具墙、灶台之间折返十几次。更糟的是,如果厨房同时出十道菜,每个人的食材会混在一起,找一样东西要翻半天。
这是 Vue2 的 Options API 写复杂组件的真实体验:data 里堆数据、methods 里堆函数、computed 里堆派生值。一个“登录功能”用到的用户名、登录方法、验证逻辑、错误提示,分别散落在三个选项里。代码一旦超过三百行,想改一个功能的逻辑,得在文件中跳来跳去,维护成本急剧上升。
Composition API 的核心改革是“以功能为单位组织代码”:每个功能相关的状态、计算、监听、生命周期,全部写在一个代码块里。setup() 是统一入口,组件初始化时先进入 setup,所有组合式逻辑都在这里运行。数据用 ref、reactive 声明,计算用 computed,监听用 watch,生命周期钩子也可以直接在 setup 内调用。于是,组件的逻辑不再被“选项类型”切碎,而是被“业务功能”聚合。
老厨房的“分类储物”逻辑单看没错,但人脑是按“菜”思考的,不是按“食材类型”思考的。Composition API 让代码的思考方式和人的思考方式对齐——料理台模式,一菜一台,逻辑自然清晰。
- 分子求和第 i 个功能聚拢的代码量记为 $S_{i}^{\text{聚类}}$,
n表示组件里有 n 个独立功能,连加符号把这些功能聚拢的代码全部加总。 - 分母总代码$S_{\text{总代码}}$ 是整个组件的全部代码行数,作为对比基准。
- 比值含义比值 $C$ 越接近 1,说明代码高度按功能聚类;越接近 0,说明代码被按类型切碎后散落在各处。
setup语法糖
料理台模式听着好,但老板有顾虑:会不会每次做菜都要写三页申请单?Vue3 的 script setup 语法糖就是那张“简化开工单”——把流程里能自动化的环节全部自动化。
在传统 setup() 写法里,你需要在函数中手动声明一切,用 return 将变量暴露给模板,还要通过 components 选项注册子组件。script setup 把这些仪式感全部删掉:顶层声明的变量自动暴露给模板;import 进来的组件直接可用;defineProps 和 defineEmits 是编译器宏,无需导入直接使用;ref 在模板中自动解包,不用写 .value。
要注意,语法糖不改变响应式逻辑本身,它只是把“必须手写的样板”交给编译器处理。这就像料理包把“切菜备料”自动化,但你依然要决定放多少糖、焖几分钟——业务判断人来做,重复劳动机器做。开工单从三页缩成一页,师傅们开火更快,厨房产出自然更高。
script setup 写法的代码行数,右手是传统 setup() 写法的代码行数。R 越大,语法糖帮团队省下的样板代码越多。- 分子$S_{\text{script setup}}$ 表示使用
script setup语法糖后的代码行数,通常更短。 - 分母$S_{\text{setup()} \text{传统}}$ 表示传统
setup()加export default写法的代码行数。 - 精简率$R$ 为正说明代码量下降,R 越接近 1,说明语法糖带来的简化效果越显著。
响应式数据选型
新厨房里有两类基础容器:调味瓶(ref)和食材收纳盒(reactive)。调味瓶装的是“单颗粒子”——盐、糖、胡椒粉;收纳盒装的是“成套食材”——一块五花肉连同它的纹理、脂肪、部位信息,是一个整体。
ref 用来包装基本类型(number、string、boolean),也可以包对象。用 ref 包装后,读取和修改都要经过 .value——就像从调味瓶倒调料要拧开瓶盖。reactive 只能包装引用类型(对象、数组),它通过 Proxy 深度代理,访问属性直接用 obj.prop,无需取 .value。但 reactive 有一个著名陷阱:解构出来的属性会丢失响应性——就像把肉从收纳盒里拿出来放到案板上,案板上的肉和盒子里的温度计不再联动。需要保持响应性时,用 toRefs 把属性批量变成 ref。
调味瓶虽要求拧盖,但瓶盖设计顺手;收纳盒虽能整盒保鲜,但拆开单独用就失联。认清两者的脾气,选型就不再纠结。
- 上分支$\text{ref}$ 对应基本类型,比如一个计数器
count、一段加载状态isLoading。 - 下分支$\text{reactive}$ 对应对像,比如一个包含多个字段的表单模型。
- 选型经验场景满足上层条件走 ref,满足下层条件走 reactive,而实战中“默认 ref”是最稳妥的起点。
计算属性进阶
新厨房有个自动调味器:你告诉它“每 100 克肉配 15 克糖”,它不会每次重新称肉算糖,而是记住上次配比,只有当肉的分量真的变了,才重新算一遍。这就是 computed——带缓存的派生值。
computed 接收一个 getter 函数,返回派生值并缓存。只有 getter 依赖的响应式数据变化时,它才重新求值;否则每次访问都直接返回缓存,比普通方法高效。此外,computed 还可以传 { get, set } 对象,支持自定义 setter,实现“可写计算属性”,比如通过 v-model 绑定一个基于其它数据的派生值。而普通方法每次访问都会重新执行,没有缓存——就像每问一次盐量,厨师都要把整道菜重新尝一遍。
自动调味器把常用配比记在脑海里,省去重复尝菜的时间。这就是 computed 在长列表过滤、购物车合计等高频场景中又快又清晰的秘密。
y_cache,零开销;任一依赖变化,才调用计算函数 f 重新求值。- 缓存分支当依赖集合 $d$ 与上次相同时,直接返回缓存值 $y_{\text{cache}}$,不需要任何计算。
- 重算分支当任一依赖 $d_i$ 变化,调用函数 $f$ 对所有依赖重新求值。
- 依赖追踪Vue 在 getter 执行时自动收集读取到的响应式数据,构成依赖集合,这是判断缓存是否有效的依据。
侦听器进阶用法
厨房里的“火候报警器”(watch):你设定“汤沸腾时调小火”,它盯着锅,一旦状态达到条件,立刻按约定行动。Vue3 的 watch 就是这样一个状态警报器。
watch 监听一个响应式数据源,当它的值变化时执行回调。相比 Vue2,Vue3 的 watch 更灵活:可以监听 ref、reactive 对象的某个属性、getter 函数,甚至数组。选项 { deep: true } 开启深层监听,{ immediate: true } 让回调在设置时就先执行一次。watchEffect 则不用显式指定数据源,凡是回调里用到的响应式数据变化,它都自动触发——适合“按需收集依赖”的场合,比如日志上报、接口联调。此外,watch 回调中可以用 onCleanup 清理上一次的异步副作用,比如防抖请求的定时器。
报警器不是你自己每隔五秒跑去看锅,而是锅自己告诉你“我开了”。watch 让组件对状态变化做出反应,而不是靠事件驱动反复检查。
- 新值与旧值$v_{\text{new}}$ 是当前最新值,$v_{\text{old}}$ 是上一次的值,两者比较是触发的基础。
- 触发器条件成立时 $\text{trigger}=1$,watch 的回调被执行。
- 深层监听对于
reactive对象,Vue 会隐式开启深度比较,所以嵌套属性变化也能被捕获——这是 watch 与手动轮询的本质区别。
自定义Hook封装
如果糖醋排骨在店里卖得最好,厨师不会每天重新研究配方,而是提前把秘制酱料做成“预制料理包”。自定义 Hook 就是这个料理包:把一段可复用的逻辑——状态、计算、监听、生命周期——打包进一个函数,随用随拆。
自定义 Hook 是 Composition API 最具复用性的设计。命名约定以 use 开头,内部可以使用 ref、computed、watch、生命周期钩子等任意组合式 API,返回需要暴露的状态和方法。比如 useCounter 封装计数逻辑,useMouse 封装鼠标坐标监听,useLocalStorage 封装本地存储同步。组件中调用一个 Hook,就像拆开一包料理包倒进锅里,几行代码就获得完整功能,还能在不同组件间共享同一份逻辑。
料理包不是越多越好,关键是每一包都摸准一种做菜场景。Hook 同理:从重复代码中提炼,而不是为封装而封装。
- 分子$N_{\text{reused}}$ 是被两个及以上组件复用的自定义 Hook 数量。
- 分母$N_{\text{total}}$ 是项目中所有自定义 Hook 的总数量。
- 复用率乘以 100% 把比值转为百分比,U 越高代表抽取越精准。
跨层级依赖注入
老厨房里,老板在最顶层,帮厨在最底层。一道新菜上线,老板要把食材从顶层传到 B1、B2,再到帮厨手里,中间每层都要转手——这就是 props 逐层透传的痛苦。provide/inject 则是老板装了一部传菜电梯:顶层直接供应,底层直接取用,中间楼层完全不用管。
在顶层组件的 setup 里,用 provide(key, value) 提供数据;任意后代组件用 inject(key, defaultValue) 消费数据。value 可以是任意值,包括 ref 和 reactive——这样传递的响应式数据在注入端也能实时更新。需要注意,provide/inject 主要用于跨多层级的共享,比如主题配置、用户登录态、全局偏好;它不是响应式数据流的替代品,业务数据流向仍建议保持清晰。
传菜电梯让顶层和底层直接对话,中间层不再当二传手。代码里少掉十几个无关 props,维护时也不用再一层层翻找。
- 分子$D_{\text{skip}}$ 是使用注入后跳过的不再参与传参的中间层级数。
- 分母$D_{\text{total}}$ 是组件树从顶层到底层的总层级数。
- 效率比$E$ 接近 1 说明几乎所有层级都被跳过,注入收益最大化;接近 0 则说明层级很浅,直接透传也不麻烦。
AI 应用场景
AI 编程助手生成代码的整理与重构
阶段:AI 辅助前端开发
解决的问题:Copilot、ChatGPT 等 AI 工具生成的 Vue3 代码往往把逻辑平铺在 setup 里,缺少 Hook 意识,导致状态和方法混杂。利用 Composition API 的“按功能聚类”标准,开发者能迅速把 AI 输出重构为清晰的逻辑单元。
具体案例:开发智能客服工作台时,用 AI 生成用户登录、消息推送的初始代码,随后用 useAuth、useWebSocket 两个自定义 Hook 封装相关状态和副作用,组件体积减少约 40%,AI 生成的代码也从“能跑”升级为“好维护”。
LLM 流式输出的前端数据流管理
阶段:大模型应用状态管理
解决的问题:大模型流式返回 token 时,需要实时把增量文本渲染到页面上。传统的事件拼接方式容易漏帧、难清理。用 ref 包装流状态,用 computed 派生处理文本,用 watch 监听完成状态触发后续动作,整个数据流清晰可控。
具体案例:AI 对话页面中,const message = ref('') 接收各个流式 chunk,computed 计算累计字数与预计剩余时间,watch 监听 done 状态后自动滚动到底部。整个链路只用三个响应式 API 就搭好了。
AI 模型调用链路的可观测面板
阶段:AI 可观测性与监控
解决的问题:AI 服务的耗时、错误率需要实时上报,手动轮询和事件上报容易漏数据或过度开销。用 watch 监听请求状态池的变化,用自定义 Hook 封装 useModelMonitor,把上报逻辑收敛到一处。
具体案例:监控面板每秒收集 100 个请求的耗时分布,watch 聚合到图表数据源并自动刷新,配合 computed 计算 P95 分位数,帮助算法团队快速定位模型服务波动。
Python 演示
# -*- coding: utf-8 -*-"""用 Python 模拟 Vue3 的 ref / computed / watchEffect 最小响应式系统。""" current_effect = None # 当前正在执行的副作用函数 class Ref: """模拟 Vue3 ref:读取时收集依赖,写入时触发更新。""" def __init__(self, value=0): self._value = value self._deps = set() @property def value(self): if current_effect is not None: self._deps.add(current_effect) return self._value @value.setter def value(self, new_value): if new_value != self._value: self._value = new_value for effect in tuple(self._deps): effect() def watch_effect(fn): """模拟 watchEffect:立即运行一次并在依赖变化时自动重跑。""" def run(): global current_effect current_effect = run try: fn() finally: current_effect = None run() return run def watch(source_ref, callback): """简易 watch:只在值变化后执行回调。""" last = {"value": source_ref.value} def run(): current = source_ref.value if current != last["value"]: last["value"] = current callback(current) watch_effect(run) return callback class Computed: """模拟 Vue3 computed:有缓存,依赖变化才重新求值。""" def __init__(self, compute_fn): self._fn = compute_fn self._cache = None self._dirty = True self._subs = set() global current_effect old_effect = current_effect def mark_dirty(): self._dirty = True for effect in tuple(self._subs): effect() current_effect = mark_dirty try: self._fn() # 读取依赖,注册 mark_dirty finally: current_effect = old_effect @property def value(self): global current_effect if current_effect is not None: self._subs.add(current_effect) if self._dirty: self._cache = self._fn() self._dirty = False return self._cache if __name__ == "__main__": price = Ref(10) quantity = Ref(3) total = Computed(lambda: price.value * quantity.value) def on_total_change(): print("total changed ->", total.value) watch_effect(on_total_change) watch(price, lambda v: print("price ->", v)) print("initial total:", total.value) price.value = 20 quantity.value = 5
current_effect = None全局变量,记录当前正在执行的副作用函数,这是实现依赖收集的核心机制。self._deps.add(current_effect)读取ref.value时把当前 effect 记入依赖集合,对应 Vue 的track操作。for effect in tuple(self._deps): effect()写入新值时遍历依赖集合逐个触发,对应 Vue 的trigger操作。watch_effect(on_total_change)立即执行一次回调,并自动追踪其中读取到的所有响应式数据。last = {"value": source_ref.value}watch 内部记录旧值,新旧比对后才调用回调,避免初始重复触发。self._subs.add(current_effect)computed 自己也有订阅者集合,实现“依赖的依赖”多级联动。
常见误区
误区听说 reactive 能处理对象,就觉得它比 ref 更高级、更全面,于是所有状态都用 reactive 包一层,包括数字和字符串。
正解reactive 只能接收对象、数组等引用类型,传基本类型会直接报错;解构出来的属性还会丢失响应性。ref 才是更通用的容器,它内部对对象也会自动套用 reactive。实战选型应该“默认 ref,对象也可用 ref,确需整体代理对象时用 reactive”。
为何常见:不少教程示例喜欢用 reactive({ ... }) 展示对象响应式,给人留下“reactive 才是正规写法”的错觉,忽略了两种 API 各自的边界。
误区在 JS 逻辑中要用 `count.value` 读取 ref,于是模板里也写 `{{ count.value }}`,认为不写就不响应。
正解在 script setup 中,ref 在模板里会自动解包,直接写 {{ count }} 即可获得数值。模板中写 count.value 反而会先在解包后的值上取 value 属性,得到 undefined。只有 JS 逻辑中才需要显式 .value。
为何常见:初学者刚学会 JS 中的 .value 用法,把上下文直接迁移到模板,忽略了语法糖层级的自动解包机制。
误区遇到需要计算的场景就无脑用 computed,包括异步请求、定时器、随机数生成也塞进 getter,觉得反正都是“算一个值”。
正解computed 必须同步返回一个值,并且应当是“纯函数”——不产生副作用。异步请求放进 getter 不会得到正确更新,随机数会让依赖追踪失真,反复触发重算。异步派生值应该用 watch + ref 或自定义 Hook 管理。
为何常见:computed 用起来很顺手,很多人把它当作普通函数的替代品,忽略了它对“同步”与“纯净”的隐含要求。
误区把普通字符串或静态对象 `provide` 出去,子组件 `inject` 后直接使用,发现数据更新时子组件不跟着变,还以为是 bug。
正解provide 的普通值在注入端只是初始快照,不会随源端更新。要实现响应式共享,provide 应当传一个 ref 或 reactive 对象,注入端通过 .value 或属性访问最新值。
为何常见:Vue2 时代的 provide/inject 本身就不是响应式的,带来的惯性思维让一部分开发者忽略了 Vue3 中“传引用类型才能联动”的关键细节。
面试题
Q1. Vue3 Composition API 的入口函数是以下哪个?
- A.created() 钩子函数
- B.setup() 入口函数← 正确答案
- C.mounted() 钩子函数
- D.data() 方法函数
答案:B。Composition API 的一切逻辑都从 setup() 开始,它取代了 Options API 中分散的 data、computed、methods、生命周期选项。created 和 mounted 是生命周期钩子,data 是声明数据的方法,都不作为入口。
Q2. ref 和 reactive 的主要区别是什么?
- A.ref 包装基本类型,reactive 包装对象← 正确答案
- B.两者语义完全相同可以任意互换
- C.ref 只能用于字符串和布尔类型
- D.reactive 缺少深层响应式能力
答案:A。ref 主要包装基本类型,读取修改要 .value;reactive 只能包装对象/数组,访问属性直接取值,且默认深度代理。B 显然错误,C 忽略了 ref 也可以包对象,D 是常识性错误。
Q3. 在 script setup 中引入组件后如何使用?
- A.需要 components 选项手动注册
- B.引入后直接使用无需注册← 正确答案
- C.只能通过动态组件方式使用
- D.必须用 defineComponent 包裹
答案:B。script setup 语法糖会自动把 import 的组件暴露给模板,无需 components 注册,也无需 defineComponent 包裹。组件引入即用,是语法糖最直观的体验提升。
Q4. computed 相比普通方法的核心优势是什么?
- A.代码更短但性能没有差别
- B.依赖不变时可读取缓存结果← 正确答案
- C.每次访问都会强制重新计算
- D.只能用来修改 data 里的值
答案:B。computed 会追踪依赖并缓存结果,依赖不变时多次访问零开销。普通方法每次渲染都会重新执行。A 说“没有差别”错误,C 是普通方法的行为,D 完全误解了 computed 的用途。
Q5. 关于 watch 侦听器,哪些说法是正确的?
- A.deep 选项可监听对象深层变化← 正确答案
- B.immediate 让回调立即执行一次← 正确答案
- C.监听 ref 时需要加 .value 后缀
- D.watchEffect 无需指定监听目标← 正确答案
答案:ABD。deep: true 开启深层监听,immediate: true 让回调立即执行一次,watchEffect 自动追踪回调内使用的数据。C 错误,监听 ref 直接传 ref 对象即可,不需要 .value。
Q6. 自定义 Hook 的命名必须以什么开头?
- A.use 前缀以关联组合式 API 约定← 正确答案
- B.setup 前缀以区分普通函数
- C.watch 前缀以方便依赖追踪
- D.get 前缀以表示取值逻辑
答案:A。use 前缀是 Vue3 社区的命名约定,用于标识“内部使用组合式 API 的复用函数”,也让 lint 工具能识别它应当只在 setup 上下文中调用。其余前缀没有这种约定支撑。
Q7. 关于 provide/inject 的特点,哪些描述是准确的?
- A.解决深层组件属性透传问题← 正确答案
- B.值可以传递 ref 保持响应性← 正确答案
- C.只允许根组件向孙组件传递
- D.inject 只能传递静态字符串
答案:AB。provide/inject 专门解决跨层级透传,且任意祖先都可 provide,值可以是 ref/reactive。C 错在“只允许根组件”,任意层级的组件都能 provide;D 错在 inject 接受任意值类型。
Q8. reactive 解构后为什么丢失响应性?
- A.解构得到普通值不再被代理← 正确答案
- B.JavaScript 不支持对象解构
- C.解构会自动复制整个原始对象
- D.reactive 本身就不是响应式的
答案:A。reactive 基于 Proxy 代理对象本身,解构操作会把属性值复制成普通变量带走,这些变量不再经过代理,因此失去响应性。需要保持响应性时用 toRefs 转换。
Q9. script setup 中声明组件 props 的正确方式是?
- A.使用 defineProps 编译器宏← 正确答案
- B.在 components 选项中注册
- C.在 data 里初始化同名属性
- D.在 emits 配置里声明事件
答案:A。defineProps 是 script setup 的编译器宏,无需导入即可声明 props 类型与默认值。components 选项用于注册组件,data 用于普通状态,emits 用于声明事件,均与 props 声明无关。
Q10. 关于 Vue3 响应式原理,哪些描述是正确的?
- A.ref 底层对对象使用 reactive 代理← 正确答案
- B.模板中 ref 变量会自动解包← 正确答案
- C.watchEffect 需要显式声明依赖
- D.Proxy 可以监听属性新增操作← 正确答案
答案:ABD。ref 包装对象时内部会调用 reactive;模板中 ref 自动解包;Proxy 支持监听属性新增和删除。C 错误,watchEffect 不需要声明依赖,它会自动追踪回调内读取到的响应式数据,这正是它和 watch 的最大区别。