ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue 3 MVVM 实战:从响应式原理到组件契约落地

Vue 3 MVVM 实战:从响应式原理到组件契约落地 1. 这不是又一个“架构名词科普”而是你每天都在写的代码背后的真实逻辑“MVVM”这三个字母你可能在招聘JD里见过在技术分享PPT第3页见过在某次Code Review被同事随口提过——但真要让你关掉浏览器、合上文档只凭手头正在维护的那个电商商品列表页说清楚“ViewModel到底在哪一行Model和View之间究竟谁先动了手”很多人会卡住。这不是记不住概念而是因为市面上太多讲解把MVVM讲成了教科书里的静态模型图左边画个View中间标个ViewModel右边写个Model再用双向箭头连起来配上一句“数据驱动视图更新”。可现实里你改一行v-model绑定的字段页面没反应你调用了一个API成功返回了数据列表却还是空的你明明在computed里写了依赖逻辑但数据变了它就是不重新计算……这些时刻才是MVVM真正露出“牙齿”的地方。我带过不少刚从培训班出来的前端新人也帮某高校实验室重构过一套教学管理系统的前端架构。他们共同的问题不是不会写Vue或React而是写了一年组件依然分不清什么时候该用ref、什么时候该用reactive搞不懂为什么watch监听一个对象属性要加.value更说不清setup()函数里定义的变量和模板里{{ title }}之间那条看不见的链路到底是怎么被建立、又被维护的。这背后不是语法问题是MVVM在具体框架中落地时的契约关系被模糊了。它不是一个抽象理论而是一套关于“谁负责什么、谁信任谁、谁向谁暴露接口”的硬性约定。比如Vue 3的响应式系统本质就是用Proxy劫持了Model层的数据访问让每一次obj.name new都自动触发View层的更新通知而WPF里的INotifyPropertyChanged接口则是强制要求Model自己“举手报告”“我变啦请刷新”——同样是MVVM实现机制天差地别但核心契约不变View不直接操作数据ViewModel是唯一的协调者Model只管数据本身。这篇文章不讲“什么是MVVM”而是带你回到你昨天刚提交的那行代码里看清楚那个被框架悄悄帮你完成的、从数据变更到像素渲染的完整闭环。它适合所有已经能写出功能页面、但想搞懂“为什么这么写才稳”的中级开发者也适合正被面试官问到“Vue的响应式原理和MVVM的关系”而答得磕磕绊绊的同学。接下来的内容没有一张PPT式的示意图只有真实项目里拆解出来的逻辑断点、调试器里的堆栈快照以及我踩过坑后写在注释里的那句“此处勿删”。2. 内容整体设计与思路拆解为什么今天还要死磕MVVM而不是直接学Composition API2.1 不是“过时”而是“被误读”MVVM从未离开只是换了一身衣服很多人以为MVVM是Vue 2时代的产物Vue 3推了Composition API就等于抛弃了MVVM。这是最大的误解。Composition API不是MVVM的替代品而是它的一次工程化升级。你可以把Options API看作是MVVM的“说明书”——它用data、methods、computed这些固定区块强行把你往MVVM的三个角色里塞而Composition API则像给你发了一套“乐高零件”让你自己拼出符合MVVM契约的结构。比如下面这段看似“很Composition”的代码export default { setup() { const count ref(0) const doubleCount computed(() count.value * 2) function increment() { count.value } return { count, doubleCount, increment } } }表面看是函数式写法但拆开看count是Model承载原始数据doubleCount是ViewModel的计算逻辑封装衍生状态increment是ViewModel的行为方法处理用户交互而模板里{{ count }}和{{ doubleCount }}就是View。它完全符合MVVM的职责划分只是不再用data(){ return { count: 0 } }这种声明式语法而是用ref()这个响应式工厂函数来创建Model实例。所以理解MVVM不是为了去背诵一个老古董模式而是为了看懂为什么Vue官方文档里强调“不要在setup()里直接修改props”因为props是View传给ViewModel的输入ViewModel可以读但不能改——这是契约为什么script setup里定义的变量默认暴露给模板因为ViewModel必须向View提供明确的“接口面”——这也是契约。跳过MVVM直接学API就像学开车只练油门和刹车却不理解“离合器分离时发动机和变速箱是断开的”这个物理事实。一旦遇到v-model在自定义组件里失效、watch监听不到嵌套对象变化这类问题你就只能靠试错而不是靠原理定位。2.2 为什么选Vue 3作为主分析载体不是因为它最好而是因为它最“诚实”市面上讲MVVM常拿WPF或Knockout.js举例但对绝大多数国内前端开发者来说这些场景太遥远。我们选Vue 3不是因为它多先进而是因为它把MVVM的底层契约暴露得最彻底。比如它的响应式系统Model层的“活化”过程Vue 2用Object.defineProperty劫持已有属性导致新增属性无法响应Vue 3用Proxy代理整个对象只要你用reactive({})包裹它就自动成为“活”的Model。这个“活化”动作就是MVVM中Model向ViewModel暴露自身可观察性的关键一步。你写const state reactive({ name: Alice })本质上是在告诉ViewModel“我这个对象以后任何属性的读写你都可以监听到。”ViewModel的“桥接”职责computed和watch不是语法糖它们是ViewModel履行“状态派生”和“副作用管理”职责的法定工具。computed保证派生状态的惰性求值和缓存watch负责处理那些不能放进computed的异步或复杂副作用比如调用API、操作DOM。如果你在computed里写了fetch()那说明你混淆了ViewModel和Model的边界——获取数据是Model的事ViewModel只负责“当数据来了我怎么展示”。View层的“契约接口”v-model不是简单的双向绑定它是View向ViewModel发起“数据同步请求”的标准化协议。你写input v-modeluser.name /等价于input :valueuser.name inputuser.name $event.target.value /这个协议强制View只通过ViewModel暴露的方法来修改ModelView自己绝不直接碰user.name。这就是MVVM防止“视图逻辑污染数据逻辑”的防火墙。选择Vue 3还因为它提供了defineComponent、defineAsyncComponent等显式API让你能清晰看到“哪里是组件定义的起点”而不会像某些框架那样把初始化逻辑藏在生命周期钩子里。这种“显式优于隐式”的设计恰恰让MVVM的三层边界更容易被肉眼识别。2.3 拒绝“纸上谈兵”我们的分析锚点是真实项目中的三个高频痛点很多架构模式文章失败的原因是用理想化的TodoMVC来演示。但真实项目里MVVM的挑战从来不在“增删改查”而在以下三个场景跨组件状态共享的失控比如一个筛选面板FilterPanel和一个数据表格DataTable需要共享筛选条件。新手常把filters直接$emit到父组件再$props传下去结果父组件成了“状态垃圾场”十几个子组件的filters、sortConfig、pagination全挤在一个data对象里改一个字段要全局搜索三遍。MVVM的解法是把filters抽成独立的Model如useFilters()由ViewModel组合式函数统一管理ViewFilterPanel和DataTable只订阅它暴露的state和actions。这样状态流是单向的View → ViewModel → Model → ViewModel → View而不是网状纠缠。异步数据加载的时机混乱点击按钮触发APIloading状态没及时显示错误提示一闪而过成功后的数据更新又和另一个并发请求打架。这是因为把“网络请求”Model层职责和“UI状态控制”ViewModel层职责混在了一起。MVVM要求Model只负责fetchUser(id)并返回PromiseViewModel用watch监听id变化调用Model并将loading、error、data这些状态封装进自己的state对象View只消费这个state。这样loading状态的生命周期完全由ViewModel控制和API调用解耦。表单验证的逻辑漂移邮箱格式校验写在blur事件里密码强度校验放在input里提交时又重复一遍。验证规则散落在View的各个事件处理器中Model层完全不知情。MVVM的正确姿势是把验证规则定义在Model层如const userSchema { email: /^[^\s][^\s]\.[^\s]$/ }ViewModel封装validate(field, value)方法View只负责传递field和value拿到{ valid, message }后更新对应UI。验证逻辑从此有了唯一信源测试也只需针对ViewModel的validate方法。这三个痛点就是我们接下来所有技术解析的出发点。不讲虚的只解决你明天上班就要面对的问题。3. 核心细节解析与实操要点从代码行到架构契约的逐层穿透3.1 Model层不是“数据容器”而是“可观察契约的签署方”很多人把Model简单理解为const user { name: , email: }这是危险的。在MVVM中Model的核心价值不是“存数据”而是“承诺可观察”。Vue 3里这个承诺通过ref和reactive两个API来签署。ref(value)适用于基础类型string/number/boolean或需要被解构使用的对象。它返回一个带.value属性的响应式引用。为什么需要.value因为JavaScript的基础类型是值传递无法被Proxy代理。ref用一个对象包装它让Proxy能劫持这个对象的.value属性。所以const count ref(0)你读取的是count.value修改的也是count.value。这个.value不是语法糖而是MVVM中Model向ViewModel暴露“可读可写接口”的显式标识。reactive(obj)适用于对象和数组。它返回一个Proxy代理后的响应式对象。注意reactive返回的对象不能解构使用。比如const state reactive({ count: 0 }) const { count } state // ❌ 解构后count失去响应式 console.log(count) // 0但后续state.count不会触发更新原因在于解构赋值得到的是普通值不再是Proxy的属性访问。MVVM要求Model的访问必须经过ViewModel的“代理层”而解构绕过了这个代理。正确的做法是始终通过state.count访问或者用toRefs(state)将响应式对象的所有属性转为ref再解构const state reactive({ count: 0 }) const { count } toRefs(state) // ✅ count是ref保持响应式提示ref和reactive的选择本质是MVVM中Model粒度的决策。ref适合原子级状态如单个输入框的值reactive适合结构化状态如整个表单数据对象。混用时reactive内部的ref会被自动展开但ref内部的reactive对象不会被代理——这是Vue响应式系统的“穿透规则”也是Model层契约的边界线。3.2 ViewModel层不是“逻辑仓库”而是“状态转换与副作用的调度中心”ViewModel是MVVM中最容易被滥用的一层。常见错误是把它写成“大杂烩”既处理API调用又做数据格式化还直接操作DOM。正确的ViewModel应该严格遵循两个原则单一职责每个组合式函数Composable只解决一个领域问题。useUser()只管用户数据的获取、缓存、更新useForm()只管表单状态、验证、提交usePagination()只管分页参数、跳转逻辑。它们之间通过参数传递数据而不是互相import对方的内部状态。纯函数倾向ViewModel的导出函数应尽量是“纯”的——给定相同输入返回相同输出且不产生副作用。副作用如API调用、定时器、DOM操作必须被显式封装和隔离。例如// ❌ 错误副作用混在逻辑里 function useUser(id) { const data ref(null) const loading ref(false) fetch(/api/user/${id}).then(res res.json()).then(d { data.value d loading.value false }) return { data, loading } } // ✅ 正确副作用被封装逻辑可测试 function useUser(id) { const data ref(null) const loading ref(false) const error ref(null) async function load() { loading.value true try { const res await fetch(/api/user/${id}) data.value await res.json() } catch (e) { error.value e } finally { loading.value false } } // load是副作用方法data/loading/error是纯状态 return { data, loading, error, load } }这里的关键洞察是load()方法是ViewModel向View暴露的“能力接口”而data、loading、error是它向View暴露的“状态接口”。View通过调用load()来触发行为通过读取data来获取数据——这正是MVVM中View与ViewModel的契约View不关心数据怎么来只关心“有”和“没有”ViewModel不关心View长什么样只关心“状态是否就绪”。3.3 View层不是“模板字符串”而是“ViewModel契约的消费者”View层的代码.vue文件的template部分常被低估但它其实是MVVM契约的最终执行者。一个健壮的View必须做到三点零业务逻辑模板里禁止出现v-ifuser.role admin user.status ! banned这种复合判断。应该由ViewModel提供isUserAdminAndActive这样的计算属性// ViewModel const isUserAdminAndActive computed(() user.value?.role admin user.value?.status ! banned )View只写v-ifisUserAdminAndActive。这样权限逻辑集中在ViewModelView只做“呈现决策”而非“业务决策”。明确的数据来源每个插值表达式{{ xxx }}或指令v-bind:xxx其xxx必须能追溯到ViewModel的某个明确导出项。如果看到{{ $route.params.id }}说明View越过了ViewModel直接和Router耦合——这违反了MVVM的“View只信任ViewModel”原则。正确做法是ViewModel通过useRoute()获取id再暴露给View。事件处理的标准化clickhandleClick是反模式。handleClick应该是ViewModel暴露的方法且命名体现意图而非实现如clickonUserDeleteClick。更重要的是View不应承担事件参数的解析工作。比如!-- ❌ 错误View解析事件细节 -- button clickdeleteUser($event.target.dataset.id)删除/button !-- ✅ 正确View只传递原始事件解析由ViewModel做 -- button clickonUserDeleteClick删除/buttonViewModel里function onUserDeleteClick(event) { const id event.target.closest(button).dataset.id // 或更好的方式用事件委托或直接绑定id到方法 }注意Vue的v-model在自定义组件中的实现是View层契约的绝佳案例。它要求子组件必须同时定义modelValueprop和update:modelValue事件。当你写my-input v-modelname /Vue自动编译为my-input :model-valuename update:model-valueval name val /这个编译规则就是MVVM强制View与ViewModel此处是子组件的ViewModel之间必须遵守的“数据同步协议”。不按这个协议写v-model就失效——这就是契约的硬性约束力。4. 实操过程与核心环节实现从零搭建一个符合MVVM契约的商品筛选列表系统4.1 第一步定义Model层——用TypeScript刻画数据契约我们以电商后台的商品管理页为例。核心数据包括筛选条件分类、价格区间、上架状态、商品列表、分页信息。Model层的目标是用类型系统定义数据结构并用响应式API使其可观察。// models/product.model.ts export interface ProductFilter { category: string // 分类ID minPrice: number | null maxPrice: number | null status: all | online | offline } export interface ProductItem { id: string name: string price: number category: string status: online | offline createdAt: string } export interface Pagination { currentPage: number pageSize: number total: number } // 创建可观察的Model实例 import { reactive } from vue export function createProductModel() { return reactive({ filters: { category: , minPrice: null, maxPrice: null, status: all as const } satisfies ProductFilter, list: [] as ProductItem[], pagination: { currentPage: 1, pageSize: 20, total: 0 } satisfies Pagination, // 加载状态属于Model的“元数据” loading: false, error: null as string | null }) }这里的关键设计点satisfies ProductFilterTypeScript 4.9的特性确保filters对象的结构严格匹配接口同时保留字面量类型如status: all的字面量类型被保留而非宽泛的string。这是Model层“契约”的第一道防线。list: [] as ProductItem[]显式类型断言避免[]被推断为any[]。Model的类型必须精确否则ViewModel的计算逻辑会失去类型保护。loading和error被放在Model里是因为它们是数据加载过程的固有状态和list、pagination同属一个数据域。如果把它们放到ViewModel里会导致状态分散违背“单一数据源”原则。4.2 第二步构建ViewModel层——用组合式函数封装状态与行为ViewModel层要解决三个核心问题1如何根据filters变化自动加载商品列表2如何处理分页跳转3如何重置筛选条件。我们将其拆分为useProductList和useProductFilters两个组合式函数。// composables/useProductList.ts import { ref, watch, onMounted } from vue import { createProductModel, ProductFilter, ProductItem, Pagination } from /models/product.model import { fetchProducts } from /api/product.api // 假设的API模块 export function useProductList(filters: ProductFilter) { const model createProductModel() // 将filters与model.filters建立响应式连接 // 这里filters是来自useProductFilters的ref所以用watch监听 watch( () filters, (newFilters) { // 重置分页到第一页 model.pagination.currentPage 1 // 触发加载 loadProducts(newFilters) }, { immediate: true } // 组件挂载时立即执行 ) async function loadProducts(filter: ProductFilter) { model.loading true try { const { data, pagination } await fetchProducts(filter, model.pagination.currentPage, model.pagination.pageSize) model.list data model.pagination { ...model.pagination, ...pagination } model.error null } catch (e) { model.error e instanceof Error ? e.message : 加载失败 model.list [] } finally { model.loading false } } function goToPage(page: number) { if (page 1 || page Math.ceil(model.pagination.total / model.pagination.pageSize)) return model.pagination.currentPage page loadProducts(filters) } return { // 暴露Model的响应式状态View可读 ...model, // 暴露ViewModel的行为方法View可调用 loadProducts, goToPage } } // composables/useProductFilters.ts import { ref, reactive } from vue import { ProductFilter } from /models/product.model export function useProductFilters() { const filters reactiveProductFilter({ category: , minPrice: null, maxPrice: null, status: all }) function reset() { filters.category filters.minPrice null filters.maxPrice null filters.status all } function setCategory(id: string) { filters.category id } // 其他setter方法... return { filters, // 暴露响应式对象供watch reset, setCategory, // ...其他方法 } }ViewModel的设计哲学useProductList不创建自己的filters而是接收外部传入的filters这体现了ViewModel的“被动性”——它不主动持有筛选条件只响应条件变化。筛选条件的“所有权”在useProductFiltersuseProductList只是它的“消费者”。这种分离让filters的状态变更可以被多个ViewModel如筛选面板、统计图表同时订阅而无需全局状态管理。watch的immediate: true选项是MVVM中“初始化加载”的标准模式它确保ViewModel在创建后立即履行“根据当前筛选条件加载数据”的契约而不是等待第一次filters变化。这是View层“首次渲染即有数据”的保障。goToPage方法内部调用loadProducts(filters)而不是loadProducts()因为filters是响应式对象其最新值在loadProducts函数体内读取即可。ViewModel不维护filters的副本只信任它所接收的“权威来源”。4.3 第三步编写View层——用模板语法兑现ViewModel契约现在我们将ViewModel的输出映射到真实的.vue组件中。重点展示如何让View层“零思考”地消费ViewModel。!-- views/ProductManagement.vue -- template div classproduct-management !-- 筛选面板消费useProductFilters -- FilterPanel :filtersfilterState.filters resetfilterState.reset set-categoryfilterState.setCategory / !-- 商品列表消费useProductList -- div classlist-header span共 {{ listState.pagination.total }} 件商品/span LoadingSpinner v-iflistState.loading / /div ProductList :itemslistState.list :loadinglistState.loading :errorlistState.error / !-- 分页器消费useProductList的goToPage -- Pagination :current-pagelistState.pagination.currentPage :totallistState.pagination.total :page-sizelistState.pagination.pageSize changelistState.goToPage / /div /template script setup langts import { useProductFilters } from /composables/useProductFilters import { useProductList } from /composables/useProductList // 创建ViewModel实例 const filterState useProductFilters() const listState useProductList(filterState.filters) /scriptView层的精妙之处组件间通信被ViewModel完全吸收FilterPanel和ProductList之间没有$emit/$on也没有provide/inject。它们都只和各自的ViewModel交互。FilterPanel通过set-category事件通知filterState更新filtersfilterState的filters是响应式对象listState的watch立刻捕获变化并触发加载。整个数据流是ViewFilterPanel→ ViewModelfilterState→ ViewModellistState→ ModelAPI→ ViewModellistState→ ViewProductList。View层只负责“触发”和“展示”不参与“流转”。LoadingSpinner v-iflistState.loading /是MVVM“状态驱动UI”的典型loading是Model层的一个状态字段ViewModel未做任何加工直接暴露给View。View根据这个布尔值决定是否显示加载动画——逻辑完全下放View极度轻量。changelistState.goToPage是事件绑定的最优实践goToPage是ViewModel暴露的方法change事件传递的页码参数会自动作为第一个参数传入。View不需要写changepage listState.goToPage(page)因为Vue的事件绑定机制会自动透传。这减少了View的模板噪音让契约更干净。4.4 第四步强化契约——添加类型守卫与运行时校验MVVM的健壮性不仅靠开发时的自觉更要靠运行时的防护。我们在关键节点添加校验// composables/useProductList.ts (续) import { assert } from /utils/assert export function useProductList(filters: ProductFilter) { // 运行时校验确保filters是有效的ProductFilter对象 assert( typeof filters object filters ! null, useProductList: filters must be a non-null object ) const model createProductModel() watch( () filters, (newFilters) { // 深度校验filters结构 assert( typeof newFilters.category string (newFilters.minPrice null || typeof newFilters.minPrice number) (newFilters.maxPrice null || typeof newFilters.maxPrice number) [all, online, offline].includes(newFilters.status), useProductList: invalid filters shape: ${JSON.stringify(newFilters)} ) model.pagination.currentPage 1 loadProducts(newFilters) }, { immediate: true } ) // ... }assert函数在开发环境抛出错误在生产环境静默可通过DefinePlugin替换为空函数。这种校验相当于在ViewModel的入口处设置了一个“安检门”确保流入的数据符合Model层的契约。它比TypeScript的静态检查更进一步能捕获运行时的非法数据如后端返回了status: draft而TypeScript接口未定义此值。5. 常见问题与排查技巧实录那些让你深夜加班的MVVM“幽灵Bug”5.1 问题现象v-model在自定义组件中失效输入框内容不更新现场还原你写了一个MyInput v-modelform.name /但在MyInput组件内部props.modelValue能收到初始值但你在input上绑定了:valueprops.modelValue和input$emit(update:modelValue, $event.target.value)输入时form.name却纹丝不动。根本原因Vue 3中v-model的默认prop名是modelValue但事件名必须是update:modelValue。你可能写成了input$emit(update:model-value, ...)用了短横线或input$emit(input, ...)旧Vue 2写法。排查步骤打开浏览器开发者工具切换到“Vue Devtools”标签页在组件树中找到你的MyInput组件点击它查看右侧的“Props”面板确认modelValue的值是否随输入实时变化如果modelValue不变切换到“Events”面板手动触发一次update:modelValue事件Devtools提供Event Emit按钮看form.name是否更新。如果更新说明是事件名错误如果不更新说明emit没被调用。终极解决方案!-- MyInput.vue -- template input :valuemodelValue input$emit(update:modelValue, $event.target.value) / /template script setup langts const props defineProps{ modelValue: string }() const emit defineEmits{ (e: update:modelValue, value: string): void }() /script注意defineEmits的类型声明是关键。它不仅提供类型安全还让Vue编译器知道这个组件支持v-model从而启用优化。漏掉它v-model可能在某些版本中降级为props $emit的模拟实现导致性能问题。5.2 问题现象watch监听一个reactive对象的属性变化时回调不执行现场还原你写了const state reactive({ count: 0 })然后watch(() state.count, (newVal) console.log(newVal))但state.count后回调没打印。根本原因watch的监听函数() state.count在state.count时state.count的值确实变了但watch的内部机制是基于响应式依赖收集的。state.count是一个基础类型reactive代理的对象属性访问其依赖收集发生在state.count被读取的那一刻。state.count是读取赋值但watch的回调只在“读取”时建立依赖而“赋值”触发更新。问题在于state.count等价于state.count state.count 1它触发了state.count的set但watch监听的是state.count的get。只要state.count在监听函数中被读取过set就会触发回调。所以这个例子理论上应该工作。真正的常见原因是监听函数中没有真正读取到响应式属性。排查步骤检查监听函数是否真的执行了读取。比如watch(() { console.log(getter called) return state.count }, (newVal) console.log(newVal))如果getter called没打印说明watch根本没建立依赖可能是watch写在了onBeforeMount之后而state在那时还未初始化。检查state是否是reactive创建的。如果是ref必须用state.valueconst state ref({ count: 0 }) watch(() state.value.count, ...) // ✅ 正确 watch(() state.count, ...) // ❌ state是ref没有count属性避坑技巧对于深层嵌套对象的监听永远用watch的第三个参数{ deep: true }而不是试图在监听函数里写state.user.profile.address.city。因为deep: true会递归收集所有嵌套属性的依赖而手动写路径一旦路径中某个中间属性是undefined整个监听就失效了。5.3 问题现象computed返回的值在模板中显示正常但在watch中监听它回调不执行现场还原你定义了const fullName computed(() user.firstName user.lastName)模板里{{ fullName }}显示正确但watch(fullName, (val) console.log(val))当user.firstName变化时watch回调不触发。根本原因computed返回的是一个RefImpl对象带.value的ref而watch的第一个参数如果传入的是Ref它会自动解包监听.value的变化。但如果你传入的是一个函数watch会监听函数的返回值。所以两种写法等价// 方式1传入ref自动解包 watch(fullName, (val) ...) // 方式2传入getter函数手动解包 watch(() fullName.value, (val) ...)问题通常出在你可能把fullName当成了普通值写了watch(() fullName, ...)这监听的是fullName这个RefImpl对象本身的引用变化永远不会变而不是它的.value。快速验证在watch回调里打印val的类型watch(fullName, (val) { console.log(type:, typeof val, value:, val) // 应该是string })如果打印出来是object说明你监听的是RefImpl对象本身。解决方案始终用第一种方式——直接传入computed返回的ref。这是Vue官方推荐的、最安全的方式。5.4 问题现象组件卸载后watch或setTimeout的回调还在执行导致“Cannot update a component while unmounting”警告现场还原你在setup()里写了watch(...)并在其中调用了api.fetchData()但用户快速切换路由组件已卸载fetchData的then回调里执行了state.data result报错。根本原因watch、onMounted、setTimeout等API返回的清理函数必须被正确调用。Vue 3的watch在组件卸载时会自动清理但前提是它是在setup()或onBeforeMount等生命周期钩子内调用的。如果你在watch的回调里启动了一个setTimeout这个setTimeout的清理责任就在你身上。**标准修复
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进