Vue3 响应式设计相关文章:
接着上一篇:我们已经实现了一个基础的响应式数据系统。先来回顾一下之前的代码:
let bucket = new WeakMap()
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect (fn) {
activeEffect = fn
fn()
}
const obj = {
content: 'hello 响应式数据',
title: 'hello title'
}
const proxy = new Proxy(obj, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, newVal) {
target[key] = newVal
trigger(target, key, newVal)
return true
}
})
function track (target, key) {
// acctiveEffect 直接返回
if (!activeEffect) return target[key]
// 根据target 从bucket中取出depsMap, 它也是一个Map 类型: key: effects
let depsMap = bucket.get(target)
// 如果不存在depsMap,那么新建一个Map 与target 关联
if (!depsMap) {
bucket.set(target, depsMap = new Map())
}
// 如果deps不存在,新建一个Set 与key关联。
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, deps = new Set())
}
deps.add(activeEffect)
activeEffect = null // 收集完成之后将存储的变量变为空,因为多个副作用函数的时候会篡位
}
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) {
return
}
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
function test () {
console.log('test')
document.body.innerHTML = proxy.content
}
function test2 () {
console.log('test2')
document.title = proxy.title
}
effect(test)
effect(test2)
proxy.content = 'hello 新的响应式数'
proxy.title = 'hello 新title'
上面代码已经能实现响应是数据额基本功能,但是还是有很多不足,需要进一步完善。
本篇将从4个方面思考来完善我们的响应式数据设计。
分支切换:
先来看一段代码:
const obj = {
content: 'hello 响应式数据',
isContent: true
}
function test () {
document.body.innerHTML = proxy.isContent ? proxy.content : 'No content'
}
effect(test)
基于之前的代码。我们将obj的title字段改成了isContent: true。 test 改成了document.body.innerHTML = proxy.isContent ? proxy.content : 'No content' 三元表达式。当proxy.isContent 的值变化时。document.body.innerHTML 会接收到不一样的值。这就是分支切换。
上面的代码执行后,test 函数会被收集两次。proxy.isContent 的时候触发读取操作触发一次收集,proxy.content 读取content 属性触发一次收集。这本身没有问题。但是现在我们将obj.isContent的值改为false 式就有问题了。会触发更新。但是现在由于isContent 的值是false,无论怎么修改proxy.content的值,document.body.innerHTML 都为 No content. 所以理想的结果是当proxy.isContent 更改为false 后无论proxy.content 怎么修改。都不需要再触发test 的执行。但是我们现在的实现并不能达到这样的目的。 来运行下下面的代码:
function test () {
console.log('test')
document.body.innerHTML = proxy.isContent ? proxy.content : 'No content'
}
effect(test)
proxy.isContent = false
proxy.content = '新内容'
可以看到test 被执行了3次。初始化的时候执行了一次,修改proxy.isContent 时触发了一次。修改proxy.content 时又触发了一次。第三次是我们不需要的,因为当proxy.isContent 为false 时,proxy.content 触发更新已经没有意义。这个问题该怎么解决呢?
解决分支切换导致的不必要的更新
- 每次执行副作用函数时,先把它从所有与之关联的依赖集合中删除。
- 当副作用函数执行完毕之后重新建立联系
但是问题又来了, 要想将副作用函数从所有与之关联的的依赖集合中移除,就需要明确的知道哪些集合包含了它。因此需要重新设计下副作用函数的收集,注册功能。
来看下具体的实现:
注册副作用函数方法修改
function effect(fn) {
function effectFn() {
// 调用clearUp 方法清除与之关联的
clearUp(effectFn)
acctiveEffect = effectFn
fn()
}
// 用来存储所有与该副作用函数相关联的依赖集合。
effectFn.deps = []
effectFn()
}
收集副作用函数方法修改
function track(target, key) {
if (!acctiveEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, deps = new Set())
}
deps.add(acctiveEffect)
// 收集副作用函数关联的所有集合
acctiveEffect.deps.push(deps)
}
新增清除副作用函数关联的所有集合方法
function clearUp (effectFn) {
for (var i = 0; i < effectFn.deps.length; i++ ) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
这个时候来运行下代码,会发现出现了无限循环, 问题出在哪呢?,主要出在trigger 函数中
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const newEffects = new Set(effects)
newEffects && newEffects.forEach(fn => { // 问题出现在这行代码
fn()
})
}
在trigger 中我们遍历副作用函数集合执行副作用函数,而当副作用函数执行时会调用clearUp 方法,实际上就是从effects结合中将当前执行的副作用函数移除,但是副作用函数的执行又会导致其被重新收集到集合中。而此时effects 还正在进行,所以就导致了无限循环。那怎么解决这个问题呢?
可以声明一个新的集合来进行遍历,来看下具体代码:
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const newEffects = new Set(effects)
newEffects && newEffects.forEach(fn => {
fn()
})
}
现在来看看运行效果,
这个时候就可以看到test 只执行了两次,结果符合预期。
effect 嵌套
我们知道Vue组件有嵌套情况,同样响应式数据一样需要考虑嵌套的情况。但是我们目前实现的响应式数据设计是不支持嵌套的。来试一试
const obj = {
content: 'hello 响应式数据',
title: '响应式标题'
}
const proxy = new Proxy(obj, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
}
})
let activeEffect = null
const effectStack = []
function effect(fn) {
function effectFn() {
clearUp(effectFn)
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
let bucket = new WeakMap()
function track(target, key) {
// activeEffect 直接返回
if (!activeEffect) return target[key]
// 根据target 从bucket中取出depsMap, 它也是一个Map 类型: key: effects
let depsMap = bucket.get(target)
// 如果不存在depsMap,那么新建一个Map 与target 关联
if (!depsMap) {
bucket.set(target, depsMap = new Map())
}
// 如果deps不存在,新建一个Set 与key关联。
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, deps = new Set())
}
// 把当前激活的副作用函数添加到依赖集合deps 中。
deps.add(activeEffect)
// deps 就是一个与当前副作用函数存在联系的依赖集合
activeEffect.deps.push(deps)
}
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
console.log(effects)
const newEffects = new Set(effects)
newEffects && newEffects.forEach(fn => {
fn()
})
}
function clearUp (effectFn) {
for (var i = 0; i < effectFn.deps.length; i++ ) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
effect(() => {
console.log('effect执行')
effect(() => {
console.log('effect2执行')
document.querySelector('#app').innerHTML = proxy.content
})
document.title = proxy.title
})
proxy.title = '新标题'
在上述代码中,我们执行了嵌套的副作用函数。然后在内层副作用函数里面读取了proxy.content的值,在外层副作用函数中读取了proxy.title 的值。然后我们修改了title 的值。理想的情况是内层和外层都会执行两次,因为我们修改的是最外层的,最外层的执行会执行会导致内层的执行。但实际情况并不是这样的。来看看效果
那么是什么原因导致的这种情况的呢?思考一分钟...
问题主要出现在activeEffect上。来回顾下effect函数:
let activeEffect = null
const effectStack = []
function effect(fn) {
function effectFn() {
clearUp(effectFn)
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
我们用了一个全局变量activeEffect 来存储注册的副作用函数,这意味着同一时刻activeEffect所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect 的值。这时候如果再有响应式数据进行依赖收集,即使这个响应式数据是在最外层副作用函数。它收集到的副作用函数也会是内层副作用函数。
为了解决这个问题,我们需要声明一个数组来模拟栈。来看看具体代码。
let activeEffect = null
const effectStack = []
function effect(fn) {
function effectFn() {
clearUp(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
在上述代码中,我们定义了effectStack 数组用来模拟栈,当前副作用函数会被压入栈顶,这样副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的就是内层副作用函数。 来看看执行效果。
可以看到效果符合预期。
解决无限递归循环问题。
现在我们将 obj 数据包改为如下数据
const obj = {
count: 1
}
effect 函数执行
effect(() => {
proxy.count++
})
来运行代码 会看到Uncaught RangeError: Maximum call stack size exceeded 错误,为什么会出现这个错误呢?因为proxy.count++ 这句代码既包含了读取操作又包含了设置操作。该怎么解决呢?我们可以在trigger函数中增加守卫条件。来看看具体代码的实现
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
console.log(effects)
const newEffects = new Set()
effects && effects.forEach(effectFn => {
// 如果trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
if (effectFn !== activeEffect) {
newEffects.add(effectFn)
}
})
newEffects && newEffects.forEach(fn => {
fn()
})
}
调度执行
所谓的调度,指的是当trigger 动作触发副作用函数重新执行时。有能力决定副作用函数执行的时机、次数,以及方式。我们目前实现的响应式系统是没法实现的,他会在trigger 触发时立即执行。那么怎样才能让我们的响应式系统具备可调度性呢?我们可以为effect 设计一个选项参数options,允许用户指定调度器。来看看具体的代码实现。
effect 函数
let acctiveEffect = null
const effectStack = []
function effect(fn, options = {}) {
function effectFn() {
clearUp(effectFn)
acctiveEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
acctiveEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
effectFn.deps = []
effectFn()
}
trigger 函数
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
console.log(effects)
const newEffects = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== acctiveEffect) {
newEffects.add(effectFn)
}
})
newEffects && newEffects.forEach(fn => {
if (fn.options && fn.options.scheduler) {
fn.options.scheduler(fn)
} else {
fn()
}
})
}
现在我们就可以控制trigger 触发时副作用函数的执行时机了。
effect(() => {
console.log('effect执行', proxy.count)
}, {
scheduler (effectFn) {
setTimeout(() => {
effectFn()
}, 0);
}
})
proxy.count++
来看看结果
可以看到我们通过options 传入scheduler 改变了副作用函数的执行时机。
调度器除了可以控制执行顺序外还可以控制控制执行次数。来看个例子:
effect(() => {
console.log('effect执行', proxy.count)
})
proxy.count++
proxy.count++
看下执行结果:
控制台会打印三次。如果我们只关心结果,中间的次数就是多余的。那么我们有没有办法省掉中间的步骤呢?其实也是可以的,基于调度器就可以实现。来看下具体代码实现。
// 定义一个任务队列。
const jobQueue = new Set()
const p = Promise.resolve()
// 代表是否正在刷新的标志。
let isFlushing = false
function flushJop () {
// 如果正在刷新则什么都不做。
if (isFlushing) {
return
}
isFlushing = true
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
effect(() => {
console.log('effect执行', proxy.count)
}, {
scheduler (effectFn) {
jobQueue.add(effectFn)
flushJop()
}
})
来看下效果:
看到结果符合我们的预期。
现在来看看我们响应式设计的完整代码
const obj = {
count: 1
}
const proxy = new Proxy(obj, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
}
})
// 当前激活的副作用函数
let acctiveEffect = null
const effectStack = []
function effect(fn, options) {
function effectFn() {
clearUp(effectFn)
acctiveEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中。
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕之后,将当前副作用函数弹出栈,并把activeEffect还原为之前的值。
effectStack.pop()
acctiveEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
//
effectFn.deps = []
effectFn()
}
let bucket = new WeakMap()
function track(target, key) {
if (!acctiveEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, deps = new Set())
}
deps.add(acctiveEffect)
acctiveEffect.deps.push(deps)
}
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const newEffects = new Set() // 声明新变量时为了避免无限循环
effects && effects.forEach(effectFn => {
if (effectFn !== acctiveEffect) {
newEffects.add(effectFn)
}
})
newEffects && newEffects.forEach(fn => {
if (fn.options && fn.options.scheduler) {
fn.options.scheduler(fn)
} else {
fn()
}
})
}
function clearUp (effectFn) {
for (var i = 0; i < effectFn.deps.length; i++ ) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
// 定义一个任务队列。
const jobQueue = new Set()
const p = Promise.resolve()
// 代表是否正在刷新的标志。
let isFlushing = false
function flushJop () {
// 如果正在刷新则什么都不做。
if (isFlushing) {
return
}
isFlushing = true
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
effect(() => {
console.log('effect执行', proxy.count)
}, {
scheduler (effectFn) {
jobQueue.add(effectFn)
flushJop()
}
})
proxy.count++
proxy.count++
Vue3 响应式数据设计(二)就分享到这里了,感谢收看,一起学习一起进步。