语言和宿主机的基础设施由对象来提供,并且JavaScript程序即是一系列互相通讯的对象集合。
Object(对象)在英文中,是一切事物的总称,这和面向对象编程的抽象思维有互通之处。
从人类的认知角度来说,对象应该是下列事物之一:一个可以触摸或者可以看见的东西;人的智力可以理解的东西;可以指导思考或行动(进行想象或施加动作)的东西。
从运行时角度来谈论对象,就是在讨论 JavaScript 实际运行中的模型,这是由于任何代码执行都必定绕不开运行时的对象模型。幸运的是,从运行时的角度看,可以不必受到这些“基于类的设施”的困扰,因为任何语言运行时类的概念都是被弱化的。
划重点!!!,这是面向对象编程语言的基石,对象(唯一、状态、行为)。
对象的本质特征:
一般而言,各种语言的对象唯一标识性都是用内存地址来体现的, 对象具有唯一标识的内存地址,所以具有唯一的标识。任何不同的 JavaScript 对象其实是互不相等的。
var o1 = { a: 1};
var o2 = { a: 1};
console.log(o1==o2);// false
o1 和 o2 初看是两个一模一样的对象,但是打印出来的结果却是 false。
不同语言会使用不同的术语来抽象描述它们,比如 :
考虑到 JavaScript 中将函数设计成一种特殊对象,所以 JavaScript 中的行为和状态都能用属性来抽象。
下面这段代码其实就展示了普通属性和函数作为属性的一个例子,其中 o 是对象,d 是一个属性,而函数 f 也是一个属性,对 JavaScript 来说,d 和 f 就是两个普通属性。
var o = {
d: 1,
f(){
console.log(this.d);
}
};
在 JavaScript 中,对象的状态和行为都被抽象为了属性。如果用过 Java,一定不要觉得奇怪,尽管设计思路有一定差别,但是二者都很好地表现了对象的基本特征:标识性、状态和行为。
在实现了对象基本特征的基础上, JavaScript 中对象独有的特色是:对象具有高度的动态性,因为 JavaScript 赋予了使用者在运行时为对象添改状态和行为的能力。
比如,JavaScript 允许运行时向对象添加属性,这就跟绝大多数基于类的、静态的对象设计完全不同。下面这段代码就展示了运行时如何向一个对象添加属性,一开始定义了一个对象 o,定义完成之后,再添加它的属性 b,这样操作是完全没问题的。
var o = { a: 1};
o.b = 2;
console.log(o.a,o.b);// 1 2
为了提高抽象能力,JavaScript 的属性被设计成比别的语言更加复杂的形式,它提供了数据属性和访问器属性(getter/setter)两类。
对 JavaScript 来说,属性并非只是简单的名称和值,JavaScript 用一组特征(attribute)来描述属性(property)。
第一类属性,数据属性。它比较接近于其它语言的属性概念。数据属性具有四个特征。
在大多数情况下,我们只关心数据属性的值即可。
第二类属性,访问器(getter/setter)属性。它也有四个特征。
访问器属性使得属性在读和写时执行代码,它允许使用者在写和读属性时,得到完全不同的值,它可以视为一种函数的语法糖。
通常用于定义属性的代码会产生数据属性,其中的 writable、enumerable、configurable 都默认为 true。可以使用内置函数 Object.getOwnPropertyDescripter 来查看,如以下代码所示:
var o = { a:1};
o.b=2;
// a和b皆为数据属性
Object.getOwnPropertyDescripter(o,"a");// {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescripter(o,"a");// {value: 2, writable: true, enumerable: true, configurable: true}
在这里使用了两种语法来定义属性,定义完属性后,用 JavaScript 的 API 来查看这个属性,可以发现,这样定义出来的属性都是数据属性,writeable、enumerable、configurable 都是默认值为 true。
如果要想改变属性的特征,或者定义访问器属性,可以使用 Object.defineProperty,示例如下:
var o = { a: 1 };
Object.defineProperty(o, "b", {value: 2, writable: false, enumerable: false, configurable: true});//a和b都是数据属性,但特征值变化了
Object.getOwnPropertyDescriptor(o,"a"); // {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor(o,"b"); // {value: 2, writable: false, enumerable: false, configurable: true}
o.b = 3;
console.log(o.b); // 2
使用 Object.defineProperty 来定义属性,这样定义属性可以改变属性的 writable 和 enumerable。同样用 Object.getOwnPropertyDescriptor 来查看,发现确实改变了 writable 和 enumerable 特征。因为 writable 特征为 false,所以重新对 b 赋值,b 的值不会发生变化。
在创建对象时,也可以使用 get 和 set 关键字来创建访问器属性,代码如下所示:
var o = { get a(){ return 1 } };
console.log(o.a);// 1
访问器属性跟数据属性不同,每次访问属性都会执行 getter 或者 setter 函数。这里的 getter 函数返回了 1,所以 o.a 每次都得到 1。
实际上 JavaScript 对象的运行时是一个“属性的集合”,属性以字符串或者 Symbol(Symbol 类型,能够以 Symbol 为属性名,这是 JavaScript 对象的一个特色) 为 key,以数据属性特征值或者访问器属性特征值为 value。
对象是一个属性的索引结构(索引结构是一类常见的数据结构,可以把它理解为一个能够以比较快的速度用 key 来查找 value 的字典)。
以上面的对象 o 为例,可以想象一下“a”是 key。{writable:true,value:1,configurable:true,enumerable:true}是 value。
由于 JavaScript 的对象设计跟目前主流基于类的面向对象差异非常大。可事实上,这样的对象系统设计虽然特别,但是 JavaScript 提供了完全运行时的对象系统,这使得它可以模仿多数面向对象编程范式,所以它也是正统的面向对象语言。
JavaScript 语言标准也已经明确说明,JavaScript 是一门面向对象的语言,因为 JavaScript 的高度动态性的对象系统。
从 ES6 开始,JavaScript 提供了 class 关键字来定义类,尽管,这样的方案仍然是基于原型运行时系统的模拟,但是它修正了之前的一些常见的“坑”,统一了社区的方案,这对语言的发展有着非常大的好处。
“基于类”并非面向对象的唯一形态,如果把视线从“类”移开,Brendan 当年选择的原型系统,就是一个非常优秀的抽象对象的形式。
在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象。
“基于类”的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。
“基于原型”的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。基于原型的面向对象系统通过“复制”的方式来创建新对象。一些语言的实现中,还允许复制一个空对象。这实际上就是创建一个全新的对象。
基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同。在 JavaScript 之前,原型系统就更多与高动态性语言配合,并且多数基于原型的语言提倡运行时的原型修改。
原型系统的“复制操作”有两种实现思路:
如果抛开 JavaScript 用于模拟 Java 类的复杂语法设施(如 new、Function Object、函数的 prototype 属性等),原型系统可以说相当简单,可以用两条概括:
[[prototype]],就是对象的原型;这个模型在 ES 的各个历史版本中并没有很大改变,但从 ES6 以来,JavaScript 提供了一系列内置函数,以便更为直接地访问操纵原型。三个方法分别为:
Object.create 根据指定的原型创建新对象,原型可以是 null;Object.getPrototypeOf 获得一个对象的原型;Object.setPrototypeOf 设置一个对象的原型。利用这三个方法,可以完全抛开类的思维,利用原型来实现抽象和复用。
下面的代码展示了用原型来抽象猫和虎的例子。
var cat = {
say(){
console.log("meow~");
},
jump(){
console.log("jump");
}
}
var tiger = Object.create(cat,{
say:{
value: function(){
console.log("roar!");
},
writable: true,
enumerable: true,
configurable: true
}
})
var anotherCat = Object.create(cat);
anotherCat.say()
var anotherTiger = Object.create(tiger);
anotherTiger.say();
这段代码创建了一个“猫”对象,又根据猫做了一些修改创建了虎,之后完全可以用 Object.create 来创建另外的猫和虎对象,可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为。
考虑到 new 和 prototype 属性等基础设施今天仍然有效,而且被很多代码使用,学习这些知识也有助于理解运行时的原型工作原理,下面试着回到过去,追溯一下早年的 JavaScript 中的原型和类。
在早期版本的 JavaScript 中,“类”的定义是一个私有属性 [[class]],语言标准为内置类型诸如 Number、String、Date 等指定了[[class]]属性,以表示它们的类。语言使用者唯一可以访问[[class]]属性的方式是 Object.prototype.toString。以下代码展示了所有具有内置 class 属性的对象:
var o = new Object;
var n = new Number;
var s = new String;
var b = new Boolean;
var d = new Date;
var arg = function(){ return arguments }();
var r = new RegExp;
var f = new Function;
var arr = new Array;
var e = new Error;
console.log([o, n, s, b, d, arg, r, f, arr, e].map(v => Object.prototype.toString.call(v)));
因此,在 ES3 和之前的版本,JS 中类的概念是相当弱的,它仅仅是运行时的一个字符串属性。
在 ES5 开始,[[class]] 私有属性被 Symbol.toStringTag 代替,Object.prototype.toString 的意义从命名上不再跟 class 相关。甚至可以自定义 Object.prototype.toString 的行为,以下代码展示了使用 Symbol.toStringTag 来自定义 Object.prototype.toString 的行为:
var o = { [Symbol.toStringTag]: "MyObject"};
console.log(o+"");
这里创建了一个新对象,并且给它唯一的一个属性 Symbol.toStringTag,用字符串加法触发了 Object.prototype.toString 的调用,发现这个属性最终对 Object.prototype.toString 的结果产生了影响。
但是,考虑到 JavaScript 语法中跟 Java 相似的部分,对类的讨论不能用“new运算是针对构造器对象,而不是类”来试图回避。所以,仍然要把 new 理解成 JavaScript 面向对象的一部分。
new 运算接受一个构造器和一组调用参数,实际上做了几件事:
[[prototype]]的区分)为原型,创建新对象;new 这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观上提供了两种方式,
下面代码展示了用构造器模拟类的两种方法:
// 第一种方法,直接在构造器中修改this,给this添加属性
function c1(){
this.p1=1;
this.p2= function(){
console.log(this.p1);
}
}
var o1 = new c1;
o1.p2();
// 第二种方法,修改构造器的prototype属性指向的对象,它是从这个构造器构造出来的所有对象的原型
function c2(){
}
c2.prototype.p1=1;
c2.prototype.p2=function(){
console.log(this.p1);
}
var o2 = new c2;
o2.p2();
没有 Object.create、Object.setPrototypeOf 的早期版本中,new 运算是唯一一个可以指定[[prototype]]的方法(当时的 mozilla 提供了私有属性 __proto__,但是多数环境并不支持),所以,当时已经有人试图用它来代替后来的 Object.create,我们甚至可以用它来实现一个 Object.create 的不完整的 polyfill,见以下代码:
Object.create = function(prototype){
var cls = function(){}
cls.prototype = prototype;
return new cls;
}
这段代码创建了一个空函数作为类,并把传入的原型挂在了它的 prototype,最后创建了一个它的实例,根据 new 的行为,这将产生一个以传入的第一个参数为原型的对象。这个函数无法做到与原生的 Object.create 一致,一个是不支持第二个参数,另一个是不支持 null 作为原型,所以放到今天意义已经不大了。
ES6 中加入了新特性 class,new 跟 function 搭配的怪异行为终于可以退休了(虽然运行时没有改变),在任何场景,都推荐使用 ES6 的语法来定义类,而令 function 回归原本的函数语义。
ES6 中引入了 class 关键字,并且在标准中删除了所有[[class]]相关的私有属性描述,类的概念正式从属性升级成语言的基础设施,从此,基于类的编程方式成为了 JavaScript 的官方编程范式。我们先看下类的基本写法:
class Recttangle {
constructor(height,width){
this.height = height;
this.width = width;
}
// Getter
get area(){
return this.calcArea();
}
// Method
calcArea(){
return this.height * this.width;
}
}
在现有的类语法中,getter/setter 和 method 是兼容性最好的。
类的写法实际上也是由原型运行时来承载的,逻辑上 JavaScript 认为每个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象之上。此外,最重要的是,类提供了继承能力。
class Animal{
constructor(name){
this.name = name;
}
speak(){
console.log(this.name + 'makes a noise.');
}
}
class Dog extends Animal{
constructor(name){
super(name);// 调用父类构造体并传入name参数
}
speak(){
console.log(this.name + 'barks.');
}
}
let d = new Dog('Mitzie');
d.speak(); // Mitzie barks.
以上代码创造了 Animal 类,并且通过 extends 关键字让 Dog 继承了它,展示了最终调用子类的 speak 方法获取了父类的 name。
比起早期的原型模拟方式,使用 extends 关键字自动设置了 constructor,并且会自动调用父类的构造函数,这是一种更少坑的设计。
所以当我们使用类的思想来设计代码时,应该尽量使用 class 来声明类,而不是用旧语法,拿函数来模拟对象。
一些激进的观点认为,class 关键字和箭头运算符可以完全替代旧的 function 关键字,它更明确地区分了定义函数和定义类两种意图,这是有一定道理的。
可以把对象分成几类。
JavaScript 宿主对象千奇百怪,但是前端最熟悉的无疑是浏览器环境中的宿主。
在浏览器环境中,全局对象是 window,在它之上又有很多属性,如 document。
实际上,这个全局对象 window 上的属性,一部分来自 JavaScript 语言,一部分来自浏览器环境。
JavaScript 标准中规定了全局对象属性,W3C 的各种标准中规定了 Window 对象的其它属性。
宿主对象分为:
document.createElement 就可以创建一些 DOM 对象固有对象是由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。
固有对象在任何 JavaScript 代码执行前就已经被创建出来了,它们通常扮演者类似基础库的角色。
前面提到的“类”其实就是固有对象的一种。ECMA 标准为我们提供了一份固有对象表,里面含有 150+ 个固有对象,点击链接查看。但是遗憾的是,这个表格并不完整。
把 JavaScript 中,能够通过语言本身的构造器创建的对象称作原生对象。在 JavaScript 标准中,提供了 30 多个构造器。按照不同应用场景,把原生对象分成了以下几个种类。
| 基本类型 | 基础功能和数据结构 | 错误类型 | 二进制操作 | 带类型的数组 |
|---|---|---|---|---|
| Boolean | Array | Error | ArrayBuffer | Float32Array |
| String | Date | EvalError | SharedArrayBuffer | Float64Array |
| Number | RegExp | RangeError | DataView | Int8Array |
| Symbol | Promise | ReferenceError | Int16Array | |
| Object | Proxy | SyntaxError | Int32Array | |
| Map | TypeError | UInt8Array | ||
| WeakMap | URIError | UInt16Array | ||
| Set | ||||
| WeakSet | ||||
| Function | ||||
通过这些构造器,可以用 new 运算创建新的对象,所以把这些对象称作原生对象。
几乎所有这些构造器的能力都是无法用纯 JavaScript 代码实现的,它们也无法用 class/extend 语法来继承。
这些构造器创建的对象多数使用了私有字段, 例如:
Error: [[ErrorData]]Boolean: [[BooleanData]]Number: [[NumberData]]Date: [[DateValue]]RegExp: [[RegExpMatcher]]Symbol: [[SymbolData]]Map: [[MapData]]这些字段使得原型继承方法无法正常工作,所以,所有这些原生对象都是为了特定能力或者性能,而设计出来的“特权对象”。
在 JavaScript 中,还有一个看待对象的不同视角,这就是用对象来模拟函数和构造器。
事实上,JavaScript 为这一类对象预留了私有字段机制,并规定了抽象的函数对象与构造器对象的概念。
[[call]]私有字段的对象,[[construct]]的对象。JavaScript 用对象模拟函数的设计代替了一般编程语言中的函数,它们可以像其它语言的函数一样被调用、传参。任何宿主只要提供了“具有[[call]]私有字段的对象”,就可以被 JavaScript 函数调用语法支持。
[[call]]私有字段必须是一个引擎中定义的函数,需要接受 this 值和调用参数,并且会产生域的切换。
可以这样说,任何对象只需要实现[[call]],它就是一个函数对象,可以去作为函数被调用。而如果它能实现[[construct]],它就是一个构造器对象,可以作为构造器被调用。
对于为 JavaScript 提供运行环境的程序员来说,只要字段符合,在上文中提到的宿主对象和内置对象(如 Symbol 函数)可以模拟函数和构造器。用户用 function 关键字创建的函数必定同时是函数和构造器。不过,它们表现出来的行为效果却并不相同。
对于宿主和内置对象来说,它们实现[[call]](作为函数被调用)和[[construct]](作为构造器被调用)不总是一致的。比如内置对象 Date 在作为构造器调用时产生新的对象,作为函数时,则产生字符串,见以下代码:
console.log(new Date); // 1
console.log(Date())
而浏览器宿主环境中,提供的 Image 构造器,则根本不允许被作为函数调用。
console.log(new Image);
console.log(Image());//抛出错误
基本类型(String、Number、Boolean),它们的构造器被当作函数调用,则产生类型转换的效果。值得一提的是,在 ES6 之后 => 语法创建的函数仅仅是函数,它们无法被当作构造器使用,见以下代码:
new(a => 0);// error
对于用户使用 function 语法或者 Function 构造器创建的对象来说,[[call]]和[[construct]]行为总是相似的,它们执行同一段代码。
function f(){
return 1;
}
var v = f(); // 把f作为函数调用
vat o = new f(); // 把f作为构造器调用
大致可以认为,它们[[construct]]的执行过程如下:
Object.protoype 为原型创建一个新对象;[[call]];[[call]]的返回值是对象,那么,返回这个对象,否则返回第一步创建的新对象。这样的规则造成了个有趣的现象,如果构造器返回了一个新的对象,那么 new 创建的新对象就变成了一个构造函数之外完全无法访问的对象,这一定程度上可以实现“私有”。
function cls(){
this.a = 100;
return {
getValue:() => this.a
}
}
var o = new cls;
o.getValue(); //100
//a在外面永远无法访问到
在固有对象和原生对象中,有一些对象的行为跟正常对象有很大区别。
它们常见的下标运算(就是使用中括号或者点来做属性访问)或者设置原型跟普通对象不同。
Object.prototype:作为所有正常对象的默认原型,不能再给它设置原型了。JavaScript 语言规定了全局对象的属性。
三个值:
九个函数:
一些构造器:
四个用于当作命名空间的对象: