你是否好奇 Vue 为什么能在你修改变量时自动更新页面?
本文将手把手带你实现一个最简版的 Vue 响应式系统,从原理到代码,彻底讲透它的核心机制。
响应式系统的核心概念
什么是响应式
一句话概括就是:系统自动追踪数据的变化,并实时同步更新依赖该数据的视图或逻辑
大白话说就是:当你修改了变量,vue就会自动帮你更新页面,不用你手动改DOM
核心概念
在Vue中,它的本质是通过追踪数据和副作用函数之间的依赖关系,实现自动更新
那么它是怎么实现的呢?
依赖收集
当某个响应式变量被读取时,vue会记录当前是谁在用这个值,这个'谁'通常是一个副作用函数,也就是effect
javascript
// 想象这个 effect 会订阅 count
effect(() => {
console.log(count.value)
})
这段代码中effect
传入的参数是一个函数,而这个函数中读取了count
,那么,vue就会把这个函数,也就是副作用函数保存起来
当响应式变量的值发生变化时(如count.value++
),Vue 会自动找到依赖它的所有副作用函数,并重新执行它们,从而更新视图或逻辑
这种机制就是发布订阅模式:
- 依赖收集 = 订阅
- 触发更新 = 发布
触发更新
当响应式对象的某个属性被修改时,Vue会找到依赖它的所有effect
,并依次调用他们。比如:
javascript
count.value++ // 会触发上面订阅了 count 的 effect
响应式对象
Vue3中的reactive
使用了es6的proxy
来实现响应式代理
每当你对对象进行访问(get
)或者修改(set
)时,Vue 都能感知到这一行为,并自动进行依赖收集或触发更新
接下来我们来实现一个简单的响应式系统
最简实现
案例:
javascript
const count = ref(0)
effect(() => {
console.log(count.value)
})
setTimeout(() => {
count.value++
}, 1000)
接下来我们依次实现ref
和effect
,来成功让count
变化时自动调用effect
中的函数
effect实现
javascript
// 定义一个全局对象,用来存储正在执行的副作用函数
let activeSub = null
class ReactiveEffect {
constructor(public fn: Function) {}
run() {
// 将当前实例赋值给activeSub
activeSub = this
try {
// 执行副作用函数
return this.fn()
} finally {
// 将全局对象存储的副作用函数清空
activeSub = null
}
}
}
function effect(fn) {
const e = new ReactiveEffect(fn)
e.run()
}
ref实现
这块代码比较复杂,我先把整体放出来,然后在分步骤解析
javascript
class RefImpl {
_value; // 当前的值
subs; // 链表头,用来存副作用函数
subsTail; // 链表尾
constructor(value) {
this._value = value
}
get value() {
if (activeSub) {
trackRef(this)
}
return this._value
} // 当访问 .value 时,Vue就会收集这个依赖,记录哪个副作用函数用到了
set value(newValue) {
this._value = newValue
triggerRef(this)
} // 当 .value 改变时,系统就会调用 triggerRef 通知所有依赖它的副作用函数去重新执行
}
function ref(value) {
return new RefImpl(value)
}
// 收集依赖
function trackRef(dep) {
if (activeSub) { // 如果当前有正在运行的 effect 就与这个 ref 建立关联
link(dep, activeSub)
}
}
// 触发ref关联的依赖
function triggerRef(dep) {
if (dep.subs) { // 如果有关联的副作用函数便执行
propagate(dep.subs)
}
}
function link(dep, sub) {
// 定义一个依赖关系的双向链表节点的结构
let newLink = {
sub, // 订阅者,即副作用函数(表示谁依赖了这个ref)
nextSub: undefined, // 链表中的下一个订阅者(副作用函数)
prevSub: undefined, // 链表中的上一个订阅者
}
// 判断当前的 dep 是不是第一次被订阅(即 subsTail 为 null)
if (dep.subsTail) {
dep.subsTail.nextSub = newLink // 将尾节点的nextSub指向新的节点(插入到尾部)
newLink.prevSub = dep.subsTail // 将这个新节点的prevSub指向原来的尾节点
dep.subsTail = newLink // 将原来的尾节点指向新节点
} else {
// 第一次被订阅,将头和尾都指向这个新节点
dep.subs = newLink
dep.subsTail = newLink
}
}
function propagate(subs) {
let link = subs // 拿到链表头
const queuedEffect = [] // 定义一个数组用来存储需要重新执行的副作用函数
while (link) { // 如果有值就去将副作用函数取出来,存入到数组
const sub = link.sub
queuedEffect.push(link.sub) // 将副作用函数追加到数组中
link = link.nextSub // 赋值为下一个链表
}
// 依次调用
queuedEffect.forEach(effect => effect.run())
}
首先说一下什么是链表?
链表是一种基础的数据结构,用于按顺序存储元素,通过节点之间的指针连接来组织数据
我们为什么用链表而不是数组?来看下这两者的简单对比:
特性 | 数组(Array) | 链表(当前用的结构) |
---|---|---|
添加依赖(effect) | O(1) or O(n) | ✅ O(1)(直接接到尾部) |
删除依赖(effect) | ❌ O(n) | ✅ O(1)(通过指针) |
遍历执行所有依赖 | O(n) | O(n) |
实现复杂度 | 简单 | 略高 |
链表的设计可以让我们更高效的添加和移除副作用函数依赖
接下来我们看看在我们的代码中链表结构是怎么设计的:
javascript
let newLink = {
sub, // 订阅者,即副作用函数 effect 实例(表示谁依赖了这个ref)
nextSub: undefined, // 下一个
prevSub: undefined, // 上一个
}
它用来存储与当前响应式数据关联的副作用函数effect
实例,sub
是我们调用link
函数时传递过来的全局定义的一个变量,用来存储正在执行的副作用函数effect
实例,这一块的代码在effect
实现那里
接下来看这段代码,我将每行都写上了注释,想必应该很清楚了
javascript
// 判断当前的 dep 是否已有订阅者(即是否已经建立过依赖)
if (dep.subsTail) {
// 已经有订阅者,追加到链表尾部
// 1. 让当前尾节点的 nextSub 指向新节点(连接尾部)
dep.subsTail.nextSub = newLink
// 2. 让新节点的 prevSub 指向旧尾部(形成双向链表)
newLink.prevSub = dep.subsTail
// 3. 更新 dep.subsTail,标记新的尾节点
dep.subsTail = newLink
} else {
// 没有任何订阅者,这是第一次收集依赖
// 1. 让 subs(链表头)指向新节点
dep.subs = newLink
// 2. 同时 subsTail(链表尾)也指向它(链表中只有一个节点)
dep.subsTail = newLink
}
dep
也就是调用link
函数时传递过来的ref实例,在RefImpl
构造函数中,我们定义了subs
和subsTail
,分别用于存储链表的头部和尾部
这样的话我们在修改ref
响应式数据时就可以从自己身上拿到subs
然后一路nextSub
下去,就可以拿到所有关联的副作用函数effect
实例啦
然后我们把这些函数全部执行一遍就可以了
也就是这一段代码
javascript
function propagate(subs) {
let link = subs // 1. 从链表头开始遍历
const queuedEffect = [] // 2. 用一个数组缓存所有需要触发的副作用函数
while (link) { // 3. 遍历链表,直到 null
const sub = link.sub // 当前节点中的副作用函数
queuedEffect.push(sub) // 加入执行队列
link = link.nextSub // 移动到下一个节点
}
// 4. 依次执行所有副作用函数(即 effect.run())
queuedEffect.forEach(effect => effect.run())
}
这样一个简易的响应式系统就完成啦
javascript
const count = ref(0)
effect(() => {
console.log(count.value)
})
setTimeout(() => {
count.value++
}, 1000)
() => {console.log(count.value)}
执行- 触发
count
的get
- 收集依赖执行
link
函数 - 一秒后执行
count.value++
- 触发
count
的set
- 将
count
的值++ - 执行
propagate
- 拿到
count
实例中的subs
,遍历执行收集的副作用函数 - 控制台再次打印
count.value
总结
虽然这个响应式系统已经能自动追踪数据变化并更新副作用函数,但它仍然是一个极简版本,存在不少限制,比如:
- 不支持嵌套 effect
- 不支持复用 effect
- 没有调度器(scheduler)
这些问题将在后续文章中继续优化和完善,欢迎关注 🌟