
Vue3 发布这么久我接触过的团队里仍然有不少人停留在会用script setup写点东西的阶段说起 Options API 和 Composition API 的区别只能答出前者是选项对象、后者是函数式这种表面话。这其实挺可惜的因为这次演进的核心不是语法换了口味而是组件逻辑组织方式的底层变革牵涉到响应式系统的重新设计、复用模式的替代、甚至是 diff 算法的优化思路。这篇文章我想抛开面试八股从一个真实项目从 Vue2 迁到 Vue3 的视角把这条进化之路上的关键节点讲透。先给这篇文章定个位如果你是一个正在从 Vue2 往 Vue3 过渡的开发者或者已经在用 Vue3 但总觉得 Composition API 用不顺手、不知道什么时候该用ref什么时候该用reactive、对组合式函数的设计边界很模糊的人这篇文章值得你花二十分钟完整读一遍。全文围绕为什么要进化进化解决了什么迁移时真正要关注的坑三条线展开没有粘贴官方文档的翻译腔基本都是我在实际项目里验证过的结论。1. Options API 在实际项目中的痛点问题从哪来1.1 代码不是按业务切分而是按类型切分先说个我在旧项目里反复遇到的场景。比如做一个商品列表页面功能包含搜索条件筛选、列表分页、选中项管理、批量操作、价格计算。Options API 的组织方式大家都很熟悉export default { data() { return { keyword: , categoryId: null, page: 1, total: 0, list: [], selectedRows: [], loading: false }; }, computed: { filteredList() {}, totalPrice() {}, hasSelected() {} }, watch: { keyword() { this.page 1; this.fetchList(); }, categoryId() { this.page 1; this.fetchList(); }, page: fetchList }, methods: { fetchList() {}, handleSearch() {}, handleReset() {}, handleSelectionChange() {}, handleBatchDelete() {}, handleExport() {} } };这个组件在文件里大概四百行。表面上看结构规整——响应式数据归 data计算属性归 computed方法归 methods。但如果你真的维护过这种组件你会发现一个特别头疼的问题你要理解搜索这个业务逻辑必须同时在 data 里找 keyword 和 categoryId在 methods 里找 handleSearch在 watch 里找监听逻辑在 computed 里找筛选结果。一个完整的功能点被物理切割成四块散落在文件各个位置。这就像把一个人的档案按身高xxx体重xxx喜欢的食物xxx分门别类放进不同的抽屉里当你想知道这个人完整的样子时得把每个抽屉都翻一遍拼凑全貌。小组件还好一旦组件超过三百行这种割裂感会成倍放大。1.2 mixin 复用看似省事追责却难Options API 时代跨组件复用逻辑的标准方案是 mixin。当时 Vue2 官方文档也是这么推荐的。但我用下来的感受是mixin 适合一次性缝合不适合长期维护。举一个典型例子。我当时封装了一个paginationMixin里面包含了分页相关的 data、methods大概是这样// paginationMixin.js export default { data() { return { page: 1, pageSize: 20, total: 0, loading: false }; }, methods: { handleSizeChange(size) { this.pageSize size; this.page 1; this.fetchList(); }, handleCurrentChange(page) { this.page page; this.fetchList(); }, async fetchList() { this.loading true; try { const res await this.getListApi(this.buildParams()); this.list res.data.records; this.total Number(res.data.total); } finally { this.loading false; } } } };看着挺方便对吧用的时候一行mixins: [paginationMixin]就全部继承。问题在于当两个纯业务组件各自需要调整分页行为时mixin 里的代码就开始长出刺。组件 A 需要翻页时额外埋点上报组件 B 需要在翻页前做表单校验组件 C 的列表接口不走分页但需要复用 loading 逻辑。每一种变体都要在 mixin 里加参数、加判断分支甚至有的团队直接在 mixin 里写if (this.someFlag) {}久而久之这个 mixin 变成了一个把所有案件都往里面塞的黑洞。更要命的是来源不明确。我在一个老项目里看到过一个组件页面模板里用了this.$refs、this.showDialog、this.formRules你以为这些都是当前组件定义的结果搜索代码发现其中一部分来自一个叫commonMixin的混入、一部分来自validateMixin、还有一部分来自组件的祖父级 mixin。当多个 mixin 合并后你根本不知道某个数据或方法来自哪里也不知道会不会被另一个 mixin 覆盖。探测一个字段的来龙去脉只能靠全局搜索和跑代码验证。1.3 组件膨胀之后心智负担指数上升Options API 还有一个被忽视的问题代码行数的增长速度远快于业务复杂度的增长速度。因为每个功能点拆开后每个碎片都会附赠一层模板化的代码。比如加一个是否展示价格的开关你需要data 里加一行showPrice: truecomputed 里加一个hasPricePermissionmethods 里加一个togglePricewatch 里可能要加showPrice() { this.$emit(price-changed) }四条线同步加代码。如果是 Composition API 的写法这个功能的代码天然能聚合到一段区域里阅读时不需要跳跃。我当时被一个几百行的组件折磨得受不了把 console.log 打在不同生命周期里反复对比 props 变化就为了定位一个重置按钮点了之后为什么有些字段没清空的问题。最后发现是因为resetForm方法里漏掉了两个字段、而那两个字段又恰好被watch监听了触发了一连串预期外行为。当组件大到一定程度副作用在隐性地相互传染而模板语法无法从设计层面阻止这种失控。2. Composition API 的设计逻辑setup 与响应式系统重排2.1 setup 函数组件逻辑的统一入口Vue3 组合式 API 的核心载体是setup函数。它之所以被称为组合式核心思路是允许你按照逻辑关注点来组织代码而不是按照选项类型来切分代码。对比感受一下同样一个搜索列表功能在script setup里长这样script setup import { ref, reactive, computed, watch, onMounted } from vue; import { searchGoods } from /api/goods; // 搜索相关逻辑 const keyword ref(); const categoryId ref(null); watch([keyword, categoryId], () { page.value 1; fetchList(); }); // 列表与分页 const page ref(1); const total ref(0); const list ref([]); const loading ref(false); const filteredList computed(() list.value.filter(...)); // 选中与批量操作 const selectedRows ref([]); const hasSelected computed(() selectedRows.value.length 0); const handleSelectionChange (rows) { selectedRows.value rows; }; const handleBatchDelete () { // ... }; async function fetchList() { loading.value true; try { const res await searchGoods({ keyword: keyword.value, categoryId: categoryId.value, page: page.value }); list.value res.data.records; total.value Number(res.data.total); } finally { loading.value false; } } onMounted(fetchList); /script很明显搜索、分页、批量操作这三块逻辑在物理位置上各自成块维护时不需要再跨 data/computed/methods 跳来跳去。这种按业务切分的组织方式是人脑更容易理解的方式——它符合局部性原理看代码时眼睛扫过去就完成了上下文加载。2.2 ref 与 reactive 的本质区别和使用边界聊到 Composition API 就绕不开ref和reactive的选择困惑。很多初学者问这两个到底用哪个我从实际经验给出结论reactive接受一个对象作为参数返回一个深度响应式的代理对象。ref本质上是一个{ value: 原始值 }的包装对象。它的存在意义是解决基本类型无法被 Proxy 直接代理的问题。所以我理解ref更准确的定位是给基本类型和普通值穿一件外套让它们在响应式系统中有一席之地。reactive则面向对象数据。但在真实项目里你会发现用ref的情况远多于reactive。原因很实际模板中可以自动解包ref而reactive要用.属性名才能访问。这导致团队规范通常约定// 推荐 const list ref([]); const page ref(1); // 不推荐 const state reactive({ list: [], page: 1 });为什么reactive在大型项目里容易出问题我踩过一个坑用reactive包裹了一个深层对象在某个方法里用解构赋值提取了它的两个属性const state reactive({ a: 1, b: 2 }); function foo() { const { a, b } state; // 这个方法里对 a 的修改不会触发更新 }解构会切断响应式连接。如果你结构化的层级很深这种隐性问题很难第一时间发现。用ref就没有这个问题因为每次都是通过.value访问最外层对象响应式连接始终存在。不过reactive也不是一无是处。当你的数据天然是一个整体比如表单对象、且需要整体操作时reactive的语义更自然const form reactive({ username: , password: , remember: false }); function resetForm() { Object.assign(form, { username: , password: , remember: false }); }这两种用法的取舍我的习惯是基本类型和数组一律用ref复杂对象看场景如果整个对象经常被整体替换或重置用reactive更省心如果这个对象的字段会被频繁单独读取和修改且散落在多个函数中用ref更安全。2.3 setup 的执行时机与生命周期映射setup的执行时机是在组件实例被创建之后、beforeCreate之前。理解这一点很关键在setup内部this并不能指向组件实例。因为这时候组件实例还没有完全初始化完毕this还没有绑定好所以 Vue3 才不让你在setup里用this。这个设计有时候会让从 Vue2 过来的人不习惯。我记得团队里有同事刚迁到 Vue3 时在setup里写this.$route直接炸了跑上来问为什么。我说你现在这个阶段拿不到this要用useRoute()来拿路由信息。生命周期钩子也是映射关系Vue2 Options APIVue3 Composition APIbeforeCreate不需要直接写在setup里created不需要直接写在setup里beforeMountonBeforeMountmountedonMountedbeforeUpdateonBeforeUpdateupdatedonUpdatedbeforeDestroyonBeforeUnmountdestroyedonUnmountederrorCapturedonErrorCaptured需要注意setup里的代码实际上替代了原来created和beforeCreate阶段的工作。所以初始数据请求可以直接在setup顶层执行效果和mounted里请求略有差异——setup执行时机更早如果涉及到子组件 ref 获取还是要等onMounted。原生 DOM 操作用onMounted是安全选择因为此时视图已经挂载完成。3. 组合式函数如何解决真实复用问题3.1 从 mixin 到 useXxx命名冲突与来源不明解药在 Vue2 时代我受 mixin 折磨最深的一点就是命名冲突是沉默的。两个 mixin 里都定义了handleSearch后者会默默覆盖前者页面行为变成薛定谔的猫。排查这种问题只能靠读代码看到底 import 了哪些 mixin、顺序如何。Vue3 的组合式函数从根上解决了这个问题。因为组合式函数是普通函数变量名在使用时完全由你决定不存在自动合并这回事// usePagination.js import { ref } from vue; export function usePagination() { const page ref(1); const total ref(0); const pageSize ref(20); function handleSizeChange(size) { pageSize.value size; page.value 1; } function handleCurrentChange(p) { page.value p; } return { page, total, pageSize, handleSizeChange, handleCurrentChange }; }在组件中使用时const { page, total, pageSize } usePagination();就算两个不同的组合式函数都返回了page你在使用层也可以给它们起不同的名字从根本上避免了 mixin 那种隐式合并的覆盖问题。更重要的是组合式函数的所有数据和方法都来自函数的 return 值代码是从哪来的、哪些作用域可见是一目了然的。3.2 组合式函数的组织一个功能一个函数组合式函数的设计思路简单说就是把一个完整的功能闭环封装起来外部只暴露必要的数据和操作。我在带团队时总结了一条实践规范如果一个功能点的状态 操作超过 3 个变量就应该考虑抽成组合式函数。举个例子。我项目里有个通用需求列表接口需要支持搜索关键字、分页、刷新、重置。传统 Options API 写法散落各处Vue3 可以这样封装// useListPage.js import { ref, watch } from vue; export function useListPage(fetcher, options {}) { const list ref([]); const loading ref(false); const total ref(0); const page ref(1); const pageSize ref(options.pageSize ?? 20); const filters ref(options.filters ?? {}); async function fetchList() { loading.value true; try { const res await fetcher({ ...filters.value, page: page.value, pageSize: pageSize.value }); list.value res.data.records; total.value Number(res.data.total); } finally { loading.value false; } } function reset() { page.value 1; filters.value {}; fetchList(); } watch([page, pageSize, filters], fetchList, { deep: true }); return { list, loading, total, page, pageSize, filters, fetchList, reset }; }这个函数在项目里谁要用引入一行就完事。组合式函数的 return 对象就是对外契约使用者可以按需解构且不担心命名冲突。在实际复用场景里这种模式比 mixin 至少省一半排查时间。3.3 在 defineProps、defineEmits 下的父子组件交互Vue3 的script setup中父子组件交互的体验也有不少变化。最直观的是defineProps和defineEmits这两个编译宏——它们不需要 import直接在script setup顶层使用即可。一个典型的子组件交互示例script setup const props defineProps({ modelValue: { type: String, default: }, placeholder: { type: String, default: 请输入内容 } }); const emit defineEmits([update:modelValue, focus, blur]); function handleInput(e) { emit(update:modelValue, e.target.value); } /script这里有个细节值得注意defineProps返回的 props 对象在script setup中是响应式的但你不能再给 props 重新赋值。很多人从 Vue2 过来习惯直接this.someProp x来改 prop在 Vue3 里直接警告正确的做法永远是 emit 事件让父组件改。v-model的机制也变了。Vue2 的value input被替换成modelValue update:modelValue。如果你在项目里兼容过 Vue2会发现写v-model的体验其实更统一了支持v-model语法糖和多个v-model:xxx的绑定这比 Vue2 灵活很多。4. 从 Options 迁移到 Composition 的工程化路线4.1 大型项目迁移先定顺序再动手很多人问我手上有一个几百个组件的 Vue2 老项目怎么迁到 Vue3这个问题我实际做过我的经验是别想着一步到位全部重写。建议的迁移顺序是先升级到 Vue 2.7。Vue 2.7 是官方发布的一个兼容版本它把 Composition API 带回了 Vue2 生态让你可以在不改变构建工具链的前提下先熟悉 Composition API 的写法。构建工具从 webpack 换到 Vite。Vite 基于 ES module冷启动速度快不是一点半点。但这一步要注意公司基础设施是否支持如果项目里有大量依赖 webpack 的插件比如传统 qiankun 微前端方案迁移成本可能比较高。组件逐个迁移。从公共组件、基础组件开始因为它们被大量复用迁移收益最高、暴露问题也最集中。最后处理路由和状态管理。Vue Router 4 和 Pinia 都需要配合 Vue3这两个一般作为收尾。我见过失败的仓库迁移案例主因都是同时改版本 改架构 改状态管理 改 UI 库四座大山一起压过来bug 铺天盖地。渐进式迁移虽然周期长但每一小步都能通过回滚控制风险。4.2script setup语法糖迁移中必须掌握的写法严格来说Vue3.2 之前写 Composition API 需要在script里暴露 setup 函数并 return那体验其实是比较啰嗦的script export default { setup() { const count ref(0); function inc() { count.value; } return { count, inc }; } }; /script每个变量都要在 return 里写一遍体感接近为了响应式而被迫把作用域打平。script setup是官方语法糖它把自动暴露顶层绑定这件事做掉了script setup const count ref(0); function inc() { count.value; } /script模板里可以直接用count和inc不用 return。这个改变极大降低了 Composition API 的上手成本。如果你要迁移或新写 Vue3 项目我强烈建议直接用script setup它不必考虑 setup 返回的繁琐细节代码量直接减三分之一。要注意的一点是script setup里不能使用普通的export default因为它本身就承担了组件定义的作用。如果确实需要和 Options API 混用比如想用某些 Options API 特有的选项可以在同一个 SFC 文件里加一个普通的script块。4.3 Vue 2.7 的桥梁作用与迁移陷阱Vue 2.7 在 Options API 和 Composition API 之间搭了一座桥我实际用下来觉得它最大的价值是让老团队可以先在原有项目中试用 Composition API不需要立即把构建体系推倒重来。但它在细节上和真正的 Vue3 还有差异迁移时需要注意几点。一是ref在模板中的解包行为不同。Vue 2.7 里如果在模板中直接访问ref对象本身会有兼容性问题Vue3 则总是解包到.value。二是依赖收集的时机和深度不同。我遇到过 Vue2.7 中watch一个ref数组修改arr[0]不触发更新的问题在 Vue3 中是正常的。这类差异性能通过升级解决但会导致排查时困惑。三是很多 Vue2 生态库在 Vue3 下没有对应版本。这是制约迁移的硬性问题比如一些老牌 UI 库的 Vue3 版本功能不完整图表库的 Vue3 封装要新增维护成本。所以迁移前必须先盘点项目中所有 npm 依赖的社区支持情况不要先写代码再发现某个核心库停更了。5. 响应式与渲染性能的进化diff 算法背后的优化逻辑5.1 静态提升与 patchFlag编译器层面的自动优化很多人知道 Vue3 虚拟 DOM diff 性能比 Vue2 好但说不出好在哪里。这里要澄清Vue3 的 diff 性能优化主要不是靠运行时逻辑写得更快而是靠编译阶段把静态信息和动态信息分离极大地减少了需要对比的节点数。看一个最简单的模板div span静态文字/span span{{ msg }}/span /divVue2 编译后的渲染函数每次重新渲染时都会生成完整的新虚拟 DOM然后完整地 diff。Vue3 的编译器做了两件事一是静态提升hoistStatic。编译器识别出span静态文字/span这个节点永远不会改变于是把它提升到渲染函数外部创建一次之后每次渲染直接复用同一个 vnode省去了重复创建的消耗。二是patchFlag补丁标记。像span{{ msg }}/span这种动态节点编译时会打上1代表 TEXT 动态文本之类的标记。diff 时 Vue3 就知道只有这个节点需要对比更新不需要再递归遍历整个子树。这类优化让 Vue3 在管理大型列表、复杂页面时性能明显优于 Vue2关键是这些优化是默认开启、自动作用的开发者根本不需要手写任何东西。5.2 key 的作用与最长递增子序列diff 算法的核心逻辑Vue3 在 diff 列表时的另一个关键优化是使用最长递增子序列算法这个点也是面试的高频题。在 Keyed Children diff 过程中Vue 需要判断新旧列表中的节点关系从而安排插入、删除、移动操作。Vue2 在同级比较时使用双端对比但遇到节点复用错位时可能会多做几次移动操作。Vue3 引入了最长递增子序列思想在确定了新列表在旧列表中的相对位置之后通过查找最长递增子序列计算出哪些节点不需要移动只需要移动其余节点从而把移动操作次数降到最低。举个例子旧列表[A, B, C, D, E]新列表[C, A, B, D, E]。Vue3 会找到[A, B, D, E]或者[C, D, E]这类递增序列确定哪些节点不需要动尽量只移动 C 或 A。算法复杂度是O(nlogn)比 Vue2 更容易在大型列表下的性能表现更优。不过普通开发者在实际开发中不太需要手写这个算法理解它的意义在于编写列表渲染时给每个元素加一个稳定且唯一的 key能最大限度地利用这个算法的优势。如果不加 keyVue 会走就地复用策略这可能导致组件状态错乱比如输入框的值串位这是我在实际项目中遇到过的排查了很久才定位到是 key 重复的问题。5.3 性能不是自动来的模板写法影响优化效果虽然 Vue3 在编译阶段做了大量优化但这些优化只有在模板能被静态分析时才有效。如果你大量滥用动态绑定、整块模板使用v-html、或者把组件渲染逻辑全部塞进函数式组件里优化效果会大打折扣。我在项目里见过一个反面教材把整个列表区域都用v-html输出导致 Vue 无法复用 DOM每次数据更新都重建整块列表页面卡到爆。Vue3 的优化思路是编译器尽力但开发者别拆台。合理的做法是动态部分保持小粒度静态部分交给编译器提升列表渲染保证 key 稳定复杂更新场景用computed避免不必要的重复渲染。这里还有个容易被忽略的点shallowRef/shallowReactive可以在深度响应式开销过大的场景下手动降级。比如一个只读的巨大配置对象根本不需要深度代理用shallowReactive就能避免不必要的 Proxy 层级提升性能。不过这类优化属于后期手段不要在项目初期引入避免带来不必要的理解成本。6. 如果真的要从头开始Vite 搭建 Vue3 项目的流程复盘最后补充一个实操内容因为不少读者搜vue3其实是为了搭一个能跑的项目。Vite 作为 Vue3 官方推荐的构建工具搭建体验比我早期用 webpack 配 Vue3 舒服太多了。我把一个最小可行项目的搭建过程复盘在这里。6.1 创建项目与目录结构设计使用 npm 创建npm create vitelatest my-vue3-app -- --template vueVite 会生成一个极简的骨架项目包含src/main.js、src/App.vue、vite.config.js等核心文件。这里我建议马上做的两件事是配置路径别名。在vite.config.js中加入import { defineConfig } from vite; import path from path; export default defineConfig({ resolve: { alias: { : path.resolve(__dirname, src) } } });没这个配置之前写../components/xxx.vue这种相对路径长到让人怀疑人生。配置开发代理解决本地开发跨域问题server: { proxy: { /api: { target: http://your-api-server.com, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } }6.2 从模板语法到组件交互一个最小示例初始化完项目跑起来之后大多数人第一件想做的事是写一个列表页请求接口、渲染数据、管理加载状态。这里我给出一段可以在项目里直接跑的模板template div classgoods-page input v-modelkeyword placeholder输入关键词 / button clickhandleSearch搜索/button div v-ifloading加载中.../div ul v-else li v-foritem in list :keyitem.id {{ item.name }} - {{ item.price }} /li /ul /div /template script setup import { ref, watch } from vue; import { searchGoods } from /api/goods; const keyword ref(); const list ref([]); const loading ref(false); async function fetchList() { loading.value true; try { const res await searchGoods({ keyword: keyword.value }); list.value res.data; } finally { loading.value false; } } function handleSearch() { fetchList(); } // 输入防抖后搜索 watch(keyword, (val) { clearTimeout(timer); timer setTimeout(fetchList, 500); }); /script这个示例虽然简单但集中展示了 Vue3 日常开发中最常用的几个点ref创建响应式数据、v-model双向绑定、watch 监听变化、模板中的自动解包、组合式函数如果把 fetchList 和 keyword 抽出去就自然变成一个 useSearch 组合式函数。7. 写在最后Composition API 不是银弹但它确实更适合复杂逻辑我在团队推广 Composition API 时遇到的最大阻力不是技术而是习惯。很多同事在 Options API 里写了三四年肌肉记忆已经把响应式数据放在 data 里、方法放在 methods 里突然让他们全混在一个setup里很不适应。这种感受我能理解但我想说Composition API 的定位不是取代所有 Options API 场景而是为复杂逻辑提供更合理的组织方式。如果你的组件不超过一两百行Options API 完全没问题但当一个组件的逻辑开始变复杂、需要复用、需要测试时Composition API 的收益就会越来越明显。最后分享一个我自己的经验迁移不是二选一的零和游戏。在我负责的一个中大型后台项目中我采取的是新组件用 Composition API老组件保持 Options API 不动公共逻辑逐步抽成组合式函数的策略。三个月后新需求几乎都跑在 Composition API 这条线上老代码也肉眼可见地沉淀出了十多个可复用的useXxx函数整个团队对新语法的接受度比预期快很多。如果你的团队正处于观望阶段我建议你挑一个边缘模块先试点跑通之后再往核心模块推这样既控制风险又能攒下实战经验。至少在 Vue3 这个版本的生命周期内Composition API 一定是主航道早一天上手后面重构时就能少踩一个坑。