站在巨人的肩膀上看vue,来自霍春阳的vue设计与实现。作者以问题的形式一步步解读vue响应式系统的作用与实现。那一起跟随文章欣赏作者那行云流水的思路和巧妙的设计,这是一个系列,也是自己读后的记录,更希望可以通过文章的形式可以和大家一起交流学习。
4.1-4.2、响应式数据与副作用函数
副作用函数:函数的执行会直接或间接的影响其他函数的执行。
响应式数据基本实现:
- 首先创建一个用于存储副作用函数的桶 const buket = new Set();
- 通过 Proxy 对data 设置get 和 set 拦截函数,用于拦截读取和设置操作
- 当读取树形将副作用函数effect添加到桶里,bucket.add(effect),然后返回其属性值;
- 当设置属性值时,先更新原始数据,再将副作用函数从桶里取出并重新执行;
const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
get(target, key) {
bucket.add(effect)
return target[key]
},
set(targrt, key, newVal) {
targrt[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
function effect(fn) {
console.log(obj.text)
}
effect()
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
// 输出 hello world
// 1s后输出 hello vue3
复制代码
4.3、设计一个完善的响应系统
问题1: 副作用函数名可以动态收集,即使是匿名函数
- 定义全局变量存储被注册的函数 let activeEffect
- 定义effect函数,将需要执行的函数当入参数,赋值给activeEffect并执行,统一副作用函数名
问题2:在响应式数据obj设置一个不存在的属性,也会触发副作用函数,没有在副作用函数与被操作的目标字段之间建立明确的关系。设置属性或者读取属性都会将副作用放到桶里和执行副作用函数。
- 用WeakMap作为桶的数据解构 const bucket = new WeakMap()
- WeakMap 由 target —> Map 构成 ;Map 由 key —> Set 构成; Set存储与key有关的副作用函数
let activeEffect // 全局变量存储被注册的副作用函数
function effect(fn) { // effect 函数用于注册副作用函数
activeEffect = fn
fn()
}
const bucket = new WeakMap()
const data = { text: 'hello world' }
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, newVal) {
target[key] = newVal
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
})
effect(
() => {
console.log(obj.text)
}
)
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
复制代码
问题3:为什么不用Map 而是用 WeakMap
WeakMap 的 key是弱引用,不影响垃圾回收器的工作,target 对象没有任何引用了,这时垃圾回收期会完成回收任务,如果使用Map 代替,用户代码对target没有任何引用,这个target也不会回收,导致内存溢出。
4.4、分支切换与 cleanup
分支切换:函数内部存在三元运算符(条件判断)会执行不用的代码分支。
问题4: 分支切换会产生遗留的副作用函数,重复执行
console.log(obj.ok ? obj.text : ‘not’)
复制代码
当obj.ok的值变为false,不会在读取obj.text的值,无论改变obj.text的值,都不需要再重新执行副作用函数。
当副作用执行完毕后,会重新建立联系,在新的联系中不包含遗留的副作用函数,所以在每次副作用执行前,将其从相关联的依赖中移除
- 在effect内部定义个新的effectFn函数,并为其添加effectFn.deps属性,存储所有包含当前副作用函数的依赖集合
- 在track中将副作用函数push到数组中 activeeffect.deps.push(deps),这说明deps就是一个与当前副作用函数存在联系的依赖集合
- 在每次副作用函数执行时,将副作用函数从依赖集合中移除,定义cleanup 函数
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
复制代码
4.5、嵌套的 effect 和 effect栈
当组件嵌套组件时,effect就需要嵌套。
问题5:嵌套的effect函数,外层数据改变内层的副作用函数会被执行
全局变量 activeEffect 用来存储 effect 函数注册的副作用,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个,当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且不会恢复到原来的值,这时如果再有响应式数据进行依赖收集,收集的副作用函数也会是内层副作用函数
- 定义一个副作用函数栈 effectStack,在副作用执行时,将当前副作用函数压入栈中
- 副作用函数执行完毕后将其从栈中弹出,并且让 activeEffect 指向栈顶的副作用函数
const effectStack = [] // effect 栈
function effect(fn) { // effect 函数用于注册副作用函数
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
// 在调用副作用之前将副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把activeEffect还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length -1]
}
effectFn.deps = []
effectFn()
}
复制代码
4.6、避免无限递归循环
问题6:在effect内部如果有一个自增操作,该操作会引起栈溢出
effect(() => obj.foo++)
复制代码
读取obj.foo 会触发track 操作,将当前副作用函数收集到桶中,接着将其加1在赋值给obj.foo,会触发 trigger,把桶中的副作用函数取出并执行,该副作用函数正在执行,还没执行完就要开始下一次执行,这样导致无限递归地调用自己,产生栈溢出。
- 增加 守卫条件
- 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
//
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(fn => fn())
}
复制代码
4.7、调度执行
可调度:当 trigger 动作触发副作用函数重新执行时,有能力决定副作用执行的时机、次数以及方式。
effect 函数设计一个选项参数options,允许用户指定调度器scheduler。
- 在effect将options绑定到
effectFn.options = options
- 在trigger副作用执行之前判断有无scheduler
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options?.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
复制代码
问题7:如何控制同一个值的执行次数,在vue中更新一个响应式数据,不会每次都会执行
const data = { foo: 1 }
effect(() => console.log(obj.foo))
obj.foo++
obj.foo++
// 会执行1,2,3
复制代码
如何不执行过度状态2,通过调度器很容易实现:
在这这之前需要了解宏任务和微任务:
由于js是单线程,js设计者把任务分为同步任务和异步任务,同步任务都在主线程上排队执行,前面任务没有执行完成,后面的任务会一直等待;异步任务则是挂在在一个任务队列里,等待主线程所有任务执行完成后,通知任务队列可以把可执行的任务放到主线程执行。异步任务放到主线程执行完后,又通知任务队列把下一个异步任务放到主线程中执行。这个过程一直持续,直到异步任务执行完成,这个持续重复的过程就叫Event loop。而一次循环就是一次tick 。
在任务队列中的异步任务又可以分为两种microtast(微任务) 和 macrotask(宏任务)
microtast(微任务):Promise, process.nextTick, Object.observe, MutationObserver
macrotask(宏任务):script整体代码、setTimeout、 setInterval等
执行优先级上,先执行宏任务macrotask,再执行微任务mincrotask。
执行过程中需要注意的几点是:
- 在一次event loop中,microtask在这一次循环中是一直取一直取,直到清空microtask队列,而macrotask则是一次循环取一次。
- 如果执行事件循环的过程中又加入了异步任务,如果是macrotask,则放到macrotask末尾,等待下一轮循环再执行。如果是microtask,则放到本次event loop中的microtask任务末尾继续执行。直到microtask队列清空。
回到这一章,vue如何实现跳过中间状态,只执行一次:
/ 定义一个任务队列
const jobQueue = new Set()
// 建给一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷行队列
let isFlushing = true
function flushJob() {
if (!isFlushing) return
isFlushing = false
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
effect(() => console.log(obj.foo), {
scheduler: function (fn) {
jobQueue.add(fn)
flushJob()
}
}
)
obj.foo++
obj.foo++
复制代码
连续对foo执行自增操作,会连续执行两次scheduler,同一副作用函数会被jobQueue.add(fn)两次,但是Set数据有去重能力,所以只会有一项
flushJob()也会执行两次,但是isFlushing,flushJob函数在一个事件循环内只会执行一次,在微任务中队列中执行一次,当微任务队列开始执行时,遍历jobQueue队列内的副作用函数。只有一个所以也只会执行一次。这也是vue 连续修改响应式数据但只会触发一次更新的逻辑。
4.8、计算属性 computed 和 lazy
问题8:effect的函数会立即执行
在options添加 lazy 属性,在effect中根据lazy属性值判断是否执行
function effect(fn, options) { // effect 函数用于注册副作用函数
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
// 在调用副作用之前将副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把activeEffect还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length -1]
}
effectFn.options = options
effectFn.deps = []
if (!options.lazy) {
effectFn()
}
return effectFn
}
const effectFn = effect(() => console.log(obj.foo),
{
lazy: true
}
)
effectFn() // 手动执行副作用函数
复制代码
问题9: computed如何拿到副作用的值
- 将副作用函数当作getter传入到computed中
- 将fn的执行结果存储到一个变量res中
- 执行副作用函数的时候返回
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)
复制代码
问题10:如何对computed的值做缓存,避免重复计算
- 增加一个dirty变量,标识是否需要重新计算,false直接读上次缓存的值
- 当响应式数据变化时,再将dirty设置为true,重新调用effectFn计算
- 当响应的值在effect函数中读取,修改响应式数据副作用函数不执行,需要手动调用trigger/trick
function computed(getter) {
// 缓存上一次计算的值
let value
// 是否需要重新计算
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler: function() {
if (!dirty) {
dirty = true
// 当计算属性依赖响应书数据变化时,手动调用trigger触发
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取value时,手动调用trick进行响应跟踪
track(obj, 'value')
return value
}
}
return obj
}
复制代码
4.9、watch的实现
watch的本质:观察一个响应式数据,当数据发生改变时,通知并执行相应的回调函数。
实际上watch的实现本质就是利用effect以及options.scheduler选项。
副作用函数与响应式数据建立联系,当响应式数据变化时,会触发副作用函数重新执行,当副作用函数中有scheduler选项,当响应式数据变化时,会触发scheduler 调度函数执行,而非直接触发副作用函数执行
问题11: watch还可以接受一个getter函数,在getter 函数内部,用户可以指定该watch依赖哪些响应式数据,只有当这些数据发生改变,才会触发回调函数执行
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse()
}
effect(() => getter()
, {
scheduler() {
// 当 obj.foo的值发生
cb()
}
})
}
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || value == null || seen.has(value)) return
seen.add(value)
for (const k in value) {
traverse(value[k], seen)
}
return value
}
复制代码
问题12:如何拿到旧值和新值
- 使用lazy选项创建一个懒执行的effect,手动调用effectFn 函数得到返回值,即第一次执行得到的值oldValue
- 当数据发生变化触发scheduler调度函数执行时,会重新调用effectFn 函数得到新值newValue
- 接着将newValue,oldValue作为参数传递给回调函数
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse()
}
let oldValue, newValue
const effectFn = effect(() => getter()
, {
lazy: true,
scheduler() {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
})
oldValue = effectFn()
}
复制代码
4.10、立即执行的 watch 与回调执行时机
watch的本质是对effect的二次封装,立即执行函数就是把scheduler手动执行一遍,把scheduler函数抽离成job,当options有immediate时手动调用job。
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse()
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn =
effect(
() => getter(),
{
lazy: true,
scheduler: job
})
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
复制代码
在除指定回调函数为immeditate时,还可以通过其他参数(flush)来指定回调函数的执行时机
flush为post:代表scheduler需要将副作用函数放到一个微任务中,并等待DOM更新结束后再执行,
4.11、过期的副作用
问题13: 更改响应式数据发送网络请求,返回数据快慢会影响最后的值,比如先发A请求,立马发送B请求,B请求先返回A请求后返回,最后的结果是A的返回值
watch设计了第三个参数onInvalidate,在watch内部每次检测到变更后,在副作用函数重新执行之前,会先调用通过onInvalidate函数注册的过期回调,在过期回调中将上一次的副作用标记为“过期”
总结
vue3响应式系统如何实现
通过Proxy监听对象属性的Get 和 Set 方法,在 get 中进行依赖收集track,使用WeakMap 配合 Map和Set的数据解构,在响应式与副作用函数之间建立联系。将目标对象作为key,Map()作为value存入到WeakMap中。而Map存储的则是具体对象属性的key值和副作用的Set集合。当改变对象的属性值时,会触发set方法trigger,这时会找到在Set集合中的副作用函数遍历执行。
连续多次修改响应式数据只会触发一次更新
通过一个微任务队列对任务进行缓存,首先定义了一个Set集合的任务队列,多次且连续的修改数据,也会多次执行scheduler函数,将副作用函数放入到Set集合,由于Set有去重功能,所以队列中只会有一项。并且在scheduler中会调用一个flushJob的方法刷新队列。通过一个isFlushing的标识,只有为true的时候才会执行,并且在Promise.resolve的微任务中遍历Set集合执行,执行完之后finally将标识改为false,回到下一次的更新前的状态。
computed如何实现
computed计算属性实际上是一个lazy的副作用函数,通过lazy的选项使得副作用函数可以懒执行,将方法getter作为参数传递给effect函数并且options.lazy设置为true,内部定义了一个obj对象,重写了get value的方法执行了副作用函数,最后返回整个obj对象,当计算属性中依赖的响应式数据发生变化,通过value拿到执行之后的结果值。为了避免多次计算设计一个缓存的开关 dirty
,当dirty 为真时才会计算,当数据发生改变时在set中会调用scheduler,这是将dirty设置为true,重新计算
watch如何实现
watch本质上利用了副作用函数重新执行时的可调度性schedluer,在scheduler中执行用户通过watch函数注册的回调函数即可,scheduler指的是当trigger动作触发副作用函数重新执行,在effect函数中增加的第二个options参数