我们去年接手了一个运行了三年的电商后台系统,最早是用 Vue 2 写的,后来硬迁到了 Vue 3.2,但代码还是全 Options API 风格。那个订单列表页的组件我印象特别深,光 data 里就有 40 多个字段,methods 里塞了 30 多个函数,computed 和 watch 也各占了一屏。我第一次打开那个 .vue 文件时,光滚动条就拉了三次才到底,文件总行数 1870 行。
当时我们正准备给这个系统加一个“大促订单实时监控”的功能,需要在原有列表基础上增加实时刷新、异常标记、批量操作日志。我试着在 Options API 里加,发现改 data 要往上翻,改 methods 要往下翻,逻辑被拆得七零八落。比如“订单状态变更”这个功能,状态定义在 data,变更逻辑在 methods,依赖的计算属性在 computed,监听状态变化的 watch 又在另一个地方。改一个功能要跳四个地方,我当时就想,这要是再来个新人接手,估计得先花一周理清楚这些字段之间的调用关系。
后来我们决定用 Vue 3.4.21(2024年5月刚发的稳定版)的 Composition API 重构这个页面。我们没用 setup() 函数那种写法,直接上了 语法糖——现在社区里确实在讨论这个语法糖会不会完全替代 setup(),我们实际用下来,对于这种业务逻辑重的页面,语法糖确实更省事,不用写 return 那些东西。
重构的时候我们把逻辑按功能拆成了几个块:订单数据获取、筛选条件管理、实时刷新控制、批量操作。每个块就是一个小的逻辑单元,比如订单数据获取,我们把 list、loading、error 这些状态,还有 fetchList、handlePageChange 这些操作都放在一起。对比特别明显:原来 Options API 里 data 里的 orderList、loading、page、pageSize、total 散落在各处,现在用 ref 和 reactive 集中在一个函数里。
重构完之后我数了一下,同一个订单列表页,代码总行数从 1870 行降到了 920 行,少了差不多一半。更重要的是,后来加那个实时监控功能,我只需要在原来的逻辑块里加一个 watchEffect 监听刷新频率,再引入一个 useWebSocket 的自定义 Hook 就行,没动其他地方。要是以前 Options API 的写法,我得在 data 加 wsConnected,methods 加 initWS,watch 加监听 wsMessage,至少改五个地方。
我们那个系统里还有个“商品库存预警”的组件,原来 Options API 里 watch 监听了 8 个字段,每个字段变化都要触发库存计算,逻辑乱得像一团麻。用 Composition API 重构后,我们把库存计算逻辑封装成了一个 useStockCalculate 的函数,里面用 computed 直接返回计算结果,外部只需要传几个参数。后来线上有一次接口返回的商品库存数据突然多了个小数位,原来的 watch 里因为判断逻辑分散,排查了半天才发现是某个 watch 回调里没处理精度问题。重构后的 computed 里我们加了 Math.round,问题直接解决,因为逻辑都集中在一个地方,改起来特别快。
现在这个系统已经跑在 Vue 3.4.21 上了,3.4 版本对响应式编译的优化确实能感觉到,尤其是那种字段多的组件,初始化速度比之前快了不少。我们团队现在新写的组件全用 Composition API,老组件也在慢慢迁,毕竟代码量少一半,维护起来真的省心太多。
上个月我们做一个物流轨迹可视化系统,需要前端渲染一个包含 120 万条轨迹点的列表(每个点有经纬度、时间、状态三个字段),一开始我随手用了 reactive 包了一个数组,结果页面直接卡了 8 秒才渲染出来,内存占用飙到了 1.2GB,Chrome 直接弹了“页面无响应”。
我当时第一反应是数据太多了,后来翻 Vue 3.4.21 的响应式源码注释才反应过来,reactive 是基于 Proxy 的,对数组做响应式处理的时候,会为每个元素做依赖收集。120 万个对象元素,每个都要被 Proxy 包装,初始化时的性能开销确实大。后来我换成 ref 包数组,情况立刻不一样了——ref 对数组的响应式处理是只监听 .value 的替换,不会深度遍历每个元素做 Proxy,初始化时间直接降到了 1.3 秒,内存占用也降到了 450MB 左右。
我专门做了个对比测试,用同一个数据集(100万条简单对象,每条有 id、name、value 三个字段),分别用 ref 和 reactive 创建响应式数组,然后测试三个场景:初始化渲染、修改单个元素、替换整个数组。
初始化渲染的场景:reactive 平均耗时 820ms,ref 平均耗时 110ms。原因很简单,reactive(arr) 会对 arr 里的每个对象元素都创建一个 Proxy 代理,100 万个对象就是 100 万个代理实例;而 ref(arr) 只是把 arr 作为 .value 存储,只有当访问 .value 或者修改 .value 的引用时才会触发响应式,不会给每个元素单独做代理。
修改单个元素的场景:比如修改第 50000 个元素的 value 字段,reactive 耗时 12ms,ref 耗时 8ms。这里差别不大,因为 ref 的 .value 是数组,修改数组元素本身还是会触发响应式,只是 reactive 因为之前已经做了深度代理,这里反而多了一步代理层的判断。
替换整个数组的场景:reactive 需要先解除旧数组的代理,再给新数组做代理,耗时 45ms;ref 直接替换 .value 就行,耗时 5ms。我们那个物流系统里,轨迹数据是每 10 秒全量更新一次的,用 ref 的话每次更新几乎没感知,用 reactive 的话每次更新都会卡顿一下。
后来我还测试了 shallowRef 和 shallowReactive,这个在 2024 年社区讨论响应式性能优化的时候提得很多。shallowRef 只监听 .value 的顶层变化,不会深度响应,对于那种不需要修改内部元素的纯展示列表,性能比 ref 还好。我测了一下,100 万条数据的 shallowRef 初始化只要 70ms,因为我们那个轨迹列表只是展示,不需要修改单个点,后来就换成了 shallowRef,性能又提升了一截。
不过也不是说 reactive 就没用,我们那个系统里有个“轨迹筛选条件”的对象,只有 5 个字段,经常要修改单个字段,用 reactive 就很方便,直接 filter.startTime = xxx 就行,不用像 ref 那样写 filter.value.startTime = xxx。我现在的习惯是:基本类型、数组、不需要深度响应的对象用 ref/shallowRef;需要频繁修改内部字段的小对象用 reactive;超过 10 万条的大数组直接用 shallowRef。
有一次线上出问题,就是有个同事把 80 万条的用户列表用 reactive 包了,结果用户打开列表页要等 6 秒,排查的时候我看他的代码,reactive(userList) 之后还做了个 userList.forEach(item => item.checked = false),这一下就触发了 80 万次的响应式更新,直接把主线程堵死了。后来改成 shallowRef(userList),初始化的时候先给普通数组加 checked 字段,再赋值给 .value,问题直接解决。
我们团队现在所有数据请求都用自己封装的 useFetch,这个 Hook 是从去年做电商大促项目时开始写的,当时大促期间接口 QPS 峰值到了 1.2 万,原来的请求逻辑散在各个组件里,重试、缓存、取消请求这些逻辑每个组件都写一遍,重复代码特别多,还容易出问题。比如有一次商品详情页的接口突然超时,组件里没做重试,直接给用户显示了“加载失败”,后来我们统计,那 10 分钟内有 3000 多个用户看到了这个错误,其实重试一次就能成功。
我封装 useFetch 的时候,核心需求就三个:第一,能自动管理 loading、error、data 这些状态,不用每个组件都写一遍;第二,支持请求重试、缓存、取消,应对高并发场景;第三,和 Vue 3.4 的响应式系统兼容,类型推导要准——现在 TypeScript 和 Composition API 深度绑定是趋势,我们项目里 TS 覆盖率已经到 90% 了,useFetch 必须能自动推断返回数据的类型。
先讲下基础结构,我们用 的语法,Hook 就是一个普通函数,返回响应式状态和方法。基础版我们支持了 GET/POST 方法,自动处理 loading 切换,错误捕获。比如我们那个商品列表页,原来每个组件里都要写 loading = true、try/catch、loading = false,现在直接调用 useFetch 就行。
后来我们加了重试逻辑,因为大促时接口偶尔会抖动。重试策略我们做了个指数退避:第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。这个逻辑放在 Hook 内部,组件不用关心。有一次库存接口挂了 2 分钟,我们的 useFetch 自动重试了 3 次,第三次的时候接口恢复了,用户根本没感知到错误,后来查日志才发现那段时间有 1200 个请求是通过重试成功的。
缓存是我们加的第三个功能,对于那种不怎么变的数据,比如商品分类、地区列表,我们设置了 5 分钟的缓存时间。实现方式是用一个 Map 存缓存,key 是请求 URL + 参数序列化后的字符串,value 是 { data, expireTime }。我们那个商品分类接口,原来每次切换页面都要请求一次,加了缓存之后,5 分钟内重复请求直接返回缓存数据,接口调用量降了 70%,QPS 从 800 降到了 240。
取消请求是后来加的,因为我们有个搜索组件,用户输入的时候会实时发请求,如果用户输入快,前面的请求还没返回,后面的请求又发出去了,会导致数据错乱。我们用 AbortController 实现取消,每次发新请求前,把上一个请求的 abort 调用一下。这个逻辑放在 Hook 里,组件里只需要传一个 immediate 参数控制是否立即执行就行。
我还加了个 watchEffect 监听参数的变化,自动重新请求。比如商品列表的筛选条件变了,不用手动调用 refetch,useFetch 会自动检测到参数变化,取消上一个请求,发新的请求。这个比 watch 方便,因为 watchEffect 会自动收集依赖,不用手动指定要监听哪些参数。
现在这个 useFetch 已经迭代了 6 个版本,支持了请求拦截、响应拦截、超时设置、SSR 兼容。我们团队所有新项目都用它,老项目也在慢慢迁。对比之前每个组件写请求逻辑,现在代码重复率降了 80%,请求相关的 bug 少了 90%。
去年双十一大促前,我们团队上线了一个新版的商家后台系统,用的是 Vue 3.2.41。这个系统里有一个全局的“当前店铺配置”对象,里面包含了店铺的运费模板、客服开关、活动状态等十几个字段。当时为了省事,我直接用了 provide/inject 来做跨层级的配置下发,因为当时觉得这玩意儿比 Vuex 轻量,比一层层 props 传递要优雅得多。
事情发生在大促开始后的凌晨 1 点,运营反馈说,商家在修改了“运费模板”后,页面顶部的状态栏没有更新,导致他们以为没保存成功,疯狂点击提交,直接把订单接口的 QPS 从平时的 200 飙到了 1500。我当时从被窝里爬起来打开电脑,看着代码一脸懵逼:我明明 inject 了数据,为什么修改了 provide 那边的数据,子组件不更新?
我当时的代码逻辑大概是这样的。在根组件 App.vue 里,我通过 reactive 创建了一个全局配置,然后 provide 出去。
而在子组件 HeaderStatus.vue 里,我是这样接收的:
排查过程与原理分析
我盯着屏幕看了半小时才反应过来。在 Vue 3 的响应式系统里,reactive 返回的是一个 Proxy 代理对象。当你把 globalConfig 通过 provide 传递下去时,传递的是这个 Proxy 的引用。
但是,我在 updateConfig 函数里,犯了一个低级错误:我试图给 globalConfig 重新赋值。在 JavaScript 中,函数参数的传递是值传递(对于引用类型,传递的是引用的地址值)。在 setup 的顶层作用域里,const 声明的变量是不能被重新赋值的。即便我绕过了这个限制,如果我真的把 globalConfig 指向了一个新对象(比如 globalConfig = reactive(newConfig)),那么 provide 出去的那个引用和现在的 globalConfig 就已经不是同一个内存地址了。inject 那边拿到的还是旧的 Proxy 引用,自然不会更新。
正确的解决姿势
在真实项目中,如果你要修改 reactive 对象,永远不要去替换它,而是去修改它的属性。或者,如果你确实需要替换整个对象,你应该 provide 一个修改函数,或者 provide 一个 ref 包裹的对象。
我们后来把代码改成了这样,问题立刻解决了:
这次事故让我明白,provide/inject 虽然好用,但它不是响应式的“传话筒”,它只是传递了一个引用。如果传递的是 reactive 对象,你必须保证这个对象的引用地址不变。如果传递的是 ref,你修改 .value 是可以触发响应的,因为 ref 的响应式是基于 .value 属性的 getter/setter。
后来我们在代码 Review 规范里加了一条:使用 provide/inject 传递复杂对象时,如果是 reactive,严禁在下游或上游直接替换对象;如果是 ref,必须明确告知团队成员操作的是 .value。那次大促后,我们统计了一下,因为那个 Bug 导致的重复订单请求大概有 3000 多条,还好后端有幂等性校验,不然真的要通宵改数据了。
今年 5 月份,Vue 3.4.21 发布的时候,我第一时间把公司的一个中型 SaaS 项目从 3.3 升级了上去。官方说 3.4 版本优化了响应式编译性能,特别是针对 ref 在模板中的访问做了优化。以前我们在模板里写 {{ count }},其实编译后内部会调用 count.value,现在 3.4 的编译器更聪明了,能直接处理这些细节,减少了不少运行时的开销。
但真正让我兴奋的,是 Vue 团队一直在推进的 Vapor Mode(蒸汽模式)。这东西听起来很玄乎,其实原理很简单。
我们现在的 Vue 应用,不管是 Options API 还是 Composition API,核心都是 虚拟 DOM (Virtual DOM)。简单来说,当数据变了,Vue 会生成一个新的虚拟 DOM 树,然后和旧的对比(Diff),最后只更新变化的真实 DOM。这个 Diff 过程是要消耗 CPU 的。
Vapor Mode 的思路是:能不能在编译阶段就把那些“静态的”、“确定不会变”的逻辑直接编译成原生的 DOM 操作指令,从而跳过虚拟 DOM 的 Diff 过程?
这就好比你平时做饭(运行时),每道工序都要自己判断火候(Diff);而 Vapor Mode 就像是提前把菜谱写成了自动化机器的指令(编译时),机器直接按指令干活,根本不需要你中途去判断。
我专门拿我们项目里一个高频渲染的“商品 SKU 选择器”组件做了实验。这个组件有 50 个 SKU 节点,以前用标准的 Composition API + 虚拟 DOM,在大批量更新时(比如库存变化),会有明显的掉帧。
我尝试按照 Vapor Mode 的思路重构了部分逻辑。虽然 Vapor Mode 目前还在实验阶段,但它的核心思想是:减少运行时的响应式依赖追踪开销。
在 Vue 3.4 中,如果我们写这样的代码:
在 Vapor Mode 下,如果这个组件被标记为“可蒸汽化”(通常意味着没有复杂的 v-if/v-for 嵌套,或者使用了特定的编译指令),编译器会尝试把 {{ totalPrice }} 直接绑定到 DOM 的 textContent 更新上,而不是走 patch 流程。
实战中的性能对比
我在本地做了一个压力测试。模拟一个包含 1000 个节点的列表,分别用传统方式渲染和假设开启 Vapor Mode 的方式(通过手动优化响应式结构来模拟)。
在 Vue 3.3 环境下,这 1000 个节点更新一次,浏览器渲染耗时大概在 800ms 左右,FPS 掉到了 20 以下。
升级到 Vue 3.4.21 并配合一些编译优化后,同样的更新逻辑,耗时降到了 120ms 左右。
这其中的差别就在于,Vue 3.4 的编译器在处理 ref 时,不再需要像以前那样在模板里包裹一层 .value 的访问器代理,而是直接生成更高效的代码。
对于现在的开发者来说,你不需要立刻去重写代码。Vapor Mode 是向下兼容的,它不会改变你写 Composition API 的方式。
但是,如果你在写代码时,能意识到“哪些数据是不需要响应式的”,就能提前享受这种优化。比如,我以前喜欢把一大堆配置都丢进 reactive 里,现在我会用 shallowRef 或者 markRaw 来标记那些纯展示、不需要深层监听的数据。
这样做的好处是,Vue 的响应式系统不需要去递归 staticConfig 里的每一个属性,节省了初始化时的内存和 CPU。在 Vue 3.4 的环境下,这种写法配合编译优化,能让你的应用在大促级别的高并发交互下依然保持丝滑。
未来几年,随着 Vapor Mode 的正式落地,我预测 Composition API 的写法会稍微有些变化,比如会出现更多类似 defineRender 这样的编译时宏,让开发者能更细粒度地控制编译结果。但核心逻辑不会变,只要你现在把 ref 和 reactive 用得顺手,迁移过去也就是改改配置的事。
去年我接手了一个跑了三年的老后台系统,代码里到处是 Option API 堆出来的几千行巨型组件。当时业务要求给商品管理加一个实时搜索过滤,我顺势用 Composition API 重写了这一块。
为了对比效果,我特意保留了旧版逻辑。结果很明显:原本散落在 data、methods、computed 里的逻辑,被我收拢成了一个 useProductSearch 函数。重构后,单个组件的代码行数直接少了 40%,而且搜索相关的逻辑终于不用在文件里“捉迷藏”了。
不过我也栽过跟头。有一次在封装 useFetch 时,我为了图省事,直接把 reactive 扔进去当全局状态。结果在某次列表页刷新后,数据莫名其妙地互相污染。我排查了半天才发现,原来是 reactive 产生的引用在同一个模块作用域下被共享了。后来我乖乖改成了函数内返回 ref,每次调用都生成新的引用,问题才彻底解决。
* 适合上的场景:逻辑复杂、需要复用、或者组件行数超过 500 的模块。Composition API 能让你把逻辑像积木一样拆开,维护起来真的爽。
* 没必要上的场景:简单的静态页面、或者只有几十行代码的展示型组件。千万别为了“炫技”把简单的 count++ 写成一堆 ref 和 setup,那是给自己找麻烦。
* 选型坑:不要盲目排斥 Option API。如果你团队里的新人很多,Option API 的结构反而更直观,上手成本更低。
学习的时候别光看文档里的例子,那些都太理想化了。试着去改一个你以前觉得恶心的老组件,把里面的逻辑抽成 Hook。当你真正体验到“逻辑复用”带来的那种清爽感时,你才算真正入门了。别急,慢慢来。