Vue2.0是如何通过 Object.defineProperty()实现数据双向绑定?

335 阅读7分钟

我们都知道vue2.0通过Object.defineProperty来实现对属性的getter和setter劫持,达到监听数据变动的目的。这也是我们理解vue实现数据双向绑定必不可少的一个知识点,可以说是前端面试必问,所以Object.defineProperty成了本文的主角。

背景: 在 ES5 之后,Object新增defineProperty方法,它会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象,对于定义的这个对象有两种描述它的状态,一种称之为数据描述符,一种被称为存取描述符,

1、 Object.defineProperty是干什么的?

数据劫持: 当访问或者设置对象的属性的时候,触发相应的函数,并且返回设置属性的值。

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

例如:

        let Person = {
            name: 'jack',
        }
        console.log(Person) {name: "jack"}
        
        Person.name = 'lusi' //修改Person对象name属性的值
        console.log(Person) //{name: "lusi"}
        
        Object.defineProperty(Person,'name',{value: 'xdong'}) //修改Person对象name属性的值
        console.log(Person) //{name: "xdong"}
        
        Person.age=18  //给Person对象添加新属性age
        console.log(Person) //{name: "xdong", age: 18}
        
        Object.defineProperty(Person,'sex',{value: "男"}) //给Person对象添加新属性sex
        console.log(Person) //{name: "xdong", age: 18,sex: "男"}

2、 Object.defineProperty怎么用?

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

参数说明:
obj 要定义属性的对象。
prop 要定义或修改的属性的名称或 [Symbol] --- 【参数prop类型:String】
descriptor 属性描述 --- 添加或要修改的这个属性有什么样的特性【类型:Object】

对象里目前存在的属性描述符有两种主要形式:*数据描述符**存取描述符*数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者

2.1 数据描述符可选键值: value

  • 该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。
    默认为 undefined 给Person对象添加新属性age
      let Person = {
          name: 'jack',
      }
      console.log(Person) {name: "jack"}
      
      Object.defineProperty(Person,'age',{value: 18}) //给Person对象添加新属性age
      console.log(Person) //{name: "jack", age: 18}

2.2 数据描述符可选键值: writable

  • 当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被[赋值运算符 (en-US)]改变。
    默认为 false修改Person对象的属性name
let Person = {}
console.log(Person) //{name: "jack"}
        
Object.defineProperty(Person,'name',{value: "xdong"}) 
console.log(Person) //{name: "xdong"}
        
Person.name = "lusi"
console.log(Person)//{name: "xdong"}

从上面例子可以看出 Person.name = "lusi"并没有修改成功,为什么呢?

原因:因为descriptor有很多属性,除了value属性还有个 writable【属性是否可以被重新赋值】,接受数据类型为 boolean(默认为false) ,true => 支持被重新赋值 false=>只读。没写默认writable为false

再看个有意思的例子

let Person = {
    name: 'jack',
}
console.log(Person) //{name: "jack"}
        
Object.defineProperty(Person,'name',{value: "xdong"}) //修改Person对象的属性name
console.log(Person) //{name: "xdong"}
        
Person.name = "lusi"
console.log(Person)//{name: "lusi"}

咦,不是说没写默认writable键值为false?怎么这里Person.name = "lusi"修改成功了? 原因是这里Person对象的属性name不是通过Object.defineProperty添加的,而是在Person对象中直接声明的。

小结:只有通过Object.defineProperty添加的对象属性,它的writable默认才为false。在Person对象中直接声明的属性,该属性的writable键值为true。

不信你看下面这个例子,通过Object.defineProperty将属性name的writable设为false后,Person.name 就不能修改了。

let Person = {
    name: 'jack',
}
console.log(Person) //{name: "jack"}
        
Object.defineProperty(Person,'name',{
    value: "xdong"writable: false
}) //修改Person对象的属性name
console.log(Person) //{name: "xdong"}
        
Person.name = "lusi"
console.log(Person)//{name: "xdong"}

2.3 存取描述符可选键值: getset

说明 注意:当使用了getter或setter方法,不允许使用writable和value这两个属性(如果使用,会直接报错滴)

get 是获取值的时候的方法,类型为 function ,获取值的时候会被调用,不设置时为 undefined
set 是设置值的时候的方法,类型为 function ,设置值的时候会被调用,undefined

例:

let Person = {
    name: 'jack'
}
let i = 18
Object.defineProperty(Person,'age',{
   get: function(){
        return i
   },
   set: function(){
       i++
   }
}) 
console.log(Person.age) //18

Person.age = 30
console.log(Person.age) //19

千万不能这么写

let Person = {
    name: 'jack',
    age: 18,
}

Object.defineProperty(Person,'age',{
   get: function(){
        return this.age
   },
   set: function(){
       this.age++
   }
}) 
console.log(Person.age) 

Person.age = 30
console.log(Person.age)

直接上错误截图: image.png

错误解析:

  • 以上错误的意思是 "超出最大调用堆栈大小"
  • 出现这种错误最常见的原因是:在代码中的某个地方,您正在调用一个函数,该函数又调用另一个函数,依此类推,直到达到调用堆栈限制。这几乎总是因为具有未满足的基本情况的递归函数。相当于this.age 自己调用自己,造成无限循环。

2.4 数据描述符和存取描述符都可选键值: configurable

configurable有两个作用:

1 属性是否可以被删除
2 属性的特性在第一次设置之后可否被重新定义特性

let Person ={
 name:"狂奔的蜗牛",
 age:18
} ;
//定义一个性别 可以被删除和重新定义特性
Object.defineProperty(Person,"gender",{
 value:"男",
 configurable:true
})

//删除前
console.log(Person); // {name: "xdong", age: 18, gender: "男"}

//删除一下
delete Person.gender;
console.log(Person); // {name: "xdong", age: 18}

//重新定义特性
Object.defineProperty(Person,"gender",{
 value:"男",
 configurable:false
})

//删除前
console.log(Person); // {name: "xdong", age: 18, gender: "男"}

//删除一下 删除失败
delete Person.gender;
console.log(Person); // {name: "xdong", age: 18, gender: "男"}

小结:configurable设置为 true 则该属性可以被删除和重新定义特性;反之属性是不可以被删除和重新定义特性的,默认值为false(Ps.除了可以给新定义的属性设置特性,也可以给已有的属性设置特性哈

2.5 数据描述符和存取描述符都可选键值: enumerable

想知道这个 user对象有哪些属性我们一般会这么做

var Person ={
   name: "xdong",
   age: 18
}

//es6
var keys = Object.keys(Person)
console.log(keys);// ['name','age']

//es5
var keys=[];
for(key in Person){
 keys.push(key);
}
console.log(keys);// ['name','age']

如果我们使用 Object.defineProperty的方式定义属性会发生什么呢?我们来看下输出

var Person ={
   name: "xdong",
   age: 18
}

//定义一个性别 可以被枚举
Object.defineProperty(Person,"gender",{
 value:"男",
 enumerable:true
})

//定义一个出生日期 不可以被枚举
Object.defineProperty(Person,"birth",{
 value:"2000-05-26",
 enumerable:false
})

//es6
var keys=Object.keys(Person)
console.log(keys);
// ["name", "age", "gender"]

console.log(Person);
// {name: "xdong", age: 18, gender: "男", birth: "2000-05-26"}
console.log(user.birth);
// 1956-05-03

很明显,我们定义为 enumerable=falsebirth属性并没有被遍历出来

小结:enumerable 属性取值为 布尔类型 true | false 默认值为 false,为真属性可以被枚举;反之则不能。此设置不影响属性的调用和 查看对象的值。

3、 Object.defineProperty可以用来干嘛?

3.1 简单实现数据双向绑定

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div>
        <p>你好,<span id='nickName'></span></p>
        <div id="introduce"></div>
    </div>  

    <script>
        //视图控制器,设置一个数据的属性的getter和setter
        var userInfo = {};
        Object.defineProperty(userInfo, "nickName", {
            get: function(){
                return document.getElementById('nickName').innerHTML;
            },
            set: function(nick){
                document.getElementById('nickName').innerHTML = nick;
            }
        });
        Object.defineProperty(userInfo, "introduce", {
            get: function(){
                return document.getElementById('introduce').innerHTML;
            },
            set: function(introduce){
                document.getElementById('introduce').innerHTML = introduce;
            }
        })
        
        //然后就能愉快地绑定数据交互了。
        userInfo.nickName = "china";
        userInfo.introduce = "我是xdong,我来自重庆,..."
    </script>
</body>
</html>

但是,上面这个例子只是数据和dom节点的绑定,而vue.js更为复杂一点,它在网页dom和accessor之间会有两层,一层是Wacher,一层是Directive,比如以下代码。

var a = { b: 1 }
var vm = new Vue({ 
  data: data
})

把一个普通对象(a={b:1})传给 Vue 实例作为它的 data 选项,Vue.js 将遍历它的属性,用Object.defineProperty 将它们转为 getter/setter,如图绿色的部分所示。
每次用户更改data里的数据的时候,比如a.b =1,setter就会重新通知Watcher进行变动,Watcher再通知Directive对dom节点进行更改。\

image.png 官网图片

3.2 解决JS奇葩问题:如何让 (a==1 && a==2 && a==3)返回true

当我们访问一个被设置了存取描述符的元素时,如果在get方法里面做一些操作,就能巧妙的使得最终的结果达到预期:

var i = 1
Object.defineProperty(window'a', {
  get() { return i++ }
})

if (a === 1 && a === 2 && a === 3) {
  console.log('Hello World!');
}

这种劫持gettersetter的方法本质上是执行了一个函数,内部除了用自增变量,如果我们想得到其他结果,只需在getter函数内部返回我们需要的结果即可。

这道题的其他解法 请看这里

本文参考链接
vue.js关于Object.defineProperty的利用原理
js中Object.defineProperty()方法的解释
JS 奇葩知识点,如何让 (a == 1 && a == 2 && a == 3) 返回 true