前言

我们或多或少都听过“数据绑定”这个词,“数据绑定”的关键在于监听数据的变化,可是对于这样一个对象:var obj = {value: 1},我们该怎么知道 obj 发生了改变呢?

definePropety

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

语法

Object.defineProperty(obj, prop, descriptor)

参数

  1. obj: 要在其上定义属性的对象。
  2. prop: 要定义或修改的属性的名称。
  3. descriptor: 将被定义或修改的属性的描述符。

举个例子:

  1. var obj = {};
  2. Object.defineProperty(obj, "num", {
  3. value : 1,
  4. writable : true,
  5. enumerable : true,
  6. configurable : true
  7. });
  8. // 对象 obj 拥有属性 num,值为 1

虽然我们可以直接添加属性和值,但是使用这种方式,我们能进行更多的配置。

函数的第三个参数 descriptor 所表示的属性描述符有两种形式:数据描述符和存取描述符

两者均具有以下两种键值

configurable

  1. 当且仅当该属性的 configurable true 时,该属性描述符才能够被改变,也能够被删除。默认为 false

enumerable

  1. 当且仅当该属性的 enumerable true 时,该属性才能够出现在对象的枚举属性中。默认为 false

数据描述符同时具有以下可选键值

value

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

writable

  1. 当且仅当该属性的 writable true 时,该属性才能被赋值运算符改变。默认为 false

存取描述符同时具有以下可选键值

get

  1. 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为 undefined

set

  1. 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为 undefined

值得注意的是:

属性描述符必须是数据描述符或者存取描述符两种形式之一,不能同时是两者 。这就意味着你可以:

  1. Object.defineProperty({}, "num", {
  2. value: 1,
  3. writable: true,
  4. enumerable: true,
  5. configurable: true
  6. });

也可以:

  1. var value = 1;
  2. Object.defineProperty({}, "num", {
  3. get : function(){
  4. return value;
  5. },
  6. set : function(newValue){
  7. value = newValue;
  8. },
  9. enumerable : true,
  10. configurable : true
  11. });

但是不可以:

  1. // 报错
  2. Object.defineProperty({}, "num", {
  3. value: 1,
  4. get: function() {
  5. return 1;
  6. }
  7. });

此外,所有的属性描述符都是非必须的,但是 descriptor 这个字段是必须的,如果不进行任何配置,你可以这样:

  1. var obj = Object.defineProperty({}, "num", {});
  2. console.log(obj.num); // undefined

Setters 和 Getters

之所以讲到 defineProperty,是因为我们要使用存取描述符中的 get 和 set,这两个方法又被称为 getter 和 setter。由 getter 和 setter 定义的属性称做”存取器属性“。

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

举个例子:

  1. var obj = {}, value = null;
  2. Object.defineProperty(obj, "num", {
  3. get: function(){
  4. console.log('执行了 get 操作')
  5. return value;
  6. },
  7. set: function(newValue) {
  8. console.log('执行了 set 操作')
  9. value = newValue;
  10. }
  11. })
  12.  
  13. obj.num = 1 // 执行了 set 操作
  14.  
  15. console.log(obj.num); // 执行了 get 操作 // 1

这不就是我们要的监控数据改变的方法吗?我们再来封装一下:

  1. function Archiver() {
  2. var value = null;
  3. // archive n. 档案
  4. var archive = [];
  5.  
  6. Object.defineProperty(this, 'num', {
  7. get: function() {
  8. console.log('执行了 get 操作')
  9. return value;
  10. },
  11. set: function(value) {
  12. console.log('执行了 set 操作')
  13. value = value;
  14. archive.push({ val: value });
  15. }
  16. });
  17.  
  18. this.getArchive = function() { return archive; };
  19. }
  20.  
  21. var arc = new Archiver();
  22. arc.num; // 执行了 get 操作
  23. arc.num = 11; // 执行了 set 操作
  24. arc.num = 13; // 执行了 set 操作
  25. console.log(arc.getArchive()); // [{ val: 11 }, { val: 13 }]

watch API

既然可以监控数据的改变,那我可以这样设想,即当数据改变的时候,自动进行渲染工作。举个例子:

HTML 中有个 span 标签和 button 标签

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

当点击按钮的时候,span 标签里的值加 1。

传统的做法是:

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

如果使用了 defineProperty:

  1. var obj = {
  2. value: 1
  3. }
  4.  
  5. // 储存 obj.value 的值
  6. var value = 1;
  7.  
  8. Object.defineProperty(obj, "value", {
  9. get: function() {
  10. return value;
  11. },
  12. set: function(newValue) {
  13. value = newValue;
  14. document.getElementById('container').innerHTML = newValue;
  15. }
  16. });
  17.  
  18. document.getElementById('button').addEventListener("click", function() {
  19. obj.value += 1;
  20. });

代码看似增多了,但是当我们需要改变 span 标签里的值的时候,直接修改 obj.value 的值就可以了。

然而,现在的写法,我们还需要单独声明一个变量存储 obj.value 的值,因为如果你在 set 中直接 obj.value = newValue 就会陷入无限的循环中。此外,我们可能需要监控很多属性值的改变,要是一个一个写,也很累呐,所以我们简单写个 watch 函数。使用效果如下:

  1. var obj = {
  2. value: 1
  3. }
  4.  
  5. watch(obj, "value", function(newvalue){
  6. document.getElementById('container').innerHTML = newvalue;
  7. })
  8.  
  9. document.getElementById('button').addEventListener("click", function(){
  10. obj.value += 1
  11. });

我们来写下这个 watch 函数:

  1. (function(){
  2. var root = this;
  3. function watch(obj, name, func){
  4. var value = obj[name];
  5.  
  6. Object.defineProperty(obj, name, {
  7. get: function() {
  8. return value;
  9. },
  10. set: function(newValue) {
  11. value = newValue;
  12. func(value)
  13. }
  14. });
  15.  
  16. if (value) obj[name] = value
  17. }
  18.  
  19. this.watch = watch;
  20. })()

现在我们已经可以监控对象属性值的改变,并且可以根据属性值的改变,添加回调函数,棒棒哒~

proxy

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

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

  1. var proxy = new Proxy(target, handler);

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

  1. var proxy = new Proxy({}, {
  2. get: function(obj, prop) {
  3. console.log('设置 get 操作')
  4. return obj[prop];
  5. },
  6. set: function(obj, prop, value) {
  7. console.log('设置 set 操作')
  8. obj[prop] = value;
  9. }
  10. });
  11.  
  12. proxy.time = 35; // 设置 set 操作
  13.  
  14. console.log(proxy.time); // 设置 get 操作 // 35

除了 get 和 set 之外,proxy 可以拦截多达 13 种操作,比如 has(target, propKey),可以拦截 propKey in proxy 的操作,返回一个布尔值。

  1. // 使用 has 方法隐藏某些属性,不被 in 运算符发现
  2. var handler = {
  3. has (target, key) {
  4. if (key[0] === '_') {
  5. return false;
  6. }
  7. return key in target;
  8. }
  9. };
  10. var target = { _prop: 'foo', prop: 'foo' };
  11. var proxy = new Proxy(target, handler);
  12. console.log('_prop' in proxy); // false

又比如说 apply 方法拦截函数的调用、call 和 apply 操作。

apply 方法可以接受三个参数,分别是目标对象、目标对象的上下文对象(this)和目标对象的参数数组,不过这里我们简单演示一下:

  1. var target = function () { return 'I am the target'; };
  2. var handler = {
  3. apply: function () {
  4. return 'I am the proxy';
  5. }
  6. };
  7.  
  8. var p = new Proxy(target, handler);
  9.  
  10. p();
  11. // "I am the proxy"

又比如说 ownKeys 方法可以拦截对象自身属性的读取操作。具体来说,拦截以下操作:

  • Object.getOwnPropertyNames()
  • Object.getOwnPropertySymbols()
  • Object.keys()
    下面的例子是拦截第一个字符为下划线的属性名,不让它被 for of 遍历到。
  1. let target = {
  2. _bar: 'foo',
  3. _prop: 'bar',
  4. prop: 'baz'
  5. };
  6.  
  7. let handler = {
  8. ownKeys (target) {
  9. return Reflect.ownKeys(target).filter(key => key[0] !== '_');
  10. }
  11. };
  12.  
  13. let proxy = new Proxy(target, handler);
  14. for (let key of Object.keys(proxy)) {
  15. console.log(target[key]);
  16. }
  17. // "baz"

更多的拦截行为可以查看阮一峰老师的 《ECMAScript 6 入门》

值得注意的是,proxy 的最大问题在于浏览器支持度不够,而且很多效果无法使用 poilyfill 来弥补。

watch API 优化

我们使用 proxy 再来写一下 watch 函数。使用效果如下:

  1. (function() {
  2. var root = this;
  3.  
  4. function watch(target, func) {
  5.  
  6. var proxy = new Proxy(target, {
  7. get: function(target, prop) {
  8. return target[prop];
  9. },
  10. set: function(target, prop, value) {
  11. target[prop] = value;
  12. func(prop, value);
  13. }
  14. });
  15.  
  16. return proxy;
  17. }
  18.  
  19. this.watch = watch;
  20. })()
  21.  
  22. var obj = {
  23. value: 1
  24. }
  25.  
  26. var newObj = watch(obj, function(key, newvalue) {
  27. if (key == 'value') document.getElementById('container').innerHTML = newvalue;
  28. })
  29.  
  30. document.getElementById('button').addEventListener("click", function() {
  31. newObj.value += 1
  32. });

我们也可以发现,使用 defineProperty 和 proxy 的区别,当使用 defineProperty,我们修改原来的 obj 对象就可以触发拦截,而使用 proxy,就必须修改代理对象,即 Proxy 的实例才可以触发拦截。

ES6 系列

ES6 系列目录地址:https://github.com/mqyqingfeng/Blog

ES6 系列预计写二十篇左右,旨在加深 ES6 部分知识点的理解,重点讲解块级作用域、标签模板、箭头函数、Symbol、Set、Map 以及 Promise 的模拟实现、模块加载方案、异步处理等内容。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。