Defineproperty 使用

Posted by Youzi Blog on December 13, 2019

Object.defineProperty在 Vue2.0 的应用

在 ES6 之前,没有Proxy类用于代理劫持对象,通常会通过Object.defineProperty来实现对象属性的拦截,在 Vue2.0 版本中,使用这一属性实现了双向数据绑定,即数据的变化会引起 vDOM 的更新,从而更新实际 DOM。

基础语法

我们知道一个对象的属性,具有如下六个描述:

  • value:属性值,不允许和get共用;
  • writable:可写性,为false时属性不可写,不允许和set共用;
  • configurable:为false时,属性不可被删除,属性的描述也不能被修改;
  • enumerable:为false时,属性不可枚举,即遍历对象时,使用的for in | Object.keys | Object.assign | JSON.stringify | Object.keys,会忽略enumerablefalse的属性;
  • get:属性访问器函数,可以利用这个函数来劫持对象属性的访问;
  • set:属性设置器函数,可以劫持对象属性的设值;

实现简单的 DOM 和数据双向绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div>
  <input type="number" id="nameInput" />
</div>
<script>
  let nameInput = document.querySelector('#nameInput');
  let tmpObj = {};
  Object.defineProperty(tmpObj, 'name', {
    get: () => {
      return nameInput.value;
    },
    set: val => {
      nameInput.value = parseFloat(val) + 1;
    }
  });
  const onNameInput = e => {
    tmpObj.name = parseFloat(e.target.value) + 1;
    console.log(tmpObje.name);
  };
  nameInput.addEventListener('input', onNameInput);
</script>

可以看到代码中设置了一个对象,类似Vue中的data属性,其中name是作为绑定DOM的值,在更新name的同时也会对输入框的值进行更新,在更新输入框的值时也会对name进行更新;

实现对数组的监听

我们知道,在一般情况下除非整体替换整个数组,否则单独更新数组元素是不会触发数组的set方法的,来看下面的例子;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let obj = { array: [] };
let initialVal = [];
Object.defineProperty(obj, 'array', {
  set: val => {
    console.log('in setter: ' + val);
    initialVal = val;
  },
  get: () => {
    return initialVal;
  }
});
obj.array[0] = 10; // 10
obj.array.push(20); // 4
obj.array = [1, 2, 3]; // "in setter [1, 2, 3]" [1, 2, 3]

可以看到给单独给数组元素赋值或者调用数组方法push,不会触发set,而整体替换时可以触发;

其实Vue可以对数组实现监听,这是因为源码对数组的一部分原型方法进行了重写,这部分感觉对我来说有点复杂了,下面是一个 demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var arrayProto = Array.prototype;

var arrayMethods = Object.create(arrayProto);

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) {
  // 使用 Object.defineProperty 进行监听
  Object.defineProperty(arrayMethods, method, {
    value: function testValue() {
      console.log('数组被访问到了');
      const original = arrayProto[method];
      // 使类数组变成一个真正的数组
      const args = Array.from(arguments);
      original.apply(this, args);
    }
  });
});

代码中重写了部分方法,直接改写了value,不改变这些方法的实现(original.apply(this, args)直接使原生方法调用结果不变),但可以在调用这些方法时加入我们需要的其他代码,比如添加订阅者响应等,这里的订阅者就是console.log('数组被访问到了')

而对每个数组元素,也需要对它们改写get | set函数,使得访问数组元素时也能添加我们自己的代码,从而实现双向绑定;

来看一个 demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 为了不影响Array的原生方法,我们应该新生成Array原型的子类arrayMethods,继承自Array.prototype,这样我们去覆盖子类的部分方法时,就不会影响到其他普通数组去调用这部分方法。
let arrayProto = Array.prototype;
let arrayMethods = Object.create(arrayProto);

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
  // 对这些方法进行改写,添加自己的代码,并使用apply让方法正常运行
  Object.defineProperty(arrayMethods, method, {
    value(...args) {
      console.log('数组被访问到了', method, args);
      const original = arrayProto[method];
      // 此时this指向调用时的实例
      original.apply(this, [...args]);
    }
  });
});
// 定义观察者类,在初始化时调用一次
class Observer {
  constructor(data) {
    this.data = data;
    traversal(data);
  }
}
// 添加拦截器,这里把对象和数组都作为可以添加拦截器的对象,所以针对数组元素也会受到影响,最直接的就是单独访问数组元素时也能触发拦截器
const defAry = (key, val, o) => {
  // debugger;
  Object.defineProperty(o, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log(key, 'getters', val);
      return val;
    },
    set(newVal) {
      console.log(key, 'setters', newVal);
      val = newVal;
    }
  });
};
// 遍历函数,用于遍历对象的属性,如果是嵌套对象也会进行递归调用
const traversal = o => {
  Object.entries(o).forEach(val => {
    let [key, value] = [...val];
    if (typeof value === 'object') {
      if (Array.isArray(value)) {
        // 这一步是将当前数组的__proto__指向子类arrayMethods,从而可以访问被覆盖的数组方法,比如push等
        value.__proto__ = arrayMethods;
        value.forEach((el, index) => {
          debugger;
          defAry(index, el, value);
        });
      } else {
        // 如果是对象的话,递归调用
        traversal(value);
      }
    }
    // 对对象的每个属性都要执行添加拦截器操作
    defAry(key, value, o);
  });
};
// 例子
let data = {
  a: 10,
  b: [1, 2, 3]
};
let observer = new Observer(data);
data.b[1]; // 1 getters 2
data.push(4); // 数组被访问到了 push 4

那么其实在 Vue 源码中有更完整的实现过程,主要是 2.0 的版本,详见 Vue 源码分析从源码角度再看数据绑定