1. 先聊两句:Vue3到底“新”在哪
很多朋友从Vue2一路写过来,第一次打开Vue3的项目时,大脑基本是空白的:代码怎么长得完全不像以前的写法了?到处都是setup()、ref、reactive,data、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而不是defineProperty
Vue2的响应式系统是建立在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与reactive
Vue3的响应式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代理。解决方案是使用toRefs:
import { reactive, toRefs } from 'vue' const state = reactive({ count: 0, name: '张三' }) const { count, name } = toRefs(state) // count 是 Ref,count.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 :data="list" v-loading="loading"> <el-table-column prop="name" label="名称" /> <el-table-column prop="status" label="状态" /> </el-table> <el-pagination :current-page="page" :page-size="size" :total="total" @current-change="changePage" /> </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 API,Vue3在日常写码时常碰到的另一个变化就是生命周期和模板语法。这些变化没有响应式API那么大,但影响面广,很多老项目迁移到Vue3时出问题都是出在这些细节上。
4.1 生命周期钩子的大调整
Vue2的生命周期钩子大家应该背得很熟:beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、beforeDestroy、destroyed。
Vue3里beforeDestroy变成onBeforeUnmount,destroyed变成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 :value="modelValue" @input="updateName" /> <input :value="searchKey" @input="updateSearch" /> </template>父组件就可以这样用:
<MyComponent v-model="name" v-model:searchKey="keyword" />这种能力在封装自定义组件时非常有用。比如你要封一个“高级搜索组件”,里面有多个搜索条件,用多个v-model传值就是最优雅的写法,不用搞一堆@update:xxx事件来监听。
事件修饰符中新增了exact,这个比较冷门,但偶尔有用:用于精确控制按键组合,比如“只有按着Ctrl+A的时候才触发某个动作”,而其他情况下不触发。
移除了v-on的native修饰符。Vue2里你要监听组件的原生事件可以在组件上写@click.native="xxx",Vue3里这个被移除了,你需要自己在组件内部把原生事件转发出去,或者使用fallthrough attributes机制。这个变化对写组件库的人影响较大。
还有一个容易被忽略的细节:Vue3中多个根节点的组件被称为Fragment。Vue2强制要求单根节点,Vue3不再限制。这对布局很友好,比如你要并排输出两列内容,不用再包一层无意义的div了。但注意:多根节点上如果使用继承了attribute的特性,编译器会发出警告,因为无法确定把非prop的attribute传给哪个根节点。
5. 实战演练:从零搭一个可复用的Vue3后台模块
理论说了一大堆,不上手你永远学不会。我们结合热词里提到的“vue3后台管理系统”、“vue3动态添加删除form表单一行数据”,来走一遍实际搭建一个可复用后台模块的流程。这个模块虽然不复杂,但覆盖了Vue3核心API的绝大多数用法。
5.1 初始化与目录结构设计
假设我们使用Vite创建项目:
npm create vite@latest 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 class="dynamic-form"> <el-form ref="formRef" :model="form" :rules="rules"> <el-form-item v-for="(item, index) in form.items" :key="item.key" :prop="'items.' + index + '.name'" :rules="{ required: true, message: '名称不能为空' }" > <div class="row"> <el-input v-model="item.name" placeholder="输入名称" /> <el-input v-model="item.phone" placeholder="输入电话" /> <el-button type="danger" @click="removeItem(index)">删除</el-button> </div> </el-form-item> </el-form> <el-button @click="addItem">新增一行</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 若依Vue3+TS项目里最常见的报错
热词里提到了“若依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类型推断不到。你可以给变量明确标注类型,或者使用ref<Type>来约束类型。不熟悉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++,页面无法更新。正确做法是在赋值时取.value:pageInfo.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-if,Vue3中v-if优先级高于v-for。前者意味着同一元素上同时使用二者时,v-if无法访问v-for作用域里的变量;后者意味着你可以这样写:
<li v-for="item in list" v-if="item.visible">{{ item.name }}</li>虽然语法上能跑通,但官方还是建议将需要过滤的列表用computed先算好,再交给v-for,这样代码更容易理解,性能也更好。
另外,watch和watchEffect的区别是面试官爱追问的细节。watch需要明确指定依赖源,并且默认只在依赖变化时执行;watchEffect则自动追踪内部用到的响应式状态,首次执行时就会跑一遍来收集依赖。前者适合“监听某个值变化后再做点什么”的场景,后者适合“渲染副作用本身就需要响应式状态”的场景,比如打印日志、联动请求。
我的建议是在面试时带上具体业务例子回答,比如“我在后台管理系统中用watch监听路由变化来更新Tab标签页”,这一下就比干背概念有说服力得多。
最后再分享一个实际经验
写完第十一章,我自己有个直观感受:Vue3的学习曲线看着比Vue2陡,但实际写起来反而比Vue2更让人踏实。因为Composition API把逻辑的边界还给开发者,代码再也不是被语法结构捆绑的“抽屉”,而是按照功能自然生长的“模块”。
如果你是从Vue2切过来的,我建议你在一个小型项目或者一个现有项目的某个模块里,尝试用Vue3重写一遍。不要一上来就全量重构,成本太高风险太大。挑一个逻辑最复杂的页面,用组合式函数重构,你会明显体会到“按功能切代码”和“按选项切代码”的巨大差异。
做后台管理系统的话,最值得花时间打磨的是动态表单、复杂列表搜索、Tab标签页状态维护、多级权限路由这几个方向。把这些底层逻辑抽成组合式函数,然后一个项目一个项目地复用,越用越顺手。
如果你在Vue3里遇到了没想通的报错或诡异的响应式问题,别急着甩锅给框架——大概率是响应式状态被解构了、整体替换了,或者ref被塞进了reactive对象里。回头仔细查一下这三类问题,绝大多数诡异现象都能找到答案。