02-对象

语言和宿主机的基础设施由对象来提供,并且JavaScript程序即是一系列互相通讯的对象集合。

面向对象

Object(对象)在英文中,是一切事物的总称,这和面向对象编程的抽象思维有互通之处。

从人类的认知角度来说,对象应该是下列事物之一:一个可以触摸或者可以看见的东西;人的智力可以理解的东西;可以指导思考或行动(进行想象或施加动作)的东西。

从运行时角度来谈论对象,就是在讨论 JavaScript 实际运行中的模型,这是由于任何代码执行都必定绕不开运行时的对象模型。幸运的是,从运行时的角度看,可以不必受到这些“基于类的设施”的困扰,因为任何语言运行时类的概念都是被弱化的。

JavaScript对象的特征

划重点!!!,这是面向对象编程语言的基石,对象(唯一、状态、行为)。

对象的本质特征

  • 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。
  • 对象有状态:对象具有状态,同一对象可能处于不同状态之下。
  • 对象具有行为:即对象的状态,可能因为它的行为产生变迁。

唯一标识性

一般而言,各种语言的对象唯一标识性都是用内存地址来体现的, 对象具有唯一标识的内存地址,所以具有唯一的标识。任何不同的 JavaScript 对象其实是互不相等的。

var o1 = { a: 1};
var o2 = { a: 1};
console.log(o1==o2);// false

o1 和 o2 初看是两个一模一样的对象,但是打印出来的结果却是 false。

状态和行为

不同语言会使用不同的术语来抽象描述它们,比如 :

  • C++ 中称它们为“成员变量”和“成员函数”
  • Java 中则称它们为“属性”和“方法”
  • JavaScript 中,将状态和行为统一抽象为“属性”

考虑到 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 来说,属性并非只是简单的名称和值,JavaScript 用一组特征(attribute)来描述属性(property)。

数据属性

第一类属性,数据属性。它比较接近于其它语言的属性概念。数据属性具有四个特征。

  • value:就是属性的值。
  • writable:决定属性能否被赋值。
  • enumerable:决定 for in 能否枚举该属性。
  • configurable:决定该属性能否被删除或者改变特征值。

在大多数情况下,我们只关心数据属性的值即可。

访问器属性

第二类属性,访问器(getter/setter)属性。它也有四个特征。

  • getter:函数或 undefined,在取属性值时被调用。
  • setter:函数或 undefined,在设置属性值时被调用。
  • enumerable:决定 for in 能否枚举该属性。
  • configurable:决定该属性能否被删除或者改变特征值。

访问器属性使得属性在读和写时执行代码,它允许使用者在写和读属性时,得到完全不同的值,它可以视为一种函数的语法糖

通常用于定义属性的代码会产生数据属性,其中的 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 当年选择的原型系统,就是一个非常优秀的抽象对象的形式。

在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象。

  • 最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如 C++、Java 等流行的编程语言。这个流派叫做基于类的编程语言。
  • 还有一种就是基于原型的编程语言,它们利用原型来描述对象。我们的 JavaScript 就是其中代表。

“基于类”的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。

“基于原型”的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。基于原型的面向对象系统通过“复制”的方式来创建新对象。一些语言的实现中,还允许复制一个空对象。这实际上就是创建一个全新的对象。

基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同。在 JavaScript 之前,原型系统就更多与高动态性语言配合,并且多数基于原型的语言提倡运行时的原型修改。

原型系统的“复制操作”有两种实现思路:

  • 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;这是JavaScript的选择。
  • 另一个是切实地复制对象,从此两个对象再无关联。

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 运算接受一个构造器和一组调用参数,实际上做了几件事:

  1. 以构造器的 prototype 属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象;
  2. 将 this 和调用参数传给构造器,执行;
  3. 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。

new 这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观上提供了两种方式,

  • 一是在构造器中添加属性,
  • 二是在构造器的 prototype 属性上添加属性。

下面代码展示了用构造器模拟类的两种方法:

// 第一种方法,直接在构造器中修改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.createObject.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中的类

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 是兼容性最好的。

  • 通过 get/set 关键字来创建 getter
  • 通过括号和大括号来创建方法
  • 数据型成员最好写在构造器里面

类的写法实际上也是由原型运行时来承载的,逻辑上 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中的对象分类

可以把对象分成几类。

  • 宿主对象(host Objects):由 JavaScript 宿主环境提供的对象,它们的行为完全由宿主环境决定。
  • 内置对象(Built-in Objects):由 JavaScript 语言提供的对象。
    • 固有对象(Intrinsic Objects ):由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。
    • 原生对象(Native Objects):可以由用户通过 Array、RegExp 等内置构造器或者特殊语法创建的对象。
    • 普通对象(Ordinary Objects):由语法、Object 构造器或者 class 关键字定义类创建的对象,它能够被原型继承。

宿主对象

JavaScript 宿主对象千奇百怪,但是前端最熟悉的无疑是浏览器环境中的宿主。

在浏览器环境中,全局对象是 window,在它之上又有很多属性,如 document

实际上,这个全局对象 window 上的属性,一部分来自 JavaScript 语言,一部分来自浏览器环境。

JavaScript 标准中规定了全局对象属性,W3C 的各种标准中规定了 Window 对象的其它属性。

宿主对象分为:

  • 固有对象:宿主提供一些构造器,比如可以使用 new Image 来创建 img 元素
  • 用户可创建对象:比如 document.createElement 就可以创建一些 DOM 对象

内置对象

固有对象

固有对象是由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。

固有对象在任何 JavaScript 代码执行前就已经被创建出来了,它们通常扮演者类似基础库的角色。

前面提到的“类”其实就是固有对象的一种。ECMA 标准为我们提供了一份固有对象表,里面含有 150+ 个固有对象,点击链接查看。但是遗憾的是,这个表格并不完整。

原生对象

把 JavaScript 中,能够通过语言本身的构造器创建的对象称作原生对象。在 JavaScript 标准中,提供了 30 多个构造器。按照不同应用场景,把原生对象分成了以下几个种类。

基本类型基础功能和数据结构错误类型二进制操作带类型的数组
BooleanArrayErrorArrayBufferFloat32Array
StringDateEvalErrorSharedArrayBufferFloat64Array
NumberRegExpRangeErrorDataViewInt8Array
SymbolPromiseReferenceErrorInt16Array
ObjectProxySyntaxErrorInt32Array
MapTypeErrorUInt8Array
WeakMapURIErrorUInt16Array
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]]的执行过程如下:

  1. Object.protoype 为原型创建一个新对象;
  2. 以新对象为 this,执行函数的[[call]]
  3. 如果[[call]]的返回值是对象,那么,返回这个对象,否则返回第一步创建的新对象。

这样的规则造成了个有趣的现象,如果构造器返回了一个新的对象,那么 new 创建的新对象就变成了一个构造函数之外完全无法访问的对象,这一定程度上可以实现“私有”。

function cls(){
    this.a = 100;
    return {
        getValue:() => this.a
    }
}

var o = new cls;
o.getValue(); //100
//a在外面永远无法访问到

特殊行为的对象

在固有对象和原生对象中,有一些对象的行为跟正常对象有很大区别。

它们常见的下标运算(就是使用中括号或者点来做属性访问)或者设置原型跟普通对象不同。

  • Array:Array 的 length 属性根据最大的下标自动发生变化。
  • Object.prototype:作为所有正常对象的默认原型,不能再给它设置原型了。
  • String:为了支持下标运算,String 的正整数属性访问会去字符串里查找。
  • Arguments:arguments 的非负整数型下标属性跟对应的变量联动。
  • 模块的 namespace 对象:特殊的地方非常多,跟一般对象完全不一样,尽量只用于 import 吧。
  • 类型数组和数组缓冲区:跟内存块相关联,下标运算比较特殊。
  • bind 后的 function:跟原来的函数相关联。

全局对象

JavaScript 语言规定了全局对象的属性。

三个值:

  • Infinity
  • NaN
  • undefined

九个函数:

  • eval
  • isFinite
  • isNaN
  • parseFloat
  • parseInt
  • decodeURI
  • decodeURIComponent
  • encodeURI
  • encodeURIComponent

一些构造器:

  • Array
  • Date
  • RegExp
  • Promise
  • Proxy
  • Map
  • WeakMap
  • Set
  • WeakSet
  • Function
  • Boolean
  • String
  • Number
  • Symbol
  • Object
  • Error
  • EvalError
  • RangeError
  • ReferenceError
  • SyntaxError
  • TypeError
  • URIError
  • ArrayBuffer
  • SharedArrayBuffer
  • DataView
  • Typed Array
  • Float32Array
  • Float64Array
  • Int8Array
  • Int16Array
  • Int32Array
  • UInt8Array
  • UInt16Array
  • UInt32Array
  • UInt8ClampedArray

四个用于当作命名空间的对象:

  • Atomics
  • JSON
  • Math
  • Reflect
上次修改: 14 April 2020