Object.defineProperty实现数据双向绑定

1,623 阅读6分钟

1 前言

作为一名前端开发人员,我们或许都听说过Vue 2.0中实现双向数据绑定采用了Object.defineProperty,我相信有很多小伙伴们和我一样有疑问,这个神奇的东西是怎么做到的呢?在介绍Object.defineProperty之前,我们先来认识一下什么是属性类型。

2 属性类型

ECMA-262描述道:

ECMA-262 第5 版在定义只有内部才用的特性(attribute)时,描述了属性(property)的各种特征。 ECMA-262 定义这些特性是为了实现JavaScript 引擎用的,因此在JavaScript 中不能直接访问它们。

也就是说,属性类型是属性内部的特性,不能直接访问,但是我们可以一些手段操作属性类型来改变他们的属性特征,呈现出一个定制化的对象。属性类型只有两种:数据属性和访问器属性,下面我们来具体看看它们的用法和区别吧。

2.1 数据属性

数据属性包含一个数据值的位置。在这个位置可以读取和写入值

数据属性有四个描述其行为的特性,放在了两对方括号中,表示是不可直接访问的。

  • [[Configurable]]:可以通过delete删除的属性,默认为true
  • [[Enumerable]]:可枚举的属性,也就是可以通过for-in循环遍历出来的属性,默认true
  • [[Writable]]:可写的属性,也就是可以修改属性,默认true
  • [[Value]]:属性的值。默认为undefined

那怎么来验证呢,一般来说我们创建对象是没法看到这些属性的,这个时候我们的主角Object.defineProperty就可以上场了,它是ES5中提供的一个很强大的方法,下面是MDN的描述:

Object.defineProperty方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

方法接受三个参数:属性所在的对象、属性名和一个描述符对象(descriptor)。

下面是一个简单的例子:

var obj = {}

Object.defineProperty(obj, 'name', {
  configurable: false,
  writable: true,
  enumerable: true,
  value: 'Tom'
})

delete obj.name // false, 查找 configurable
console.log(obj.name) // Tome, 查找 value

obj.name = 'Amy' // 查找 writable
console.log(obj.name) // Amy

for (var key in obj) {
  console.log(key + '================' + obj[key]) // name================Amy, 查找enumerable
}

注意:

  1. IE8最先实现这个方法,但是只能在DOM对象使用,有局限性,不推荐在IE8使用。
  2. 在调用 Object.defineProperty()方法时,如果不指定,configurableenumerablewritable特性的默认值都是 false 。
  3. 在严格模式下configurable一旦被指定为false,调用Object.defineProperty() 方法修改除 writable之外的特性,都会导致错误。

2.2 访问器属性

访问器属性不包含数据值;它们包含一对gettersetter 函数(不过,这两个函数都不是必需的,除非是严格模式)。在读取访问器属性时,会调用getter 函数,这个函数负责返回有效的值;在写入访问器属性时,会调用setter 函数并传入新值,这个函数负责决定如何处理数据。

访问器属性也有4个特性:

  • [[Configurable]]:可以通过delete删除的属性,默认为true
  • [[Enumerable]]:可枚举的属性,也就是可以通过for-in循环遍历出来的属性,默认true
  • [[Set]]:写入属性调用的函数,默认undefined
  • [[Get]]:读取属性调用的函数,默认为undefined

对于数据属性,访问器属性没有了writablevalue,相反被替换成了setget,虽然都有获取和读取数据的作用,但后者把读取数据和写入数据的放到了一个方法内,这样可以定制化数据读取和写入的行为了,这也是实现数据劫持一个很重要的特征。

var obj = {
  _name: 'Tom'
}

Object.defineProperty(obj, 'name', {
  get () {
    return 'I am ' + this._name;
  },
  set (newValue) {
    this._name = newValue;
  }
});

console.log(obj.name); // I am Tom, 调用get方法
obj.name = 'Amy' // 调用set方法
console.log(obj.name); // I am Amy

注意:

  1. 严格模式下,setget缺一不可,否则会报错
  2. 在不支持setget的浏览器中(比如IE8)可以使用非标准的__defineGetter__or__defineSetter__来替代。

2.3 定义多个属性

当我们需要定制一个对象中多个属性的行为的时候,Object.defineProperty的孪生兄弟Object.defineProperties就派上用场了,用法很简单:

var obj = {}

Object.defineProperties(obj, {
  _name: {
    value: 'Tom',
    writable: true
  },
  age: {
    value: 18,
    enumerable: false,
  },
  name: {
    get() {
      return 'I am ' + this._name
    },
    set(newValue) {
      this._name = newValue
    },
  },
})	

2.4 获取某个属性的描述符

我们如果想要查看对象某个属性的特性描述,那么使用Object.getOwnPropertyDescriptor方法,可以取得给定属性的描述符。

这个方法接收两个参数:属性所在的对象和要读取其描述符的属性名称。返回值是一个对象,如果是访问器属性,这个对象的属性有configurable、enumerable、get 和set;如果是数据属性,这个对象的属性有configurable、enumerable、writable 和value。

同样地,它也有一个孪生兄弟Object.getOwnPropertyDescriptors可以某个对象所有属性的描述符。下面是一个例子:

// 获取某个属性的描述符
var result1 = Object.getOwnPropertyDescriptor(obj, 'name')
// 获取所有属性的描述符
var result1 = Object.getOwnPropertyDescriptors(obj)

3 实战:实现简单的Vue双向数据绑定

有了前面知识的铺垫,下面我们来利用Object.defineProperty实现简单的Vue双向数据绑定吧。

首先我们定义一个DOM模板:

  <div id="app">
    <input type="text" v-model="username"/>
    <p>{{ username }}</p>
  </div>

然后我们按照Vue里面的结构来写JS代码:

let el = document.getElementById('app') // 模拟el
let data = { _username: 'Tom' }; // 模拟data对象
let template = el.innerHTML // 获取el里面的字符串模板,方便以后替换

接下来我们需要一个render函数来渲染模板:

function render() {
      // 初始渲染先替换DOM里面的模板
      el.innerHTML = template.replace(/{{\s+[\w.]+\s+}}/g, (str) => {
        str = str.slice(2, str.length - 2).trim()
        return data[str]
      })
      //双向绑定
      // 获取所有带`v-model`属性的input元素
      Array.from(el.getElementsByTagName('input'))
        .filter(element => element.getAttribute('v-model'))
        .forEach(input => {
        // 为每个input元素进行双向绑定
          let bindData = input.getAttribute('v-model');
          // 监听input时间,动态把input数据传入data
          input.addEventListener('input', function () {
            data[bindData] = this.value;
          }, false)
          // 将data的数据绑定到DOM
          input.value = data[bindData];
        });
    }

有了render函数就很好说了,接下来就是使用Object.defineProperty来定义数据的行为了:

  Object.defineProperty(data, 'username', {
    enumerable: true,
    configurable: true,
    get() {
      return this._username;
    },
    set(value) {
      this._username = value;
      render()
    }
  });

最后实现的效果如下:

在这里插入图片描述

4 总结

这篇文章前面讲述了数据属性的种类和区别,以及它们的用法。后面利用访问器属性的特性来实现了一个简单版的Vue双向绑定的效果。虽然我们平时很少用这个属性,但是对于我们来理解这门语言有很大的帮助。最后,这篇文章难免会有一些错误,欢迎大家指正和批评。

5 参考

【1】《JavaScript高级程序设计》(第3版)