likes
comments
collection
share

精读 Vuejs 设计与实现第 4 章(响应式系统)

作者站长头像
站长
· 阅读数 10

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

副作用函数是指那些产生副作用的函数:

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

执行 effect 函数时,它会设置 body 的文本内容,这种更改可以被其他任何函数读取或设置。因此,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 仅仅是一个普通对象,当我们改变它的值时,除了值本身之外,不会有任何其他反应。下一节我们将讨论如何让数据变为响应式数据。

4.2 基本响应式数据实现

为了使 obj 成为响应式数据,我们可以从以下两点出发:

  1. 执行副作用函数 effect 时,会触发 obj.text 的读取操作。
  2. 当修改 obj.text 的值时,会触发 obj.text 的设置操作。

如果我们能拦截对象的读取和设置操作,这个问题就简单了。

  • 读取 obj.text 时,将副作用函数 effect 存储到一个“桶”中。
  • 在设置 obj.text 时,从“桶”中取出副作用函数 effect 并执行。

在 ES2015 之前,我们可以使用 Object.defineProperty 函数,这是 Vue.js 2 的实现方式。在 ES2015+ 中,我们可以使用代理对象 Proxy,这是 Vue3 的实现方式。精读 Vuejs 设计与实现第 4 章(响应式系统)精读 Vuejs 设计与实现第 4 章(响应式系统)基于以上思路,我们可以用 Proxy 来实现响应式数据:

// 存储副作用函数的桶
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())
    // 返回 true 表示设置成功
    return true
  }
})

首先,我们创建一个用于存储副作用函数的 Set 类型的桶 bucket。然后,定义原始数据 data,并创建其代理对象 obj,我们为代理对象设置了 get 和 set 拦截器,以拦截读取和设置操作。读取属性时,我们把副作用函数 effect 添加到桶中。设置属性时,我们先更新原始数据,然后重新执行桶中的副作用函数。这样就实现了响应式数据。测试一下:

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

// 触发读取
effect()

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

在浏览器中运行以上代码,我们将得到预期的结果。但当前的实现仍有不足,比如我们是直接通过函数名 effect 获取副作用函数,这种硬编码方式缺乏灵活性。副作用函数的名字是可以任意命名的,我们可以把副作用函数命名为 myEffect,或者甚至用一个匿名函数。因此,我们需要找到去除这种硬编码的方法。下一节将详细讨论这个问题,这里只需理解响应式数据的初步实现和工作原理即可。

4.3 设计完善响应系统

接下来我们将构建一个更完善的响应系统,实现步骤如下:

  1. 读取操作时,将副作用函数收集到“桶”中。
  2. 设置操作时,从“桶”中取出并执行副作用函数。

为了解决硬编码副作用函数名(effect)的问题,我们提供一个注册副作用函数的机制:

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

// effect 函数用于注册副作用函数
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

首先,我们定义了一个全局变量 activeEffect,用于存储被注册的副作用函数。然后定义了 effect 函数,这个函数用于注册副作用函数,接受一个参数 fn,也就是我们要注册的副作用函数。使用 effect 函数的示例:

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

我们传递一个匿名的副作用函数作为 effect 函数的参数。当 effect 函数执行时,会先将匿名副作用函数 fn 赋值给全局变量 activeEffect,然后执行注册的副作用函数 fn,触发响应式数据 obj.text 的读取操作,同时触发 Proxy 的 get 拦截函数:

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

上述代码,由于副作用函数已经存储在 activeEffect 中,因此在 get 拦截函数中,我们将 activeEffect 收集到“桶”中。这样,响应系统就不再依赖副作用函数的名字了。但是如果我们进行更深入测试,尝试设置响应式数据 obj 上的一个不存在的属性:

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

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

这段代码中,匿名副作用函数读取了 obj.text,从而和这个字段建立了响应联系。接着,我们启动一个定时器,1秒后为 obj 添加新的 notExist 属性。理论上,由于副作用函数并未读取 obj.notExist,因此这个字段并未与副作用建立响应联系。因此,当定时器内的语句执行时,不应触发副作用函数的重新执行。然而,运行上述代码,我们发现在定时器触发后,副作用函数却重新执行了。这是因为我们的"桶"数据结构的设计存在问题。只要触发了 obj 对象的 get 操作就会收集副作用进桶。因此,我们需要重新设计“桶”的数据结构,使得副作用函数与被操作的字段之间建立联系。首先,让我们更仔细地观察以下的代码:

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

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

  1. 被操作(读取)的代理对象 obj;
  2. 被操作(读取)的字段名 text;
  3. 使用 effect 函数注册的副作用函数 effectFn。

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

target
└── key
    └── effectFn

这是一种树形结构。例如,如果有两个副作用函数同时读取同一个对象的属性值:

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

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

那么关系如下:

target
└── text
    ├── effectFn1
    └── effectFn2

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

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

那么关系如下:

target
└── text1
    └── effectFn
└── text2
    └── effectFn

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

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

那么关系如下:

target1
└── text1
    └── effectFn1
target2
└── text2
    └── 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[key]
		// 根据 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())
	},
})

通过这段代码,我们可以看到数据结构的构建方式。我们使用了 WeakMap、Map 和 Set:

  • WeakMap 由 target --> Map 构成;
  • Map 由 key --> Set 构成。

WeakMap 的键是原始对象 target,值是一个 Map 实例。而 Map 的键是原始对象 target 的 key,值是一个由副作用函数组成的 Set。这些关系如下图所示:精读 Vuejs 设计与实现第 4 章(响应式系统)我们可以称 Set 数据结构中存储的副作用函数集合为 key 的依赖集合使用 WeakMap 的原因在于其键为弱引用,不影响垃圾回收器的工作。一旦 key 被垃圾回收器回收,那么对应的键和值就无法访问。因此,WeakMap 常用于存储只有当 key 所引用的对象存在时(没有被回收)才有价值的信息。例如上面场景,如果 target 对象没有任何引用,它会被垃圾回收器回收。如果使用 Map 可能会导致内存泄露。下面这段代码展示了 WeakMap 和 Map 的区别:

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

当该函数表达式执行完毕后,对于对象 foo 来说,它仍然作为 map 的 key 被引用着,因此垃圾回收器(garbage collector)不会把它从内存中移除而对于对象 bar 来说,由于 WeakMap 的 key 是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象 bar 从内存中移除。

最后我们优化前面响应式代码,将收集副作用函数到“桶”以及触发副作用函数的逻辑分别封装到 track 和 trigger 函数中:

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

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

通过将这些逻辑封装到 track 和 trigger 函数中,我们可以使代码更加灵活。

4.4 分支切换与清理

首先,我们定义一个简单的响应式数据和副作用函数:

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
├── ok
│   └── effectFn
└── text
    └── effectFn

精读 Vuejs 设计与实现第 4 章(响应式系统)可以看到,副作用函数 effectFn 被 data.ok 和 data.text 所对应的依赖集合收集。当 obj.ok 的值修改为 false,触发副作用函数重新执行后,此时不会读取 obj.text,只会触发 obj.ok 的读取操作。理想情况下,副作用函数 effectFn 不应该被 obj.text 所对应的依赖集合收集。如下所示:精读 Vuejs 设计与实现第 4 章(响应式系统)但是,根据前面的实现,我们还做不到这一点。换言之,当我们将 obj.ok 的值修改为 false 并触发副作用函数重新执行后,整个依赖关系仍然保持不变,这就产生了副作用函数的遗留。遗留的副作用函数可能会导致不必要的更新。例如,在上面的代码中,当我们将 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 的值并不需要改变。

解决此问题思路在于:

  • 每次执行副作用函数前,我们将其从相关联的依赖集合中移除,函数执行完后再重新建立联系,新的联系中则不包含遗留的副作用函数。

精读 Vuejs 设计与实现第 4 章(响应式系统)为了实现这一点,我们需要重新设计副作用函数,使其具有一个 deps 属性,用于存储与其相关联的依赖集合:

// 用一个全局变量存储正在执行的副作用函数
let activeEffect;

function effect(fn) {
  const effectFn = () => {
    // 将 effectFn 设为当前活动的副作用函数
    activeEffect = effectFn;
    fn();
  };
  // 用 effectFn.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 数组中:精读 Vuejs 设计与实现第 4 章(响应式系统)接下来,我们在每次执行副作用函数时,根据 effectFn.deps 获取所有相关联的依赖集合,将副作用函数从依赖集合中移除:

let activeEffect;

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 执行清除操作
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = [];
  effectFn();
}

// 实现 cleanup 函数
function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn); // 将 effectFn 从依赖集合中移除
  }
  effectFn.deps.length = 0; // 重置 effectFn.deps 数组
}

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

为了避免无限执行,我们可以构造一个新的 Set 集合并遍历它:

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  const effectsToRun = new Set(effects); // 新建一个集合并遍历
  effectsToRun.forEach(effectFn => effectFn());
}

我们创建了一个新的集合 effectsToRun,遍历它而不是直接遍历 effects 集合,从而避免无限执行。

4.5 嵌套的 effect 与 effect 栈

effect 能够被嵌套使用,例如,以下代码中 effectFn1 中嵌套了 effectFn2,执行 effectFn1 会触发 effectFn2 的执行::

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

实际上,Vue.js 的渲染函数本身就在一个 effect 中执行。例如,对于如下定义的 Foo 组件:

// 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 不支持嵌套,会导致问题。例如,以下代码:

// 原始数据
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
  └── foo
    └── effectFn1
  └── bar
    └── effectFn2

三次打印的结果分别是 :

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

前两次分别是副作用函数 effectFn1 与 effectFn2 初始执行的打印结果,到这一步是正常的。问题出在第三行打印。我们修改了字段 obj.foo 的值,发现 effectFn1 并没有重新执行,反而使得 effectFn2 重新执行了,这显然不符合预期。问题的根源在于我们使用全局变量 activeEffect 来存储当前激活的 effect 函数,当 effect 函数被嵌套调用时,内层 effect 的执行会覆盖 activeEffect 的值,且无法恢复至原先的状态:

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

解决方法是使用一个副作用函数栈 effectStack。执行 effect 函数时,将当前函数压入栈中;执行完毕后,再将其从栈中弹出,保持 activeEffect 始终指向栈顶的 effect 函数。:

// 用一个全局变量存储当前激活的 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 数组作为栈,用于存储嵌套的 effect 函数, activeEffect 没有变化,它仍然指向当前正在执行的副作用函数。不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的是外层副作用函数,而栈顶存储的则是内层副作用函数,精读 Vuejs 设计与实现第 4 章(响应式系统)当内层副作用函数 effectFn2 执行完毕后,它会被弹出栈,并将副作用函数 effectFn1 设置为 activeEffect精读 Vuejs 设计与实现第 4 章(响应式系统)如此,我们可以保证响应式数据只收集直接读取其值的 effect 函数,避免了混乱。

4.6 避免无限递归循环

在实现完善响应式系统时,需要注意避免无限递归循环。以以下代码为例:

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

effect(() => obj.foo++) // 既会读取 obj.foo 的值,又会设置 obj.foo 的值

上述代码,effect 注册的副作用函数会触发栈溢出。为什么呢?其实,我们可以将 obj.foo++ 分解为看作是两个步骤:读取 obj.foo 的值并给它增加 1:

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

上述代码,我们首先读取 obj.foo 的值,触发数据追踪(track)操作,将当前的副作用函数添加到依赖列表。然后,我们对 obj.foo 赋值,这会触发触发器(trigger)操作,从依赖列表中取出并执行所有的副作用函数。这就引发了问题,因为我们正在执行的副作用函数还没结束,就开始了下一次的执行,从而导致了无限递归调用,最终引发栈溢出。

解决办法是在 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())
}

通过这种方式,我们可以避免无限递归调用和栈溢出。

4.7 调度执行

调度性是响应式系统的重要特性,它允许我们决定副作用函数执行的时机、次数和方式。以以下的代码为例:

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

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

obj.foo++

console.log('结束了')

现在,假设我们希望改变输出顺序,但不改变代码结构。这就需要在响应系统中支持调度。为了实现可调度性,我们可以为 effect 函数添加一个选项参数 options,允许用户指定调度器:

effect(
  () => {
    console.log(obj.foo)
  },
  {
    scheduler(fn) {
      // ...
    }
  }
)

在调用 effect 函数注册副作用函数时,用户可以传入第二个参数 options。这是一个对象,可以指定 scheduler 调度函数。同时,我们需要将 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()
}

有了调度函数,我们在 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 => {
		// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
		if (effectFn.options.scheduler) {
			effectFn.options.scheduler(effectFn) // 新增
		} else {
			// 否则直接执行副作用函数(之前的默认行为)
			effectFn() // 新增
		}
	})
}

这样,当触发副作用函数时,我们首先检查副作用函数是否有调度器。如果有,我们调用调度器函数,并将当前的副作用函数作为参数传递,由用户自己控制执行方式;否则,我们保持默认行为,即直接执行副作用函数。

有了上面基础设施的支持下,我们使用 setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能更灵活控制代码的执行顺序了。

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

effect(
  () => {
    console.log(obj.foo)
  },
  {
    scheduler(fn) {
      setTimeout(fn)
    }
  }
)

obj.foo++

console.log('结束了')

输出结果:

1
'结束了'
2

通过调度器,我们还可以控制副作用函数的执行次数。这是一个重要的特性,如下所示:

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

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

obj.foo++
obj.foo++

在这个例子中,obj.foo 的值从 1 增加到 3,2 只是过渡状态。如果我们只关心最终结果而不关心过程,那么打印过渡状态就是多余的。我们希望输出:'1','3'。基于调度器,我们可以轻松实现:

// 定义一个任务队列
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(obj.foo)
	},
	{
		scheduler(fn) {
			// 每次调度时,将副作用函数添加到 jobQueue 队列中
			jobQueue.add(fn)
			// 调用 flushJob 刷新队列
			flushJob()
		},
	}
)

obj.foo++
obj.foo++

上述代码,我们首先定义了一个任务队列 jobQueue,利用了 Set 数据结构的自动去重能力。在调度函数中,我们将当前的副作用函数添加到 jobQueue 队列,再调用 flushJob 函数刷新队列。在 flushJob 函数内部,该函数通过 isFlushing 标志判断是否需要执行,只有当其为 false 时才需要执行,而一旦 flushJob 函数开始执行,isFlushing 标志就会设置为 true,意思是无论调用多少次 flushJob 函数,在一个周期内都只会执行一次。需要注意的是,在 flushJob 内通过 p.then 将一个函数 添加到微任务队列,在微任务队列内完成对 jobQueue 的遍历执行。结果是,连续两次自增操作,虽然调度函数执行了两次,但因为 Set 的去重能力,jobQueue 中只有一个副作用函数。类似地,flushJob 也会同步且连续地执行两次,但由于 isFlushing 标志的存在,实际上 flushJob 函数在一个事件循环内只会执行一次, 即在微任务队列内执行一次。当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。由于此时 jobQueue 队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段 obj.foo 的值已经是 3 了,这样我们就实现了期望的输出:

1
3

这个功能类似于 Vue.js 中,连续多次修改响应式数据只会触发一次更新。实际上,Vue.js内部实现了一个更加完善的调度器,其思路与我们刚才介绍的一致。

4.8 计算属性 computed 与 lazy

在某些场景下,我们希望副作用函数不立即执行,而是在需要时执行,例如计算属性。这时,我们可以在 options 中添加 lazy 属性来实现,如下:

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

我们可以修改 effect 函数的实现逻辑,当 options.lazy 为 true 时,不立即执行副作用函数:

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

当调用 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
}

通过这些修改,我们可以在执行 effectFn 时,获取到真正副作用函数 fn 的执行结果。现在我们已经实现了具有懒执行的副作用函数,我们就可以实现计算属性了:

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

  const obj = {
    // effectFn 仅在访问 value 时执行
    get value() {
      return effectFn();
    }
  };

  return obj;
}

这里,我们首先定义了一个 computed 函数,接收一个 getter 函数作为参数。在访问对象的 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 时,才会进行计算并得到值。但是,它不能缓存值。即使 obj.foo 和 obj.bar 的值没有变化,每次访问 sumRes.value 都会触发多次计算:

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

为了解决这个问题,我们在实现 computed 函数时,需要添加缓存机制:

function computed(getter) {
  let value;  // 用于缓存上次计算的值
  let dirty = true;  // 代表是否需要重新计算的标志

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

  const obj = {
    get value() {
      // 只有“脏”时才计算值,并将得到的值缓存到 value 中
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    }
  };

  return obj;
}

现在我们访问 sumRes.value 都只会在第一次访问时进行实际计算,后续直接从缓存读取 value 值。当我们修改 obj.foo 或 obj.bar 的值,就会发现 sumRes.value 的值也发生了改变。但是,如果我们在另一个 effect 中读取计算属性的值,当计算属性的值变化时,它并不会触发副作用函数的执行,如下所示:

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

effect(() => {
	// 在该副作用函数中读取 sumRes.value
	console.log(sumRes.value)
})

// 修改 obj.foo 的值
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)
	└── value
		└── effectFn

精读 Vuejs 设计与实现第 4 章(响应式系统)这样,在其他 effect 中访问计算属性的值时,该 effect 就会因为计算属性值的变化而触发:

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

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

obj.foo++

在这段代码中,修改 obj.foo 的值会触发 effect。

4.9 watch 的实现原理

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

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

// 当响应数据的值改变时,回调函数将被触发
obj.foo++

实际上,watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项:

effect(() => {
  console.log(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 的读取操作,我们需要封装一个通用的读取操作:

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
}

上述代码,调用 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 函数,如果不是函数则使用原来做法。这样就实现了自定义 getter 的功能

目前 watch 的回调函数拿不到旧值与新值。那么如何获得新值与旧值呢?这需要充分利用 effect 函数的 lazy 选项:

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

我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值,将它们作为参数传递给回调函数 cb 就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值,否则下一次变更发生时会得到错误的旧值。这样,我们就完成了一个简化版的 watch 函数实现。

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

默认情况下的 watch 回调函数,watch 的回调只在被观察的响应式数据发生变化时触发:

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

在 Vue.js 中,我们可以通过 immediate 参数来设定回调函数是否需要立即执行:

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

当 immediate 参数为 true,回调函数在创建该 watch 时就会立即执行。实际上,回调函数的立即执行与后续执行并没有本质区别。因此,我们可以将调度函数 scheduler 封装为一个通用函数 job,让它在初始化和数据变更时都会被执行:

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,这符合预期的行为。

除此之外,我们还可以指定 watch 回调的执行时机。例如,在 Vue3 中,我们可以使用 flush 参数来设定:

watch(obj, () => {
  console.log('变化了');
}, {
  // 创建 watch 时,回调函数会立即执行一次
  flush: 'pre' // 可选 'post' 或 'sync'
});

flflush 选项本质上是控制调度函数的执行时机。之前我们已经讲过如何在微任务队列中执行调度函数 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' 本质上指的是组件更新前和更新后,但这不影响我们理解如何控制回调函数的执行时机。

4.11 过期副作用与竞态问题

在多进程或多线程编程中,竞态问题常见,尽管前端工程师讨论较少,但在实际场景中也会遇到类似问题。以下是一个例子:

let finalData;

watch(obj, async () => {
  const res = await fetch('/path/to/request');
  finalData = res;
});

在这段代码中,每次 obj 发生变化时,都会发送网络请求并将结果赋值给 finalData。但这可能导致竞态问题。精读 Vuejs 设计与实现第 4 章(响应式系统)上面我们认为应该 请求B 的数据才是最终赋值给 finalData 的值。为了解决这个问题,我们需要实现一个让副作用过期的手段。Vue.js 的 watch 函数提供了 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。最后,只有当没有过期时才采用请求结果,避免了竞态问题导致的错误结果。要实现 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(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()
	}
}

在这段代码中,我们定义了 cleanup 变量,用来存储用户通过 onInvalidate 函数注册的过期回调。在每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数 cleanup。最后我们把 onInvalidate 函数作为回调函数的第三个参数传递给 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 修改了两次,所以 watch 执行了两次。watch 的回调函数第一次执行的时候,我们已经注册了一个过期回调,所以在 watch 的回调函数第二次执行之前,会优先执行之前注册的过期回调。这会使得第一次执行的副作用函数内闭包的变量 expired 的值变为 true,即副作用函数的执行过期了。于是等请求 A 的结果返回时,其结果会被抛弃,从而避免了过期的副作用函数带来的影响,如图所示:精读 Vuejs 设计与实现第 4 章(响应式系统)

总结

本章我们深入探讨了副作用函数与响应式数据的关系。我们明确了响应式数据的基本实现依赖于“读取”和“设置”操作的拦截,连接副作用函数与响应式数据。当读取数据,我们将副作用函数存储,而在设置数据时,我们将存储的副作用函数取出并执行,这是响应系统的核心实现原理。我们构建了一个完整的响应系统,用 WeakMap 和 Map 创建新的存储结构,确保响应式数据与副作用函数之间的精确关联。此外,我们分析了 WeakMap 和 Map 的差异,其中 WeakMap 的弱引用特性不会妨碍垃圾回收。在解决分支切换导致的冗余副作用问题时,我们清除上一次建立的响应关系,重新建立新的关系。在此过程中,我们避免了遍历 Set 数据结构导致的无限循环问题,通过创建新的 Set 结构进行遍历。讨论嵌套的副作用函数时,我们引入了副作用函数栈来管理不同的副作用函数,解决了响应关系的混乱问题。同时,我们处理了副作用函数无限递归调用自身,导致栈溢出的问题。我们增强了响应系统的可调度性,即可以决定副作用函数执行的时机、次数及方式。我们为 effect 函数增加了调度器选项,使用户能自行调度任务。此外,我们解释了如何利用微任务队列实现任务去重。接着,我们讨论了计算属性(computed),它是一种懒执行的副作用函数,可以通过手动执行更新。当计算属性的依赖数据变化时,会标记属性为“脏”,使下次读取时重新计算。此外,我们探讨了 watch 的实现,它依赖副作用函数的可调度性。我们在调度器中执行用户注册的回调函数,通过 immediate 选项实现立即执行回调,通过 flush 选项控制回调函数的执行时机。最后,我们讨论了竞态问题,它源自过期的副作用函数。为解决此问题,Vue.js 设计了 onInvalidate 参数,让用户可以注册过期回调,在回调函数执行前标记副作用为“过期”,从而解决竞态问题。