今天介绍的这个API和之前介绍的其他API有很大的不同。熟悉Vue的小伙伴都知道,Vue3中的ref是用来将原始值转换为响应式数据,而我们之前介绍的API都是使用的引用类型的响应式数据,在响应式数据的基础上拓展而来的API。至于引用类型数据转换为响应式,因为引用类型数据结构比较复杂,所以会在后面再讲。
长话短说,对于引用类型数据的响应式转换,是基于ES6的proxy这个API。但是对于原始类型的响应式数据转换,是基于我们熟悉的getter和setter。我们知道ref返回值只能使用.value来访问(模板除外),所以我们可以推断出,ref会把原始值转化为对象的value这个键的getter和setter。
function ref(val) {
return new RefImp(val)
}
class RefImp {
constructor(val) {
this._value = val
}
get value() {
/* 在这里收集依赖于该响应式数据的副作用函数 */
return this._value
}
set value(val) {
if (this._value !== val) {
/* 在这里触发依赖于该响应式数据的副作用函数重新执行 */
this._value = val
}
}
}
就这样我们简单的实现了ref,通过.value设置数据和读取数据。但是最核心的逻辑还没有实现,那就是这两句注释中的,收集依赖于该响应式数据的副作用函数,和触发依赖于该响应式数据中和的副作用函数重新执行。这两件事可以分别封装为track和trigger这两个函数,也就是副作用函数“追踪依赖”和“触发重新执行”。
响应式原理:
之前虽然也简要介绍了一下响应式原理,但是可能不够详细,所以在这里深入的解释一下:
let A2
let A0 = 0
let A1 = 1
function update() {
A2 = A0 + A1
}
函数update的执行过程中,引用了它外部词法环境的变量,无论在这个update内读取了外部变量的值,还是更改了外部变量的值,都算是使用了外部变量。但是很可能别的函数也使用了外部变量,所以对外部变量的使用可能会对别的函数的执行产生不好的影响。假如说,因为这个update函数的执行导致A2的值变化了,很有可能update后面函数的返回值也会跟着改变。所以像这样使用了外部环境的变量并且可能造成不好的影响的,产生了“副作用”的函数就叫做“副作用函数”。
在update函数中,A0和A1导致了这个“副作用”,所以A0和A1就是这个副作用函数的依赖,我们想要的是,能够在A0或A1变化后,重新执行这个函数,以得到新的A2的值。但是我们知道这在顺序执行的JavaScipt代码中是不可能的,函数执行完推出了上下文就结束了。
不过其实可以看出来在函数里面是读取了A0和A1这两个依赖,所以响应式方案就是在依赖的读取上做手脚。因为JS单线程,函数执行的时候其实正在执行的函数就只有这一个,所以执行到update函数的时候我们可以设置这个函数为“正在激活中的副作用函数”,然后执行时会读取A0和A1,我们之前说过,原始值的响应式其实是触发了包装为对象的getter,所以我们在每个依赖的getter里定义一个数据结构收集这个“正在激活中的副作用函数”,然后当A0或A1变化的时候,就会触发setter,在setter中把这些依赖取出来重新执行一次,就会重新执行一次update函数,这样就得到了依赖变化后的A2的新值。
track():
所以我们就来先来实现一下用来收集副作用函数的函数track:
// 使用weakMap来保存每个target对应的依赖映射,target回收后对应的依赖映射也一并回收
const targetMap = new weakMap()
// 把全局唯一的activeEffet定义在window上
window.activeEffet = undefined
// 追踪依赖
function track(target, key) {
if (!activateEffect) return
let depsMap = bucket.get(target)
//找不到就新建一个,注意是使用的Map
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
//找不到就新建一个
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 收集全局唯一的activeEffect
deps.add(activateEffect)
// 让每个副作用函数都能知道时谁收集了自己,用于解除追踪
activateEffect.deps.push(deps)
}
我们定义了一个weakMap来保存所有的副作用函数,weakMap不会影响内存回收,所以响应式数据失去引用被回收后,weakMap所保存的关于这个数据的一切都会直接消失,这样就不会我们手动清除了。这个weakMap的键为target,也就是每一个响应式依赖,value为depsMap,它是一个map,这个map的key是响应式依赖的每一个key(如果响应式数据是对象的话),value是一个set,用来保存每一个响应式数据的每一个key所收集的副作用函数。整体的逻辑就是需要追踪这个依赖的时候,就在现在weakMap中找到这个依赖,然后找依赖的key,然后把“正在激活中的副作用函数”也就是activateEffect保存起来,这样就完成了副作用函数的收集。
trigger():
然后我们再来实现一个依赖变化时,触发副作用函数重新运行的trigger:
// 触发执行
function trigger(target, key) {
// 寻找target
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectToRun = new Set()
// 如果注册activeEffect的过程中,改变了响应式数据,会导致又track又trigger的过程,引发无线递归。
// 所以去掉当前正在激活的副作用函数
effects && effects.forEach((fn) => {
if (fn !== activateEffect) {
effectToRun.add(fn)
}
})
effectToRun.forEach(fn => fn())
}
触发时的逻辑就是收集时的逻辑相反,找depsMap,然后找对应的key,然后找到之后拿出来执行。这里还有一点处理,就是执行时并非直接取出来遍历执行effets,而是取一份去掉activeEffect的拷贝effectToRun然后遍历执行。
这是因为如果注册activeEffect的过程中,改变了响应式数据,会导致又track又trigger的过程,引发无线递归。举个例子:
let A0 = ref(0)
function update() {
A0.value++
}
可以发现副作用函数update肯定是读取了A0,读取的时候响应式数据会收集update。但是这时候A0又自增了,这回导致trigger触发,把update又拿出来执行,但问题是现在还处在update运行的过程中,所以这就导致了函数的无限递归,解决的办法trigger时把update这个当前“正在激活中的副作用函数”去掉,也就是把activeEffect去掉,但是去掉又不能破坏原本的依赖所收集的副作用函数集合,所以用拷贝的形式来遍历执行副作用函数。
有了track和trigger就可以实现响应式逻辑了,我们再回到文章开头的例子:
function ref(val) {
return new RefImp(val)
}
class RefImp {
constructor(val) {
this._value = val
}
get value() {
// 被读取的时候,收集副作用函数
// target就是响应式数据本身,key就是value
track(this, value)
return this._value
}
set value(val) {
if (this._value !== val) {
this._value = val
// 数据变化的时候 , 把副作用函数取出来重新执行
trigger(this, value)
}
}
}
let A2
let A0 = ref(0)
let A1 = ref(1)
function update() {
// 读取时track
A2 = A0 + A1
}
// 设置当前全局唯一的activeEffect
window.activeEffect = update
update()
// 读取过响应式数据后再清空它
window.activeEffect = null
A0++ // 响应式数据变化触发update重新执行(trigger),得到新的A2
A1++ // 响应式数据变化触发update重新执行(trigger),得到新的A2
这样我们就算是实现了简易的原始值的响应式转换,但是功能还不完美,因为ref也可以接受引用类型的值,然后把引用类型的值转换为响应式,我们目前的实现只是针对原始值的设计。
关于引用类型的响应式数据如何转化我会在后面的文章详细解读