Skip to content

第三章:响应系统-响应系统的核心设计原则

01:前言

从本章开始我们将开始陆续实现 Vue 3 的核心内容,那么本章首先来看的就是:响应系统

我们通常把:会影响视图变化的数据称为响应数据,当响应式数据发生变化时,视图理应发生变化。

  1. 那么 Vue 中这样的响应性数据时如何进行实现的呢?
  2. Vue2Vue3 之间响应性的设计有什么变化吗?为什么会产生这种变化呢?

如果想要知道这些内容,那么就快快开始本章的学习吧!

02:JS 的程序性

想要了解响应性,那么首先我们先了解什么叫做:JS的程序性

我们来看下面的这段代码:

js
<script>
  // 定义一个商品对象,包含价格和数量
  let product = {
    price: 10,
    quantity: 2
  }
	// 总价格
	let total = product.price * product.quantity;
	// 第一次打印
	console.log(`总价格:${total}`);
	// 修改了商品的数量
	product.quantity = 5;
	// 第二次打印
	console.log(`总价格:${total}`);
</script>

大家可以想一下,在这段代码中,第一次打印的值是什么?第二次打印的值是什么?

这是一个非常简单的 JS 逻辑,两次打印的值应该都是一样的:总价格:20

但是大家有没有想过一个问题?

那就是当我们去进行第二次打印的时候,你真的希望它还是20吗?

我们知道我们最终希望打印的是 总价格,那么当 quantity 由 2 变为 5 的时候,总价格不应该是 50 了吗?

我们打印出来的总价格,难道不应该是 50 吗?

那么此时你有没有冒出来一个想法:商品数量发生变化了,如果总价格能够自己跟随变化,那就太好了!

但是 js 本身具备 程序性, 所谓程序性指的就是:一套固定的,不会发生变化的执行流程,在这样的一个程序性之下,我们是不可能拿到想要的 50 的。

那么如果我们想要拿到这个 50 就必须让你的程序变得更加 “聪明”,也就是使其具备 响应性

03:如何让你的程序变得更加“聪明”?

你为了让你的程序变得更加“聪明”,所以你开始想:“如果数据变化了,重新执行运算就好了”。

那么怎么去做呢?你进行了一个这样的初步设想:

  1. 创建一个函数 effect,在其内部封装 *计算总价格的表达式
  2. 在第一次打印总价格之前,执行 effect 方法
  3. 在第二次打印总价格之前,执行 effect 方法

那么这样我们是不是就可以在第二次打印时,得到我们想要的 50 了呢?

所以据此,你得到了如下的代码:

html
<script>
  // 定义一个商品对象,包含价格和数量
  let product = {
    price: 10,
    quantity: 2
  }
	// 总价格
	let total = 0;
	// 计算总价格的匿名函数
+	let effect = () => {
+    total = product.price * product.quantity;
+  }
  // 第一次打印
+ effect();
  console.log(`总价格:${total}`); // 总价格:20
  // 修改了商品的数量
  product.quantity = 5;
  // 第二次打印
+ effect();
  console.log(`总价格:${total}`); // 总价格:50
</script>

在这样的代码中,我们成功的让第二次打印得到了我们期望的结果:数据变化了,运算也重新执行了

但是大家也可以发现,在我们当前的代码中存在一个明显的问题,那就是:**必须主动在数量发生变化之后,重新主动执行 effect ** 才可以得到我们想要的结果。那么这样未免太麻烦了。有什么好的办法吗?

肯定是有的,我们继续来往下看~~~

04:vue2 的响应性核心API:Object.defineProperty

vue2Object.defineProperty 作为响应性的核心 API ,该 API 可以监听:指定对象的指定属性的 gettersetter

那么接下来我们就可以借助该 API,让我们之前的程序进行 自动计算,该 API 接收三个参数:指定对象、指定属性、属性描述符对象

html
<script>
  // 定义一个商品对象,包含价格和数量
  let quantity = 2
  let product = {
    price: 10,
    quantity: quantity
  }
  // 总价格
  let total = 0;
  // 计算总价格的匿名函数
  let effect = () => {
    total = product.price * product.quantity;
  };

  // 第一次打印
  effect();
  console.log(`总价格:${total}`); // 总价格:20

  // 监听 product 的 quantity 的 setter
  Object.defineProperty(product, 'quantity', {
    // 监听 product.quantity = xx 的行为,在触发该行为时重新执行 effect
    set(newVal) {
      // 注意:这里不可以是 product.quantity = newVal,因为这样会重复触发 set 行为
      quantity = newVal
      // 重新触发 effect
      effect()
    },
    // 监听 product.quantity,在触发该行为时,以 quantity 变量的值作为 product.quantity 的属性值
    get(val) {
      return quantity
    }
  });
</script>

那么此时我们就通过 Object.defineProperty 方法成功监听了 quantity 属性的 gettersetter 行为,现在当 quantity 发生变化时,effect 函数将重新计算,以此得到最新的 total

05:Object.defineProperty 在设计层的缺陷

vue2 使用 Object.defineProperty 作为响应性的核心 API,但是在 vue3 的时候却放弃了这种方式,转而使用 Proxy(后面会详细讲解,现在只需要知道即可) 实现,为什么会这样呢?

这是因为:Object.defineProperty 存在一个致命的缺陷

vue 官网中存在这样的一段描述

由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。尽管如此我们还是有一些办法来回避这些限制并保证它们的响应性。

他说:由于 JavaScript 的限制,Vue 不能检测数组和对象的变化 这是什么意思呢?

我们来看下面的这个例子:

vue
<template>
	<div id="app">
		<ul>
			<li v-for="(val, key, index) in obj" :key="index">
				{{ key }} - {{ val }}
			</li>
		</ul>
		<button @click="addObjKey">为对象增加属性</button>
		<hr />
		<ul>
			<li v-for="(item, index) in arr" :key="index">
				{{ item }}
			</li>
		</ul>
		<button @click="addArrItem">为数组添加元素</button>
	</div>
</template>

<script>
export default {
	name: 'App',
	data() {
		return {
			obj: {
				name: '张三',
				age: 30
			},
			arr: ['张三', '李四']
		}
	},
	methods: {
		addObjKey() {
			this.obj.gender = '男'
			console.log(this.obj) // 通过打印可以发现,obj 中存在 gender 属性,但是视图中并没有体现
		},
		addArrItem() {
			this.arr[2] = '王五'
			console.log(this.arr) // 通过打印可以发现,arr 中存在 王五,但是视图中并没有体现
		}
	}
}
</script>

在上面的例子中,我们呈现了 vue2 中响应性的限制:

  1. 当为 对象 新增一个没有在 data 中声明的属性时,新增的属性 不是响应性
  2. 当为 数组 通过下标的形式新增一个元素时,新增的元素 不是响应性

那么为什么会这样呢?

想要搞明白这个原因,那就需要明白官网所说的 由于 JavaScript 的限制 指的是什么意思。

我们知道

  1. vue 2 是以 Object.defineProperty 作为核心 API 实现的响应性
  2. Object.defineProperty 只可以监听 指定对象的指定属性的 getter 和 setter
  3. 被监听了 gettersetter 的属性,就被叫做 该属性具备了响应性

那么这就意味着:我们 必须要知道指定对象中存在该属性,才可以为该属性指定响应性。

但是 由于 JavaScript 的限制,我们没有办法监听到 指定对象新增了一个属性,所以新增的属性就没有办法通过 Object.defineProperty 来监听 gettersetter,所以 新增的属性将失去响应性

那么如果想要增加具备响应性的新属性,那么可以通过 Vue.set 方法实现

那么此时,我们已经知道了这些 vue2 中的 “缺陷”,那么 vue3 是如何解决这些缺陷的呢?我们继续来往下看~~~

06:vue3 的响应性核心API:proxy

因为 Object.defineProperty 存在的问题,所以 vue3 中修改了这个核心 API,改为使用 Proxy 进行实现。

proxy 顾名思义就是 代理 的意思。我们来看如下代码:

html
<script>
  // 定义一个商品对象,包含价格和数量
  let product = {
    price: 10,
    quantity: 2
  }

  // new Proxy 接收两个参数(被代理对象,handler 对象)。
  // 生成 proxy 代理对象实例,该实例拥有《被代理对象的所有属性》 ,并且可以被监听 getter 和 setter
  // 此时:product 被称为《被代理对象》,proxyProduct 被称为《代理对象》
  const proxyProduct = new Proxy(product, {
    // 监听 proxyProduct 的 set 方法,在 proxyProduct.xx = xx 时,被触发
    // 接收四个参数:被代理对象 tager,指定的属性名 key,新值 newVal,最初被调用的对象 receiver
    // 返回值为一个 boolean 类型,true 表示属性设置成功
    set(target, key, newVal, receiver) {
      // 为 target 附新值
      target[key] = newVal
      // 触发 effect 重新计算
      effect()
      return true
    },
    // 监听 proxyProduct 的 get 方法,在 proxyProduct.xx 时,被触发
    // 接收三个参数:被代理对象 tager,指定的属性名 key,最初被调用的对象 receiver
    // 返回值为 proxyProduct.xx 的结果
    get(target, key, receiver) {
      return target[key]
    }
  })

  // 总价格
  let total = 0;
  // 计算总价格的匿名函数
  let effect = () => {
    total = proxyProduct.price * proxyProduct.quantity;
  };

  // 第一次打印
  effect();
  console.log(`总价格:${total}`); // 总价格:20
</script>

在以上代码中,我们可以发现,ProxyObject.defineProperty 存在一个非常大的区别,那就是:

  1. proxy

    1. Proxy 将代理一个对象(被代理对象),得到一个新的对象(代理对象),同时拥有被代理对象中所有的属性。
    2. 当想要修改对象的指定属性时,我们应该使用 代理对象 进行修改
    3. 代理对象 的任何一个属性都可以触发 handlergettersetter
  2. Object.defineProperty

    1. Object.defineProperty指定对象的指定属性 设置 属性描述符
    2. 当想要修改对象的指定属性时,可以使用原对象进行修改
    3. 通过属性描述符,只有 被监听 的指定属性,才可以触发 gettersetter

所以当 vue3 通过 Proxy 实现响应性核心 API 之后,vue不会 再存在新增属性时失去响应性的问题。

07:proxy的最佳拍档:Reflect — 拦截 js 对象操作

当我们了解了 Proxy 之后,那么接下来我们需要了解另外一个 Proxy 的 “伴生对象”:Reflect Reflect 属性,多数时候会与 proxy 配合进行使用在 MDN Proxy 的例子中,Reflect 也有对此出现。 那么 Reflect 的作用是什么呢?

查看 MDN 的文档介绍,我们可以发现 Reflect 提供了非常多的静态方法,并且很巧的是这些方法与 ProxyHandler 的方法类似:


Reflect 静态方法

Reflect.get(target, propertyKey[, receiver\])

Reflect.has(target, propertyKey)

Reflect.set(target, propertyKey, value[, receiver\])

...


handler 对象的方法

handler.has()

handler.get()

handler.set()

...

我们现在已经知道了 handlergetset 的作用,那么 Reflectgetset 的作用是什么呢?

我们来看一下代码:

js
<script>
  const obj = {
    name: '张三'
  }

  console.log(obj.name) // 张三
  console.log(Reflect.get(obj, 'name')) // 张三
</script>

由以上代码可以发现,两次打印的结果是相同的。这其实也就说明了 Reflect.get(obj, 'name') 本质上和 obj.name 的作用 相同

那么既然如此,我们为什么还需要 Reflect 呢?

根据官方文档可知,对于 Reflect.get 而言,它还存在第三个参数 receiver,那么这个参数的作用是什么呢?

根据官网的介绍为:

如果target对象中指定了getterreceiver则为getter调用时的this值。

什么意思呢?我们来看以下代码:

html
<script>
  const p1 = {
    lastName: '张',
    firstName: '三',
    // 通过 get 标识符标记,可以让方法的调用像属性的调用一样
    get fullName() {
      return this.lastName + this.firstName
    }
  }

  const p2 = {
    lastName: '李',
    firstName: '四',
    // 通过 get 标识符标记,可以让方法的调用像属性的调用一样
    get fullName() {
      return this.lastName + this.firstName
    }
  }

  console.log(p1.fullName) // 张三
  console.log(Reflect.get(p1, 'fullName')) // 张三
  // 第三个参数 receiver 在对象指定了 getter 时表示为 this
  console.log(Reflect.get(p1, 'fullName', p2)) // 李四
</script>

在以上代码中,我们可以利用 p2 作为第三个参数 receiver ,以此来修改 fullName 的打印结果。即:此时触发的 fullName 不是 p1 的 而是 p2

那么明确好了这个之后,我们再来看下面这个例子:

html
<script>
  const p1 = {
    lastName: '张',
    firstName: '三',
    // 通过 get 标识符标记,可以让方法的调用像属性的调用一样
    get fullName() {
      return this.lastName + this.firstName
    }
  }

  const proxy = new Proxy(p1, {
    // target:被代理对象
    // receiver:代理对象
    get(target, key, receiver) {
      console.log('触发了 getter');
      return target[key]
    }
  })

  console.log(proxy.fullName);
</script>

在以上这个代码中,我问大家,此时我们触发了 prox.fullName,在这个 fullName 中又触发了 this.lastName + this.firstName 那么问:getter 应该被触发几次?


此时 getter 应该被触发 3 次! ,但是 实际只触发了 1 次! 。为什么?

可能有同学已经想到了,因为在 this.lastName + this.firstName 这个代码中,我们的 thisp1而非 proxy !所以 lastNamefirstName 的触发,不会再次触发 getter

那么怎么办呢?我们如何能够让 getter 被触发三次?

想要实现这个想过,那么就需要使用到 Reflect.get 了。

我们已知,Reflect.get 的第三个参数 receiver 可以修改 this 指向,那么我们可不可以 利用 Reflect.get 把 fullName 中的 this 指向修改为 proxy,依次来达到触发三次的效果?

我们修改以上代码:

js
const proxy = new Proxy(p1, {
    // target:被代理对象
    // receiver:代理对象
    get(target, key, receiver) {
      console.log('触发了 getter');
+      // return target[key]
+      return Reflect.get(target, key, receiver)
    }
  })

修改代码之后,我们发现,此时 getter 得到了三次的触发!

总结

本小节的内容比较多,但是核心其实就是在描述一件事情,那就是 Reflect 的作用,我们为什么要使用它。

最后做一个总结:

当我们期望监听代理对象的 gettersetter 时,不应该使用 target[key],因为它在某些时刻(比如 fullName)下是不可靠的。而 应该使用 Reflect ,借助它的 getset 方法,使用 receiver(proxy 实例) 作为 this,已达到期望的结果(触发三次 getter)。

08:总结

在本章中,我们首先了解了 JS 的程序性,我们知道了默认情况下,JS 是非常死板的,所以如果想要让程序变得更加 “聪明” 那么需要额外做一些事情。

通常我们有两种方式可以监听 targetgettersetter 分别是:

  1. Object.defineProperty:这是 vue2 的响应式核心 API,但是这个 API 存在一些缺陷,他只能监听 指定对象指定属性gettersetter 。所以在 “某些情况下”,vue2 的对象或数组会失去响应性。
  2. Proxy:这是 vue3 的响应式核心 API。该 API 表示代理某一个对象。代理对象将拥有被代理对象的所有属性和方法,并且可以通过操作代理对象来监听对应的 gettersetter

最后如果我们想要 “安全” 的使用 Proxy,还需要配合 Reflect 一起才可以,因为一旦我们在 **被代理对象的内部,通过 this 触发 gettersetter ** 时,也需要被监听到。

Released under the MIT License.