响应式 —— Reactivity
Vue 的响应式系统核心在于响应式对象的属性与 effect 副作用函数之间建立的依赖关系。让我们通过具体示例来理解这个概念:
- 普通函数访问响应式数据
import { ref } from 'vue'
const count = ref(0)
// 普通函数
function fn() {
console.log(count.value)
}
fn() // 打印 0
setTimeout(() => {
count.value = 1 // 修改值不会触发 fn 重新执行
}, 1000)
在这个例子中,虽然 fn 读取了响应式数据 count.value,但由于它不是在 effect 中执行的,因此当 count.value 发生变化时,该函数不会重新执行。
- effect 中访问响应式数据
import { ref, effect } from 'vue'
const count = ref(0)
effect(() => {
console.log(count.value) // 首次执行打印 0
})
setTimeout(() => {
count.value = 1 // 触发 effect 重新执行,打印 1
}, 1000)
我们平时使用的 computed、watch、watchEffect 包括组件的 render 都是依赖于 effect 函数来收集依赖的 当在 effect 中访问响应式数据时,会发生以下过程:
- 依赖收集:当 effect 中的函数首次执行时,访问 count.value 会触发 ref 的 get,此时系统会自动收集当前 effect 作为依赖。
- 触发更新:当 count.value 被修改时,会触发 ref 的 set,系统会通知之前收集的所有依赖(effect)重新执行。 这就是为什么在第二个例子中,修改 count.value 会导致 effect 重新执行并打印新值。这种自动追踪依赖和触发更新的机制,正是 Vue 响应式系统的核心特征。
响应式最基础的实现 - ref
那此时我们可以知道,当我们访问某个值的时候,就是我们要知道,谁使用了这个值(也就是某个函数访问了这个值),当我们更新这个数据的时候, 我们需要重新去执行这个函数,也就是之前使用了这个值的函数,我需要让它重新执行,此时这个函数重新执行,就可以获取到最新的值
响应式数据 (Ref)
响应式 Ref
是一个包装器对象,它可以让我们追踪简单值的变化。
- get:当我们读取 .value 的时候,触发 get 此时在 get 中会收集依赖,也就是建立响应式数据和 effect 之间的关联关系
- set:当我们重新给 .value 赋值的时候,触发 set,此时在 set 中会找到之前 get 的时候收集的依赖,触发更新
- ref.ts
import { activeSub } from './effect'
enum ReactiveFlags {
// 属性标记,用于表示对象是不是一个ref
IS_REF = '__v_isRef'
}
/**
* Ref 的类
*/
class RefImpl {
// 保存实际的值
_value;
// ref 标记,证明是一个 ref
[ReactiveFlags.IS_REF] = true
// 保存和 effect 之间的关联关系
subs
constructor(value) {
this._value = value
}
get value() {
// 收集依赖
if (activeSub) {
// 如果 activeSub 有,那就保存起来,等我更新的时候,触发
this.subs = activeSub
}
return this._value
}
set value(newValue) {
// 触发更新
this._value = newValue
// 通知 effect 重新执行,获取到最新的值
this.subs?.()
}
}
export function ref(value) {
return new RefImpl(value)
}
/**
* 判断是不是一个 ref
* @param value
* @returns {boolean}
*/
export function isRef(value) {
return !!(value && value[ReactiveFlags.IS_REF])
}
副作用函数 (Effect)
副作用是指那些依赖响应式数据的函数,当数据发生变化时,这些函数会自动重新执行。
- effect.ts
// 当前正在收集的副作用函数,在模块中导出变量,这个时候当我执行 effect 的时候,我就把当前正在执行的函数,放到 activeSub 中,
// 当然这么做只是为了我们在收集依赖的时候能找到它,如果你还是不理解,那你就把他想象成一个全局变量,
// 这个时候如果执行 effect 那全局变量上就有一个正在执行的函数,就是 activeSub
export let activeSub
// effect 函数用于注册副作用函数
// 执行传入的函数,并在执行期间自动收集依赖
export function effect(fn) {
// 设置当前活跃的副作用函数,方便在 get 中收集依赖
activeSub = fn
// 执行副作用函数,此时会触发依赖收集
fn()
// 清空当前活跃的副作用函数
activeSub = undefined
}
这段代码实现了一个简单的响应式系统,它能够让我们追踪数据的变化并自动执行相关的更新操作。
const count = ref(0)
effect(() => {
console.log(count.value) // 这个函数会在 count.value 变化时自动重新执行
})
setTimeout(() => {
count.value = 1
}, 1000)
当然,这一节我们只是实现了一个最基础的响应式,vue 中的响应式实现远比这个要复杂的多,不要担心,后面我们会慢慢完善它
链表
链表(Linked List)是一种数据结构,它由一系列节点(Node)组成,每个节点存储一个数据值,并且指向下一个节点,它和数组一样,都是线性数据结构。 链表与数组的区别:
- 数组: 查找元素速度快(O(1)),这里指的是通过索引查找,但增删元素需要移动其他元素,效率较低(O(n))。
- 链表: 增删元素更快(O(1)),但查找元素需要遍历整个链表(O(n))。
单向链表
在单向链表中,通常会有一个头节点(必须)和尾节点(非必须) head: 头节点,表示链表的第一个节点,如果我们需要遍历这个链表,需要从它开始 tail: 尾节点,表示链表的最后一个节点 如果链表的 头节点(head) 是 node1,通过 node1 的 next 属性可以访问到 node2, 每个节点都有一个 next 属性,指向下一个节点,直到最后一个节点 node4,如果 node4 是最后一个节点,我们通常称它为尾结点(tail) 链表和数组都属于线性数据结构,它对比数组有什么优势呢? 以下是链表和数组在新增和删除操作时的对比案例:
- 数组
const arr = ['a', 'b', 'c', 'd'] // a=>0 b=>1 c=>2 d=>3
// 删除数组的第一项
arr.shift()
console.log(arr) // ['b', 'c', 'd'] b=>0 c=>1 d=>2
我们初始化的时候声明了一个数组 arr,然后调用 arr.shift() 删除数组的第一个元素, 此时移除第一个元素后,数组的所有元素的索引都会变动,都需要往前移动一位,这样是比较耗费性能的
- 链表
// 头节点是 head
let head = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: {
value: 4,
next: null
}
}
}
}
// 删除链表的第一项
head = head.next // 将头节点指向下一个节点 node2
console.log(head) // 输出新的头节点 [2, 3, 4]
node1,只需让 head 指向 head.next,这样 node2 就成为新的头节点,而 node1 会被垃圾回收自动释放。
对比总结 只针对头节点操作,不考虑移除中间节点
- 数组:
- 新增操作需要移动后续元素,可能导致性能下降(O(n))。
- 删除操作同样需要移动后续元素,性能也为O(n)。
- 链表:
- 新增操作只需修改指针,性能为O(1)。
- 删除操作也只需修改指针,性能为O(1)。 通过上面的案例我们了解到,链表的节点新增和删除动作,是会比数组要快的,但是我们目前只是删除了头节点, 那如果我要删除某一个中间节点呢?这个时候仅靠单向链表也能做到:
// 头节点是 head
let head = { value: 1, next: undefined }
const node2 = { value: 2, next: undefined }
const node3 = { value: 3, next: undefined }
const node4 = { value: 4, next: undefined }
// 建立链表之间的关系
head.next = node2
node2.next = node3
node3.next = node4
// 好,我们忘掉前面的动作,假设现在手里只有 node3
// 现在我们要把 node3 删掉
let current = head
while (current) {
// 找到 node3 的上一个节点
if (current.next === node3) {
// 把 node3 的上一个指向 node3 的下一个
current.next = node3.next
break
}
current = current.next
}
console.log(head) // 输出新的链表 [1, 2,4]
双向链表
上一章我们讲了单向链表,它存在一个问题,就是它不能快速的往前面添加节点,比如在下面这个案例中, 我们在 node3 前面添加一个节点,这个时候我们很难直接通过 node3 把新节点和 node2 关联起来, 当然你可以通过遍历的方式拿到 node2, 但是这样时间复杂度就比较高了,有没有什么快速删除的方式呢?有的 那么我们再来思考一下,我们现在想往 node3 前面添加一个,但是我们必须把这个新节点和 node2 建立关联关系, 如果我们能拿到 node2 就好办了
双向链表的每个节点都有两个指针,一个指向下一个节点(next),一个指向上一个节点(prev)。
此时每个节点都有一个属性 prev 指向它的上一个节点,如果我需要在 node3 前面添加 node5 , 那我们是不是可以通过 node3 的 prev 拿到 node2,这个时候我们手里就有三个节点 node2 、node3、node5, 我们只需要把 node2 的 next 指向 node5,node5 的 next 指向 node3,就ok了
// 假设链表的头节点是 head
let head = { value: 1, next: undefined, prev: undefined }
const node2 = { value: 2, next: undefined, prev: undefined }
const node3 = { value: 3, next: undefined, prev: undefined }
const node4 = { value: 4, next: undefined, prev: undefined }
// 建立链表之间的关系
head.next = node2
// node2 的上一个节点指向 head
node2.prev = head
// node2 的下一个指向 node3
node2.next = node3
// node3 的上一个节点指向 node2
node3.prev = node2
// node3 的下一个指向 node4
node3.next = node4
// node4 的上一个指向 node3
node4.prev = node3
// 好,我们忘掉前面的动作,假设现在手里只有 node3
// 现在我们要把 node3 删掉
// 如果 node3 有上一个,那就把上一个节点的下一个指向 node3 的下一个
if (node3.prev) {
node3.prev.next = node3.next
} else {
head = node3.next
}
if (node3.next) {
node3.next.prev = node3.prev
}
console.log(head) // 输出新的链表 [1, 2, 4]
到此我们讲完了链表相关的知识,接下来我们会在 vue 源码中使用它,再来看一下它到底有多强大
链表应用
把链表应用到我们的响应式系统中来 首先我们创建一个接口 Link
- system.ts
interface Link {
// 保存当前要关联的 effect
sub: Function
// 链表的下一个节点
nextSub: Link
// 链表的上一个节点
prevSub: Link
}
然后把 RefImpl 中的 subs 结构改成 Link
- ref.ts
/**
* Ref 的类
*/
class RefImpl {
// 保存实际的值
_value
// ref 标记,证明是一个 ref [ReactiveFlags.IS_REF] = true
/**
* 订阅者链表的头节点,理解为我们讲的 head
*/
subs: Link
/**
* 订阅者链表的尾节点,理解为我们讲的 tail
*/
subsTail: Link
constructor(value) {
this._value = value
}
get value() {
// 收集依赖
if (activeSub) {
trackRef(this)
}
return this._value
}
set value(newValue) {
// 触发更新
this._value = newValue
triggerRef(this)
}
}
这里我们添加了一个 subsTail 属性,用来保存尾节点 我们会调用 trackRef 函数收集依赖,调用 triggerRef 函数触发更新
- ref.ts
/**
* 收集依赖,建立 ref 和 effect 之间的链表关系
* @param dep
*/
export function trackRef(dep) {
if (activeSub) {
link(dep, activeSub)
}
}
/**
* 触发 ref 关联的 effect 重新执行
* @param dep
*/
export function triggerRef(dep) {
if (dep.subs) {
propagate(dep.subs)
}
}
- system.ts
/**
* 链接链表关系
* @param dep
* @param sub
*/
export function link(dep, sub) {
// 如果 activeSub 有,那就保存起来,等我更新的时候,触发
const newLink = {
sub,
nextSub: undefined,
prevSub: undefined
}
/**
* 关联链表关系,分两种情况
* 1. 尾节点有,那就往尾节点后面加
* 2. 如果尾节点没有,则表示第一次关联,那就往头节点加,头尾相同
*/
if (dep.subsTail) {
dep.subsTail.nextSub = newLink
newLink.prevSub = dep.subsTail
dep.subsTail = newLink
} else {
dep.subs = newLink
dep.subsTail = newLink
}
}
/**
* 传播更新的函数
* @param subs
*/
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
queuedEffect.push(link.sub)
link = link.nextSub
}
queuedEffect.forEach((effect) => effect())
}
在这里,我们将关联关系的数据结构,做成双向链表,这样修改后,收集依赖就变成了下面的样子
const count = ref(0)
const effect1 = effect(() => {
count.value
})
const effect2 = effect(() => {
count.value
})
setTimeout(() => {
count.value = 1
}, 1000)
count 作为一个响应式数据源,如何与两个副作用 effect1 和 effect2 进行关联。 在这张图中,count 表示响应式数据 ref,effect1 和 effect2 表示副作用函数
count 通过 subs 指向了一个 link1 节点(头节点),这个头节点的 sub 指向了 effect1,这个 link 节点有一个 nextSub 属性,指向链表的下一个节点,link2,link2 的 sub 指向 effect2,当一秒钟后执行 count.value = 1 的时候,会触发 ref 的 set,在 set 中我们会通过 subs 遍历整个链表,找到 effect1 和 effect2 通知它们重新执行,它们在重新执行的过程中,会获取到最新的数据。
响应式数据的伴侣 - ReactiveEffect
构造函数
我们之前的 effect 函数是这样的
- effect
export let activeSub = null
// effect 函数用于注册副作用函数
// 执行传入的函数,并在执行期间自动收集依赖
export function effect(fn) {
// 设置当前活跃的副作用函数,方便在 get 中收集依赖
activeSub = fn
// 执行副作用函数,此时会触发依赖收集
fn()
// 清空当前活跃的副作用函数
activeSub = null
}
但是实际上,vue 中需要考虑的问题比较多,所以 effect 函数中创建了一个类的实例,这个类就是 ReactiveEffect
- effect
class ReactiveEffect {
// 表示当前是否被激活,如果为 false 则不收集依赖
active = true
constructor(public fn) {}
run() {
// 如果当前的 effect 未激活,那就不收集依赖,直接返回 fn 执行结果
if (!this.active) {
return this.fn()
}
// 将当前的 effect 保存到全局,以便于收集依赖
activeSub = this
try {
return this.fn()
} finally {
// fn 执行完毕后将 activeSub 回收
activeSub = undefined
}
}
}
export function effect(fn) {
// 创建一个 ReactiveEffect 实例
const e = new ReactiveEffect(fn)
e.run() // 执行 fn
}
那么此时对应的 propagate 函数中的依赖触发也要修改,因为此时 activeSub 已经变成了一个对象
- system.ts
/*
* 传播更新的函数
* @param subs
*/
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
queuedEffect.push(link.sub)
link = link.nextSub
}
queuedEffect.forEach((effect) => effect.run())
}
嵌套effect
接着我们再来看一个案例:
const count = ref(0)
// effect1
effect(() => {
// 🚨 effect2 在 effect1 中执行
effect(() => {
console.log('effect2', count.value)
})
console.log('effect1', count.value)
})
setTimeout(() => {
count.value = 1
}, 1000)
老规矩,猜一下一秒钟后的打印结果。 答案是:输出 'effect2' 1 这不对啊,我们的预期应该是同时输出:输出 'effect2' 1 和 输出 'effect1' 1 我们来看一下原因
class ReactiveEffect {
// 表示当前是否被激活,如果为 false 则不收集依赖
active = true
constructor(public fn) {}
run() {
// 如果当前的 effect 未激活,那就不收集依赖,直接返回 fn 执行结果
if (!this.active) {
return this.fn()
}
// 将当前的 effect 保存到全局,以便于收集依赖
activeSub = this
try {
return this.fn()
} finally {
// fn 执行完毕后将 activeSub 回收
activeSub = undefined // 🚨 fn 执行完毕后,被置空了
}
}
}
activeSub = undefined 看一下这行代码,乍一看,似乎没有什么问题,但是,我们回到我们的案例中看一下,当 effect1 执行的时候,activeSub = effect1,然后在 effect1 中又创建了一个 effect2,此时执行 effect2 的 run 方法,然后马上 activeSub 又变成了 effect2,等 effect2 执行完毕后,将 activeSub 设置为 undefined,但是此时我们的 effect1 还没执行完毕对吧?那我后面访问到的 ref 就不会被收集到了,那么我们思考一下这个问题,当一个 effect 执行完毕后,我们是否需要把它设置为 undefined ?在我们这个案例中,肯定是不能的,那我们需要怎么做呢?我们可以考虑这样,我们在 activeSub = this 之前,也就是在 effect2 执行之前,activeSub 是有值的,它在 effect2 执行之前的值是 effect1,我们是不是可以把它保存起来,这样等 activeSub 执行完毕后,我们再把之前保存的值重新赋值给它,于是代码就变成了这样:
class ReactiveEffect {
// 表示当前是否被激活,如果为 false 则不收集依赖
active = true
constructor(public fn) {}
run() {
// 💡 保存之前的 activeSub
const prevSub = activeSub
// 将当前的 effect 保存到全局,以便于收集依赖
activeSub = this
try {
return this.fn()
} finally {
// 💡 fn 执行完毕后将 activeSub 恢复为 prevSub
activeSub = prevSub
}
}
}
这样我们在执行 activeSub = this 之前,先将它保存起来,等 fn 执行完毕后,再将它重新恢复,这样我们嵌套的问题就解决了,有的兄弟可能会说,那如果没有嵌套怎么办,如果没有嵌套,那第一次执行的时候,prevSub 就是 undefined,并不会影响我们的逻辑
const count = ref(0)
// effect1
effect(() => {
// 🚨 effect2 在 effect1 中执行
effect(() => {
console.log('effect2', count.value)
})
console.log('effect1', count.value)
})
setTimeout(() => {
count.value = 1
}, 1000)
此时这段代码在定时器修改完后会正常打印出: 'effect2' 1 'effect1' 1
调度器(scheduler)
调度器是响应式系统重一个重要的概念,我们默认使用 effect 访问响应式属性的时候,会收集依赖,当然我们修改响应式属性后,这个 effect 的 fn 会重新执行,而 scheduler 的作用是,当响应式数据发生变化的时候,执行 scheduler,而不是重新执行 fn,当然我们在创建 effect 的时候,还是会执行 fn,因为要靠它收集依赖,我们来看一下:
const count = ref(0)
effect(
() => {
console.log('在 fn 中收集了依赖', count.value)
},
{
scheduler() {
console.log('scheduler', count.value)
}
}
)
setTimeout(() => {
// ⭐️ 由于传递了 scheduler ,所以我们更新响应式属性的时候,会触发 scheduler
count.value++ // scheduler
}, 1000)
那么我们应该如何实现这个功能呢?其实说困难也困难,说简单也简单,我们来看下区别:
- 默认:effect 在创建时会执行一次 fn,当 fn 中访问的响应式数据发生变化时,它会重新执行,此时无论是初始化,还是数据发生变化,都会重新执行 fn
- 调度器:当我们传递了 scheduler,首次创建 effect 的时候,依然会执行 fn,但是当我们数据发生变化的时候,就会执行 scheduler,也就是说响应式数据触发更新的时候,要换台了,不能执行 fn 了,当然这一切都是建立在我们传递了 scheduler,或者说我们也可以这样,ReactiveEffect 本身就存在 scheduler,这个方法默认会帮我们调用 run 方法,但是如果我们传递了 scheduler,对象本身的 scheduler,这样就完成我们的功能了 那此时我们就假设 ReactiveEffect 本身的 scheduler 是直接调用 run 方法,
class ReactiveEffect {
constructor(public fn) {}
run() {
// 先将当前的 effect 保存起来,用来处理嵌套的逻辑
const prevSub = activeSub
// 每次执行 fn 之前,把 this 放到 activeSub 上面
activeSub = this
try {
return this.fn()
} finally {
// 执行完成后,恢复之前的 effect
activeSub = prevSub
}
}
/**
* 默认调用 run,如果用户传了,那以用户的为主,实例属性的优先级,由于原型属性
*/
scheduler() {
this.run()
}
}
export function effect(fn, options) {
const e = new ReactiveEffect(fn)
// 将传递的属性合并到 ReactiveEffect 的实例中
Object.assign(e, options)
// 执行 run 方法
e.run()
}
当然此时我们的 propagate 中的执行的方法也需要修改一下,因为我们之前执行的是 run 方法
/**
* 传播更新的函数
* @param subs
*/
export function propagate(subs) {
// 省略部分代码...
// 这里执行的是 run 方法
queuedEffect.forEach((effect) => effect.run())
}
这里我们现在需要触发 scheduler 了,这里我们修改了几次了,我们索性这样,搞一个 notify 方法,我只管调用你的 notify 方法,至于你最终执行那个方法,你自己决定:
/**
* 传播更新的函数
* @param subs
*/
export function propagate(subs) {
// 省略部分代码...
// 这里执行 notify 方法
queuedEffect.forEach((effect) => effect.notify())
}
然后我们在 ReactiveEffect 中再添加一个 notify 方法
class ReactiveEffect {
// 省略部分代码...
/**
* 通知更新的方法,如果依赖的数据发生了变化,会调用这个函数
*/
notify() {
this.scheduler()
}
// 省略部分代码...
}
dep 和 sub 之间的双向链接
我们来看一个案例
const flag = ref(false)
effect(() => {
console.count(flag.value)
})
btn.onclick = () => {
flag.value = !flag.value
}
当我们点击按钮,此时会触发 effect 重新执行,这次执行,必然会触发 flag 的 get,从而再次收集依赖, 此时 flag 的订阅者链表中已经有两个节点,并且它们同时指向同一个 effect,这必然会导致一次更新触发多次 effect, 那么我们能不能想办法优化一下它呢?那肯定是可以的。 我们先来设计一个结构,既然 dep 可以通过链表的节点找到 sub,那么我们能不能也让 sub 通过链表的节点找到 dep, 这样我们每次重新执行的时候,都看一下,sub 和 dep 之间,有没有关联关系,如果有,那我们就不重新创建了,于是我们给链表设计成了这种结构:
/**
* 依赖项
*/
interface Dep {
// 订阅者链表的头节点
subs: Link | undefined
// 订阅者链表的尾节点
subsTail: Link | undefined
}
/**
* 订阅者
*/
interface Sub {
// 依赖项链表的头节点
deps: Link | undefined
// 依赖项链表的尾节点
depsTail: Link | undefined
}
/**
* 链表节点
*/
export interface Link {
// 订阅者
sub: Sub
// 下一个订阅者节点
nextSub: Link | undefined
// 上一个订阅者节点
prevSub: Link | undefined
// 依赖项
dep: Dep
// 下一个依赖项节点
nextDep: Link | undefined
}
那这样,我们的链表里面就同时保存了 dep 和 sub,并且我们给 ReactiveEffect 加一个单向链表,让它在重新执行时可以找到自己之前收集到的依赖,尝试复用:
class ReactiveEffect {
/**
* 依赖项链表的头节点
*/
deps: Link | undefined
/**
* 依赖项链表的尾节点
*/
depsTail: Link | undefined
// 省略后续代码...
}
然后 effect 在执行的时候,我们就要创建 sub 和 link 之间的关联关系了,怎么创建呢?当然和 dep 一样,在 link 函数中创建:
/**
* 链接链表关系
* @param dep
* @param sub
*/
export function link(dep, sub) {
// 如果 activeSub 有,那就保存起来,等我更新的时候,触发
const newLink = {
sub,
dep,
nextDep: undefined,
nextSub: undefined,
prevSub: undefined
}
// 省略了 dep 和 link 创建关联关系的代码
//region 将链表节点和 sub 建立关联关系
/**
* 关联链表关系,分两种情况
* 1. 尾节点有,那就往尾节点后面加
* 2. 如果尾节点没有,则表示第一次关联,那就往头节点加,头尾相同
*/
if (sub.depsTail) {
sub.depsTail.nextDep = newLink
sub.depsTail = newLink
} else {
sub.deps = newLink
sub.depsTail = newLink
}
//endregion
}
好那这个结构搞好了之后,我们需要做什么呢?当 effect 执行完毕后,会收集到依赖,我们可以这样,当 effect 被通知更新的时候, 我们把 depsTail 设置成 undefined 那么此时,我们的 depsTail 指向 undefined,deps 指向 link1, 这种情况下,我们可以视为它之前收集过依赖,在重新执行的时候,需要尝试着去复用,那么复用谁呢?肯定是先复用第一个,然后依次往后
class ReactiveEffect {
run() {
// 先将当前的 effect 保存起来,用来处理嵌套的逻辑
const prevSub = activeSub
// 每次执行 fn 之前,把 this 放到 activeSub 上面
activeSub = this
// 这里在开始执行之前,我们将 depsTail 设置成 undefined
this.depsTail = undefined
try {
return this.fn()
} finally {
// 执行完成后,恢复之前的 effect activeSub = prevSub
}
}
}
好,那么此时我们进入到了第一种情况,就是 头节点有,尾节点没有,此时代表我们要尝试着去复用依赖项:
export function link(dep, sub) {
const currentDep = sub.depsTail
// 如果尾节点没有,头节点有,那我们拿到头节点
const nextDep = currentDep === undefined ? sub.deps : undefined
// 看一下头节点有没有,如果头节点也有,那么我们看一下头节点的 dep 是不是等于当前我们要收集的 dep
if (nextDep && nextDep.dep === dep) {
// 相同,将尾节点指向头节点
sub.depsTail = nextDep
return
}
}
当然我们还有另一种情况,就是有多个依赖,此时肯定尾节点还是有 nextDep 的,于是我们要再进一步复用 nextDep:
export function link(dep, sub) {
//region 尝试复用链表节点
const currentDep = sub.depsTail
/**
* 分两种情况:
* 1. 如果头节点有,尾节点没有,那么尝试着复用头节点
* 2. 如果尾节点还有 nextDep,尝试复用尾节点的 nextDep
*/
const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
if (nextDep && nextDep.dep === dep) {
sub.depsTail = nextDep
return
}
//endregion
}
总结需要复用 link 节点的两种情况:
- 尾节点没有,头节点有,此时需要复用头节点
- 尾结点有 nextDep 需要进一步复用 nextDep
节点复用
我们先来看一下数据结构
/**
* 依赖项
*/
interface Dep {
// 订阅者链表的头节点
subs: Link | undefined
// 订阅者链表的尾节点
subsTail: Link | undefined
}
/**
* 订阅者
*/
interface Sub {
// 依赖项链表的头节点
deps: Link | undefined
// 依赖项链表的尾节点
depsTail: Link | undefined
}
/**
* 链表节点
*/
export interface Link {
// 订阅者
sub: Sub
// 下一个订阅者节点
nextSub: Link | undefined
// 上一个订阅者节点
prevSub: Link | undefined
// 依赖项
dep: Dep
// 下一个依赖项节点
nextDep: Link | undefined
}
我们每次触发 effect,都需要重新收集依赖,但是针对那些已经收集过的依赖,我们是不需要重复收集的,所以我们需要尝试着去复用之前收集过的依赖, 但是我们需要知道,此次执行是需要复用依赖项的,所以我们可以这样,每次开始执行 fn 的时候,我们先把 depsTail 置空
class ReactiveEffect {
run() {
// 先将当前的 effect 保存起来,用来处理嵌套的逻辑
const prevSub = activeSub
// 每次执行 fn 之前,把 this 放到 activeSub 上面
activeSub = this
// ⭐️ 每次执行 fn 之前,先把 depsTail 置空
this.depsTail = undefined
try {
return this.fn()
} finally {
// 执行完成后,恢复之前的 effect
activeSub = prevSub
}
}
}
这样我们就可以明确的知道,以下两种情况是需要复用节点的
- 头节点复用: deps存在,但 depsTail 为 undefined
- nextDep复用: depsTail 存在且其 nextDep 可用
复用逻辑
// 先拿到尾节点
const currentDep = sub.depsTail
/**
* 分两种情况:
* 1. 如果头节点有,尾节点没有,那么尝试着复用头节点
* 2. 如果尾节点还有 nextDep,尝试复用尾节点的 nextDep
*/ const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
if (nextDep && nextDep.dep === dep) {
sub.depsTail = nextDep
return
}
头节点复用
当 depsTail
为 undefined
时,系统会尝试复用 deps
(头节点)。
nextDep 复用
当 depsTail
存在时,系统会尝试复用 depsTail.nextDep
,就是 nextDep2
复用的核心判断条件是:
if (nextDep && nextDep.dep === dep)
这个条件确保:
- 待复用的节点存在
- 待复用节点的依赖项与当前依赖项相同 以上两条成立,会复用节点
补充:
deps 有, depsTail 没有,表示是收集第一个依赖,因为在执行 effect.fn 之前,已经把 depsTail 设置成 undefined 了, 所以这个时候表示尝试复用链表的头节点,链表的头节点复用完成后,需要把 depsTail 指向当前已经复用成功的节点(就是 deps)此时头尾相同, 注意此时有可能 depsTail 还有 nextDep,因为头节点可能有下一个节点
如果depsTail.nextDep有,表示本次是重新执行的,并且头节点已经复用完毕,此时需要尝试复用的节点,就是 depsTail.nextDep
除了收集第一个依赖的时候,我们是用头节点去复用的,其他任何情况下,我们都会尝试复用 depsTail.nextDep
复用失败后的处理
如果无法复用节点,系统会创建新的链表节点并建立双向关联:
const newLink = {
sub,
dep,
nextDep,
nextSub: undefined,
prevSub: undefined
}
然后分别建立与 dep 和 sub 的关联关系: 以上就是节点复用的流程
分支切换
我们通过一个具体的例子来说明:
const count = ref(0)
const flag = ref(true)
effect(() => {
if (flag.value) {
console.log(count.value)
} else {
console.log('不收集 count 了')
}
})
setTimeout(() => {
flag.value = false
}, 1000)
这个例子里面我们可以看到,初始化的时候,effect 访问了 flag 和 count,此时会收集到这两个依赖,但是当一秒钟后,定时器触发 flag.value = false, 此时我们就走了 else 的逻辑,按理说 count 变化就不应该触发我们的 effect 了,但是我们目前并不能做到这一点,本节我们来解决这个问题
依赖收集过程
首次执行的时候 flag 为 true 此时的数据结构:
effect.deps = {
dep: flag依赖,
nextDep: {
dep: count依赖,
nextDep: undefined
}
}
effect.depsTail = count依赖对应的Link
当 flag.value 变为 false 时: effect -> flag依赖 -> nextDep需要清理 -> count依赖
清理过程
怎样拿到需要清理的依赖?
const newLink = {
sub,
dep,
nextDep, // 这里的 nextDep 来自于之前复用失败的依赖
nextSub: undefined,
prevSub: undefined
}
这里的关键是 nextDep
的传递,它保留了原有的依赖链,为后续清理提供了依据。 在 endTrack
函数中:
export function endTrack(sub: Sub) {
// 如果 depsTail 还有 nextDep,说明后面的依赖需要清理
if (sub.depsTail?.nextDep) {
// clearTracking 用来清理依赖,后续实现它,就是断开所有的关联关系
clearTracking(sub.depsTail.nextDep)
// 如果从尾节点后面开始的,那尾节点后面的就不要了,因为我们已经把它清理掉了
sub.depsTail.nextDep = undefined
}
// 如果 depsTail 为空但 deps 存在,说明这次执行没有收集到任何依赖
else if (!sub.depsTail && sub.deps) {
// clearTracking 用来清理依赖,后续实现它,就是断开所有的关联关系
clearTracking(sub.deps)
sub.deps = undefined // 如果从头节点开始清理的,那头节点就不要了
}
}
清理场景
当 flag.value
变为 false
时:
- 只会收集
flag
的依赖 depsTail
指向flag
的 LinkdepsTail.nextDep
指向原来的count
依赖- 此时
depsTail.nextDep
存在,触发清理
完全没有收集到依赖
切换前: effect -> flag依赖 -> count依赖
切换后: effect -需要清理> flag依赖 -需要清理> count依赖
例如:
effect(() => {
if (false) {
console.log(count.value)
}
})
此时:
- depsTail 为 undefined(没有收集到任何依赖)
- deps 存在(之前的依赖还在)
- 这种情况,从 deps 开始清理整个链表
清理函数实现
/**
* 清理依赖关系
* @param link
*/
function clearTracking(link: Link) {
while (link) {
const { prevSub, nextSub, nextDep, dep } = link
/**
* 如果 prevSub 有,那就把 prevSub 的下一个节点,指向当前节点的下一个
* 如果没有,那就是头节点,那就把 dep.subs 指向当前节点的下一个
*/
if (prevSub) {
prevSub.nextSub = nextSub
link.nextSub = undefined
} else {
dep.subs = nextSub
}
/**
* 如果下一个有,那就把 nextSub 的上一个节点,指向当前节点的上一个节点
* 如果下一个没有,那它就是尾节点,把 dep.depsTail 指向上一个节点
*/
if (nextSub) {
nextSub.prevSub = prevSub
link.prevSub = undefined
} else {
dep.subsTail = prevSub
}
link.dep = link.sub = undefined
link.nextDep = undefined
link = nextDep
}
}
为什么要清理依赖
- 内存管理:防止内存泄漏
- 性能优化:避免不必要的更新计算
- 确保正确性:保证响应式系统的依赖关系准确性
链表节点复用
- system.ts
// 保存已经被清理掉的节点,留着复用
let linkPool: Link
/**
* 链接链表关系
* @param dep
* @param sub
*/
export function link(dep, sub) {
//region 尝试复用链表节点
const currentDep = sub.depsTail
/**
* 分两种情况:
* 1. 如果头节点有,尾节点没有,那么尝试着复用头节点
* 2. 如果尾节点还有 nextDep,尝试复用尾节点的 nextDep
*/
const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
if (nextDep && nextDep.dep === dep) {
sub.depsTail = nextDep
return
}
//endregion
// 如果 activeSub 有,那就保存起来,等我更新的时候,触发
let newLink
/**
* 看一下 linkPool 有没有,如果有,就复用
*/
if (linkPool) {
newLink = linkPool
linkPool = linkPool.nextDep
newLink.nextDep = nextDep
newLink.dep = dep
newLink.sub = sub
} else {
// 如果没有,就创建新的
newLink = {
sub,
dep,
nextDep,
nextSub: undefined,
prevSub: undefined,
}
}
//region 将链表节点和 dep 建立关联关系
/**
* 关联链表关系,分两种情况
* 1. 尾节点有,那就往尾节点后面加
* 2. 如果尾节点没有,则表示第一次关联,那就往头节点加,头尾相同
*/
if (dep.subsTail) {
dep.subsTail.nextSub = newLink
newLink.prevSub = dep.subsTail
dep.subsTail = newLink
} else {
dep.subs = newLink
dep.subsTail = newLink
}
//endregion
//region 将链表节点和 sub 建立关联关系
/**
* 关联链表关系,分两种情况
* 1. 尾节点有,那就往尾节点后面加
* 2. 如果尾节点没有,则表示第一次关联,那就往头节点加,头尾相同
*/
if (sub.depsTail) {
sub.depsTail.nextDep = newLink
sub.depsTail = newLink
} else {
sub.deps = newLink
sub.depsTail = newLink
}
//endregion
}
/**
* 传播更新的函数
* @param subs
*/
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
queuedEffect.push(link.sub)
link = link.nextSub
}
queuedEffect.forEach(effect => effect.notify())
}
/**
* 开始追踪依赖,将depsTail,尾节点设置成 undefined
* @param sub
*/
export function startTrack(sub) {
sub.depsTail = undefined
}
/**
* 结束追踪,找到需要清理的依赖,断开关联关系
* @param sub
*/
export function endTrack(sub) {
const depsTail = sub.depsTail
/**
* depsTail 有,并且 depsTail 还有 nextDep ,我们应该把它们的依赖关系清理掉
* depsTail 没有,并且头节点有,那就把所有的都清理掉
*/
if (depsTail) {
if (depsTail.nextDep) {
clearTracking(depsTail.nextDep)
depsTail.nextDep = undefined
}
} else if (sub.deps) {
clearTracking(sub.deps)
sub.deps = undefined
}
}
/**
* 清理依赖关系
* @param link
*/
function clearTracking(link: Link) {
while (link) {
const { prevSub, nextSub, nextDep, dep } = link
/**
* 如果 prevSub 有,那就把 prevSub 的下一个节点,指向当前节点的下一个
* 如果没有,那就是头节点,那就把 dep.subs 指向当前节点的下一个
*/
if (prevSub) {
prevSub.nextSub = nextSub
link.nextSub = undefined
} else {
dep.subs = nextSub
}
/**
* 如果下一个有,那就把 nextSub 的上一个节点,指向当前节点的上一个节点
* 如果下一个没有,那它就是尾节点,把 dep.depsTail 只想上一个节点
*/
if (nextSub) {
nextSub.prevSub = prevSub
link.prevSub = undefined
} else {
dep.subsTail = prevSub
}
link.dep = link.sub = undefined
/**
* 把不要的节点给 linkPool,让它去复用吧
*/
link.nextDep = linkPool
linkPool = link
link = nextDep
}
}
避免无限循环递归
import { ref, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
effect(() => {
console.log(count.value++)
})
上面的代码会导致无限循环递归 我们在 ReactiveEffect
中添加一个 tracking = false
开始追踪依赖设置成 true
,结束追踪依赖设置成 false
/**
* 开始追踪依赖,将depsTail,尾节点设置成 undefined
* @param sub
*/
export function startTrack(sub) {
sub.tracking = true
sub.depsTail = undefined
}
/**
* 结束追踪,找到需要清理的依赖,断开关联关系
* @param sub
*/
export function endTrack(sub) {
sub.tracking = false
const depsTail = sub.depsTail
/**
* depsTail 有,并且 depsTail 还有 nextDep ,我们应该把它们的依赖关系清理掉
* depsTail 没有,并且头节点有,那就把所有的都清理掉
*/
if (depsTail) {
if (depsTail.nextDep) {
clearTracking(depsTail.nextDep)
depsTail.nextDep = undefined
}
} else if (sub.deps) {
clearTracking(sub.deps)
sub.deps = undefined
}
}
reactive实现原理
Reactive核心
reactive
的核心思想是通过 Proxy
来拦截对象的操作,实现依赖收集和触发更新,结构如下: reactive -> Proxy -> get拦截器 -> track依赖收集
reactive -> Proxy -> set拦截器 -> trigger触发更新
依赖收集的数据结构
targetMap(WeakMap) -> target对象 -> depsMap(Map) -> key -> dep对象 -> subs链表
- targetMap:存储所有响应式对象的依赖关系
- depsMap:存储某个对象的所有属性依赖
- dep:存储某个属性的所有订阅者
实现流程详解
- 创建响应式对象
export function reactive(target) {
/**
* reactive 必须接受一个对象
*/
if (!isObject(target)) {
/**
* target 不是一个对象,哪儿来的回哪儿去
*/
return target
}
/**
* 看一下这个 target 在不在 reactiveSet 里面,如果在,就证明 target 是响应式的,直接返回
*/
if (reactiveSet.has(target)) {
return target
}
/**
* 获取到之前这个 target 创建的代理对象
*/
const existingProxy = reactiveMap.get(target)
if (existingProxy) {
/**
* 如果这个 target 之前使用 reactive 创建过响应式对象,那就直接返回这个响应式对象
*/
return existingProxy
}
/**
* 创建 target 的代理对象
*/
const proxy = new Proxy(target, mutableHandlers)
/**
* 保存 target 和 proxy 之间的关联关系
* target => proxy
*/
reactiveMap.set(target, proxy)
// 保存响应式对象到 reactiveSet
reactiveSet.add(proxy)
return proxy
}
- mutableHandlers
export const mutableHandlers = {
get(target, key, receiver) {
/**
* target = { a:0 }
* 收集依赖,绑定 target 中某一个 key 和 sub 之间的关系
*/
track(target, key)
const res = Reflect.get(target, key, receiver)
if (isRef(res)) {
/**
* target = {a:ref(0)}
* 如果target.a 是一个 ref,那么就直接把值给它,不要让它 .value
*/
return res.value
}
if (isObject(res)) {
/**
* 如果 res 是一个对象,那么我就给它包装成 reactive
*/
return reactive(res)
}
/**
* receiver 用来保证 访问器里面的 this 指向代理对象
*/
return res
},
set(target, key, newValue, receiver) {
const oldValue = target[key]
/**
* 触发更新,set 的时候,通知之前收集的依赖,重新执行
*/
const res = Reflect.set(target, key, newValue, receiver)
/**
* 如果更新了 state.a 它之前是个 ref,那么会修改原始的 ref.value 的值 等于 newValue
* 如果 newValue 是一个 ref,那就算了
*/
if (isRef(oldValue) && !isRef(newValue)) {
/**
* const a = ref(0)
* target = { a }
* 更新 target.a = 1 ,它就等于更新了 a.value
* a.value = 1
*/
oldValue.value = newValue
return res
}
if (hasChanged(newValue, oldValue)) {
/**
* 如果新值和老值不一样,触发更新
* 先 set 再通知 sub 重新执行
*/
trigger(target, key)
}
return res
}
}
- dep.ts
import { Link, link, propagate } from './system'
import { activeSub } from './effect'
/**
* 绑定 target 的 key 关联的所有的 Dep
* obj = { a:0, b:1 }
* targetMap = {
* [obj]:{
* a:Dep,
* b:Dep
* }
* }
*/
const targetMap = new WeakMap()
export function track(target, key) {
if (!activeSub) {
return
}
/**
* 找 depsMap = {
* a:Dep,
* b:Dep
* }
*/
let depsMap = targetMap.get(target)
if (!depsMap) {
/**
* 没有 depsMap,就是之前没有收集过这个对象的任何 key
* 那就创建一个新的,保存 target 和 depsMap 之间的关联关系
*/
depsMap = new Map()
targetMap.set(target, depsMap)
}
/**
* 找 dep => Dep
*/
let dep = depsMap.get(key)
if (!dep) {
/**
* 第一次收集这个对象,没找到,创建一个新的,并且保存到 depsMap 中
*/
dep = new Dep()
depsMap.set(key, dep)
}
/**
* 绑定 dep 和 sub 之间的关联关系
*/
link(dep, activeSub)
}
export function trigger(target, key) {
console.log('target,key ==> ', target, key)
/**
* 找 depsMap = {
* a:Dep,
* b:Dep
* }
*/
const depsMap = targetMap.get(target)
if (!depsMap) {
/**
* depsMap 没有,表示这个对象,从来没有任何属性在 sub 中访问过
*/
return
}
/**
* 找到 key 对应的 Dep
* key => Dep
*/
const dep = depsMap.get(key)
if (!dep) {
// dep 不存在,表示这个 key 没有在 sub 中访问过
return
}
/**
* 找到 dep 的 subs 通知它们重新执行
*/
propagate(dep.subs)
}
class Dep {
/**
* 订阅者链表的头节点,理解为我们将的 head
*/
subs: Link
/**
* 订阅者链表的尾节点,理解为我们讲的 tail
*/
subsTail: Link
constructor() {}
}
依赖收集过程 当访问响应式对象的属性时,会触发
get
拦截器, 然后调用track
函数进行依赖收集触发更新过程 当设置响应式对象的属性时,会触发
set
拦截器,然后调用trigger
函数进行更新通知,触发所有依赖该属性的订阅者重新执行。
总结
reactive
实现主要依靠:
Proxy
代理实现对象操作的拦截- 将对应的
target
和key
通过targetMap(WeakMap)
保存和Dep
之间的关联关系 - 通过
depsMap
找到Dep
收集依赖和触发更新