Proxy、Reflect 与响应式原理

在 vue2 中,实现数据的响应式依靠的是 Object.defineProperty() 的存取属性描述符,但是这个方法设计的初衷并不是用来监听拦截对象的属性的改变的,而是用来给一个对象定义一个新属性或是修改某个现有属性的,这样实现响应式的同时就会把对象中的属性的属性描述符全部变为存取描述符,而且只能侦听 getter 和 setter 操作,无法监听诸如新增、删除属性的操作,所以用它来实现响应式不是很好。于是,vue3 中,实现响应式就改成了使用 ES6 新增的 Proxy 结合 Reflect 实现。

Proxy

以例 1 为例,我们想要监听对象 obj,那么就可以使用 Proxy 类,通过 new 创建实例 proxy,proxy 就是 obj 的代理对象,之后对 obj 的所有操作,都应该通过代理对象来完成。因为我们在创建 proxy 时还需要传入一个处理器对象(handler) 作为第二个参数 ,里面定义对代理对象执行 13 种操作时要做些什么,包括诸如对监听对象属性的 delete 操作等,具体可参看 MDN 文档,属性通常是函数,可称为捕捉器(trap)。

例 1 中定义了 get(属性读取操作的捕捉器) 和 set(属性设置操作的捕捉器)两个函数属性,这样当我们在第 17 行设置 proxy.name 时第 11 行就会执行,打印"name被更改了";在第 16 、18 行获取 proxy.name 时第 6 行就会执行,打印"name被获取了"。如果我们只是去对原对象 obj 做操作,比如 obj.name = 'Zhou',那么 proxy 的 name 也会变成 Zhou,但是 set 函数就不会执行了。

javascript 复制代码
// 例 1
const obj = { name: 'Jay' }
const proxy = new Proxy(obj, {
  // 属性读取操作的捕捉器
  get(target, p) {
    console.log(p + '被获取了')
    return target[p]
  },
  // 属性设置操作的捕捉器
  set(target, p, value) {
    console.log(p + '被更改了')
    target[p] = value
  }
})

console.log(proxy.name) // Jay
proxy.name = 'Zhou'
console.log(proxy.name) // Zhou
console.log(obj.name) // Zhou
// 查询一个原本不存在的属性
console.log(proxy.age) // undefined
proxy.age = 20
console.log(proxy.age) // 20
console.log(obj.age) // 20

对代理对象做的操作会被同步 到原对象上,所以例 1 中第 16 行获取 proxy.name 得到的是 Jay,第 19 行打印 obj.name 得到的是 Zhou。其实,第 3 行新建代理对象 proxy 时,即使我们只传入一个空对象作为处理器对象,即 new Proxy(obj, {}),之后我们改变 obj 或 proxy 的属性时,另一个对象亦会跟着改变。

另外,当我们在第 21 行获取 proxy.age 时,也会触发 get,在 22 行给 proxy.age 赋值时也会触发 set,这就是为什么在 vue3 中,不再需要 vue2 的 this.$set

Reflect

Reflect 是一个内置的对象,所以使用时不能像使用 Proxy 那样用 new 运算符调用,Reflect 的所有属性和方法都是静态的。Reflect 拥有 13 种方法,它们与 Proxy 的处理器对象(handler)中捕捉器一一对应,比如有 Reflect.get()Reflect.set()。还有其它的一些方法和 Object 上的方法很像,比如 Reflect.defineProperty()Reflect.getPrototypeOf()等,个中细微差别可参见 MDN

那么这些方法既然已经在 Object 上了,为什么还要在 Reflect 上再实现一次呢?这是因为 Object 本身只是一个构造函数而已,照理不应该有这么多对对象本身操作的 API,只是由于早期的规范考虑并不周全,才将它们一股脑都放在了 Object 上而已,所以 ES6 新增了 Reflect 来承载这些操作。包括像 indelete 这样的操作符,可以分别用 Reflect 的 has()deleteProperty() 方法取代了:

javascript 复制代码
// 例 2
const temp = { name: 'Jay' }
console.log(Reflect.has(temp, 'name')) // true
Reflect.deleteProperty(temp, 'name')
console.log(temp.name) // undefined

在例 1 中,我们写 get 和 set 这两个属性时,在对应的函数中,其实是直接操作了被监听的对象 obj,因为 return target[p]target[p] = value 中的 target 指向的就是 obj,p 则为要操作的属性 name。我们创建代理对象 proxy 的目的之一就是想避免直接对原对象 obj 进行操作,可例 1 的写法在 handler 中还是直接操作了 obj。所以一般情况下,我们可以使用 Proxy 时,都会配合使用 Reflect 的方法,针对例 1 ,可以改成下面这样:

javascript 复制代码
// 例 2.1
const obj = { name: 'Jay' }
const proxy = new Proxy(obj, {
  get(target, p) {
    console.log(p + '被获取了')
    return Reflect.get(target, p)
  },
  set(target, p, value) {
    console.log(p + '被更改了')
    Reflect.set(target, p, value)
  }
})

例 2.1 中使用 Reflect 和例 1 中直接对原对象进行操作,有时候会有些区别,比如设置属性的操作,Reflect.set(target, p, value, receiver) 是会有个返回值表明属性是否成功设置,那么如果有需要,就可以根据属性设置成功或失败(比如对象 obj 被冻结了,Object.freeze(obj),导致设置失败)分别处理。

receiver 参数

补充一下,Proxy 的 handler 的或是 Reflect 的 get 和 set 除了上面例子中的参数之外都还有个 receiver 参数,作为 get/set 的最后一个参数。我们来看个案例(仅以 get 为例,set 同理):

javascript 复制代码
// 例 2.2
const obj = {
  firstName: 'Jay',
  lastName: 'Zhou',
  get fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

const proxy = new Proxy(obj, {
  get(target, p) {
    console.log(p + '被获取了')
    return Reflect.get(target, p)
  }
})
console.log(proxy.fullName)

例 2.2 中,obj 对象拥有 getter(setter 同理),我们创建了 obj 的代理对象 proxy,并且没有往 get 中传入 receiver ,然后在第 16 行去获取 proxy.fullName,结果如下图:

可以看到第 12 行只在获取 fullName 时触发了一次。但请注意,在第 6 行去获取 fullName 的时候,是需要去获取 this.firstNamethis.lastName 的,照理来说我们给 obj 对象设置了代理,且设置了属性读取操作的捕捉器 get,那么获取同为 obj 的属性 firstName 或 lastName 时也应该要被捕捉到,但是因为第 6 行的 this 指向的是 obj,所以没能触发第 12 行的打印。如果我们在第 11 行的 get() 和第 13 行的 Reflect.get() 中传入了 receiver

javascript 复制代码
// 例 2.2.1
// ...
const proxy = new Proxy(obj, {
  get(target, p, receiver) {
    console.log(p + '被获取了')
    return Reflect.get(target, p, receiver)
  }
})
// ...

这样打印的结果就会变为:

可以看到 proxy 的 handler.get() 执行了 3 次。这是因为在例 2.2.1 的第 4 行,传给 handler.get() 的 receiver 指向的是 proxy 对象本身,可以执行 console.log(receiver === proxy) 验证,然后我们将该 receiver传给了第 6 行的 Reflect.get(),当执行到原对象 obj 中 getter 里的 return this.firstName + ' ' + this.lastName,就将里面的 this 赋值为 receiver,此处就是让 this 指向了 proxy 对象,所以获取代理对象的 firstName/lastName 自然会触发执行第 5 行的打印。

响应式原理与实现

vue3 中把一个对象变成响应式的方法就是把该对象传给 reactive(),返回的就是该对象的代理对象(Proxy 对象)。为了探究其背后的原理,我们可以把响应式简单理解为如果一个对象的某个属性改变了,那么凡是用到了这个对象的被更改属性的函数都会自动执行一遍。比如现在有个 obj 对象,和一些用到了 obj 的函数:

javascript 复制代码
// 例 3
const obj = {
  name: 'Jay',
  age: 40
}

function fnName1() {
  console.log('我用到了 obj 的 name 属性', obj.name)
}
function fnName2() {
  console.log('我也用到了 obj 的 name 属性', obj.name)
}
function fnAge() {
  console.log('我用到了 obj 的 age 属性', obj.age)
}

我们针对例 3 手写实现个响应式的效果,目标是改变 obj.name 时,会自动执行 fnName1()fnName2();改变 obj.age 时,会自动执行 fnAge()

创建代理对象

javascript 复制代码
// 例 3.1
function reactive(raw) {
  return new Proxy(raw, {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      Reflect.set(target, key, value, receiver)
    }
  })
}

首先我们定义个 reactive() 函数用于生成并返回 Proxy 对象,想要监听哪个对象,就把哪个对象传给 reactive()。现在,我们可以直接把例 3 中的 obj 对象传给 reactive() 然后将返回得到的代理对象直接赋值给变量 obj

javascript 复制代码
// 例 3.1.1
const obj = reactive({
  name: 'Jay',
  age: 40
})

此时 obj 就是个代理对象了,如果我们去获取 obj 的属性就会触发例 3.1 第 4 行的属性读取操作的捕捉器 get();改变 obj 的属性,就会触发第 7 行的属性设置操作的捕捉器 set()。那么我们就可以在 getter 中去收集那些用到了 obj 属性的函数,也就是收集依赖;当 obj 属性改变时在 getter 中通知这些依赖执行。

管理依赖

我们定义一个 Dep 类来管理依赖,之所以用类,是想方便地实现让需要响应式的对象的每个属性都能有个对应的 Dep 类的实例对象 dep,dep 中的 activeEffects 属性用来存储对应的依赖,它是个 Set 对象,保证其中的依赖的唯一性,避免重复添加;depend() 方法用来收集依赖;notify()用来通知依赖的执行:

javascript 复制代码
// 例 3.2
class Dep {
  constructor() {
    this.activeEffects = new Set()
  }
  depend(activeEffect) {
    activeEffect && this.activeEffects.add(activeEffect)
  }
  notify() {
    this.activeEffects.forEach(item => item())
  }
}

我们希望,obj 的 name 和 age 属性可以分别有各自对应的 dep,这样,当我们改变 obj 的 name 时,才能在例 3.1 的 setter 中去正确地通知与 obj.name 相关的依赖执行。我们定义个 getDep() 函数来实现获取正确的 dep:

javascript 复制代码
// 例 3.2.1
const wm = new WeakMap()
function getDep(target, key) {
  let depMap = wm.get(target)
  if (!depMap) {
    depMap = new Map()
    wm.set(target, depMap)
  }
  let dep = depMap.get(key)
  if (!dep) {
    dep = new Dep()
    depMap.set(key, dep)
  }
  return dep
}

因为除了 obj 之外,我们之后可能还有其它对象,比如 obj1,也需要变成响应式对象,所以我们在例 3.2.1 的第 2 行新建了一个 WeakMap 对象来通过对象(target)找到存储了属性与依赖映射关系的 Map 对象(depMap),示意图如下:

在开始的时候,wm 只是一个空的 weakMap 对象,所以例 3.2.1 第 4 行通过对象(target)找不到对应的 Map 对象,depMap 会是 undefined,所以我们需要在这种情况下新建一个 Map 对象并建立与 target 的映射关系存储到 wm中。对 dep 的处理也是同个道理,不再赘述。

现在,我们就可以对例 3.1 的代码进行补充,添加对代理对象 getter 和 setter 时的处理:

javascript 复制代码
// 例 3.2.2
function reactive(raw) {
  return new Proxy(raw, {
    get(target, key, receiver) {
      getDep(target, key).depend()
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      Reflect.set(target, key, value, receiver)
      getDep(target, key).notify()
    }
  })
}

set()getDep().notify() 要写在 Reflect.set() 之后,这样通知执行依赖时获取的属性值才是改变后的值。现在如果我们去改变 obj 的属性,比如执行 obj.name = 'Chaim',并不会自动执行 fnName1fnName2,因为我们还没去获取过 obj.name,所以相关依赖,也就是 fnName1fnName2 还没被收集进 obj 的 name 对应的 dep 的 activeEffects中。我们需要定义个函数 watchEffect() 来让相关依赖先执行一遍以实现对它们的收集:

javascript 复制代码
// 例 3.2.3
let activeEffect = null // 定义于 class Dep {} 之前

function watchEffect(effect) {
  activeEffect = effect
  effect()
  activeEffect = null
}
watchEffect(fnName1)
watchEffect(fnName2)
watchEffect(fnAge)

在例 3.2.3 中,执行第 9 ~ 11 行,也就让这些函数各自执行了一遍(第 4 行),进而触发了例 3.2.2 的第 5 行的添加依赖操作 getDep(target, p).depend()。但是我们看例 3.2 的代码,dep 的 depend()方法是需要传入当前依赖(activeEffect)的,于是我们需要在定义 class Dep {} 之前添加个全局变量 activeEffect用于保存当前是哪个依赖正在执行,然后就可以直接在例 3.2 中获取到,再添加进 activeEffects 即可,所以例 3.2 的 depend()方法不需要传入形参 activeEffect了。为了更严谨些,例 3.2.3 第 7 行将 activeEffect 设为了 null,其实不写这一句也没影响,毕竟我们只是简单实现,一些边界情况不予考虑。

代码总结

至此我们对响应式的简单实现已经完成,想要将某个对象变为响应式的只需把它传给 reactive(),然后将需要响应式执行的用到该对象属性的函数传给 watchEffect(),就能在改变该对象的属性(第一层属性)时,就会自动执行相应函数。代码汇总如下:

javascript 复制代码
// 例 3.3
let activeEffect = null

class Dep {
  constructor() {
    this.activeEffects = new Set()
  }
  depend() {
    activeEffect && this.activeEffects.add(activeEffect)
  }
  notify() {
    this.activeEffects.forEach(item => item())
  }
}

const wm = new WeakMap()
function getDep(target, key) {
  let depMap = wm.get(target)
  if (!depMap) {
    depMap = new Map()
    wm.set(target, depMap)
  }
  let dep = depMap.get(key)
  if (!dep) {
    dep = new Dep()
    depMap.set(key, dep)
  }
  return dep
}

function reactive(raw) {
  return new Proxy(raw, {
    get(target, key, receiver) {
      getDep(target, key).depend()
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      Reflect.set(target, key, value, receiver)
      getDep(target, key).notify()
    }
  })
}

function watchEffect(effect) {
  activeEffect = effect
  effect()
  activeEffect = null
}

const obj = reactive({
  name: 'Jay',
  age: 40
})
watchEffect(() => {
  console.log('我用到了 obj 的 name 属性', obj.name)
})
watchEffect(() => {
  console.log('我也用到了 obj 的 name 属性', obj.name)
})
watchEffect(() => {
  console.log('我用到了 obj 的 age 属性', obj.age)
})

const obj1 = reactive({
  name: 'Teaser'
})
watchEffect(() => {
  console.log(obj1.name)
})

obj.name = 'Chaim'
obj.age = 18
obj1.name = '亦黑迷失'

例 3.3 我们是直接把函数声明传给了 watchEffect(),这样可以直接传个匿名函数,不再需要 fnName1、fnAge 等这些函数名。执行例 3.3 得到的打印结果如下图:

前 4 句是由 watchEffect() 执行时将传入的函数执行了一遍得到的;第 5、6 句是因为第 71 行的 obj.name = 'Chaim';第 7 句则是因为第 72 行的 obj.age = 18;最后一句是因为第 73 行的我们改变了 obj1 的 name 所致。

相关推荐
码蜂窝编程官方24 分钟前
【含开题报告+文档+PPT+源码】基于SpringBoot+Vue的虎鲸旅游攻略网的设计与实现
java·vue.js·spring boot·后端·spring·旅游
gqkmiss24 分钟前
Chrome 浏览器 131 版本开发者工具(DevTools)更新内容
前端·chrome·浏览器·chrome devtools
Summer不秃29 分钟前
Flutter之使用mqtt进行连接和信息传输的使用案例
前端·flutter
旭日猎鹰34 分钟前
Flutter踩坑记录(二)-- GestureDetector+Expanded点击无效果
前端·javascript·flutter
Viktor_Ye40 分钟前
高效集成易快报与金蝶应付单的方案
java·前端·数据库
hummhumm42 分钟前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
乐闻x1 小时前
Vue.js 性能优化指南:掌握 keep-alive 的使用技巧
前端·vue.js·性能优化
一条晒干的咸魚1 小时前
【Web前端】创建我的第一个 Web 表单
服务器·前端·javascript·json·对象·表单
花海少爷1 小时前
第十章 JavaScript的应用课后习题
开发语言·javascript·ecmascript
Amd7941 小时前
Nuxt.js 应用中的 webpack:compiled 事件钩子
前端·webpack·开发·编译·nuxt.js·事件·钩子