第二篇 响应系统

大约 39 分钟

第二篇 响应系统

第四章 响应系统的作用与实现

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

4.1 响应式数据与副作用函数open in new window

副作用函数:当一个函数的执行会直接或间接影响其他函数的执行时,这个函数就是一个副作用函数。例子如下:

function effect() {
  document.body.innerText = "body"
}

上面的代码中,effect() 函数的执行会修改 body 的文本内容,但是这个文本内容并不是只有 effect() 函数才能修改的,因此这个函数的执行会直接或间接的影响其他函数的执行,它产生了副作用。其他例子如一个函数修改了一个全局变量,那么这个函数也是一个副作用函数。

响应式数据:当这个数据变化时,依赖于它的函数或者说副作用函数会自动的重新执行,那么这个数据就是响应式数据。如:

let count = 0
function effect() {
  document.body.innerText = count
}

count 的值变化时,effect() 函数如果会自动执行的话,那么 count 就是一个响应式数据。

4.2 响应式数据的基本实现

4.2 响应式数据的基本实现open in new window

整体思路分为两步,以上面的代码举例:

  1. 读取到 count 时,将函数 effect 保存起来
  2. 当再次设置 count 时,将函数 effect 取出来执行

这两步的关键点就在于拦截数据,即我们能手动控制数据的读取(get)和设置(set)两个操作。在 ES2015 前,我们只能通过Object.defineProperty()函数实现,也就是 Vue2 采用的方式,但是在 ES2015+ 后,我们可以使用代理对象Proxy来实现了,也就是 Vue3 采用的方式。

这里我们采用Proxy来实现:

// 原始数据
const data = {
  text: 'hello'
}

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

// 对数据进行代理
const dataProxy = new Proxy(data, {
  // 拦截数据的读取操作
  get(target, key, receiver) {
    // 保存副作用函数
    bucket.add(effect)
    // 返回读取的属性值,Reflect 可以查看上面 Proxy 链接
    return Reflect.get(target, key, receiver)
  },

  // 拦截数据的赋值操作
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    // 取出副作用函数并执行
    bucket.forEach(fn => fn())
    // set 方法需要返回一个 boolean
    return result
  }
})

// 副作用函数
function effect() {
  console.log(dataProxy.text)
}

console.log(dataProxy.text) // hello
dataProxy.text = "world" // world

这个代码还有很多缺陷,但是也是一个简单的实现了。

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

4.3 设计一个完善的响应系统open in new window

根据上一节的代码,我们要解决的第一个问题就是副作用函数,因为它不一定就是effect,也可能是别的名字,因此我们需要正确的收集副作用函数。

// 存储副作用函数
const bucket = new Set()
// 用来存储被注册的副作用函数
let activeEffect = null
// effect 函数用来注册副作用函数
function effect(fn) {
  // 当调用 effect 时,注册副作用函数
  activeEffect = fn
  // 执行一次副作用函数
  fn()
}

完成这一步后,我们就需要使用这个effect函数来注册副作用函数了。

// 对数据进行代理
const dataProxy = new Proxy(data, {
  // 拦截数据的读取操作
  get(target, key, receiver) {
    if (activeEffect) {
      // 保存副作用函数
      bucket.add(activeEffect)
    }
    // 返回读取的属性值,Reflect 可以查看上面 Proxy 链接
    return Reflect.get(target, key, receiver)
  },

  // 拦截数据的赋值操作
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    // 取出副作用函数并执行
    bucket.forEach(fn => fn())
    // set 方法需要返回一个 boolean
    return result
  }
})






 














ok,完成这两步后,我们先试试看:

// 副作用函数
function myEff() {
  console.log(dataProxy.text)
}
effect(myEff)

console.log(dataProxy.text) // hello
dataProxy.text = "world" // world




 



可以看到,目前这个副作用函数的名字为myEff,我们使用effect函数去注册它后,代码依然能正常运行。但是,它并不是没有问题,如下:

// dataProxy 中并没有 name 属性
dataProxy.name = "world" // 还是会输出 hello

可见,当我们给一个并不存在的属性赋值时,还是会触发副作用函数,这显然是不合理的,因此,我们需要对存储副作用函数的const bucket = new Set()进行重新设计。

设计 bucket 数据结构的一点思考
function myEff() {
  console.log(dataProxy.text)
}

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

  1. 被操作的对象:dataProxy
  2. 被操作的属性,或者说字段:text
  3. 副作用函数:myEff

它们之间的关系应该是如下的:dataProxy -> text -> myEff,即由 target 保存 key,由 key 保存 activeEffect(这里的 target 是Proxygetset的第一个参数,即代理的对象本身,key 是第二个参数,即操作的对象中的哪个字段,activeEffect 是副作用函数)。

如果是多个函数操作同一个字段的话,关系就是如下:target -> key -> (activeEffect1, activeEffect2, ...),有点像一个树形结构:

image-20230314224201381

如果是多个字段对应同一个副作用函数、多个字段对应多个副作用函数等情况都与此类似。

总的来说,一个 target 可以对应 0-∞ 个字段,每个字段可以对应 0-∞ 个副作用函数,是一个此类的属性结构。

由此,我们来处理整个数据的结构如下,我们这里做个假设,代理的对象为target,每个字段为key,每个字段对应的所有副作用函数为activeEffects

  1. 我们知道,Set类型是没有键只有值的,且没有重复项,因此我们用它来保存每个key的对应的activeEffects
  2. 让每个key和上面第一步中activeEffectsSet对应起来的数据我们使用Map类型来存储,因为Map可以让键值一一对应起来。
  3. 最后让每个target和它所有的key对应起来的数据我们使用WeakMap类型来存储,这里不使用Map类型的原因如下,(这个下面的key与上面的不一样,下面这里指的是WeakMap这个数据类型中笼统的键):
    • WeakMapkey是弱引用,不会影响垃圾回收器的工作,一旦它的key被垃圾回收了,那么对应的key: value就访问不到了,因此它经常用于存储之后当key所引用的对象存在时(没有被垃圾回收)才有价值的信息,就如这里。当被代理的数据被垃圾回收后,它所对应的一系列关系就没有价值了。

综合上面设计 bucket 数据的思考,我们得到了这个保存target -> key -> activeEffects的数据结构如下:

数据类型变量名作用
WeakMapbucket保存target和它的所有key的关系,保存的是一个Map类型数据
MapdepsMap保存key和它的所有activeEffects的关系,保存的是一个Set类型数据
Setdeps保存每个key的所有activeEffects,只有所有的副作用函数
改造完的代码完整如下:
// 原始数据
const data = {
  text: 'hello'
}

// 存储 target 和 key,保存的是一个 Map 类型
const bucket = new WeakMap()
// 用来存储被注册的副作用函数
let activeEffect = null
// effect 函数用来注册副作用函数
function effect(fn) {
  // 当调用 effect 时,注册副作用函数
  activeEffect = fn
  // 执行一次副作用函数
  fn()
}

// 对数据进行代理
const dataProxy = new Proxy(data, {
  // 拦截数据的读取操作
  get(target, key, receiver) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return Reflect.get(target, key, receiver)
    // 根据 target 从 bucket 中取 depsMap,它也是一个 Map 类型
    // key -> effects
    let depsMap = bucket.get(target)
    // 如果 depsMap 不存在,根据 target 新建一个
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
    // 里面存储着所有与当前 key 相关联的副作用函数
    let deps = depsMap.get(key)
    // 如果 deps 不存在,根据 key 新建一个
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    // 最后将当前激活的副作用函数存入 deps 中
    deps.add(activeEffect)
    // 返回读取的属性值,Reflect 可以查看上面 Proxy 链接
    return Reflect.get(target, key, receiver)
  },

  // 拦截数据的赋值操作
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    // 根据 target 取出 depsMap
    const depsMap = bucket.get(target)
    if (!depsMap) return
    // 根据 key 取出所有副作用函数
    const effects = depsMap.get(key)
    // 取出副作用函数并执行
    effects && effects.forEach(fn => fn())
    // set 方法需要返回一个 boolean
    return result
  }
})






 
















 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 







 
 
 
 
 
 
 




再来验证下这个代码能否正确运行:

// 副作用函数
function myEff() {
  console.log(dataProxy.text) // hello
}
effect(myEff)

console.log(dataProxy.text) // hello
dataProxy.text = "world" // world
dataProxy.name = "world" // 无输出

可以看到,当我们调用奴存在的字段时,并不会有输出了。

最后我们可以对上面的代码做一下封装,将get中处理数据的部分封装到track函数中,将set中处理数据的部分封装到trigger函数中:

// 在 get 方法中调用,追踪变化
function track(target, key, receiver) {
  // 没有 activeEffect,直接 return
  if (!activeEffect) return Reflect.get(target, key, receiver)
  // 根据 target 从 bucket 中取 depsMap,它也是一个 Map 类型
  // key -> effects
  let depsMap = bucket.get(target)
  // 如果 depsMap 不存在,根据 target 新建一个
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  // 里面存储着所有与当前 key 相关联的副作用函数
  let deps = depsMap.get(key)
  // 如果 deps 不存在,根据 key 新建一个
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 最后将当前激活的副作用函数存入 deps 中
  deps.add(activeEffect)
}

// 在 set 方法中调用,追踪变化
function trigger(target, key) {
  // 根据 target 取出 depsMap
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 根据 key 取出所有副作用函数
  const effects = depsMap.get(key)
  // 取出副作用函数并执行
  effects && effects.forEach(fn => fn())
}

// 对数据进行代理
const dataProxy = new Proxy(data, {
  // 拦截数据的读取操作
  get(target, key, receiver) {
    track(target, key)
    // 返回读取的属性值,Reflect 可以查看上面 Proxy 链接
    return Reflect.get(target, key, receiver)
  },

  // 拦截数据的赋值操作
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    trigger(target, key)
    // set 方法需要返回一个 boolean
    return result
  }
})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 


















4.4 分支切换与cleanup

4.4 分支切换与cleanupopen in new window

分支切换:例如一个三元表达式res.data ? true : false,根据res.data值的不同,会执行不同的代码分支,这就是分支切换。

分支切换可能会产生遗留的副作用函数。如下面这个代码:

cosnt data = {
  ok: true,
  text: "hello"
}

const dataProxy = new Proxy(data, {
  // ... 一些代理的代码
})

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










 

这段代码中,dataProxy.okdataProxy.text都绑定了effectFn,但是实际上,dataProxy.textdataProxy.ok决定触发与否,即这里的理想情况为dataProxy.text的依赖集合中不应该收集这个effectFn,但是目前代码的实际情况却并不是如此的,即产生了遗留的副作用函数。它导致的问题为:当dataProxy.ok为 false 时,我们修改dataProxy.text的值也会触发这个effectFn

要解决这个问题的整体思路是:每次副作用函数执行时,我们先将它从所有与之关联的以来集合中删除,当副作用函数执行完毕后,重新建立联系,但在新的联系中不会包含遗留的副作用函数。

因此我们首先需要修改注册副作用函数的effect函数,如下:

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




 
 
 
 
 
 
 



这个修改的主要思路是添加了effectFn.deps,它是一个数组,用来存储所有包含当前副作用函数的依赖集合。然后我们修改上面提取出来的track函数,让effectFn.deps能收集这些依赖集合:

// 在 get 方法中调用,追踪变化
function track(target, key, receiver) {
  // 没有 activeEffect,直接 return
  if (!activeEffect) return Reflect.get(target, key, receiver)
  // 根据 target 从 bucket 中取 depsMap,它也是一个 Map 类型
  // key -> effects
  let depsMap = bucket.get(target)
  // 如果 depsMap 不存在,根据 target 新建一个
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  // 里面存储着所有与当前 key 相关联的副作用函数
  let deps = depsMap.get(key)
  // 如果 deps 不存在,根据 key 新建一个
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 最后将当前激活的副作用函数存入 deps 中
  deps.add(activeEffect)
  // 将 deps 添加到 effectFn.deps 数组中。这里的 deps 就是我们想要的依赖集合
  activeEffect.deps.push(deps)
}




















 
 

完成track函数的改造后,我们就收集到了我们想要的依赖集合,它们保存在effectFn.deps中,因此我们就可以继续修改effect函数了,添加一个cleanup函数,用来将副作用函数从依赖集合中清除:

// cleanup 用来将副作用函数从依赖集合中清除
function cleanup(effectFn) {
  const len = effectFn.deps.length
  for (let i = 0; i < len; i++) {
    // 取出依赖集合
    const deps = effectFn.deps[i]
    // 将 effectFn 从这个集合中清除
    deps.delete(effectFn)
  }
  // 重置 effectFn.deps 数组
  effectFn.deps = []
}

effect函数中调用cleanup方法:

function effect(fn) {
  const effectFn = () => {
    // 调用 cleanup 方法
    cleanup(effectFn)
    // ... 其他代码
  }
  // ... 其他代码
}



 




在完成以上步骤后,此时运行代码会发现进入了死循环,这是因为**我们在trigger方法中遍历了Set类型,即遍历了有effectFn的依赖集合,但是在effectFn的运行时将其从这个集合中去掉了,完成后又添加了进去。**可以简单理解为如下这个代码:

const set = new Set([1])

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

这就会造成死循环。

注意

这个问题在语言规范中描述如下:

在调用 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("遍历set")
})

 

 




这里大家肯定会有点疑问,这个新的Set套了一个旧的Set,它俩数据结构都不一样,这个新的代替旧的遍历有什么用呢?其实不是啊,当我们把一个Set作为参数传递给另一个Set时,它俩的数据结构都是一样的,如下是上面setnewSet的输出:

image-20230315152652086

现在我们来改造trigger函数解决这个死循环问题:

// 在 set 方法中调用,追踪变化
function trigger(target, key) {
  // 根据 target 取出 depsMap
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 根据 key 取出所有副作用函数
  const effects = depsMap.get(key)
  const newEffects = new Set(effects)
  // 代替 effects 进行遍历
  newEffects.forEach(effectFn => effectFn())
}







 
 
 

现在,这套代码就可以按照预期运行了。

4.5 嵌套的 effect 与 effect 栈

4.5 嵌套的 effect 与 effect 栈open in new window

effect是可以发生嵌套的,如effect(function() { effect(function() {}) })。具体场景如:在 Vue 中,一个组件里面使用了另一个组件,这就会造成effect的嵌套,因为 Vue 的渲染函数就是在一个effect中执行的。所以effect需要设计成可嵌套的,但是目前我们实现的代码并不能支持这一功能,原因就在于activeEffect这个变量上。

activeEffect这个变量是我们用来存储通过effect注册的副作用函数的,但是,它目前是一个全局变量,因此就导致我们每次存储的副作用函数只能有一个,当产生effect嵌套时,内层的副作用函数就会覆盖外层的,所以,我们需要一个栈类型的变量来存储这些要注册的副作用函数,因此,我们对effectactiveEffect的改造如下:

// 用来存储被注册的副作用函数
let activeEffect = null
// 这是一个副作用函数栈,用来存储要注册的副作用函数
let effectStack = []
// effect 函数用来注册副作用函数
function effect(fn) {
  const effectFn = () => {
    // 调用 cleanup 方法
    cleanup(effectFn)
    // 当 effectFn执行时,将其设置为当前激活的副总用函数
    activeEffect = effectFn
    // 将注册的副作用函数压入栈内
    effectStack.push(effectFn)
    fn()
    // 在这个副作用函数执行完后,将其从栈内取出,
    // 这是为了解决嵌套 effect 时,内层的副作用函数执行完后能正确使用外层的副作用函数
    effectStack.pop() // 当前副作用函数执行后弹出
    activeEffect = effectStack[effectStack.length - 1] // 还原 activeEffect 为原值
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}


 
 







 
 

 
 
 
 






4.6 避免无限递归循环

4.6 避免无限递归循环open in new window

这个问题简单来说就是避免effect函数自动的无线嵌套导致栈溢出。这种情况其实在使用时很容易遇到,如下:

const data = {
  count: 1
}

const dataProxy = new Proxy(data, { /* 其他代码 */ })

effect(() => dataProxy.count++)
// dataProxy.count++ 相当于 dataProxy.count = dataproxy.count + 1






 
 

这里我们在effect中使dataProxy.count自增,这就会导致我们我们读取count时触发get将副作用函数收集到栈中,然后给count赋值时触发set将副作用函数取出来执行,但是目前它本身就还在执行过程中,因此就又触发了dataProxy.count++,这就会无限递归下去,导致栈溢出。

这个 bug 其实不难解决,因为我们能发现这里问题的关键就在于在getset中执行的都是同一个副作用函数,因此,我们只要在set中添加一个判断条件:当这个副作用函数正在执行时,就不在set中执行它。代码如下:

// 在 set 方法中调用,追踪变化
function trigger(target, key) {
  // 根据 target 取出 depsMap
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 根据 key 取出所有副作用函数
  const effects = depsMap.get(key)
  const newEffects = new Set()
  // 循环判断副作用函数
  effects && effects.forEach(effectFn => {
    // 如果目前 trigger 触发的这个副作用函数不在执行,则继续它的执行步骤
    // 否则不执行任何操作
    if (effectFn !== activeEffect) {
      newEffects.add(effectFn)
    }
  })
  // 代替 effects 进行遍历
  newEffects.forEach(effectFn => effectFn())
}








 
 
 
 
 
 
 
 



4.7 调度执行

4.7 调度执行open in new window

可调度性:当trigger方法触发时,或者说当被Proxy中的set行为被触发从而导致副作用函数执行时,我们有能力决定它执行的时机、次数和方式。这对于一个响应式系统来说是很重要的特性。

因此,我们在不改变当前代码结构的情况下,需要实现这个需求,则需要修改effect这个函数:给它设计一个选项参数 options,允许用户指定调度器。大致如下:

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


 
 
 
 
 
 
 

在有了大致的修改框架后,我们就需要在effect函数内部将 options 挂载到对应的副作用函数上:

function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 方法
    cleanup(effectFn)
    // 当 effectFn执行时,将其设置为当前激活的副总用函数
    activeEffect = effectFn
    // 将注册的副作用函数压入栈内
    effectStack.push(effectFn)
    fn()
    // 在这个副作用函数执行完后,将其从栈内取出,
    // 这是为了解决嵌套 effect 时,内层的副作用函数执行完后能正确使用外层的副作用函数
    effectStack.pop() // 当前副作用函数执行后弹出
    activeEffect = effectStack[effectStack.length - 1] // 还原 activeEffect 为原值
  }
  // 将 options 挂载到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}














 
 





提示

我们这里设定 options 中的可能有一个方法scheduler,它的参数应该为副作用函数,用来帮助用户控制副作用函数的执行。

如果这个方法存在,则直接将副作用函数传递给它,否则副作用函数直接执行。

有了调度函数,我们在trigger函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,这样就可以将控制副作用函数执行的能力给到用户了:

function trigger(target, key) {
  // 根据 target 取出 depsMap
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 根据 key 取出所有副作用函数
  const effects = depsMap.get(key)
  const newEffects = new Set()
  // 循环判断副作用函数
  effects && effects.forEach(effectFn => {
    // 如果目前 trigger 触发的这个副作用函数不在执行,则继续它的执行步骤
    // 否则不执行任何操作
    if (effectFn !== activeEffect) {
      newEffects.add(effectFn)
    }
  })
  // 代替 effects 进行遍历
  newEffects.forEach(effectFn => {
    // 如果一个副作用函数存在调度器,则调用这个调度器,并将副作用函数作为参数传递
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}
















 
 
 
 
 
 
 
 

这里我们在trigger中就添加了判断调度器scheduler是否存在的代码,存在则向它传递副作用函数,不存在则直接执行副作用函数。这个调度器就是用来帮用户控制副作用执行的。

验证一下以上代码:
effect(
  () => {
    console.log(dataProxy.text)
  },
  // options
  {
    scheduler(fn) {
      setTimeout(fn)
    }
  }
)

dataProxy.text = "world"
console.log("over")

输出如下:

image-20230320151302642

没有 options 的如下:

effect(
  () => {
    console.log(dataProxy.text)
  }
)

dataProxy.text = "world"
console.log("over")

image-20230320151629236

这里其实也有一个问题,比如我们这里有dataProxy.count = 1这个数据,我们在多次使其自增后,只想获取到它最后一次自增的结果,那么很明显我们现在的代码并不能满足这个需求。因为它会输出所有自增的结果。

那你也许会问,这有什么意义吗?其实是有的,就比如在 Vue 中,当我们连续多次修改了一个响应式数据后,它只会触发一次更新,因为它的中间过渡状态其实是我们不需要的。

我们先来看实现这个功能的代码:

// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例
// 用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 用来标志是否正在刷新队列
let isFlushing = false
// 用来刷新队列
function flushJob() {
  // 如果正在刷新,则直接返回
  if (isFlushing) return false
  // 设置 isFlushing 为 true,表示正在刷新
  isFlushing = true
  // 在微任务队列中刷新 JobQueue 队列
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false
  })
}

/*
	下面是验证功能的代码
*/
effect(
  () => {
    console.log(dataProxy.count)
  },
  {
    scheduler(fn) {
      // 每次调度时,将副作用函数添加到 jobQueue 队列中
      jobQueue.add(fn)
      // 调用 flushJob 刷新队列
      flushJob()
    }
  }
)

dataProxy.count++
dataProxy.count++
console.log("over")

输出如下:

image-20230320154555333

详解

上面我们定义JobQueue这个队列为Set,主要是利用Set的自动去重能力。

然后定义一个微任务(Promise.resolve()),它用来添加任务到微任务队列。

然后定义一个用于控制队列刷新的锁(isFlushing),主要是控制队列不能重复刷新。

最后就是完成刷新队列方法(flushJob)。

我们在使用中,如果有 options 这个选项,则在scheduler方法中先将副作用函数添加到JobQueue队列中,然后调用刷新队列的方法flushJob

4.8 计算属性 computed 与 lazy

4.8 计算属性 computed 与 lazyopen in new window

首先,我们先总结下通过上面这些小节,我们目前拥有的函数方法:

  1. effect:用来注册副作用函数的方法,它可以传入options,例如使用scheduler控制副作用函数的执行
  2. track:用来追踪和收集依赖
  3. trigger:用来重新触发副作用函数执行

有了这些方法后,我们就可以实现 Vue 中的一个很有特色的功能:计算属性computed

但是,在完成这个功能之前,我们首先来完成effect的懒执行。当前我们的effect函数是会立刻执行的,但是在某些情况下,我们不希望它立即执行,我们更希望它在该执行的时候执行,这就是懒执行。在使用时,我们可以通过给options提供参数lazy: true来开启这个功能。下面我们来实现这个功能:

function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 方法
    cleanup(effectFn)
    // 当 effectFn执行时,将其设置为当前激活的副总用函数
    activeEffect = effectFn
    // 将注册的副作用函数压入栈内
    effectStack.push(effectFn)
    fn()
    // 在这个副作用函数执行完后,将其从栈内取出,
    // 这是为了解决嵌套 effect 时,内层的副作用函数执行完后能正确使用外层的副作用函数
    effectStack.pop() // 当前副作用函数执行后弹出
    activeEffect = effectStack[effectStack.length - 1] // 还原 activeEffect 为原值
  }
  // 将 options 挂载到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  // 判断是否懒执行
  if (!options.lazy) {
    // 未开启懒执行功能,则直接执行副作用函数
    effectFn()
  }
  // 返回副作用函数
  return effectFn
}


















 
 
 
 
 
 
 

在添加了懒执行的代码后,我们就可以在使用effect方法注册副作用函数后,在想让其执行的地方调用effect的返回值来执行副作用函数。如下:

const effectFn = effect(
  () => {
    console.log(dataProxy.text)
  },
  {
    // 开启懒执行功能
    lazy: true
  }
)

effectFn()
 









 

但是,其实能手动执行副作用函数这个功能意义并不大,因为我们直接在想执行的地方调用effect也是一样。但是,这个功能实现的意义在于,我们能获取到副作用函数返回的值了,如:

const effectFn = effect(
  () => dataProxy.text + " world",
  { lazy: true }
)

const text = effectFn()
console.log(text)

当然,目前它返回的是 undefined,为了实现获取返回值的功能,我们还需要对effect做一些修改:

function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 方法
    cleanup(effectFn)
    // 当 effectFn执行时,将其设置为当前激活的副总用函数
    activeEffect = effectFn
    // 将注册的副作用函数压入栈内
    effectStack.push(effectFn)
    // 将副作用函数的执行结果保存
    const res = fn()
    // 在这个副作用函数执行完后,将其从栈内取出,
    // 这是为了解决嵌套 effect 时,内层的副作用函数执行完后能正确使用外层的副作用函数
    effectStack.pop() // 当前副作用函数执行后弹出
    activeEffect = effectStack[effectStack.length - 1] // 还原 activeEffect 为原值
    // 返回副作用函数的执行结果
    return res
  }
  // 将 options 挂载到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  // 判断是否懒执行
  if (!options.lazy) {
    // 未开启懒执行功能,则直接执行副作用函数
    effectFn()
  }
  // 返回副作用函数
  return effectFn
}








 
 




 
 













现在,我们再执行上面获取返回值的代码即可有正确的返回结果了:

image-20230320162700317

好了,目前我们终于实现了懒执行的副作用函数,也能拿到它的执行结果了。那么现在就让我们来完成computed吧:getter | MDNopen in new window

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

  const obj = {
    // 当读取 value 时才是行 effectFn
    // 在 vue3 中,computed 返回的是一个 ref 对象,要使用 .value 才能读取具体值
    get value() {
      return effectFn()
    }
  }

  return obj
}

验证下computed方法:

const comText = computed(() => dataProxy.text + " world")
console.log(comText.value) // hello world

这个时候我们已经实现了简单的计算属性功能,但是,我们知道,vue 中的计算属性是可以缓存的,但是我们这里的值并不能缓存,因为我们目前实现的computed是只有读取.value时,它才会进行计算并得到值,所以每次调用.value,都会进行计算,即使dataProxy.text的值没有变化。

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

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

  // 用来缓存上一次计算的值
  let value
  // 用来标记是否需要重新计算,默认为 true,以便第一次进行计算
  let isRecalculate = true

  const obj = {
    // 当读取 value 时才是行 effectFn
    get value() {
      if (isRecalculate) {
        // 缓存值
        value = effectFn()
        isRecalculate = false
      }
      return value
    }
  }

  return obj
}






 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

但是,很明显,这样会有一个问题,就是isRecalculate这个变量外部无法读取到,导致第一次计算后它就一直是 false,会导致后续即使dataProxy.text的值改变了,它也不会重新计算。

那么解决这个问题的关键就在于将isRecalculate设置为 true 就行了,那么怎么才能更改呢?不要忘了,options中可是有一个scheduler方法的,我们只要在这里面更改就可以解决问题啦:

function computed(getter) {
  // 用来缓存上一次计算的值
  let value
  // 用来标记是否需要重新计算,默认为 true,以便第一次进行计算
  let isRecalculate = true

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

  const obj = {
    // 当读取 value 时才是行 effectFn
    get value() {
      if (isRecalculate) {
        value = effectFn()
        isRecalculate = false
      }
      return value
    }
  }

  return obj
}









 
 
 















验证如下:

const comText = computed(() => dataProxy.text + " world")
console.log(comText.value) // hello world
console.log(comText.value) // hello world
console.log(comText.value) // hello world

dataProxy.text = "hi"
console.log(comText.value) // hi world

dataProxy.text变化时,触发trigger方法,并进入到effect.options.scheduler存在的分支,触发computed中的这个scheduler,将isRecalculate置为 true,最后我们调用.value时,重新计算值并将其存储。

现在,这个computed已经近乎完美,但是确实还存在一个缺陷,也就是当我们在另一个effect中读取计算属性的值后,我们在其他地方修改这个计算属性依赖的值时,它应该会触发副作用函数的重新执行,但其实这里并没有,如下:

const comText = computed(() => dataProxy.count + dataProxy.text)

effect(() => {
  console.log(comText.value) // 1hello
})

dataProxy.count++ // 我们期望这个地方输出 2hello,但实际这里没有任何输出

要解决这个问题,我们首先观察下上面的代码,可以发现,这是一个effect嵌套的问题,这个计算属性内部有自己的effect,然后我们在外部给他又套了一层effect,因此对于computed中的getter来说,它只会将computed内部的effect收集为依赖,而外部嵌套的它则不会收集。

因此,要解决这个问题其实也不难。就是当读取计算属性的值时,我们手动调用track函数进行追踪,当计算属性依赖的响应式数据改变时,手动调用trigger函数进行触发响应

function computed(getter) {
  // 用来缓存上一次计算的值
  let value
  // 用来标记是否需要重新计算,默认为 true,以便第一次进行计算
  let isRecalculate = true

  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!isRecalculate) {
        isRecalculate = true
        // 当计算属性依赖的响应式数据变化时,手动调用 trigger 方法触发响应
        trigger(obj, "value")
      }
    }
  })

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

  return obj
}










 
 
 
 
 










 
 
 





现在我们再来验证下上面的代码:

const comText = computed(() => dataProxy.count + dataProxy.text)

effect(() => {
  console.log(comText.value) // 1hello
})

dataProxy.count++ // 2hello

现在,它就是正确符合我们预期的了。

4.9 watch 的实现原理

4.9 watch 的实现原理open in new window

watch本质上就是观测响应式数据,当数据变化时通知并执行相应的回调函数。

格式如下:watch(obj, () => {/* 其他代码 */}),obj 是观测的响应式数据,回调函数是当数据变化时执行的。

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

function watch(obj, cb) {
  effect(
    () => obj.count,
    {
      scheduler() {
        cb && cb()
      }
    }
  )
}

watch(dataProxy, () => {
  console.log("响应式数据改变了")
})

dataProxy.count++ // 响应式数据改变了

当然,我们能很容易的看出来,这是个代码是有问题的,因为上面我们硬编码观测的是dataProxy.count,所以,首先我们先解决传入的问题是一个对象,如dataProxy时,我们需要递归读取它上面的属性,从而当任意属性发生变化时都能触发回调函数执行,这个递归的方法我们将其命名为traverse,如下:

// watch方法,观测数据
function watch(obj, cb) {
  effect(
    () => traverse(obj),
    {
      scheduler() {
        cb && cb()
      }
    }
  )
}

// 递归方法,用来读取 watch 中传入的对象的属性
function traverse(value, seen = new Set()) {
  // seen 用来保存已经读取过的属性
  // 如果传入的数据是原始数据类型,或者已经被读取过了,则什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  // 将读取了的属性存储到 seen 中,避免循环引用
  seen.add(value)
  // 暂时不考虑数组等其他结构
  // 假设 value 是一个对象,使用 for...in 读取对象的每一个值,并递归调用 traverse 方法
  for (const key in value) {
    traverse(value[key], seen)
  }

  return value
}

watch(dataProxy, () => {
  console.log("响应式数据改变了")
})

dataProxy.count++ // 响应式数据改变了












 
 
 
 
 
 
 
 
 
 
 
 
 
 
 






目前,我们实现了非硬编码观测对象的能力,但是,我们知道,watch不仅能传入一个对象,也能传入一个getter函数,如:

watch(
	() => dataProxy.count,
  () => {
    console.log("dataProxy.count改变了")
  }
)

因此,watch函数修改如下:

// watch方法,观测数据
function watch(source, cb) {
  // 定义 getter
  let getter
  // 如果 source 是函数,则说明用户传递的是 getter 函数
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则调用 traverse 方法
    getter = () => traverse(source)
  }

  effect(
    // 执行 getter
    () => getter(),
    {
      scheduler() {
        cb && cb()
      }
    }
  )
}

watch(
  () => dataProxy.count,
  () => {
    console.log("响应式数据改变了")
  })

dataProxy.count++ // 响应式数据改变了


 
 
 
 
 
 
 
 
 


 
 















ok,目前,我们也完成了传递getter函数的功能,现在,还有一个功能我们没有完成,也就是watch中获取到被观测数据改变前后的值,即oldValuenewValue

现在我们来完成这个功能:

function watch(source, cb) {
  // 定义 getter
  let getter
  // 如果 source 是函数,则说明用户传递的是 getter 函数
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则调用 traverse 方法
    getter = () => traverse(source)
  }

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

watch(
  () => dataProxy.count,
  (newValue, oldValue) => {
    console.log("响应式数据改变了", newValue, oldValue)
  })

dataProxy.count++ // 响应式数据改变了 2 1











 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 









以上就是这个watch方法的基本实现了。

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

4.10 立即执行的 watch 与回调执行时机open in new window

上节我们完成了watch方法的基本实现,其本质就是对effect的二次封装。本节我们继续完成watch的两个特性:

  1. 立即执行的回调函数
  2. 回调函数的执行时机

默认情况下,watch方法的回调函数只在被观测的数据变化时才会触发,但是,在 vue 中,watch可以通过传入immediate参数来指定回调是否需要立即执行:

watch(obj, () => {
 console.log("变化了") 
}, {
  immediate: true
})

为了完成这个功能,我们需要改造watch函数,在其中添加options.immediate的判断:

function watch(source, cb, options = {}) {
  // 定义 getter
  let getter
  // 如果 source 是函数,则说明用户传递的是 getter 函数
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则调用 traverse 方法
    getter = () => traverse(source)
  }

  // 定义新值和旧值
  let oldValue, newValue
  // 提取 scheduler 调度函数为一个独立的函数 job
  const job = () => {
    // 在 scheduler 中重新执行副作用函数,得到的是新值
    newValue = effectFn()
    // 将旧值和新值作为回调函数的参数
    cb && cb(newValue, oldValue)
    // 更新旧值,以便下一次获取旧值时时正确的
    oldValue = newValue
  }

  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: job
    }
  )

  // 判断 immediate 是否为 true
  if (options.immediate) {
    job()
  } else {
    // 手动调用副作用函数,得到的就是旧值
    oldValue = effectFn()
  }
}

watch(
  () => dataProxy.count,
  (oldValue, newValue) => {
    console.log("响应式数据改变了", oldValue, newValue)
  },
  {
    immediate: true
  }
) // 响应式数据改变了 1 undefined













 
 
 
 
 
 
 
 
 







 



 
 
 
 
 
 
 











这样,我们就完成了回调函数立即执行的功能。

现在,我们就来完成执行回调函数执行时机的功能,同样的,我们也需要使用一个options参数来实现,在 vue 中,这个参数是flush,它有三个值:pre | post | sync

要实现控制执行时机,其实我们在4.7 调度执行中提到过,使用Promise.resolve()方法,将要执行的方法添加到微队列中即可。实现如下:

function watch(source, cb, options = {}) {
  // 定义 getter
  let getter
  // 如果 source 是函数,则说明用户传递的是 getter 函数
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则调用 traverse 方法
    getter = () => traverse(source)
  }

  // 定义新值和旧值
  let oldValue, newValue
  // 提取 scheduler 调度函数为一个独立的函数 job
  const job = () => {
    // 在 scheduler 中重新执行副作用函数,得到的是新值
    newValue = effectFn()
    // 将旧值和新值作为回调函数的参数
    cb && cb(newValue, oldValue)
    // 更新旧值,以便下一次获取旧值时时正确的
    oldValue = newValue
  }

  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 在调度函数中判断 flush 是否为 'post',如果是,则将其放到微任务队列中执行
        if(options.flush === "post"){
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )

  // 判断 immediate 是否为 true
  if (options.immediate) {
    job()
  } else {
    // 手动调用副作用函数,得到的就是旧值
    oldValue = effectFn()
  }
}






























 
 
 
 
 
 
 












如上述代码,我们实现了flush === 'post'的情况,而剩下的sync就是同步执行,至于pre,它涉及到组件的更新时机,目前没办法模拟。

4.11 过期的副作用

4.11 过期的副作用open in new window

举个例子,我们发送了一次网络请求,那么在这一次请求的结果还没有返回时,我们又重新发送了一次请求,那么,前一次的请求就是过期的。拿副作用函数来说就是前一次的副作用函数还没有执行完成时,有了新的副作用函数执行,那么前一次的就是过期的副作用。

因此我们就需要一个让副作用过期的手段,不执行过期的副作用。在 vue 中,watch的回调函数接收第三个参数onInvalidate,这个参数是一个函数,类似于事件监听器,我们可以使用onInvalidate注册一个回调,这个回调函数会在当前副作用函数过期时执行:

watch(obj, async(newValue, oldValue, onInvalidate) => {
  // 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表未过期
  let expired = false
  // 调用 onInvalidate() 函数注册一个过期的回调
  onInvalidate(() => {
    // 当过期时,将 expired 置为 true
    expired = true
  })
  
  // 网络请求
  const res = await fetch('url')
  // 当该副作用函数的执行还没有过期时,才会执行后续操作
  if (!expired) {
    data = res
  }
})

如上面的代码,我们在发送请求前就先定义一个变量expired来标识这次的副作用函数的执行是否过期,然后使用onInvalidate函数注册一个过期时的回调,用来改变expired的值。然后再发送请求,并判断是否过期,未过期则执行其他后续操作。

下面,我们来改造watch函数,给它的回调函数添加onInvalidate函数参数。这个函数的原理就是在watch内部每次检测到变更后,在副作用函数重新执行之前,会先调用通过onInvalidate注册的过期时的回调函数

function watch(source, cb, options = {}) {
  // 定义 getter
  let getter
  // 如果 source 是函数,则说明用户传递的是 getter 函数
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则调用 traverse 方法
    getter = () => traverse(source)
  }

  // 定义新值和旧值
  let oldValue, newValue
  // 定义 cleanup 变量用来存储用户注册的过期回调函数,这跟我们在外面定义的 cleanup 全局方法不一样
  let cleanup
  // 定义 onInvalidate 函数
  function onInvalidate(fn) {
    // 将过期的回调存储到 cleanup 中
    cleanup = fn
  }

  // 提取 scheduler 调度函数为一个独立的函数 job
  const job = () => {
    // 在 scheduler 中重新执行副作用函数,得到的是新值
    newValue = effectFn()
    // 调用回调函数前,清除过期回调
    if (cleanup) {
      cleanup()
    }
    // 将旧值和新值作为回调函数的参数,将 onInvalidate 作为第三个参数
    cb && cb(newValue, oldValue, onInvalidate)
    // 更新旧值,以便下一次获取旧值时时正确的
    oldValue = newValue
  }

  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 在调度函数中判断 flush 是否为 'post',如果是,则将其放到微任务队列中执行
        if (options.flush === "post") {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )

  // 判断 immediate 是否为 true
  if (options.immediate) {
    job()
  } else {
    // 手动调用副作用函数,得到的就是旧值
    oldValue = effectFn()
  }
}













 
 
 
 
 
 
 





 
 
 
 
 
 






























上面定义了一个变量cleanup,它与上面我们用来将副作用函数从依赖集合中清除的cleanup()方法不同,这里它用来保存我们通过onInvalidate函数注册的副作用过期时的回调。

第四章的完整代码

完整代码
// 原始数据
const data = {
  text: 'hello',
  count: 1
}

// 存储 target 和 key,保存的是一个 Map 类型
const bucket = new WeakMap()

// cleanup 用来将副作用函数从依赖集合中清除
function cleanup(effectFn) {
  const len = effectFn.deps.length
  for (let i = 0; i < len; i++) {
    // 取出依赖集合
    const deps = effectFn.deps[i]
    // 将 effectFn 从这个集合中清除
    deps.delete(effectFn)
  }
  // 重置 effectFn.deps 数组
  effectFn.deps = []
}

// 用来存储被注册的副作用函数
let activeEffect = null
// 这是一个副作用函数栈,用来存储要注册的副作用函数
let effectStack = []
// effect 函数用来注册副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 方法
    cleanup(effectFn)
    // 当 effectFn执行时,将其设置为当前激活的副总用函数
    activeEffect = effectFn
    // 将注册的副作用函数压入栈内
    effectStack.push(effectFn)
    // 将副作用函数的执行结果保存
    const res = fn()
    // 在这个副作用函数执行完后,将其从栈内取出,
    // 这是为了解决嵌套 effect 时,内层的副作用函数执行完后能正确使用外层的副作用函数
    effectStack.pop() // 当前副作用函数执行后弹出
    activeEffect = effectStack[effectStack.length - 1] // 还原 activeEffect 为原值
    // 返回副作用函数的执行结果
    return res
  }
  // 将 options 挂载到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  // 判断是否懒执行
  if (!options.lazy) {
    // 未开启懒执行功能,则直接执行副作用函数
    effectFn()
  }
  // 返回副作用函数
  return effectFn
}

// 在 get 方法中调用,追踪变化
function track(target, key, receiver) {
  // 没有 activeEffect,直接 return
  if (!activeEffect) return Reflect.get(target, key, receiver)
  // 根据 target 从 bucket 中取 depsMap,它也是一个 Map 类型
  // key -> effects
  let depsMap = bucket.get(target)
  // 如果 depsMap 不存在,根据 target 新建一个
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  // 里面存储着所有与当前 key 相关联的副作用函数
  let deps = depsMap.get(key)
  // 如果 deps 不存在,根据 key 新建一个
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 最后将当前激活的副作用函数存入 deps 中
  deps.add(activeEffect)
  // 将 deps 添加到 effectFn.deps 数组中。这里的 deps 就是我们想要的依赖集合
  activeEffect.deps.push(deps)
}

// 在 set 方法中调用,追踪变化
function trigger(target, key) {
  // 根据 target 取出 depsMap
  const depsMap = bucket.get(target)
  if (!depsMap) return
  // 根据 key 取出所有副作用函数
  const effects = depsMap.get(key)
  const newEffects = new Set()
  // 循环判断副作用函数
  effects && effects.forEach(effectFn => {
    // 如果目前 trigger 触发的这个副作用函数不在执行,则继续它的执行步骤
    // 否则不执行任何操作
    if (effectFn !== activeEffect) {
      newEffects.add(effectFn)
    }
  })
  // 代替 effects 进行遍历
  newEffects.forEach(effectFn => {
    // 如果一个副作用函数存在调度器,则调用这个调度器,并将副作用函数作为参数传递
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

// 对数据进行代理
const dataProxy = new Proxy(data, {
  // 拦截数据的读取操作
  get(target, key, receiver) {
    track(target, key)
    // 返回读取的属性值,Reflect 可以查看上面 Proxy 链接
    return Reflect.get(target, key, receiver)
  },

  // 拦截数据的赋值操作
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    trigger(target, key)
    // set 方法需要返回一个 boolean
    return result
  }
})

// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例
// 用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 用来标志是否正在刷新队列
let isFlushing = false
// 用来刷新队列
function flushJob() {
  // 如果正在刷新,则直接返回
  if (isFlushing) return false
  // 设置 isFlushing 为 true,表示正在刷新
  isFlushing = true
  // 在微任务队列中刷新 JobQueue 队列
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false
  })
}

// 计算属性方法
function computed(getter) {
  // 用来缓存上一次计算的值
  let value
  // 用来标记是否需要重新计算,默认为 true,以便第一次进行计算
  let isRecalculate = true

  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!isRecalculate) {
        isRecalculate = true
        // 当计算属性依赖的响应式数据变化时,手动调用 trigger 方法触发响应
        trigger(obj, "value")
      }
    }
  })

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

  return obj
}

// watch方法,观测数据
function watch(source, cb, options = {}) {
  // 定义 getter
  let getter
  // 如果 source 是函数,则说明用户传递的是 getter 函数
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则调用 traverse 方法
    getter = () => traverse(source)
  }

  // 定义新值和旧值
  let oldValue, newValue
  // 定义 cleanup 变量用来存储用户注册的过期回调函数,这跟我们在外面定义的 cleanup 全局方法不一样
  let cleanup
  // 定义 onInvalidate 函数
  function onInvalidate(fn) {
    // 将过期的回调存储到 cleanup 中
    cleanup = fn
  }

  // 提取 scheduler 调度函数为一个独立的函数 job
  const job = () => {
    // 在 scheduler 中重新执行副作用函数,得到的是新值
    newValue = effectFn()
    // 调用回调函数前,清除过期回调
    if (cleanup) {
      cleanup()
    }
    // 将旧值和新值作为回调函数的参数,将 onInvalidate 作为第三个参数
    cb && cb(newValue, oldValue, onInvalidate)
    // 更新旧值,以便下一次获取旧值时时正确的
    oldValue = newValue
  }

  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 在调度函数中判断 flush 是否为 'post',如果是,则将其放到微任务队列中执行
        if (options.flush === "post") {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )

  // 判断 immediate 是否为 true
  if (options.immediate) {
    job()
  } else {
    // 手动调用副作用函数,得到的就是旧值
    oldValue = effectFn()
  }
}

// 递归方法,用来读取 watch 中传入的对象的属性
function traverse(value, seen = new Set()) {
  // seen 用来保存已经读取过的属性
  // 如果传入的数据是原始数据类型,或者已经被读取过了,则什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  // 将读取了的属性存储到 seen 中,避免循环引用
  seen.add(value)
  // 暂时不考虑数组等其他结构
  // 假设 value 是一个对象,使用 for...in 读取对象的每一个值,并递归调用 traverse 方法
  for (const key in value) {
    traverse(value[key], seen)
  }

  return value
}

第五章 非原始值的响应式方案

通过上一章我们了解到了响应系统的概念与实现,并简单介绍了响应式数据的基本原理。

这一章我们就专注与响应式数据本身,深入探讨实现响应式数据都需要考虑哪些内容,其中的难点又是什么。

实际上实现响应式数据是很难的,并不是简单的get/set操作即可,还有例如拦截for...in循环,代理Map | Set | WeakMap | WeakSet等数据类型。

5.1 理解 Proxy 和 Reflect

5.1 理解 Proxy 和 Reflectopen in new window

Proxy可以创建一个代理对象,它能够实现对其他对象的代理,但是也 只能代理对象,无法代理如string | boolean | number 等原始值。

代理:指的是对一个对象基本语义(或者说基本操作,如读取、赋值)的代理,它允许我们拦截并重新定义对一个对象的基本操作。

Reflect是一个全局对象,其有许多方法。任何在Proxy的拦截器中能够找到的方法,都能够在Reflect中找到同名函数。

Proxy 和 Reflectopen in new window

5.2 JavaScript 对象及 Proxy 的工作原理

5.2 JavaScript 对象及 Proxy 的工作原理open in new window

这一节主要讲解了 ECMAScript 规范 中 JavaScript 中的两种对象:

  1. 常规对象:满足以下三点要求的对象就是常规对象。
    • 必须使用 ECMA 规范10.1.x给出的定义实现
    • 对于内部方法[[Call]],必须使用 ECMA 规范10.2.1给出的定义实现
    • 对于内部方法[[Construct]],必须使用 ECMA 规范10.2.2给出的定义实现
  2. 异质对象:所有不属于常规对象的对象都是异质对象。

区分函数对象和普通对象:通过内部方法和内部槽来区分。函数对象会部署内部方法[[Call]],而普通对象不会。

Proxy 对象内部的方法[[Get]]没有使用 ECMA 规范的10.1.8给出的定义实现,所以 Proxy 是一个异质对象。

内部方法的多态性:不同对象有同样的内部方法,但是这些内部方法的实现方式又有不同。

创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来自定义被代理对象的内部方法和行为的。即我们代理了一个对象obj,代理对象就是objProxy,被代理对象就是obj,我们指定的拦截函数是给objProxy的,而obj其实没什么变化。

5.3 如何代理 Object

5.3 如何代理 Objectopen in new window

下面是一些对普通对象的所有可能的读取操作:

  1. 访问属性:obj.foo
  2. 判断对象或原型上是否存在给定的 key:key in obj
  3. 使用 for...in 遍历对象:for (const key in obj) {}

下面就逐步讨论如何拦截上面这些读取操作。首先是属性的读取,我们知道可以使用get来进行拦截。

但是in操作符呢?Proxy 中拦截各种情况的函数

in操作符的运算结果是通过调用一个叫做HasProperty的抽象方法得到的。关于这个抽象方法,可以在 ECMA-262 规范的 7.3.11 中找到。

HasProperty抽象方法的返回值是通过调用对象内部的[[HasProperty]]得到的。这个内部方法对应的拦截函数为has

因此我们可以通过has拦截函数实现对in操作符的代理。 如下:

const obj = { foo: 1 }
const p = new Proxy(obj, {
  has(target, key) {
    track(target, key)
    return Reflect.has(target, key)
  }
})

effect(() => {
  'foo' in p // 会建立依赖关系
})

上面我们知晓了in操作符的拦截,那么for...in呢。

通过上面那个链接,我们能直接得到答案,使用ownKeys方法即可拦截for...in操作。至于原因,如下:

详情

image-20230424152041584

const obj = { foo: 1 }
const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {
  ownKeys(target) {
    // 将副作用函数与 ITERATE_KEY 关联
    track(target, ITERATE_KEY)
    return Reflect.ownKeys(target)
  }
})

为什么使用 ITERATE_KEY 作为追踪的 key

因为ownKeys拦截函数与get/set不同,在get/set中,我们可以得到具体操作的 key,但是在ownKeys中,我们只能拿到目标对象的 target。

这里追踪的是 ITERATE_KEY,那么在触发响应时也应该用它:trigger(target, ITERATE_KEY)

第六章 原始值的响应式方案

上次编辑于: