1. 为什么监听 props 变化是个技术活刚接触 Vue3 那会儿我总觉得监听个 props 变化能有多难不就是用个watch吗结果在实际项目里我踩了不少坑。比如一个复杂的表单组件父组件传下来一堆配置项里头的某个字段一变子组件里好几个地方都得跟着联动更新。一开始我图省事在setup里写了好几个watch结果发现组件渲染有点卡性能监控工具里一堆不必要的依赖追踪。后来我才明白监听 props 这事儿选对方法效果天差地别。Vue3 给了我们更灵活的组合式 API监听数据变化的选择也多了。但选择多有时候反而让人纠结到底该用watch、watchEffect还是computed它们看起来都能干类似的活儿但在不同的业务场景下它们的表现和适用性完全不同。用错了轻则代码冗余、难以维护重则引发性能问题比如不必要的重复计算、组件过度渲染。所以这篇文章我就结合自己这几年在真实项目里摸爬滚打的经验跟你聊聊 Vue3 里监听 props 变化的三种核心方案。我不会只给你干巴巴的 API 文档而是会带你回到最常见的开发场景里比如“表单字段联动”、“动态组件切换”看看在这些具体情况下哪种方案最顺手、最高效。我的目标很简单让你看完就能用用了就见效避开我当年走过的弯路。2. 方案一经典可靠的watch—— 精准狙击变化watch就像是 Vue 世界里的一个老牌侦察兵它的任务非常明确盯紧一个或多个特定的目标一旦目标有动静就立刻向你汇报并执行你预设的任务。在监听 props 这件事上watch的优势在于它的精准性和明确性。2.1 基础用法与核心语法我们先来看看最基本的用法。假设你有一个用户头像组件父组件会传递一个userId过来当userId变化时子组件需要去重新拉取对应用户的头像信息。template img :srcavatarUrl alt用户头像 / /template script setup import { ref, watch } from vue import { fetchUserAvatar } from /api/user const props defineProps({ userId: { type: String, required: true } }) const avatarUrl ref() // 使用 watch 精准监听 userId 的变化 watch( () props.userId, // 数据源一个返回 props.userId 的 getter 函数 async (newUserId, oldUserId) { // 回调函数 console.log(用户ID从 ${oldUserId} 变更为 ${newUserId}) if (newUserId) { avatarUrl.value await fetchUserAvatar(newUserId) } }, { immediate: true } // 选项立即执行一次回调 ) /script这段代码有几个关键点我解释一下。首先watch的第一个参数是一个getter 函数() props.userId。为什么不直接传props.userId呢这是因为在 Vue3 的setup或script setup中props本身是一个响应式对象但它的属性需要被一个函数“包裹”起来watch才能正确建立依赖关系。其次回调函数能拿到新值和旧值这在很多需要对比的场景下非常有用比如判断数据是新增还是更新。最后{ immediate: true }这个选项让回调在侦听器创建时立即执行一次这对于初始化数据非常方便。2.2 高级场景监听多个 props 与深度监听实际项目很少只监听一个属性。比如一个数据表格组件它同时接收queryParams查询参数和pageSize每页大小两个 props任意一个变化都需要重新查询数据。script setup import { watch } from vue import { fetchTableData } from /api/table const props defineProps({ queryParams: { type: Object, default: () ({}) }, pageSize: { type: Number, default: 10 } }) // 监听多个数据源用数组包裹 watch( [() props.queryParams, () props.pageSize], // 回调函数接收的新值和旧值也是数组形式 ([newParams, newSize], [oldParams, oldSize]) { console.log(查询条件或分页大小变化了重新拉取数据) loadData() }, { deep: true } // 注意当监听对象时可能需要深度监听 ) async function loadData() { // 组合参数进行查询 const data await fetchTableData({ ...props.queryParams, pageSize: props.pageSize }) // ... 处理数据 } /script这里引入了两个新概念。一是监听多个源用数组把多个 getter 函数包起来就行对应的新值旧值也是按顺序的数组。二是deep选项。当你的 prop 是一个复杂的对象或数组并且你关心它内部嵌套属性的变化时比如queryParams.filter.name变了就需要设置{ deep: true }。但我要给你提个醒深度监听对性能有开销因为它需要递归遍历整个对象。如果可能尽量监听一个具体的、扁平的属性或者使用后面会提到的其他方案来优化。2.3 适用场景与实战经验在我用过的项目里watch最适合以下几种情况副作用操作比如上面例子中的发起网络请求、操作 DOM虽然 Vue 不推荐直接操作、调用第三方库等。这些操作通常需要在确切知道什么变了之后才执行。需要旧值进行逻辑判断例如一个视频播放器组件当videoSrc这个 prop 变化时我需要先判断旧链接是否还在播放如果有先停止旧视频再加载新的。控制执行的时机watch默认是惰性的只在侦听的源变化时才执行。你还可以通过flush: post选项让回调在组件更新之后再执行这在需要访问更新后的 DOM 时特别有用。我踩过的一个坑是关于深度监听和默认值的。有一次我监听一个对象 prop设置了deep: true但父组件有时不传这个 prop。我给了它一个空对象default: () ({})作为默认值。结果发现即使父组件没传值子组件初始化时watch的回调也被触发了。这是因为deep监听会对这个新创建的空对象进行依赖收集。解决方案是要么不加deep要么在回调里加一个判断如果对象是“空”的则跳过逻辑。3. 方案二智能自动的watchEffect—— 响应式依赖追踪器如果说watch是目标明确的侦察兵那watchEffect就像是一个装了自动感应雷达的机器人。你不需要告诉它具体监视哪个目标只需要给它一个任务一个函数。它会自动运行这个函数并且在运行过程中智能地“嗅探”并收集函数内部所有用到的响应式数据包括 props、ref、reactive 等作为自己的依赖。之后只要这些依赖中的任何一个发生了变化它就会自动重新执行这个任务。3.1 核心机制与基本使用这个概念听起来有点抽象我们直接看代码。还是那个根据userId获取头像的场景用watchEffect来实现script setup import { ref, watchEffect } from vue import { fetchUserAvatar } from /api/user const props defineProps({ userId: { type: String, required: true } }) const avatarUrl ref() // watchEffect自动追踪回调函数内的响应式依赖 watchEffect(async () { // 函数内部使用了 props.userId它会被自动追踪为依赖 if (props.userId) { console.log(检测到 userId 变化为: ${props.userId}开始获取头像) avatarUrl.value await fetchUserAvatar(props.userId) } else { avatarUrl.value // 清空头像 } }) /script看到了吗代码简洁了很多。我们不需要显式地声明要监听props.userId只需要在函数里使用它。watchEffect会在首次执行时记录下函数执行过程中访问过的每一个响应式属性。之后只要props.userId一变这个函数就会自动重新跑一遍。这种写法非常符合直觉尤其是当你的副作用逻辑依赖于多个响应式状态时你不需要再费心把它们都列到监听数组里。3.2 与watch的关键差异和典型陷阱虽然用起来方便但watchEffect和watch有几个根本区别理解不透就容易掉坑里。第一它没有旧值。回调函数被触发时你只能拿到最新的值。如果你需要对比新旧值来做一些逻辑比如只有值从 A 变成 B 时才执行操作那watchEffect就不太方便需要你自己用额外的ref来存储上一次的值。第二初始化执行时机。watchEffect会在组件挂载后立即同步执行一次。而watch默认是惰性的除非你设置immediate: true。这意味着如果你的副作用函数里有异步操作比如上面的fetchUserAvatar在组件创建阶段就会触发一次。这可能是你期望的也可能不是需要根据业务逻辑来判断。第三依赖收集是动态的。这是最需要小心的一点。watchEffect的依赖是在每次执行时动态收集的。看下面这个例子script setup import { ref, watchEffect } from vue const props defineProps({ showExtra: Boolean, extraData: String }) const message ref() watchEffect(() { if (props.showExtra) { // 只有当 showExtra 为 true 时才会访问 props.extraData message.value 主要信息额外信息${props.extraData} } else { message.value 主要信息 } }) /script在这个例子里watchEffect的依赖关系是动态的。如果props.showExtra初始为false那么第一次执行时函数只访问了props.showExtra和message所以它只追踪了这两个依赖。此时即使props.extraData发生变化也不会触发副作用。只有当props.showExtra变成true后函数再次执行访问了props.extraData这时extraData才会被加入依赖列表。这种特性既强大又危险强大在于可以优化性能只在需要时追踪危险在于如果理解不到位可能会疑惑“为什么数据变了函数没执行”3.3 最佳使用场景与性能考量根据我的经验watchEffect在以下场景中大放异彩依赖多个响应式状态的复杂副作用。比如一个图表组件它根据传入的data数据、chartType图表类型、colorScheme配色三个 props 来渲染。任何一项变化都需要重新绘制图表。用watch你得写[() props.data, () props.chartType, ...]而用watchEffect你只需要在绘制图表的函数里使用这三个属性依赖会自动管理代码更干净。建立非响应式连接的“桥梁”。例如你需要用一个 prop 的值去初始化一个第三方非 Vue 库的实例并且在这个 prop 变化时更新实例配置。用watchEffect可以把创建和更新逻辑写在一起非常清晰。但是性能上要留个心眼。因为watchEffect会立即执行并且依赖追踪是“贪婪”的如果副作用函数很庞大或者内部有条件分支导致依赖频繁变动可能会引起一些不必要的计算。对于简单的、目标明确的监听watch的确定性反而更有优势。我个人的习惯是逻辑简单、依赖明确时用watch逻辑复杂、依赖动态或众多时优先考虑watchEffect但要仔细审视其执行逻辑。4. 方案三声明式计算的computed—— 派生响应式状态前两种方案watch和watchEffect核心是处理“副作用”Side Effect比如发请求、改DOM、打印日志。但很多时候我们监听 props 变化并不是为了干这些“额外”的事而是为了根据 props 计算出一个新的、用于视图渲染的值。对于这种场景Vue3 提供了更优雅、更声明式的方案computed计算属性。你可以把computed想象成一个智能的、带缓存的计算器。你给它一个计算规则一个函数它就会返回一个只读的响应式引用。这个引用值会根据其内部依赖的响应式数据比如 props自动计算得出并且只有当依赖变化时它才会重新计算否则就直接返回缓存的上一次结果。4.1 将监听转化为派生状态我们来看一个非常常见的例子一个显示用户全名的组件。父组件传递firstName和lastName两个 props我们需要在它们变化时组合成完整的姓名显示。template div !-- 直接在模板中使用计算属性就像使用普通 ref 一样 -- p用户全名{{ fullName }}/p p姓名长度{{ nameLength }}/p /div /template script setup import { computed } from vue const props defineProps({ firstName: String, lastName: String }) // 使用 computed 声明一个派生状态 const fullName computed(() { console.log(计算 fullName...) return ${props.firstName || } ${props.lastName || }.trim() }) // 甚至可以基于另一个计算属性进行计算 const nameLength computed(() fullName.value.length) /script这段代码非常清晰。我们定义了一个fullName计算属性它的值由props.firstName和props.lastName决定。当这两个 prop 中的任何一个发生变化时fullName会自动重新计算并且触发模板的更新。注意我们在计算函数里加了个console.log你可以观察到只有在依赖的 props 真正变化时这个函数才会执行否则就读取缓存这就是它的性能优势。4.2 对比watch理念上的不同用watch也能实现类似功能但写法迥异script setup import { ref, watch } from vue const props defineProps({ firstName: String, lastName: String }) const fullName ref() // 使用 watch 来达到类似效果 watch( [() props.firstName, () props.lastName], () { fullName.value ${props.firstName || } ${props.lastName || }.trim() }, { immediate: true } // 别忘了初始化 ) /script两相对比高下立判。computed的方案是声明式的“全名是姓和名的组合”。而watch的方案是命令式的“当姓或名变化时去执行一段代码来更新全名”。前者更简洁更贴近数据之间的本质关系也更容易被 Vue 的响应式系统优化。4.3 可写的计算属性与复杂场景绝大多数情况下computed返回的是只读引用。但 Vue3 也提供了定义“可写计算属性”的能力这在你需要基于 prop 创建一个本地可修改的数据但又希望在修改时做一些额外操作时非常有用。不过这个场景相对进阶我提一下让你有个概念。假设你有一个表单输入组件它接收一个modelValuepropv-model 绑定但你在组件内部需要对输入进行格式化比如自动去除首尾空格。template input :valuedisplayValue inputonInput / /template script setup import { computed } from vue const props defineProps([modelValue]) const emit defineEmits([update:modelValue]) const displayValue computed({ // getter从 prop 派生显示值这里可以加格式化逻辑 get() { return props.modelValue ? String(props.modelValue).trim() : }, // setter当显示值变化时更新父组件这里可以加验证逻辑 set(newValue) { const formattedValue newValue.trim() emit(update:modelValue, formattedValue) } }) function onInput(e) { displayValue.value e.target.value // 这里会触发 setter } /script在这个例子里displayValue成了一个“中间人”。它从props.modelValue读取get经过格式化后显示在输入框当输入框内容改变它通过 setter 将格式化后的值同步回父组件。这样子组件内部就有了一个响应式的、可操作的“副本”同时保证了数据流依然是清晰单向的prop down, event up。这比用watch去监听props.modelValue然后更新一个本地ref要优雅和高效得多。5. 三大方案横向对比与选型指南聊了这么多是时候把这三个家伙拉到一起做个全方位的对比了。光知道怎么用还不够关键是要知道在什么情况下该用谁。我画了个简单的表格帮你快速抓住核心区别特性维度watchwatchEffectcomputed核心目的监听特定数据源变化执行副作用。自动追踪函数内的响应式依赖执行副作用。根据依赖计算一个新的响应式值。依赖声明显式声明在第一个参数中列出。隐式自动收集在回调函数执行过程中收集。隐式自动收集在 getter 函数中收集。能否获取旧值可以回调函数参数提供 (newVal, oldVal)。不可以只能访问到最新的值。不可以计算的是当前最新值。首次执行时机默认惰性需设置immediate: true来立即执行。立即同步执行。立即执行计算初始值。返回值返回一个停止侦听的函数。返回一个停止侦听的函数。返回一个只读的响应式引用可写计算属性除外。典型场景数据变化后需要发起请求、执行动画、操作DOM等。依赖多个状态且逻辑复杂的副作用建立与非Vue库的响应式连接。派生新的视图数据格式化、过滤或组合 props。5.1 实战选型心法光看表格可能还是有点抽象我结合几个真实场景给你讲讲我的选型思路。场景A一个搜索框组件当父组件传递的keywords关键词变化时需要防抖后发起搜索请求。分析这是一个典型的副作用发请求并且需要明确的源keywords还需要防抖控制频率。方案首选watch。因为你可以精确监听keywords在回调里实现防抖逻辑并且可以轻松拿到新旧值虽然这里可能不需要。watchEffect也能做但防抖逻辑需要写在 effect 内部并且对keywords的依赖不够直观。computed完全不合适因为这不是在计算一个新值。场景B一个实时数据仪表盘需要根据timeRange时间范围、dataType数据类型、filters筛选器等多个 props 的变化实时重绘一个复杂的 ECharts 图表。分析副作用调用 ECharts 的setOption依赖多个且可能动态变化的 props逻辑相对复杂。方案优先考虑watchEffect。你可以把绘制图表的整个函数放在watchEffect回调里它内部用到的所有 props 都会被自动追踪。任何一项变化都会触发重绘代码组织起来非常集中和直观。用watch的话监听数组会很长且如果依赖关系后期有变动维护起来麻烦。场景C一个用户信息展示组件接收userInfo对象 prop需要从中提取出displayName显示名优先取昵称没有则用用户名和formattedDate格式化后的注册日期在模板中显示。分析这纯粹是根据现有 props派生出新的用于显示的数据没有副作用。方案毫无疑问使用computed。定义const displayName computed(() ...)和const formattedDate computed(() ...)。代码声明性强性能好有缓存且直接在模板中使用简洁优雅。用watch来实现则是舍近求远。5.2 性能与代码风格上的最终建议从性能角度说computed由于其惰性求值和缓存机制在纯计算场景下通常是最优的。watch和watchEffect都需要执行回调函数开销相对大尤其是deep: true的watch和依赖复杂的watchEffect要谨慎使用。从代码风格和维护性上看我强烈推荐遵循这个原则能用computed解决的就不用watch或watchEffect。这能促使你写出更多声明式的、数据驱动视图的代码减少命令式的、过程式的副作用代码这样的代码更容易理解和测试。当确实需要副作用时再在watch和watchEffect间选择。选择的关键在于你是否需要明确知道是“谁”变了以及变化前的旧值如果需要选watch如果不需要且依赖关系复杂或动态选watchEffect。最后记住一点这三种方式并不是完全互斥的。在一个组件里完全可以根据不同需求混合使用。比如用computed派生几个显示用的值再用一个watch监听某个关键的派生值变化去发起请求。灵活运用才能写出既高效又清晰的 Vue3 代码。