双向绑定 Proxy 与 Object.defineProperty

939 阅读5分钟

Object.defineProperty

ES5 提供了 Object.defineProperty 方法,该方法可以在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。

语法

Object.defineProperty(obj, prop, descriptor)

举例

const target = {};

Object.defineProperty(target, "num", {
  value: 1,
  configurable: true,
  writable: true,
  enumerable: true,
});

console.log(target); // { num:1 }

属性说明

configurable

当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。 默认为 false。

enumerable

当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。 默认为 false。 数据描述符还具有以下可选键值:

value

该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。 默认为 undefined。

writable 当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。 默认为 false。

get

属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。 默认为 undefined。

set

属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。 默认为 undefined。

描述符

描述符分为数据描述符和存取描述符,这两个只能取其中之一,不能两者存在

描述符默认值

拥有布尔值的键 configurable、enumerable 和 writable 的默认值都是 false。 属性值和函数的键 value、get 和 set 字段的默认值为 undefined。

描述符可拥有的键值

configurableenumerablevaluewritablegetset
数据描述符YESYESYESYESNONO
存取描述符YESYESNONOYESYES

Setters 和 Getters

getset这两个方法又被称为 getter 和 setter。由 getter 和 setter 定义的属性称做“存取器属性”。

当程序查询存取器属性的值时,JavaScript 调用 getter方法。这个方法的返回值就是属性存取表达式的值。当程序设置一个存取器属性的值时,JavaScript 调用 setter 方法,将赋值表达式右侧的值当做参数传入 setter。从某种意义上讲,这个方法负责“设置”属性值。可以忽略 setter 方法的返回值。

封装一个对象监听

function Archiver() {
  let value = null;
  let archive = [];

  Object.defineProperty(this, "num", {
    get: function () {
      console.log("执行了 get 操作");
      return value;
    },
    set: function (value) {
      console.log("执行了 set 操作");
      value = value;
      archive.push({ val: value });
    },
  });

  this.getArchive = function () {
    return archive;
  };
}

var arc = new Archiver();
arc.num; // 执行了 get 操作
arc.num = 11; // 执行了 set 操作
arc.num = 13; // 执行了 set 操作
console.log(arc.getArchive()); // [{ val: 11 }, { val: 13 }]

watch API

基于Object.defineProperty封装的属性监听,正是Vue2.x的双向绑定实现的原理

HTML 中有个 span 标签和 button 标签,实现点击button后,span的内容+1

<span id="container">1</span>
<button id="button">点击加 1</button>

传统的DOM操作方法

document.getElementById('button').addEventListener("click", function(){
    var container = document.getElementById("container");
    container.innerHTML = Number(container.innerHTML) + 1;
});

使用Object.defineProperty

使用Object.defineProperty监听的好处就是直接修改obj.value即可,相当于obj.value绑定在了DOM渲染层

const obj = { value: 1 };

let value = 1;

Object.defineProperty(obj, "value", {
  get() {
    return value;
  },
  set(newValue) {
    value = newValue;
    document.getElementById("container").innerHTML = newValue;
  },
});

document.getElementById('button').addEventListener("click", function() {
    obj.value += 1;
});

上面代码需要额外声明value,如果要监控很多个属性,那就要写一大堆额外的变量,可以封装一个watch函数,达到类似如下的调用

var obj = {
    value: 1
}

watch(obj, "value", function(newvalue){
    document.getElementById('container').innerHTML = newvalue;
})

document.getElementById('button').addEventListener("click", function(){
    obj.value += 1
});

watch函数

function watch(obj, name, func) {
  let value = obj[name];

  Object.defineProperty(obj, name, {
    get() {
      return value;
    },

    set(newValue) {
      value = newValue;
      func(newValue);
    },
  });
}

Proxy

Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)

使用 defineProperty 只能重定义属性的读取(get)和设置(set)行为,到了 ES6,提供了 Proxy,可以重定义更多的行为,比如 in、delete、函数调用等更多行为。

Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,ES6 原生提供 Proxy 构造函数,用来生成Proxy实例

语法

const p = new Proxy(target, handler)

target

要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。

handler

一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

proxy 对象的所有用法,都是上面这种形式,不同的只是handler参数的写法。其中,new Proxy()表示生成一个Proxy实例,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。

const proxyObj = new Proxy(
  {},
  {
    get(obj, prop) {
      return obj[prop];
    },
    set(obj, prop, value) {
      obj[prop] = value;
    },
  }
);

proxyObj.num = 2 // set操作

proxyObj.num // get 2

proxy除了对get和set的拦截以外,还有大量可拦截的方法,比如Reflect.has(等同于in),Reflect.ownKeys(类似Object.keys,Reflect.ownKeys不受到enumerable限制,Object.keys会受到enumerable限制)

const proxyObj = new Proxy(
  {},
  {
    ownKeys(target) {
      return [];
    },
    has(target, key) {
      if (key[0] === "_") {
        return false;
      }
      return Reflect.has(target, key);
    },
  }
);

Reflect.ownKeys(proxyObj); // [];

proxyObj.abc = 1;
proxyObj._abc = 2;

Reflect.has(proxyObj, "abc"); // true

Reflect.has(proxyObj, "_abc"); // false

通过Proxy重写watch

function watch(target, func) {
  const proxy = new Proxy(target, {
    get(obj, prop) {
      return obj[prop];
    },
    set(obj, prop, value) {
      target[prop] = value;
      func(prop, value);
    },
  });

  return proxy;
}

watch(obj, (key, value) => {
  if (key === "value") {
    document.getElementById("container").innerHTML = value;
  }
});

document.getElementById("button").addEventListener("click", function () {
  newObj.value += 1;
});

基于双向绑定的优劣比较

  • Object.definedProperty作用是劫持一个对象的属性,劫持属性的getter和setter方法,在对象的属性发生变化时进行特定的操作。而 Proxy 劫持的是整个对象。
  • Proxy 会返回一个代理对象,我们只需要操作新对象即可,而 Object.defineProperty只能遍历对象属性直接修改。
  • Object.definedProperty不支持数组,更准确的说是不支持数组的各种API,因为如果仅仅考虑arry[i] = value 这种情况,是可以劫持的,但是这种劫持意义不大。而Proxy 可以支持数组的各种API。
  • 尽管 Object.defineProperty 有诸多缺陷,但是其兼容性要好于 Proxy.
  • PS: Vue2.x 使用 Object.defineProperty 实现数据双向绑定,V3.0 则使用了 Proxy