
1. 先聊两句Vue3到底“新”在哪很多朋友从Vue2一路写过来第一次打开Vue3的项目时大脑基本是空白的代码怎么长得完全不像以前的写法了到处都是setup()、ref、reactivedata、methods、computed这些老熟人全不见了。你这不是一个人几乎所有转Vue3的人都会经历这段“看不懂、不习惯、想退回Vue2”的阶段。这一章我不打算给你整一堆概念轰炸而是从实际开发视角把Vue3最核心的几个技术点拆开讲清楚。你会知道Composition API是怎么来的ref和reactive到底该用哪个组合式函数为什么比mixin好用以及Vue3在工程落地时常见的坑长什么样。这套内容既有给纯新手的铺垫也有给正在用Vue3做后台管理系统、商城项目、低代码平台的人看的实战经验。我写的时候默认你是跟着这个“从零开始学Vue”系列一路学过来的但你就算是中途插进来的也能看懂因为每个核心点我都会从头讲。需要先说清楚的是这一章不覆盖Vue3的全部API——像Teleport、Suspense、Fragment这些我也会提但重心放在日常写业务时最常用、最核心的那批东西上。把核心技术吃透剩下的都是细枝末节用到再查也完全来得及。1.1 从选项式到组合式的核心转变Vue2时代的写法叫Options API也就是选项式API。它的结构非常固定data、computed、methods、watch、lifecycle每个选项往里面填内容。这种写法对新手极其友好因为结构规范、强迫你分门别类但也正是这种“分类学”在项目变大后带来了致命问题——一个功能的完整逻辑被拆散到不同选项里你要在data里看状态、在methods里看方法、在computed里看计算属性、在watch里看副作用来回跳着读代码才能拼出一个功能的完整画面。Vue3推出的Composition API核心思路就是把“按选项分类”改成“按功能组织”。一个功能相关的状态、方法、计算属性、侦听器全部放在一起形成一个独立的功能块。你在读代码时顺着一个函数往下读就能把一个功能完整看完。这里有一个非常典型的体验差异Vue2里做一个用户列表你需要在data里定义userList、loading、queryParams在methods里写fetchList、resetQuery、handleSearch在computed里写filteredList在watch里监听查询条件变化触发重新请求。一个功能拆了四处代码短还好一旦超过200行维护体验就开始崩。Vue3里同一个用户列表用setup()就能组织成setup() { const userList ref([]) const loading ref(false) const queryParams reactive({ page: 1, keyword: }) const fetchList async () { loading.value true try { userList.value await getUsers(queryParams) } finally { loading.value false } } const resetQuery () { queryParams.page 1 queryParams.keyword fetchList() } onMounted(fetchList) return { userList, loading, queryParams, fetchList, resetQuery } }看到没状态、异步方法、生命周期触发全部在一个函数块里从上往下读就是我处理这个功能的完整思路。这种组织方式在项目大了之后尤其舒服因为你的代码不再是“水平分类”而是“垂直切片”。不过要说句公道话Options API并没有被废除Vue3里依然可以用script setup语法也允许你混用。我的建议很简单小型项目、团队新手多、组件复杂度低Options API依然能用一旦组件逻辑超过一屏或者你要写可复用的逻辑果断切到Composition API这是趋势也是面试常考点。1.2 为什么是Proxy而不是definePropertyVue2的响应式系统是建立在Object.defineProperty之上的走的是“数据劫持”的路子它有几个天生的缺陷无法检测对象属性的新增和删除对数组的索引修改和length变化也感知不到所以Vue2后来不得不靠Vue.set、Vue.delete之类的方法来打补丁。Vue3把整个响应式系统重写了底子换成了ES6的Proxy。Proxy拦截的是整个对象的操作不再局限于“已有的属性”。你在对象上新增一个属性、删除一个属性甚至用in操作符去判断都会被捕获到。这就从机制上解决了新增和删除属性无法响应的问题。const state reactive({ count: 0 }) // Vue2里这样写不会触发更新 // state.newProp hello // Vue3里直接这样写就行 state.newProp hello // 页面会自动更新因为Proxy能拦截到set操作这里值得注意的是Vue3的响应式是基于“事件驱动”的不再像Vue2那样初始化时给每个属性做递归劫持而是在访问到深层子对象时才进行get拦截并做惰性代理。这意味着什么意味着你定义了一个巨大的响应式对象但页面只用了其中一层那么深层对象只有在首次被访问时才会被代理性能开销被大幅削减。这对大型后台管理系统来说是个实打实的收益。另一个Proxy带来的变化是Set、Map等原生集合类型也能变成响应式的了。Vue2时代处理Map基本得靠手动维护Vue3里reactive(new Map())直接用配合effect跑得非常顺。2. 响应式系统的双生花ref与reactiveVue3的响应式API基础就是这两个面试的时候基本必问也是日常开发中绕不开的两个核心方法。但说实话很多教程讲得模棱两可导致初学者总是分不清“到底什么时候用ref、什么时候用reactive”。2.1 ref的设计思路与使用场景ref的作用是把一个普通值包装成响应式引用。注意“引用”这个词它在底层实现上是创建了一个RefImpl实例这个实例内部有value属性你用ref(0)创建了一个{ value: 0 }的结构。访问时必须通过.value但模板中可以自动解包也就是模板里你无需写.value。import { ref } from vue const count ref(0) console.log(count.value) // 0 count.value console.log(count.value) // 1ref不仅仅是给基本类型用的它也可以接收对象——这种情况下ref内部会自动调用reactive来包装这个对象。但从开发角度看我建议基本类型和对象都习惯性地用ref理由后面讲。为什么我说ref是“万能对象”uni-app和很多跨端框架在Vue3模式下推荐用ref管理几乎所有状态。原因在于ref的类型语义清晰、解构安全、传参方便而且配合toRefs、toRef之后从组合式函数里导出多个响应式状态变得非常干净。2.2 reactive的边界与注意点reactive接收一个对象返回一个通过Proxy包装的响应式代理对象。它不能用基本类型你用reactive(0)会直接报错或者得到一个无效结果因为Proxy只能代理对象。import { reactive } from vue const state reactive({ count: 0 }) state.count // 直接访问属性不需要 .value这里藏着Vue3最常见的响应式“陷阱”如果你把reactive对象解构出来响应性就丢了。const state reactive({ count: 0, name: 张三 }) // 这样写会丢失响应性 const { count, name } state count // 页面不会更新原因很简单解构出来的count是原始值已经脱离了Proxy代理。解决方案是使用toRefsimport { reactive, toRefs } from vue const state reactive({ count: 0, name: 张三 }) const { count, name } toRefs(state) // count 是 Refcount.value 可以触发更新还有个典型的场景是整个reactive对象被重新赋值。比如后台管理系统里你请求接口返回了一组新数据直接state newData整个对象引用变了响应性自然就断了。正确做法是遍历赋值或者直接把整个对象放到ref里然后state.value newData反而不容易出现这种问题。2.3 ref/reactive在实际项目中的取舍我的经验是能用ref就用ref必要时再用reactive。这不是说reactive不好而是ref在写法和使用上更统一不容易踩坑。举几个实际例子无脑使用ref请求列表数据、表单模型、弹窗显隐状态这些统一用ref无论存的是数组、对象还是布尔值都不会有坑。有明确需要再用reactive当你有一个结构固定的对象需要频繁读写其多层属性且你很清楚不会整体替换它时reactive写起来更爽因为不需要满屏.value。组合式函数返回优先用ref配合toRefs后解构依然保持响应性。来看一个后台管理系统中常见的搜索表单// 不推荐的写法 const searchForm reactive({ keyword: , status: undefined, dateRange: [] }) // 推荐写法 const searchForm ref({ keyword: , status: undefined, dateRange: [] })为什么推荐ref因为搜索表单经常会有“重置”、“赋值初始化”这类操作。reactive整体赋值需要遍历ref只需要searchForm.value {...}一行搞定不容易出错。3. 组合式函数Composables——Vue3的“代码复用”方案Vue2时代大家复用逻辑的方式主要是mixin、mixin还有mixin。不可否认mixin解决了部分问题但它也带来了太多痛苦命名冲突无法解决mixin内部状态不可见导致调试困难多个mixin叠加后根本分不清数据从哪来。Vue3的组合式函数Composables彻底改掉了这个局面。你可以通过命名就能看出每个数据和方法来自哪里。组合式函数实际上就是一个函数它内部可以使用响应式API、生命周期钩子、watch等等最终返回一组暴露给外界的状态和方法。就这么简单。3.1 从mixin到组合式函数的演进mixin的命名冲突问题在多团队协作时是灾难级的。你引入了一个分页mixin、一个表单mixin他们都在data里定义了page、search等字段一旦合并就撞车编译器不会告诉你错在哪里但运行时的行为就是不对。组合式函数呢它天然支持解构重命名// usePagination.ts export function usePagination() { const page ref(1) const size ref(10) const total ref(0) const pageChange (p) { page.value p } return { page, size, total, pageChange } } // 使用时完全无冲突 const { page: userPage, size: userSize, total: userTotal } usePagination() const { page: orderPage, size: orderSize, total: orderTotal } usePagination()同一个函数被调两次两套状态互不干扰这在mixin时代想都不敢想。这也是为什么组合式函数越来越受后端管理系统、高复用场景项目欢迎的原因。组合式函数不限定状态它也可以封装纯逻辑。比如封装一个倒计时、一个请求重试逻辑、一个防抖节流函数。只要是“有状态的逻辑”都可以扔到组合式函数里。3.2 手写一个业务级的组合式函数光讲概念容易飘直接上一个真实场景后台管理系统里最常见的“表格分页加载”。正常情况下你会在组件里写当前页码、每页条数、总数、请求方法、页码切换监听。这些逻辑几乎每个列表页都有。我们可以封装成usePagedList。// composables/usePagedList.js import { ref, computed, watch } from vue export function usePagedList(fetchFn, options {}) { const { immediate true, defaultPage 1, defaultSize 10 } options const list ref([]) const loading ref(false) const page ref(defaultPage) const size ref(defaultSize) const total ref(0) const totalPages computed(() Math.ceil(total.value / size.value)) const loadData async () { loading.value true try { const res await fetchFn({ page: page.value, size: size.value }) list.value res.list total.value res.total } finally { loading.value false } } const changePage (p) { page.value p loadData() } const changeSize (s) { size.value s page.value 1 loadData() } const refresh () loadData() if (immediate) { loadData() } return { list, loading, page, size, total, totalPages, loadData, changePage, changeSize, refresh } }然后在组件里使用就非常简单了template div el-table :datalist v-loadingloading el-table-column propname label名称 / el-table-column propstatus label状态 / /el-table el-pagination :current-pagepage :page-sizesize :totaltotal current-changechangePage / /div /template script setup import { usePagedList } from /composables/usePagedList import { fetchUserList } from /api/user const { list, loading, page, size, total, changePage } usePagedList(fetchUserList) // 如果有删除操作删除成功后想刷新列表 // const { refresh } usePagedList(fetchUserList) // 直接调用 refresh() /script组合式函数的好处在这一段代码里体现得淋漓尽致所有分页逻辑被收纳到一个函数里调用的组件只需要知道它返回了“list、loading、page、size、total、changePage”这几个东西具体内部怎么实现完全不关心。3.3 组合式函数在真实项目中的场景延展分页只是最基础的。实际项目里我们可以把更多通用逻辑塞进组合式函数权限判断封装usePermission内部根据当前用户角色计算能不能操作返回hasCreate、hasEdit、hasDelete。表格多选封装useSelection维护选中行的数组提供选中变化、清空、回显的逻辑。导出Excel封装useExport内部处理导出参数、防重复点击、成功/失败的toast提示。Tab标签页联动封装useTabs维护当前打开页签、关闭指定页签、刷新页签时的缓存清理。这里插一句和热词相关的实践很多后台管理系统用Vue3 Element Plus做Tab标签页底部有个“关闭所有标签”按钮还要联动菜单高亮和面包屑更新。如果这些逻辑分散在App.vue、Sidebar.vue、Tabs.vue里改一处崩三处。把它们抽到useTabs里统一管理状态组件只负责渲染状态变更全部通过函数调用整个条理就清晰了。我从自己的项目经验来说组合式函数是Vue3核心思想“按功能组织代码”的最大受益者。它让“复用逻辑”和“复用组件”完全分开思考有UI的复用做组件没UI的复用做组合式函数各管一摊互不干扰。4. 生命周期与模板语法的变化除了Composition APIVue3在日常写码时常碰到的另一个变化就是生命周期和模板语法。这些变化没有响应式API那么大但影响面广很多老项目迁移到Vue3时出问题都是出在这些细节上。4.1 生命周期钩子的大调整Vue2的生命周期钩子大家应该背得很熟beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、beforeDestroy、destroyed。Vue3里beforeDestroy变成onBeforeUnmountdestroyed变成onUnmounted同时新增了onRenderTracked和onRenderTriggered用于调试。组合式API的生命周期函数要求必须在setup或者说script setup中同步调用。这里有几个要点值得注意created和beforeCreate在setup里没有对应物因为setup本身替代了这两个钩子的执行时机。你在setup里写的逻辑就已经是在“创建”阶段执行了。onMounted、onUpdated、onUnmounted是使用最频繁的三个。同一个生命周期可以被注册多次注册顺序就是执行顺序这在Vue2里是做不到的。看一个实际的应用场景Vue3商城项目里商品详情页需要注册键盘快捷键监听退出页面时又需要清除监听。Vue2里你要在mounted里加监听、在beforeDestroy里移除Vue3里可以这样写setup() { const handleKeydown (e) { if (e.key Escape) closeModal() } onMounted(() window.addEventListener(keydown, handleKeydown)) onUnmounted(() window.removeEventListener(keydown, handleKeydown)) }逻辑完全遵循“上来就注册走时就清理”的直觉而且最关键的是——如果你用了script setup主逻辑不需要变成函数再return上面的代码直接写在组件顶层即可。4.2 v-model和事件修饰符等模板细节模板部分Vue3做了些细小的调整我挑几个影响最大的讲。v-model的组件通信方式升级了。Vue2里一个组件只支持一个v-model对应value属性和input事件。Vue3里可以写多个v-model对应自定义属性和事件名称!-- 子组件 -- script setup defineProps([modelValue, searchKey]) defineEmits([update:modelValue, update:searchKey]) const updateName (e) emit(update:modelValue, e.target.value) const updateSearch (e) emit(update:searchKey, e.target.value) /script template input :valuemodelValue inputupdateName / input :valuesearchKey inputupdateSearch / /template父组件就可以这样用MyComponent v-modelname v-model:searchKeykeyword /这种能力在封装自定义组件时非常有用。比如你要封一个“高级搜索组件”里面有多个搜索条件用多个v-model传值就是最优雅的写法不用搞一堆update:xxx事件来监听。事件修饰符中新增了exact这个比较冷门但偶尔有用用于精确控制按键组合比如“只有按着CtrlA的时候才触发某个动作”而其他情况下不触发。移除了v-on的native修饰符。Vue2里你要监听组件的原生事件可以在组件上写click.nativexxxVue3里这个被移除了你需要自己在组件内部把原生事件转发出去或者使用fallthrough attributes机制。这个变化对写组件库的人影响较大。还有一个容易被忽略的细节Vue3中多个根节点的组件被称为Fragment。Vue2强制要求单根节点Vue3不再限制。这对布局很友好比如你要并排输出两列内容不用再包一层无意义的div了。但注意多根节点上如果使用继承了attribute的特性编译器会发出警告因为无法确定把非prop的attribute传给哪个根节点。5. 实战演练从零搭一个可复用的Vue3后台模块理论说了一大堆不上手你永远学不会。我们结合热词里提到的“vue3后台管理系统”、“vue3动态添加删除form表单一行数据”来走一遍实际搭建一个可复用后台模块的流程。这个模块虽然不复杂但覆盖了Vue3核心API的绝大多数用法。5.1 初始化与目录结构设计假设我们使用Vite创建项目npm create vitelatest my-admin -- --template vue-ts cd my-admin npm install npm install element-plus axios这里用了TypeScript模板虽然TS会带来一定的语法学习成本但Vue3生态在当前阶段几乎已经是“默认TS”的状态很多主流框架、若依Vue3版这类开源项目也都是TS编写。如果你对TS还不熟可以先跳过类型标注但建议尽快补上。目录结构推荐这样设计src/ api/ # 接口请求 composables/ # 组合式函数 components/ # 通用组件 layouts/ # 布局组件 router/ # 路由 stores/ # Pinia状态管理 views/ # 页面这个结构顺手的地方在于组合式函数有独立目录通用组件也有独立目录新增一个页面只需要在views下加对应文件夹再把接口放在api里不会出现在一个页面里堆一堆文件的情况。5.2 实现一个动态表单列表模块我们做一个“动态添加/删除Form表单一行数据”的功能。这个功能在后台管理系统里特别常见添加多个联系人、录入多条规格、配置多个配送地址。Vue2里实现动态行会用到v-for 下标的方式操作数组Vue3里结合ref数组维护起来更直观。先定义表单结构template div classdynamic-form el-form refformRef :modelform :rulesrules el-form-item v-for(item, index) in form.items :keyitem.key :propitems. index .name :rules{ required: true, message: 名称不能为空 } div classrow el-input v-modelitem.name placeholder输入名称 / el-input v-modelitem.phone placeholder输入电话 / el-button typedanger clickremoveItem(index)删除/el-button /div /el-form-item /el-form el-button clickaddItem新增一行/el-button /div /template关键脚本部分如下script setup import { ref } from vue import { ElMessage } from element-plus const formRef ref() const form ref({ items: [] }) let uid 0 const addItem () { uid 1 form.value.items.push({ key: uid, // 唯一key用自增id保证稳定 name: , phone: }) } const removeItem (index) { form.value.items.splice(index, 1) } const submit async () { await formRef.value.validate() // 校验通过提交数据 console.log(form.value.items) ElMessage.success(提交成功) } /script这里有个非常关键的细节v-for的:key我用的是item.key而不是数组下标。因为在动态删除时用下标当key会导致Vue复用已有DOM输入框的选中状态、表单校验状态都会错乱。这个坑我在真实项目里踩过好几次用稳定唯一的key可以从根上避免。另一个细节是校验规则里用prop动态路径items. index .name。Element Plus的表单校验是基于model和prop路径做嵌套校验的你只有写成这种路径表达式删除一行、新增一行后才能正确触发对应行的校验。这个模块虽然简单但涵盖了ref管理复杂对象、事件处理、表单校验、动态列表维护这四类核心操作。把它跑通你对Vue3的响应式列表操作基本就心里有数了。6. 常见问题与排查技巧实录Vue3正式发布到现在已经好几年了社区里积累了大量踩坑案例。下面这些内容是我从自己项目经验和网友高频讨论中总结出来的很多都能在面试题里碰到。6.1 若依Vue3TS项目里最常见的报错热词里提到了“若依vue3 ts报错”。若依RuoYi是一个非常流行的开源后台管理系统它的Vue3版使用Vite TypeScript Element Plus Pinia搭建。很多人直接拉到本地跑会碰到几个高频报错Cannot find module /api/xxx or its corresponding type declarations——这是TS无法解析别名路径。需要在tsconfig.json里配置compilerOptions.paths{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }同时确认Vite的resolve.alias里也同样配置了指向src目录。注意修改完配置后必须重启Vite开发服务否则路径不生效。Property xxx does not exist on type xxxx——这是TS类型推断不到。你可以给变量明确标注类型或者使用refType来约束类型。不熟悉TS的话临时可以用as any绕过但不建议长期这样干。6.2 关于ref解包、reactive丢失、prod/edge等细节Vue3的响应式采样器有一个很有迷惑性的现象在模板中ref会自动解包但在reactive对象内部不会。const pageInfo reactive({ currentPage: 1 }) const currentPage ref(1) pageInfo.currentPage currentPage上面这段代码中pageInfo.currentPage拿到的是ref实例本身也就是{ value: 1 }不是1。赋值后按钮点击pageInfo.currentPage页面无法更新。正确做法是在赋值时取.valuepageInfo.currentPage currentPage.value。或者用toRef把currentPage变成响应式源const currentPage ref(1) const pageInfo { currentPage: toRef(currentPage) }还有一个极容易被忽略的开发环境正常、生产环境组件不更新。这类问题一大来源是Vue的响应式依赖收集与Proxy的兼容性问题。个别老版本Edge浏览器或低版本WebView对ES6 Proxy支持不完整就会导致响应式失效。解决方案是检查目标浏览器版本、必要时启用Babel的babel/plugin-proxy相关polyfill或者锁定Vue版本到官方推荐的稳定版。如果你确实需要在旧浏览器上跑Vue3务必先做兼容性验证。6.3 面试高频点速查如果你正在准备Vue3面试下面这些点最容易被问到我先列个清单你们可以先自查再来看下面分析ref与reactive的区别和适用场景。Vue3的Proxy为什么比Vue2的defineProperty好。Composition API与Options API的优缺点。watch与watchEffect的区别。v-if和v-for的优先级问题Vue3中v-if优先级高于v-for和Vue2相反。nextTick的使用时机与原理。这些问题的答案大部分在这篇文章已经覆盖了。特别提醒一个容易答错的点Vue2中v-for优先级高于v-ifVue3中v-if优先级高于v-for。前者意味着同一元素上同时使用二者时v-if无法访问v-for作用域里的变量后者意味着你可以这样写li v-foritem in list v-ifitem.visible{{ item.name }}/li虽然语法上能跑通但官方还是建议将需要过滤的列表用computed先算好再交给v-for这样代码更容易理解性能也更好。另外watch和watchEffect的区别是面试官爱追问的细节。watch需要明确指定依赖源并且默认只在依赖变化时执行watchEffect则自动追踪内部用到的响应式状态首次执行时就会跑一遍来收集依赖。前者适合“监听某个值变化后再做点什么”的场景后者适合“渲染副作用本身就需要响应式状态”的场景比如打印日志、联动请求。我的建议是在面试时带上具体业务例子回答比如“我在后台管理系统中用watch监听路由变化来更新Tab标签页”这一下就比干背概念有说服力得多。最后再分享一个实际经验写完第十一章我自己有个直观感受Vue3的学习曲线看着比Vue2陡但实际写起来反而比Vue2更让人踏实。因为Composition API把逻辑的边界还给开发者代码再也不是被语法结构捆绑的“抽屉”而是按照功能自然生长的“模块”。如果你是从Vue2切过来的我建议你在一个小型项目或者一个现有项目的某个模块里尝试用Vue3重写一遍。不要一上来就全量重构成本太高风险太大。挑一个逻辑最复杂的页面用组合式函数重构你会明显体会到“按功能切代码”和“按选项切代码”的巨大差异。做后台管理系统的话最值得花时间打磨的是动态表单、复杂列表搜索、Tab标签页状态维护、多级权限路由这几个方向。把这些底层逻辑抽成组合式函数然后一个项目一个项目地复用越用越顺手。如果你在Vue3里遇到了没想通的报错或诡异的响应式问题别急着甩锅给框架——大概率是响应式状态被解构了、整体替换了或者ref被塞进了reactive对象里。回头仔细查一下这三类问题绝大多数诡异现象都能找到答案。