响应式对象的实现
基本的响应式功能
// 创建一个用于存储依赖的Set
let bucket = new Set()
const data = { text: 'hello world' }
// 代理对象
const obj = new Proxy(data, {
get (target, key) {
bucket.add(effect)
return target[key]
},
set (target, key, val) {
if (target[key] === val) return
target[key] = val
bucket.forEach(effectFn => {
effectFn()
})
}
})
function effect () {
console.log(obj.text)
}
effect()
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
上面代码演示了一个最基本的响应式对象,但缺点也很明显,每次收集依赖都需要对应函数的名称,一旦我们换个函数名称代码便失效了。为了不受函数名称限制,现在来改进一下,每次effect
函数执行时先把传入的函数赋值给一个全局变量activeEffect
,再执行传入的函数。
const obj = new Proxy(data, {
get (target, key) {
if (!activeEffect) return
bucket.add(activeEffect)
return target[key]
},
set (target, key, val) {
if (target[key] === val) return
target[key] = val
bucket.forEach(effectFn => {
effectFn()
})
}
})
// 声明一个全局变量来表示当前依赖
let activeEffect
// 创建一个依赖函数生成器
function effect (fn) {
activeEffect = fn
fn()
}
effect(() => {
console.log(obj.text)
})
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
到目前为止,我们已经可以通过调用依赖函数生成器来生成依赖,并在get
拦截中收集依赖,但仍存在一个致命问题,即不管访问对象的任何属性都会触发所有的依赖。因此我们需要修改依赖存储桶的数据结构,为每个对象的每个属性创建单独的存储桶。
// WeakMap --> Map --> Set
// WeakMap对象的key为target对象,value为一个Map对象
// Map对象的key为target对象的属性,value为Set数据结构,作为各个对象下各个属性的
let bucket = new WeakMap()
const obj = new Proxy(data, {
get (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.add(activeEffect)
return target[key]
},
set (target, key, val) {
if (target[key] === val) return
target[key] = val
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
deps && deps.forEach(effectFn => {
effectFn()
})
}
})
到这里,我们就成功将各个对象下各个属性的依赖集合区分开。之所以用WeakMap
作为bucket
的第一层数据结构,就是为了利用它的弱引用性,使得浏览器的垃圾回收机制可以在对象调用结束后将其回收。最后我们再把get
和set
里的拦截操作抽离成两个函数,也即track()
和trigger()
,完整代码如下:
let bucket = new WeakMap()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, val) {
if (target[key] === val) return
target[key] = val
trigger(target, key)
}
})
// 收集依赖
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.add(activeEffect)
}
// 触发依赖
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
deps && deps.forEach(effectFn => {
effectFn()
})
}
// 声明一个全局变量来表示当前依赖
let activeEffect
// 创建一个依赖函数生成器
function effect (fn) {
activeEffect = fn
fn()
}
effect(() => {
console.log(obj.text)
})
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
分支切换带来的额外开销
分支切换是代码中很常见的情景,但这些情况很有可能产生不必要的依赖更新,示例如下
const data = { text: 'hello world', ok: true }
const obj = new Proxy(data, {...})
effect(() => {
console.log(obj.ok ? obj.text ? 'no')
})
setTimeout(() => {
obj.ok = false
}, 1000)
setTimeout(() => {
obj.text = 'hello vue3'
}, 2000)
我们可以观察到如下结果,调用effect
生成器时第一次打印hello world,这时data
对象下text
和ok
属性的依赖存储桶内都增加了一个依赖;接着我们更改ok
属性的值为false
,执行它内部的依赖打印出第二个结果no,此时打印结果已经与obj.text
的值无关,结果会一直为no;最后text
属性变化时我们依然观察到依赖触发了,打印出no,这显然触发了不必要的依赖更新。
为了避免产生这种因分支切换带来的不必要开销,我们可以在每次依赖执行前把该依赖从所有与之相关联的依赖存储桶删除,执行完后再重新收集依赖,确保没有不必要的依赖产生。
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.add(activeEffect)
// add
activeEffect.deps.add(deps)
}
function effect (fn) {
const effectFn = () => {
clear(effectFn)
activeEffect = effectFn
fn()
}
// 存储相关联的依赖存储桶
effectFn.deps = new Set()
effectFn()
}
// 从依赖存储桶中删除依赖
function clear (effectFn) {
effectFn.deps.forEach(effectSet => {
effectSet.delete(effectFn)
})
effectFn.deps.clear()
}
上面代码为每个依赖创建了一个Set
数据结构用来存储与依赖相关联的依赖存储桶,并且在触发依赖之前把依赖从这些存储桶中删除,实现了依赖存储桶中只存储必要依赖的效果,这时我们再去执行代码,会发现产生了无限循环。原因很简单,我们的操作本质上就是Set
在每次遍历中先删除后添加,即
const set = new Set([1])
set.forEach(item => {
set.delete(1)
console.log(item)
set.add(1)
})
根据MDN
上Set
关于forEach
的描述:每个值都访问一次,除非在forEach()
完成之前删除并重新添加它,在访问之前删除的值不会调用callback
,在forEach()
完成之前添加的新值将被访问。要解决这种无限循环也很简单,只需复制一份Set
即可
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
// add
const effectsToRun = new Set([...deps])
effectsToRun.forEach(effectFn => {
effectFn()
})
}
effect嵌套
接下来我们考虑effect
嵌套场景。下面这个例子中首先执行外层依赖函数,遇到内层effect
,把内层函数添加到bar
属性对应的存储桶中,内层effect
执行完毕,继续往下执行,把依赖添加到foo
属性对应的存储桶中最后再修改obj.foo
的值。
const data = { foo: 'foo', bar: 'bar' }
const obj = new Proxy(data, {...})
effect(() => {
effect(() => {
console.log('effect_2', obj.bar)
})
console.log('effect_1', obj.foo)
})
setTimeout(() => {
obj.foo = 'new foo'
}, 1000)
预想中我们应该在间隔一秒后打印出先后打印出 effect_2 bar 和 effect_1 new foo,但实际结果却是只打印出 effect_2 bar,明明想触发的是foo
属性下的依赖,却执行了bar
属性的依赖。细想之下不难发现,这是由于activeEffect
在执行完内层effect
后变为了内层函数,等到添加外层依赖时我们就把内层函数添加到了foo
属性对应的存储桶中。
为了解决这一问题,我们设置一个activeEffect
的存储栈,每次执行依赖前把依赖入栈,执行完后再出栈,并且让activeEffect
指向栈顶,这样就达到了复原activeEffect
的目的。
let activeEffect
// add
let activeEffectStack = []
function effect (fn) {
const effectFn = () => {
clear(effectFn)
activeEffect = effectFn
// add
activeEffectStack.push(activeEffect)
fn()
// add
activeEffectStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
}
effectFn.deps = new Set()
effectFn()
}
无限循环
对于依赖函数中同时存在读取和设置的操作时,例如最常见的自增自减操作,会出现Maximum call stack size exceeded
栈溢出错误。
const data = { num: 0 }
const obj = new Proxy(data, {...})
effect(() => {
obj.num++
})
在上述代码中,自增等价于obj.num = obj.num + 1
。先读取num
属性的值,往num
属性对应的存储桶中添加依赖,然后设置num
属性的值,开始遍历存储桶中的依赖并执行,这就导致了无限循环的出现。为了避免这一情况的出现,我们需要在trigger()
方法上遍历依赖时增加额外判断。
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
// add
const effectsToRun = new Set()
deps && deps.forEach(effectFn => {
if (activeEffect !== effectFn) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
effectFn()
})
}
调度函数
为了增强依赖函数的可自定义性,我们可以给effect
生成器传入第二个参数,在里面进行一些自定义操作,从而达到更改执行顺序、执行次数等目的。下面通过在options
参数中设置一个scheduler
调度函数,把依赖函数作为参数传递给调度函数,在trigger
触发依赖更新时执行调度函数。
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
const effectsToRun = new Set()
deps && deps.forEach(effectFn => {
if (activeEffect !== effectFn) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// add
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
function effect (fn, options = {}) {
const effectFn = () => {
clear(effectFn)
activeEffect = effectFn
activeEffectStack.push(activeEffect)
fn()
activeEffectStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
}
// add
effectFn.options = options
effectFn.deps = new Set()
effectFn()
}
控制执行顺序
本质是通过异步任务来调整执行顺序
effect(() => console.log(obj.text), {
scheduler (fn) {
setTimeout(fn)
}
})
console.log(1)
obj.text = 'hello vue3'
console.log(3)
控制执行次数
下面例子中我们执行了三次自增操作触发三次依赖执行,但其实我们知道前两次只是一个过渡状态,需要的只有最终结果。
const data = { foo: 0 }
const obj = new Proxy(data, {...})
let taskQueue = new Set()
let isFlush = false
function flushJob () {
if (isFlush) return
isFlush = true
Promise.resolve().then(() => {
taskQueue.forEach(task => task())
}).finally(() => {
isFlush = false
})
}
effect(() => {
console.log(obj.foo)
}, {
scheduler (fn) {
taskQueue.add(fn)
flushJob()
}
})
obj.foo++
obj.foo++
obj.foo++
结果如下,修改了三次foo
属性的值,只在最后一次修改完成后触发依赖。
Computed计算属性
懒执行
计算属性具有懒执行特点,定义时不会执行,只有在使用到计算属性的时候才会执行计算过程。首先在包装好的依赖函数中获取回调函数的结果并返回,然后在options
上定义lazy
属性,为false
时立即执行依赖函数,否则就把依赖函数返回等待自定义执行。
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, {...})
function effect (fn, options = {}) {
const effectFn = () => {
clear(effectFn)
activeEffect = effectFn
activeEffectStack.push(activeEffect)
// add
const res = fn()
activeEffectStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
// add
return res
}
effectFn.options = options
effectFn.deps = new Set()
// add
if (!options.lazy) {
effectFn()
} else {
return effectFn
}
}
function computed (getter) {
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value () {
const res = effectFn()
return res
}
}
return obj
}
const sum = computed(() => obj.foo + obj.bar)
上面代码中往computed生成器中传递一个getter
函数,首先获取effect
生成器返回的依赖函数,然后定义一个对象,设置名为value
的访问器属性,访问器内部返回依赖函数的执行结果,这样就实现了通过.value
调用计算属性的效果。
缓存
细看上面的代码,可以发现每次调用计算属性都会重新计算结果,不管相关的属性值是否发生变化,缺少了计算属性的关键特性:缓存。
function computed (getter) {
// 缓存值
let cacheValue
// 是否为脏数据,即是否需要重新计算结果
let dirty = true
const effectFn = effect(getter, {
lazy: true,
// add
scheduler () {
if (!dirty) {
dirty = true
}
}
})
const obj = {
get value () {
// add
if (dirty) {
cacheValue = effectFn()
dirty = false
}
return cacheValue
}
}
return obj
}
修改部分中新增了两个变量,一个用来存储缓存值,一个用来判断是否为脏数据,初始为true
。在执行完第一次计算后,cacheValue
存储依赖函数的执行结果,并把dirty
置为false
,后续再继续调用计算属性时,不会再执行依赖函数而是直接返回缓存值。直到与计算属性相关的属性发生改变时,才会通过设置的调度函数把dirty
置为true
,并重新计算结果。
响应式
当计算属性用在依赖函数上时,我们希望同proxy
代理对象一样产生响应式的效果。然而已有的代码进行测试,foo
属性值的变化未能触发依赖函数的执行。
const sum = computed(() => obj.foo + obj.bar)
effect(() => {
console.log(sum.value)
})
obj.foo++
本质上,effect
内部调用计算属性就是effect
的嵌套。在内部effect
中访问了obj
代理对象,会收集内部依赖;外部effect
中访问sum
计算属性,但由于没有对sum
进行代理拦截,并没有收集依赖和触发依赖的行为产生。因此我们需要主动去进行收集和触发的操作,在get
访问器属性返回前收集依赖,在调度函数执行时触发依赖。
function computed (getter) {
// 缓存值
let cacheValue
// 是否为脏数据,即是否需要重新计算结果
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler () {
if (!dirty) {
dirty = true
// add
trigger(obj, 'value')
}
}
})
const obj = {
get value () {
if (dirty) {
cacheValue = effectFn()
dirty = false
}
// add
track(obj, 'value')
return cacheValue
}
}
return obj
}
watch监听器
基本监听功能
监听器可以监听数据的变化,一旦变化发生便执行相应的回调函数。在vue
中,watch
接收三个参数source
、cb
、options
,分别表示数据源、回调和监听器设置,下面先来实现基本监听功能。
function watch (source, cb, options = {}) {
effect(
// 遍历对象的所有属性,收集依赖
() => traversal(source),
{
scheduler () {
cb()
}
}
)
}
// 遍历对象,seen是为了防止循环引用
function traversal (obj, seen = new Set()) {
if (typeof obj !== 'object' || obj === null || seen.has(obj)) return
seen.add(obj)
for (let key in obj) {
traversal(obj[key], seen)
}
return JSON.parse(JSON.stringify(obj))
}
source
监听器接收的数据源不仅可以是对象,也可以是一个函数。
function watch (source, cb, options = {}) {
// add
let getter
if (typeof source === 'object') {
getter = () => traversal(source)
} else if (typeof source === 'function') {
getter = source
} else {
throw new TypeError('wrong type of source')
}
effect(getter, {
scheduler () {
cb()
}
})
}
传递新旧值
监听器触发时,会把数据源的新旧值传递给回调函数作为参数,现在问题就变为了要如何要获取这两个值?答案很简单:执行依赖函数拿到返回值。首先,options
设置lazy
为true
以获取依赖函数,并立即执行拿到第一个oldVal
也即初始值;然后在scheduler
函数体中再次执行依赖函数获取newVal
,传递给回调函数作为参数;最后需要把旧值赋为新值以待下次监听器触发时使用。
function watch (source, cb, options = {}) {
let getter
if (typeof source === 'object') {
getter = () => traversal(source)
} else if (typeof source === 'function') {
getter = source
} else {
throw new TypeError('wrong type of source')
}
// 传递给回调的参数
let oldVal, newVal
const effectFn = effect(getter, {
lazy: true,
scheduler () {
// add
newVal = effectFn()
cb(newVal, oldVal)
oldVal = newVal
}
})
// add
oldVal = effectFn()
}
options
watch
可以接收第三个参数options
,我们可以通过传入options
来控制回调,options
的选项包括immediate
、flush
等。
立即执行的watch
watch
默认是懒执行的,仅当数据源发生变化时才会执行回调。某些情况下我们需要在创建监听器时立即执行一遍回调,通过immediate: true
来强制回调立即执行。
function watch (source, cb, options = {}) {
let getter
if (typeof source === 'object') {
getter = () => traversal(source)
} else if (typeof source === 'function') {
getter = source
} else {
throw new TypeError('wrong type of source')
}
// add
const execute = () => {
newVal = effectFn()
cb(newVal, oldVal)
oldVal = newVal
}
// 传递给回调的参数
let oldVal, newVal
const effectFn = effect(getter, {
lazy: true,
scheduler () {
execute()
}
})
// add
if (options.immediate) {
execute()
} else {
oldVal = effectFn()
}
}
回调的触发时机
设置flush: post
可以延迟回调的执行
function watch (source, cb, options = {}) {
// ...
const effectFn = effect(getter, {
lazy: true,
scheduler () {
// add
if (options.flush === 'post') {
Promise.resolve().then(execute)
} else {
execute()
}
}
})
// ...
}
竞态问题
实际项目中,watch
的回调时常会出现异步任务,在连续触发回调的情况下就有可能出现竟态问题,下面通过例子来解释一下什么是竟态问题。
// 创建两个异步任务
const task1 = () => {
console.log('task1')
return new Promise((resolve) => {
setTimeout(() => {
resolve('task1')
}, 1000)
})
}
const task2 = () => {
console.log('task2')
return new Promise((resolve) => {
setTimeout(() => {
resolve('task2')
}, 500)
})
}
const data = { foo: 1 }
const obj = new Proxy(data, {...})
let final
watch(obj, async () => {
let res
if (obj.foo === 1) {
res = await task1()
} else {
res = await task2()
}
final = res
}, { immediate: true })
setTimeout(() => {
obj.foo++
}, 200)
setTimeout(() => {
console.log('final ==>', final)
}, 1500)
我们首先创建两个异步任务,然后在watch
回调中根据obj
对象的值来决定执行哪个任务。设置immediate: true
触发第一次回调,此时执行task1
,task1
执行完成后返回值赋给final
;通过setTimeout
修改obj.foo
的值触发第二次回调,此时执行task2
,task2
执行完成后返回值赋给final
。由于task1
比task2
消耗的时间更多,尽管task2
后执行却先拿到返回值,赋给final
;等到task1
结束后也把结果赋给final
,所以我们取得的最终值是task1
。
通常情况下,我们想要拿到的值都是后执行任务的返回结果,那么应该如何避免这种竟态问题呢?我们需要在后执行任务执行完成后让之前的任务处于“失效”状态,不再把返回值赋值给
final
,由此引入了watch
中回调函数的第三个参数onInvalidate
。
function watch (source, cb, options = {}) {
let getter
if (typeof source === 'object') {
getter = () => traversal(source)
} else if (typeof source === 'function') {
getter = source
} else {
throw new TypeError('wrong type of source')
}
// add
let cleanup
const onInvalidate = (fn) => {
cleanup = fn
}
const execute = () => {
newVal = effectFn()
// add
if (cleanup) {
cleanup()
}
cb(newVal, oldVal, onInvalidate)
oldVal = newVal
}
// 传递给回调的参数
let oldVal, newVal
const effectFn = effect(getter, {
lazy: true,
scheduler () {
execute()
}
})
if (options.immediate) {
execute()
} else {
oldVal = effectFn()
}
}
onInvalidate
参数接收一个函数作为参数,函数内部仅是把传入的函数赋值给cleanup
,然后在watch
的回调触发前调用cleanup
函数。
let final
watch(obj, async (newVal, oldVal, onInvalidate) => {
let res, isExpire = false
onInvalidate(() => {
isExpire = true
})
if (obj.foo === 1) {
res = await task1()
} else {
res = await task2()
}
if (!isExpire) {
final = res
}
}, { immediate: true })
setTimeout(() => {
obj.foo++
}, 200)
setTimeout(() => {
console.log('final ==>', final)
}, 1500)
上述代码中首先声明了isExpire
标识是否失效,接着往onInvalidate
传入了一个更改isExpire
变量的函数。执行第一次回调前,cleanup
为空,执行完成后cleanup
为一个函数,isExpire
为false
;执行第二次回调前,执行cleanup
函数,把第一个回调函数置为“失效”状态,而后执行第二次回调,task2
结束后把值赋给final
;接着等到task1
完成,由于第一个回调函数内的isExpire
已经为true
,所以不会把返回值赋给final
。
总结
本文主要是为了总结近期的学习内容,从基础的proxy
响应式系统出发,一步步对其进行完善,进而实现简易版的computed
计算属性和watch
监听器,其中仍有许多不足,如Reflect
的使用、深度监听等等都还有待实现。
完整代码如下:
let bucket = new WeakMap()
const data = { text: 'hello world', ok: true, foo: 1, bar: 2 }
const obj = new Proxy(data, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, val) {
if (target[key] === val) return
target[key] = val
trigger(target, key)
}
})
// 收集依赖
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.add(activeEffect)
activeEffect.deps.add(deps)
}
// 触发依赖
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
const effectsToRun = new Set()
deps && deps.forEach(effectFn => {
if (activeEffect !== effectFn) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
// 声明一个全局变量来表示当前依赖
let activeEffect
// 当前依赖存储栈
let activeEffectStack = []
// 依赖生成器
function effect (fn, options = {}) {
const effectFn = () => {
clear(effectFn)
activeEffect = effectFn
activeEffectStack.push(activeEffect)
const res = fn()
activeEffectStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
return res
}
effectFn.options = options
effectFn.deps = new Set()
if (!options.lazy) {
effectFn()
} else {
return effectFn
}
}
// 从依赖存储桶中删除依赖
function clear (effectFn) {
effectFn.deps.forEach(effectSet => {
effectSet.delete(effectFn)
})
effectFn.deps.clear()
}
// computed
function computed (getter) {
// 缓存值
let cacheValue
// 是否为脏数据,即是否需要重新计算结果
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler () {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
get value () {
if (dirty) {
cacheValue = effectFn()
dirty = false
}
track(obj, 'value')
return cacheValue
}
}
return obj
}
// watch
function watch (source, cb, options = {}) {
let getter
if (typeof source === 'object') {
getter = () => traversal(source)
} else if (typeof source === 'function') {
getter = source
} else {
throw new TypeError('wrong type of source')
}
let cleanup
const onInvalidate = (fn) => {
cleanup = fn
}
const execute = () => {
newVal = effectFn()
if (cleanup) {
cleanup()
}
cb(newVal, oldVal, onInvalidate)
oldVal = newVal
}
// 传递给回调的参数
let oldVal, newVal
const effectFn = effect(getter, {
lazy: true,
scheduler () {
execute()
}
})
if (options.immediate) {
execute()
} else {
oldVal = effectFn()
}
}
// 遍历对象,seen是为了防止循环引用
function traversal (obj, seen = new Set()) {
if (typeof obj !== 'object' || obj === null || seen.has(obj)) return
seen.add(obj)
for (let key in obj) {
traversal(obj[key], seen)
}
return JSON.parse(JSON.stringify(obj))
}
转载自:https://juejin.cn/post/7187673192624816183