不只懂 Vue 语法:Vue 3 如何使用 Proxy 实现响应式(Reactivity)?

问题回答

Vue 3 会为 data 建立一个 Proxy 物件,并在里面建立 gettersetter 来取值和更新值,藉此实现响应式。因此不用直接操作原本的 data,而是操作代理 data 的 Proxy 的物件。相反, Vue 2 因为使用 Object.defineProperty 来为 data 的最外层每一个属性加上 gettersetter,所以 Vue 2 是直接修改 data

同时,因为现在是操作 Proxy ,所以即使 data 里有多层物件资料,我们仍然可以透过 Proxy 里建立的 setter 来修改多层物件资料。但 Vue 2 就不能,因为 Vue 2 只是为 data 的最外层属性逐一加上 gettersetter,因此内层的资料并没有 setter,无法被正确修改。

以下会详细解说 Vue 3 如何使用 Proxy 。承接上一篇讨论 Vue 实现响应式(reactivity)的原理。这篇集中整理有关 Vue 3 响应式的原理。内容主要是参考 Vue 3 官方教学影片以及文件,并以个人理解和整理来作解说。

为什麽 Vue 3 要使用 Proxy 来达成响应式?

  • Vue 3 不用支援 IE,所以可以使用 ES6 语法 Proxy 和 Reflect 来处理响应式
  • 解决了没法侦测阵列和物件变动的问题
  • 提升效能,不用跑回圈的方式,为每个data里的属性,透过 Object.defineProperty() 逐一加上 getset

Proxy 是什麽?

Proxy 是代理的意思。就像在你要操作的资料前放置一个栏截器,每次操作资料时,都会先跑在 Proxy 里的程序。以下是一个简单例子:

const target = {
  user: {
    name: "Alysa",
    jobTitle: "front-end developer",
  },
};

const p = new Proxy(target, {
  get(target, key) {
    return target[key];
  },

  set(target, key, value) {
    // key 是 name, value 是 Tom
    target[key] = value;
    return true;
  },
});

p.user.name = "Tom"; // 触发 set
console.log(p.user.name); // Tom

透过建立一个 Proxy 物件,并指向代理 target 这个物件。之後触发 setter 来修改 user 里的 name

所谓响应式(reactivity)要处理的事

在讨论如何用 Proxy 实现响应式之前,简单重温要实现响应式所需要解决的问题。这一部分在上一篇已经提过,在这里再简单总结一次。重用上一篇的例子。假设现在有以下情景:

const data = {
    price: 100,
    quantity: 2,
}

const total = () => data.price * data.quantity
console.log(total()) // 200

当我修改了 price,照理 total 也要随之然更新,所以 total 也要被重新执行,以下是错误示范:

let total;

const data = {
    price: 100,
    quantity: 2,
}

total = data.price * data.quantity
console.log(total) // 200
data.price = 200
console.log(total) // 仍然是 200,但我们期望是 400

正确做法是再次计算 total 的公式,才可以更新 total

let total;

const data = {
    price: 100,
    quantity: 2,
}

total = data.price * data.quantity
console.log(total) // 200
data.price = 200

// 再计算一次,才会更新 total
total = data.price * data.quantity
console.log(total) // 400

以上简单例子,可见所谓的响应式,要达成两件事才可以完成:

  1. 更新在 data 里的 price 这个属性的值
  2. 重新渲染所有有涉及到 price 值的元件

第一点:更新在 data 里的 price 这个属性的值

第一点,Vue 3 与 Vue 2 的原理是相同。同样是在修改值时,就会触发 set。并在 set 里执行更新旧值的动作。但是, Vue 2 是在 Object.definedProperty() 里的所定义的 set,而 Vue 3 就是在 Proxyhandler 函式里定义 set

const data = {
    price: 100,
    quantity: 2,
}

const proxyData = new Proxy(data, {
    get(target, key, receiver) {
        
        //...(先作省略)执行把依赖储存起来的程序码
        
        let result = Reflect.get(target, key, receiver)
        return result
    },
    set(target, key, value, receiver){
        // 以下会回传布林值
        let bol = Reflect.set(target, key, value, receiver)
        
        //...(先作省略)执行所有依赖,更新所有受这依赖影响的值
        // set handler 规定要回传 true
        return bol
    }
})

proxyData.price = 1000  // 触发 set()
console.log(proxyData.price) // 触发 get()。结果是 1000

以上就是 Vue 3 使用 Proxy 来实现更新的概念。利用 Proxydata 包起来,并在 handler 里使用 setget。所以,每次我们操作 data 时,我们是操作由 Vue 产生出来的 Proxy 物件,而非直接修改本身data这个物件,而它本身亦没有响应式(reactive)。拥有响应式的是经过 Vue 包装出来的Proxy 物件。相反,Vue 2 是直接修改 data 这物件,而且把它变成响应式。

另外,也补充说明Reflect 以及receiver参数。Reflect 是等同於使用.[] 来访问物件:

Reflect.get(data, 'price')
// 等於
data.price

Reflect.set(data, 'price', 1000)
// 等於
data.price = 1000

receiver参数并非必需。 只是为了确保当我们所访问的物件,如果它本身有继承其他物件时,this是指向仍然是该物件本身。详细解说可参考这篇文章提及的 Reflect 和 receiver 例子。因此,Vue 使用了 Reflect 来取值和修改值,而非一般像是obj.a 或是 obj['a'] 这样的写法。

第二点:重新渲染所有有涉及到 price 值的元件

第二点,因为 price 改变了,所以 total 就要重新计算,换言之所有用到 total 的元件都要重新渲染。但 Vue 怎麽知道哪一个元件有用到 total 这个值?同时又怎样知道当 price 更新时, total 要随之然更新?

这些问题,不论是 Vue 2 还是 Vue 3 所用的手法虽然不相同,但是概念是相同。概念一样是:

  • 把依赖(dependency)的程序码,即是 total = data.price * data.quantity 收集起来。
  • 为每个 data 里的属性做记录。记录它们会牵涉到哪些依赖。所以,当某个 data 里的属性的值更新时(即是 price),就会按这个纪录,找出此值连带的所有依赖程序码,并重新执行这堆依赖(即是 total = ... ),从而更新所有有牵涉到此属性的值 (即是 total)。

第一点,Vue 2 的做法是把这些依赖都存放在每个 data 属性里所建立的 Dep 实体。但 Vue 3 的做法是用 Map 来存放。概念如下图:

值得一提是 Vue 3 是使用 Set 来存放所有依赖,Set 物件里只会存放唯一值,里面不会有重复的值。所以里面不会有重复的依赖。

第二点,Vue 2 与 3 的原理是一样。一样是透过触发 set,并在 set 里面把之前储存起来的依赖取出来,并重新跑一次。但因为第一点提到,记录的手法不一样了,所以实际上程序码也会不一样。

Vue 2 是触发 dep.depend() 来储存依赖,并用 dep.notify() 来执行依赖并更新值。但 Vue 3 是触发 track() 来达成前者,trigger() 来达成後者。

接下来看看 trigger()track() 的程序码及其概念:

// 储存不同响应式物件,例如是 "data" 
const targetMap = new WeakMap()

// 触发 get 时会跑 track,储存依赖:

function track(target, key) {
  // 取得 depsMap
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 如果还没建立 depsMap,就建立一个
    targetMap.set(target, (depsMap = new Map()))
  }
  // 取得对应 dep 实体
  let dep = depsMap.get(key)
  if (!dep) {
    // 如果没有 dep 实体,就建立一个
    depsMap.set(key, (dep = new Set())) // Create a new Set
  }
  // 把依赖储存起来
  dep.add(effect) 
}
// 触发 set 时会跑 trigger,执行所有依赖来更新所有值:

function trigger(target, key) {
  // 取得 depsMap
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }
  // 取得对应的 dep 实体
  let dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => {
      // 跑一次所有依赖
      effect()
    })
  }
}

所以,如果把之前讨论到的 Proxy 部分,以及以上的track()trigger()合并起来,就是 Vue 3 实现响应式的概念。

完整程序码

https://codepen.io/alysachan/pen/VwWLGGJ

总结

  • Vue 3 是透过 Proxy 来为资料实现响应式(reactivity),并在里面建立 settergetter
  • Vue 2 和 3 响应式的原理是相同,只是手法不同。因为两者同样都是在处理两个问题:一是如何更新值,二是如何更新被这个值所影响的值。
  • 第一点,两者都是同样在 setter 里解决。第二点,涉及到 Vue 怎麽把依赖储存起来,以及怎样重新执行这些依赖。Vue 3 是使用 MapSet,以及 dep 实体来储存依赖。当值被修改时,就储透过查找Map,去找出对应的 dep 实体,把这实体里的所有依赖跑一次,从而更新所有受影响的值。

最後,这篇文章只是简单示范了 Vue 3 响应式的概念,实际上 Vue 在实行此概念时是更加复杂。

参考资料

Proxy and Reflect
一起来了解 Javascript 中的 Proxy 与 Reflect


<<:  Day06字体样式(HTML)

>>:  Day 15 - LocalStorage and Event Delegation

Day 6 : Github issue与project

Scrum Kanban(看板) 有部美剧叫Silicon Valley (矽谷群瞎传),由HBO出...

Day 16 - 用 canvas 做射击小游戏

import { useEffect, useState, useRef } from "...

Aol Mail Not Working on iPhone Device

If AOL is not working on your iPhone, you can try ...

Day 0xC UVa10170 The Hotel with Infinite Rooms

Virtual Judge ZeroJudge 题意 输入第一组旅行团人数 S,输出酒店内第 D ...

AVFoundation 来看看 Day 19

在第17天,做了一个ISBN 查询 书本的书名、书本描述 那麽今天我希望做一个使用拍照扫描辨识出IS...