vue中最出名的就是响应式变化,仅通过改变数据的方式即可改变视图,那么vue是如何做到的呢?
我们正常使用vue3中,通常是使用ref(str|num)
和reactive(obj)
,其中ref
本质上也是调用reactive
,在reactive
中对入参进行了拦截处理,这里面的核心是effect
的收集,执行能力。
为了通俗易懂,作者将拆分几个章节,按照顺序讲解响应式。本文的内容是基于上一个章节的代码,继续一步步推演effect
源码。
先温习一下上一个章节的代码
js
// 用一个全局变量存储副作用函数
let activeEffect
// effect函数用于注册副作用
function effect(fn) {
// 储存副作用函数
activeEffect = fn
// 执行函数
fn()
}
// 要响应的对象
const obj = new Proxy(data, {
get(target, key) {
// 将target和key传入函数中用以收集副作用函数
track(target, key)
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 根据target和key取出对应的全部副作用函数并执行
trigger(target, key)
// 必须写这句,代表设置成功
return true
}
})
// 追踪变化
function track(target, key) {
if (!activeEffect) {
return target[key]
}
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
// 触发变化
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach((fn => fn())
}
看现在上去没什么问题,但有一个缺陷:缺少销毁能力。何为销毁能力,用于什么场景呢?下面举一个小例子
js
const data = {
flag: true,
text: 'hello world'
}
const obj = new Proxy(data, {...}) // 此处省略,和上述代码一样
effect(function effectFn() {
document.body.innerText = obj.flag ? obj.text : 'noop'
})
可以看到effect
中body
的文本变成了一个三元表达式,我们仔细分析这段代码,不难发现会同时读取obj.flag
和obj.text
,意味着会走两遍obj的get
,以及里面的track
收集方法。将会构建如下依赖关系图
js
targetMap: WeakMap<data, Map{
flag: Set[effectFn],
text: Set[effectFn]
}>
粗看之下确实没问题,obj.flag
和obj.text
任意一个发生变化都要重新执行effectFn
。接下来我们将obj.flag
设为false,body
的innerText
会恒定为'noop'
,理论上来讲此时obj.text
的变化,已经与innerText
无关了,但事实是:
js
obj.text = 'hello Vue'
会重新执行
js
document.body.innerText = obj.flag ? obj.text : 'noop'
body
的内容原本就是noop
,又被重置成了noop
。明明就不需要变化,却依旧执行了函数,这不符合我们的要求,所以我们要建立一个销毁机制:每次执行 effectFn这个副作用函数前(不了解副作用函数的请移步上一章)都清空与之相关的依赖收集,等下次读取触发track
收集的时候再重新收集。也就是这样:
js
// 首次收集
targetMap: WeakMap<data, Map{
flag: Set[effectFn],
text: Set[effectFn]
}>
// set触发执行副作用函数,执行前清空
targetMap: WeakMap<data, Map{
flag: Set[],
text: Set[]
}>
// flag变为false
targetMap: WeakMap<data, Map{
flag: Set[effectFn],
text: Set[]
}>
这样一来obj.text
再发生变化,就不会执行额外的副作用函数了。
要想销毁相关的全部依赖,就得先获取到相关的依赖。这里首先要建立一个储存的位置,这个位置要既能在effect
中执行 使用,也能在track
收集 时使用。有一个全局变量符合这个要求------------没错,就是activeEffect
!这个变量用于储存当前正在处理的副作用函数。我们期望可以在activeEffect
上能读取到该副作用函数相关的全部依赖。那么我们可以进行如下改造
js
function effect(fn) {
// 新增副作用容器
const effectFn = () => {
// 储存副作用函数
activeEffect = effectFn
// 执行副作用函数
fn()
}
// 在副作用函数上新增一个静态属性,用以收集副作用函数相关的全部依赖
effectFn.deps = []
// 执行副作用容器
effectFn()
}
如上所示,我们在effectFn
(也就是activeEffect
)上开辟了个静态属性deps
。既然有了储存位置,接下来我们就要向内储存东西了
js
// 追踪变化
function track(target, key) {
if (!activeEffect) {
return target[key]
}
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
// 新增代码 向activeEffect.deps填充依赖集合
activeEffect.deps.push(deps)
}
可以看到track
做的事情也很简单,将与activeEffect
关联的key
的依赖集合推入effectFn.deps
,现在回想一下我们的目的:每次执行完effectFn
之后,都清除所有与之相关的依赖关系。我们现在已经做到了有几个属性关联effectFn
,就收集几个依赖集合,这个集合里面包含了该属性关联的所有副作用函数。那么我们要做的事情也很简单:遍历全部依赖集合,在其中删除effectFn
js
function effect(fn) {
// 新增副作用容器
const effectFn = () => {
// 清除副作用函数相关的依赖
cleanup(effectFn)
// 储存副作用函数
activeEffect = effectFn
// 执行副作用函数
fn()
}
// 用以收集副作用函数相关的全部依赖
effectFn.deps = []
// 执行副作用容器
effectFn()
}
function cleanup (effectFn) {
for(let i = 0;i < effectFn.deps.length;i++){
const deps = effectFn.deps[i]
// deps为其中一个依赖集合,这里要将effectFn从依赖集合中删除
deps.delete(effectFn)
}
// 手动将长度设为0
effectFn.deps.lendth = 0
}
然而执行的时候会发现代码被阻塞掉,原因出在trigger中
js
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach((fn => fn()) // 问题出在这一行
}
我们来捋一下执行顺序:
js
// 1、修改值
data.text = 'hello'
// 2、触发setter 以下为setter部分代码
trigger(target, key)
// 3、执行trigger 以下为为trigger部分代码
effects.forEach((fn => fn())
// 4、执行fn 也就是effectFn 以下为为effectFn部分代码
cleanup(effectFn)
fn() // 执行effect传入的函数 这里很关键!
// 5、执行cleanup 将effectFn从所有与其相关的依赖集合中删除
for(let i = 0;i < effectFn.deps.length;i++){
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
让我们一步步拆解。第1步修改一下值来触发setter
,setter内部代码执行了trigger
,到达第2步。接下来到达了trigger
内部,内部遍历执行修改值绑定的副作用函数集合。进入执行阶段,也就是第4步,执行函数内部有两步比较关键:1、cleanup清除依赖 2、执行副作用函数。我们这里先看cleanup,进入第5步
注意第5步的effectFn.deps
,第3步的effects.forEach((fn => fn())
中的effects
就包含在effectFn.deps
中。也就是说第5步正在删除第3步forEach
遍历的数组里的项。
再回过头看第4步的第二项,执行副作用函数fn
。执行时会触发getter
,内部收集副作用函数并推入集合当中,也就是向effects
中推入函数。所以我们本质上是在对一个正在遍历的数组动态的添加项,导致这个数组永远遍历不完。
解决的办法也很简单,我们遍历原数组的副本,保证原数组的操作不会影响到此次遍历即可
js
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 新增
effectsToRun && effectsToRun.forEach((fn => fn())
}
可以看到我们新建了个集合去遍历,这样我们的代码执行起来就不会有问题了
本章节主要讲述了effect的边界处理之一:cleanup,主要功能为避免执行无用的副作用函数。再源码中对应关系为effect.run
以上就是本章的全部内容了,effect还有很多的边界处理情况,我们会在下一个章节中继续推演