百科狗-知识改变命运!
--

深入响应式系统 - 代理 Proxy 和 访问器属性 getter/setter - vue 基础

乐乐1年前 (2023-11-21)阅读数 18#技术干货
文章标签属性

深入响应式系统

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()函数有如下的任务:

  1. 当一个变量被读取时进行追踪。例如我们执行了表达式 A0 + A1 的计算,则 A0 和 A1 都被读取到了。
  2. 如果一个变量在当前运行的effect(作用)中被读取了,就将该effect(作用)设为此变量的一个订阅者。例如由于 A0 和 A1 在update()执行时被访问到了,则update()需要在第一次调用之后成为 A0 和 A1 的订阅者。
  3. 探测一个变量的变化。例如当我们给 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完全是出于支持旧版本浏览器的限制。

reactiveref数据劫持原理:

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的响应式追踪,而像watchcomputed这样的选项也是在内部调用相应等价的组合式 API。


运行时 vs.编译时响应性

Vue 的响应式系统基本是基于运行时的。追踪和触发都是在浏览器中运行时进行的。运行时响应性的优点是,它可以在没有构建步骤的情况下工作,而且边缘情况较少。另一方面,这使得它受到了 JavaScript 语法的制约。

我们在前面的示例中已经说到了所遇到的一个限制:JavaScript 并没有提供一种方式来拦截对局部变量的读写,因此我们始终只能够以对象属性的形式访问响应式状态,也就因此有了响应式对象和 ref。

我们已经在通过响应性语法糖这一实验性功能去尝试减少冗余代码:

let A0 = $ref(0)
let A1 = $ref(1)

// 在变量读取时追踪
const A2 = $computed(() => A0 + A1)

// 在变量写入时触发
A0 = 2

这个代码段会被编译成没有该转换时的样子,即自动地位所有变量引用处添加上.value。有了响应性语法糖,Vue 的响应式系统更加如虎添翼。


响应性调试

Vue 的响应性系统可以自动跟踪依赖关系,但在某些情况下,我们可能希望确切地知道正在跟踪什么,或者是什么导致了组件重新呈现。

组件调试钩子

我们可以在一个组件渲染时调试查看哪些依赖正在被使用,以及使用renderTrackedrenderTriggered生命周期钩子来确定哪个依赖正在触发更新。这些钩子都会收到一个调试事件,其中包含了所需依赖的信息。推荐在回调中放置一个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()传入第二个参数,是一个包含了onTrackonTrigger两个回调函数的对象:

  • 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:计算属性的onTrackonTrigger选项仅会在开发模式下工作。


侦听器调试

watch()侦听器也支持onTrackonTrigger选项:

watch(source, callback, {
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
}) watchEffect(callback, {
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
})

TIP:侦听器的onTrackonTrigger选项仅会在开发模式下工作。


与其他状态系统集成

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可以实现对整个对象的劫持,降低消耗,提高性能。能完美各类数据(包括:对象、数组、以及代理)。

深入响应式系统 - 代理 Proxy 和 访问器属性 getter/setter - vue 基础

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 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。这两种描述符都是对象。

描述符可拥有的键值
configurableenumerablevaluewritablegetset
数据描述符可选可选
存取描述符可选可选
  • 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

免责声明:我们致力于保护作者版权,注重分享,当前被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理!邮箱:344225443@qq.com)

图片声明:本站部分配图来自网络。本站只作为美观性配图使用,无任何非法侵犯第三方意图,一切解释权归图片著作权方,本站不承担任何责任。如有恶意碰瓷者,必当奉陪到底严惩不贷!

内容声明:本文中引用的各种信息及资料(包括但不限于文字、数据、图表及超链接等)均来源于该信息及资料的相关主体(包括但不限于公司、媒体、协会等机构)的官方网站或公开发表的信息。部分内容参考包括:(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供参考使用,不准确地方联系删除处理!本站为非盈利性质站点,本着为中国教育事业出一份力,发布内容不收取任何费用也不接任何广告!)