Libon

《Vue3 响应式系统原理》(上)

#vue
从 0 开始实现 Vue 的响应式系统核心

ToC

响应式例子

在 Vue 中,无论是 2 还是 3,它们使用起来差别都不太大,我们先看一个例子,借助它来分析一下响应式系统:

<template>
	<div>Price: {{ price }}</div>
	<div>Total: {{ price * quantity }}</div>
	<div>Taxes: {{ totalPriceWithTax }}</div>
</template>

<script>
export default {
	data() {
		return {
			price: 5,
			quantity: 2
		}
	},
	computed: {
		totalPriceWithTax({ price, quantity }) {
			return price * quantity * 1.03
		}
	}
}
</script>

页面中一共有三个关于对 price 变量的访问 price price * quantity totalPriceWithTax ,当我们的 price 发生变化的时候, 页面中的 price price * quantity totalPriceWithTax 变量也会随之发生改变, 而这一连锁反应就是响应式系统的优势,即:自动更新依赖项数据。

所以问题是我又是怎么知道变量被依赖了,又应该去更新哪些数据的? 因为这不是 JavaScript 通常的工作方式, 在常规情况下,我们它们会以下面这种方式来工作:

let price = 5
let quantity = 2
let total = price * quantity

console.log(`total is ${total}`) // total is 10

// 更新价格
price = 20

// 结果依旧是 10,因为 total 变量由始至终都没有发生过变化
console.log(`total is ${total}`) // total is 10

那么接下来我们一步一步尝试解决这个问题。我们需要提出问题,怎样存储 total 的计算方式才能当 pricequantity 发生变化的时候,让 total 重新计算一次?

手动收集、更新依赖

我们思考一下,上述语句中,是什么让 total 变量发生了变化?是的,是 total = price * quantity 。那我们只需要在我们想要变化的时候重新执行这一语句即可。我们知道,在编程语言中想要对代码进行一定程度的复用的话,可以利用函数的能力。我们将这一语句保存到一个函数中,将这个函数保存在一个 storage 中,当我们想要再次访问的时候,只需要从 storage 中取回这个函数并执行它即可达到效果,但是我们可能不止保存这一个函数,所以可能还会存在多个不同的 storage 。那么我们按照这个思路对上述代码进行一次改写。

let price = 5
let quantity = 2
let total = 0

// 用于保存数据变更的 storage
// 为什么要用 new Set() ?
// 因为 ES6 中的 Set() 不允许包含重复值
// 使用它可以实现当多次调用依赖追踪的方法时不会出现重复的 effects
const deps = new Set()

// 用户更新数据
function effect() {
	total = price * quantity
}

// 用于追踪我们的依赖
function track() {
  deps.add(effect)
}

function trigger() {
  deps.forEach(effect => effect())
}

track() // 添加我们对数据变化时的监听函数
trigger() // 首次计算我们的变量值

console.log(`total is ${total}`) // total is 10

// 更新价格
price = 10
// 更新数量
quantity = 3

// 这个时候 total 依然是 10,这是因为我们还没有执行数据更新的操作
console.log(`total is ${total}`) // total is 10

// 执行更新数据操作
trigger()

console.log(`total is ${total}`) // total is 30

我们达成了我们想要的效果,但我们在开发中会有很多数据有着这种依赖关系,每一个数据都去手动维护它们的状态更新显然效率更低,因为每个属性都需要自己的 deps ,或者说是这个变量自己的 effectsSet 集合。那么更好的做法显然是让每个熟悉都拥有一个自己的 effects

每个数据与依赖项的对应关系如下:

depsMap 保存了所有属性的依赖变化项,它的类型是一个 Map ,而它可能会有 pricequantity 属性,它们的值是一个 Set 类型的 deps 。而 deps 类型中保存着其属性的 effects 。那么我们尝试去实现它:

// 是的,我们有一个产品
// 其中 price/quantity 都会有一个自己的 `deps`
// `deps` 将会在属性发生变化的时候重新运行
let product = { price: 5, quantity: 2 }
const depsMap = new Map()

function track(key) {
	// 获取对应属性的 effects
	// 而这个 key 对于 product 来说,不是 price 就是 quantity
	let deps = depsMap.get(key)

	if (!deps) {
    // 如果它还没有对应的依赖项,那么表示它还没有被读取过
		// 这是第一次被访问,那么我们就新建一个依赖集合,来保证后续的变化能精准响应
		depsMap.set(key, (deps = new Set()))
  }

	// 添加数据变化时触发的监听事件
	// 因为依赖集本身是一个 Set(),所以当它已经存在的时候就不会被重复添加
	deps.add(effect)
}

function trigger(key) {
	const deps = depsMap.get(key)

	// 如果不存在对应的键依赖则直接结束
	if (!deps) {
		return
	}

	// 如果存在则执行所有的依赖函数
	deps.forEach(effect => effect())
}

function effect() {
  total = product.price * product.quantity
}

// 我们告诉 track(),我们想要把 effect 函数保存到 'price' 属性的依赖集中
// 当然也可以执行 track('quantity') 来讲依赖保存到 'quantity' 下面,两者一样
track('price')

// 告诉 trigger() 我想要立即触发 'price' 属性下面的依赖变化回调
trigger('price')

console.log(`total is ${total}`) // total is 10

// 我们修改它的值
product.price = 10

// 触发变化时的依赖
trigger('price')

console.log(`total is ${total}`) // total is 20

这是一个比较基础的响应式系统原型, 那如果我们有多个响应式对象数据的话,光是这样做还远远不够。假设我们有 user product 等其他响应式对象时,我们可能需要做更多工作。那么这时候就需要其他的对象,它的 key 以某种方式引用了我们的响应式对象,例如: product user

多个响应式对象

那么我们根据上面的信息可以得到这些:

const targetMap = new WeakMap()

function track(target, key) {
	let depsMap = targetMap.get(target)

	// 如果这个对象没有被观察则将它添加到依赖列表中
	if (!depsMap) {
		targetMap.set(target, (depsMap = new Map()))
	}

	// 读取对象上的子属性
	let deps = depsMap.get(key)
	if (!deps) {
		depsMap.set(key, (deps = new Set()))
	}

	deps.add(effect)
}

function trigger(target, key) {
	const depsMap = targetMap.get(target)

	// 如果整个对象都没有被追踪则直接返回
	if (!depsMap) {
		return
	}

  let deps = depsMap.get(key)
  if (!deps) {
    return
  }

  deps.forEach(effect => effect())
}

function effect() {
  total = product.price * product.quantity
}

track(product, 'price')
trigger(product, 'price')

console.log(`total is ${total}`) // total is 10

product.price = 10
trigger(product, 'price')

console.log(`total is ${total}`) // total is 20

我们最终的结果没有任何变化,但是执行的过程变了,这么做会使得它在面对多个响应式数据对象的时候也可以轻松应对。

但如果代码的复杂度进一步提升时,我们想要维护繁多的响应式对象的难度将会进一步提升,那么我们接下来要做的事情则是将响应式对象的依赖收集和响应式触发进一步封装,使其自动化,来达到访问属性的时候自动收集依赖,修改属性值的时候自动执行依赖项以保证数据获取的最新值。

过程自动化

如何去做?我们可以借助 ProxyReflect 对对象代理的能力实现这个功能,代理整个对象,通过监听其属性的访问和覆写来自动完成收集、触发的操作。我们首要介绍一下为什么要使用 Reflect ,以及使用它有什么好处。简单对比一下三种可以对象属性的方式:

product.price

produc['price']

Reflect.get(product, 'price')

它们都可以达成目标,但是 Reflect 配合 Proxy 的话,有一点是上面两种做不到的,即:保证当对象有继承自其他对象的值或函数时,this 指针能正确的指向使用的对象。使用方法为:Reflect.get(target, key, receiver)Reflect.set(target, key, value, receiver)

我们先了解一下 Proxy 的工作流程:

let product = { price: 5, quantity: 2 }

let proxiesProduct = new Proxy(product, {})

console.log(proxiesProduct.price)
// 在我们对 price 属性进行访问时,会先地调用代理(proxiesProduct.price)
// proxiesProduct 调用 product,然后 product 再返回 proxiesProduct
// 最后回归到对熟悉的访问,如下:
// proxiesProduct.price -> proxiesProduct -> product ->
// product.price -> proxiesProduct -> proxiesProduct.price -> 5

在了解基本工作流程后,我们试着用一个例子去理解它:

function reactive(target) {
	const handler = {
		get(target, key, receiver) {
			console.log('Get was called with key = ' + key)
			return Reflect.get(target, key, receiver)
		},
		set(target, key, value, receiver) {
			console.log('Set was called with key = ' + key + ' and value = ' + value)
			return Reflect.set(target, key, value, receiver)
		}
	}
	return new Proxy(target, handler)
}

const product = reactive({ price: 5, quantity: 2 })
product.quantity = 4
console.log(product.quantity)

之后我们尝试去封装它:

function reactive(target) {
	const handler = {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      track(target, key) // 设置响应式依赖项
			return result
		},
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver)
      const result = Reflect.set(target, key, value, receiver)
      // 当值发生变化的时候通知所有依赖项
      if (oldValue !== result) {
        trigger(target, key)
      }
			return result
		}
  }

	return new Proxy(target, handler)
}

回到我们原来的代码中,并应用这个新的函数:

const product = reactive({ price: 5, quantity: 2 })
let total = 0

function effect() {
  total = product.price * product.quantity
}

// 函数执行的时候会读取 product.price 和 product.quantity 属性
// 这会使得 `effect` 函数本身被收集
+ effect()

- track('price')

- trigger('price')

console.log(`total is ${total}`) // total is 10

product.price = 10

- trigger('price')

console.log(`total is ${total}`) // total is 20

我们将所有需要手动执行的 track trigger 都封装到了 reactive 函数内部,让程序去帮助我们去做这件事,这会使得我们在读取属性的时候,依赖项的收集和触发将变得简单了起来。这样一来,基本的响应式就完成了。

完整代码

完整代码如下:

const targetMap = new WeakMap()
const product = reactive({ price: 5, quantity: 2 })
let total = 0

function track(target, key) {
	let depsMap = targetMap.get(target)

	// 如果这个对象没有被观察则将它添加到依赖列表中
	if (!depsMap) {
		targetMap.set(target, (depsMap = new Map()))
	}

	// 读取对象上的子属性
	let deps = depsMap.get(key)
	if (!deps) {
		depsMap.set(key, (deps = new Set()))
	}

	deps.add(effect)
}

function trigger(target, key) {
	const depsMap = targetMap.get(target)

	// 如果整个对象都没有被追踪则直接返回
	if (!depsMap) {
		return
	}

  let deps = depsMap.get(key)
  if (!deps) {
    return
  }

  deps.forEach(effect => effect())
}

function effect() {
  total = product.price * product.quantity
}

function reactive(target) {
	const handler = {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      track(target, key) // 设置响应式依赖项
			return result
		},
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver)
      const result = Reflect.set(target, key, value, receiver)
      // 当值发生变化的时候通知所有依赖项
      if (oldValue !== result) {
        trigger(target, key)
      }
			return result
		}
  }

	return new Proxy(target, handler)
}

function effect() {
  total = product.price * product.quantity
}

// 函数执行的时候会读取 product.price 和 product.quantity 属性
// 这会使得 `effect` 函数本身被收集
effect()

console.log(`total is ${total}`) // total is 10

product.price = 10

console.log(`total is ${total}`) // total is 20

以上。


CD ..
接下来阅读
Vite 虚拟模块