《vue设计与实现》阅读笔记(第四章 响应系统的设计与实现)

182 阅读13分钟

4.1、副作用函数

副作用函数:

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

当effect函数执行的时候会修改body的文本内容,但是其他的代码也可以设置或者读取body的内容,就说这个函数是副作用函数。因为它可以直接或者间接影响到其他函数的执行。

4.2、响应式数据和基本实现

说明: vue3是通过proxy来实现响应式数据,假设我们有一个副作用函数,在get读取时存储它,在set设置数据时调用它,。

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

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

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

假设全局有一个副作用函数,我们设置对原始数据的代理,读取它时将副作用函数存储,在设置它的值时调用(get收集依赖,set触发依赖)

4.3、进一步完善响应式系统

4.3.1、优化副作用函数

现在副作用函数都是写死的硬编码模式,我们应该希望即使副作用函数是一个匿名函数,也可以被收进桶中。

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

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

4.3.1、副作用函数和操作字段进行联系

加入我们执行这样一段代码

setTimeout(() => {
  obj.notExist = 'hello vue3'
}, 1000)

我们前面设置的data中没有这个 notExist 字段,然后执行代码后还是触发了我们的副作用函数,因为现在是把所有的副作用函数从桶里面取出来调用,我们应该希望副作用函数和字段是有明确的对应关系。

所以这里重新设计桶的数据结构

//类似于这种
const bucket = {
	a: {
		c: [],
		d: []
	}
	b: {}
}

bucket就是我们的桶,a、b这些key对应代理对象,c、d就是代理对象上的属性,数组就是这些属性上所收集的副作用函数(依赖)。

再优化一下,将其中的对象和数组用map和set代替,将set和map中的代码提出,得到以下代码

// 存储副作用函数的桶
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())
}

这里bucket使用的weakMap,它不会阻止垃圾收集器回收其中的key对象(因为其他被引用后就被标记而不会被清除)

4.4、分支切换和clean up

4.4.1、分支切换

// 原始数据
const data = { ok: true, text: 'hello world' }

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

分支切换就是指随着一些值的变换,代码的执行分支也会跟着变化,例如上述代码中的三元表达式。

当 obj.ok 为 true 时,会发现有两个地方收集了同一个副作用函数(依赖) 但是当 obj.ok 为 false 时,又只会在一个地方收集

换句话说,就是 obj.ok 的值会影响到页面展示(innerText),而 obj.text 值的变换不会影响影响页面展示,那么 obj.text 它就不应该收集这个副作用函数(依赖)。 所以就引出了clean up

4.4.2、clean up

我们在每次副作用函数执行之前清除掉所有关联的依赖就行了。因为当副作用函数执行时会重新建立联系,这样就可以保证每次执行都是最新的依赖集合。

我们怎么知道副作用都被哪些对象给收集了呢?我们在副作用函数上增加一个属性deps,收集就好了(所以订阅者知道自己都订阅了哪些,发布者也知道自己被哪些订阅者订阅了,相互多对多的一个联系)

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

// 用一个全局变量存储当前激活的 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) // 移除set中的该副作用函数
  }
  effectFn.deps.length = 0// 重置副作用函数自己的deps依赖数组
}

回顾一下,track中的deps是一个set,里面存储着所有的该字段依赖的副作用函数。

还有一个问题,执行代码的时候会发现无限循环,修复代码

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())
  // effects && effects.forEach(effectFn => effectFn())
}

注释掉的那一行就是问题所在,原因就是 effects 是一个set集合,forEach时修改set循环中的内容,它会执行新加入的那个值,我们删掉一个加入一个,这样就导致一直循环。解决办法就是不在原set上执行forEach。

4.5、嵌套的effect与effect栈

副作用函数是可以相互嵌套的. 在vue中就是渲染函数的嵌套

const data = { foo: true, bar: true }

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

根据嵌套关系,我们希望修改 obj.foo 的时候,都执行;修改 obj.bar 的时候只执行里面的 effectFn2 函数,

但是我们实际的执行结果是:

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

看一下之前的effect函数就知道了:

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

因为我们只有一个全局变量 activeEffec,并且是后面的会覆盖前面的。 所以很容易知道,收集副作用函数是两个 effectFn2,而不是 effectFn1 了。

那我们设计一个栈,先进先出,每个副作用函数调用前进栈,执行一次之后就退栈,两个过程中更新全局变量 activeEffec。

代码如下:

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

4.6、避免无限递归循环

effect(() => obj.foo++)
// 等于
effect(() => obj.foo = obj.foo + 1)

这个副作用函数不一样的地方是它在这个语句中,既有读取操作,也有设置操作。 结果就是:读取 obj.foo 的时候,会执行 track 操作,收集依赖,设置 obj.foo 的值时,执行了 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 => {
    if (effectFn !== activeEffect) { // 如果相同就不再次调用
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}

4.7、调度执行

可调度性:在 trigger 动作调用副作用函数执行时,有能力决定副作用函数执行的实际、次数以及方式。

我们 effect 现在只有一个参数,也就是副作用函数,执行的时候也是直接调用副作用函数,那我们现在给 副作用函数增加一个 options 属性,它是一个对象,里面增加一个调度器函数 scheduler。以前我们是直接调用副作用函数,那么现在就是通过调度器函数调用。 代码如下:

function effect(fn, options = { scheduler: function}) {
  const effectFn = () => {
  // code......
  // 将 options 挂在到 effectFn 上
  effectFn.options = options
  // code.....
}

function trigger(target, key) {
  // code......
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) { // 新增
      effectFn.options.scheduler(effectFn) // 新增
    } else {
      effectFn()
    }
  })
}
  1. 在调用 effect 函数时多传入一个 options 对象,其中包含 scheduler 调度函数,在执行时将 options 对象挂载到副作用函数上
  2. 在触发副作用函数时,检查副作用函数有无调度器,有就使用调度器函数执行副作用函数

这样做就可以把副作用函数的调用交给用户手动去触发。

4.8、计算属性 computed 与 lazy

首先介绍懒(lazy)执行的 effect。我们现在的 effect 会立即执行传给它的副作用函数,从前面的代码可知。但是有些场景不需要,例如计算属性。这个时候就可以在 options 里面添加一个 lazy 属性来控制。

function effect(fn, options = { lazy: boolean}) {
  const effectFn = () => {// code......}
  // code......
  if (!option.lazy) {
    effectFn()
  }
  return effectFn;
}

如果是 lazy 的函数,那么我们就可以在外面调用 effectFn() 函数。 这时候想想 vue 的计算属性,把副作用函数看成一个 getter 函数,也就是会 return 一个值。那么看这个时候的代码:

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

根据我们以上的需求需要修改代码,如下

function effect(fn, options = { lazy: boolean}) {
  const effectFn = () => {
    // code......
	const res = fn();
	// code......
	return res
  }
  if (!option.lazy) {
    effectFn()
  }
  return effectFn;
}

将副作用函数的结果通过res变量传递出来。 然后我们来实现一个计算属性:

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

  const obj = {
    get value() {
      return effectFn();
    }
  }
  return obj;
}

定义了一个 computed 函数,它接收一个 getter 函数作为参数,其实也就是前文的副作用函数,在这里为 getter 函数(类比vue的计算属性)。在调用 computed 的函数时会返回一个对象,读取该对象的 value 时会调用 effectFn() 并将其结果返回。 例子:

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

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

console.log(sumRes.value) // 3

但是现在每次读取值都会执行计算或者说调用过程,也就是做不到对值进行缓存,即使它们本身的值并没有变化。

为了解决问题,就需要对值进行缓存,并且在值发生变化时,要能够通知更新:

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

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() { // 调度器,在值发生变化时将dirty变为true
        dirty = true
    }
  })

  const obj = {
    get value() {
      if (dirty) { // 当值发生变化时进行重新计算,否则直接返回
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}

通过 dirty 和调度器函数就能实现缓存。 但是当 effect 中读取计算属性的值时,会发现计算属性发生变化时,并不会触发 effect。类似于vue的模版中读取计算属性值时,一旦计算属性变化是会重新触发一次渲染的。所以这是一个缺陷。 原因:因为计算属性的 getter 只会把 computed 内部的 effect 收集,而不会收集外层的 effect,所以需要手动去调用 track 进行收集,在修改时在调度器中调用 trigger 触发响应:

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
}

4.9、watch的实现原理

watch,就是观测一个响应式数据,在数据发生变化时通知执行注册的回调函数。 它利用了 effect 和 options.scheduler 选项。 最简单的watch:

// source 是响应式数据,cb是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,收集依赖
	() => source.foo,
	{
	  scheduler() {
	    // 数据变化时执行回调函数
		cb()
	  }
	}
  )
}

然后一步步完善watch。

4.9.1、通用读取source

上面那个我们只能观测到 source.foo 的变化,我们肯定希望不管那个属性变化了都可以检测到,所以要封装一个通用的读取操作:

function watch(source, cb) {
  effect(
    // 通过 traverse 递归的读取
	() => traverse(source),
	{
	  scheduler() {
		cb()
	  }
	}
  )
}

function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经读取过了,就直接 return
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  // 将数据加入 seen 去重,代表遍历的读取过了,避免循环引用
  seen.add(value)
  // 如果value是一个对象,就对其里面每一个属性递归的调用 traverse。
  for (const k in value) {
    traverse(value[k], seen)
  }

  return value
}

4.9.2、source为getter

watch 还可以接收一个 getter,用户来指定 watch 依赖那些响应式数据:

function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') { // 如果是函数说明是 getter
    getter = source
  } else {
    getter = () => traverse(source)
  }

  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      scheduler: () => {
	    cb()
      }
    }
  )
}

4.9.2、newVal 和 oldVal

通过 lazy 选项来获取新值和旧值:

function watch(source, cb, options = {}) {
  // code。。。
  // 定义新旧值
  let oldValue, newValue
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true, // 开启lazy
      scheduler: () => {
		newValue = effectFn();  // 得到新值
		cb(oldValue, newValue); // 作为回调的参数
		oldValue = newValue; // 存储新值为下一次的旧值
      }
    }
  )
  // 第一次手动调用副作用函数,拿到旧值
  oldValue  = effectFn();
}

通过lazy创建一个懒执行的 effect,然后手动调用。

4.10、立即执行的watch

立即执行的 watch 也就是在创建时就需要执行一次,也就是vue中的 immediate。 其实也就是执行调度器函数中的内容,将它提前执行:

function watch(source, cb, options = {}) {
  // code。。。
  // 将调度器函数中的内容提取出来
  const job = () => {
    newValue = effectFn()
    cb(oldValue, newValue)
    oldValue = newValue
  }

  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: job()
    }
  )

  if (options.immediate) { // 立即执行时调用原调度器函数中的内容
    job()
  } else {
    oldValue = effectFn()
  }
}

并且第一次执行时 oldValue 是 undefined,也是符合预期。

4.11、过期的副作用

竞争危害(race hazard)又名竞态条件、竞争条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机

在前端中的表现常见于异步操作的结果返回的时间顺序和预期不同,比如常见的网络请求结果的顺序。 在副作用这里就是当先触发的副作用比后触发的副作用,后返回结果,会导致旧的覆盖新的。

image-1649411055777.png

这里就是A的结果覆盖了B的结果,所以我们需要有一个让副作用函数过期的方法,避免先发生的副作用函数覆盖后发生的副作用函数. 在vue中,回调函数的第三个参数onInvalidate,是一个函数,类似于事件监听器,会在副作用函数过期时执行,这样就可以交给用户自己处理需要那个副作用函数,那看下实现:

function watch(source, cb, options = {}) {
  // code。。。
  let cleanup // 过期回调
  function onInvalidate(fn) {
    cleanup = fn // 将过期回调给 cleanup
  }

  const job = () => {
    newValue = effectFn()
    if (cleanup) { // 如果存在,就在执行回调函数之前执行过期回调
      cleanup()
    }
    cb(oldValue, newValue, onInvalidate)
    oldValue = newValue
  }

  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: job()
    }
  )
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

其实就是在执行前执行过期回调。