vue3读书笔记

140 阅读19分钟

vue3读书笔记之深入响应式系统

vue3发布已经很长一段时间了,最近学习vue原理,记录下来省的忘了,感谢祖师爷尤大赏饭

proxy

首先来看看聊烂了的proxy的使用:

var obj = {
  a: 1
}

var newObj = new Proxy(obj, {
  get(target, key) {
      console.log(target[key])
      return target[key]
  },
  set(target, key, val){
      target[key] = val
      return true
  }
})
newObj.a = 2
newObj.b = 3
console.log(newObj.a) // 2
console.log(newObj.b) // 3

可以看到,在初始化的obj中并不存在b属性,但proxy也可以拦截,这是object.defineProperty不具备的,这也是vue3中不再需要$set的原因

响应式系统实现

在响应式系统实现之前,先来说说副作用函数effect,如果有写过react hook,那么对这个effect一定不会陌生,vue3中的effect与其类似,都是内部函数可以对外产生影响的函数。看如下代码:

var obj = {text: 'hello world'}
function effect(){
    document.body.innerHtml = obj.text
}

暂时把obj看做一个响应式对象,那么我们改变obj.text,则会引起视图的改变,那么effect就是一个副作用函数。那么我们如何将obj包装成响应式呢?

  • 当副作用函数effect执行,会发生obj.text的读取操作
  • 修改obj.text的值,会发生obj.text的设置操作

那如果我们能拦截读取和设置操作,就变得可行了,当发生读取操作时,我们将整个副作用函数放进一个“桶”里,当发生设置操作,就将副作用从桶里取出并执行即可。

var obj = {text: 'hello world'}
var bucket = new Set()
var data = new Proxy(obj, {
  get(target, key) {
    bucket.add(effect)
    return target[key]
  },
  set(target, key, val) {
    target[key] = val
    bucket.forEach(fn => fn())
    return true
  }
})
function effect() {
  document.body.innerHTML = data.text 
  // console.log(data.text)
}
effect()
setTimeout(() => {
  data.text = '响应式系统'
}, 3000);

运行如上代码,可以看到三秒后页面中的内容改变了,但目前还有很多问题,逐步修改。

effect函数去具名化

上边代码指定副作用函数叫做effect,但实际使用可能是别的函数,甚至是匿名函数,所以我们要去掉这种具名化命名方式。修改effect函数如下:

// 用一个全局变量存储被注册的effect
var activeEffect
// effect变成一个注册副作用函数的函数
function effect(fn) {
    activeEffect = fn
    fn()
}
var obj = {text: 'hello world'}
var bucket = new Set()
var data = new Proxy(obj, {
  get(target, key) {
    // 此处修改
    activeEffect && bucket.add(activeEffect)
    return target[key]
  },
  set(target, key, val) {
    target[key] = val
    bucket.forEach(fn => fn())
    return true
  }
})
effect(function effectFn(){
    document.body.innerHTML = data.text
})
setTimeout(() => {
  data.text = '响应式系统'
}, 3000);

目前我们的副作用函数已经可以不依赖固定的具名函数了,但因为是使用proxy,如果此时设置属性:data.newText = '我是新增的属性',还是会触发set操作,进而再执行一次effect,但其实这次的effect是没有必要的。究其原因,是因为我们没有把属性和effect对应起来。所以我们要重新设计一下bucket。

设计bucket

分析一下,我们有用的几个变量分别是:target,key,以及使用effect注册的函数effectFn,他们的几种关系如下:

  • 一个属性对应一个副作用函数:

image.png

  • 两个副作用函数同时读取一个属性:
effect(function effectFn1(){
    data.text
})
effect(function effectFn2(){
    data.text
})

image.png

  • 多个属性对应一个副作用函数:
effect(function effectFn() {
    return data.num1 + data.num2
})

image.png

  • 不同副作用函数对应不同对象的不同属性:
effect(function effectFn1(){
    data1.text
})
effect(function effectFn2(){
    data2.name
})

image.png

接下来,我们重新设计桶结构。首先,我们的bucket需要使用weakMap,桶的key就是我们的对象target,桶的value是一个map,用来存放target[key],这个map的value是一个Set结构,用来存放这个key对应的effect函数,因为Set结构天然去重。那为什么我们的桶要用weakMap结构呢?这就涉及到weakMap的优点了:

与map不同的是,weakMap的key只能是object,由于weakMap是弱引用,所以当我们的target对象没有引用的时候,也就是用户侧不需要它的时候,垃圾回收器就可以自动清除它,而不需要手动清除。但如果使用Map代替WeakMap,即是target没有引用,垃圾回收器也不会进行回收,最终会导致内存溢出。

尝试实现这个桶结构如下:

const data = new Proxy(obj, {
  get(target, key) {
    if(!activeEffect) return
    // 得到target的map结构
    let depsMap = bucket.get(target)
    // 如果这个map不存在,就向桶中加入当前target
    !depsMap && bucket.set(target, (depsMap = new Map()))
    // 获取到map中的当前key的依赖集合
    let deps = depsMap.get(key)
    // 如果不存在就新创建一个Set集合,用来装effect函数
    !deps && depsMap.set(key, (deps = new Set()))
    // 将当前注册的effect加入
    deps.add(activeEffect)
    return target[key]
  },
  set(target, key, val) {
    target[key] = val
    // 获取到当前对象的map
    let depsMap = bucket.get(target)
    if(!depsMap) return
    // 根据key属性值获取到map中的所有当前属性值关联的effect函数
    let effects = depsMap.get(key)
    // 遍历所有effect执行之
    effects && effects.forEach(effect => effect());
    return true
  }
})

现在这里已经可以实现只对当前effect函数内依赖变量改变的时候执行副作用函数了,我们在把get和set中的部分抽离出来,分别用track和trigger封装一下:

const data = new Proxy(obj, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, val) {
    target[key] = val
    trigger(target, key)
    return true
  }
})

function track(target, key) {
  if(!activeEffect) return
  let depsMap = bucket.get(target)
  !depsMap && bucket.set(target, (depsMap = new Map()))
  let deps = depsMap.get(key)
  !deps && depsMap.set(key, (deps = new Set()))
  deps.add(activeEffect)
}
function trigger(target, key) {
  let depsMap = bucket.get(target)
  if(!depsMap) return
  let effects = depsMap.get(key)
  effects && effects.forEach(effect => effect());
}
分支切换与cleanup

首先明确一下分支切换的概念,思考如下代码:

const obj = {ok: true, text: 'hello world'}
const data = new Proxy(obj, {/****/})
effect(function effectFn() {
  document.body.innerHTML = data.ok ? data.text : 'not'
})

经过分析,我们可以建立键值及effect依赖关系如下:

image.png 分析一下:当data.ok变为false后,document.body.innerHtml的值变为‘not’,如果此时修改key2(data.text),由于这个data.text会触发Proxy中的set操作,所以会将key2所依赖的副作用都遍历执行一遍,由于effectF依然在depsMap中,那么effectFn就会执行,但我们此时并不需要它去执行,因为页面中由于data.ok的值为false,我们并不关心data.text的值,所以我们需要想办法把这次的副作用给干掉。

解决的思路就是:每次副作用函数执行时,我们先把它从所有与之关联的依赖集合中删除,然后在执行完毕副作用函数的过程中会重新依赖,那么此时我们当前的属性如果不再需要这个effect,就不会将他收集起来,以此达到我们去掉多余副作用的目的。因此我们需要重新设计effect函数。如下面代码所示:在effect内部定义一个新的effectFn函数,为其添加effectFn.deps属性,改属性是一个数组,用来存储所有包含当前副作用函数的依赖集合。

let activeEffect
function effect(fn) {
  const effectFn = () => {
    // 当effctFn执行时,将本effectFn设置为当前激活的activeEffect
    activeEffect = fn
    fn()
  }
  // activeEffect.deps用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  effectFn()
}

那effectFn.deps数组中的依赖集合是如何收集的呢?其实是在track函数中:

function track(target, key) {
  if(!activeEffect) return
  let depsMap = bucket.get(target)
  !depsMap && bucket.set(target, (depsMap = new Map()))
  let deps = depsMap.get(key)
  !deps && depsMap.set(key, (deps = new Set()))
  deps.add(activeEffect)
  // 将依赖添加到activeEffect.deps数组中
  activeEffect.deps.push(deps)
}

如此我们便完成了对依赖集合的收集工作,每一个副作用都有一个其依赖的集合。有了这个联系后,我们就可以在每次副作用函数执行时,根据effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:

let activeEffect
function effect(fn) {
  const effecFn = () => {
    // 调用cleanup函数完成清除
    cleanup(effecFn)
    activeEffect = effecFn
    // 调用副作用函数并重新收集依赖
    fn()
  }
  effecFn.deps = []
  effecFn()
}
function cleanup(fn) {
  for(let i = 0; i < fn.deps.length; i++){
    const deps = effecFn.deps[i]
    // 将传入的effecFn从依赖集合中移除
    deps.delete(fn)
  }
  // 重置effectFn.deps数组
  effecFn.deps.length = 0
}

如此我们便可以避免副作用遗留问题了,但其实运行代码,会发现无限循环执行,问题产生在trigger函数中:

function trigger(target, key) {
  let depsMap = bucket.get(target)
  if(!depsMap) return
  let effects = depsMap.get(key)
  effects && effects.forEach(effect => effect()); // 问题在这儿
}

在trigger函数内部,我们遍历effects集合,他是一个Set集合,里面存储着副作用函数,当副作用函数执行时,会调用cleanup函数清除,就是从effects集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合中,此时对effects集合的遍历仍在执行

上述行为相当于如下代码:

const set = new Set([1])
set.forEach(() => {
  set.delete(1)
  set.add(1)
  console.log('运行中')
})

解决方法可以创建另一个Set集合遍历它:

const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(() => {
  set.delete(1)
  set.add(1)
  console.log('运行中')
})

回到trigger函数:

function trigger(target, key) {
  let depsMap = bucket.get(target)
  if(!depsMap) return
  let effects = depsMap.get(key)
  const effectToRun = new Set(effects)
  effectToRun && effectToRun.forEach(effect => effect()); 
}

至此分支切换可以完整实现。

嵌套的effect与effect栈

effect是可以发生嵌套的,vue模板的渲染就是在effect中执行的,所以我们的effect需要支持嵌套。思考如下代码:

var obj = {
  foo: '1',
  bar: '2'
}
const data = new Proxy(obj, {/****/})
let temp1,temp2
effect(function effectFn1() {
  console.log('我是fn1')
  effecFn(function effectFn2() {
    console.log('我是fn2')
    temp2 = data.bar
  })
  temp1 = data.foo
})

执行如上代码并修改data.foo,会发现控制台输出:
// 我是fn1
// 我是fn2
// 我是fn2

分析:首先执行effectFn1,执行effectFn1会间接执行effectFn2,所以前两次输出是没有问题的,有问题的是第三个输出,我们修改data.foo,本来应该触发effectFn1的执行,但现在缺触发了effectFn2的执行,effectFn1反而没有执行,这显然不符合逻辑。那造成这个问题的原因是什么呢?我们看前边的effect实现,每一次都需要activeEffect = effecFn,那么在上边嵌套执行effectFn2的时候,activeEffect也就变成了effectFn2,当我们需要在改变data.foo后执行effectFn1的时候,activeEffect保存的确是effectFn2函数,所以此时执行了一个错误的副作用函数,导致输出不是我们期望的结果。

为了解决这个问题,我们需要一个副作用函数栈effectStack,在副作用函数执行的时候将该副作用函数压入栈中,当副作用函数执行完毕后将此副作用函数弹出函数栈,并且activeEffect永远指向栈顶元素。 代码如下:

let activeEffect
let effectStack = [] // 新增
function effect(fn) {
  const effecFn = () => {
    cleanup(effecFn)
    // 调用effect函数注册副作用函数时,将 effecFn 赋值给 activeEffect
    activeEffect = effecFn
    // 副作用函数入栈
    effectStack.push(effecFn) // 新增
    fn()
    // 执行完毕弹栈
    effectStack.pop() // 新增
    // 重新将栈顶元素赋值给 activeEffect
    activeEffect = effectStack[effectStack.length-1] // 新增
  }
  effecFn.deps = []
  effecFn()
}

如此一来,当执行effectFn1时,effectFn1入栈,接下来effectFn2入栈,effectFn2执行完毕并出栈,activeEffect的值将重新交给effectFn1。

避免无限递归循环

思考如下代码:

var obj = {
  num: 1
}
const data = new Proxy(obj, {/****/})
effect(() => data.num++)

如上代码,最后一行相当于是data.num = data.num + 1,在一个副作用函数中读取和设置,先触发track函数,将该副作用函数保存到桶中,然后在将data.num加一并赋值给data.num,此时触发trigger操作,即把桶中的副作用函数取出执行该副作用函数。但此时的副作用函数正在执行中没有执行完毕,又要开始下一次执行,这样就会无限递归,造成栈溢出。
要解决这个问题,我们先分析一下:这种现象产生在tracK和trigger都在同一个副作用函数的情况下,要执行的副作用函数都是当前的activeEffect。那么我们在set操作中加一个拦截,也就是当发现trigger触发的副作用函数与当前的activeEffect相同,则不触发trigger的执行。代码如下:

function trigger(target, key) {
  let depsMap = bucket.get(target)
  if(!depsMap) return
  let effects = depsMap.get(key)
  const effectToRun = new Set()
  effects.forEach(effect => {
    if(effect !== activeEffect) effectToRun.add(effect)
  })
  effectToRun && effectToRun.forEach(effect => effect()); 
}
调度执行

可调度性是响应式系统非常重要的特性。所谓的可调度,也就是可以随着用户的意愿,在trigger触发副作用函数执行时,可以自定义副作用函数执行的时机、顺序、次数及执行方式。 首先看如下代码:

var obj = {
  num: 1
}
const data = new Proxy(obj, {/****/})
effect(() => console.log(data.num))
data.num++
console.log('结束了')

很容易得出运行结果如下:
// 1
// 2
// 结束了

那如果有个需求,想让其输出结果变为:1、结束了、2,要怎么办呢?这个时候就需要响应是系统支持调度了。
我们可以为effect函数设计一个选项参数options,允许用户指定调度器:

effect(
  () => console.log(data.num),
  // options
  {
    scheduler(fn) {
      // ...
    }
  }
)

如上代码所示: 用户调用effect注册副作用函数时,可以传入一个对象,用以指定scheduler函数,同时在函数内部我们要把options挂载到对应的副作用函数上。

function effect(fn, options = {}) {
  const effecFn = () => {
    cleanup(effecFn)
    activeEffect = effecFn
    effectStack.push(effecFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length-1]
  }
  // 将options挂载到副作用函数上
  effecFn.options = options // 新增
  effecFn.deps = []
  effecFn()
}

有了调度函数,我们在trigger函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,把控制权交给用户

修改trigger函数如下:

function trigger(target, key) {
  let depsMap = bucket.get(target)
  if(!depsMap) return
  let effects = depsMap.get(key)
  const effectToRun = new Set()
  effects.forEach(effect => {
    if(effect !== activeEffect) effectToRun.add(effect)
  })
  effectToRun && effectToRun.forEach(effect => {
    // 如果存在option.scheduler,则将副作用函数作为参数传递
    if(effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }); 
}

有了这个调度器,我们就可以实现上边的需求了,代码如下:

var obj = {
  num: 1
}
const data = new Proxy(obj, {/****/})
effect(
  () => console.log(data.num),
  {
    // 注意这个fn即为第一个参数,即副作用函数
    scheduler(fn) {
      setTimeout(() => {
        fn()
      }, 0)
    }
  }
)
effect(() => console.log(data.num))
data.num++
console.log('结束了')

以上是实现了改变副作用函数的执行顺序,通过调度器还可以做到控制它的执行次数,这一点也很重要。
思考如下代码:

var obj = {
  num: 1
}
const data = new Proxy(obj, {/****/})
effect(() => console.log(data.num))
data.num++
data.num++

代码依次输出:1,2,3。我们的第一次输出1是有必要的,当执行到第一个自加操作时,由于整段代码同属一个任务队列,后续还有data.num的set操作,就是说这次自加操作只是个过渡状态,那么此次的副作用执行也就不是必要的了,我们只想在此次任务队列中的任务都结束后统一更新data.num就好了。

基于调度器,我们可以实现上述功能,代码如下:

var obj = {
  num: 1
}
const data = new Proxy(obj, {/****/})
// 注册一个任务集合,并利用其去重能力
const jobQueue = new Set()
const p = Promise.resolve()
// 一个标志,代表是否正在刷新队列
let isFlushing = false
function flushJob() {
  // 如果当前正在刷新队列,代表此次之前就有当前值的设置操作等待执行,
  // 所以本次及后续的设置操作的副作用函数都不需要执行了
  if(isFlushing) return
  // 第一次执行就将标志锁定
  isFlushing = true
  // 当此次任务队列中任务都执行完毕,才统一执行放在微任务队列中的副作用函数
  p.then(() => {
    jobQueue && jobQueue.forEach(effect => effect())
  }).finally(() => isFlushing = false)
}
effect(
  () => console.log(data.num), 
  {
    scheduler(fn) {
      // 每次trigger都将effectFn放入jobQueue中
      jobQueue.add(fn)
      flushJob()
    }
  }
)
data.num++
data.num++

分析:上述代码中,我们首先定义了jobQueue任务集合,利用其自动去重能力,使得我们每次自加操作向jobQueue中添加副作用函数最终只得到一个副作用函数。当连续改变data.num的值触发trigger操作时,我们不断同步连续地执行effect.options.scheduler函数,但由于flushJob函数通过isFlushing机制,我们只能在一次周期内执行一次flushJob函数。当同步任务执行完毕后,js引擎开始执行微任务队列中的jobQueue循环遍历调用操作,并在微任务执行完毕后,将isFlushing重新置为false。


这个功能很类似vue中连续多次修改响应式数据但只会触发一次更新,实际上vue内部实现了一个更完善的调度器

computed与lazy

深入computed之前,先来看看懒执行的effect,即lazy的effect。举个例子:现在我们实现的effect函数会立即执行传递给它的副作用函数。但有些场景下,我们希望需要他的时候才执行,而不是立即执行,也就是计算属性。这时我们可以通过在options选项中添加lazy来实现。如下代码:

effect(() => console.log(data.num), {lazy: true})

那么我们可以修改之前的effect函数来实现,当options.lazy的值为true时,不立即执行这个副作用函数

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length-1]
  }
  // 将options挂载到副作用函数上
  effecFn.options = options
  effecFn.deps = []
  if(!options.lazy){ // 新增
    effectFn()
  }
  return effectFn // 新增
}

如此,我们可以实现通过传lazy选项控制副作用函数懒执行。在effect函数执行后,我们可以拿到对应的副作用函数,此时可以在手动调用之,如下:

const myComputed = effect(function effectFn(){console.log(data.num)}, {lazy:true})
myComputed()

但其实这样的意义不大,联想我们computed使用场景,如果我们把传递给effect的函数看做一个getter,这个getter可以返回任何值,如:

cosnt effectFn = effect(
  () => data.num1 + data.num2,
  {lazy: true}
)

这样我们在手动执行副作用函数时,就能拿到其返回值:

cosnt effectFn = effect(
  () => data.num1+data.num2,
  {lazy: true}
)
// value是getter的返回值
const value = effectFn()

为了实现这个目标,修改effect如下:

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 // 新增
  }
  // 将options挂载到副作用函数上
  effecFn.options = options
  effecFn.deps = []
  if(!options.lazy){
    effectFn()
  }
  return effectFn
}

好了,现在该实现我们的computed函数了:

function computed(getter) {
  // 获取到懒执行的effectFn
  const effectFn = effect(getter, {lazy: true})
  const obj = {
    // 只在真正读取computed变量的时候才执行副作用函数
    get value() {
      return effectFn()
    }
  }
  return obj
}

const computedValue = computed(() => data.num1 + data.num2)
console.log(computedValue)
console.log(computedValue)
console.log(computedValue)

在computed内部返回一个obj,它拥有一个访问器属性,只有读取value的值时才返回副作用函数的执行结果。
但当前的computed还有缺陷,还没有做到缓存值的功能。也就是说我们访问多次computedValue,会导致多次执行副作用函数。而我们希望在data.num1和data.num2没有变化的时候不执行副作用函数,即对其添加缓存机制。
优化后的代码如下:

function computed(getter) {
  // 缓存上一次的值
  let value
  // 是否是脏的标志,如果为脏,则需要重新计算
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    // 添加调度器,只有当fn中依赖的值发生变化的时候才会触发scheduler的执行
    scheduler() {
      dirty = true
    }
  })
  const obj = {
    // 只在真正读取computed变量的时候才执行副作用函数
    get value() {
      if(dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return obj
}

我们为effectFn添加scheduler,他会在getter函数中依赖的响应式数据改变时执行。这样我们将dirty置为true,下一次访问computedValue时,就会重新调用effectFn计算新值。

watch的实现

watch就是观测一个响应式数据,当数据改变时,执行其第二个参数回调。事实上,watch还是利用了effect及options.scheduler选项。
我们知道,在一个副作用函数中访问响应式数据,会在响应式数据属性和副作用函数之间构建联系。当修改响应式数据后,就会触发副作用函数的更新。但如果传入了options.scheduler就会跳过这个副作用函数,执行options.scheduler。那么我们可以来实现一个简单的watch如下:

function watch(data, cb) {
  effect(
    // 触发读取操作,与属性建立联系
    () => console.log(data.num),
    {
      scheduler() {
        cb()
      }
    }
  )
}

var obj = {
  num: 1
}
const data = new Proxy(obj, {/****/})
watch(data, () => console.log('数据改变了'))
data.num++

执行上边代码,可以发现触发watch函数了。但我们的硬编码只能观测到data.num改变。为了让其有通用性,需要封装一个通用的读取操作:

function watch(source, cb) {
  effect(
    // 触发读取操作,与属性建立联系
    () => tranverse(source),
    {
      scheduler() {
        cb()
      }
    }
  )
}
function tranverse(value, seen = new Set()) {
  // 如果要读取的数据是原始数据类型或已经读过,则什么都不做
  if(typeof value !== 'object' || typeof value === null || seen.has(value)) return
  seen.add(value)
  // 暂时不考虑数组等结构,假设value就是对象,使用for...in...读取对象的每一个值,递归调用tranverse处理
  for(let key in value) {
    tranverse(value[key], seen)
  }
  return value
}

那么watch的第一个参数除了响应式数据,还可以传入一个getter。在getter内部可以指定依赖哪些响应式数据,当这些数据变化时,才会触发回调函数执行。如下边代码:

watch(
  () => data.num,
  () => console.log('num变了')
)

修改代码如下:

function watch(source, cb) {
  let getter
  if(typeof source === 'function') {
    getter = source
  } else {
    getter = () => tranverse(source)
  }
  effect(
    // 执行getter
    () => getter(),
    {
      scheduler() {
        cb()
      }
    }
  )
}

目前已经实现了大部分watch的能力,但还有一个比较重要的没有实现,那就是在回调中我们可以拿到旧的value和新的value。如:

watch(
  () => data.num,
  (newVal, oldVal) => console.log(newVal, oldVal)
)
data.num++

那如何获取新老值呢?还是lazy!

function watch(source, cb) {
  let getter
  if(typeof source === 'function') {
    getter = source
  } else {
    getter = () => tranverse(source)
  }
  let oldVal, newVal
  const effectFn = effect(
    // 执行getter
    () => getter(),
    {
      lazy: true,
      scheduler() {
        // 在scheduler中重新执行的副作用函数,得到的是新值
        newVal = effectFn()
        cb(newVal, oldVal)
        // 将当前新值更新为下一次的旧值
        oldVal = newVal
      }
    }
  )
  // 手动调用副作用函数,拿到的是旧值
  oldVal = effectFn()
}

这段代码最核心的是lazy选项创建了一个懒执行的effect,注意最下边的,我们手动调用effectFn得到的返回值是旧值,即第一次执行得到的值。当变化发生触发scheduler调度函数执行时,会重新调用effectFn函数得到新值,将他们作为参数传给回调函数cb就可以了。还有要将当前新值赋给旧值,这样下一次旧值才是正确的。

立即执行的watch

默认情况下,回调函数只有在后续响应式数据变化才会执行。但我们可以通过immediate选项指定回调是否立即执行。仔细思考,回调函数立即执行与后续执行本质上并无差别。我们可以把scheduler封装成一个通用函数,分别在初始化和变化时执行它。如下:

function watch(source, cb, options = {}) {
  let getter
  if(typeof source === 'function') {
    getter = source
  } else {
    getter = () => tranverse(source)
  }
  let oldVal, newVal
  const job = () => {
    newVal = effectFn()
    cb(newVal, oldVal)
    oldVal = newVal
  }
  const effectFn = effect(
    // 执行getter
    () => getter(),
    {
      lazy: true,
      scheduler: job
    }
  )
  if(options.immediate) {
    // 立即执行job,触发回调函数执行
    job()
  }else{
    // 手动调用副作用函数,拿到的是旧值
    oldVal = effectFn()
  }
}

至此,响应式系统告一段落,感谢尤大,感谢霍春阳的《vue.js设计与实现》。