《Vuejs 设计与实现》第 4 章(响应式系统)( 上 )

0 阅读16分钟

《Vuejs 设计与实现》第 4 章(响应式系统)( 上 )

目录

[TOC]

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 的实现方式。

image.png image.png

基于以上思路,我们可以用 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。
这些关系如下图所示:

image.png

我们可以称 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())
}
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
image.png

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

image.png

但是,根据前面的实现,我们还做不到这一点。
换言之,当我们将 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 的值并不需要改变。

解决此问题思路在于:

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

为了实现这一点,我们需要重新设计副作用函数,使其具有一个 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 数组中:

image.png

接下来,我们在每次执行副作用函数时,根据 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());
}
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 不支持嵌套,会导致问题。例如,以下代码:

// 原始数据
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 没有变化,它仍然指向当前正在执行的副作用函数。
不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的是外层副作用函数,而栈顶存储的则是内层副作用函数,

image.png

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

image.png

如此,我们可以保证响应式数据只收集直接读取其值的 effect 函数,避免了混乱。