原文是《Vue.js设计与实现》(霍春阳) 第四章:响应系统的作用和实现
1.effect函数
响应式数据就是通过js改变数据内容时dom中的文本也会随之改变。很容易想到要去拦截对象的读取和设置操作,在ES2015+中,可以使用Proxy代理对象来实现。
const data = {
text: 'zs'
}
const obj = new Proxy(data,{
get(target,key){
return target[key]
},
set(target,key,newVal){
target[key] = newVal
document.body.innerText = newVal
}
})
document.body.innerText = 'xia'
以上的代码就实现了一个非常简单的相应式数据,可以看到document.body.innerText这行代码重复了两次,并且这行代码代表了数据更改后会发生的变化,由此引出了effect函数(副作用函数)的概念。
由此,以上的代码可以改写为:
const data = {
text: 'zs'
}
const obj = new Proxy(data,{
get(target,key){
return target[key]
},
set(target,key,newVal){
target[key] = newVal
effect()
}
})
function effect(){
document.body.innerText = 'xia'
}
effect()
上面的对象中只有一个属性,代码看起来是在正常运行的,如果现在有两个属性了,我们更改其中任意一个属性都会触发相同的effect函数,这和我们的预期显然是不一样的,因此我们需要对不同的属性收集(track)不同的effect,并在对应属性发生改变时触发(trigger)这个effect。这里引入了一个bucket(桶),用来存放所有的effect。
上面的代码还存在一个问题就是在set函数中硬编码了副作用函数(effect)的函数名,如果之后副作用函数不叫effect,则上面的代码无法正常工作。
下面的代码对以上两个问题进行了修正。第一,上面分析过要对不同对象的不同属性的effect函数收集,因此 bucket中的值的类型设置为weakmap(target,map(key,set(effect))),分别存储对象名、属性、副作用函数。第二,增加一个全局变量activeEffect,用来存储被注册的副作用函数。
const data = {
text: 'zs'
}
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 bucket = new WeakMap()
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());
}
let activeEffect
function effect(fn){
activeEffect = fn
fn()
}
effect(()=>{
document.body.innerText = obj.text
})
2.分支切换
当副作用函数中是一个关于obj的三元表达式时,也就是根据obj.ok的值的不同会执行不同的代码分支,如下:
effect(()=>{
document.body.innerText = obj.ok ? obj.text:'not'
})
以上代码上,当obj.ok的值为true时,ok和text属性都会被读取和并且收集到依赖中,当ok的属性变为false后,document.body.innerText的值将显示为not。理想情况下,此时text的值如何改变document.body.innerText的值都不会改变,也确实如此。可是text的值的改变,会触发副作用函数,即使未更新dom,这种触发,有些浪费。
解决这个问题的思路就是,每次副作用函数执行时,先把它从所有与之相关联的函数集合中删除,因此需要知道那些依赖集合中含有它。为此,在effect函数中定义了一个effectFn函数,并为每个effectFn函数添加了一个deps属性,用来存储包含依赖当前副作用函数的依赖集合。
let activeEffect
function effect(fn,options={}){
const effectFn = () => {
activeEffect = effectFn
fn()
}
effectFn.deps = []//activeEffect.deps[]存储依赖集合
effectFn()
}
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)
activeEffect.deps.push(deps) //完成了依赖集合收集
}
有了这个联系后,就可以在每次副作用函数执行性,根据effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除。
let activeEffect
function effect(fn,options={}){
const effectFn = () => {
cleanUp(effectFn)
activeEffect = effectFn
fn()
}
effectFn.deps = []//activeEffect.deps[]存储依赖集合
effectFn()
}
function cleanUp(effectFn){
for(let i = 0 ; i < effectFn.deps.length;i++){
const deps = effectFn.deps[i] // deps set集合
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
至此,可以避免副作用函数产生的遗留了,但目前运行代码会导致无限循环执行,因为在trigger函数中,遍历effect函数集合,会调用cleanUp清楚,但副作用函数的执行会导致其(副作用函数)重新被收集到集合中,因此,对于effects集合的遍历就回一直进行。解决的办法就是新增一个Set集合并遍历它。
function trigger(target,key) {
const depsMap = bucket.get(target)
if ( !depsMap ) return
const effects = depsMap.get(key)//effects、activeEffect、track(add)都指向同一个地址
//在调用foreach遍历set集合时,如果一个值已经被访问过了,但该值给删除并重新添加到集合中,且foreach未借结束,会被重新访问
effects && effects.forEach(fn => fn());
}
function trigger(target,key) {
const depsMap = bucket.get(target)
if ( !depsMap ) return
const effects = depsMap.get(key)
//effects && effects.forEach(fn => fn());//这个循环无法退出
const effectsToRun = new Set(effects)
effectsToRun.forEach( effectFn => effectFn())
}
3.effect嵌套和effect栈
在一个effect函数中传入了另一个effect函数,就发生了effect函数的嵌套。
effect(()=>{
console.log('effectFn1执行')
effect(()=>{
console.log('effectFn2执行')
console.log(obj.bar)
})
console.log(obj.foo)
})
在上面的代码中,effectFn1内部嵌套了effectFn2,很明显,effectFn1的执行会导致effectFn2的执行,其中,在effectFn1中访问了obj的foo属性,在effectFn2中访问了obj的bar属性,理想情况下,我们希望修改bar会触发effectFn2执行,修改foo会触发effectFn1执行。
但在实际运行时,我们会发现在修改foo的时候,effectFn1没有运行,反而是运行了effectFn2。原因我们使用同一个activeEffect来存储effect函数注册的副作用函数,意味着同一时刻,activeEffect函数所存储的副作用函数只能有一个,当副作用函数发生嵌套时,内层的副作用函数的执行会覆盖activeEffect的值。
为解决这个问题,需要引入一个effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。
const effectStack = []
function effect(fn,options={}){
const effectFn = () => {
cleanUp(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length-1]
}
effectFn.deps = []
effectFn()
}
4.避免无限循环
effect(() => obj.num++)
在以上代码中,同一个副作用函数中,既读取了num(track),又设置了num(trigger),因此就导致了该副作用函数会不同的调用自己,产生栈溢出。
解决方案是在trigger函数中判读触发的副作用函数和当前正在执行的副作用函数(activeEffect)是否为同一个,如果相同,则不触发执行。
function trigger(target,key) {
const depsMap = bucket.get(target)
if ( !depsMap ) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects &&effects.forEach( fn => {
if ( fn !== activeEffect ) {
effectsToRun.add(fn)
}
})
effectsToRun.forEach( effectFn => effectFn())
}
5.调度执行
调度执行,指的是trigger有能力决定触发副作用函数执行的时机、次数以及方式。
时机
const data = { foo: 1}
const obj = new Proxy(/*...*/)
effect(()=> cosnole.log(obj.foo))
obj.foo++
console.log('结束了')
以上代码输出的顺序分别是1、2、‘结束了’,若要在不改变代码结构的情况调整输出顺序,例如,将输出顺序修改为1、‘结束了’、2,就需要响应系统支持调度。
为此,我们可以为effect函数设计一个选项式参数options,允许用户指定调度器。
effect(()=>{/*...*/},{
scheduler(fn){
//...
}
})
我们需要再effect函数内部把options选项挂载到对应的副作用函数上,在trigger函数触发副作用函数执行时,就可以直接调用用户传递的副作用函数,从而把控制权交给用户。
function effect(fn,options={}){
const effectFn = () => {
cleanUp(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length-1]
}
effectFn.deps = []
effectFn.options = options
effectFn()
}
function trigger(target,key) {
const depsMap = bucket.get(target)
if ( !depsMap ) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects &&effects.forEach( fn => {
if ( fn !== activeEffect ) {
effectsToRun.add(fn)
}
})
effectsToRun.forEach( effectFn => {
if ( effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
}else {
effectFn()
}
})
}
添加好后,要实现上上述的需求,就可以直接在scheduler函数中将fn放到一个宏任务队列中执行
effect(()=>{/*...*/},{
scheduler(fn){
setTimeOut(fn)
}
})
次数
const data = { foo: 1}
const obj = new Proxy(/*...*/)
effect(()=> cosnole.log(obj.foo))
obj.foo++
obj.foo++
在上述代码中,输入的值分别是1、2、3,显然,2是一个过渡状态,我们关心的是最终结果(3),而不包含过渡状态,基于调度器,可以比较容易实现这个功能。
const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false
function flushJOb(){
if ( isFlushing ) return
isFlushing = true
p.then(()=>{
jobQueue.forEach(job => job())
}).finally(()=>{
isFlushing = false
})
}
effect(()=>{/*...*/},{
scheduler(fn){
jobQueue(fn)//每次trigger加入一个effect函数到微任务队列中
flushJob()
}
})
obj.num++
obj.num++
6.计算属性和lazy
在某些场景下,我们不希望effect函数立即执行,而是在需要的时候才执行。就比如计算属性,在依赖更新的时候才执行,此时,我们可以通过在options中添加lazy,当lazy为true时,就不立即执行effect函数。
function effect(fn,options={}){
const effectFn = () => {
cleanUp(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length-1]
}
effectFn.deps = []
effectFn.options = options
//只有lazy:flase 才执行
if( !options.lazy ) {
effectFn()
}
}
那当lazy为true时,什么时候执行effect函数呢?显然,以上代码无法执行effectFn函数,因此需要将effectFn函数返回,去手动调用副作用执行函数。
并且计算属性我们是想要获取一个返回值的,而当前的effect函数显然不能满足这个要求,因此我们需要做一些修改,返回用户传入getter函数的返回值。
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
}
effectFn.deps = []
effectFn.options = options
if (!options.lazy) {
effectFn()
}
return effectFn;//当调用effect时能拿到对应的effectFn
}
function computed(getter){
const effectFn = effect(getter,{
lazy:true
})
const computedObj = {
get value(){
return effectFn()
}
}
return computedObj
}
以上代码只做到了在读取时计算,并没有做到真正的缓存值,每次访问都会导致effectFn重新计算。
为了实现对值的缓存功能,我们添加一个val变量,用来缓存上一次计算得到的值,并增加一个dirty标志,用来表示是否需要重新计算值,默认dirty为true,当计算一次后就将dirty设置为false,在trigger中dirty又重新设置为true。
function computed(getter){
let val
let dirty = true
const effectFn = effect(getter,{
lazy:true,
scheduler(){
dirty = true
}
})
const computedObj = {
get value(){
if ( dirty ) {
val = effectFn()
dirty = false
}
return val
}
}
return computedObj
}
以上的代码已经实现computed的功能,但还有一个缺陷,就是当在另一个effect中读取计算属性的值时,修改依赖,并不会触发副作用函数的渲染。
effect(()=>{
computed(()=>obj.foo+obj.bar)
})
究其原因,对于计算属性的getter函数(effectFn)来说,它里面访问响应式数据computed内部的effect收集为依赖,而当把计算属性用于另一个effect时,就会发生effect嵌套,外层的effect不会被内层effect中的响应式数据收集。解决的办法是手动调用trac函数和trigger函数进行追踪和触发。
function computed(getter){
let val
let dirty = true
const effectFn = effect(getter,{
lazy:true,
scheduler(){
dirty = true
trigger(computedObj,'value')
}
})
const computedObj = {
get value(){
if ( dirty ) {
val = effectFn()
dirty = false
}
track(computedObj,'value')
return val
}
}
return computedObj
}
7. watch实现原理
watch就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数,实现本质是利用了effect以及options.scheduler选项。
function watch(source,cb) {
effect(()=>source.foo,{
scheduler(cb){
cb()
}
})
}
上面的代码中硬编码了source.foo,为了使得watch具有通用性,需要封装一个读取操作。
watch除了可以观测响应式数据,还可以接受一个getter函数,在getter函数内部,用户可以指定watch依赖哪些响应式数据。
watch的回调函数中可以获取新值和旧值,我们可以利用lazy选项创建一个懒执行effect,最开始手动调用effectFn函数的到一个旧值,并在之后scheduler函数执行时替换旧值。
function watch(source,cb){
let getter
if ( typeof source !== 'function') {
getter = () => traverse(source)
}else {
getter = source
}
let oldV,newV
const effectFn = effect(() => getter(),{
lazy:true,
scheduler(){
newV = effectFn()
cb(newV,oldV)
oldV = newV
}
})
oldV = effectFn()
}
//递归读取对象中的值
function traverse(value,seen = new Set()) {
if ( typeof value !== 'object' || value === null || seen.has(value)) return
seen.add(value)
//{a:{b:{,d:{}}}}
for (const key in value) {
traverse(value[key],seen)
}
return value
}
watch有两个特性:一是立即执行的回调函数,二是回调函数执行的时机。
立即执行的和后续执行没有太大的差别,我们可以为watch传入一个immediate参数控制是否立即执行,同时把scheduler调度函数封装为一个函数。
function watch(source,cb,options={}){
let getter
if ( typeof source !== 'function') {
getter = () => traverse(source)
}else {
getter = source
}
let oldV,newV
const job = () => {
newV = effectFn()
cb(newV,oldV)
oldV = newV
}
const effectFn = effect(() => getter(),{
lazy:true,
scheduler:job
//vue中通过flush来执行调度函数执行的时机
// scheduler: () => {
// if (options.flush === 'post') {
// const p = Promise.resolve()
// p.then(job)
// }else {
// job()
// }
// }
})
if ( options.imediate ) {
job()
}else {
oldV = effectFn()
}
}
当副作用函数为异步函数时,连续多次修改obj则可能会发生竞态问题,即第二次的结果先于第一次返回,最终则显示为第一次(过期)的结果,因此,我们需要一个让副作用过期的手段。在watch内部每次检测到变更后,副作用函数执行之前,先调用我们通过onInvalidate函数注册的过期回调。
function watch(source,cb){
let getter
if ( typeof source !== 'function') {
getter = () => traverse(source)
}else {
getter = source
}
let oldV,newV
let cleanUp
function onInvalidate(fn){
cleanUp = fn
}
const job = () => {
newV = effectFn()
if ( cleanUp ) {
cleanUp()
}
cb(newV,oldV,onInvalidate)
oldV = newV
}
const effectFn = effect(() => getter(),{
lazy:true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
}else {
job()
}
}
})
if ( options.imediate ) {
job()
}else {
oldV = effectFn()
}
}
watch(obj,async(newv,oldv,onInvalidate) => {
let expired = false
onInvalidate(()=>{
expired = true
})
const res = await fetch('')
if (!expired) finalData = res
})