Skip to content
Vue.js 响应式原理与进阶 API
响应式系统的工作原理
Proxy 代理与反应式对象
Vue 3 的响应式系统构建在 JavaScript 的 Proxy 之上。reactive() 接收一个普通对象,返回该对象的 Proxy 代理。一个需要留意的细节是:原始对象和代理对象并非同一个引用 —— reactive(raw) !== raw 始终成立。
这意味着数据交给 reactive() 之后,应始终通过代理对象进行操作。对原始对象的修改不会被追踪,也不会触发视图更新。
javascript
const raw = { count: 0 }
const state = reactive(raw)
// 代理对象上的修改是响应式的
state.count = 1 // 触发更新
// 原始对象上的修改无效
raw.count = 2 // 不会触发任何更新同一个原始对象多次调用 reactive() 会返回同一个代理实例;如果传入的已经是代理对象,则直接返回自身。这套去重逻辑避免了同一数据源产生多个代理副本。
依赖收集:track 与副作用
Proxy 只负责拦截属性的读写,真正让“数据变化驱动视图更新”生效的是副作用(effect)系统。在 reactive() 返回的代理中,get 拦截器会调用 track(target, key),set 拦截器会调用 trigger(target, key)。
track() 的职责是:检查当前是否存在正在运行的副作用(通过全局变量 activeEffect 体现),若有,则将该副作用记入对应属性的订阅者集合。订阅关系存储在一个全局数据结构中:
WeakMap<target, Map<key, Set<effect>>>外层使用 WeakMap 的原因在于,当目标对象被垃圾回收时,与之关联的依赖关系可以自动释放。
副作用在 Vue 中有多种形态:组件的渲染函数本身就是一个副作用 —— 它读取响应式数据,产出虚拟 DOM,数据变化时重新执行;computed 内部的 getter 也是副作用;watchEffect 的回调同样是副作用。
副作用被包裹执行的典型流程为:将 activeEffect 设置为当前副作用,接着执行副作用函数,函数内部对响应式数据的每一次读取都会被 track() 拦截并完成依赖登记,最后再将 activeEffect 清空。
触发更新:trigger 与异步调度
trigger(target, key) 负责找出目标属性所有已订阅的副作用并执行它们。不过执行并非简单的同步调用。Vue 内部有一个调度器(scheduler),用于将同一轮“数据变更”中的多次 trigger() 合并,确保每个副作用在一个 tick 内最多执行一次。
因此连续修改同一个响应式数据的多个属性:
javascript
state.a = 1
state.b = 2
state.c = 3不会导致三次独立的渲染。组件只会重新渲染一次,且发生在下一个微任务阶段。
DOM 更新本身是异步的。如果在修改数据后立刻读取 DOM,得到的仍是旧值。nextTick() 提供了一个回调时机,可以保证 DOM 已经完成更新。
完整执行流程
把上面的链路串起来,一个最基本的响应式更新流程如下:
javascript
import { reactive, effect } from 'vue'
const state = reactive({ name: 'Vue', version: 3 })
effect(() => {
// 执行期间访问 state.name 和 state.version
// track 将当前 effect 登记为这两个属性的订阅者
console.log(`${state.name} ${state.version}`)
})
// 输出:Vue 3
state.version = 4
// set 拦截 → trigger('version') → 调度器执行 effect
// 输出:Vue 4effect 首次执行时进行依赖收集,之后任意被收集的依赖变化时,effect 被重新调度。这个模型是所有响应式 API 的底层基础。
与 Vue 2 的对比有助于理解这一变化。Vue 2 使用 Object.defineProperty 为每个属性设置 getter/setter,因此无法检测属性的添加和删除,也无法直接通过索引修改数组。Vue 3 的 Proxy 从根本上解决了这些问题 —— 整个对象的任何操作都在拦截范围内。
ref 与 reactive:两种响应式声明方式
reactive 的深度代理与类型限制
reactive() 的代理是深层的。传入嵌套对象时,所有层级的属性访问都会被拦截:
javascript
const state = reactive({
user: {
profile: { name: 'Alice' }
}
})
// 修改深层属性依然触发更新
state.user.profile.name = 'Bob' // 响应式reactive() 只能接受对象类型(Object、Array、Map、Set 等)。原始类型(如字符串或数字)无法被 Proxy 代理,在开发环境下传入 reactive(1) 会直接给出警告。这一限制直接引出了 ref 的必要性。
ref 的 RefImpl 结构与 .value
ref() 接收任意值,内部创建一个 RefImpl 实例。实例暴露一个 .value 属性,getter 负责 track(),setter 负责 trigger()。在 JavaScript 代码中,读取和修改都必须通过 .value:
javascript
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1模板中引用 ref 时,Vue 的模板编译器会自动插入 .value 解包逻辑,因此不需要手动编写。原始类型在 JavaScript 中按值传递,无法通过 Proxy 追踪。将原始值包装成带有 getter/setter 的对象,就能在读取和赋值时插入依赖追踪逻辑。.value 就是这个访问路径的入口。
如果传给 ref() 的是对象,内部会调用 reactive() 对该对象进行深层代理:
javascript
const state = ref({ count: 0 })
// state.value 是 reactive 代理
state.value.count = 1 // 响应式这意味着 ref 持有的对象值自动具有深层响应性。如果不需要深层代理(例如数据量很大或状态由外部库管理),应该使用 shallowRef。
ref 与 reactive 的选用
没有绝对的优劣,取决于数据形态和使用场景。
ref 适合:单个值、需要重新赋值的变量、从组合式函数(composables)中暴露出的响应式状态。由于 .value 的存在,整体重新赋值不会丢失响应式:
javascript
let name = ref('Alice')
name.value = 'Bob' // 仍然响应式reactive 适合:一组相关状态的集合(如表单数据、配置对象)、不需要整体替换引用的数据。如果尝试对 reactive 对象整体重新赋值,会丢失响应式 —— 相当于把变量指向了一个全新的普通对象。
reactive 还有一个限制:解构赋值会断开响应式链路。这一点的处理方式见后文 toRefs 部分。
computed:惰性求值与缓存
基本用法
computed() 接收一个 getter 函数,返回一个只读的 ref:
javascript
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)也可以传入 get/set 配置,创建可写的计算属性。
缓存机制
计算属性的核心行为是缓存。只有当被依赖的响应式数据发生变化时,getter 才会重新执行。多次读取同一个计算属性,getter 只会执行一次:
javascript
const counter = ref(0)
const doubled = computed(() => {
console.log('computing...')
return counter.value * 2
})
// 连续读取两次
doubled.value // computing... → 0
doubled.value // 没有第二次日志,直接返回缓存值 0counter.value 不变,doubled 就始终返回缓存值。缓存会在依赖变化后失效,下一次读取时 getter 重新求值并建立新的缓存。
与方法的区别
使用方法可以达到同样的计算效果,但没有缓存。方法每次调用都会执行,无论依赖是否变化。计算属性只在依赖变化时重新求值,在模板中多次引用或涉及重计算逻辑时,缓存可以避免不必要的重复计算。
javascript
// 方法版本:每次模板重渲染都执行
function getFullName() {
return firstName.value + ' ' + lastName.value
}
// computed 版本:只在 firstName 或 lastName 变化时执行
const fullName = computed(() => {
return firstName.value + ' ' + lastName.value
})在开发模式下,computed 的 onTrack 和 onTrigger 回调可以用来观察依赖收集和触发时机,对于排查“为什么这个计算属性没更新”或“为什么更新了这么多次”很有帮助。
watch 与 watchEffect:侦听器
watch 的基本用法
watch() 的第一个参数是侦听源,可以是 ref、reactive 对象、getter 函数或它们的数组:
javascript
// 侦听单个 ref
watch(count, (newVal, oldVal) => {
console.log(`count 从 ${oldVal} 变为 ${newVal}`)
})
// 侦听 getter
watch(
() => state.user.name,
(newName, oldName) => { /* ... */ }
)
// 同时侦听多个源
watch(
[count, () => state.user.name],
([newCount, newName], [oldCount, oldName]) => { /* ... */ }
)深层侦听与 immediate 选项
侦听 reactive 对象时,默认会进行深层侦听——对象内部任意深度的变化都会触发回调。这背后是 watch 内部递归遍历了对象的所有属性进行依赖收集。
如果只需要侦听第一层属性变化,可以传入 getter:
javascript
// 深层侦听(reactive 默认行为)
watch(state, callback)
// 只侦听第一层
watch(() => ({ ...state }), callback)immediate: true 选项让回调在侦听器创建时立刻执行一次(此时 oldVal 为 undefined),适合需要先根据当前值执行操作的场景。
清理副作用:onCleanup
当侦听的回调发起异步操作时,下一次回调触发前上一次的异步结果可能还未返回,这会导致过期的结果覆盖最新状态。watch 回调的第三个参数是一个 onCleanup 函数,用于注册清理逻辑:
javascript
watch(keyword, async (newVal, oldVal, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
const results = await fetchResults(newVal)
if (!cancelled) {
data.value = results
}
})每次回调再次执行前,Vue 会调用上一次注册的清理函数,从而将过期的请求标记为无效。
watchEffect 的自动追踪
watchEffect() 不需要显式指定侦听源。它接受一个回调,回调执行期间访问到的所有响应式数据都会被自动追踪:
javascript
const count = ref(0)
const doubled = ref(0)
watchEffect(() => {
// 自动追踪 count.value
doubled.value = count.value * 2
})watchEffect 在一开始就会立即执行一次回调,同时完成依赖收集。此后任意依赖变化,回调重新执行。
watch 与 watchEffect 的选择
watch 适用于需要知道“谁变了”和“新旧值是什么”的场景,比如根据路由参数拉取数据、对比变更做持久化。watchEffect 适用于“状态 A 变了就同步更新状态 B”的派生关系,不需要关心具体是哪个依赖触发的。
一个实际区别:watch 默认惰性执行(除非 immediate: true),watchEffect 总是立即执行。另外 watchEffect 无法获取旧值,因为它追踪的是一个“整体过程”,而不是特定源的变更前后状态。
provide / inject:跨层级数据传递
基本用法
组件树中从祖先向任意深度的子孙传递数据时,如果靠 props 逐层透传,中间组件会被迫声明一堆自己并不关心的 props。provide 和 inject 解决了这个问题:
javascript
// 祖先组件
import { provide } from 'vue'
provide('theme', 'dark')
// 任意后代组件
import { inject } from 'vue'
const theme = inject('theme') // 'dark'传递的 key 可以是字符串或 Symbol,使用 Symbol 可以避免命名冲突。
传递响应式数据
直接 provide 一个普通值,下游拿到的是静态快照。要维持响应式联动,需要传入响应式对象或 ref:
javascript
const user = reactive({ name: 'Alice', role: 'admin' })
provide('user', user)
// 下游
const user = inject('user')
// user 是响应式代理,修改会联动到所有注入方用 ref 也是同样的效果:
javascript
const count = ref(0)
provide('count', count)在 provide 数据的组件内部修改这些响应式值,所有 inject 的子孙组件都会收到更新。
默认值与可选注入
inject() 的第二个参数用于设置默认值,当上游祖先没有提供该 key 时使用:
javascript
const theme = inject('theme', 'light')如果默认值需要计算得出,可以传入工厂函数:
javascript
const config = inject('config', () => ({ timeout: 3000 }))如果某个 injection 是必需的但未被提供,Vue 在开发模式下会给出警告。
适用边界
provide/inject 不是状态管理库的替代品。数据流动是单向的,上游修改数据下游被动接收,下游无法通过 inject 反向通知上游(除非上游主动传递一个修改函数)。
大规模使用 provide/inject 会让数据流变得难以追踪。数据从哪里来、在哪里被修改、影响哪些下游组件 —— 这些问题没有集中管理的答案。对于跨组件共享状态,Pinia 这类状态管理方案提供了更明确的控制流和 devtools 支持。
常见的合适场景包括:主题、语言偏好、权限数据、组件库的内部控值(如表单组件的 disabled 状态)。这些都是天然需要在树中向下广播的“环境型”数据。
生命周期钩子
选项式与组合式 API 的映射
组合式 API 中的生命周期钩子命名遵循 on 前缀规则,同阶段的钩子在 setup 中可被多次注册:
| 选项式 API | 组合式 API |
|---|---|
| beforeCreate | setup() 本身 |
| created | setup() 本身 |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeUnmount | onBeforeUnmount |
| unmounted | onUnmounted |
beforeCreate 和 created 没有对应的组合式钩子 —— setup() 本身就是在这一阶段执行。
注册规则
钩子只能在 setup 同步代码中注册。生命周期钩子的注册操作需要在组件初始化阶段同步完成,才能确保实例创建过程中各个阶段的回调被正确触发。在异步回调中延迟注册是无效的:
javascript
// 有效
setup() {
onMounted(() => { console.log('mounted') })
}
// 无效——钩子不会被注册
setup() {
setTimeout(() => {
onMounted(() => { /* 不会执行 */ })
}, 0)
}执行顺序
父子组件的生命周期钩子执行顺序遵循“创建由上到下,挂载由下到上”的规则:
javascript
// 父组件
setup() {
onBeforeMount(() => console.log('Parent beforeMount'))
onMounted(() => console.log('Parent mounted'))
}
// 子组件
setup() {
onBeforeMount(() => console.log('Child beforeMount'))
onMounted(() => console.log('Child mounted'))
}
// 实际输出顺序:
// Parent beforeMount
// Child beforeMount
// Child mounted
// Parent mounted更新阶段类似:父 beforeUpdate 先执行,子 beforeUpdate 接着执行,子 updated 先完成,父 updated 最后。
响应式丢失与修复:toRefs / toRef
解构 reactive 导致的响应式断开
reactive 返回的代理对象在解构后会丢失响应式链路:
javascript
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state
count = 1 // 无效——count 只是一个普通数字
state.count = 1 // 响应式正常原因是解构操作读取的是属性值,原始类型按值传递,count 拿到的是数字 0 本身,与 state 不再有任何关系。如果解构出来的值是对象,对该对象的修改仍会触发 state 的更新(对象按引用传递),但变量绑定本身已经断开。
toRefs:批量转换
toRefs() 将 reactive 对象的每个属性转换为独立的 ref,保持响应式连接:
javascript
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state)
// count 和 name 都是 ref,修改 .value 会同步回原对象
count.value++ // state.count 也变成 1这在需要从组合式函数中返回响应式对象时很有用。如果不做转换直接解构返回,调用方拿到的全是非响应式值。
toRef:单个属性引用
toRef() 用于从 reactive 对象中取出单个属性并建立响应式引用:
javascript
const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')
countRef.value++ // state.count 同步修改与 toRefs 的区别在于它只处理一个属性,且如果源属性不存在,会创建一个对应名称的 ref 并连接回原对象,后续源对象添加该属性时 ref 会同步。
toRefs 转换后的 ref 在模板中使用时会自动解包,无需 .value,这使得组合式函数返回值经 toRefs 展开后,模板中可以直接通过属性名访问。
shallowRef 与 shallowReactive:浅层响应式
shallowRef
shallowRef() 只追踪 .value 本身的替换操作,不对内部值做深层代理:
javascript
const state = shallowRef({ count: 0 })
// 替换整个 .value 触发更新
state.value = { count: 1 } // 更新
// 修改内部属性不触发
state.value.count = 2 // 无反应shallowRef 在此行为上与信号(signal)概念一致:它是一个值容器,只有在直接替换 .value 时才触发副作用。
shallowReactive
shallowReactive 只对对象的第一层属性做响应式处理:
javascript
const state = shallowReactive({
user: { name: 'Alice' },
count: 0
})
// 第一层属性修改触发更新
state.count = 1 // 更新
state.user = { name: 'Bob' } // 更新
// 深层属性修改不触发
state.user.name = 'Charlie' // 无反应适用场景
浅层 API 的适用面较窄。一种是处理大型数据集或包含大量嵌套字段的对象,深层代理的递归转换会造成明显的初始化开销和内存占用,而实际业务往往只关心最外层替换。另一种是与不可变数据模式或外部状态管理库集成,数据由外部库维护内部可变性,Vue 只需追踪顶层引用是否被替换。
需要注意:如果 shallowRef 的内部值由某个深层响应式对象担任,触发了该对象的内部变化,Vue 的调度队列并不会自动感知 —— 只有 .value 被整体替换时才会排入更新周期。混合使用浅层和深层响应式时,更新触发的边界需要明确控制。
注意点
组合式 API 的调用位置限制
setup() 及其相关 API(ref、reactive、computed、watch、生命周期钩子)必须在组件 setup 函数或 <script setup> 的同步执行阶段调用。Vue 内部使用全局变量来追踪“当前正在初始化的组件实例”,一旦离开这个同步上下文,组件实例上下文丢失,API 调用要么报错,要么静默失效。
javascript
// 正确:在 setup 同步块中
setup() {
const count = ref(0)
onMounted(() => { /* ... */ })
}
// 错误:在异步回调中
setup() {
fetchData().then(() => {
const count = ref(0) // 可以运行,但没有绑定到当前组件
})
}watchEffect 的依赖收集约束
watchEffect 的依赖收集发生在回调首次同步执行期间。如果回调内部包含异步操作,异步代码中访问的响应式数据不会被追踪:
javascript
watchEffect(() => {
// 同步部分:count 被追踪
const current = count.value
setTimeout(() => {
// 异步部分:name 不会被追踪
console.log(name.value)
}, 0)
})只有同步执行路径上的响应式读取才会被 track() 拦截。如果确实需要在异步操作后依赖某些响应式数据,可以将其移到同步位置提前读取,或者改用 watch 并在回调中明确指定侦听源。
异步操作与清理
setup 内定义的响应式变量在组件存活期间一直有效,即使它们被 setup 中发起的异步回调引用也没问题。真正容易出问题的是将响应式数据传递到组件外部 —— 例如挂载到全局事件监听器或定时器中忘记清理,导致组件卸载后引用仍然存在,阻止垃圾回收。
javascript
setup() {
const count = ref(0)
const timer = setInterval(() => {
count.value++
}, 1000)
// 忘记清理会导致组件卸载后 timer 仍在运行
onBeforeUnmount(() => {
clearInterval(timer) // 必须
})
}这类问题属于典型的副作用清理遗漏,生命周期钩子中的 onBeforeUnmount 是处理这类清理的对应入口。
