ToC
响应式例子
在 Vue 中,无论是 2 还是 3,它们使用起来差别都不太大,我们先看一个例子,借助它来分析一下响应式系统:
1<template>2 <div>Price: {{ price }}</div>3 <div>Total: {{ price * quantity }}</div>4 <div>Taxes: {{ totalPriceWithTax }}</div>5</template>6
7<script>8export default {9 data() {10 return {11 collapsed lines
11 price: 5,12 quantity: 213 }14 },15 computed: {16 totalPriceWithTax({ price, quantity }) {17 return price * quantity * 1.0318 }19 }20}21</script>
页面中一共有三个关于对 price
变量的访问 price
price * quantity
totalPriceWithTax
,当我们的 price
发生变化的时候, 页面中的 price
price * quantity
totalPriceWithTax
变量也会随之发生改变, 而这一连锁反应就是响应式系统的优势,即:自动更新依赖项数据。
所以问题是我又是怎么知道变量被依赖了,又应该去更新哪些数据的? 因为这不是 JavaScript 通常的工作方式, 在常规情况下,我们它们会以下面这种方式来工作:
1let price = 52let quantity = 23let total = price * quantity4
5console.log(`total is ${total}`) // total is 106
7// 更新价格8price = 209
10// 结果依旧是 10,因为 total 变量由始至终都没有发生过变化1 collapsed line
11console.log(`total is ${total}`) // total is 10
那么接下来我们一步一步尝试解决这个问题。我们需要提出问题,怎样存储 total
的计算方式才能当 price
或 quantity
发生变化的时候,让 total
重新计算一次?
手动收集、更新依赖
我们思考一下,上述语句中,是什么让 total
变量发生了变化?是的,是 total = price * quantity
。那我们只需要在我们想要变化的时候重新执行这一语句即可。我们知道,在编程语言中想要对代码进行一定程度的复用的话,可以利用函数的能力。我们将这一语句保存到一个函数中,将这个函数保存在一个 storage
中,当我们想要再次访问的时候,只需要从 storage
中取回这个函数并执行它即可达到效果,但是我们可能不止保存这一个函数,所以可能还会存在多个不同的 storage
。那么我们按照这个思路对上述代码进行一次改写。
1let price = 52let quantity = 23let total = 04
5// 用于保存数据变更的 storage6// 为什么要用 new Set() ?7// 因为 ES6 中的 Set() 不允许包含重复值8// 使用它可以实现当多次调用依赖追踪的方法时不会出现重复的 effects9const deps = new Set()10
31 collapsed lines
11// 用户更新数据12function effect() {13 total = price * quantity14}15
16// 用于追踪我们的依赖17function track() {18 deps.add(effect)19}20
21function trigger() {22 deps.forEach(effect => effect())23}24
25track() // 添加我们对数据变化时的监听函数26trigger() // 首次计算我们的变量值27
28console.log(`total is ${total}`) // total is 1029
30// 更新价格31price = 1032// 更新数量33quantity = 334
35// 这个时候 total 依然是 10,这是因为我们还没有执行数据更新的操作36console.log(`total is ${total}`) // total is 1037
38// 执行更新数据操作39trigger()40
41console.log(`total is ${total}`) // total is 30
我们达成了我们想要的效果,但我们在开发中会有很多数据有着这种依赖关系,每一个数据都去手动维护它们的状态更新显然效率更低,因为每个属性都需要自己的 deps
,或者说是这个变量自己的 effects
的 Set
集合。那么更好的做法显然是让每个熟悉都拥有一个自己的 effects
。
每个数据与依赖项的对应关系如下:
depsMap
保存了所有属性的依赖变化项,它的类型是一个 Map
,而它可能会有 price
或 quantity
属性,它们的值是一个 Set
类型的 deps
。而 deps
类型中保存着其属性的 effects
。那么我们尝试去实现它:
1// 是的,我们有一个产品2// 其中 price/quantity 都会有一个自己的 `deps`3// `deps` 将会在属性发生变化的时候重新运行4let product = { price: 5, quantity: 2 }5const depsMap = new Map()6
7function track(key) {8 // 获取对应属性的 effects9 // 而这个 key 对于 product 来说,不是 price 就是 quantity10 let deps = depsMap.get(key)44 collapsed lines
11
12 if (!deps) {13 // 如果它还没有对应的依赖项,那么表示它还没有被读取过14 // 这是第一次被访问,那么我们就新建一个依赖集合,来保证后续的变化能精准响应15 depsMap.set(key, (deps = new Set()))16 }17
18 // 添加数据变化时触发的监听事件19 // 因为依赖集本身是一个 Set(),所以当它已经存在的时候就不会被重复添加20 deps.add(effect)21}22
23function trigger(key) {24 const deps = depsMap.get(key)25
26 // 如果不存在对应的键依赖则直接结束27 if (!deps) {28 return29 }30
31 // 如果存在则执行所有的依赖函数32 deps.forEach(effect => effect())33}34
35function effect() {36 total = product.price * product.quantity37}38
39// 我们告诉 track(),我们想要把 effect 函数保存到 'price' 属性的依赖集中40// 当然也可以执行 track('quantity') 来讲依赖保存到 'quantity' 下面,两者一样41track('price')42
43// 告诉 trigger() 我想要立即触发 'price' 属性下面的依赖变化回调44trigger('price')45
46console.log(`total is ${total}`) // total is 1047
48// 我们修改它的值49product.price = 1050
51// 触发变化时的依赖52trigger('price')53
54console.log(`total is ${total}`) // total is 20
这是一个比较基础的响应式系统原型, 那如果我们有多个响应式对象数据的话,光是这样做还远远不够。假设我们有 user
product
等其他响应式对象时,我们可能需要做更多工作。那么这时候就需要其他的对象,它的 key
以某种方式引用了我们的响应式对象,例如: product
user
。
多个响应式对象
那么我们根据上面的信息可以得到这些:
1const targetMap = new WeakMap()2
3function track(target, key) {4 let depsMap = targetMap.get(target)5
6 // 如果这个对象没有被观察则将它添加到依赖列表中7 if (!depsMap) {8 targetMap.set(target, (depsMap = new Map()))9 }10
38 collapsed lines
11 // 读取对象上的子属性12 let deps = depsMap.get(key)13 if (!deps) {14 depsMap.set(key, (deps = new Set()))15 }16
17 deps.add(effect)18}19
20function trigger(target, key) {21 const depsMap = targetMap.get(target)22
23 // 如果整个对象都没有被追踪则直接返回24 if (!depsMap) {25 return26 }27
28 let deps = depsMap.get(key)29 if (!deps) {30 return31 }32
33 deps.forEach(effect => effect())34}35
36function effect() {37 total = product.price * product.quantity38}39
40track(product, 'price')41trigger(product, 'price')42
43console.log(`total is ${total}`) // total is 1044
45product.price = 1046trigger(product, 'price')47
48console.log(`total is ${total}`) // total is 20
我们最终的结果没有任何变化,但是执行的过程变了,这么做会使得它在面对多个响应式数据对象的时候也可以轻松应对。
但如果代码的复杂度进一步提升时,我们想要维护繁多的响应式对象的难度将会进一步提升,那么我们接下来要做的事情则是将响应式对象的依赖收集和响应式触发进一步封装,使其自动化,来达到访问属性的时候自动收集依赖,修改属性值的时候自动执行依赖项以保证数据获取的最新值。
过程自动化
如何去做?我们可以借助 Proxy 和 Reflect 对对象代理的能力实现这个功能,代理整个对象,通过监听其属性的访问和覆写来自动完成收集、触发的操作。我们首要介绍一下为什么要使用 Reflect
,以及使用它有什么好处。简单对比一下三种可以对象属性的方式:
1product.price2
3produc['price']4
5Reflect.get(product, 'price')
它们都可以达成目标,但是 Reflect
配合 Proxy
的话,有一点是上面两种做不到的,即:保证当对象有继承自其他对象的值或函数时,this 指针能正确的指向使用的对象
。使用方法为:Reflect.get(target, key, receiver)
和 Reflect.set(target, key, value, receiver)
我们先了解一下 Proxy
的工作流程:
1let product = { price: 5, quantity: 2 }2
3let proxiesProduct = new Proxy(product, {})4
5console.log(proxiesProduct.price)6// 在我们对 price 属性进行访问时,会先地调用代理(proxiesProduct.price)7// proxiesProduct 调用 product,然后 product 再返回 proxiesProduct8// 最后回归到对熟悉的访问,如下:9// proxiesProduct.price -> proxiesProduct -> product ->10// product.price -> proxiesProduct -> proxiesProduct.price -> 5
在了解基本工作流程后,我们试着用一个例子去理解它:
1function reactive(target) {2 const handler = {3 get(target, key, receiver) {4 console.log('Get was called with key = ' + key)5 return Reflect.get(target, key, receiver)6 },7 set(target, key, value, receiver) {8 console.log('Set was called with key = ' + key + ' and value = ' + value)9 return Reflect.set(target, key, value, receiver)10 }7 collapsed lines
11 }12 return new Proxy(target, handler)13}14
15const product = reactive({ price: 5, quantity: 2 })16product.quantity = 417console.log(product.quantity)
之后我们尝试去封装它:
1function reactive(target) {2 const handler = {3 get(target, key, receiver) {4 const result = Reflect.get(target, key, receiver)5 track(target, key) // 设置响应式依赖项6 return result7 },8 set(target, key, value, receiver) {9 const oldValue = Reflect.get(target, key, receiver)10 const result = Reflect.set(target, key, value, receiver)10 collapsed lines
11 // 当值发生变化的时候通知所有依赖项12 if (oldValue !== result) {13 trigger(target, key)14 }15 return result16 }17 }18
19 return new Proxy(target, handler)20}
回到我们原来的代码中,并应用这个新的函数:
1const product = reactive({ price: 5, quantity: 2 })2let total = 03
4function effect() {5 total = product.price * product.quantity6}7
8// 函数执行的时候会读取 product.price 和 product.quantity 属性9// 这会使得 `effect` 函数本身被收集10+ effect()12 collapsed lines
11
12- track('price')13
14- trigger('price')15
16console.log(`total is ${total}`) // total is 1017
18product.price = 1019
20- trigger('price')21
22console.log(`total is ${total}`) // total is 20
我们将所有需要手动执行的 track
trigger
都封装到了 reactive
函数内部,让程序去帮助我们去做这件事,这会使得我们在读取属性的时候,依赖项的收集和触发将变得简单了起来。这样一来,基本的响应式就完成了。
完整代码
完整代码如下:
1const targetMap = new WeakMap()2const product = reactive({ price: 5, quantity: 2 })3let total = 04
5function track(target, key) {6 let depsMap = targetMap.get(target)7
8 // 如果这个对象没有被观察则将它添加到依赖列表中9 if (!depsMap) {10 targetMap.set(target, (depsMap = new Map()))65 collapsed lines
11 }12
13 // 读取对象上的子属性14 let deps = depsMap.get(key)15 if (!deps) {16 depsMap.set(key, (deps = new Set()))17 }18
19 deps.add(effect)20}21
22function trigger(target, key) {23 const depsMap = targetMap.get(target)24
25 // 如果整个对象都没有被追踪则直接返回26 if (!depsMap) {27 return28 }29
30 let deps = depsMap.get(key)31 if (!deps) {32 return33 }34
35 deps.forEach(effect => effect())36}37
38function effect() {39 total = product.price * product.quantity40}41
42function reactive(target) {43 const handler = {44 get(target, key, receiver) {45 const result = Reflect.get(target, key, receiver)46 track(target, key) // 设置响应式依赖项47 return result48 },49 set(target, key, value, receiver) {50 const oldValue = Reflect.get(target, key, receiver)51 const result = Reflect.set(target, key, value, receiver)52 // 当值发生变化的时候通知所有依赖项53 if (oldValue !== result) {54 trigger(target, key)55 }56 return result57 }58 }59
60 return new Proxy(target, handler)61}62
63function effect() {64 total = product.price * product.quantity65}66
67// 函数执行的时候会读取 product.price 和 product.quantity 属性68// 这会使得 `effect` 函数本身被收集69effect()70
71console.log(`total is ${total}`) // total is 1072
73product.price = 1074
75console.log(`total is ${total}`) // total is 20
以上。