《Vue.js设计与实现》day05: 第4章 响应系统的作用与实现

2,464 阅读53分钟

一、前言

响应系统是Vue.js的重要组成部分,一起来研究下

  • 讨论什么是响应式数据和副作用函数,然后尝试实现一个相对完善的响应系统。

    • 可能遇到的问题:

      • 如何避免无限递归?
      • 为什么需要嵌套的副作用函数?
      • 两个副作用函数之间会产生哪些影响?
      • 以及其他很多需要考虑的细节
  • 详细讨论与响应式数据相关的内容。

    • Vue.js 3采用Proxy实现响应式数据, 这涉及语言规范层面的只是。

      • 包括如何根据语言规范实现对数据对象的代理
      • 以及其中的一些重要细节

接下来从认识响应式数据和副作用函数开始,一步一步地了解响应式系统的设计与实现。

二、本章内容

2.1 响应式数据与副作用函数

副作用函数指的是会产生副作用的函数,如下代码:

function effect(){
  document.body.innerText = 'hello vue3'
}

effect函数执行时,它会设置body的文本内容, 但除了effect函数之外的任何函数都可以读取或设置body的文本内容。 也就是,effect函数的执行会直接或间接的影响其他函数的执行,这时我们说effect函数产生了副作用。

副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用函数,如下代码:

//全局变量
let val = 1

function effect(){
  val = 2 //修改全局变量,产生副作用
}

理解了什么是副作用函数,再来说说什么是响应式数据。假设在一个副作用函数中读取了某个对象的属性:

const obj = {text:'hello world'}
function effect(){
  // effect函数的执行会读取 obj.text
  document.body.innerText = obj.text
}

如上面代码所示,副作用函数effect会设置body元素的innerText属性,其值为 obj.text,当obj.text的值发生变化时,我们希望副作用函数 effect会重新执行:

obj.text = 'hello vue3' //修改obj.text的值,同时希望副作用函数会重新执行

这句代码修改了字符obj.text 的值,我们希望当值变化后,副作用函数会自动重新执行,如果能实现这个目标,那么对象obj就是响应式数据。

但是很明显,以上面的代码来看,我们还做不到这一点,因为obj是一个普通对象,当我们修改它的值时,除了值本身发生变化之外,不会有任何其他反应。

接下来我们讨论如何让数据变成响应式数据。

2.2 响应式数据的基本实现

接着上文的思考,如何才能让obj变成响应式数据呢? 通过观察我们能发现两点线索:

  • 当副作用函数effect执行时,会触发字段obj.text的读取操作;
  • 当修改obj.text的值时,会促发字段obj.text的设置操作。

如果我们能拦截一个对象的读取和设置操作,事情就变得简单了,当读取字段obj.text时,我们可以把副作用函数effect存储到一个“桶”里,如下图1所示:

接着,当设置obj.text时,再把副作用函数effect从"桶"里取出来并执行即可,如下图2所示:

现在问题的关键变成了我们如何才能拦截一个对象属性的读取和设置操作。

  • ES2015之前只能通过Object.defineProperty函数实现,这也是Vue.js 2所采用的方式。
  • ES2015+中,我们可以使用代理对象Proxy 来实现,这也是Vue.js 3所采用的方式。

接下来,我们就根据上述思路,采用Proxy来实现:

// 存储副作用函数的桶
const bucket = new Set()

// 原始数据
const data = {text:'text hello'}
//对原始数据的代理
const obj = new Proxy(data,{
  //拦截读取操作
  get(target,key){
    // 将副作用函数effect添加到存储副作用函数的桶中
    bucket.add(effect)
    //返回属性值
    return target[key]
  },
  //拦截设置操作
  set(target,key,newVal){
    // 设置属性值
    target[key] = newVal
    //把副作用函数从桶里取出来并执行
    bucket.forEach(fn=> fn())
    // 返回true代表设置操作成功
    return true
  }
})
  • 首先,我们创建了一个用于存储副作用函数的桶bucket,它是Set类型。

  • 接着定义原始数据dataobj是原始数据的代理对象。

    • 分别设置了getset 拦截函数,用于拦截读取和设置操作。
  • 当读取属性时将副作用函数effect添加到桶里。

    • bucket.add(effect),然后返回属性值
  • 当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行。

通过以上步骤,我们就实现了响应式数据。可以用下面的代码来测试一下

// 副作用函数
function effect(){
  document.doby.innerText = obj.text
}

// 执行副作用函数,触发读取
effect()

//1秒后修改响应式数据
setTimeout(()=>{
  obj.text = 'hello vue3'
},1000)

在浏览器中运行上面这段代码,会得到期望的结果。

但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为myEffect,甚至是一个匿名函数,因此我们要想办法去掉这种硬编码的机制。

接下来会更详细介绍这一点,以上我们只需要理解响应式数据的基本实现和工作原理即可。

2.3 设计一个完善的响应系统

在上一节中,我们了解了如何实现响应式数据。但其实在这个过程中我们已经实现了一个微型响应系统,之所以说“微型”,是因为它还不完善,本节我们尝试构造一个更加完善的响应系统。

从上面的内容案例中不难看出,一个响应系统的工作流程如下:

  • 读取操作发生时,将副作用函数收集到“桶”中;
  • 设置操作发生时,从“桶”中取出副作用函数并执行。

看上去很简单,但需要处理的细节还真不少。例如在上一节的实现中,我们硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫effect,那么这段代码就不能正确地工作了。

而我们希望的是,哪怕副作用函数是一个匿名会按书,也能够被正确地收集到“桶”中。为了实现这一点,我们需要提供一个用来注册副作用函数的机制,如下代码所示:

// 用一个全局变量存储被注册的副作用函数
let activeEffect

//effect函数用于注册副作用函数
function effect(fn){
  // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}
  • 首先,定义了一个全局变量activeEffect ,初始值是undefined,它的作用是存储被注册的副作用函数。
  • 接着重新定义了一个effect函数,它变成了一个用来注册副作用函数的函数,effect函数接收一个参数fn,即要注册的副作用函数。

我们会如下所示使用effect函数

effect(
  // 一个匿名的副作用函数
  ()={
    doucment.body.innerText = obj.text
  }
)

可以看到,我们使用一个匿名函数的副作用函数作为effect函数的参数。当effect函数执行时,首先会把匿名的副作用函数fn赋值给全局变量activeEffect。 接着执行被注册的匿名副作用函数fn, 这将会触发响应式数据obj.text的读取操作,进而触发代理对象Proxyget拦截函数:

const objt = new Proxy(data,{
  get(target,key){
    //将activeEffect中存储的副作用函数收集到“桶”中
    if(activeEffect){ //新增
      bucket.add(activeEffect)//新增
    }
    return target[key]
  },
  set(target,key,newVal){
    target[key] = newVal
    bucket.forEach(fn=>fn())
    return true
  }
})

如上面的代码所示,由于副作用函数已经存储到了activeEffect中,所以在get拦截函数内应该把activeEffect收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。

但如果我们再对这个系统稍加测试,例如在响应式数据obj上设置一个不存在的属性时:

effect(){
  // 匿名副作用函数
  ()=>{
    console.log('effect run') // 会打印两次
    document.body.innerText = obj.text
  }
}

setTimeout(()=>{
  //副作用函数中并没用读取netExist属性的值
  obj.notExist = 'hello vue3'
},1000)

可以看到,匿名副作用函数内部读取了一个字段obj.text的值,于是匿名副作用函数与字段obj.text之间会建立响应联系。

接着,我么开启了一个定时器,一秒钟后为对象obj添加新的notExist属性。我们知道,在匿名副作用函数内并没有读取obj.notExist属性的值,所以理论上, 字段obj.notExist并没有与副作用建立响应关系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。

但如果我们执行上述这段代码就会发现,定时器到时后,匿名副作用函数却重新执行了,这是不正确的。为了解决这个问题,我们需要重新设计“桶”的数据结构。

在上节的例子中,我们使用了个Set数据结构作为存储副作用函数的“桶”。导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的关系

例如当读取属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出来并执行。副作用函数与被操作的字段之间没有明确的联系。

解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用Set类型地数据作“桶”了。

那应该设计怎么样的数据结构呢? 在回答这个问题之前,我们需要先仔细观察下面的代码:

effect(function effectFn(){
  document.body.innerText = obj.text
})

在这段代码中存在三个角色:

  • 被操作(读取)的代理对象obj
  • 被操作(读取)的字段名text
  • 使用effect函数注册的副作用函数effectFn

如果target来表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用effectFn来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:

target
   L__ key
        L___ effectFn
    

这种树形结构,下面举几个例子来对其进行补充说明。

如果有两个副作用函数同时读取同一个对象的属性值:

effect(function effectFn1(){
  obj.text 
})

effect(function effectFn2(){
  obj.text 
})

那么关系如下:

target
   L__ text
        L___ effectFn 
        L___ effectFn

如果一个副作用函数中读取了同一个对象的两个不同属性:

effect(function effectFn(){
  obj.text1
  obj.text2
})

那么关系如下:

target
   L__ text1
        L___ effectFn 
        
   L__ text2
        L___ effectFn

如果在不同的副作用函数中读取了两个不同对象的不同属性:

effect(function effectFn1(){
  obj1.text1
})
effect(function effectFn2(){
  obj2.text2
})

那么关系如下:

target1
   L__ text1
        L___ effectFn1
target2        
   L__ text2
        L___ effectFn2

总之,这其实就是一个树型数据结构。这个联系建立起来之后,就可以解决前文提到的问题了。 拿上面的例子来说, 如果我们设置了obj2.text2的值,就只会导致effectFn2函数重新执行,并不会导致effectFn1函数重新执行。

接下来我们尝试用代码来实现这个新的“桶”。 首先,需要使用WeakMap替代Set作为桶的数据结构:

// 存储到副作用函数的桶
const bucket = new WeakMap()

然后修改get/set拦截器代码:

const obj = new Proxy(data,{
  //拦截器读取操作
  get(target,key){
    //没有 activeEffect,直接return 
    if(!activeEffect) return
    // 根据target从“桶”中取得depsMap,它也是一个Map类型,key--〉effects
    let depsMap = bucket.get(target)
    // 如果不存在depsMap,那么新建一个Map并与target关联
    if(!depsMap){
      bucket.set(target,(depsMap = new Map())
    }
    // 再根据key从depsMap中取得deps,它是一个Set类型
    //里面存储着所有与当前key相关联的副作用函数:effects
    let deps = depsMap.get(key)
    // 如果deps不存在,同样在新建一个Set并与key关联
    if(!deps){
      depsMap.set(key,(deps = new Set()))
    }
      
    // 最后将当前激活的副作用函数添加到“桶”里
    deps.add(activeEffect)
    
    //返回属性值
    return target[key]
  },
    
  //拦截设置操作
  set(target,key,newVal){
    //设置属性值
    target[key] = newVal
    // 根据target 从桶中取得depsMap,它是key--> effects
    const depsMap = bucket.get(target)
    if(!depsMap) return 
    //根据key取得所副作用函数effects
    const effects = depsMap.get(key)
    //执行副作用函数
    effects && effects.forEach(fn=>fn())
  }
})

从这段代码可以看出构建数据结构的方式,我们分别使用了WeakMapMapSet

  • WeakMaptarget --> Map 构成
  • Mapkey-->Set构成

其中WeakMap的键是原始对象targetWeakMap的值是一个Map实例,而Map的键是原始对象targetkeyMap的值是一个由副作用函数组成的Set。它们的关系如图3所示:

为了方便描述,我们把关系图3中的Set数据结构所存储的副作用函数集合称为key依赖集合

搞清了它们之间的关系,我们有必要解释一下这里为什么要使用WeakMap,这其实涉及了WeakMapMap的区别,我们用一段代码解释:

const map = new Map()
const weakmap = new WeakMap()

(function(){
  const foo = {foo:1};
  const bar = {bar:2};
  
  map.set(foo,1);
  weakmap.set(bar,2);
})

首先,我们定义了mapweakmap常量,分别对应MapWeakMap的实例。

接着定义了一个立即执行的函数表达式(IIFE),在函数表达式内部定义了两个对象:foobar,这两个对象分别作为mapweakmapkey。当该函数表达式执行完毕后,对于对象foo来说,它仍然作为mapkey被引用着,因此垃圾回收器(grabage collector)不会把它从内存中移除,我们仍然可以通过map.keys打印出对象foo

然而这对于对象bar来说,由于WeakMapkey是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象bar从内存中移除,并且我们无法获取weakmapkey值,也就无法通过weakmap取得对象bar

简单地说,WeakMapkey是弱引用,不影响垃圾回收器的工作。 据这个特性可知,一旦key被垃圾回收器回收,那么对应的键和值就访问不到了。

所以WeakMap经常用于存储那些只有当key所引用的对象存在时(没有被回收)才有价值的信息,例如上面的场景中,如果target对象没有任何引用了,说明用户侧不再需要它了,这时垃圾回收器会完成回收任务。

但如果使用Map来代替WeakMap,那么即使用户侧的代码对target没有任何引用,这个target也不会被回收,最有可能导致内存溢出。

最后,我们对上文的代码再做一些封装处理。

在目前的实现中,当读取属性值时,我们直接在get拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑,但更好的做法是将这部分逻辑单独封装到一个track函数中,函数的名字叫做track是为了表达追踪的含义。 同样,我们也可以把触发副作用函数重新执行的逻辑封装到trigger函数中:

const obj = new Proxy(data,{
  //拦截读取操作
  get(target,key){
    //将副作用函数activeEffect添加到存储副作用函数的桶中
    tract(target,key)
    return target[key]
  },
  //拦截设置操作
  set(target,key,newVal){
    //设置属性值
    target[key] = newVal
    //把副作用函数从桶里取出并执行
    trigger(target,key)
  }
})

//在get拦截函数内调用track函数追踪变化
function track(target,key){
  //没有activeEffect,直接return
  if(!activeEffect) return
  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拦截函数内调用tirgger函数触发变化
function trigger(target,key){
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

如以上代码所示,分别把逻辑封装到tracktrigger函数内,这能为我们带来极大的灵活性。

2.4 分支切换与cleanup

首先,我们需要明确分支切换的定义,如下面代码所示:

const data = {ok:true,text:'hello world'}
const obj = new Proxy(data,{/*....*/})

effect(function effectFn(){
  document.body.innerText = obj.ok ? obj.text : 'not'
})

effectFn函数内部存在一个三元表达式,根据字段obj.ok值当不同会执行不同的代码分支。当字段obj.ok的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换

分支切换可能会产生遗留的副作用函数。拿上面这段代码来说:

  • 字段obj.ok的初始值为true,这是会读取字段obj.text的值
  • 所以当effectFn函数执行时会触发字段obj.ok和字段obj.text这两个属性的读取操作

此时副作用函数effectFn与响应式数据之间建立的联系如下:

data
   L___ ok
         L___effectFn
   L___ text
         L___effectFn

图4,给出了更详细的描述

可以看到,副作用函数effectFn分别被字段data.ok和字段data.text所对应的依赖集合收集。当字段obj.ok的值修改为false,并触发副作用函数重新执行后,由于此时字段obj.text不会读取,只会触发字段obj.ok的读取操作,所以理想情况下副作用函数effectFn不应该被字段obj.text所对应的依赖集合收集,如图5所示:

但按照前文的实现,我们还做不到这一点。也就是说,当我们把字段obj.ok的值修改为false,并触发副作用函数重新执行后,整个依赖关系仍然保持图4所描述的那样,这是久产生了遗留的副作用函数。

遗留的副作用函数会导致不必要的更新,拿下面这段代码来说:

const data = {ok:true,text:'hello world'}
cosnt obj = new Proxy(data,{/*...*/})

effect(function effectFn(){
  document.body.innerText = obj.ok ? obj.text : 'not'
})

obj.ok的初始值为true,当我们将其修改为false后:

obj.ok = false

这会触发更新,即副作用函数会重新执行。但由于此时obj.ok的值为false,所以不再会读取字段obj.text的值。 换句话说,无论字段obj.text的值如何改变,document.body.innerText的值始终都是字符串'not'。

所以最好的结果是:无论obj.text的值怎么变,都不需要重新执行副作用函数。但事实并非如此,如果我们尝试修改obj.text的值:

obj.text = 'hello vue3'

这仍然会导致副作用函数重新执行,即使document.body.innerText的值不需要变化。

解决这个问题的思路很简单:每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,如图6所示:

当副作用函数执行完毕后, 会重新建立联系,但在新的联系中不会包含遗留的副作用函数,即图5所描述的那样。所以,如果我们能做到每次副作用函数执行前,将其从相关联的依赖集合中移除,那么问题就迎刃而解了。

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,如下面的代码所示。

effect内部我们定义了新的effectFn函数,并为其添加了effectFn.deps属性,该属性是一个数组,用来存储包含当前副作用函数的依赖集合:

//用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn){
  const effectFn=()=>{
    //当effectFn执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn
    fn()
  }
  //activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  //执行副作用函数
  effectFn()  
}

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

function track(target,key){
  //没有activeEffect,直接return
  if(!activeEffect) return
  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中
  deps.add(activeEffect)
  
  //deps就是一个当前副作用函数存在联系的依赖集合
  //将其添加到activeEffect.deps数组中
  activeEffect.deps.push(deps) //新增
}

如以上代码所示,在track函数中我们将执行的副作用函数activeEffect添加到依赖集合deps中,这说明deps就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps数组中,这样就完成了对依赖集合的收集。如图7描述了这一步所建立的关系。

有了这个联系后,我们就可以在每次副作用函数执行时,根据effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:

//用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn){
  const effectFn = ()=>{
    //调用cleanup函数完成清除工作
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

下面是cleanup函数的实现:

function cleanup(effectFn){
  // 遍历effectFn.deps数组
  for(let i=0; i< effectFn.deps.length; i++){
    //deps是依赖集合
    const deps =effectFn.deps[i]
    //将effectFn从依赖集合中移除
    deps.delete(effectFn)
  }
  //最后需要重置effectFn.deps数组
  effectFn.deps.length=0
}

cleanup函数接收副作用函数作为参数,遍历副作用函数的effectFn.deps数组,该数组的每一项都是一个依赖集合,然后将该副作用函数从依赖集合中移除,最后重置effectFn.deps数组。

至此,我们的响应式系统已经可以避免副作用函数产生遗留了。但如果你尝试运行代码,会发现目前的实现会导致无限循环执行,问题出现在trigger函数中:

function trigger(target,key){
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn()) //问题出在这句代码
}

trigger函数内部,我们遍历effects集合,它是一个Set集合,里面存储着副作用函数。

当副作用函数执行时,会调用cleanup进行清除,实际上就是从effects集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合中,而此时对于effects集合的便利仍在进行。这个行为可以用如下简短代码来表达:

const set = new Set([1])

set.forEach(item=>{
  set.delete(1)
  set.add(1)
  console.log('遍历中')
})

在上面这段代码中,我们创建了一个集合set,它里面有一个元素数字1,接着我们调用forEach遍历了该集合。在遍历过程中,首先调用delete(1)删除数字1,紧接着调用add(1)将数字1加回,最后打印‘遍历中’。如果我们在浏览器中执行这段代码,就会发现它会在这无限执行下去。

语言规范中对此有明确的说明:在调用forEach遍历Set集合时候,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值就会重新被访问。

因此,上面的代码会无限执行。解决办法很简单,我们可以构造另一个Set集合并遍历它:

const set = new Set([1])

const newSet = new Set(set)
newSet.forEach(item=>{
  set.delete(1)
  set.add(1)
  console.log('遍历中')
})

这样就不会无限执行了。回到trigger函数,我们需要同样的手段来避免无限执行:

function trigger(target,key){
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const effects = bucket.get(key)
  
  const effectsToRun = new Set(effects) //新增
  effectToRun && effectToRun.forEach(effectFn => effectFn()) //新增
  // effects && effects.forEach(effectFn => effectFn()) //删除
}

如上代码所示,我们新构造了effectToRun集合并遍历它,代替直接遍历effects集合,从而避免了无限执行。

\

提示: ECMA关于Set.prototype.forEach的规范,可参见ECMAScript 2020 Language Specification

2.5 嵌套与effecteffect

effect是可以发生嵌套的,例如:

effect(function effectFn1(){
  effect(function effectFn2(){
    /*...*/
  })
  /*...*/
})

在上面这段代码中,effectFn1内部嵌套了effectF2,effectF1的执行会导致effectF2 的执行。

那么什么场景下会出现嵌套的effect呢?拿Vue.js来说,实际上Vue.js的渲染函数就是在一个effect中执行的:

//Foo组件
const Foo = {
  render(){
    return /*...*/
  }
}

在一个effect中执行Foo组件的渲染函数:

effect(()=>{
   Foo.render() 
})

当组建发生嵌套时,例如Foo组件渲染了Bar组件:

//Bar组件
const Bar = {
  render(){/*...*/}
}

//Foo组件渲染了Bar组件
const Foo = {
  render(){
    return <Bar /> //jsx语法
  }
}

此时就发生了effect嵌套,它相当于:

effect(()=>{
  Foo.render()
  //嵌套
  effect(()=>{
    Bar.render()
  })
})

这个例子说明了为什么effect要设计成可嵌套的。

接下来,我们需要搞清楚,如果effect不支持嵌套会发生什么? 实际上,按照前文的介绍与实现来看,我们所实现的响应系统并不支持effect嵌套,可以用下面的代码来测试:

//原始数据
const data = {foo:true,bar:true}
//代理对象
const obj = new Proxy(data,{/*...*/})

//全局变量
let temp1,temp2

// effectFn1嵌套了effectFn2
effect(function effectFn1(){
  console.log('effectFn1 执行')
  
  effect(function effectFn2(){
    console.log('effectFn2 执行')
    //在effectFn2中读取obj.bar属性
    temp2=obj.bar
  })
  //在effectFn1 中读取obj.foo属性
  temp1 = obj.foo
})

在上面这段代码中, effectFn1内部嵌套了effectFn2,很明显,effectFn1的执行会导致effectFn2的执行。

需要注意的是,我们在 effectFn2中读取了字段obj.bar,在effectFn1中读取了字段obj.foo,并且effectFn2的执行先对于字段obj.foo的读取操作。

在理想情况下,我们希望副作用函数与对象属性之间的联系如下:\

data
  L___foo
     L___effectFn1
  L___bar
     L___effectFn2

在这种情况下,我们希望当修改data.foo时会触发effectFn1执行。由于effectFn2嵌套在effectFn1里,所以会间接触发effectFn2执行,而当修改obj.bar时,只会触发 effectFn2执行。但结果不是这样的,我们尝试修改obj.foo的值,会发现输出为:

'effectFn1 执行'
'effectFn2 执行'
'effectFn2 执行'

一共打印三次, 前两次分别是副作用函数effectFn1effectFn2初始执行的打印结果, 到这一步是正常的,问题出在第三行打印。我们修改了字段obj.foo的 值,发现effectFn1并没有重新执行,反而使得effectFn2重新执行了,这显然不符合预期。

问题出现在哪里呢?其实就出在我们实现的effect函数与activeEffect上。观察下面这段代码:

//用一个全局变量存储当前激活的effect函数
let activeEffect
function effect(fn){
  const effectFn = () =>{
    cleanup(effectFn)
    //当调用effect注册副作用函数时,将副作用函数复制给activeEffect
    activeEffect = effectFn
    fn()
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖合集
    effectFn.deps = []
    //执行副作用函数
    effectFn()
  }
}

我们用全局变量 activeEffect来存储通过effect函数注册的副作用函数,这意味着同一时刻activeEffect所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进入依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这就是问题所在。\

为了解决这个问题,我们需要一个副作用函数栈effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况,如下代码所示:

//用一个全局变量存储当前激活的effect函数
let activeEffect
//effect栈
const effectStack = [] //新增

function effect(fn){
  const effectFn = () => {
    cleanup(effectFn)
    //当调用effect注册副作用函数时,将副作用函数复制给activeEffect
    activeEffect = effectFn
    //在调用副作用函数之前将当前副作用函数压入栈中
    effectStack.push(effectFn) //新增
    fn()
    
    //在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把activeEffect还原为之前的值
    effectStack.pop() //新增
    activeEffect = effectStack[effectStack.length-1]//新增
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  //执行副作用函数
  effectFn()
}

我们定义了effectStack数组,用它来模拟栈,activeEffect没有变化, 它仍然指向当前正在执行的副作用函数。不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数。如果8所示。

当内层副作用函数effectFn2执行完毕后, 它会被弹出栈,并将副作用函数effectFn1设置为activeEffect,如图9所示。

如此依赖,响应式数据就只会手机直接读取其值的副作用函数作为依赖,从而避免发生错乱。

2.6 避免无限递归循环

如前文所说,实现一个完善的响应系统要考虑诸多细节。而本节要介绍的无限递归循环就是其中之一,还是举个例子:

const data = {foo:1}
const obj = new Proxy(data,{/*...*/})

effect(()=>obj.foo++)

可以看到, 在effect注册的副作用函数内有一个自增操作obj.foo++,该操作会引起栈溢出:

Uncaught RangeError:Maxinum call stack size exceeded

为什么会这样呢?接下来我们就尝试搞清楚这个问题,并提供解决方案。

实际上,我们可以把obj.foo++ 这个自增操作分开来看,它相当于:

effect(()=>{
  //语句
  obj.foo = obj.foo+1
})

在这个语句中, 既会读取obj.foo的值,又会设置obj.foo的值,而这就是导致问题的根本原因。

我们可以尝试推理下代码的执行流程:

  • 首先读取obj.foo的值,这就会触发track操作,将当前副作用函数收集到“桶”中
  • 接着将其加1后再赋值给obj.foo,此时会触发trigger操作,即把“桶”中的副作用函数取出并执行。

但问题是该副作用函数正在执行中, 还没有执行完毕,就要开始下一次的执行。这样会导致无限递归地调用自己,于是就产生了栈溢出。

解决办法并不难。通过分析这个问题我们能够发现,读取和设置操作是在同一个副作用函数内进行的。 此时无论是track时收集的副作用函数,还是trigger时要触发执行的副作用函数都是activeEffect

基于此,我们可以在trigger动作发生时增加守卫条件:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。 如下代码所示:

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 =>{
    //如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if(effectFn !== activeEffect){ //新增
      effectsToRun.add(effectFn)
    }
  })
  
  effectsToRun.forEach(effectFn => effectFn())
  // effects && effects.forEach(effectFn => effectFn())
}

这样我们就能够避免无限递归调用, 从而避免栈溢出。

2.7 调度执行

可调度性是响应系统非常重要的特性。首先我们需要明确说明是可调度性。

所谓可调度,指的是当trigger动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

首先来看一下,如何决定副作用函数的执行方式,以下面的代码为例:

const data = {foo:1}
const obj = new Proxy(data,{/*...*/})

effect(()=>{
  console.log(obj.foo)
})

obj.foo++

console.log('结束了')

在副作用函数中,我们首先使用console.log语句打印obj.foo的值,接着对obj.foo执行自增操作,最后使用console.log语句打印‘结束了’。 这段代码的输出结果如下:

1
2
'结束了'

现在假设需求有变,输出顺序需要调整为:

1
'结束了'
2

根据打印结果我们很容易想到对策,即把语句obj.foo++和语句console.log('结束了')位置互换即可。那么有没有什么办法能够在不调整代码的情况下实现需求呢?

这时就需要响应系统支持调度。

我们可以为effect函数设计一个选项参数options,允许用户指定调度器:

effect(
  ()=>{
    console.log(obj.foo)
  },
  //options
  {
    //调度器scheduler是一个函数
    scheduler(fn){
      //...
    }
  }
)

如上面的代码所示,用户在调用effect函数注册副作用函数时,可以传递第二个参数options。它是一个对象,其中允许指定scheduler调度函数,同时在effect函数内部我们需要把options选项挂载到对应的副作用函数上:

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 = []
  //执行副作用函数
  effectFn()
}

2.8 计算属性computedlazy

前文介绍了effect函数,它用来注册副作用函数,同时它也允许指定一些选项参数options,例如指定scheduler调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track函数,以及用来触发副作用函数重新执行的trigger函数。实际上,综合这些内容,我们就可以实现Vue.js中一个非常重要并且非常有特色的能力 ---计算属性。

在深入讲解计算属性之前,我们需要先来聊聊关于懒执行的effect,即lazyeffect。这是什么意思呢?举个例子,现在我们所实现的effect函数会立即执行传递给它的副作用函数,例如:

effect(
  //这个函数会立即执行
  ()=>{
    console.log(obj.foo)
  }
)

但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候踩执行,例如计算属性。这时我们可以在通过options中添加lazy属性来达到目的,如下面的代码所示:

effect(
  //指定来lazy 选项,这个函数不会立即执行
  ()=>{
    console.log(obj.foo)
  },
  //options
  {
    lazy:true
  }
)

lazy选项和之前介绍的scheduler一样,它通过options选项对象指定。有了它,我们就可以修改effect函数的实现逻辑了, 当options.lazytrue时,则不立即执行副作用函数:

function effect(fn,options = {}){
  const effectFn = ()=>{
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length-1]
  }
  
  effectFn.options = options
  effectFn.deps =[]
  //只有非lazy的时候, 才执行
  if(!options.lazy){ // 新增
     // 执行副作用函数
    effectFn()
  }
  // 将副作用函数作为返回值返回
  return effectFn //新增
}

通过这个判断,我们就实现了让副作用函数不立即执行的功能。但问题是,副作用函数应该什么时候执行呢?通过上面的代码可以看到,我们将副作用函数effectFn 作为effect函数的返回值,这就意味着当调用effect函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行该副作用函数了:

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

// 手动执行副作用函数
effectFn()

如果仅仅能够手动执行副作用函数,其意义并不大。但如果我们把传递给effect的函数看作一个getter,那么这个getter函数可以返回任何值,例如:

const effectFn = effect(
  //getter 返回 obj.foo 与 obj.bar 的和
  () => obj.foo + obj.bar
  {lazy:true}
)

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

const effectFn = effect(
  // getter 返回obj.foo 和 obj.bar 的和
  ()=> obj.foo + obj.bar,
  {lazy:true}
)

  //value 是 getter的返回值
const value = effectFn()

为了实现这个目标,我们需要再对effect函数做一些修改,如下所示:

function effect(fn,options={}){
	const effectFn = () =>{
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    //将fn的执行结果存储到res中
    const res = fn() //新增
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    //将res作为effectFn的返回值
    return res //新增
  }
  effectFn.options = options
  effectFn.deps = []
  if(!options.lazy){
    effectFn()
  }
  return effectfn
}

通过新增的代码可以看到,传递给effect函数的参数fn才是真正的副作用函数,而effectFn是我们包装后的副作用函数。

为了通过effectFn得到真正的副作用函数fn的执行结果,我们需要将其保存到res变量中,然后将其作为effectFn函数的返回值。

现在我们已能够实现依赖执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了,如下所示:

function computed(getter){
	//把getter作为副作用函数,创建一个lazy的effect
  const effectFn = effect(getter,{
    	lazy:true
  })

  const obj = {
    // 当读取value时才执行effectFn
    get value(){
    	return effectFn()
    }
  }
  return obj
}

首先我们定义一个computed函数,它接收一个getter函数作为参数,我们把getter函数作为副作用函数,用它创建一个lazyeffectcomputed函数的执行会返回一个对象,该对象的value属性是一个访问器属性,只有读取value的值时,才会执行effectFn并将其结果作为返回值返回。

我们可以使用computed函数来创建一个计算属性:

const data = {foo:1,bar:2}
const obj = new Proxy(data,{/*...*/})

const sumRes = computed(()=> obj.foo+obj.bar)
console.log(sumRes.value) //3

可以看到它能够正确地工作, 不过现在我们实现计算属性只做到了懒计算,也就是说,只有当你真正读取sumRes.value的值时,它才会进行计算并得倒值。但是还做不到对值进行缓存,即假如我们多次访问sumRes.value的值,会导致effectFn进行多次计算,即使obj.fooobj.bar的值本身并没有变化:

console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3

上面的代码多次访问sumRes.value的值,每次访问都会调用effectFn重新计算。

为了解决这个问题,就需要我们在实现computed函数时,添加对值进行缓存的功能,如下代码所示:

function computed(getter){
	//value用来缓存上一次计算的值
  let value
  //dirty标志,用来表示是否需要重新计算值,为true则意味着“脏”,需要计算
  let dirty = true

  const effectFn = effect(getter,{
    lazy:true
  })

  const obj = {
    get value(){
      //只有“脏”时才计算值,并将得到的值缓存到value中
      if(dirty){
        value = effectFn()
        //将dirty设置为false,下一次访问直接使用缓存到value中的值
        dirty = false
      }
      return value
    }
  }
  return obj
}

我们新增了两个变量valuedirty,其中value用来缓存上一次计算的值,而dirty是一个标识,代表是否需要重新计算。 当我们通过sumRes.value访问值时,只有当dirtytrue时,才会调用effectFn重新计算值,否则直接使用上一次缓存在value中的值。这样无论我们访问多少次sumRes.value,都只会在第一次访问时进行真正的计算,后续访问都会直接读取缓存的value值。

相信你已经看到问题所在了, 如果此时我们修改obj.fooobj.bar的值,在访问sumRes.value会发现访问到的值没有发生变化:

const data = {foo:1,bar:2}
const obj = new Proxy(data,{/*...*/})

const sumRes = computed(()=> obj.foo+obj.bar)

console.log(sumRes.value) //3
console.log(sumRes.value) //3

//修改obj.foo
obj.foo++

//再次访问,得到的仍然是3,但预期结果应该是4
console.log(sumRes.value) //3

这是因为,当第一次访问sumRes.value的值后,变量dirty会设置为false,代表不需要计算, 即使我们修改了obj.foo的值,但只要dirty的值为false,就不会重新计算, 所以导致我们得倒了错误的值。

解决办法很简单,当obj.fooobj.bar的值发生变化时,只要dirty的值重置为true就可以了。 那么应该怎么做呢?

这时候就用到了上一节介绍的scheduler选项,如下代码所示:

function computed(getter){
	let value
  let dirty = true

  const effectFn = effect(getter,{
    lazy:true,
    //添加调度器,在调度器中将dirty重置为true
    scheduler(){
      dirty = true
    }
  })

  const obj = {
    get value(){
      if(dirty){
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return obj
}

我们为effect添加了scheduler调度器函数,它会在getter函数中所依赖的响应式数据变化时执行,这样我们在scheduler函数内将dirty重置为true,当下一次访问sumRes.value时,就会重新调用effectFn计算值,这样就能够得倒预期的结果了。

现在,我们设计的计算属性已经趋于完美了,但是还有一个缺陷,它体现在当我们在另外一个effect中读取计算属性时的值:

const sumRes = computed(()=> obj.foo+obj.bar)
effect(()=>{
	//在该副作用函数中读取sumRes.value
  console.log(sumRes.value)
})
//修改obj.foo的值
obj.foo++

如上代码所示,sumRes是一个计算属性, 并且在另一个effect的副作用函数中读取了sumRes.value的值。如果此时修改了obj.foo的值,我们期望副作用函数重新执行,就像我们在Vue.js的模版中读取计算属性值的时候,一旦计算属性发生变化就会触发重新渲染一样。

但是如果尝试运行上面这段代码,就会发现修改obj.foo的值并不会触发副作用函数的渲染,因此我们说这是一个缺陷。

分析问题的原因,我们发现,从本质上看这就是一个典型的effect潜套。

  • 一个计算属性内部拥有自己的effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。
  • 对于计算属性的getter函数来说,它里面访问的响应式数据只会把computed内部的effect收集为依赖。
  • 而当把计算属性用于另外一个effect时,就会发生effect潜套,外层的effect不会被内层effect中的响应式数据收集。

解决的办法很简单。当读取计算属性的值时,我们可以手动调用track函数进行追踪;当计算属性依赖的响应式数据发生变化时,我们可以手动调用trigger函数触发响应:

function computed(getter){
	let value 
  let dirty = true

  const effectFn = effect(getter,{
  	lazy:true,
    scheduler(){
      if(!dirty){
        dirty = true
        //当计算属性依赖的响应式数据变化时,手动调用trigger函数触发响应
        trigger(obj,'value')
      }
    }
  })

  const obj = {
    get value(){
      if(dirty){
        value = effectFn()
        dirty = false
      }
      // 当读取value时,手动调用track函数进行追踪
      track(obj,'value')
      return value
    }
  }
  return obj
}

如以上代码所示,当读取一个计算属性的value值时,我们手动调用track函数,把计算属性返回的对象obj作为target,同时作为第一个参数传递给track函数。当计算属性所依赖的响应式数据变化时,会执行调度器函数,在调度器函数内手动调用trigger函数触发响应即可。这时,对于如下代码来说:

effect(function effectFn(){
  console.log(sumRes.value)
})

它会建立这样的联系:

computed(obj)
   L___ value
      	L___effectFn

下图给出了更详细的描述:

2.9 watch的实现原理

所谓watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。举个例子:

watch(obj,()=>{
	console.log('数据变了')
})

//修改响应数据的值,会导致回调函数执行
obj.foo++

假设obj是一个响应数据,使用watch函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。

实际上,watch的实现本质上就是利用了effect以及options.scheduler选项,如以下代码所示:

effect(()=>{
  console.log(obj.foo)
},{
  scheduler(){
    //当obj.foo的值变化时,会执行scheduler调度函数
  }
})

在一个副作用函数中访问响应式数据obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。

但有一个例外,即如果副作用函数存在scheduler选项,当响应式数据发生变化时,会触发scheduler调度函数执行,而非直接触发副作用函数执行。

从这个角度来看,其实scheduler调度函数就是相当于一个回调函数,而watch的实现就是利用了这个特点。下面是最简单的watch函数的实现:

//watch函数接受两个参数,source是响应式数据,cb是回调函数
function watch(source,cb){
	effect(
    //触发读取操作,从而建立联系
  ()=> source.foo,
    {
      scheduler(){
        //当数据变化时,调用回调函数cb
        cb()
      }
    }
  )
}

我们可以如下所示使用watch函数:

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

上面这段代码能正常工作, 但是我们注意到在watch函数的实现中,硬编码了对source.foo的读取操作。

换句话说,现在只能观测obj.foo的改变。为了让watch函数具有通用性,我们需要一个封装一个通用的读取的读取操作:

function watch(source,cb){
	effect(
    // 调用traverse递归地读取
    ()=> traverse(source),
    {
      scheduler(){
        //当数据变化时,调用回调函数cb
        cb()
      }
    }
  )
}

function 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内部的effect中调用traverse函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能触发回调函数执行。

watch函数除了可以观测响应式数据,还可以接受一个getter函数:

watch(
  //getter函数
  ()=> obj.foo,
  //回调函数
  ()=>{
    console.log('obj.foo 的值变了')
  }
)

如以上代码所示,传递给watch函数的第一个参数不再是一个响应式数据,而是一个getter函数。在getter函数内部,用户可以指定该watch依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:\

function watch(source,cb){
  //定义getter
  let getter
  //如果source是函数,说明用户传递的是getter,所以直接把source赋值给getter
  if(typeof source === 'function'){
    getter = source
  }else{
    //否则按照原来的实现调用traverse递归地读取
    getter = () => traverse(source)
  }
  effect(
    //执行 getter
    () => getter(),
    {
      scheduler(){
        cb()
    }
  )
}

首先判断source的类型,如果是函数类型,说明用户直接传递了getter函数, 这时直接使用用户的getter函数;如果不是函数类型,那么保留之前的做法,即调用traverse函数递归地读取。这样就实现了自定义getter的功能,同时使得watch函数更加强大。。

仔细观察你可能会注意到,现在的实现还缺少了一个非常重要的能力,即在回调函数中拿不到旧值与新值。通常我们在使用Vue.js中的watch函数时,能够在回调函数中得倒变化前后的值;

watch(
  () => obj.foo,
  (newValue,oldValue) =>{
    console.log(newValue,oldValue) // 2,1
  }
)

obj.foo++

那么如何获得新值与旧值呢?这需要充分利用effect函数的lazy选项 , 如下代码所示:

function watch(source,obj){
	let getter
  if(typeof source === 'function'){
    getter = source
  }else{
    getter = () => traverse(source)
  }

  //定义旧值与新值
  let objValue, newValue
  //使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
  const effectFn = effect(
    ()=> getter(),
    {
      lazy:true,
      scheduler(){
        //在scheduler 中重新执行副作用函数,得到的是新值
        newValue = effectFn()
        // 将旧值和新值作为回调函数的参数
        cb(newValue,oldValue)
        //更新旧值,不然下一次会得到错误的旧值
        oldValue = newValue
      }
    }
  )
  //手动调用副作用函数,拿到的值就是旧值
  oldValue = effectFn()
}

在这段代码中, 最核心的改动就是使用lazy选项创建了一个懒执行的effect。注意上面代码中最下面的部分,我们手动调用effectFn函数得到的返回值就是旧值,即第一次执行的到的值。

当变化发生并触发scheduler调度函数执行时,会重新调用effectFn函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数cb就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值。

2.10 立即执行的watch与回调执行时机

上一节,我们介绍了watch的基本实现。在这个过程中我们认识到,watch的本质其实是对effect的二次封装。本节我们继续讨论关于watch的两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机。

首先来看立即执行的回调函数。默认情况下,一个watch的回调只会在响应式数据发生变化时才执行:

//回调函数只有在响应式数据obj后续发生变化时才执行
watch(obj,()=>{
  console.log('变化了')
})

Vue.js中可以通过选项参数immdiate来指定回调是否需要立即执行:

watch(obj,()=>{
  console.log('变化了')
},{
  //回调函数会在watch创建时立即执行一次
  immediate:true
})

immediate选项存在并且为true时,回调函数会在该watch创建时立即执行一次。仔细思考就会发现,回调函数的立即执行与后续执行本质上没有任何差异,所以我们可以把scheduler调度函数封装为一个通用函数,分别在初始化和变更时执行它,如下代码所示:

function watch(source,cb,options ={}){
  let getter
  if(typeof source === 'function'){
    getter = source
  }else{
    getter = () => traverse(source)
  }
  let oldValue,newValue

  //提取scheduler调度函数为一个独立的job函数
  const job = () => {
    newValue = effectFn()
    cb(newValue,oldValue)
    oldValue = newValue
  }

  const effectFn = effect(
    //执行getter
    () => getter(),
    {
      lazy:true,
      //使用job函数作为调度函器函数
      scheduler:job
    }
  )

  if(options.immediate){
    //当immediate为true时立即执行job,从而触发回调执行
    job()
  }else{
    oldValue = effectFn()
  }
}

这样就实现了回调函数的立即执行功能。由于回调函数是立即执行的,所以第一次回调执行时没有所谓的旧值,因此此时回调函数的oldValue值为undefined,这也是符合预期的。

除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定回调函数的执行时机,例如在Vue.js3中使用flush选项来指定:

watch(obj,()=>{
  console.log('变化了')
},{
  //回调函数会在watch创建时立即执行一次
  flush:'pre' //还可以指定为'post' | 'sync'
})

flush本质上是在指定调度函数的执行时机。前文讲解过如何在微任务队列中执行调度函数scheduler,这与flush的功能相同。当flush的值为'post'时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待DOM更新结束后再执行,我们可以用如下代码进行模拟:

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(newValue,oldValue)
    oldValue = newValue
  }

  const effectFn = effect(
    //执行getter
    ()=> getter(),
    {
      lazy:true,
      scheduler:()=>{
        //在调度函数中判断flush是否为'post'	,如果是,将其放到微任务队列中执行
        if(options.flush === 'post'){
          const p = Promise.resolve()
          p.then(job)
        }else{
          job()
        }
      }
    }
  )

  if(options.immediate){
    job()
  }else{
    oldValue = effectFn()
  }
}

如以上代码所示,我们修改了调度器函数scheduler的实现方式,在调度器函数内检测options.flush的值是否为post,如果是,则将job函数放到微任务队列中,从而实现异步延迟执行;否则直接执行job函数,这本质上相当于sync的实现机制,即同步执行。

对于options.flush的值为'pre'的情况,我们暂时还没有办法模拟,因为这涉及组件的更新时机,其中'pre''post'原本的语义指的就是组件更新前和更新后,不过这并不影响我们理解如何控制回调函数的更新时机。

2.11 过期的副作用

竞态问题通常在多进程或多线程编程中被提及,前端工程师可能很少讨论它,但在日常工作中你可能早就遇到过竞态问题相似的场景,举个例子:

let finalData

watch(obj,async()=>{
  //发送并等待网络请求
  const res = await fetch('/path/to/request')
  //将请求结果赋值给data
  finalData = res
})

在这段代码中,我们使用watch观测obj对象的变化,每次obj对象发生变化都会发送网络请求,例如请求接口数据,等数据请求成功后,将结果赋值给finalData变量。

观察上面的代码,乍一看似乎没什么问题。但仔细思考会发现这段代码会发生竞态问题。

假设我们第一次修改obj对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求A 。

随着时间的推移,在请求A的结果返回之前,我们对obj对象的某个字段值进行了第二次请求修改,这会导致发生第二次请求B。

此时请求A和请求B都在进行中,那么哪一个请求会先返回结果呢?我们不确定,如果请求B先于请求A返回结果,就会导致最终finalData中存储的是A请求的结果,如图所示:

但由于请求B是后发送的,因此我们认为请求B返回的数据才是“最新”的,而请求A则应该被视为“过期”的,所以我们希望变量finalData存储的值应该是由请求B返回的结果,而非请求A返回的结果。

实际上,我们可以对这个问题做进一步总结。请求A是副作用函数第一次执行所产生的副作用,请求B是副作用函数第二次执行所产生的副作用。由于请求B后发生,所以请求B的结果应该被视为“最新”的,而请求A已经“过期”了,其产生的结果应被视为无效。通过这种方式,就可以避免竞态问题导致的错误结果。

归根结底,我们需要的是一个让副作用过期的手段。为了让问题更加清晰,我们先拿Vue.js中的watch函数来浮现场景,看看Vue.js是如何帮助开发者解决这个问题的,然后尝试实现这个功能。

Vue.js中,watch函数的回调函数接受第三个参数onInvalidate,它是一个函数,类似于时间监听器,我们可以用onInvalidate函数注册一个回调,这个回调函数会在当副作用函数过期时执行:

watch(obj,async(newValue,oldValue,onInvalidate)=>{
  //定义一个标志,代表当前副作用函数是否过期,默认为false,代表没有过期
  let expired = false
  //调用onInvalidate() 函数注册一个过期回调
  onInvalidate(() =>{
    //当过期时,将expired设置为true
    expired = true
  })

  //发送网络请求
  const res = await fetch('/path/to/request')

  //只有当该副作用函数的执行没有过期时,才会执行后续操作
  if(!expired){
    finalData = res
  }
})

如上面的代码所示,在发送请求之前,我们定义了expired标志变量,用来标识当前副作用函数的执行是否过期;接着调用onInvalidate函数注册了一个过期回调,当该副作用函数的执行过期时将expired标志变量设置为true;最后只有当没有过期时才采用请求结果,这样就可以有效的避免上述问题了。

那么Vue.js是怎么做到的呢?换句话说,onInvalidate的原理是什么呢?其实很简单,在watch内部每次检测到变更后,在副作用函数重新执行之前,会先调用我们通过onInvalidate函数注册的过期回调,仅此而已,如下代码所示:

function watch(source,cb,options={}){
  let getter
  if(typeof source === 'function'){
    getter = source
  }else{
    getter = () => traverse(source)
  }

  let oldValue,newValue

  //cleanup 用来存储用户注册的过期回调
  let cleanup
  //定义onInvalidate函数  
  function onInvalidate(fn){
    //将过期回调存储到cleanup中
    cleanup = fn
  }

  const job = ()=>{
    newValue = effectFn()
    //在调用回调函数cb之前,先调用过期回调
    if(cleanup){
      cleanup()
    }
    //将onInvalidate作为回调函数的第三个参数,以便用户使用
    cb(oldValue,newValue,onInvalidate)
    oldValue = newValue
  }

  const effectFn = effect(
    //执行getter
    ()=>getter(),
    {
      lazy:true,
      scheduler:()=>{
        if(options.flush === 'post'){
          const p = Promise.resolve()
          p.then(job)
        }else{
          job()
        }
      }
    }
  )

  if(options.immediate){
    job()
  }else{
    oldValue = effectFn()
  }
}

在这段代码中,我们首先定义了cleanup变量,这个变量用来存储用户通过onInvalidate函数注册的过期函回调。可以看到onInvalidate函数的实现非常简单,只是把过期回赋值给了cleanup变量。这里的关键点在job函数内,每次执行回调函数cb之前,先检查是否存在过期回调,如果存在,则执行过期回调函数cleanup。最后我们把onInvaidate函数作为回调函数的第三个参数传递给cb,以便用户使用。

我们还是痛殴一个例子来进一步说明:

watch(obj,async(newValue,oldValue,onInvalidate)=>{
  let expired = false
  onInvalidate(()=>{
    expired = true
  })

  const res = await fetch('path/to/request')

  if(!expired){
    finalData = res
  }
})

//第一次修改
obj.foo++
setTimeout(()=>{
  //200ms后做第二次修改
	obj.foo++
},200)

如上代码所示,我们修改了两次obj.foo的值,第一次修改是立即执行的,这会导致watch的回调函数执行。

由于我们在回调函数内调用了onInvalidate,所以会注册一个过期回调,接着发送请求A。

  • 假设请求A需要1000ms才能返回结果,而我们在200ms时第二次修改了obj.foo的值,这又会导致watch的回调函数执行。
  • 这时需要注意的是,在我们的实现中,每次执行回调函数之前要先检查过期回调是否存在,如果存在,会优先执行过期回调。
  • 由于在watch的回调函数第一次执行的时候,我们已经注册了一个过期回调,所以在watch的回调函数第二次执行之前,会优先执行之前注册的过期回调,这会使得第一次执行的副作用函数内闭包的变量expired的值变为true,即副作用函数的执行过期了。
  • 于是等请求A的结果返回时,其结果会被抛弃,从而避免了过期的副作用函数带来的影响,如图所示:

三、总结

  • 首先介绍了副作用函数和响应式数据的概念,以及它们之间的关系。

    • 一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。
    • 当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶”中;
    • 当“设置”操作发生时,再将副作用函数从“桶”里取出并执行

以上就是响应式系统的根本实现原理。

  • 接着,我们实现了一个相对完善的响应系统。

    • 使用WeakMap配合Map构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间建立更加精确的联系。
    • 同时,也介绍了WeakMapMap这两个数据结构的区别:
      • WeakMap是弱引用,它不影响垃圾回收器的工作。
      • 当用户代码对一个对象没有引用关系时,WeakMap不回阻止垃圾回收器回收该对象
  • 讨论了分支切换导致的冗余副作用问题,这个问题会导致副作用函数进行不必要的更新。

    • 为了解决这个问题,我们需要在每次副作用函数重新执行之前, 清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存在冗余副作用问题,从而解决了问题。

    • 但在此过程中,我们还遇到了便历Set数据结构导致无限循环的新问题,

      • 该问题产生的原因可以从ECMA规范中得知:即“在调用forEach遍历Set集合时,如果一个值已经被访问过了,但这个值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么这个值会被重新访问”。
      • 解决方案是建立一个新的Set数据结构用来遍历。
  • 讨论了关于嵌套的副作用函数的问题。在实际场景中,嵌套的副作用函数发生在组件嵌套的场景中,即父子组件关系。

    • 这时为了避免在响应式数据与副作用函数之间建立的响应联系发生错乱,我们需要使用副作用函数栈来存储不同的副作用函数。

      • 当一个副作用函数执行完毕后,将其从栈中弹出。
      • 当读取响应式数据的时候,被读取的响应式数据只会与当前栈顶的副作用函数建立响应联系,从而解决问题。
    • 而后,我们遇到了副作用函数无限递归调用自身,导致栈溢出的问题。

      • 该问题的根本原因在于,对响应式数据的读取和设置操作发生在同一个副作用函数内。
      • 解决办法很简单:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
  • 随后,我们讨论了响应系统的可调度性。

    • 所谓可调度,指的是当trigger动作触发副作用函数重新执行时,有能力决定副作用函数执行的实机、次数以及方式。
    • 为了实现调度能力,我们为effect函数增加了第二个选项参数,可以通过scheduler选项指定调用器,这样用户可以通过调度器自行完成任务的调度。
    • 如何通过调度器实现任务去重,即通过一个微任务队列对任务进行缓存,从而实现去重。
  • 而后,讲解了计算属性,即computed

    • 计算属性实际上是一个懒执行的副作用函数,我们通过lazy选项使得副作用函数可以懒执行。
    • 被标记为懒执行的副作用函数可以通过手动方式让其执行。
    • 利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。
    • 当计算属性依赖的响应式数据发生变化时,会通过schedulerdirty标记设置为true,代表“脏”。
    • 这样,下次读取计算属性的值时,我们会重新计算真正的值。
  • 之后,我们讨论了watch的实现原理,它本质上利用了副作用函数重新执行时的可调度行。

    • 一个watch本身会创建一个effect,当这个effect依赖的响应式数据发生变化时,会执行该scheduler中执行用户通过watch函数注册的回调函数即可。
    • 此外,讲解了理解执行回调的watch,通过添加新的immediate选项来实现
    • 还讨论了,如何控制回调函数的执行时机,通过flush选项来制定回调函数具体的执行时机,本质上是利用了调用器和一步的微任务队列。
  • 最后,我们讨论了过期的副作用函数,他会导致竞态问题。

    • 为了解决这个问题,Vue.jswatch的回调函数设计了第三个参数,即onInvalidate
      • 它是一个函数,用来注册过期回调。
      • 每当watch的回调函数执行之前,会优先执行用户通过onInvalidate注册的过期回调。
      • 这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。