vue 3.5.0版本重构了响应式,重构后内存占用减少56%,性能提升176%~244%,本文依据源码简单总结了其实现原理。
回顾
关于vue2/3的响应式对比,直接上 Deepseek 总结一下
Vue2 的响应式实现
原理: 基于 Object.defineProperty 对对象的属性进行劫持,结合发布-订阅模式实现。
特点:
- 属性劫持: 需要递归遍历对象的所有属性,为每个属性设置
getter和setter。 - 数组处理: 重写数组的
push、pop、splice等方法,手动触发更新。 - 新增/删除属性: 无法检测对象属性的新增或删除,需通过
Vue.set/Vue.delete手动触发响应式。
劣势:
- 初始化性能差: 递归遍历所有属性,对大型对象性能影响较大。
- 无法监听动态属性: 无法自动响应新增或删除的属性,需要手动处理。
- 数组局限性: 无法检测通过索引直接修改数组元素(如
arr[0] = value)或修改数组长度。 - 内存占用高: 每个属性都需要独立的
Dep实例管理依赖。
Vue3 的响应式实现
原理: 基于 Proxy 代理整个对象,结合 Reflect 操作对象属性。
特点:
- 代理对象:
Proxy直接代理整个对象,无需遍历属性。 - 动态监听: 自动检测属性的新增、删除和深层嵌套对象的修改。
- 惰性响应式: 只有在访问属性时才会递归处理嵌套对象,提升初始化性能。
- 数组处理: 直接监听数组索引变化和
length修改,无需重写方法。
优势:
-
性能优化:
- 初始化更快(惰性处理嵌套对象)。
- 大型对象或数组的响应式处理更高效。
-
动态响应: 支持属性的动态增删,无需手动触发。
-
内存占用低: 通过
Proxy统一管理依赖,无需为每个属性创建Dep。 -
更强大的监听能力: 支持
Map、Set、WeakMap等复杂数据结构。
劣势:
- 兼容性问题:
Proxy无法被 Polyfill,不支持 IE11 及更低版本。 - 调试复杂度:
Proxy的透明代理特性可能增加调试难度。
vue^3.5.0 基于双向链表和计数的响应式实现
先贴 Example 代码,看代码的时候可以思考以下几个问题:
- 响应式值(counter_1, counter_2)的初始化做了什么?
- 响应式值被订阅(watchEffect,渲染template)时发生了什么?
Vue 的 Reactive 是观察者模式,后文中,我们就把 ref 这样的被观察者叫做 Dep(Dependency),而 watchEffect 这样的观察者叫做 Sub(Subscriber)。
<script setup lang="ts">
import { ref, watchEffect, type Ref } from 'vue'
const counter_1 = ref(1)
const counter_2 = ref(2)
watchEffect(() => {
console.log('counter_1:', counter_1, 'counter_2', counter_2)
})
watchEffect(() => {
console.log('counter_2:', counter_2, 'counter_1', counter_1)
})
const updateCount = (val: Ref<number, number>) => { val.value++ };
</script>
<template>
<button @click="updateCount">updateCount</button>
<div>counter_1: {{ counter_1 }}</div>
<div>counter_2: {{ counter_2 }}</div>
</template>
上面的示例中,响应式分为 初始化 - 依赖收集 - 依赖触发 几个阶段,下面也用这个顺序去看;
响应式的初始化
简单总结一下,响应式的初始化就是:
-
传入原始值,创建类
RefImpl的实例,以代理原始值的get和set;- 基本数据类型 只需要包裹其原始值即可;
- 引用数据类型 则需要用
Proxy包装原始值,以劫持对象属性的访问(如果不是普通对象,还要根据不同类型为其设置不同的ProxyHandler,以劫持其内建方法,比如Set.has)
对象分成 普通对象&Array 和 COLLECTION ,COLLECTION 类型的对象需要考虑代理基于内部插槽的方法,为了保持简单,这一部分不在本文展开;
- 为
RefImpl的 value 属性设置 get/set 方法,并在 get 中收集依赖(使用 Dep 类),在 set 中通知 Sub(也就是 effect 副作用);
function ref(value) {
return (new RefImpl(value))
}
class RefImpl<T> {
_value: T
dep: Dep = new Dep()
constructor(value){
this.value = toReactive(value)
}
get value() {
this.dep.track()
return this._value
}
set value(newValue) {
this._value = toReactive(newValue)
this.dep.trigger()
}
}
// reactivity
function toReactive(value) {
return isObject(value)
? reactive(value)
: value
}
function reactive(target) {
// cache 机制
const existingProxy = proxyMap.get(target)
if (existingProxy) return existingProxy
const proxy = new Proxy(
target,
// COLLECTION 类型的对象需要额外代理基于内部插槽的方法;
getTargetType(target) === TargetType.COLLECTION ? collectionHandlers : baseHandlers
)
proxyMap.set(target, proxy)
return proxy
}
依赖收集
RefImpl.value 的 get 方法里收集依赖;
class RefImpl<T> {
...
dep: Dep = new Dep()
get value() {
this.dep.track()
return this._value
}
...
}
顺着线头看一下 Dep,这里的 subsHead & subs 就分别指向依赖当前 Dep 的双向链表的头和尾,这里是用 Link 这个指代“连接”的类表示的,从代码不难看出来,整个由 Sub Dep Link 组成的数据结构是方格网状结构,相比于以前的“多对多”关系,既可以维护 Sub 依赖的 Dep 顺序,又可以节省空间。
export class Link {
/**
* - Before each effect run, all previous dep links' version are reset to -1
* - During the run, a link's version is synced with the source dep on access
* - After the run, links with version -1 (that were never used) are cleaned
* up
*/
version: number
/**
* Pointers for doubly-linked lists
*/
nextDep?: Link
prevDep?: Link
nextSub?: Link
prevSub?: Link
prevActiveLink?: Link
constructor(
public sub: Subscriber,
public dep: Dep,
) {
this.version = dep.version
this.nextDep =
this.prevDep =
this.nextSub =
this.prevSub =
this.prevActiveLink =
undefined
}
}
class Dep {
// 当前 Dep 被访问的次数
version = 0
// 分别指向subs双向链表的头和尾
subsHead: Link
subs: Link
// 也可能依赖
map?: KeyToDepMap = undefined
key?: unknown = undefined
/**
* Subscriber counter
*/
sc: number = 0
track() {
/**
* Edgecase
* activeSub 是指当前活跃的 effect,是一个 ReactiveEffect;
* 回忆一下代码是如何运行到 Dep.track 的:当访问 RefImpl.value 的时候,比如
* <div>{{ refVal }}<div>
* 或者 watchEffect(() => { console.log('val', refVal.value) })
* 当 Sub 运行时,会把自己挂载到全局变量 activeSub,来指明当前活跃的 Sub 是哪个,便于依赖收集
*/
if (!activeSub) return;
let link = this.activeLink
// 当前 Dep 作为 Sub 的新依赖
if (link === undefined || link.sub !== activeSub) {
link = this.activeLink = new Link(activeSub, this)
// add the link to the activeEffect as a dep (as tail)
if (!activeSub.deps) {
activeSub.deps = activeSub.depsTail = link
} else {
link.prevDep = activeSub.depsTail
activeSub.depsTail!.nextDep = link
activeSub.depsTail = link
}
/**
* 1、更新dep的sc计数;
* 2、处理 computed 依赖收集,递归的将 computed 的 deps 也加到此 effect 的
*/
addSub(link)
// 当前 Dep 已经是 Sub 的依赖,且重复访问
} else if (link.version === -1) {
// reused from last run - already a sub, just sync version
// 设置 link.version,最后垃圾回收的时候,link.version 仍然为 -1 的依赖会被回收
link.version = this.version
// 重新排列 deps 的顺序,保证链表顺序与代码执行顺序一致
// If this dep has a next, it means it's not at the tail - move it to the
// tail. This ensures the effect's dep list is in the order they are
// accessed during evaluation.
if (link.nextDep) {
const next = link.nextDep
next.prevDep = link.prevDep
if (link.prevDep) {
link.prevDep.nextDep = next
}
link.prevDep = activeSub.depsTail
link.nextDep = undefined
activeSub.depsTail!.nextDep = link
activeSub.depsTail = link
// this was the head - point to the new head
if (activeSub.deps === link) {
activeSub.deps = next
}
}
}
return link
}
}
依赖改变,通知 Sub
调用 RefImpl.value 的 set 方法,触发 Dep.notify 方法,然后沿着链表调用每一个 Sub.notify 方法,这一步距离副作用的实际执行还有一个 Batch,也就是批处理,下面我们顺着线头接着捋,看 ReactiveEffect(Sub)。
class RefImpl<T> {
...
set value(newValue) {
this._value = toReactive(newValue)
this.dep.trigger()
}
...
}
class Dep {
...
trigger() {
this.version++
this.notify()
}
notify() {
// 递归调用
startBatch()
try {
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) {
// if notify() returns `true`, this is a computed. Also call notify
// on its dep - it's called here instead of inside computed's notify
// in order to reduce call stack depth.
// 遇到 sub 是 computed 时需要递归,因为 computed 可能既是 Sub 也是 Dep
;(link.sub as ComputedRefImpl).dep.notify()
}
}
} finally {
endBatch()
}
}
...
}
Sub 的代码里,也有 deps/depsTail 的指向 Dep 的双向链表头尾;
flags 是记录当前 Sub 的状态的标志位,这里巧妙的用了位运算来表示,性能更好;
constructor 里的 EffectScope 是用来收集管理 effect 的,在一些使用 reactivity 特性的 vue 生态库中比较常见。
说多了......我们直接看 ReactiveEffect.notify,直接调用了 batch 方法,把需要运行的副作用连成链表,最后在 Dep.notify 里 endBatch,完成实际的副作用执行。
let activeSub: Subscriber | undefined
// Sub
class ReactiveEffect<T = any> implements Subscriber {
// 双向链表的头和尾
deps: Link
depsTail: Link
// 当前的运行状态,二进制表示,位运算转换
flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING
// EffectScope 是用来收集管理 effect 的,在一些使用 reactivity 特性的 vue 生态库中比较常见;
constructor(public fn: () => T) {
if (activeEffectScope && activeEffectScope.active) {
activeEffectScope.effects.push(this)
}
}
// 实际副作用的执行
run(): T {
// 这个标志位记录当然 ReactiveEffect 的状态,用位运算实现,很巧妙,感兴趣的自己去看;
// this.flags |= EffectFlags.RUNNING
prepareDeps(this)
const prevEffect = activeSub
activeSub = this
try {
// this.fn 在 constructor 里用语法糖设置了
return this.fn()
} finally {
cleanupDeps(this)
activeSub = prevEffect
}
}
// 触发副作用执行
trigger(): void {
// 暂停逻辑
if (this.flags & EffectFlags.PAUSED) {
pausedQueueEffects.add(this)
} else if (this.scheduler) {
// 使用额外定义的调度器来执行副作用,委托模式,一般不走这个分支
this.scheduler()
} else {
// 检查是否有依赖变化,变化才执行
this.runIfDirty()
}
}
// 被 Dep 调用,通知 Sub
notify(): void {
// 禁止递归标志位
if (
this.flags & EffectFlags.RUNNING &&
!(this.flags & EffectFlags.ALLOW_RECURSE)
) {
return
}
//
if (!(this.flags & EffectFlags.NOTIFIED)) {
batch(this)
}
}
}
// 用链表收集本次的副作用
function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED
if (isComputed) {
sub.next = batchedComputed
batchedComputed = sub
return
}
sub.next = batchedSub
batchedSub = sub
}
/**
* Run batched effects when all batches have ended
* @internal
*/
// 触发依赖,调用每个 Sub 的 trigger 方法
export function endBatch(): void {
while (batchedSub) {
let e: Subscriber | undefined = batchedSub
batchedSub = undefined
while (e) {
const next: Subscriber | undefined = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
if (e.flags & EffectFlags.ACTIVE) {
try {
// ACTIVE flag is effect-only
;(e as ReactiveEffect).trigger()
} catch (err) {
if (!error) error = err
}
}
e = next
}
}
}
function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true
}
}
// @ts-expect-error only for backwards compatibility where libs manually set
// this flag - e.g. Pinia's testing module
if (sub._dirty) {
return true
}
return false
}
依赖回收
在 Commit 里面说 refactors the core reactivity system to use version counting and a doubly-linked list,双线链表就是刚才聊的那些,现在再看 counting。
就是每次执行副作用之前,prepareDeps 方法把所有 Link 的 version 设置为 -1;然后在副作用运行时,当访问到某条 Link 对应的 Dep 时,再把这个值设置为别的;等到 endBatch 的时候,仍为 -1 的就是不再被依赖的 Dep 了,就完成了依赖回收。
class ReactiveEffect<T = any> {
...
run(): T {
prepareDeps(this)
}
...
}
function prepareDeps(sub: Subscriber) {
// Prepare deps for tracking, starting from the head
for (let link = sub.deps; link; link = link.nextDep) {
// set all previous deps' (if any) version to -1 so that we can track
// which ones are unused after the run
link.version = -1
// store previous active sub if link was being used in another context
link.prevActiveLink = link.dep.activeLink
link.dep.activeLink = link
}
}
class Dep {
if (link.version === -1) {
// reused from last run - already a sub, just sync version
link.version = this.version
}
}