语言和宿主机的基础设施由对象来提供,并且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 语言规定了全局对象的属性。
三个值:
九个函数:
一些构造器:
四个用于当作命名空间的对象: