学 Vue 绕不过的坎 | defineProperty 与 Proxy 的区别

399 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情

JS 中拦截对象操作有两种方式,分别是 Obejct.definePropertyProxy,它们也分别是 Vue2 与 Vue3 实现数据响应式的原理,可以说是前端 Vue 程序员在面试中必问的内容。

本文将介绍这两种拦截操作的语法与使用,彼此的区别。

Object.defineProperty 用法

此方法可以直接在对象上定义一个新属性,也可以修改对象的现有属性。

语法:Object.defineProperty(obj, prop, descriptor)

  • obj:要定义或修改属性的对象
  • prop:要定义或修改的属性的名称或 Symbol
  • descriptor:该属性的描述符
  • 返回值:传入的对象obj

该方法的核心在于属性描述符(descriptor 参数),存在两种写法,分别为数据描述符存取描述符

这两种描述符都是对象。它们共享以下键值:

  • configurable 表示该属性的描述符能否被改变以及该属性能否从对象上删除,默认为 false

  • enumerable 表示该属性是否会出现在对象的枚举属性中,默认为 false

数据描述符还具有以下键值:

  • value 表示该属性的值,默认为 undefined

  • writable 表示该属性是否可写,默认为 false

存取描述符还具有以下键值:

  • get 表示该属性的取函数,在读取改属性时会执行此函数,函数返回值作为属性的值

  • set 表示该属性的存函数,修改属性值时会调用此函数,并接受一个参数(被赋予的新值)

两种描述符不可同时配置,如果一个描述符同时拥有 value 或 writable 和 get 或 set 键,则会产生一个异常

用一个简单的示例展示其用法:

let obj = {}

Object.defineProperty(obj, '_num', {
  value: 1,
  writable: true,
  enumerable: false, // 不被枚举
  configurable: false,
})
Object.defineProperty(obj, 'num', {
  get() {
    console.log('num属性被访问')
    return this._num
  },
  set(value) {
    console.log('num属性被修改')
    this._num = value
  },
  enumerable: true,
  configurable: true,
})

console.log(obj._num) // 1
console.log(obj.num) // num属性被访问 1
console.log(Object.keys(obj)) // ['num'] (_num属性未被枚举)
obj.num = 10 // num属性被修改
delete obj._num // (configurable为false,无法删除)
delete obj.num
console.log(obj.num) // undefined
console.log(obj._num) // 1

Proxy 用法

Proxy 是一个类,可以为对象创建一个代理对象,从而实现对基本操作的拦截和自定义。

语法:const p = new Proxy(target, handler)

  • target:要代理的对象
  • handler:拦截器对象
  • 返回值:新的代理对象

该方法的核心在于拦截器对象,其包含许多的函数属性,用于定义代理行为。

Proxy 可以拦截对象的 14 种操作,不过我们一般拦截的只有读写操作,语法如下:

const hander = {
  get(target, property, receiver) {},
  set(target, property, value, receiver) {},
}
  • target:被代理的原对象
  • property:要访问的属性名或 Symbol
  • value:要设置的新属性值
  • receiver:代理对象
  • 返回值:get 返回要获取的属性值,而 set 返回一个布尔值,表示赋值是否成功

用一个简单的示例展示其用法:

const obj = {
  _num: 1,
}

const hander = {
  get(target, property) {
    if (property === 'num') return target['_num']
    else return undefined
  },
  set(target, property, value) {
    if (property === 'num') {
      console.log('num属性赋值成功')
      target['_num'] = value
      return true
    } else {
      console.log(`${property}属性赋值失败`)
      return false
    }
  },
}

const p = new Proxy(obj, hander)

console.log(p._num) // undefined
console.log(p.num) // 1
p.num = 2 // num属性赋值成功
p._num = 3 // _num属性赋值失败
console.log(p.num) // 2
console.log(obj._num) // 2

两者区别

看完了 Obejct.definePropertyProxy 的用法,接下来结合 Vue 讲解一下它们的区别

属性值的存储位置

在 Vue2 中,是通过闭包来存储属性值的

// 属性值存储在 val 变量中
const def = (obj, prop, val = obj[prop]) => {
  Object.defineProperty(obj, prop, {
    get() {
      console.log(`${prop}属性被访问`)
      return val
    },
    set(value) {
      console.log(`${prop}属性被修改为${value}`)
      val = value
    },
    enumerable: true,
    configurable: true,
  })
}

而在 Vue3 中,属性值一致存储在原对象中

const hander = {
  get(target, prop) {
    console.log(`${prop}属性被访问`)
    return target[prop]
  },
  set(target, prop, value) {
    console.log(`${prop}属性被修改为${value}`)
    target[prop] = value
    return true
  },
}

拦截多个属性

当使用 Obejct.defineProperty 拦截对象的多个属性时,需要遍历对象的所有属性名,依次设置描述符

而使用 Proxy 拦截对象的多个属性时,拦截器会直接作用于所有属性

Obejct.defineProperty

const def = (obj, prop) => {...}

const observe = (obj) => {
  // 循环所有属性名,Vue2为了兼容 IE,源码中使用的是 for in
  for (const prop of Object.keys(obj)) {
    def(obj, prop)
  }
  return obj
}

const obj = observe({
  a: 1,
  b: 2,
  c: 3,
})

obj.a // a属性被访问
obj.b // b属性被访问

Proxy

const hander = {...}

const reactive = (obj) => {
  return new Proxy(obj, hander)
}

const obj = reactive({
  a: 1,
  b: 2,
  c: 3,
})

obj.a // a属性被访问
obj.b // b属性被访问

多层对象

二者在拦截多层对象的操作,也就是深度监听时,都需要递归地操作对象,区别在于 Vue2 在定义时就要完成所有层属性的拦截,而 Vue3 则是到真正使用时,才生成对应的 Proxy 对象。

Vue2

const def = (obj, prop, val = obj[prop]) => {
  // 值是对象,递归监听
  if (typeof val === 'object') observe(val)

  Object.defineProperty(obj, prop, {
    get() {
      console.log(`${prop}属性被访问`)
      return val
    },
    set(value) {
      console.log(`${prop}属性被修改为${value}`)
      val = value
    },
    enumerable: true,
    configurable: true,
  })
}

const observe = (obj) => {
  for (const prop of Object.keys(obj)) {
    def(obj, prop)
  }
  return obj
}

let obj = observe({
  a: {
    b: 1,
  },
})
obj.a.b
// a属性被访问
// b属性被访问

Vue3

const hander = {
  get(target, key) {
    let val = target[key]
    // 值是对象,则进行包装后返回
    return typeof val === 'object' ? reactive(val) : val
  },
  set(target, key, value) {
    target[key] = value
    return true
  },
}

// 对象与代理的映射表,weakMap是为了不影响垃圾回收
const weakMap = new WeakMap()

const reactive = (obj) => {
  // 已经包装过了,返回现成的代理
  if (weakMap.has(obj)) return weakMap.get(obj)

  const p = new Proxy(obj, hander)
  weakMap.set(obj, p)
  return p
}

const obj = reactive({
  a: {
    b: 1,
  },
})
obj.a.b
// a属性被访问
// b属性被访问

监听数组

Obejct.defineProperty 只能通过设置原型,改写数组方法来实现响应式,较为复杂,且无法实现通过数组索引的访问

// 设置响应式数组的原型
const arrayProto = Object.create(Array.prototype)
// 要被改写的7个数组方法
const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
for (const methodName of methods) {
  // 备份原来的方法
  const original = [][methodName]
  // 定义新的方法
  arrayProto[methodName] = function (...args) {
    // 恢复原来的功能
    const result = original.apply(this, args)
    // 数组可能插入新项,也需要变为observe
    let inserted = []
    switch (methodName) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        // splice格式是splice(下标,数量,插入的新项)
        inserted = args.slice(2)
        break
    }
    // 将新插入的项也变为响应式
    for (const newItem of inserted) {
      observe(newItem)
    }

    console.log(`调用数组的${methodName}方法`)

    return result
  }
}

const def = (obj, prop, val = obj[prop]) => {……}

const observe = (obj) => {
  // 如果是数组,改变原型
  if (Array.isArray(obj)) Object.setPrototypeOf(obj, arrayProto)
  else
    for (const prop of Object.keys(obj)) {
      def(obj, prop)
    }
  return obj
}

const obj = observe({
  a: [0, 1, 2],
})

obj.a.push({ b: 3 })
// a属性被访问
// 调用数组的push方法

obj.a[3].b
// a属性被访问
// b属性被访问

Proxy 能直接拦截对数组的所有操作,包括数字索引的访问

const hander = {……}

// 对象与代理的映射表,weakMap是为了不影响垃圾回收
const weakMap = new WeakMap()

const reactive = (obj) => {……}

const obj = reactive({
  a: [1, 2, 3],
})

obj.a[0] = 2
// a属性被访问
// 0属性被修改为2

obj.a.push(4)
// push属性被访问
// length属性被访问
// 3属性被修改为4
// length属性被修改为4

结语

至此,definePropertyProxy 的区别也就讲完了

如果文中有不理解或不严谨的地方,欢迎评论提问。

如果喜欢或有所帮助,希望能点赞关注,鼓励一下作者。