4. 响应系统的作用与实现
4.1 响应式数据和副作用函数
本节理解如下概念:
- 什么是副作用函数?
- 什么是响应式数据
副作用函数:副作用函数执行会对其他函数产生影响,如下例子:
effect函数执行会读取了document.body.innerText并赋值为data.text变量的值。此时再去执行other函数同样会访问document.body.innerText的值。effect函数执行对other函数产生了影响 ,这就是副作用函数
js
let data = {
text: '我是text文本'
}
function effect () {
document.body.innerText = data.text
}
function other () {
console.log(document.body.innerText, 'document.body.innerText');
}
effect()
other()
响应式数据 :以往我理解的概念是数据变化,视图也要发生改变。在这里更进一步就是数据变化,副作用函数也会重新执行。
如下代码,当修改data.text的值后,如果effect函数能够重新执行,data就是响应式数据
diff
let data = {
text: '我是text文本'
}
function effect () {
document.body.innerText = data.text
}
function other () {
console.log(document.body.innerText, 'document.body.innerText');
}
effect()
other()
+data.text = '我是修改后的text文本'
4.2 响应式数据的基本实现
本节目标:实现最基础的一个响应式系统:
- 在effect函数执行时,会触发obj.text字段的读取操作
- 在obj.text值修改时,会触发obj.text字段的设置操作
所以我们能拦截该对象的访问和设置行为,借助代理对象Proxy执行,区别于Vue2的Object.defineProperty()
js
// 拦截的数据
let data = {
text: '我是text文本'
}
// 副作用函数
function effect () {
document.body.innerText = obj.text
}
// 桶,存储副作用函数
const bucket = new Set()
const obj = new Proxy(data, {
get(target, key) {
// 访问数据时,把副作用函数存储到桶里面
bucket.add(effect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
// 修改数据时,把副作用函数从桶里面拿出来执行
bucket.forEach(fn => fn())
// 返回true,表示设置操作成功
return true
}
})
effect()
// 控制台进行如下设置后,页面的数据修改了,表示成功
obj.text = '我是修改后的text文本'
如上代码,我们创建了一个Set数据结构。Proxy构造函数中,参数1是原始数据,参数2是一个对象,对象有get访问器和set设置器两个函数。在访问数据时触发get,把副作用函数添加到桶里面;在设置数据时触发set,把副作用函数从桶里面拿出来执行。
此时如果在控制台修改obj.text的值,会触发副作用函数
如上代码存在问题:副作用函数名称是硬编码,下面我们将会讨论如何执行
4.3.1 设计一个完善的响应式系统
目标:我们希望副作用函数哪怕是一个匿名函数,也能够被正确的收集,本节将实现一个注册副作用函数的机制。
js
let data = {
text: '我是text文本'
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect (fn) {
// 调用注册函数 存储fn给activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
const bucket = new Set()
const obj = new Proxy(data, {
get(target, key) {
// 副作用函数加到桶里面去 => 不依赖具体的函数名
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
// 但无论obj的哪个属性触发,都会导致这里的副作用函数执行
bucket.forEach(fn => fn())
return true
}
})
effect(() => {
console.log('effect runs');
document.body.innerText = obj.text
})
如上代码中,重新定义了effect函数,他变成了用来注册副作用函数的函数。在effect函数中,我们创建了一个activeEffect变量,初始值为undefined,用来存储被注册的副作用函数。
调用effect函数,会把副作用函数存到activeEffect变量中,然后执行fn也就是这个副作用函数,将document.body.innerText值赋值为obj.text。
此时会触发obj.text的get函数,进而将副作用函数收集到桶里面去,此时activeEffect函数里面存的已经是副作用函数了,直接收集,如下图
但是存在问题:如果给一个新的属性进行赋值操作,如下代码,副作用函数在2s后会再执行一次。原因是,设计的代码中,无论读取的是哪个属性,都会把副作用函数收集到桶里面,无论修改的是哪个值,都会把桶里面的副作用函数拿出来再执行一遍。
js
setTimeout(() => {
obj.notExist = '333'
}, 2000)
4.3.2 进一步完善数据结构
如下代码中有三个成员。obj数据对象,text属性,副作用函数effectFn:
js
effect(
function effectFn() {
console.log('effect runs');
document.body.innerText = obj.text
}
)
对应关系
arduino
obj
-text
-effectFn
如果是一个对象的某个属性,对应2个副作用函数
js
effect(
function effectFn1() {
obj.text
}
)
effect(
function effectFn2() {
obj.text
}
)
对应关系
arduino
obj
-text
-effectFn1
-effectFn2
如果是一个副作用函数读取了同一个对象的两个不同的属性
js
effect(
function effectFn1() {
obj.text1
obj.text2
}
)
对应关系
markdown
obj
-text1
-effectFn1
-text2
-effectFn1
使用WeakMap代替Set作为桶的数据结构,改造如下:
js
const bucket = new WeakMap()
const obj = new Proxy(data, {
get(target, key) {
// 没有副作用函数,直接return
if (!activeEffect) {
return target[key]
}
// 根据对象去拿到对应map
let depsMap = bucket.get(target)
// 没有对应desMap则初始化
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 根据key属性去拿到对应Set集合
let deps = depsMap.get(key)
// 没有对应set则初始化
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
let depsMap = bucket.get(target)
if (!depsMap) return
// 根据属性拿到副作用函数集合
let effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
})
如上代码中,WeakMap的target是原始数据对象值,WeakMap的值是Map数据结构,Map数据结构的key是对象的key属性,Map数据结构的value值是Set数据结构,是副作用函数集合。
为什么用WeakMap作为桶的数据结构呢?首先,WeakMap的一大特点是其键是弱引用,一旦别的地方没有引用这个key,则会被垃圾回收机制回收。这个Key是target原始对象,也是响应式数据,一旦没有别的地方使用这个响应式数据,垃圾回收机制就会回收他,能够防止内存溢出。
如下,我们将get和set函数里面的逻辑进一步封装到track和trigger函数中,代码如下:
diff
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数activeEffect添加到存储桶里面
+ track(target, key)
return target[key]
},
set(target, key, newVal) {
// 修改属性值执行对应的副作用函数
+ target[key] = newVal
trigger(target, key)
}
})
let btn1 = document.getElementById('btn1')
let btn2 = document.getElementById('btn2')
effect(() => {
console.log('effect runs');
// 按钮btn1读取obj.a的值,只有当修改obj.a的值时,effect函数才会再次执行
btn1.innerText = obj.a
})
// 在get拦截函数内调用track函数追踪变化
+function track (target, key) {
if (!activeEffect) {
return target[key]
}
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(activeEffect)
}
// 在set拦截函数内调用trigger函数触发变化
+function trigger (target, key) {
let depsMap = bucket.get(target)
if (!depsMap) return
let effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
4.4 分支切换
假设副作用函数如下,当obj.ok为true时,body的innerText值设置为obj.text,否则设置为'no'。这个就是分支切换的表现
diff
let data = {
+ ok: true,
text: 'hello world'
}
effect(() => {
console.log('effect runs');
// 分支切换:obj.ok值为true,执行obj.text;否则都显示no,这个叫分支切换
// 正常来看,入宫obj.ok改为false,那么之后,无论obj.text的值无论如何变,都不应该触发effect副作用函数,目前是会触发,是一种冗余的副作用函数
+ document.body.innerText = obj.ok ? obj.text : 'no'
})
当obj.ok为true,此时副作用函数里面读取了obj.ok和obj.text,那么桶中,副作用函数会被收集到ok和text各自的Map中。我们希望当obj.ok为false时,此时不读取obj.text的值了,那么当修改obj.text的值,副作用函数不执行,但是我们之前的代码不能实现这个效果,obj.text修改后,副作用函数还是会执行,为什么?因为目前代码中,副作用函数一旦收集进去了,就没删掉,
js
obj
text
effectFn ==>这里本来应该要删掉的
ok
effectFn
如何删呢?副作用函数执行时,先把所有与他关联的依赖集合都删掉。副作用函数执行完,会再次触发get里面的track函数,会再次绑定最新的依赖关系。如何找到这个所有与他关联的依赖集合呢?这个依赖集合就在track函数中,就是下面的deps
diff
function track (target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
+ 就是这个deps
+ let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 把当前激活的副作用函数添加到依赖集合deps中
deps.add(activeEffect)
}
改造effect函数,里面增加effectFn函数,并给这个函数增加了deps属性存储依赖集合列表。注意,下面的fn才是真正的副作用函数,effectFn是我们进行包装的副作用函数
diff
function effect (fn) {
// effectFn执行时,将其设置为当前激活的副作用函数
+ const effectFn = () => {
+ // 调用 cleanup 函数完成清除工作
+ cleanup(effectFn)
activeEffect = effectFn
+ fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
+ effectFn.deps = []
+ effectFn()
}
track依赖函数中,把依赖集合push到activeEffect.deps中
diff
function track (target, key) {
if (!activeEffect) 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中
deps.add(activeEffect)
+ activeEffect.deps.push(deps)
}
注意,cleanUp函数首先,遍历effectFn.deps里面的所有依赖集合,把集合里面的对应的effectFn给删掉,再把effectFn.deps数组给置空。
js
function cleanup (effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
// 最后需要重置effectFn.deps的数组
effectFn.deps.length = 0
}
这里我产生了一些疑问:为什么不直接effectFn.deps.length = 0就可以了,还要进行遍历呢?
我理解的是,这里的deps.delete(effectFn),注意deps数据来自effectFn.deps[i],而这个effect.deps数据是在track里面从桶里面拿出来的,所以这一步是在清空桶里面的数据。
effectFn.deps.length = 0这个是在操作副作用函数自己身上的deps属性。当桶数据处理好后,这里的留着就没啥用了
如下图:
此时运行代码,能够避免副作用函数遗留的问题,但是目前会存在无限循环执行的问题,原因是先执行cleanup函数清空了桶里面的副作用函数,把set里面的清空后,又执行副作用函数,又给桶里面的set数据加回去了,此时forEach遍历Set还没完,又有新的数据,立马又执行了一遍:
示例: 这样的也会造成无限递归
js
let set = new Set()
set.add(1)
set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中');
debugger
})
解决方案:以旧Set为数据源,创建新的newSet数据结构,遍历这个newSet
js
let set = new Set()
set.add(1)
const newSet = new Set([...set])
newSet.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中set');
})
如下:
diff
function trigger (target, key) {
let depsMap = bucket.get(target)
if (!depsMap) return
let effects = depsMap.get(key)
+ const effectsToRun = new Set(effects)
// 防止无限递归;fn执行时会清空依赖,再把依赖加进去,这样就只会遍历effectsToRun的依赖,而effects的就删掉了
// effectsToRun && effectsToRun.forEach(fn => fn())
+ effectsToRun && effectsToRun.forEach(fn => {
console.log(effects, 'effects');
fn()
})
}
4.5 嵌套的effect和effect栈
实际开发中存在许多嵌套的场景,比如组件如下:
js
// Bar组件
const Bar = {
render () {}
}
// Foo组件里面嵌套Bar
const Foo = {
render () {
return Bar
}
}
我们之前的设计无法支持effect嵌套:
js
let temp1, temp2
effect(
function effectFn1 () {
console.log('effectFn1执行了');
effect(
function effectFn2 () {
console.log('effectFn2执行了');
temp2 = obj.bar
}
)
temp1 = obj.foo
}
)
如上代码,在effectFn1函数
里嵌套了effectFn2
函数,effectFn2函数
里面读取了obj.bar
的值, effectFn2
执行后紧接着,effectFn1
会去读取obj.foo
值,我们希望当修改obj.foo
值时,触发effectFn1函数并间接触发effectFn2 ;而当修改obj.bar值时,只触发effectFn2函数。
当我们修改obj.foo值时:
js
obj.foo = 'change'
打印如下,红色方框的地方只打印了effectFn2,按理来说应该先打印effectFn1,然后再打印effectFn2。
原因如下:
js
effect(
function effectFn1 () {
console.log('effectFn1执行了');
effect(
function effectFn2 () {
debugger
console.log('effectFn2执行了');
temp2 = obj.bar
}
)
temp1 = obj.foo
}
)
js
function effect (fn) {
// effectFn执行时,将其设置为当前激活的副作用函数
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
如上代码执行过程中:
- effect函数执行:传入effectFn1到effect函数
- effect函数:
activeEffect = effectFn
这步是把当前函数赋值给全局变量activeEffect,此时是effectFn1对应的函数
- fn()执行:因为effectFn2嵌套在effectFn1中,所以effectFn2传递到effect函数中去执行
- effect函数执行:此时就把effectFn2对应的effectFn赋值给activeEffect全局变量,执行fn,读取obj.bar值
- 回到effectFn1函数:此时去读取obj.foo值,在get里面收集依赖时,
deps.add(activeEffect)
这句代码把依赖存到桶里面去,activeEffect里面是effectFn2对应的副作用函数
,所以访问obj.foo执行的是effectFn2对应的副作用函数
修改方案如下:
diff
let activeEffect;
// 增加`effectStack`数组
+let effectStack = []
function effect (fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
// 将当前effectFn函数放到effectStack栈中
+ effectStack.push(effectFn)
fn()
// 将当前副作用函数从栈中弹出
+ effectStack.pop()
// 将activeEffect还原为之前的值
+ activeEffect = effectStack[effectStack.length - 1]
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
如上代码中:
- 当effectFn1函数执行时,会把effectFn1对应的副作用函数压到栈顶。
- 紧接着effectFn2函数执行时,把effectFn2的副作用函数压到栈顶,读取bar,此时activeEffect就是effectFn2对应的副作用函数,存储到桶中。
- 当effectFn2函数执行完毕,此时会把该函数从栈顶弹出,activeEffect重新赋值为effectFn1对应的副作用函数。
- 然后去读取obj.foo,会把其对应的副作用函数存到桶里面去。这样就不会发生互相嵌套影响了
这个时候修改obj.foo打印的就是正确的:
代码执行图示如下:
4.6 如何避免无限递归
如下执行,会导致栈溢出
js
effect(
function effectFn1 () {
// 这样执行会造成栈溢出
obj.foo = obj.foo + 1
}
)
原因是:该副作用函数执行,会执行track里面的读取操作,读取obj.foo的值,还会对obj.foo进行赋值,触发trigger,在trigger里面会把副作用函数拿出来再执行。但是当前副作用函数还没执行完毕呢,又执行一次副作用函数,就会造成无限递归
如下代码中,增加判断,如果要执行的副作用函数和activeEffect是一个函数,则不执行。
diff
function trigger (target, key) {
let depsMap = bucket.get(target)
if (!depsMap) return
let effects = depsMap.get(key)
const effectsToRun = new Set(effects)
// 防止无限递归;fn执行时会清空依赖,再把依赖加进去,这样就只会遍历effectsToRun的依赖,而effects的就删掉了
// effectsToRun && effectsToRun.forEach(fn => fn())
effectsToRun && effectsToRun.forEach(fn => {
+ if (fn === activeEffect) {
return
}
fn()
})
}
4.7.1 调度执行-时机
可调度性是响应式系统非常重要的特性。可调度性是指,trigger函数触发执行副作用函数时,我们有能力决定副作用函数执行的时机、次数以及方式。
如下代码中:
js
effect(() => {
console.log(obj.foo, 'obj.foo');
})
obj.foo++
console.log('结束了');
打印结果是:
如果我们希望打印的结果是:
js
1
'结束了'
2
改成这样就能实现
js
effect(() => {
console.log(obj.foo, 'obj.foo');
})
console.log('结束了');
obj.foo++
但是如果我们希望不改变代码呢,就需要让这个响应系统支持调度:
如下给effect函数传入一个调度器:
diff
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
{
// scheduler调度器函数
+ scheduler (fn) {
}
}
)
将options挂载到对应的副作用函数上:
diff
+function effect (fn, options) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
debugger
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
debugger
// 把options挂载到effectFn上
+ effectFn.options = options
effectFn.deps = []
effectFn()
}
在修改obj.foo++时,会触发trigger函数,在这里我们就要判断是否传递了调度器函数
diff
function trigger (target, key) {
let depsMap = bucket.get(target)
if (!depsMap) return
let effects = depsMap.get(key)
const effectsToRun = new Set(effects)
debugger
effectsToRun && effectsToRun.forEach(effectFn => {
debugger
// 如果有调度器配置则执行
+ if (effectFn.options.scheduler) {
+ effectFn.options.scheduler(effectFn)
} else {
// 若没有,则还是像原来一样直接执行副作用函数
effectFn()
}
})
}
在调度器函数里面,我们把fn传递到setTimeout函数里面,开启宏任务执行,这样obj.foo执行的时机就会延迟
diff
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
{
// scheduler调度器函数
scheduler (fn) {
+ setTimeout(fn)
}
}
)
4.7.2 调度执行的次数
学会这节就能理解,为什么vue当中对响应式数据连续做多次的更新操作,但是最终只会触发一次更新。
如下代码中,对obj.foo执行两次自增操作,控制台会打印 1 2 3
js
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
)
obj.foo++
obj.foo++
我们希望,自增2次,但是控制会只会打印1和3,中间不管执行了几次都不管.
如下
js
// 定义一个任务队列
const jobQuene = new Set()
// 使用Promise.resolve()创建一个promise实例,借助他,将一个任务添加到微任务队列
const p = Promise.resolve()
// 标志,是否正在刷新队列
let isFlushing = false
// 刷新函数
function flushJob () {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return
// 设置为true,代表正在刷新
isFlushing = true
// 在微任务队列中刷新jobQuene队列
p.then(() => {
jobQuene.forEach(job => {
job()
})
}).finally(() => {
isFlushing = false
})
}
在副作用函数中,增加如下代码
js
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
{
scheduler (fn) {
// 每次调度时,将副作用函数添加到队列中
jobQuene.add(fn)
// 调用flushJob刷新队列
flushJob()
}
}
)
首先,在上面的调度器里面,我们定义了一个jobQuene,其数据结构是Set,因为Set是有自动去重能力的,如果添加了重复的副作用函数进去,会无效。紧接着,调用了flushJob函数。
来到flushJob函数中,只有当isFlushing是false才会执行,通过p.then(),在一个微任务队列里面去遍历执行jobQuene里面的副作用函数。
当第一次obj.foo第一次自增时,就会把他对应副作用函数添加到jobQuene里面去,并且开始执行flushJob函数,当第二次obj.foo自增时,由于此时jobQuene里面已经添加过一次set函数了,所以再添加即无效。而执行flushJob函数时,也会直接return。在所有同步代码执行完毕来到p.then里面的微任务,执行jobQuene里面的函数,执行完毕后来到finally里面,把isFlushing改为false
最终打印的效果如下:
4.8 计算属性与lazy
通过之前的知识点,我们能够实现Vue.js中非常重要且有特色的能力--计算属性。
先介绍懒执行的effect,即lazy的effect。我们实现的effect函数会立即执行,如下:
js
effect(
() => {
console.log(obj.foo, 'obj.foo');
}
)
但是某些场景,我们不希望他立即执行,希望在需要的时候才执行,例如计算属性。如下我们在options选项中,增加了lazy配置项为true:
js
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
// options
{
lazy: true
}
)
需要对effect函数增加代码,判断options选项中,lazy属性如果为false,才会立即执行effectFn函数。并且这里我们还增加了代码用来返回effectFn
diff
function effect (fn, options) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 把options挂载到effectFn上
effectFn.options = options
effectFn.deps = []
+ if (!options.lazy) {
+ effectFn()
+ }
+ return effectFn
}
如果不立即执行,应该何时执行?effect函数返回了一个函数,我们能够拿到他,手动执行。
js
const effectFn = effect(
() => {
console.log(obj.foo, 'obj.foo');
},
// options
{
lazy: true
}
)
// 手动执行这个函数
effectFn()
我们希望不仅仅是手动执行,还希望能够拿到返回值。如下代码中, 通过() => obj.foo + obj.bar
我们能够拿到返回值。
js
const effectFn = effect(
() => obj.foo + obj.bar,
// options
{
lazy: true
}
)
const value = effectFn()
需要对effect函数本身做修改,因为fn函数
才是真正 的副作用函数,也就是() => obj.foo + obj.bar
,effectFn是我们包装过后的副作用函数,接受fn()的返回值,并且在最后return。这样上面就能够拿到返回值了
diff
function effect (fn, options) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
+ const res = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
+ return res
}
// 把options挂载到effectFn上
effectFn.options = options
effectFn.deps = []
if (!options.lazy) {
effectFn()
}
return effectFn
}
如下能够拿到返回的的value值:
js
const effectFn = effect(
() => obj.foo + obj.bar,
// options
{
lazy: true
}
)
const value = effectFn()
console.log(value, 'value'); // 3
如下实现一个computed函数:
js
function computed (getter) {
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value () {
return effectFn()
}
}
return obj
}
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value, 'sumRes.value');
给computed函数传入getter,其实就是暂的副作用函数,然后将这个getter传递到effect函数中,给effect函数增加lazy的选项,得到effectFn函数,他是我们包装后的副作用函数。
我们再声明一个obj对象,他有一个value访问器属性,返回的是effectFn的执行结果,当我们访问这个对象时,就会调用effectFn函数,得到副作用函数的返回值
js
let data = {
foo: 1,
bar: 2
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value, 'sumRes.value'); // 3
如上,能够拿到返回值是3。但是当前我们访问多次sumRes.value,就会执行多次,即便obj.foo和obj.bar的值没有变化也会重复计算。我们希望能够避免这样的重复计算,修改如下:
diff
function computed (getter) {
// 用来缓存上一次计算的值
+ let value;
// dirty标志
+ let dirty = true
const effectFn = effect(getter, {
})
const obj = {
get value () {
// 只有dirty为脏时才计算值,并将得到的值缓存在value中
+ if (dirty) {
console.log('get执行了');
value = effectFn()
// 将dirty值设置为false,下一次访问只用缓存中的值
+ dirty = false
}
return value
}
}
return obj
}
如上代码中,我们增加变量dirty,只有为true时,可以去执行get里面的effectFn()逻辑。增加了变量value,在第一次就赋值为effectFn()的返回值,之后立刻将dirty改为false。这样多次访问其value属性都只会有第一次调用。
如上图可以看到,get里面的执行逻辑就只打印了一次。
但是还存在问题,dirty不能一直为false。因为当obj.foo和obj.bar变化时,我们希望能够重新执行get value () {}访问器函数里面的逻辑。修改如下:
diff
function computed (getter) {
// 用来缓存上一次计算的值
let value;
// dirty标志
let dirty = true
const effectFn = effect(getter, {
lazy: true,
+ scheduler () {
+ dirty = true
+ }
})
const obj = {
get value () {
// 只有dirty为脏时才计算值,并将得到的值缓存在value中
if (dirty) {
console.log('get执行了');
value = effectFn()
// 将dirty值设置为false,下一次访问只用缓存中的值
dirty = false
}
return value
}
}
return obj
}
我们在代码中,给effect函数的执行增加了一个调度器函数,这样当obj.foo和obj.bar修改时,触发trigger函数,就会把副作用函数拿出来执行,并执行调度器函数,这样dirty就会改为true,下次访问sumRes.value就会执行effectFn了。
如下在控制台调试,修改obj.foo为3,再去访问sumRes.value,拿到的就是5
如果还在另一个effect函数里面读取了计算属性的值,如下:
diff
const sumRes = computed(() => obj.foo + obj.bar)
+effect(() => {
+ console.log(sumRes.value, 'someRes.value112');
+})
如上代码,新增对effect函数的调用,里面访问sumRes值,希望当obj.foo变化时,effect函数能够重新执行。就像在Vue的模版中读取计算属性值,当其值发生变化时,能重新渲染模版。
在控制台执行obj.foo++,发现并不会有新的打印值:
我们之前讲过嵌套的effect栈,那种情况是effectFn1里面嵌套了effectFn2函数,effectFn2函数会把effectFn1给覆盖了,activeEffect变量里面始终存的是effectFn2对应的副作用函数。
这里的嵌套是指,在一个副作用函数里面访问sumRes.value值,访问时主动触发computed里面的getter函数,这时obj.foo和obj.bar会把computed内部的effect收集到依赖集合。而外层的这个effect函数不会被收集进去。 此时的执行顺序:
第一,访问sumRes.value,打印这句
第二,访问getter,触发effect
第三步,读取obj.foo和obj.bar,依次给他们收集依赖函数,依赖函数就是() => obj.foo + obj.bar对应的副作用函数
如何解决呢?
diff
function computed (getter) {
// 用来缓存上一次计算的值
let value;
// dirty标志
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler () {
dirty = true
// 这里是个闭包,所以能够访问到obj变量
+ trigger(obj, 'value')
}
})
const obj = {
get value () {
// 只有dirty为脏时才计算值,并将得到的值缓存在value中
if (dirty) {
console.log('get执行了');
value = effectFn()
// 将dirty值设置为false,下一次访问只用缓存中的值
dirty = false
}
+ track(obj, 'value')
return value
}
}
return obj
}
如上代码,当访问计算属性返回值时,执行track(obj, 'value'),把计算属性的返回值obj作为一个对象传递进去,track函数对obj对象的value属性进行追踪,把外层副作用函数添加到其value属性对应的副作用函数集合中。当修改obj.foo或者obj.bar属性时,执行调度器scheduler,触发trigger函数,把obj里面的value属性对应的副作用函数拿出来挨个执行一遍。这样就实现了我们的需求了
4.9 watch侦听器实现原理
利用之前的effect函数和scheduler调度器, 能够实现最基本的watch侦听器函数
js
// 最简单的watch函数实现
function watch (source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
// 当数据变化时,调用回调函数
scheduler () {
cb()
}
}
)
}
上面代码,执行effect函数,第一个参数是一个回调函数读取source.foo的值,第二个参数传入一个调度器scheduler,并在里面执行cb回调函数。注意,如果传了调度器scheduler,就不会立即执行副作用函数,还是会执行调度器函数里面的内容。
js
watch(obj, () => {
console.log('数据变化了');
})
在控制台给obj.foo++,打印如下:
但是现在我们硬编码了source.foo的数据,只能侦听foo属性。现在来封装一个可以监听对象所有属性的方法
js
// 最简单的watch函数实现
function watch (source, cb) {
effect(
// 直接调用traverse函数,传入source对象
() => traverse(source),
{
// 当数据变化时,调用回调函数
scheduler () {
cb()
}
}
)
}
// 递归遍历传入对象的所有属性
function traverse (value, seen = new Set()) {
// 如果数据是原始数据类型 或者已经侦听过,则return,防止死循环
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 将数据添加到seen里面
seen.add(value)
// 暂不考虑数组等其他数据结构,假设就是对象
for (const key in value) {
traverse(value[key], seen)
}
return value
}
当我把数据结构改为
js
let data = {
foo: 1,
bar: 2,
b: 3
}
如下,我们在控制台调试,修改obj的其他属性也能够触发watch的回调
只是现在数据结构如果是嵌套的比如 let data = {b: {bb: 1}}这样里面的bb还不能被监听到。这个我们后面再实现
我们现在来解决另一个问题,如果侦听器第一个参数传入的是一个回调函数怎么办
js
watch(() => obj.foo, () => {
console.log('数据变化了');
})
修改如下:
diff
// 支持自定义函数
function watch (source, cb) {
+ let getter
+ // 如果是函数,直接赋值给getter
+ if (typeof source === 'function') {
+ getter = source
+ } else {
+ // 如果不是函数,则还是原来的逻辑
+ getter = () => traverse(source)
+ }
+ effect(
+ () => getter(),
{
// 当数据变化时,调用回调函数
scheduler () {
cb()
}
}
)
}
js
watch(() => obj.bar, () => {
console.log('数据变化了');
})
如上我们增加了getter变量,如果传入source是一个函数,则直接赋值给getter,如果不是,则还是原来的逻辑。并对obj.bar属性进行侦听,如果是修改obj.foo,则不能执行回调函数。控制台打印结果如下:
现在还有一个问题,就是无法获取到新值和旧值,我们使用watch侦听器的时候,第二个回调函数可以接受newVal和oldVal的
js
watch(() => obj.bar, (newVal, oldVal) => {
console.log('数据变化了');
})
修改如下:
diff
// 支持自定义函数
function watch (source, cb) {
let getter
+ let newVal, oldVal
// 如果是函数,直接赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 如果不是函数,则还是原来的逻辑
getter = () => traverse(source)
}
const effectFn = effect(
() => getter(),
{
// 当数据变化时,调用回调函数
scheduler () {
// 在effectFn中重新执行副作用函数,得到的是新值
+ newVal = effectFn()
// 把旧值和新值传递给回调函数的参数
+ cb(newVal, oldVal)
// 将新值赋值给旧值,不然下次拿到的是错误的值
+ oldVal = newVal
}
}
)
// 手动调用副作用函数,拿到旧值
+ oldVal = effectFn()
}
如上,在watch第一次执行时,会执行effectFn函数并拿到oldVal值,在后续修改数据后触发scheduler调度器,里面会再次调用effectFn,这样能拿到最新的值,然后把newVal和oldVal传递给cb回调函数,再将newVal赋值给oldVal,不然下次oldVal值就还是初始值是错的。
现在再来调用,能拿到newVal和oldVal
js
watch(() => obj.bar, (newVal, oldVal) => {
console.log(newVal, oldVal, '数据变化了');
})
4.10 立即执行的watch和回调函数执行的时机
在使用Vue的Watch侦听器时,支持传入{immediate: true}参数,这样在watch创建时,立即执行一次:
js
watch(
() => obj.bar,
(newVal, oldVal) => {
console.log(newVal, oldVal, 'newVal, oldVal数据变化了');
},
{
immediate: true
}
)
实现如下:
diff
// 支持自定义函数
function watch (source, cb, options = {}) {
let getter
// 如果是函数,直接赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 如果不是函数,则还是原来的逻辑
getter = () => traverse(source)
}
const effectFn = effect(
() => getter(),
{
// 当数据变化时,调用回调函数
+ scheduler: job
}
)
let newVal, oldVal
+ const job = function () {
+ // 在effectFn中重新执行副作用函数,得到的是新值
+ newVal = effectFn()
+ // 把旧值和新值传递给回调函数的参数
+ cb(newVal, oldVal)
+ // 将新值赋值给旧值,不然下次拿到的是错误的值
+ oldVal = newVal
+ }
+ if (options.immediate) {
+ job()
} else {
// 手动调用副作用函数,拿到旧值
oldVal = effectFn()
}
}
如上,将调度器里面的新旧赋值操作提取为job函数,赋值给给到scheduler。然后在watch初始化执行时,判断是否传递了immediate: true
的选项,如果有,则立即执行一次job,否则还是之前的逻辑。
如下,页面初始化就执行了一次watch,其中oldVal是undefined也和Vue的watch行为一致
在Vue3中还可以使用flush选项,值为post,表示调度函数需要放到微任务队列中,等DOM更新后再执行
js
watch(() => obj.bar, (newVal, oldVal) => {
console.log(newVal, oldVal, 'newVal, oldVal数据变化了');
}, {
immediate: true,
flush: 'post'
})
修改如下:
diff
// 支持自定义函数
function watch (source, cb, options = {}) {
let getter
// 如果是函数,直接赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 如果不是函数,则还是原来的逻辑
getter = () => traverse(source)
}
const job = function () {
// 在effectFn中重新执行副作用函数,得到的是新值
newVal = effectFn()
// 把旧值和新值传递给回调函数的参数
cb(newVal, oldVal)
// 将新值赋值给旧值,不然下次拿到的是错误的值
oldVal = newVal
}
const effectFn = effect(
() => getter(),
{
// 当数据变化时,调用回调函数
+ scheduler () {
+ if (options.flush === 'post') {
+ const p = Promise.resolve()
+ p.then(job)
+ console.log('执行了一次这里');
+ } else {
+ job()
+ }
}
}
)
let newVal, oldVal
if (options.immediate) {
job()
} else {
// 手动调用副作用函数,拿到旧值
oldVal = effectFn()
}
}
如上修改了调度函数,如果传递了flush的值为post,则将job函数放到.then里面去执行,加入微任务队列。我们查看浏览器打印结果, 发现该语句console.log('执行了一次这里');
先执行,然后再是watch的第二个回调函数,证明实现了延迟执行
如果没有post的值,则本质上和flush的sync的值一样。如果flush的值是pre,代表是组件更新前。
4.11 过期的副作用
如上封装的watch函数还是存在竞态问题, 看如下例子:
js
let finalData;
watch(() => obj.bar, async (newVal, oldVal) => {
const res = await request({url: '/path/to/request'})
finalData = res
})
使用watch监听obj.bar属性,一旦发生变化,会触发回调函数去调用接口拿数据。注意,如果我们连续修改了obj.bar两次,那么回调函数会连续发两次,接口会连续触发第一次和第二次,但是有可能第一次接口返回的数据比第二次要晚,造成finalData存储的是第一次的数据,如下图。
Vue.js中的watch函数是如何解决这个问题的?
js
watch(() => obj.bar, async (newVal, oldVal, onInvalidate) => {
let expired = false
onInvalidate(() => {
expired = true
})
const res = await request({url: '/path/to/request'})
if (!expired) {
finalData = res
}
})
如上代码,Vue.js中的回调函数有第三个参数onInvalidate,他是一个函数,可以传入一个回调函数进去,该回调会在当前副作用函数过期时执行
js
watch(() => obj.bar, async (newVal, oldVal, onInvalidate) => {
// 定义一个标志,判断当前副作用函数是否过期,默认为false-没过期
let expired = false
onInvalidate(() => {
// 调用onInvalidate函数,注册一个回调,当前副作用函数过期则将exipred改为true
expired = true
})
const res = await request({url: '/path/to/request'})
// 只有没有过期才能进行赋值
if (!expired) {
finalData = res
}
})
我们如何去模拟这个功能?实现如下:
diff
function watch (source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let newVal, oldVal
+ let cleanup // 存储用户注册过的过期回调
+ function onInvalidate (fn) {
+ // 过期回调存储在cleanup中
+ cleanup = fn
+ }
const job = function () {
newVal = effectFn()
+ // 调用回调函数cb之前,先调用过期回调
+ if (cleanup) {
+ cleanup()
+ }
+ // 将onInvalidate作为回调函数的第三个参数,用户可以调用
+ cb(newVal, oldVal, onInvalidate)
oldVal = newVal
}
const effectFn = effect(
() => getter(),
{
scheduler () {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
// 手动调用副作用函数,拿到旧值
oldVal = effectFn()
}
}
如上代码,在watch函数中,先定义一个cleanup变量,存储用户注册的过期回调,然后定义一个onInvalidate方法,接受一个过期回调函数,并赋值给cleanup变量,
在job函数里,在调用cb函数之前,先调用过期函数,然后把onInvalidate作为回调的第三个参数传出去给用户使用
我们这样来模拟这个功能:
diff
watch(() => obj.bar, async (newVal, oldVal, onInvalidate) => {
// 定义一个标志,判断当前副作用函数是否过期,默认为false-没过期
let expired = false
onInvalidate(() => {
// 调用onInvalidate函数,注册一个回调,当前副作用函数过期则将exipred改为true
expired = true
})
+ const res = await new Promise((resolve, reject) => {
+ setTimeout(() => {
+ resolve(newVal * 2)
+ }, 1000)
+ })
// 只有没有过期才能进行赋值
+ if (!expired) {
+ finalData = res
+ console.log(`Updated value: ${res}`);
+ } else {
+ console.log('The effect was invalidated');
+ }
})
+setTimeout(() => {
+ obj.bar++
+}, 200)
+setTimeout(() => {
+ obj.bar++
+}, 600)
如上代码中,我们在200ms和400ms后分别对obj.bar进行了自增操作。而watch函数里面的结果要等到1s之后才会返回。如果过期了则打印'The effect was invalidated'
,否则打印Updated value: ${res}
控制台打印结果如下,先打印invalidated,紧接着才是Updated