笔记:《Vue.js设计与实现》—— 响应系统的作用与实现


最近在看《Vue.js设计与实现》这本书,第四章响应系统的作用与实现真是干货满满,反反复复看了好几遍才看得明白,记录一下~

响应式数据与副作用函数

副作用函数

在系统开发和设计中,一般函数设计的时候都是内部执行,并不会影响外部的执行,但是如果一个内部函数影响了外部的执行,这个就叫做副作用,基于这个特性对于这些函数都被称为具有副作用的函数

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

上面示例中 effect 函数执行时会设置 body 的文本内容,getText 函数读取 body 的文本内容,effect 函数的执行会影响 getText 函数的执行结果,也就是说 effect 函数产生了副作用

响应式数据

当数据发生变化时,与之相关联的副作用函数能够自动重新执行

如下面示例中的 obj,我们希望在 obj.text 的值变化时能够自动重新执行 effect 函数(这里还没有实现响应式~)

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

响应式数据的基本实现

实现响应式数据的核心逻辑

  • getter,即数据读取:当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作

  • setter,即数据修改:当修改 obj.text 的值时,会触发字段 obj.text 的设置操作

实现原理

当读取操作发生时,将当前执行的副作用函数存储到“桶”中,当设置操作发生时,再将副作用函数从“桶”里取出并执行

如何才能拦截一个对象属性的读取和设置操作

Object.defineProperty(obj, prop, descriptor)

obj 要定义属性的对象。

prop 一个字符串或 Symbol,指定了要定义或修改的属性键。

descriptor 要定义或修改的属性的描述符。

  • Vue 3 通过代理对象 Proxy 实现
const p = new Proxy(target, handler)

target 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。

handler 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

根据以上,完成一个响应系统的简单实现 code 1

// 以下技术代码

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

// 原始数据,原始数据的定义其实应该在业务代码里面的,这里先不做讨论
const data = { text: 'hello world' }
// 对原始数据的代理
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())
  }
})

// 技术代码结束

// 以下业务代码

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

setTimeout(() => {
  // 1 秒后修改 text
  obj.text = '123'
}, 1000)

// 业务代码结束

运行可以看见 1 秒后文本内容变成 123

注:技术代码为方便开发者开发使用,业务代码与业务逻辑相关,两者应当是分离的,书中并没有提及技术代码和业务代码,这里只是为了方便自己理解 ~~~

设计一个完善的响应系统

问题1:消除硬编码副作用函数名

观察 code 1,技术代码中直接使用业务代码的 effect 副作用函数名,一般来说,技术代码和业务代码应该是分离的。另外,即使副作用函数是匿名函数也应当能被收集到“桶”中。这里给出的解决方法是:用一个全局变量存储被注册的副作用函数,从业务代码中抽离出一个副作用函数注册函数到技术代码中,业务代码调用注册函数,将副作用函数参数的形式传入赋值给全局变量,原先技术代码内调用业务代码中副作用函数的地方修改为调用全局变量存储的副作用函数

code 2

// 以下技术代码

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

// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = 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())
  }
})

// 用一个全局变量存储当前被注册的 effect 函数
let activeEffect
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

// 技术代码结束

// 以下业务代码
effect(() => {
  document.body.innerText = obj.text
})

setTimeout(() => {
  // 1 秒后修改 text
  obj.text = '123'
}, 1000)

// 业务代码结束

问题2:响应式数据上设置不存在的属性时不应该执行副作用函数

观察 code 2,代理对象拦截读取、设置操作时并没有对 key 做判断,这就存在一个问题:在响应式数据 obj 上设置任何属性时,都能触发收集在桶里的副作用函数

// 以下业务代码
effect(() => {
  document.body.innerText = obj.text
  console.log('执行了')
})

setTimeout(() => {
  // 1 秒后修改 text
  obj.name = '123'
}, 1000)

// 业务代码结束

稍微修改下业务代码,可以看见虽然 1 秒后修改的是 obj.name 而不是 obj.text,effect 函数仍然会执行。导致问题的根本原因是:没有在副作用函数与被操作的目标字段之间建立明确的联系

此外,可以想到,一个副作用函数可能读取多个对象属性,一个对象属性可能同时存在多个副作用函数,多个对象亦关联多个相同或不同的副作用函数

用树型结构表示如下

一个副作用函数中读取了同一个对象的两个不同属性
01 target
02     └── text1
03         └── effectFn
04     └── text2
05         └── effectFn

两个副作用函数同时读取同一个对象的属性值
01 target
02     └── text
03         └── effectFn1
04         └── effectFn2

在不同的副作用函数中读取了两个不同对象的不同属性
01 target1
02     └── text1
03         └── effectFn1
04 target2
05     └── text2
06         └── effectFn2

回看前面对桶的定义 const bucket = new Set(),也就是说桶是类似数组 [effectFn1, effectFn2, …] 这样的结构,这样是有缺陷的,经上面的分析:副作用函数需与被操作的目标字段之间建立明确的联系,也就是应当是类似

{ 
  target1: {
    text1: [effectFn1, effectFn2, ...],
    ...
  },
  ...
}

这样的对象结构。于是使用 WeakMap 代替 Set 作为桶的数据结构

为什么不是 Map 呢
WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。所以 WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息,如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出

code 3

// 以下技术代码

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

// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

// 技术代码结束

// 以下业务代码

effect(() => {
    document.body.innerText = obj.text
    console.log('执行了 effect')
})

setTimeout(() => {
    obj.name = '123'
    console.log('effect 不会再执行')
}, 1000)

// 业务代码结束

code 3 做了一些封装处理:

  • track 函数:副作用函数收集到“桶”里的这部分逻辑

  • trigger 函数:触发副作用函数重新执行的逻辑


下面重头戏开始啦(🤯要长脑子啦~


分支切换与 cleanup

问题3:分支切换导致依赖冗余

引用书中示例

01 const data = { ok: true, text: 'hello world' }
02 const obj = new Proxy(data, { /* ... */ })
03
04 effect(function effectFn() {
05   document.body.innerText = obj.ok ? obj.text : 'not'
06 })

当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。(obj.ok 才是主角,obj.text 是配角,副作用函数的执行由主角决定,配角在主角需要的时候才上场,不需要的时候是没有戏份滴 🤭)

现在来看 code 3,稍微修改下代码

// 以下技术代码

const data = { text: 'hello world', ok: true }

// 原代码不变

// 技术代码结束

// 以下业务代码

effect(() => {
    document.body.innerText = obj.ok ? obj.text : 'not'
    console.log('执行了 effect')
})

setTimeout(() => {
    obj.ok = false
    console.log('effect 会再执行')

    setTimeout(() => {
        obj.text = 'change text'
        console.log('effect 不应该再执行但实际上会执行')
    }, 1000)
}, 1000)

// 业务代码结束

// 控制台显示:
// 执行了 effect
// 执行了 effect
// effect 会再执行
// 执行了 effect
// effect 不应该再执行但实际上会执行

可以看见,在 obj.ok 为 false 之后修改 obj.text 会重新执行副作用函数,尽管此时 document.body.innerText 并不需要 get obj.text(这个时候希望的副作用函数应该如下代码 2 所示),出现这个问题的原因是在首次 obj.ok = true 时已经存储了如下(代码 1)所示副作用函数

// 代码 1
// obj.ok = true
effect(() => {
    document.body.innerText = obj.text
    console.log('执行了 effect')
})
// 代码 2
// obj.ok = false
effect(() => {
    document.body.innerText = 'not'
    console.log('执行了 effect')
})

所以解决问题的思路是:每次副作用函数执行时,可以先把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数

这里可能有点疑惑是否存在性能问题,如果不做依赖回收,代码执行过程中频繁切换分支导致冗余依赖增长,成本巨大,所以这是一个权衡的结果

实现:在 effect 内部定义了新的 effectFn 函数,并为其添加了 effectFn.deps 属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合,在 track 函数中把 activeEffect 添加到 activeEffect.deps 数组中,这样就完成了对依赖集合的收集

code 4

// 以下技术代码

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

// 原始数据
const data = { ok: false, text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
  activeEffect.deps.push(deps)
}

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 => effectsToRun.add(effectFn))
  effectsToRun.forEach(effectFn => effectFn())
}

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

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

// 技术代码结束

// 以下业务代码

effect(() => {
  console.log('effect run')
  document.body.innerText = obj.ok ? obj.text : 'not'
})

// 为了方便理解 effectFn.deps,这里多调用了两次 effect 副作用注册函数
effect(() => {
  const xxx = obj.ok
  console.log('effect run obj.ok')
})

effect(() => {
  const xxx = obj.text
  console.log('effect run obj.text')
})

setTimeout(() => {
  obj.ok = false
  setTimeout(() => {
    obj.text = 'hello vue3'
  }, 1000)
}, 1000)

// 业务代码结束

下面列了几个阅读时困惑的问题

1. 如何想到用函数属性(effectFn.deps)来存储的

不知道,想到这个方法的人真是人才(褒义~)

emmmm 道行不够还需修行

2. 为什么 effect 函数不 activeEffect = fn 而是 activeEffect = effectFn

复制书友

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
  activeEffect = fn
  activeEffect.deps = []
  fn()
}

如果直接这样写的话, 相当于给 fn 函数添加了一个 deps 的属性, 会导致对外暴露了 deps, 不安全的行为

3. cleanup 函数为什么要遍历 effectFn.deps 数组

effectFn:function

effectFn.deps: [Set(), Set(), …] => Set():[effectFn, effectFn, …]

这里 Set() 是 key 的依赖集合,遍历是为了把 effectFn 从依赖集合中移除

4. cleanup 函数为什么是用 effectFn.deps.length = 0 而不是 effectFn.deps = []

// array.length = 0 和 array = [] 的区别:
let foo = [1, 2]
let bar = [1, 2]
let foo1 = foo
let bar1 = bar
foo1.length = 0
bar1 = []
console.log(foo, foo1, bar, bar1) 

// [] [] [1, 2] []
// array.length = 0 保留对原数组的引用并清空元素
// array = [] 创建一个新的空数组,对原数组无影响

5. trigger 函数里新构造的 effectsToRun 集合

这其实是书里的内容,目的是为了避免无限执行

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

嵌套的 effect 与 effect 栈

先来理解下为什么需要支持嵌套

Vue.js 的渲染函数其实是在一个 effect 中执行的,在组件 A 中渲染组件 B 是很常见的场景,所以 effect 需要设计成可嵌套的。

问题4:支持 effect 嵌套

将业务代码修改如下

let temp1, temp2

effect(function effectFn1() {
  console.log('effectFn1 执行')
  effect(function effectFn2() {
    console.log('effectFn2 执行')
    temp2 = obj.text
  })
  temp1 = obj.ok
})

setTimeout(() => {
  obj.ok = false
}, 1000);

可以看到控制台输出

effectFn1 执行
effectFn2 执行
effectFn2 执行

理想结果是

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

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

解决的思路:我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数

code 5

// 以下技术代码

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

// 原始数据
const data = { ok: false, text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
  activeEffect.deps.push(deps)
}

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 => effectsToRun.add(effectFn))
  effectsToRun.forEach(effectFn => effectFn())
}

// 用一个全局变量存储当前激活的 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()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

// 技术代码结束

// 以下业务代码
let temp1, temp2

effect(function effectFn1() {
  console.log('effectFn1 执行')
  effect(function effectFn2() {
    console.log('effectFn2 执行')
    temp2 = obj.text
  })
  temp1 = obj.ok
})

setTimeout(() => {
  obj.ok = false
}, 1000);

// 业务代码结束

避免无限递归循环

问题5:解决无限递归循环

修改业务代码

effect(() => {
  obj.text = obj.text + ' '
})

会发现报错 RangeError: Maximum call stack size exceeded (范围错误: 超出最大调用堆栈大小),这是因为副作用函数还没执行完就要开始下一次回调,因为当前回调没有执行完毕所以不会出栈,不断重复执行当前回调导致栈溢出(obj.text 在 set 阶段又进入 get 阶段,同样的副作用函数入栈,重复循环…)

解决思路

在 trigger 动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

code 6

// 以下技术代码

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

// 原始数据
const data = { ok: false, text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
  activeEffect.deps.push(deps)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}

// 用一个全局变量存储当前激活的 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()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

// 技术代码结束

// 以下业务代码
effect(() => {
  obj.text = obj.text + ' '
})
// 业务代码结束

调度执行

问题6:副作用函数执行的时机、次数及方式可控?

effect 函数提供第 2 个参数 options 对象,其中允许指定 scheduler 调度函数,同时在 effect 函数内部我们需要把 options 选项挂载到对应的副作用函数上(看代码)

code 7

// 以下技术代码

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

// 原始数据
const data = { ok: false, text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
  activeEffect.deps.push(deps)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
    if (effectFn.options.scheduler) { // 新增
      effectFn.options.scheduler(effectFn)  // 新增
    } else {
      // 否则直接执行副作用函数(之前的默认行为)
      effectFn()  // 新增
    }
  })
  
}

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

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()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

// 技术代码结束

// 以下业务代码
effect(() => {
  console.log(obj.text)
}, {
  scheduler(fn) {
    setTimeout(() => {
      fn()
    }, 1000)
  }
})

obj.text = '111111'

console.log('end')
// 业务代码结束

Vue 文档在介绍 nextTick 时提及:

当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。

🫷 这里不讨论 nextTick,只解释 Vue 异步更新响应式状态的思路

code 7 业务代码替换如下

// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()

// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {
  // 如果队列正在刷新,则什么都不做
  if (isFlushing) return
  // 设置为 true,代表正在刷新
  isFlushing = true
  // 在微任务队列中刷新 jobQueue 队列
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false
  })
}

effect(() => {
  console.log(oobj.text)
}, {
  scheduler(fn) {
    // 每次调度时,将副作用函数添加到 jobQueue 队列中
    jobQueue.add(fn)
    // 调用 flushJob 刷新队列
    flushJob()
  }
})

obj.text = '111'
obj.text = '222'

先了解一下宏任务和微任务大致有:

  • 宏任务

    • script全部代码
    • setTimeout
    • setInterval
    • I/O
    • mouseover(之类的事件)
    • Web API大部分异步返回方法(XHR,fetch)
  • 微任务

    • Promise.then catch finally
    • MutationObserver
    • queueMicrotask

上面代码中

obj.text = '111'
obj.text = '222'

在同一个宏任务中,当执行 obj.text = '111' 完毕,副作用函数添加到任务队列中,promise.then 注册的回到函数是微任务,在当前宏任务完成后的下一个微任务队列中执行,接下来执行的是 宏任务中的obj.text = '222' ,由于任务队列是 Set 对象,所以执行完毕不会重复添加副作用函数,这时候宏任务结束,开始执行微任务,此时 obj.text 是 ‘222’,所以控制台输出

hello world
222

计算属性 computed 与 lazy

懒执行(lazy)的 effect

在 options 中添加 lazy 属性,使得 effect 函数不立即执行而是在需要的时候执行

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  // 新增
}

实现计算属性 computed

code 8

// 以下技术代码

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

// 原始数据
const data = { foo: 1, bar: 2 }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
  activeEffect.deps.push(deps)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
  // effects && effects.forEach(effectFn => effectFn())
}

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

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    const res = fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]

    return res
  }
  // 将 options 挂在到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  if (!options.lazy) {
    effectFn()
  }

  return effectFn
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

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

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })

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

  return obj
}

// 技术代码结束

// 以下业务代码

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

effect(() => {
  console.log('effect:' + sumRes.value)
})

obj.foo++

// 业务代码结束

🗒︎ 笔记:

  1. effect 副作用注册函数内部 effectFn 的返回值是 effect 副作用注册函数参数 fn 的执行结果

  2. effect 副作用注册函数的返回值是内部包装的 effectFn 副作用函数(懒执行的 effect 中已体现)

  3. effect 副作用注册函数是懒执行的,即 lazy = true

  4. computed 函数返回一个对象,该对象的 value 属性是一个访问器属性,返回第 2 点副作用函数的执行结果

  5. dirty 标志用来标识是否需要重新计算值(缓存功能)

  6. scheduler 调度器函数重置 dirty

  7. 读取计算属性的值时,手动调用 track 函数进行追踪;计算属性依赖的响应式数据发生变化时,手动调用 trigger 函数触发响应(解决 effect 嵌套)

watch 的实现原理

watch() API

侦听一个或多个响应式数据源,并在数据源变化时调用所给的回调函数

// 侦听单个来源
function watch<T>(
  source: WatchSource<T>,
  callback: WatchCallback<T>,
  options?: WatchOptions
): StopHandle

// 侦听多个来源
function watch<T>(
  sources: WatchSource<T>[],
  callback: WatchCallback<T[]>,
  options?: WatchOptions
): StopHandle

第一个参数是侦听器的源。这个来源可以是以下几种:

  • 一个函数,返回一个值(getter 函数)
  • 一个 ref
  • 一个响应式对象
  • 由以上类型的值组成的数组

第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:新值、旧值,以及一个用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。当侦听多个来源时,回调函数接受两个数组,分别对应来源数组中的新值和旧值。

第三个可选的参数是一个对象(下一节讨论),支持以下这些选项:

  • immediate:在侦听器创建时立即触发回调。第一次调用时旧值是 undefined。
  • deep:如果源是对象,强制深度遍历,以便在深层级变更时触发回调。参考深层侦听器。
  • flush:调整回调函数的刷新时机。参考回调的刷新时机及 watchEffect()。
  • onTrack / onTrigger:调试侦听器的依赖。参考调试侦听器。
  • once:回调函数只会运行一次。侦听器将在回调函数首次运行后自动停止。

实现原理 code 9

// 以下技术代码

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

// 原始数据
const data = { foo: 1, bar: 2 }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
  activeEffect.deps.push(deps)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
  // effects && effects.forEach(effectFn => effectFn())
}

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

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    const res = fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]

    return res
  }
  // 将 options 挂在到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  if (!options.lazy) {
    effectFn()
  }

  return effectFn
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

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

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })

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

  return obj
}

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

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(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log(newValue, oldValue)  // 2, 1
  }
)
obj.foo++
// 业务代码结束

🗒︎ 笔记:

  1. watch() 的第一个参数可能是响应式对象也可能是一个 getter 函数(暂不考虑其它),所以需要先对 source 进行判断

  2. 在 watch 内部的 effect 中调用 traverse 函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行

  3. 使用 lazy 选项创建了一个懒执行的 effect

  4. oldValue = effectFn() 手动调用 effectFn 函数获取旧值。当变化发生并触发 scheduler 调度函数执行时,newValue = effectFn() 重新调用 effectFn 函数得到新值,将它们作为参数传递给回调函数 cb。

  5. ❗不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值

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

立即执行的 watch

前面提到 watch() API 提到第三个参数对象支持的选项

immediate:在侦听器创建时立即触发回调。第一次调用时旧值是 undefined

实现思路

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()
  }
}

回调执行时机

前面提到 watch() API 提到第三个参数对象支持的选项

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()
  }
}

过期的副作用

竞态问题,又叫竞态条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。

此词源自于两个信号试着彼此竞争,来影响谁先输出。

简单来说,它出现的原因是无法保证异步操作的完成会按照他们开始时同样的顺序。

举个🌰,有一个分页列表,我们快速地切换第二页,第三页。

会先后请求 data2 与 data3,分页器显示当前在第三页,并且进入 loading。

但由于网络的不确定性,先发出的请求不一定先响应,有可能 data3 比 data2 先返回。

最终,请求返回 data2 后,分页器指示当前在第三页,但展示的是第二页的数据。

这就是竞态条件,在前端开发中,常见于搜索,分页,选项卡等切换的场景。

解决思路:当发出新的请求时,取消掉上次请求

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

文章作者: April-cl
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 April-cl !
  目录