响应式系统
响应式系统是Vue3的核心
,其结构大致如下:
在学习响应式系统前,我们要明白两个概念:
副作用函数
和
响应式数据
。
1. 响应式数据和副作用函数
1.1副作用函数
如下代码:
function effect(){ document.body.innerText = "hello vue3" }
当effect函数执行的时候他会设置body的文本内容,但除了effect之外的其他函数仍然可以读取或者设置body的文本内容。也就是说:
effect函数的执行会直接或者间接应影响其他函数的执行
,这时我们说effect函数产生了副作用。
1.2响应式数据
理解了什么副作用函数,再来说说什么是响应式数据。副作用函数会读取某个对象的属性:
const obj = { text:"hello world " } function effect(){ //effect函数的执行会读取obj.text document.body.innerText = obj.text }
effect函数把body的innerText属性设置为对象obj的某个属性,并且,我们希望在该
属性变化时,effect函数会重新执行
。此时,obj即为
响应式数据
。
1.3响应式数据的实现
不难发现,响应式数据的两个特点:
- 当副作用函数Effect执行时,会触发obj.text的Getter
- 当obj.text内容发生变化时,会触发obj.text的Setter
我们要做的,就是在getter触发时将副作用函数放到一个“桶”里面,在setter触发时将“桶”中的副作用函数取出来依次执行即可。只要能够拦截到Getter和Setter,实现这点并不复杂。
在Vue2版本中,通过Object.defineProperty函数来实现。然而这样的实现方法仍有缺陷(详见
深入浅出VUE学习笔记
)Vue3通过ES6提供的proxy代理对象来实现这一功能。
2 响应式系统的设计思路
2.1 Effect的实现
首先我们需要提供一个副作用函数注册机制用来保证不同名字的的副作用函数(甚至是匿名函数)都可以被正确的收集到“桶”中。如下所示:
let activeEffect //作用是存储被注册的副作用函数 function effect(fn) { activeEffect = fn fn() } //我们会如下使用effect函数: effect(() => { console.log('effect run') document.body.innerText = obj.text }) const bucket = new Set() // 原始数据 const data = { text: 'hello world' } // 对原始数据的代理 const obj = new Proxy(data, { // 拦截读取操作 get(target, key) { if(activeEffect){ bucket.add(activeEffect) } // 返回属性值 return target[key] }, // 拦截设置操作 set(target, key, newVal) { // 设置属性值 target[key] = newVal bucket.forEach(fn => fn()) } })
2.2 “桶”的设计
在上例中,我们使用Set数据结构来作为“桶”来存储副作用函数。这导致我们并没有在
副作用函数与目标字段间建立明确的联系
。无论读取目标对象的任何一个属性,都会将副作用函数收集到同一个桶里,修改任何属性都会将桶中的所有副作用函数取出来执行。分析数据,发现响应式数据与副作用函数存在三个角色:
- 被操作的代理对象obj
- 被操作的属性名
- 使用effect函数注册的副作用函数
他们具有如下的关系:
结合WeakMap,Map,Set,我们设计出如下的“桶”实现:
Ps.使用WeakMap的原因是,一旦target没有被引用,该target就会被垃圾回收机制回收,如果使用map则可能会导致内存溢出。
经过改进后的代码如下(收集副作用函数与调用副作用函数的逻辑封装在track和trigger中):
const 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, newVal) { // 设置属性值 target[key] = newVal trigger(target, key) } }) function track(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) } function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) effects && effects.forEach(fn => fn()) }
2.3 分支切换与cleanup
上例的实现会导致
遗留副作用函数
问题,如:effect(function effectfn(){ document.body.innerText =obj.ok ? obj.text : 'not' })
该副作用函数可能会被同时收集到ok和text对应的“桶”中,而这个问题会导致不必要的更新。
为了解决这个问题,我们需要在每次副作用函数执行前,把它从所有的与它关联的Set中删除。为此,我们需要重新设计副作用函数,让每一个副作用函数能够明确的知道与哪些依赖相关联。如下所示:
let activeEffect function effect(fn) { const effectFn = () => { cleanup(effectFn) // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect activeEffect = effectFn fn() } // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合 effectFn.deps = [] // 执行副作用函数 effectFn() } //用于副作用函数从每个与它关联的set中删除 function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 } //对track函数做出一些更改 function track(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存入副作用函数的deps数组中建立双向关联 activeEffect.deps.push(deps) }
但是根据forEach的说明:调用forEach遍历set集合时,如果一个被访问过的值被删除后又重新加入集合,若此时forEach遍历还没有结束,该值会被重新访问。而每次执行副作用函数前我们会将其从set中删除,所以这很可能导致无限循环问题。
解决方法很简单,我们构造一个新的set遍历即可:
function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) //构造一个新的set const effectsToRun = new Set(effects) effectsToRun.forEach(effectFn => effectFn() }
2.4 effect嵌套与effect栈
实际上,effect嵌套的场景也会出现:Vue的渲染函数实际上就是在一个effect函数中执行的,而当组件发生嵌套时,effect也就发生了嵌套。为此我们添加一个副作用函数栈,让全局变量activeEffect始终指向栈顶元素,就解决了effect嵌套的问题。
let activeEffect const effectStack = []//用于实现effect嵌套 function effect(fn) { const effectFn = () => { cleanup(effectFn) // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() acctiveEffect = effectStack[effectStack.length - 1] } // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合 effectFn.deps = [] // 执行副作用函数 effectFn() }
考虑下面一个情况:
const data = { ok: true, text: 'hello world',foo:0 } ... effect(() => { obj.foo ++ })
运行下会发现控制台报栈溢出的错误,因为obj.foo ++有一个同时具有读取和赋值操作,这就会引起track和trigger函数来调用对应的操作函数,而操作函数就是effect中的匿名函数,这就是一个递归调用,解决方案也比较简单,就是在调用操作函数之前判断下:trigger触发的函数和正在执行的函数是否一致即可:
effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } })
2.5 可调度执行
调度就是指当trigger动作触发操作函数重新执行时,有能力决定函数执行的时机、次数以及方式。
我们可以为effect函数设置一个选项参数options,允许用户指定调度器:
function effect(fn,options ={}){ const effectFn = () => { ... } effectFn.options = options }
接下来在trigger函数中调用操作函数时先判断是否存在调度器,如果存在调度器就先执行调度器:
function trigger(target,key){ ... effectsToRun.forEach(effectFn =>{ if(effectFn.options.scheduler){ effectFn.options.scheduler(effectFn) }else{ effectFn() } }) }
3. Computed & Lazy
3.1 computed 与 lazy
在讲解计算属性前,我们需要先了解懒执行effect,即
lazy effect
。我们之前提到过的effect函数都是立即执行的,而某些情况下我们不希望其立即执行,而是在需要的时候执行(如计算属性),我们只需要在options选项中添加lazy属性来实现这一目的。如下:
function effect(fn,options ={}){ const effectFn = () => { ... } ... //如果lazy属性为true,曾把副作用函数作为返回值返回,否则就立即执行。 if(!options.lazy){ effectFn() } return effectFn() } const effectFn = effect(() => { console.log(obj.foo) },{ lazy:true }) effectFn()
实现了lazy功能,我们就可以据此来实现一个计算属性函数:
function computed(getter) {
let value//用于缓存上一次计算值
let dirty = true//用于表示是否需要重新计算值
const effectFn = effect(getter, {
lazy: true,
//调度器会在getter函数中依赖的响应式数据变化时执行,
//将dirty设置为true,避免多次修改同样的数据无法生效
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
//只有'脏'时才计算值,并把结果缓存到value中
if (dirty) {
value = effectFn()
//将dirty设置为false,下次访问直接使用value中的缓存
dirty = false
}
track(obj, 'value')
return value
}
}
return obj//obj的value是一个访问器属性,只有读取value值时,才会执行effectFn并将其结果返回
}
以上就是Vue3的简单实现思路,下一篇文章继续总结computed,lazy与watch的实现原理。
以上内容均用于个人学习总结,欢迎批评指正。