1 响应式数据和副作用函数
副作用函数:
传统副作用函数定义:某个函数执行,会对其他函数或者变量产生影响,这就是副作用函数。例如你在这个函数里面修改了全局变量,会对其他使用这个变量的函数产生影响Vue3中的副作用函数定义:- 首先是该函数访问
响应式数据, - 其次是被响应式API如
effect computed watch中的一个包装,他就是副作用函数,也是依赖。响应式数据变化,关联的副作用函数会重新执行。 - Vue3中扩展了
副作用函数的概念,这个函数即便仅仅是访问响应式数据,没有执行修改的操作,但被包装注册后也成了副作用函数。
- 首先是该函数访问
例子:
如下effect函数执行会读取document.body.innerText并赋值为data.text变量的值,会对other函数的执行产生影响,effect函数就是传统意义上的副作用函数,other不算作副作用函数
let data = {
text: '我是text文本'
}
function effect () {
document.body.innerText = data.text
}
function other () {
console.log(document.body.innerText, 'document.body.innerText');
}
effect()
other()
响应式数据:数据变化,视图也要发生改变。在Vue3中,数据变化,会把所有与之关联的副作用函数拿出来重新执行一遍。
例子:
如下代码,当修改data.text的值后,如果effect函数能够重新执行,我们把effect函数的执行类比于DOM的更新,那么data就是响应式数据
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文本'
2. 响应式系统的基本实现
实现最基础的响应式系统:
- 在effect函数执行时,会触发obj.text字段的读取操作
- 在obj.text值修改时,会触发obj.text字段的设置操作
借助代理对象Proxy执行拦截该对象的访问和设置行为
// 拦截的数据
let data = {
text: '我是text文本'
}
// 副作用函数
function effect () {
document.body.innerText = obj.text
}
// 利用Set桶,存储副作用函数
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文本'
此时如果在控制台修改obj.text的值,会触发副作用函数更新DOM的值
3 设计一个完善的响应式系统
目标:我们希望副作用函数哪怕是一个匿名函数,也能够被正确的收集,本节将实现一个注册副作用函数的机制。
let data = {
text: '我是text文本'
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect函数改造为包装函数
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变量存储被注册的副作用函数。
调用effect函数,会把副作用函数存到activeEffect变量中,然后执行fn也就是这个副作用函数,将document.body.innerText值赋值为obj.text。
此时会触发obj.text的get函数,进而将副作用函数收集到桶里面去,此时activeEffect函数里面存的已经是副作用函数了,直接收集,如下图
但是存在问题:如果给一个新的不存在的属性进行赋值操作,副作用函数依然会执行。
原因是,设计的代码中,无论读取的是哪个属性,都会把副作用函数收集到桶里面,无论修改的是哪个值,都会把桶里面的副作用函数拿出来再执行一遍。
4. 完善响应式系统
使用WeakMap代替Set作为桶的数据结构,改造如下:
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的key来存储target原始数据对象值,WeakMap的值是Map数据类型,而Map的key是对象的key属性,Map的value值是Set数据结构,是副作用函数集合,也就是这个key对应的所有副作用函数。
为什么用WeakMap作为桶的数据结构呢?首先,WeakMap的一大特点是其键是弱引用,一旦别的地方没有引用这个key,则会被垃圾回收机制回收。这个Key是target原始对象,也是响应式数据,一旦没有别的地方使用这个响应式数据,垃圾回收机制就会回收他,能够防止内存溢出。
如下,我们将get和set函数里面的逻辑进一步封装到track和trigger函数中,代码如下:
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())
}
这样设计的原因是为了对象的每个key都有自己的副作用函数集合,见下面的示例:
如下代码中有三个成员。obj数据对象,text属性,副作用函数effectFn:
effect(
function effectFn() {
console.log('effect runs');
document.body.innerText = obj.text
}
)
对应关系
obj
-text
-effectFn
如果是一个对象的某个属性,对应2个副作用函数
effect(
function effectFn1() {
obj.text
}
)
effect(
function effectFn2() {
obj.text
}
)
对应关系
obj
-text
-effectFn1
-effectFn2
如果是一个副作用函数读取了同一个对象的两个不同的属性
effect(
function effectFn1() {
obj.text1
obj.text2
}
)
对应关系
obj
-text1
-effectFn1
-text2
-effectFn1
5. 分支切换
分支切换的定义:分支切换可以理解为条件判断。当某个副作用函数访问了响应式数据A,但是因为存在条件判断后可能就不访问A,此时无论A数据如何变化,都不应该触发这个依赖/副作用函数
例子:
假设副作用函数如下,当obj.ok为true时,body的innerText值设置为obj.text,否则设置为'no'。这个就是分支切换的表现。当obj.ok为false时,无论obj.text如何变化,都不能执行下面这个副作用函数。但是目前还是会执行,原因就是目前代码中,副作用函数一旦收集进去了,就没删掉
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
text
effectFn ==>这里本来应该要删掉的
ok
effectFn
需要改造effect函数,里面增加effectFn函数,并给这个函数增加了deps属性存储依赖集合列表。下面的fn才是真正的副作用函数,effectFn是我们进行包装的副作用函数
function effect (fn) {
// effectFn执行时,将其设置为当前激活的副作用函数
+ const effectFn = () => {
+ // 调用 cleanup 函数完成清除工作
+ cleanup(effectFn)
activeEffect = effectFn
+ fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
+ effectFn.deps = []
+ effectFn()
}
track依赖函数中,把依赖集合push到activeEffect.deps中
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数组给置空。
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里面从桶里面拿出来的,所以这一步是在清空桶里面的数据。
如下图:
此时运行代码,能够避免副作用函数遗留的问题。
6. 避免Set无限递归
目前会存在无限循环执行的问题,原因是先执行cleanup函数清空了桶里面的副作用函数,把set里面的清空后,又执行副作用函数,又给桶里面的set数据加回去了,此时forEach遍历Set还没执行完,又有新的数据,立马又执行了一遍:
示例: 这样的也会造成无限递归
let set = new Set()
set.add(1)
set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中');
debugger
})
解决方案:以旧Set为数据源,创建新的newSet数据结构,遍历这个newSet
let set = new Set()
set.add(1)
const newSet = new Set([...set])
newSet.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中set');
})
如下:
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()
})
}
7. 嵌套的effect和effect栈
实际开发中存在许多嵌套的场景,比如组件如下:
// Bar组件
const Bar = {
render () {}
}
// Foo组件里面嵌套Bar
const Foo = {
render () {
return Bar
}
}
我们之前的设计无法支持effect嵌套:
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值时:
obj.foo = 'change'
打印如下,红色方框的地方只打印了effectFn2,按理来说应该先打印effectFn1,然后再打印effectFn2。
原因如下:
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对应的副作用函数
修改方案如下:
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打印的就是正确的:
代码执行图示如下:
8 避免栈溢出
如下执行,会导致栈溢出
effect(
function effectFn1 () {
// 这样执行会造成栈溢出
obj.foo = obj.foo + 1
}
)
原因是:该副作用函数执行,会执行track里面的读取操作,读取obj.foo的值,还会对obj.foo进行赋值,触发trigger,在trigger里面会把副作用函数拿出来再执行。但是当前副作用函数还没执行完毕呢,又执行一次副作用函数,就会造成无限递归
如下代码中,增加判断,如果要执行的副作用函数和activeEffect是一个函数,则不执行。
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()
})
}
9. 计算属性
9.1 计算属性简洁版
- 计算属性定义:计算属性的值
依赖其他的响应式数据,当依赖的数据值更新时,计算属性会重新计算,计算后会自动进行缓存 - 计算属性的实现依赖
响应式数据和副作用函数3.lazy:内部声明了一个getter访问器,值是value,当访问计算属性.value时才会执行副作用函数 dirty:通过dirty变量控制缓存,只有为true时才能执行副作用函数重新计算,执行完毕后立刻改为false;当依赖的数据变化时会触发Proxy的setter拦截器,进行将dirty改为true,此时就会再次计算
function computed (getter) {
// 用来缓存上一次计算的值
let value;
// dirty标志
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler () {
dirty = true
// 这里是个闭包,所以能够访问到computed函数作用域内所有变量
trigger(obj, 'value')
}
})
const obj = {
get value () {
// 只有dirty为脏时才计算值,并将得到的值缓存在value中
if (dirty) {
console.log('get执行了');
value = effectFn()
// 将dirty值设置为false,下一次访问只用缓存中的值
dirty = false
}
console.log(obj, 'obj');
track(obj, 'value')
return value
}
}
return obj
}
9.2 调度执行的时机
可调度性是响应式系统非常重要的特性。可调度性是指,trigger函数触发执行副作用函数时,我们有能力决定副作用函数执行的时机、次数以及方式。
例子:
effect(() => {
console.log(obj.foo, 'obj.foo');
})
obj.foo++
console.log('结束了');
打印结果是:
如果我们希望打印的结果是:
1
'结束了'
2
改成这样就能实现
effect(() => {
console.log(obj.foo, 'obj.foo');
})
console.log('结束了');
obj.foo++
但是如果我们希望不改变代码呢,就需要让这个响应系统支持调度:
如下给effect函数传入一个调度器:
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
{
// scheduler调度器函数
+ scheduler (fn) {
}
}
)
将options挂载到对应的副作用函数上:
+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函数,在这里我们就要判断是否传递了调度器函数
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执行的时机就会延迟
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
{
// scheduler调度器函数
scheduler (fn) {
+ setTimeout(fn)
}
}
)
9.3 调度执行的次数
学会这节就能理解,为什么vue当中对响应式数据连续做多次的更新操作,但是最终只会触发一次更新。
如下代码中,对obj.foo执行两次自增操作,控制台会打印 1 2 3
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
)
obj.foo++
obj.foo++
我们希望自增2次,但是控制会只会打印1和3,中间不管执行了几次都不管
// 定义一个任务队列
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
})
}
在副作用函数中,增加如下代码
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
最终打印的效果如下:
9.4 计算属性具体实现
通过之前的知识点,我们能够实现Vue.js中非常重要且有特色的能力--计算属性。先介绍懒执行的effect,即lazy的effect。之前实现的effect函数会立即执行,如下:
effect(
() => {
console.log(obj.foo, 'obj.foo');
}
)
但是某些场景,我们不希望他立即执行,希望在需要的时候才执行,例如计算属性。如下我们在options选项中,增加了lazy配置项为true:
effect(
() => {
console.log(obj.foo, 'obj.foo');
},
// options
{
lazy: true
}
)
需要对effect函数增加代码,判断options选项中,lazy属性如果为false,才会立即执行effectFn函数。并且这里我们还增加了代码用来返回effectFn
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函数返回了一个函数,我们能够拿到他,手动执行。
const effectFn = effect(
() => {
console.log(obj.foo, 'obj.foo');
},
// options
{
lazy: true
}
)
// 手动执行这个函数
effectFn()
我们希望不仅仅是手动执行,还希望能够拿到返回值。如下代码中, 通过() => obj.foo + obj.bar我们能够拿到返回值。
const effectFn = effect(
() => obj.foo + obj.bar,
// options
{
lazy: true
}
)
const value = effectFn()
需要对effect函数本身做修改,因为fn函数才是真正的副作用函数,也就是() => obj.foo + obj.bar,effectFn是我们包装过后的副作用函数,接受fn()的返回值,并且在最后return。这样上面就能够拿到返回值了
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值:
const effectFn = effect(
() => obj.foo + obj.bar,
// options
{
lazy: true
}
)
const value = effectFn()
console.log(value, 'value'); // 3
如下实现一个computed函数:
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函数,得到副作用函数的返回值
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的值没有变化也会重复计算。我们希望能够避免这样的重复计算,修改如下:
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 () {}访问器函数里面的逻辑。修改如下:
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函数里面读取了计算属性的值,如下:
const sumRes = computed(() => obj.foo + obj.bar)
+effect(() => {
+ console.log(sumRes.value, 'someRes.value112');
+})
如上代码,新增对effect函数的调用,里面访问sumRes值,希望当obj.foo变化时,effect函数能够重新执行。就像在Vue的模版中读取计算属性值,当其值发生变化时,能重新渲染模版。
在控制台执行obj.foo++,发现并不会有新的打印值:
这里的嵌套指,在一个副作用函数里访问sumRes.value值,访问时触发computed里的getter函数,这时obj.foo和obj.bar会把computed内部effect函数收集到依赖集合。而外层effect函数不会被收集进去
解决方式:
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属性对应的副作用函数拿出来挨个执行一遍。这样就实现了我们的需求了
10 watch侦听器实现原理
10.1 侦听器实现原理-简洁版
watch侦听器的实现原理:
- Watch侦听定义:用于观察响应式数据源的变化,并在变化时执行特定的回调函数
兼容函数和对象:接受参数source,判断是函数还是对象,如果是函数则直接赋值给getter,否则递归遍历整个对象的所有属性`首次加载立即执行:Watch函数接受第三个参数options,如果传入了immediate:true的属性,则初始化立即执行一次副作用函数控制侦听器的执行时机:通过effect包装函数,传入副作用函数和调度器,回调函数放到调度器里面,当数据修改的时候会触发Proxy的set拦截进而触发调度器执行;如果传了flush: post的配置项进来,则回调要通过Promise.resolve()放到异步任务队列里面去执行
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()
}
}
10.2 简易版侦听器
利用effect函数和scheduler调度器, 能够实现最基本的watch侦听器函数
// 最简单的watch函数实现
function watch (source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
// 当数据变化时,调用回调函数
scheduler () {
cb()
}
}
)
}
如上,执行effect函数,第一个参数是回调函数,该回调会读取source.foo的值,第二个参数传入调度器scheduler,并在里面执行cb回调函数。如果传了调度器scheduler,就不会立即执行副作用函数
watch(obj, () => {
console.log('数据变化了');
})
在控制台给obj.foo++,打印如下:
10.3 监听所有属性
但是现在我们硬编码了source.foo的数据,只能侦听foo属性。现在来封装一个可以监听对象所有属性的方法
// 最简单的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
}
当我把数据结构改为
let data = {
foo: 1,
bar: 2,
b: 3
}
如下,我们在控制台调试,修改obj的其他属性也能够触发watch的回调
只是现在数据结构如果是嵌套的比如 let data = {b: {bb: 1}}这样里面的bb还不能被监听到。这个我们后面再实现
10.4 监听函数
我们现在来解决另一个问题,如果侦听器第一个参数传入的是一个回调函数怎么办
watch(() => obj.foo, () => {
console.log('数据变化了');
})
修改如下:
// 支持自定义函数
function watch (source, cb) {
+ let getter
+ // 如果是函数,直接赋值给getter
+ if (typeof source === 'function') {
+ getter = source
+ } else {
+ // 如果不是函数,则还是原来的逻辑
+ getter = () => traverse(source)
+ }
+ effect(
+ () => getter(),
{
// 当数据变化时,调用回调函数
scheduler () {
cb()
}
}
)
}
watch(() => obj.bar, () => {
console.log('数据变化了');
})
如上,增加getter变量,如果传入source是一个函数,则直接赋值给getter,如果不是,则还是原来的逻辑。并对obj.bar属性进行侦听,如果是修改obj.foo,则不能执行回调函数。控制台打印结果如下:
10.5 监听旧值
现在还有一个问题,就是无法获取到新值和旧值,我们使用watch侦听器的时候,第二个回调函数可以接受newVal和oldVal的
watch(() => obj.bar, (newVal, oldVal) => {
console.log('数据变化了');
})
修改如下:
// 支持自定义函数
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
watch(() => obj.bar, (newVal, oldVal) => {
console.log(newVal, oldVal, '数据变化了');
})
10.6 immediate立即执行与flush: post
在使用Vue的Watch侦听器时,支持传入{immediate: true}参数,这样在watch创建时,立即执行一次:
watch(
() => obj.bar,
(newVal, oldVal) => {
console.log(newVal, oldVal, 'newVal, oldVal数据变化了');
},
{
immediate: true
}
)
实现如下:
// 支持自定义函数
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更新后再执行
watch(() => obj.bar, (newVal, oldVal) => {
console.log(newVal, oldVal, 'newVal, oldVal数据变化了');
}, {
immediate: true,
flush: 'post'
})
修改如下:
// 支持自定义函数
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的值一样代表同步
10.7 watch竞态问题
如上封装的watch函数还是存在竞态问题, 看如下例子:
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函数是如何解决这个问题的?
如下代码是Vue3中的Watch的使用方式:Vue.js中的回调函数有第三个参数onInvalidate,他是一个函数,可以传入一个回调函数进去,该回调会在当前副作用函数过期时执行
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
}
})
我们如何去模拟这个功能?实现如下:
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作为回调的第三个参数传出去给用户使用
我们这样来模拟这个功能:
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