• 首页
  • vue
  • TypeScript
  • JavaScript
  • scss
  • css3
  • html5
  • php
  • MySQL
  • redis
  • jQuery
  • 深入响应式系统

    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<target, Map<key, Set<effect>>>数据结构中。如果在第一次追踪时没有找到对相应属性订阅的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<any, any> | Set<any>
    }
    


    计算属性调试

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

    描述符可拥有的键值
    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; // 可对象数组的索引操作进行劫持
    

    上篇:响应式基础

    下篇:计算属性