Vue 基础体系 · 第 4/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 响应式原理:ref、reactive、computed、watch 与依赖追踪
Vue 的响应式系统要解决的问题可以形式化为:
当状态
S发生变化时,自动找到依赖S的副作用E,并在合适的时机重新执行E。
这里的“副作用”包括:
- 组件渲染函数:状态变化后更新 DOM;
computed的派生值:依赖变化后失效;watch回调:监听到变化后执行逻辑;watchEffect:重新执行读取过响应式状态的函数。
ref 和 reactive 负责让数据具备可追踪的读写行为,computed 负责缓存派生数据,watch 负责监听变化并执行副作用。它们都建立在同一套“依赖收集与触发”机制上。
一、先建立模型:什么是依赖追踪
设有一个状态对象:
const state = {
count: 0
}
以及一个副作用函数:
function render() {
console.log(state.count)
}
如果希望 state.count++ 后自动重新执行 render,系统至少需要完成四件事:
- 执行
render; - 发现执行期间读取了
state.count; - 建立
state.count -> render的依赖关系; - 当
state.count被修改时,找到并重新执行render。
可以用一个依赖图表示:
flowchart LR
A["state.count"] --> B["render effect"]
A --> C["computed effect"]
A --> D["watch callback"]
E["state.count = 1"] --> F["trigger"]
F --> B
F --> C
F --> D
1. track:读取时收集依赖
当响应式对象的属性被读取时,Vue 会执行类似下面的逻辑:
track(target, key)
其中:
target是被读取的对象;key是属性名;- 当前正在执行的副作用函数会被记录下来。
概念上可以把依赖表写成:
WeakMap<object, Map<PropertyKey, Set<ReactiveEffect>>>
对应关系是:
target
└── key
└── 依赖该属性的 effect 集合
例如:
state
└── count
└── renderEffect
2. trigger:修改时触发依赖
当响应式属性被修改时,Vue 会执行类似:
trigger(target, key)
它会找到依赖表中与 target 和 key 对应的 effect,然后根据 effect 的调度策略执行、缓存失效或加入更新队列。
3. 正在执行的 effect 才能被收集
依赖收集不是“扫描整个函数源码”,而是运行时收集。
下面的读取会被收集:
const state = reactive({ count: 0 })
watchEffect(() => {
console.log(state.count)
})
执行 watchEffect 的函数时,state.count 被读取,因此建立依赖。
而下面的代码不会收集 state.count:
const value = state.count
watchEffect(() => {
console.log(value)
})
state.count 的读取发生在 watchEffect 开始之前。后面的 effect 只读取普通变量 value,Vue 无法知道这个普通变量原来来自哪个响应式属性。
这也是响应式依赖追踪的基本条件:
不是所有访问都能被追踪,只有在响应式上下文中发生的访问才会进入依赖图。
二、effect、调度器与更新时机
可以把一个响应式副作用抽象成:
type Effect = () => void
但 Vue 实际上还需要记录:
- 当前正在执行的 effect;
- effect 读取过哪些依赖;
- effect 是否已经在执行;
- effect 是否有 scheduler;
- effect 是否应该立即执行或排队执行。
1. 为什么需要 scheduler
如果连续修改多个状态:
state.firstName = 'Ada'
state.lastName = 'Lovelace'
Vue 通常不会同步执行两次组件更新,而是把相关更新放入队列,在当前同步代码结束后批量处理。这样可以避免同一轮代码中重复计算和重复更新 DOM。
可以简化为:
同步修改状态
↓
trigger 找到相关 effect
↓
scheduler 将 effect 放入队列
↓
当前同步任务结束
↓
批量执行队列中的 effect
↓
更新 DOM
因此,下面的代码不能简单理解为“赋值后 DOM 立刻更新”:
state.count++
console.log(document.querySelector('#count')?.textContent)
如果依赖该状态的 DOM 更新尚未执行,读取到的可能仍是旧内容。需要使用:
import { nextTick } from 'vue'
state.count++
await nextTick()
console.log(document.querySelector('#count')?.textContent)
nextTick 等待的是 Vue 已经安排的更新批次,而不是等待任意异步任务。
2. effect 嵌套与依赖清理
computed 和 watch 可能在一个 effect 中读取另一个响应式值,因此存在嵌套执行:
const count = ref(1)
const doubled = computed(() => count.value * 2)
watchEffect(() => {
console.log(doubled.value)
})
外层 effect 读取 doubled.value,而 computed 内部又读取 count.value。Vue 需要正确切换当前 active effect,否则可能把外层 effect 错误地登记到 count 的内部执行过程,或在嵌套结束后丢失上下文。
当 effect 条件性读取不同属性时,还需要清理旧依赖:
const state = reactive({
enabled: true,
a: 1,
b: 2
})
watchEffect(() => {
console.log(state.enabled ? state.a : state.b)
})
第一次执行依赖:
enabled、a
如果之后 enabled = false,重新执行后应该变为依赖:
enabled、b
如果不清理旧依赖,后续修改 a 仍可能错误地触发这个 effect。
三、reactive:通过 Proxy 代理对象
reactive 接收对象、数组、Map、Set 等可代理值,返回一个响应式代理对象。
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: {
name: 'Ada'
}
})
state.count++
state.user.name = 'Grace'
在现代浏览器中,Vue 3 主要通过 JavaScript Proxy 拦截属性读取和修改:
读取 state.count
↓
Proxy.get
↓
track(state, "count")
修改 state.count
↓
Proxy.set
↓
trigger(state, "count")
1. reactive 的深层响应式
普通对象嵌套在 reactive 对象中时,嵌套对象也会在访问时被转换为响应式代理:
const state = reactive({
user: {
profile: {
name: 'Ada'
}
}
})
state.user.profile.name = 'Grace'
state.user.profile.name 的修改可以触发依赖它的组件或 effect。
这不意味着 Vue 会在创建时递归复制整个对象。实际转换通常具有惰性特征:访问嵌套对象时再返回对应代理。
2. reactive 返回的是代理,不是原对象
const raw = { count: 0 }
const proxy = reactive(raw)
console.log(proxy === raw) // false
因此不要混用原对象和代理对象:
const raw = { count: 0 }
const state = reactive(raw)
raw.count++ // 直接修改原对象,绕过代理,不能可靠触发响应式更新
应该始终通过代理访问和修改:
state.count++
如果需要从代理获取原对象,可以使用 toRaw,但它主要用于特定边界场景,例如与不兼容代理对象的第三方库交互,不应作为日常修改入口。
3. reactive 的限制
不能直接代理原始值
下面的代码无法工作:
const count = reactive(0)
reactive 需要对象类型。原始值应使用 ref:
const count = ref(0)
count.value++
不能通过重新赋值替换整个响应式引用
let state = reactive({ count: 0 })
state = reactive({ count: 1 })
这里的变量 state 被换成了另一个代理,原来依赖第一个代理的 effect 不会自动转移到新对象。
如果状态需要整体替换,应使用 ref:
const state = ref({ count: 0 })
state.value = { count: 1 }
解构会丢失属性访问代理
const state = reactive({
count: 0
})
const { count } = state
解构后,count 是一个普通变量。后续读取它不再经过 state 的 Proxy,因此失去对 state.count 的响应式连接。
可以使用 toRefs 保留连接:
import { reactive, toRefs } from 'vue'
const state = reactive({
count: 0,
name: 'Ada'
})
const { count, name } = toRefs(state)
count.value++
toRefs(state) 会为每个属性创建一个 ref-like 对象,其读写仍然转发到 state 的对应属性。
如果只需要一个属性,可以使用:
import { toRef } from 'vue'
const count = toRef(state, 'count')
四、ref:为值提供稳定的 .value 容器
ref 的核心是把一个值放进具有响应式访问器的容器中:
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++
概念上可以近似理解为:
const count = {
get value() {
track(count, 'value')
return internalValue
},
set value(nextValue) {
internalValue = nextValue
trigger(count, 'value')
}
}
这不是 Vue 的完整源码,只用于说明为什么必须读写 .value。
1. 模板中的自动解包
在模板中,顶层 ref 通常会自动解包:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">
{{ count }}
</button>
</template>
模板中不需要写 count.value。
但在普通 JavaScript 或 TypeScript 代码中仍然需要:
count.value++
自动解包不是所有嵌套位置都成立。例如,ref 放入数组后通常仍需显式访问 .value:
const list = [ref(1)]
console.log(list[0].value)
2. ref 包裹对象
ref 不只适用于原始值:
const user = ref({
name: 'Ada',
age: 36
})
user.value.name = 'Grace'
对于对象值,普通 ref 会使其内部对象具备深层响应式能力。整体替换也会触发更新:
user.value = {
name: 'Marie',
age: 28
}
这使 ref 适合表示“可被整体替换的状态”。
3. shallowRef 与 triggerRef
当外部库对象很大、不可代理,或者只希望替换整体对象时,可以使用 shallowRef:
import { shallowRef } from 'vue'
const chart = shallowRef<unknown>(null)
chart.value = createChart()
shallowRef 只追踪 .value 的替换,不会自动把内部对象转为深层响应式代理:
chart.value = someChart
// 会触发依赖
// 如果直接修改 chart.value 内部属性,通常不会触发依赖
如果确实需要在原地修改后手动通知:
import { triggerRef } from 'vue'
chart.value.someOption = 'new'
triggerRef(chart)
这类 API 适合明确控制更新边界的场景,不应因为“少写一点响应式转换”而随意使用,否则容易出现界面不更新。
4. ref 与 reactive 的选择本质
可以用状态的替换方式理解二者:
const count = ref(0)
count.value = 10
这里响应式连接依赖的是 count.value。
const state = reactive({ count: 0 })
state.count = 10
这里响应式连接依赖的是代理对象的 count 属性。
经验上:
- 原始值使用
ref; - 需要整体替换的对象使用
ref; - 多个相关字段需要作为一个对象管理时可使用
reactive; - 从
reactive解构字段时,用toRefs或toRef保留响应式连接。
这不是硬性规范,而是由二者的依赖边界决定的。
五、computed:带缓存和失效机制的派生状态
computed 用于表示由其他状态计算得到的值:
import { computed, ref } from 'vue'
const price = ref(100)
const quantity = ref(2)
const total = computed(() => {
return price.value * quantity.value
})
读取:
console.log(total.value) // 200
修改依赖:
price.value = 120
下一次读取 total.value 时会得到 240。
1. computed 的核心过程
computed 内部可以看作一个特殊的 lazy effect:
首次读取 computed.value
↓
发现尚未计算
↓
执行 getter
↓
getter 读取 price.value、quantity.value
↓
收集 computed 对这些依赖的关系
↓
缓存结果 200
再次读取 computed.value
↓
依赖未变化
↓
直接返回缓存 200
price.value = 120
↓
触发 computed 内部 effect
↓
标记为 dirty
↓
下一次读取时重新计算
因此,computed 的重要特征是:
- 惰性:没有被读取时,getter 通常不会执行;
- 缓存:依赖不变时,多次读取返回缓存结果;
- 失效:依赖变化时,缓存标记为过期;
- 可继续被依赖:组件或另一个 effect 读取
computed.value时,会依赖这个 computed。
可以用公式表示:
其中:
- 是响应式源;
- 是 computed getter;
- 是派生值。
当且仅当某个 发生了会影响读取结果的响应式变化时,C 才需要重新计算。
2. 完整示例
<script setup lang="ts">
import { computed, ref } from 'vue'
const price = ref(10)
const quantity = ref(2)
const total = computed(() => {
console.log('计算 total')
return price.value * quantity.value
})
function increaseQuantity() {
quantity.value++
}
</script>
<template>
<p>单价:{{ price }}</p>
<p>数量:{{ quantity }}</p>
<p>总价:{{ total }}</p>
<button @click="increaseQuantity">增加数量</button>
</template>
初次渲染时输出:
计算 total
如果同一轮渲染中多次读取 total,getter 不会因每次读取而重复执行。点击按钮后,quantity 变化,total 被标记为失效;下一次模板更新读取它时重新计算。
3. computed getter 应保持派生性质
不推荐在 getter 中修改响应式状态:
const total = computed(() => {
count.value++ // 错误方向:读取派生值时修改源状态
return count.value
})
这会造成难以推导的依赖关系,甚至产生递归更新。getter 应尽量是从输入状态得到输出状态的纯计算。
如果需要响应式副作用,例如请求、日志、写入外部系统,应使用 watch 或 watchEffect。
4. 可写 computed
默认 computed 只有 getter:
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
写入:
fullName.value = 'Grace Hopper'
会触发运行时警告,因为没有 setter。
需要双向映射时,可以显式提供 get 和 set:
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(value: string) {
const [first, ...rest] = value.trim().split(/\s+/)
firstName.value = first ?? ''
lastName.value = rest.join(' ')
}
})
这里的“可写”不是让 computed 自己存储状态,而是把写入转发给源状态。
六、watch:显式监听源,并在变化后执行回调
watch 与 computed 的区别在于:
computed返回一个派生值;watch不负责返回派生值,而是执行副作用;computed主要关心“结果是什么”;watch主要关心“变化后做什么”。
1. 监听 ref
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log('count changed:', oldValue, '=>', newValue)
})
count.value = 1
在默认调度时,回调会被安排到 Vue 的更新调度流程中,而不是简单理解为赋值语句中的同步调用。
2. 监听 getter
const state = reactive({
count: 0,
enabled: true
})
watch(
() => state.count,
(newValue, oldValue) => {
console.log('count changed:', oldValue, '=>', newValue)
}
)
getter 的作用是明确指定监听源。只有 getter 执行过程中读取的响应式属性会成为依赖。
下面的监听只关心 state.count,不会因为 state.enabled 修改而触发。
3. 监听多个源
const firstName = ref('Ada')
const lastName = ref('Lovelace')
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log({
newName: `${newFirst} ${newLast}`,
oldName: `${oldFirst} ${oldLast}`
})
}
)
新旧值数组的顺序与监听源数组一致。
4. 直接监听 reactive 对象
const state = reactive({
user: {
name: 'Ada'
}
})
watch(state, (newState, oldState) => {
console.log('state changed')
})
state.user.name = 'Grace'
直接把 reactive 对象作为源时,Vue 会进行深层监听。需要注意:对于对象内部的原地修改,newState 和 oldState 可能是同一个代理对象,因为对象没有被整体替换:
console.log(newState === oldState) // 可能为 true
这并不表示没有发生变化,而是表示 Vue 没有为每次深层修改创建一个完整的旧对象快照。
如果使用 getter 返回对象:
watch(
() => state.user,
callback
)
默认情况下,只有 state.user 被替换时才会触发;如果要监听其内部变化,需要显式指定深度:
watch(
() => state.user,
callback,
{ deep: true }
)
深层监听需要遍历对象图,嵌套数据很大时可能产生明显成本。它解决的是“监听对象内部变化”,不是免费获得结构化旧值。
5. immediate
默认情况下,watch 只在源发生变化后调用回调:
watch(source, callback)
如果希望创建监听器时立即执行一次:
watch(
() => props.id,
async (id) => {
await loadDetail(id)
},
{ immediate: true }
)
这适合“初始加载一次,之后变化时继续加载”的场景。
6. watch 的时间选项
watch 的 flush 决定回调进入更新流程的时机:
watch(source, callback, { flush: 'post' })
常见值:
'pre':默认选项,回调通常在组件自身 DOM 更新前执行;'post':组件 DOM 更新后执行,适合读取更新后的 DOM;'sync':同步触发,不进入批处理队列。
例如需要读取组件更新后的元素高度:
import { nextTick, ref, watch } from 'vue'
const expanded = ref(false)
const panel = ref<HTMLElement | null>(null)
watch(
expanded,
() => {
const height = panel.value?.getBoundingClientRect().height
console.log('更新后的高度:', height)
},
{ flush: 'post' }
)
flush: 'sync' 会失去批处理优势。对数组或循环中的连续修改使用同步监听,可能导致回调执行次数过多,应明确确认其必要性。
七、watchEffect 与 watch 的依赖方式不同
watchEffect 会立即执行函数,并自动收集函数执行期间读取的响应式依赖:
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
console.log('当前 count:', count.value)
})
执行过程:
创建 watchEffect
↓
立即执行回调
↓
读取 count.value
↓
建立 count -> effect 的依赖
↓
count 改变后重新执行
相比之下,watch 显式指定源:
watch(count, (newValue, oldValue) => {
console.log(newValue, oldValue)
})
两者的主要区别:
| 特性 | watch |
watchEffect |
|---|---|---|
| 是否立即执行 | 默认否,可用 immediate |
是 |
| 监听源 | 显式指定 | 自动收集 |
| 是否提供新旧值 | 提供 | 不提供 |
| 适合场景 | 精确控制源、比较新旧值 | 副作用自然依赖多个状态 |
| 依赖范围 | 明确 | 由回调执行路径决定 |
watchEffect 的自动性也带来边界:如果回调中有条件分支,实际依赖会随每次执行而改变;如果异步函数在第一个 await 之后才读取响应式数据,该读取通常不会被当前同步依赖收集阶段追踪。
例如:
watchEffect(async () => {
console.log(userId.value) // 会在第一次同步执行阶段读取
await Promise.resolve()
console.log(theme.value) // 不应依赖异步边界后的自动追踪
})
对于异步逻辑,通常应在 await 前读取关键依赖,或者改用显式 watch。
八、异步 watch:处理竞态、取消和失效
异步监听最容易出现的问题不是“是否触发”,而是请求返回顺序不确定。
假设:
用户先选择 A,发起请求 A
用户随后选择 B,发起请求 B
请求 B 先返回
请求 A 后返回
如果不处理,旧请求 A 可能覆盖新请求 B 的结果。
1. 使用清理函数取消旧请求
<script setup lang="ts">
import { ref, watch } from 'vue'
interface User {
id: number
name: string
}
const userId = ref(1)
const user = ref<User | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
watch(
userId,
async (id, _oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
loading.value = true
error.value = null
try {
const response = await fetch(`/api/users/${id}`, {
signal: controller.signal
})
if (!response.ok) {
throw new Error(`请求失败:${response.status}`)
}
user.value = await response.json() as User
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return
}
error.value = err instanceof Error
? err
: new Error('未知错误')
} finally {
if (!controller.signal.aborted) {
loading.value = false
}
}
},
{ immediate: true }
)
</script>
每次监听源变化时,Vue 会先调用上一次回调注册的清理函数,再执行新的回调。于是:
id = 1
└── 请求 A
id = 2
├── 清理请求 A
└── 请求 B
onCleanup 是处理 watcher 失效工作的关键,例如:
- 取消
fetch; - 清除定时器;
- 移除事件监听;
- 断开 WebSocket;
- 取消第三方库订阅。
Vue 3.5 提供了 onWatcherCleanup 等相关能力,但它有版本和调用时机要求;使用第三个参数传入的 onCleanup 兼容性更广,适合一般代码。
2. 仅使用“请求序号”也可以避免覆盖
如果请求库不支持取消,可以增加序号:
let requestVersion = 0
watch(userId, async (id) => {
const currentVersion = ++requestVersion
const result = await loadUser(id)
if (currentVersion !== requestVersion) {
return
}
user.value = result
})
这里不是取消网络请求,而是丢弃过期结果。生产代码中可以同时使用取消和序号:取消减少资源浪费,序号保证即使取消失败也不会覆盖新数据。
九、组件生命周期与 effect 的作用域
在 setup 或 <script setup> 中创建的 computed、watch、watchEffect,会被绑定到当前组件的作用域。组件卸载时,相关 watcher 会自动停止。
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
console.log(count.value)
})
</script>
当组件卸载后,这个 effect 不应继续响应 count 的变化。
但如果在组件外部、异步回调中或自定义全局模块中创建 watcher,就不能假设它会自动随某个组件停止:
let stop: (() => void) | undefined
setTimeout(() => {
stop = watchEffect(() => {
// 这个 watcher 的创建时机已脱离普通 setup 执行过程
})
}, 0)
应保存停止函数,并在适当的生命周期或业务边界调用:
const stop = watchEffect(() => {
// ...
})
stop()
对于组合式函数,可以返回停止函数,或者使用组件作用域管理其内部资源。这样状态、监听器和副作用的生命周期才是一致的。
十、一个端到端示例:搜索、派生状态和异步监听
下面的组件同时展示:
ref保存输入和请求状态;computed计算是否可以提交;watch监听搜索关键词;- 清理旧请求;
- 模板中的 ref 自动解包;
- 错误和加载状态。
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
interface SearchResult {
id: number
title: string
}
const keyword = ref('')
const results = ref<SearchResult[]>([])
const loading = ref(false)
const errorMessage = ref('')
const canSearch = computed(() => {
return keyword.value.trim().length >= 2
})
watch(
keyword,
async (newKeyword, _oldKeyword, onCleanup) => {
const query = newKeyword.trim()
if (query.length < 2) {
results.value = []
errorMessage.value = ''
loading.value = false
return
}
const controller = new AbortController()
onCleanup(() => controller.abort())
loading.value = true
errorMessage.value = ''
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
results.value = await response.json() as SearchResult[]
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
errorMessage.value = error instanceof Error
? error.message
: '搜索失败'
} finally {
if (!controller.signal.aborted) {
loading.value = false
}
}
},
{
flush: 'pre'
}
)
</script>
<template>
<section>
<input
v-model="keyword"
placeholder="至少输入两个字符"
/>
<p v-if="!canSearch">请输入至少两个字符</p>
<p v-else-if="loading">加载中……</p>
<p v-else-if="errorMessage">错误:{{ errorMessage }}</p>
<ul v-else>
<li v-for="item in results" :key="item.id">
{{ item.title }}
</li>
</ul>
</section>
</template>
运行时的因果关系如下:
输入框修改 keyword
↓
keyword.value 触发
├── computed canSearch 失效
└── watch 回调进入调度流程
↓
取消旧请求
↓
发起新请求
↓
results.value 更新
↓
组件重新渲染
这里不应使用 computed 发起请求,因为请求是副作用,不是可缓存的纯派生值。canSearch 适合 computed,而网络请求适合 watch。
十一、常见错误及其诊断方式
1. 忘记 .value
错误:
const count = ref(0)
console.log(count) // 打印 Ref 对象
count++ // TypeScript 报错或逻辑错误
正确:
console.log(count.value)
count.value++
排查时先确认代码运行位置:模板顶层表达式可能自动解包,普通 TypeScript 代码不会。
2. 解构 reactive 后页面不更新
错误:
const state = reactive({ count: 0 })
const { count } = state
setTimeout(() => {
state.count++
}, 1000)
模板或 effect 使用的是普通变量 count,它不会随着 state.count 变化。
修复:
const { count } = toRefs(state)
或直接使用:
state.count
3. 把普通变量作为 watch 源
错误:
const state = reactive({ count: 0 })
watch(state.count, () => {
// 错误:传入的是当前数字,而不是响应式源
})
正确:
watch(
() => state.count,
() => {
// ...
}
)
watch 需要 ref、reactive 对象、getter,或这些源组成的数组;普通的当前值已经丢失了来源信息。
4. 依赖只在非响应式路径读取
错误:
const rawCount = state.count
watchEffect(() => {
console.log(rawCount)
})
effect 没有读取 state.count,因此不会建立依赖。
诊断方法是把读取放回响应式上下文:
watchEffect(() => {
console.log(state.count)
})
5. 深层对象触发次数过多
错误倾向:
watch(
() => hugeForm,
callback,
{ deep: true }
)
大型表单、编辑器文档或复杂树结构使用深层监听时,需要遍历大量嵌套数据,并且回调拿不到自动生成的深拷贝旧值。
更精确的做法是监听业务真正关心的字段:
watch(
() => [form.name, form.email, form.address.city],
callback
)
如果需要比较完整快照,应在业务层明确创建快照,并承担相应的复制成本,而不是误以为 watch 会免费保存历史对象。
6. 在 watch 中更新自身依赖造成循环
例如:
const keyword = ref('')
watch(keyword, (value) => {
keyword.value = value.trim()
})
当赋值结果与原值不同时,可能再次触发 watcher;复杂情况下会形成循环更新。
可以先判断:
watch(keyword, (value) => {
const normalized = value.trim()
if (normalized !== value) {
keyword.value = normalized
}
})
更清晰的方式通常是把规范化放在输入事件、计算属性 setter 或明确的数据转换边界中。
十二、依赖追踪与性能边界
响应式系统的成本主要来自三个阶段:
- 代理访问成本:读取和修改需要经过 Proxy 或 ref 访问器;
- 依赖收集成本:effect 执行时记录读取过的属性;
- 更新执行成本:状态变化后运行受影响的 effect,并可能触发组件更新。
这不意味着“响应式一定慢”,而是说明更新成本取决于依赖图的精细程度和副作用规模。
1. 让依赖更精确
下面的 effect 读取了整个对象中的多个字段:
watchEffect(() => {
sendAnalytics({
name: form.name,
email: form.email,
address: form.address
})
})
只要这些字段任何一个变化,effect 都会重新执行。如果分析上只关心 name,应显式缩小依赖:
watch(
() => form.name,
(name) => {
sendAnalytics({ name })
}
)
依赖越精确,更新边界越容易推导。
2. 对大对象使用 shallow API 需要配套更新策略
const data = shallowRef<LargeData | null>(null)
这可以避免深层代理,但内部修改不会自动触发更新。使用它意味着业务必须采用整体替换:
data.value = {
...data.value!,
items: newItems
}
或者在确认修改完成后调用 triggerRef。如果团队成员不知道这一约束,shallowRef 会表现为“偶尔不更新”。
3. 计算状态不要用 watch 复制
不推荐:
const total = ref(0)
watch(
[price, quantity],
() => {
total.value = price.value * quantity.value
}
)
这引入了一个额外可变状态,并且存在更新时序问题。派生值应优先写成:
const total = computed(() => price.value * quantity.value)
只有当计算过程包含副作用或需要异步过程时,才使用 watch。
十三、规范保证、实现细节与经验判断
需要区分三类结论。
Vue API 的规范行为
以下属于公开 API 语义:
ref通过.value暴露响应式值;reactive返回响应式代理;computed提供只读或带 setter 的派生 ref;watch监听指定源并执行回调;watchEffect自动收集同步执行阶段读取的依赖;- watcher 支持停止和清理回调;
flush、immediate、deep等选项改变监听行为。
常见实现机制
以下是理解原理的实现模型,不应视为稳定的内部源码接口:
- 使用
WeakMap -> Map -> Set保存依赖; - 使用 active effect 收集当前依赖;
- computed 使用 dirty 标记实现惰性缓存;
- Vue 使用 scheduler 合并更新;
- 依赖变化时清理旧依赖。
具体内部类名、字段名和优化策略可能随 Vue 版本变化。
工程经验
以下属于取舍而非 API 强制要求:
- 原始值和可整体替换的对象通常使用
ref; - 多字段状态可使用
reactive; - 派生状态优先使用
computed; - 网络请求、订阅和 DOM 操作使用
watch; - 深层监听大型对象前先确认真实依赖范围;
- 异步 watcher 必须考虑旧结果覆盖新结果;
- 组件外创建的 watcher 必须有明确的停止边界。
十四、用一组问题快速判断 API
可以按下面的因果关系选择:
问题一:这是源状态,还是派生状态?
const price = ref(10)
const quantity = ref(2)
这是源状态。
const total = computed(() => price.value * quantity.value)
这是派生状态。
问题二:需要得到一个值,还是执行一个动作?
需要得到值:
const fullName = computed(() => `${first.value} ${last.value}`)
需要执行动作:
watch(userId, id => loadUser(id))
问题三:监听源是否明确?
源明确:
watch(() => route.params.id, loadDetail)
副作用自然依赖多个同步读取:
watchEffect(() => {
document.title = `${user.value.name} - ${count.value}`
})
问题四:对象是局部修改,还是整体替换?
局部修改:
const form = reactive({ name: '', email: '' })
form.name = 'Ada'
整体替换:
const form = ref({ name: '', email: '' })
form.value = { name: 'Ada', email: 'ada@example.com' }
响应式 API 的差异最终都可以回到同一个问题:
依赖关系挂在什么位置,以及状态变化时哪个位置会被触发?
reactive 把依赖挂在代理对象的属性上,ref 把依赖挂在容器的 .value 上,computed 把多个源依赖封装成一个可缓存的派生节点,watch 则在依赖变化后连接到外部副作用。理解这条依赖链,就能解释大多数“为什么更新了”“为什么没更新”“为什么执行太多次”和“为什么旧请求覆盖新结果”的问题。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 模板与渲染:指令、表达式、列表、条件和 Virtual DOM
- 下一篇:Vue Composition API:setup、生命周期、作用域和逻辑组织
- 延伸:Vue 性能优化:更新边界、长列表、异步组件、Bundle 和指标
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论