深入响应式系统 - 代理 Proxy 和 访问器属性 getter/setter - vue 基础
深入响应式系统
Vue 最标志性的功能就是其低侵入性的响应式系统。组件状态都是由响应式的 JavaScript 对象组成的。当更改它们时,视图会随即自动更新。这让状态管理更加简单直观,但理解它是如何工作的也是很重要的,这可以帮助我们避免一些常见的陷阱。在本节中,我们将深入研究 Vue 响应性系统的一些底层细节。
什么是响应性
这个术语在今天的各种编程讨论中经常出现,但人们说它的时候究竟是想表达什么意思呢?本质上,响应性是一种可以使我们声明式地处理变化的编程范式。一个经常被拿来当作典型例子的用例即是 Excel 表格:
这里单元格 A2 中的值是通过公式= A0 + A1
来定义的(你可以在 A2 上点击来查看或编辑该公式),因此最终得到的值为 3,正如所料。但如果你试着更改 A0 或 A1,你会注意到 A2 也随即自动更新了。
而 JavaScript 默认并不是这样的。如果我们用 JavaScript 写类似的逻辑:
let A0 = 1 let A1 = 2 let A2 = A0 + A1 console.log(A2) // 3 A0 = 2 console.log(A2) // 仍然是 3
当我们更改 A0 后,A2 不会自动更新。
那么我们如何在 JavaScript 中做到这一点呢?首先,为了能重新运行计算的代码来更新 A2,我们需要将其包装为一个函数::
let A2 function update() { A2 = A0 + A1 }
然后,我们需要定义几个术语:
- 这个
update()
函数会产生一个effect(副作用),或者就简称为作用,因为它会更改程序里的状态。 - A0 和 A1 被视为这个作用的依赖(dependency),因为它们的值被用来执行effect(作用)。因此这个effect(作用),也可以说是一个它依赖的订阅者(subscriber)。
还需要一个魔法函数,能够在 A0 或 A1 (这两个依赖)变化时调用update()
(产生作用)。
whenDepsChange(update)
whenDepsChange()
函数有如下的任务:
- 当一个变量被读取时进行追踪。例如我们执行了表达式 A0 + A1 的计算,则 A0 和 A1 都被读取到了。
- 如果一个变量在当前运行的effect(作用)中被读取了,就将该effect(作用)设为此变量的一个订阅者。例如由于 A0 和 A1 在
update()
执行时被访问到了,则update()
需要在第一次调用之后成为 A0 和 A1 的订阅者。 - 探测一个变量的变化。例如当我们给 A0 赋了一个新的值后,应该通知其所有订阅了的effect(作用)重新执行。
Vue 中的响应性是如何工作的
我们无法直接追踪对上述示例中局部变量的读写过程,在原生 JavaScript 中没有提供这样一种机制。但是,我们是可以追踪对象属性的读写。
在 JavaScript 中有两种劫持 property 访问的方式:getter/setters(对象的访问器属性)和Proxy(对象代理)。
- 在 Vue 3 中使用了
Proxy
来创建响应式对象,仅将getter/setters用于ref
。 - 在 Vue 2 中使用
Object.defineProperty
,把这些 property 全部转为getter/setters。Vue 2 使用getter/setters完全是出于支持旧版本浏览器的限制。
reactive
、ref
数据劫持原理:
function reactive(obj) { return new Proxy(obj, { get(target, key) { track(target, key) return target[key] }, set(target, key, value) { trigger(target, key) target[key] = value } }) } /* Proxy es6 特性,是js中的原生对象,用来创建一个对象的代理,可以实现基本操作的拦截和自定义。 ** let p = new Proxy(target, handler); ** new Proxy() 表示生成一个 Proxy 实例 ** 第一参数 target,表示所要拦截的目标对象。第二个参数 handler,是一个配置对象,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。第二个参数是用于监听目标对象行为的监听器。 ** Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写 **/ function ref(value) { const refObject = { // es5 中的访问器属性 // get关键字可在对象内部使用,可为此对象创造一个伪属性。不能有参数。 get value() { track(refObject, 'value') return value }, // set关键字特性:赋值。用set时,必须有且仅能有一个参数 set value(newValue) { trigger(refObject, 'value') value = newValue } } return refObject }
以上代码解释了我们在基础章节部分讨论过的一些事情:
- 当你将一个响应性对象的属性解构为一个局部变量时,响应性就会“断开连接”,因为对局部变量的访问不再触发
get()
、set()
代理捕获。 - 从
reactive()
返回的代理尽管行为上表现得像原始对象,但我们通过使用===
运算符还是能够比较出它们的不同。
在track()
内部,我们会检查当前是否有正在运行的effect(作用)。如果有,我们会查找到一个所有追踪了该属性的订阅者(它们存储在一个Set集合中),然后将当前这个effect(作用)作为新订阅者添加到该Set集合中。
// 这会在一个作用就要运行之前被设置 // 我们会在后面处理它 let activeEffect function track(target, key) { if (activeEffect) { const effects = getSubscribersForProperty(target, key) effects.add(activeEffect) } }
effect(作用)订阅者将被存储在一个全局的WeakMap
数据结构中。如果在第一次追踪时没有找到对相应属性订阅的effect(作用)集合,它将会在这里新建。这就是getSubscribersForProperty()
函数所做的事。为了简化描述,我们跳过了它其中的细节。
在trigger()
之中,我们会再查找到该属性的effect(作用)的所有订阅者。但这一次我们是去调用它们:
function trigger(target, key) { const effects = getSubscribersForProperty(target, key) effects.forEach((effect) => effect()) }
现在让我们回到whenDepsChange()
函数中:
function whenDepsChange(update) { const effect = () => { activeEffect = effect update() activeEffect = null } effect()}
它包裹了原先的update()
函数到一个effect(作用)中,并在运行实际的更新之前,将它自己设为当前活跃的effect(作用)。而在更新期间开启的track()
调用,都将能定位到这个当前活跃的effect(作用)。
此时,我们已经创建了一个能自动跟踪其依赖关系的effect(副作用),它会在依赖关系更改时重新运行。我们称其为响应式副作用(effect)。
watchEffect()
Vue 提供了一个 API 来让你创建响应式effect(作用)watchEffect()。事实上,你会发现它的使用方式和我们上面示例中说的魔法函数whenDepsChange()
非常相似。我们可以用真正的 Vue API 改写上面的例子
import { ref, watchEffect } from 'vue' const A0 = ref(0) const A1 = ref(1) const A2 = ref() watchEffect(() => { // 追踪 A0 和 A1 A2.value = A0.value + A1.value }) // 将触发作用 A0.value = 2
在内部,computed
会使用响应式作用来管理失效与重新计算的过程。
那么,常见的响应式effect(作用)的用例是什么呢?自然是更新 DOM!我们可以像下面这样实现一个简单的“响应式渲染”:
import { ref, watchEffect } from 'vue' const count = ref(0) watchEffect(() => { document.body.innerHTML = `计数:${count.value}` }) // 更新 DOM count.value++
实际上,这与 Vue 组件保持状态和 DOM 同步的方式非常接近。每个组件实例创建一个响应式effect(作用)来渲染和更新 DOM。当然,Vue 组件使用了比 innerHTML 更高效的方式来更新 DOM。这会在渲染机制一章中详细介绍。
ref()
、computed()
和watchEffect()
这些 API 都是组合式 API 的一部分,如果你至今只使用过选项式 API,那么你需要知道的是组合式 API 更贴近 Vue 底层的响应式系统。事实上,Vue 3 中的选项式 API 正是基于组合式 API 建立的。对该组件实例(this
)所有的属性访问都会触发getter/setter的响应式追踪,而像watch
和computed
这样的选项也是在内部调用相应等价的组合式 API。
运行时 vs.编译时响应性
Vue 的响应式系统基本是基于运行时的。追踪和触发都是在浏览器中运行时进行的。运行时响应性的优点是,它可以在没有构建步骤的情况下工作,而且边缘情况较少。另一方面,这使得它受到了 JavaScript 语法的制约。
我们在前面的示例中已经说到了所遇到的一个限制:JavaScript 并没有提供一种方式来拦截对局部变量的读写,因此我们始终只能够以对象属性的形式访问响应式状态,也就因此有了响应式对象和 ref。
我们已经在通过响应性语法糖这一实验性功能去尝试减少冗余代码:
let A0 = $ref(0) let A1 = $ref(1) // 在变量读取时追踪 const A2 = $computed(() => A0 + A1) // 在变量写入时触发 A0 = 2
这个代码段会被编译成没有该转换时的样子,即自动地位所有变量引用处添加上.value
。有了响应性语法糖,Vue 的响应式系统更加如虎添翼。
响应性调试
Vue 的响应性系统可以自动跟踪依赖关系,但在某些情况下,我们可能希望确切地知道正在跟踪什么,或者是什么导致了组件重新呈现。
组件调试钩子
我们可以在一个组件渲染时调试查看哪些依赖正在被使用,以及使用renderTracked
和renderTriggered
生命周期钩子来确定哪个依赖正在触发更新。这些钩子都会收到一个调试事件,其中包含了所需依赖的信息。推荐在回调中放置一个debugger语句,使你可以在开发者工具中交互式地查看依赖:
export default {renderTracked
(event) { debugger 语句 },renderTriggered
(event) { debugger 语句 } }
TIP:组件调试钩子仅会在开发模式下工作
调试事件对象有如下的类型定义:
type DebuggerEvent = { effect: ReactiveEffect target: object type: | TrackOpTypes /* 'get' | 'has' | 'iterate' */ | TriggerOpTypes /* 'set' | 'add' | 'delete' | 'clear' */ key: any newValue?: any oldValue?: any oldTarget?: Map | Set }
计算属性调试
我们可以向computed()
传入第二个参数,是一个包含了onTrack
和onTrigger
两个回调函数的对象:
onTrack
将在响应属性或引用作为依赖项被跟踪时被调用。onTrigger
将在侦听器回调被依赖项的变更触发时被调用。
这两个回调都会作为组件组件调试的钩子,接受相同格式的调试事件:
const plusOne = computed(() => count.value + 1, { onTrack(e) { // 当 count.value 被追踪为依赖时触发 debugger }, onTrigger(e) { // 当 count.value 被更改时触发 debugger } }) // 访问 plusOne,会触发 onTrack console.log(plusOne.value) // 更改 count.value,应该会触发 onTrigger count.value++
TIP:计算属性的onTrack
和onTrigger
选项仅会在开发模式下工作。
侦听器调试
watch()
侦听器也支持onTrack
和onTrigger
选项:
watch(source, callback, { onTrack(e) { debugger }, onTrigger(e) { debugger } }) watchEffect(callback, { onTrack(e) { debugger }, onTrigger(e) { debugger } })
TIP:侦听器的onTrack
和onTrigger
选项仅会在开发模式下工作。
与其他状态系统集成
Vue 的响应性系统是通过深度转换纯 JavaScript 对象到响应式代理来实现的。这种深度转换可以是不必要的,或者在集成其他外部状态管理系统时甚至是我们不想要的。(例如,一个外部的解决方案也用了Proxy
)。
将 Vue 的响应性系统与外部状态管理方案集成的总体意见是:将外部状态放在一个shallowRef
中。一个浅层的ref中只有它的.value
属性本身被访问时才是有响应性的,而不关心它内部的值。当外部状态改变时,替换此ref的.value
才会触发更新。
不可变数据
如果你正在实现一个撤销/重做的功能,你可能想要对用户编辑时应用的状态进行快照记录。然而,如果状态树很大的话,Vue 的可变响应性系统没法很好地处理这种情况,因为在每次更新时都序列化整个状态对象对 CPU 和内存开销来说都是非常昂贵的。
不可变数据结构通过永不更改状态对象来解决这个问题。与 Vue 不同的是,它会创建一个新对象,保留旧的对象未发生改变的一部分。在 JavaScript 中有多种不同的方式来使用不可变数据,但我们推荐使用 Immer 搭配 Vue,因为它使你可以在保持原有直观、可变的语法的同时,使用不可变数据。
我们可以通过一个简单的可组合函数来集成 Immer:
import produce from 'immer' import { shallowRef } from 'vue' export function useImmer(baseState) { const state = shallowRef(baseState) const update = (updater) => { state.value = produce(state.value, updater) } return [state, update] }
状态机
状态机是一种数据模型,用于描述应用程序可能处于的所有可能状态,以及从一种状态转换到另一种状态的所有可能方式。虽然对于简单的组件来说,这可能有些小题大做了,但它的确可以使得复杂的状态流更加健壮和易于管理。
JavaScript 中一个最受欢迎的状态机实现方案就是 XState。这里是集成它的一个例子:
import { createMachine, interpret } from 'xstate' import { shallowRef } from 'vue' export function useMachine(options) { const machine = createMachine(options) const state = shallowRef(machine.initialState) const service = interpret(machine) .onTransition((newState) => (state.value = newState)) .start() const send = (event) => service.send(event) return [state, send] }
RxJS
RxJS 是否有一个用于处理异步事件流的库。VueUse 库提供了@vueuse/rxjs
扩展来支持连接 RxJS 流与 Vue 的响应性系统。
Vue2 vs. Vue3
- 在 Vue 2 中使用
Object.defineProperty
,把这些 property 全部转为getter/setter。Object.defineProperty
只能实现对象属性进行数据劫持,并且数据劫持式层层递归,消耗很大。对于对象的属性、方法,新增、删除则无能为力。另外,对数组数据存在缺陷。 - 在 Vue 3 中,对响应式对象,使用
Proxy
来创建;对ref,使用getter/setter来实现。Proxy
可以实现对整个对象的劫持,降低消耗,提高性能。能完美各类数据(包括:对象、数组、以及代理)。
Vue2 使用Object.defineProperty()
存在的缺点。原理示意:
const obj = {name: "vue", arr: [1, 2, 3]}; Object.keys(obj).forEach((key) => { let value = obj[key]; Object.defineProperty(obj, key, { get() { console.log(`get key is ${key}`); return value; }, set(newVal) { console.log(`set key is ${key}, newVal is ${newVal}`); value = newVal; } }); }); // 此时给对象新增一个age属性 obj.age = 18; // 因为对象劫持的时候,没有对 age 进行劫持,所以新增属性无法劫持 delete obj.name; // 删除对象上已经进行劫持的 name 属性,发现删除属性操作也无法劫持 obj.arr.push(4); // 无法劫持数组的 push 等方法 obj.arr[3] = 4; // 无法劫持数组的索引操作,因为没有对数组的每个索引进行劫持,并且由于性能原因,Vue2并没有对数组的每个索引进行劫持
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
Object.defineproperty( obj,prop, descriptor)
obj:要定义属性的对象,或者要修改属性的对象。
prop:要定义或修改的属性的名称或 Symbol。
descriptor:要定义或修改的属性描述符。属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。这两种描述符都是对象。
configurable | enumerable | value | writable | get | set | |
---|---|---|---|---|---|---|
数据描述符 | 有 | 有 | 可选 | 可选 | 无 | 无 |
存取描述符 | 有 | 有 | 无 | 无 | 可选 | 可选 |
- configurable:当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
- enumerable:当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
- value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。
- writable:当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符(en-US)改变。默认为 false。
- get:属性的getter函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的 this 并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。默认为 undefined。
- set:属性的setter函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。默认为 undefined。
Vue3 使用Proxy
实现完美劫持。原理示意:
const obj = {name: "vue", arr: [1, 2, 3]}; function proxyData(value) { const proxy = new Proxy(value, { get(target, key) { console.log(`get key is ${key}`); const val = target[key]; if (typeof val === "object") { return proxyData(val); } return val; }, set(target, key, value) { console.log(`set key is ${key}, value is ${value}`); return target[key] = value; }, deleteProperty(target, key) { console.log(`delete key is ${key}`); } }); return proxy; } const proxy = proxyData(obj); proxy.age = 18; // 可对新增属性进行劫持 delete proxy.name; // 可对删除属性进行劫持 proxy.arr.push(4); // 可对数组的push等方法进行劫持 proxy.arr[3] = 4; // 可对象数组的索引操作进行劫持
鹏仔微信 15129739599 鹏仔QQ344225443 鹏仔前端 pjxi.com 共享博客 sharedbk.com
图片声明:本站部分配图来自网络。本站只作为美观性配图使用,无任何非法侵犯第三方意图,一切解释权归图片著作权方,本站不承担任何责任。如有恶意碰瓷者,必当奉陪到底严惩不贷!