第一章 Vue3响应式核心effect函数实现
前言
Vue是一个数据驱动的框架。其特点是MVVM的架构,即model(数据模型)影响view(视图),view也会反作用于model,而完成model与view之间传导的机制就是响应式系统,本章将会以代码的形式向大家介绍响应式系统的设计与实现原理。(PS:本章的代码内容与Vue3的源码存在出入,将会是源码的最小实现,详细源码的梳理过程可见第二章。)
副作用的实现
function effect(){
document.body.innerHtml = 'hello world'
}
effect()
上述effect函数的执行会使得页面内容变成‘hello world',如果有另一个foo函数访问了当前body的内容,那么effect函数的执行就会对foo函数的结果造成影响,这种影响我们就称之为“副作用”,而造成副作用的函数,我们就称为副作用函数,可见effect函数就是一个副作用函数。 现在我们将effect函数中的数据源抽离出来成如下代码:
const obj = {msg: 'hello world'}
function effect() {
document.body.innerHtml = obj.msg
}
现在页面中的内容就由obj.msg所控制。假如我们可以完成当obj.msg发生改变时,再执行一次effect函数,那么页面中的内容就完全由obj.msg所驱动。为此,我们需要声明一个“桶”变量来存储我们的副作用函数,当obj.msg发生变化时,我们就将“桶”中的副作用函数全部取出执行。见如下代码:
const obj = {msg: 'hello world'}
const buckets = []
function effect() {
document.body.innerHtml = obj.msg
}
buckets.push(effect)
以上代码完成了我们对副作用函数的收集目的,但是它无法完成我们“当数据发生改变时取出副作用函数执行的需求”。为了实现这个目的,我们需要劫持数据,即我们需要获得对一个对象的getter和setter操作,在es6中我们有Proxy可以实现这一机制,这里对Proxy的内容就不再介绍了,修改代码如下:
const obj = {msg: 'hello world'}
const buckets = []
const newObj = new Proxy(obj, {
get(target, key) {
buckets.push(effect)
},
set(target, key, newValue) {
target[key] = newValue
buckets.forEach(effect => effect())
return true
}
})
function effect() {
document.body.innerHtml = newObj.msg
}
effect()
由上述可见,当effect函数执行时就会访问代理对象的msg属性,所以收集副作用函数的代码也可以简化到代理对象的get中。 我们目前的代码还有一个问题就是我们当前的effect函数是一个具名函数,buckets只能收集我们的effect函数,而响应式系统中的副作用应该是有许多许多,所以我们的响应式系统应该是一个通用的代码,我们要修改effect函数如下:
let activeEffect
const buckets = []
const obj = {msg:'hello world'}
const newObj = new Proxy(obj, {
get(target, key) {
buckets.push(activeEffect)
},
set(target, key, newValue) {
target[key]=newValue
buckets.forEach(fn => fn())
}
})
function effect(fn) {
activeEffect = fn
fn()
}
effect(() => console.log(newObj.msg))
这里我们声明了activeEffect变量来传递副作用函数,并且把副作用函数由之前的document.body.innerHtml=obj.msg改成console.log(obj.msg)使得看上去更加通俗。这里我们的effect函数已经不在是之前的副作用函数,而是一个注册副作用函数的函数,这样我们就完成了对多个匿名副作用函数的收集工作。
TargetsMap
我们尝试着去改变newObj.msg的值会发现,console.log函数又执行了。显然它满足了我们响应式更新的需求,但是当执行newObj.value = ''时,会发现副作用函数也执行了,这是因为我们的buckets并没有与newObj的属性之间建立关联关系,newObj的set一旦执行都会触发更新,于是我们需要建立一个对象属性与副作用函数之间的一对多的关系,便因此引入了targetsMap对象,现修改代码如下:
let activeEffect
const obj = {msg:'hello world'}
const targetsMap = new WeakMap()
const newObj = new Proxy(obj, {
get(target, key) {
if (!activeEffect) return
// 从targetsMap中获得原对象
let depsMap = targetsMap.get(target)
if (!depsMap) {
targetsMap.set(target, depsMap = new Map())
}
// 获得原对象属性所对应的副作用函数集合
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, deps = new Set())
}
deps.add(activeEffect)
},
set(target, key, newValue) {
target[key]=newValue
const depsMap = targetsMap.get(target)
if (depsMap) {
const effects = depsMap.get(key)
// 从targetsMap中获取原对象所对应的Map,从中获取对应属性的副作用集合
effects && effects.forEach(effect => effect())
}
}
})
function effect(fn) {
activeEffect = fn
fn()
}
effect(() => console.log(newObj.msg))
为了逻辑清晰,封装track和trigger函数:
let activeEffect
const obj = {msg:'hello world'}
const targetsMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return
let depsMap = targetsMap.get(target)
if (!depsMap) {
targetsMap.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 = targetsMap.get(target)
if (depsMap) {
const effects = depsMap.get(key)
effects && effects.forEach(effect => effect())
}
}
const newObj = new Proxy(obj, {
get(target, key) {
track(target, key)
},
set(target, key, newValue) {
target[key]=newValue
trigger(target, key)
}
})
function effect(fn) {
activeEffect = fn
fn()
}
effect(() => console.log(newObj.msg))
CleanupEffect
上述代码已经完成了基本的响应式功能,但仍然存在问题,比如如下代码:
const obj = {flag: true, msg:'hello world'}
// 封装reactive函数创建obj的代理对象
const newObj = reactive(obj)
effect(() => {
console.log('execute')
if (newObj.flag) {
console.log(newObj.msg)
}
})
newObj.flag = false
setTimeout(() => {
newObj.msg = 'hello vue'
}, 1000);
上述代码执行后会发现延迟一秒后又打印了execute,表示副作用函数又执行了。此时newObj.flag已经为false,不会访问newObj.msg,但是改变msg的值依然触发了副作用执行,这次因为我们初始化时flag为true,将副作用函数也收集到了msg属性所对应的集合中,尽管修改flag为false后,msg的副作用仍然在集合中存在,我们缺乏清除“缓存”副作用的功能。 每当副作用函数执行就会触发get收集副作用,而触发set又会间接的触发get,从而收集副作用函数。所以如果我们中副作用函数执行前先把之前收集的副作用清空,然后再执行副作用函数把副作用收集回来,就可以保证我们所持有的集合中的副作用函数一定是最新的,现修改代码如下:
//...省略部分代码
function track(target, key) {
if (!activeEffect) return
let depsMap = targetsMap.get(target)
if (!depsMap) {
targetsMap.set(target, depsMap = new Map())
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, dep = new Set())
}
dep.add(activeEffect)
// 收集副作用函数的同时,也要副作用函数收集其所在的集合
activeEffect.deps.push(dep)
}
function cleanup(effectFn) {
for(let i=0;i<effectFn.deps.length;i++) {
const dep = effectFn.deps[i]
// 将副作用在其所在的集合中删除
dep.delete(effectFn)
}
effectFn.deps.length = 0
}
function effect(fn) {
// 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
const effectFn = () => {
activeEffect = effectFn
cleanup(effectFn)
fn()
}
if (!effectFn.deps) effectFn.deps = []
effectFn()
}
//...省略部分代码
执行上述代码会发现进入死循环中,因为我们在触发trigger时会使副作用函数执行,清空之前收集的副作用,然后重新收集,但此时我们的trigger还没有结束,循环更新中又加入了新收集的副作用所以更新继续,导入进入死循环,类似于:
const set = new Set([1])
set.forEach(num => {
set.delete(1)
set.add(1)
console.log('遍历中')
})
所以,需要修改trigger函数代码如下:
function trigger(target, key) {
const depsMap = targetsMap.get(target)
if (depsMap) {
const effects = depsMap.get(key)
if (effects) {
const effectsToRun = new Set(effects)
effectsToRun.forEach(effect => effect())
}
}
}
因为副作用函数的触发又会引起副作用函数的收集,假设如果有一个指令同时满足对代理对象的属性的访问与修改,那我们的副作用函数将会进入死循环,即newObj.msg++指令,所以为了避免这种情况,修改代码如下:
function trigger(target, key) {
const depsMap = targetsMap.get(target)
if (depsMap) {
const effects = depsMap.get(key)
if (effects) {
const effectsToRun = new Set(effects)
effectsToRun.forEach(effect => {
if(effect !== activeEffect) effect()
})
}
}
}
Nest
其实当我们在effect函数中传入绘制页面的副作用函数后,页面内容就由我们的代理对象所描绘,而我们知道vue是用vnode来描绘页面的样式的,这些vnode之间又组合成了一个vnode树,每一个vnode都是vnode树中的一个节点,而节点下又有多个分支,每一个分支下又是一个vnode,而每一个vnode又会有一个依据它描绘的页面,所以又对应有一个副作用函数。即副作用函数需要支持嵌套。effect函数修改如下:
// ...省略部分代码
// 用一个栈记录嵌套的副作用函数
const activeEffectStack = []
function effect(fn) {
// 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
const effectFn = () => {
activeEffect = effectFn
activeEffectStack.push(activeEffect)
cleanup(effectFn)
fn()
activeEffetStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
}
if (!effectFn.deps) effectFn.deps = []
effectFn()
}
// ...省略部分代码
Scheduler
假设有这样一段代码:
// newObj.msg的值为hello world
effect(() => console.log(newObj.msg))
console.log('update')
newObj.msg = 'hello vue3'
当上述代码执行时我们发现会先打印hello world,再是update,最后是hello vue3。现在如果我们想先打印hello vue3再打印update,需要如下修改:
// newObj.msg的值为hello world
effect(() => console.log(newObj.msg))
newObj.msg = 'hello vue3'
console.log('update')
这里我们为了修改执行顺序改变了代码的位置,这样的代码是不好维护和升级的,我们需要可以自由的决定我们的副作用函数的执行时机,所以引入了scheduler概念,即调用时我们可以这样写:
const isFlush = false
const p = Promise.resolve();
const flushJob = () => {
if (isFlush) return;
isFlush = true
// 放入微任务队列,异步执行副作用函数
p.then(() => {
queue.forEach(effect => effect())
}).finally(() => {
isFlush = false;
queue.length = 0
})
}
const jobQueue = (job) => {
// 收集副作用函数
queue.push(job)
flushJob();
}
// newObj.msg的值为hello world
// 这里的effect函数可以传入第二个参数,即一个含有scheduler的函数,我们要用scheuler函数控制副作用函数的执行
effect(() => console.log(newObj.msg), {
scheduler(effect) {
jobQueue(effect);
}
})
newObj.msg = 'hello vue3'
console.log('update')
effect函数修改如下:
// ...省略部分代码
function effect(fn, options = {}) {
// 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
const effectFn = () => {
activeEffect = effectFn
activeEffectStack.push(activeEffect)
cleanup(effectFn)
fn()
activeEffetStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
}
if (!effectFn.deps) effectFn.deps = []
effectFn.options = options
effectFn()
}
function trigger(target, key) {
const depsMap = targetsMap.get(target)
if (depsMap) {
const effects = depsMap.get(key)
if (effects) {
const effectsToRun = new Set(effects)
effectsToRun.forEach(effect => {
if (effect === activeEffect) return;
// 如果调度器存在,则用调度器执行副作用函数
if (effect.options && effect.options.scheduler) {
const { scheduler } = effect.options
scheduler && scheduler(effect)
} else {
effect()
}
})
}
}
}
// ...省略部分代码
这里我们发现调度器的本质是一个回调函数,我们也可以不采用微任务队列的方式,自定义scheduler函数控制副作用函数的执行顺序。
Lazy
副作用函数会默认执行一次,通过代理来收集副作用。可以通过在options中传入lazy属性来控制副作用函数不默认执行,但是不默认执行副作用函数的话,副作用就无法收集,所以我们需要在effect函数中把effectFn函数暴露出来,使得可以外部调用副作用函数。
// ...省略部分代码
function effect(fn, options = {}) {
// 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
const effectFn = () => {
activeEffect = effectFn
activeEffectStack.push(activeEffect)
cleanup(effectFn)
fn()
activeEffetStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
}
if (!effectFn.deps) effectFn.deps = []
effectFn.options = options
// 有lazy属性就不默认执行
if (!options.lazy) effectFn()
// 返回内部的副作用函数给外部手动调用。
return effectFn;
}
基于effect函数的懒执行,我们可以简单实现computed函数如下:
function computed(getter) {
// 副作用懒执行,我们需要在value属性被访问时才执行副作用函数
const effectFn = effect(getter, {
lazy: true
})
return {
value() {
// 这里需要返回副作用函数的返回值,所以effect函数也需要修改下
return effectFn()
}
}
}
//...省略部分代码
function effect(fn, options = {}) {
// 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
const effectFn = () => {
activeEffect = effectFn
activeEffectStack.push(activeEffect)
cleanup(effectFn)
const res = fn()
activeEffetStack.pop()
activeEffect = activeEffectStack[activeEffectStack.length - 1]
// 返回fn函数的执行结果
return res
}
if (!effectFn.deps) effectFn.deps = []
effectFn.options = options
// 有lazy属性就不默认执行
if (!options.lazy) effectFn()
// 返回内部的副作用函数给外部手动调用。
return effectFn;
}
// ...省略部分代码
Computed
computed还有两个特点1.computed返回值本身也是响应式的;2.computed具有缓存。 修改代码如下:
//...省略部分代码
function computed(getter) {
// 标记是否是"脏"数据
let dirty = true
let res
// 副作用懒执行,我们需要在value属性被访问时才执行副作用函数
const effectFn = effect(getter, {
lazy: true,
// 因为副作用函数的执行会通过scheduler,所以可以在scheduler中改变dirty值,触发更新
scheduler() {
dirty = true
trigger(obj, 'value')
}
})
const obj = {
get value() {
track(obj, 'value')
if (dirty) {
res = effectFn()
dirty = false
}
return res
}
return obj
}
}
//...省略部分代码
Watch
上文中提到scheduler其实本质是一个回调函数,基于此,我们可以简单实现一个watch函数:
function watch(source, callback, options = {}) {
let oldValue
let newValue
let cleanup
let getter
if (typeof source === 'function') {
getter = source
} else {
// 赋值一个匿名函数访问对象属性
getter = () => traverse(source)
}
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || value === null || seen.has(value) ) return;
seen.add(value)
for(let key in value) {
traverse(value[key], seen)
}
return value
}
// 解决竞态问题
const onValidate = (fn) => {
cleanup = fn
}
const job = () => {
newValue = effectFn()
cleanup && cleanup()
callback(newValue, oldValue, onValidate)
oldValue = newValue
}
const effectFn = effect(getter, {
lazy: true,
scheduler() {
// 发生trigger时调用回调函数
job()
}
})
if (options.immediate) {
job()
} else {
oldValue = effectFn();
}
}
总结
本章最简的实现了vue3中的响应式原理,虽与源码的实现存在出入,但是描述了响应式中需要考虑的问题与解决方案,后续章节会优化响应式代码与vue3相同,可以在重构的同时思考vue3源码中的设计思路和解决方案。
转载自:https://juejin.cn/post/7248180884767506490