JavaScript系列之深入理解对象属性

《谈谈JavaScript中对象建立(Object)》这一文中,我们曾经简单地介绍过对象及其创建方式。在今天这一篇文章当中呢,我们要更深入地来理解JavaScript的对象与其他编程语言的对象有何差异。

开始介绍之前先来复习一下。

之前说过,所有原始类型(Primitives) 以外的值都是对象,原始类型有以下几种:

  • string
  • number
  • boolean
  • null
  • undefined
  • symbol (ES6 新增)

除了上述这些以外的类型,都是对象。

JavaScript 真是一门面向对象的编程语言吗?

在过去JS语言的发展中,这个话题已经被讨论过无数次,有人说它是,也有人说它不那么像是。就像一个使用Java或C#或者其他面向对象开发语言的开发者接触JavaScript的时候,他总会抱怨JavaScript太混乱、没有类型、结构也不好,还有很多奇奇怪怪的地方,它的对象支持也是微乎其微,因此他绝对不是一个面向对象编程的语言。

但JavaScript 确实是一门面向对象的编程语言,只是它与其他语言很大不同的地方是,它的继承方法是通过”prototype” 来实现的。其余大多数的面向对象的编程语言(比如Java)是以「类」为基础的(class-based) ,但JavaScript 没有class、没有extends ,却可以通过「原型」(prototype-based) 来建立起对象之间的继承关系。

PS:ES6虽然新增了class语法,但仍然属于prototype-based的继承。class实质上只是通过简洁的语法来建立对象和处理继承的语法糖。

JavaScript 自定义对象

先前曾介绍过,在JavaScript创建对象我们可以通过new关键字:

1
2
var person = new Object();
person.name = 'mike';

或是直接用大括号{ },即可创建起一个新的对象:

1
2
3
var person = {
name: 'mike'
};

理解JavaScript构造函数

虽然JavaScript没有class的语法,但如果你希望JavaScript也能像其他面向对象编程语言一样有类似class的语法时,可以怎么做呢?由于函数也是个对象,所以可以借用来当作「构造函数」来建立其他对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person( name, age, gender ){
this.name = name;
this.age = age;
this.gender = gender;

this.greeting = function(){
console.log('Hello! My name is ' + this.name + '.');
};
}

var mike = new Person( 'Mike', 28, 'male');
mike.greeting(); // "Hello! My name is Mike."

var jay = new Person( 'Jay', 18, 'male');
jay.greeting(); // "Hello! My name is Jay."

像这样,我们建立了一个Person构造函数(Constructor) ,然后就可以通过new关键字来建立各种对应的对象。

为什么JavaScript明明没有class却可以通过new一个函数来建立对象?这里简单拆解一下流程:

1
2
3
4
5
6
7
8
9
10
function Person( name, age, gender ){
// 和上面一致,这里省略
}

var mike = new Person( 'Mike', 28, 'male');

/*
===> var mike = {};
===> Person.call(mike, 'Mike', 28, 'male');
*/

通过new Person(...)这个动作,返回的对象会有name, age, gender以及greeting属性,而JavaScript会在背景执行Person.call方法,将mike作为this的参考对象,然后把这些属性通通新增到mike对象中。

但是,即使是通过构造函数(Constructor)建立的对象,这个对象的属性仍然可以通过.来公开存取:

1
2
3
4
5
6
7
8
9
10
11
function Person( name, age, gender ){
// 和上面一致,这里省略
}

var mike = new Person( 'Mike', 28, 'male');
console.log( mike.age ); // 32

// 因为是公开属性,所以可以很无耻地开放修改
mike.age = 18;

console.log(mike.age ); // 18

属性描述符(Property descriptor)

从ES5开始,我们可以通过新的对象模型来控制对象属性的存取、删除、列举等功能。这些特殊的属性,我们将它称为「属性描述符」(Property descriptor)。

属性描述符一共可以分为六种:

  1. value: 属性的值
  2. writable:定义属性是否可以改变,如果是false那就是只可读属性。
  3. enumerable:定义对象内的属性是否可以通过for-in语法来迭代。
  4. configurable:定义属性是否可以被删除、或修改属性内的writableenumerableconfigurable设定。
  5. get: 对象属性的getter function。
  6. set: 对象属性的setter function。

上述除了value之外的值都可以不设定,writableenumerableconfigurable的默认值是false,而getset如果没有特别指定则是undefined,并且前四种属性不能和getset混用,否则会抛出错误。

这些「属性描述符」必须要通过ES5所提供的Object.defineProperty()来处理。

Object.defineProperty 与Object.getOwnPropertyDescriptor

我们可以通过Object.defineProperty来定义对象的属性描述,用法:Object.defineProperty(obj, prop, descriptor)

其中:obj->要在其上定义属性的对象;prop->要定义或修改的属性的名称;descriptor->将被定义或修改的属性描述符。

下面通过实际范例来解释:

一般来说,要建立一个简单对象,我们可以用这样方式:

1
2
3
var person = {
name: 'mike'
};

当然,我们也可以通过Object.defineProperty来定义对象person的属性:

1
2
3
4
5
var person = {};

Object.defineProperty(person, 'name', {
value: 'mike'
});

这样的方式与直接指定对象字面属性是一样的结果。

然后,我们可以用Object.getOwnPropertyDescriptor()来检查对象属性描述器的状态:

1
2
3
4
5
6
7
var person = {};

Object.defineProperty(person, 'name', {
value: 'mike'
});

Object.getOwnPropertyDescriptor(person, 'name');

可以看到在默认的情况下,writableenumerableconfigurable都是false

1
2
3
4
5
var person2 = {
name: 'mike'
};

console.log(Object.getOwnPropertyDescriptor(person2, 'name'));

而通过字面式创建对象建立的属性,默认值则会是true

defineProperty可以针对对象一次设定多个属性描述:

1
2
3
4
5
6
Object.defineProperty(person, 'name', {
value: 'mike',
writable: false,
enumerable: false,
configurable: false
});

或是分别设定:

1
2
3
Object.defineProperty(person, 'name', {
enumerable: true
});

这些都是合法的做法。

假设我们已经定义person.name属性描述configurablefalse的情况:

1
2
3
4
5
6
7
8
var person = {};

Object.defineProperty(person, 'name', {
value: 'mike',
writable: false,
enumerable: false,
configurable: false
});

那么此时,我们再去执行删除属性的行为:

1
delete person.name;   // it will return false

虽然不会出错,但是你会发现执行结果会返回false,且person对象的name属性依然存在。同样地,当writabletrue时,你去尝试删除属性「值」的时候,你会发现结果是无效的。

要注意的是,上面这些行为,若是在「严格模式」下则会发生TypeError的错误。

属性的get 与set 方法

在本文的开始,我们介绍了早期在ES5以前通过this.getXXX()this.setXXX()来作为getset的存取方法。

而现在ES5提供了Object.defineProperty()之后,我们可以更直观地来处理这些方法:

1
2
3
4
5
6
7
8
9
10
11
12
var person = {};

Object.defineProperty(person, 'name', {
get: function(){
console.log('get');
return this._name_;
},
set: function(name){
console.log('set');
this._name_ = name;
}
});

像这样,我们可以分别为name属性去定义getset方法,而实际上,我们是通过了另一个属性_name_来作为name属性的封装。要注意的是,如果你定义了getset方法,表示你要自行控制属性的存取,那么就不能再去定义valuewritable的属性描述。

理解了ES5的对象属性描述符之后,往后我们在对对象的属性处理就可以更加灵活,像是VueJS也是通过Object.defineProperty的get与set来做到双向数据绑定的:

每个组件实例都对应一个watcher实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

图片来源:Vue.js: 如何追踪变化

如果觉得文章对你有些许帮助,欢迎在我的GitHub博客点赞和关注,感激不尽!