vue3响应式系统(3)

121 阅读13分钟

上一篇我们改善和解决了Vue3的几点不足,本篇文章我们来看看computed/watch的实现原理。

调度执行

在讲解computed/watch实现原理之前,先了解调度执行

可调度是响应式系统的一个特性,所谓可调度指的是当trigger触发的副作用函数重新执行时我们有能力控制它的执行时机,次数以及方式。

执行时机

提供以下测试例子:

effect(() => {
    console.log(data.version) // 3
})
console.log('start')
data.version++
console.log('end')

默认的打印顺序如下:

3
start
4
end

现在我们希望打印顺序如下:

3
start
end
4

我们很容易就想到只有当trigger动作触发的副作用函数是异步任务才会有上述的结果,那么我们可以给effect多传一个参数options对象,使用它的属性scheduler作为调度器去决定副作用函数执行的时机,其中,scheduler是一个函数,接收副作用函数为参数,在trigger动作触发执行副作用函数时,我们判断是否存在options.scheduler,如果存在,则执行该调度器,否则直接执行副作用函数,上代码:

const effect = (fn, options) => {
  const effectFn = () => {
    // 将当前的副作用函数赋给activeEffect
    activeEffect = effectFn;
    activeEffect.options = options // 新增
    effectStack.push(effectFn)
    // 清除上一次所有对象属性对该依赖的收集
    cleanUp();
    // 执行副作用函数
    fn();
    effectStack.pop() // 当前依赖执行完后则弹出
    activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶

  };
  effectFn();
};

trigger函数如下:

const trigger = (target, key) => {
  const deps = bucket.get(target)?.get?.(key);
  const depsToRun = new Set() // 这一句是避免无限循环
  deps && deps.forEach(fn => {
    if (fn !== activeEffect) { // `trigger`触发执行的副作用函数和当前执行的函数如果是同一个,就不触发执行
        depsToRun.add(fn)
    }
  })
  depsToRun.forEach((fn) => {
    if (fn?.options?.schedule) { // 新增
        fn.options.schedule(fn)
    } else {
        fn() // 原先默认方式
    }
  });
};

那么我们的测试例子就可以修改成以下:

effect(() => {
    console.log(data.version) // 3
},{
    scheduler(fn) {
        setTimeout(fn, 0)
    }
})
console.log('start')
data.version++
console.log('end')

这样便可以决定副作用的执行时机了,得到期待的结果了。

执行次数

提供以下测试例子:

// 这里data.version默认为3
effect(() => {
    console.log(data.version) 
}
data.version++
data.version++

以上例子会打印3, 4, 5,但是我们知道,4在这里只是一个过渡状态,可以不必执行这一次的副作用函数,确切地说,无论data.version修改了多少次,我们希望一个任务周期之内只执行一次(聪明的你是不是想到Vue中也有类似的处理了),那么,如何设计呢?

  1. 将副作用函数放在Set队列里,利用Set数据结构去重
  2. 开启一个异步任务,利用事件循环的执行机制:
    1. 当前代码执行过程中,如果遇到微任务(Promise),就把它放进微任务队列,如果遇到宏任务(setTimeout/setInterval),就放到宏任务队列,
    2. 执行完毕之后,查看微任务队列是否为空,不为空则拿出来一个执行,执行过程过程中也会遇到新的宏任务和微任务,同样按照上述方式处理,直至微任务队列为空
    3. 查看宏任务队列是否为空,不为空则取出来一个执行,重复1),2)

等当前任务的代码执行完毕后,开始执行该异步任务,而我们的副作用函数队列正是在该任务中执行的,于是

const callback = new Set() // 存放副作用函数
const p = Promise.resolve()
let isFlushing = false // 可以保证只创建了一个微任务:即意思是无论调用多少次 flushJob 函数,在一个周期内都只会执行一次
const flushCallback = () => {
    if (isFlushing) return
    isFlushing = true
    p.then(() => { // 创建一个微任务:这一部分代码会在同步代码执行完后执行
        callback.forEach(fn => fn())
    }).finally(() => {
        isFlushing = false
        callback.clear()
    })
}

测试如下:

effect(() => {
    console.log('effect')
    document.body.innerHTML = `${data.name}${data.version} `
}, {
    scheduler(fn) {
        callback.add(fn)
        flushCallback() // 刷新队列
    }
})

data.version++
data.version++
data.name = 'react'

分析上述代码:

  • isFlushing最开始为false
  • 第一次data.version++时,副作用函数添加到callback,执行flushCallback,将isFlushingtrue,开启了一个微任务,但是由于是异步任务,这部分代码会在同步代码执行完毕后执行
  • 第二次data.version++时,同样执行上述操作(添加副作用函数到callback和执行flushCallback),不同的是,由于callbackSet结构,具有去重能力,副作用函数实际上只被添加了一次,isFlushingtrue,保证了一个任务周期内flushCallback的异步代码只会被执行一次。
  • 当所有的同步代码执行完毕后,开始执行flushCallback的异步代码,callback里面的副作用函数执行完毕,最后重置isFlushingfalse

这样我们便可以决定副作用函数的执行次数了。代码查看

computed

有了上述铺垫,现在我们可以开始学习computed原理了。

lazy

提供以下测试例子:

// 副作用函数会立即执行
effect(() => {
    console.log(1)
})

如果希望副作用延迟执行可以怎么做呢?给effect传入选项options.lazy对副作用函数的执行进行控制,并返回包装好的副作用函数。

const effect = (fn, options) => {
  const effectFn = () => {
    // 将当前的副作用函数赋给activeEffect
    activeEffect = effectFn;
    activeEffect.options = options 
    effectStack.push(effectFn)
    // 清除上一次所有对象属性对该依赖的收集
    cleanUp();
    // 执行副作用函数
    fn();
    effectStack.pop() // 当前依赖执行完后则弹出
    activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶

  };
  if (!options.lazy) { // 新增
      effectFn();
  }
  return effectFn
};

然后就可以这样使用

const effectFn = effect(() => {
    console.log()
}, {
    lazy: true
})
effectFn()

我们来看Vue3中的computed用法

const computedObj = computed(getter)
// 读取
computedObj.value

那么,我们传给effect函数的fn可不可以也是一个getter呢?答案显然可以的。并且让我们包装后的副作用函数执行返回getter的值,那么effect修改如下:

const effect = (fn, options) => {
  const effectFn = () => {
    // 将当前的副作用函数赋给activeEffect
    activeEffect = effectFn;
    activeEffect.options = options 
    effectStack.push(effectFn)
    // 清除上一次所有对象属性对该依赖的收集
    cleanUp();
    // 执行副作用函数
    const res = fn(); // 新增
    effectStack.pop() // 当前依赖执行完后则弹出
    activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶
    return res // 新增
  };
  if (!options.lazy) { 
    effectFn();
  }
  return effectFn
};

测试如下:

const effectFn = effect(() => data.name + data.version, {
    lazy: true
})

const value = effectFn()

console.log(value) // vue3

据上我们可以实现一个简单的computed:

const computed = (getter) => {
    const effectFn = effect(() => data.name + data.version, 
    {
        lazy: true
    }
    )
    const obj = {
        get value() {
            return effectFn()
        }
    }
    return obj
}

使用:

const computedObj = computed(() => data.name + data.version, {
   lazy: true
}) 
console.log(computedObj.value)
console.log(computedObj.value)

发现computed虽然做到了懒加载,但是并没有做到缓存,每次调用computedObj.value都会重新执行副作用函数,因此我们引入dirty来标识是否已经计算过了

const computed = (getter) => {
    const effectFn = effect(getter, {
        lazy: true
    })
    let dirty = true
    let value = effectFn()
    return {
        get value() {
            if (dirty) {
                value = effectFn() 
                dirty = false
            }
            return value
        }
    }
}

这个时候computed有缓存的效果了,但是又发现了一个问题:修改data.name或者data.versioncomputed还是旧值,原因很简单,读取了value的值后,dirty总是为false,因此computed实际上只计算了一次,所以我们在修改data.name或者data.version时,应该设置dirtytrue,以重新计算,那么在哪里设置呢?聪明的你已经想到了,就是上文的sceduler方法,上代码:

const computed = (getter) => {
    let dirty = true
    let value

    const effectFn = effect(getter, {
        lazy: true,
        scheduler(fn) { // 新增
            dirty = true
        }
    })
    
    return {
        get value() {
            if (dirty) {
                value = effectFn() 
                dirty = false
            }
            return value
        }
    }
}

上面代码还有一个缺陷,提供以下测试例子: 在另一个effect里面使用computedObj

const computedObj = computed(() => {
    return data.name + data.version
})
effect(function fn() {
    console.log(computedObj.value)
})

按照期待,当computedObj依赖的data.name或者data.version发生改变时,fn会重新执行,然而事实并没有,其实这也可以看成嵌套的effectdata.name或者data.version只收集了内层effect,那么外层effect只能靠computedObj自己收集了,于是利用前面封装的tracktrigger有:

const computed = (getter) => {
    let dirty = true
    let value

    const effectFn = effect(getter, {
        lazy: true,
        scheduler(fn) { 
            if (dirty) return
            dirty = true
            // 触发依赖
            trigger(obj, 'value') // 新增
        }
    })
    
    const obj =  {
        get value() {
            if (dirty) {
                value = effectFn() 
                dirty = false   
            }
            track(obj, 'value') // 新增
            return value
        }
    }
    return obj
}

完整代码查看

watch

初步实现

首先来看watch的用法:

watch(data, () => {})

data发生改变时,执行回调函数。首先我们想一下,这个回调函数是不是可以是传给effect的副作用函数,也可以是scheduler函数,然后这句话我们再来解读这句话。

  1. 要想data的任意key对应的值发生改变时触发回调函数,那么就必须再effect里面遍历读取了整个对象
  2. 既然effect的副作用函数已经用于遍历读取data了,那么回调函数只能由scheduler充当了。 由此初步实现一个watch
const watch = (source, cb) => {
  effect(
    // 调用 traverse 递归地读取
    () => traverse(source),
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb();
      },
    }
  );
};

const traverse = (value, seen = new Set()) => {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== "object" || value === null || seen.has(value)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value);
  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen);
  }
  
  return value;
};

watch除了可以观测响应式对象,也可以观测getter, 如

    watch(()=> data.name, () => {
        console.log('数据已经发生改变')
    }) 

所以我们先对watch接收的第一个函数进行判断

    const watch = (source, cb) => {
     let getter = typeof source === 'function' ? source : () => traverse(source)
      effect(
        getter,
        {
          scheduler() {
            // 当数据变化时,调用回调函数 cb
            cb();
          },
        }
      );
    };

新值和旧值

watch的一个特性是可以在回调函数里拿到新值和旧值,那么为了实现这个功能,我们利用上面的lazy选项,实现如下:

const watch = (source, cb) => {
 let getter = typeof source === 'function' ? source : () => traverse(source)
 let oldValue, newValue
 //  为了拿到值,使用懒执行的方式
  const effectFn = effect(
    getter,
    {
      lazy: true,
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        newValue = effectFn()
        cb(newValue, oldValue);
        oldValue = newValue // 回调函数执行完毕后记得把newValue赋给oldValue
      },
    }
  );
  // 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
  oldValue = effectFn()
};

上面会有一个问题,当第一个参数是对象时,新旧值是一样的,原因可以使用以下例子说明:

image.png 因此我们要做出改善:

const watch = (source, cb) => {
 let getter = typeof source === 'function' ? source : () => traverse(source)
 let oldValue, newValue
 //  为了拿到值,使用懒执行的方式
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        newValue = effectFn()
        cb(newValue, oldValue);
        oldValue = {...newValue} // 回调函数执行完毕后记得把newValue赋给oldValue
      },
    }
  );
  // 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
  oldValue = {...effectFn()}
  console.log(oldValue)
};

完整代码

立即执行的watch和回调执行的时机

立即执行

上述wacth初始化时并不会执行回调函数,那么我们希望它立即执行回调函数可以怎么做呢?其实立即执行回调函数和后面数据发生执行的回调函数本质上并没有什么不同,因此我们可以把 scheduler 调度函数封装为一个通用函数,分别在初始化和变更时执行它,并通过传给watch第3个参数进行控制

const watch = (source, cb, options) => {
 let getter = typeof source === 'function' ? source : () => traverse(source)
 let oldValue, newValue
  const job = () => {
    // 当数据变化时,调用回调函数 cb
    newValue = effectFn()
    cb(newValue, oldValue);
    oldValue = {...newValue} // 回调函数执行完毕后记得把newValue赋给oldValue
  }
 //  为了拿到值,使用懒执行的方式
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: job
    }
  );
 
 
  // 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
  oldValue = {...effectFn()}

  if (options.immidiately) {
    job()
  }
};

执行时机

这实际上跟我们前面讲解调度执行一致,我们通过参数flushscheduler里面去控制job的执行,当 flush 的值为 post 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行,那么:

 //  为了拿到值,使用懒执行的方式
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') { // flush的值还可以是'pre'等,根据不同的值去控制job的执行时机
            const p = Promise.resolve()
            p.then(job)
        } else {
            job()
        }
      }
    }
  );

完整代码

过期的副作用

假设我们提供以下测试例子:

let finalRes
watch(data, async () => {
    finalRes = await fetch('/xxx') 
})

分析上面测试例子:

  1. 假设data第一次发生改变时,发送请求A
  2. 在请求A还没有返回结果之前data再次发生改变,于是发送第2次请求B
  3. 请求A400ms后返回结果,而请求B200ms后返回结果
  4. 那么最后finalRes的值是A返回的结果,这明显与我们的期望不符

所以我们要解决的问题便是如何将上一次的副作用结果判断为无效,实际上我自己想了很久也没有想出来,于是我们直接上代码:

const watch = (source, cb, options = {}) => {
 let getter = typeof source === 'function' ? source : () => traverse(source)
 let oldValue, newValue
 let cleanUp
  const onValidate = (fn) => { // 新增
    cleanUp = fn
  }
 const job = () => {
    if (cleanUp) { // 新增
      cleanUp()
    }
    // 当数据变化时,调用回调函数 cb
    newValue = effectFn()
    // 将onValidate作为第3个参数传给回调函数
    cb(newValue, oldValue, onValidate);
    oldValue = {...newValue} // 回调函数执行完毕后记得把newValue赋给oldValue
  }

  
 
 //  为了拿到值,使用懒执行的方式
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') { // flush的值还可以是'pre'等,根据不同的值去控制job的执行时机
            const p = Promise.resolve()
            p.then(job)
        } else {
            job()
        }
      }
    }
  );

  if (options.immediate) {
    job()
  } else {
    // 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
    oldValue = {...effectFn()}
  }
};

// 模拟请求
let finalRes
let count = 2
watch(data, async (newVal, oldVal, onValidate) => {
    let expired = false
    let date = new Date().valueOf()
    onValidate(() => {
      console.log(date, '-------') // 这个date应该是上一次的回调函数执行的date,与下面的date是不同的值
      expired = true
    })
    const res = await getRes(count)
    if (!expired) {
      finalRes = res
    }
    console.log('发生改变:', finalRes, res, expired, date)
})

const getRes = (times) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(times)
    }, times * 1000)
  })
}

data.version = count
count = 1
setTimeout(() => data.version = count, 0)

首先我是利用count的值来区分2次不同的请求,其中包括控制getRes返回的时机,返回值为count,并且在onValidate打印date以区分是哪个请求 然后我们就来分析上面的代码:

  1. 第一次修改data.version会执行回调函数(我们把第一次执行的回调函数设为fn1),fn1执行onValidate把入参函数赋给cleanUp
  2. 第2次修改data.version,由于cleanUp保存着fn1传进来的函数,这个函数在fn1中是闭包的形式,所以它执行后修改的是fn1中的expired,把expired设置为true,然后执行回调函数(设为fn2
  3. 1000ms后,fn2返回结果,因为expired仍为false,所以finalResfn2返回的结果
  4. 2000ms后,fn1返回结果,因为expiredtrue,放弃该结果,所以finalRes仍为fn2返回的结果

这样就可以解决过期的副作用函数了,总结来说,思路是这样的

  1. 本次会给下一次的回调函数注册一个函数,用于控制本次的状态
  2. 本次先执行上一次注册的函数,再执行真正的回调函数

完整代码

本篇文章就到这里啦终于结束了