前言
本篇文章是《Vue.js设计与实现》第 4 章 响应系统的作用与实现笔记,其中的代码和图片部分来源于本书,用于记录学习收获并且分享。
在之前的文章Vue3响应式基本原理中我们实现了一个基本的副作用函数,现在在此基础上讨论如何实现computed 和watch 。
一、副作用函数的可调度执行
为了实现computed
和watch
,我们需要对普通的副作用函数进行改造,使其支持可调度执行 。 可调度执行指的是 trigger函数
被触发使得副作用函数重新执行时,可以指定其执行的时机次数和方式。
我们可以为effect
函数添加一个参数options
用于配置调度器,并将其挂载到副作用函数上。 改造后的effect
函数如下:
js
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 新增:将 options 挂在到 effectFn 上
effectFn.options = options
effectFn.deps = []
effectFn()
}
当trigger
触发时运行副作用函数时,查看副作用函数上是否有options
以及其中的scheduler
调度器配置,有的话就运行调度器,并将副作用函数作为调度器的参数传入,从而可以在调度器内部控制副作用函数的执行。
js
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()
}
})
}
经过改造之后我们就可以通过如下方式使用options
来配置调度器。
js
effect(
()=>{
...
},
// options
{
//调度器配置函数
scheduler(fn){
...
}
}
)
二、实现computed
从功能上看computed
的功能和effect
类似,都是在响应式数据变化的时候会触发运行,但computed
却有一些特性effect
不具备,这就需要使用options
实现:
1.懒计算
computed
并不会在其依赖的响应式数据产生变化的时候立即响应,而是 在需要使用到computed
值得时候才会去响应。 为了实现懒计算的效果:我们通过配置options
来实现:
js
effect(
()=>{}
,{
lazy:true
}
)
通过传入lazy:true
,并在effect
中进行判断lazy
的值为true
的时候就不立即执行.
js
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂在到 effectFn 上
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
if (!options.lazy) {
effectFn()
}
return effectFn
}
上面的操作 仅仅只是阻止了副作用函数的立即执行 。 我们如何在需要的时候去手动执行副作用函数并获取结果呢?
我们需要对副作用函数进行修改,使其能够返回副作用函数,从而可以自定义其执行方式。
js
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
const res = fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
//返回fn
return res
}
// 将 options 挂在到 effectFn 上
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
if (!options.lazy) {
effectFn()
}
return effectFn
}
这样我们只要手动执行effect
就能拿到执行结果
js
const effectFunc = effect(
()=>{}
,{
lazy:true
}
)
effectFunc()
接下来我们只需要组合这些调整即可得到一个懒执行的副作用函数
js
function computed(getter) {
let value
let dirty = true
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)
2.缓存数据
计算属性的另一个特征是缓存数据: 计算属性依赖的值如果没有发生变化,则在执行的情况下直接返回返回上一次计算好的值,为了实现这一功能,我们需要:
- 对上一次执行的值进行缓存,存于
value
中 - 增加一个标识
dirty
,用于确定是否需要去重新计算,true
则需要重新计算
js
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}
在lazy
为true
的情况下,若调度器scheduler
被触发意味着依赖的数据发生变化,此时将dirty
设置为 true
。当下一次再访问sumRes.value
时会effectFn()
重新计算,并缓存给数据value
.
3.特殊情况:computed被另一个副作用函数嵌套:
当在副作用函数中读取计算属性值时:
js
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
console.log(sumRes.value)
})
obj.foo++
该代码并不会使得effect
重新执行,因为此时发生了副作用函数的嵌套,此时外层的副作用函数是不会被内层副作用函数的响应式数据所收集的。为了解决这个问题,我们在computed
中手动去调用track
和trigger
方法,使得computed
的返回值和外层的副作用函数建立联系。
js
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value') //新增
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value') //新增
return value
}
}
return obj
}
三、实现watch
watch
可以监听具体响应式数据的变化,类似于一个回调函数,而effect
则是其中的响应式数据发生变化时会使其本身重新执行。
watch
的特点及实现如下:
1.可侦听响应式数据和getter函数
如果需要监听一个响应式数据,则直接在调度器中执行watch
的回调函数cb
即可
js
function wawtch(source,cb){
effect(
()=> cource.foo,
{
scheduler(){
cb()
}
}
)
}
const data = { foo:1 }
const obj = new Proxy(data,{...})
watch(obj,()=>{
console.log('数据变化了')
})
上面的实现会有一些局限性:只能检测obj.foo
的变化,为了能够对整个侦听对象进行观测,我们需要在副作用函数中对所侦听数据的所有的属性都进行读取。
js
function watch(source, cb) {
effect(
()=> traverse(source),
{
scheduler(){
cb()
}
}
)
}
function traverse(value, seen = new Set()) {
//原始值、null、或者已经读取过的不再处理
if (typeof value !== 'object' || value === null || seen.has(value)) return
//保存已经读取过的值
seen.add(value)
//递归遍历,能读取到更深层的数据
for (const k in value) {
traverse(value[k], seen)
}
return value
}
通过增加traverse
函数对source
进行处理能够保证source里面的数据都被读取从而和副作用函数建立联系。
除了监听数据,watch
还可以监听一个getter
函数,所以需要在上述实现中增加对source类型的判断,若传入一个函数证明是getter
直接使用:
js
function watch(source, cb) {
let getter
//传入的是getter
if(typeof source === 'function'){
getter = source
}else{
getter = traverse(source)
}
effect(
()=> getter
{
scheduler(){
cb()
}
}
)
}
2.获取新值和旧值
watch
还有一个特点就是其可以获取到侦听数据的新值和旧值,如下所示:
js
watch(
()=>obj.foo,
(newVal,oldVal)=>{
...
}
)
为了能够获得每一次副作用函数运行的值,我们需要使用之前定义在options
的lazy
属性,这样就能通过手动调用副作用函数获取运行值。
js
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
newValue = effectFn()
cb(oldValue, newValue)
oldValue = newValue
}
}
)
oldValue = effectFn()
}
如上,我们启用了lazy
属性并定义了oldValue
和newValue
,首次运行手动运行副作用函数获取初始值作为oldValue
,并在调度器中运行副作用函数,在后续侦测的数据产生变化时更新新旧值,将新旧值作为参数传递给watch
的回调cb
。
3.回调执行时机: immediate
当immediate
被设为true
时,watch
的 回调函数会被立即执行一次 。
而我们在上面实现的watch
的回调函数只会在响应式数据source
变化的时候才会执行。
为了在首次创建时就能执行一次回调函数,需要对watch
函数进行一些修改:
js
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(oldValue, newValue)
oldValue = newValue
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: job
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
如上,我们将和回调函数有关的逻辑放入一个单独的函数job
中,并在watch
中新增参数options
用于配置immediate
。当immediate
为true
时,直接运行一次job
,从而使得回调函数在source
未变化时便执行。
4.回调函数的触发时机: post 和 sync
watch中还有参数flush
用于指定调度函数的执行时机,当flush
为post
则,watch
中的副作用函数需要放到微任务队列中,等待DOM更新完毕后再执行。为此需要在调度器中对flush
进行判断,当flush
为post
时,使用Promise
将job
放入微任务队列。而flush
为sync
时则是同步执行,直接执行job
即可。 经完善后的watch如下:
js
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(oldValue, newValue)
oldValue = newValue
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
总结:
- 我们可以在
effect
上添加options 用于配置副作用函数,并在options
中添加scheduler函数
,从而使得副作用函数支持可调度执行; - 通过给副作用函数添加
lazy
选项,使其能够支持懒执行 ,从而能够以手动的方式执行副作用函数,实现了computed 。在配置了副作用函数的scheduler
使其支持可调度执行后,通过使用dirty
标记响应式数据是否变化,从而实现了数据的缓存 ,只有数据变化时才去重新执行副作用函数; - 通过配置副作用函数的
scheduler
后,在调度器中添加回调函数 实现watch 监听数据改变后的回调。并添加immediate
选项去控制回调函数是否在watch
创建时执行 ,添加flush
选项去决定回调函数是异步还是同步执行;