侧边栏壁纸
博主头像
lac博主等级

行动起来,活在当下

  • 累计撰写 66 篇文章
  • 累计创建 12 个标签
  • 累计收到 1 条评论

目 录CONTENT

文章目录

js红书读书笔记——第八章 对象、类与面对对象编程

Hude
2024-04-08 / 0 评论 / 0 点赞 / 35 阅读 / 138883 字

ECMA-262将对象定义为一组属性的无序集合

对象的每一个属性或方法都会有一个名称来标识,这个名称映射到这个值

简单理解:ESMCSript中可以把对象想象成一个无序的散列表,其中的内容就是一组键值对,值可以是数据或函数

理解对象

创建自定义对象的两种方式:

  • 通过创建Object对象的实例:const obj = new Object();

  • 通过字面量创建:const obj = {}

属性的类型

认识内部特性:

CMA-262使用一些特性来描述属性的特征

这些特性是由JavaScript引擎实现的,开发者不能访问这些特性

标识内部特性的方法:使用双括号标识,如[[Enumerable]]

属性的分类:

  • 数据属性

  • 访问器属性

1.数据属性:

数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置

数据属性有四个特征描述它们的行为:

  • [[Configurable]]:表示属性是否可以delete删除并重新定义,是否可以修改它的特性,以及是否可以把把它改为访问器属性。默认情况下,所有定义对象上的属性这个内部特性都为true

  • [[Enumerable]]:表示属性是否可以通过for-in循环返回,默认为true

  • [[Writable]]:表示属性的值是否可以被修改。默认为true

  • [[Value]]:包含属性实际的值,读写属性值的位置。默认为undefined

这四个内部特性默认是不可以编辑的,但是可以通过Object.defineProperty()方法来指定内部特性。

具体的,Object.defineProperty(obj, prototypeName, featureObj),其中,obj为传入的对象,prototypeName为属性名称,featureObj是包含四个可以指定的属性的对象configurable、enumerable、writable 和 value

在设置configurable为false时,非严格模式下会静默失败,严格模式下会抛出错误

此外,一个属性被定义为不可配置之后,就不能再变回可配置的了。再次调用 Object.defineProperty()并修改任何非 writable 属性会导致错误

在调用 Object.defineProperty()时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false

2.访问器属性

访问器属性不包含数据值,它包含一个非必须的获取函数(getter)和设置函数(setter)

在读取访问器属性时,会调用获取函数(getter),在写入访问器属性时,会调用设置函数(setter)并传入新值

访问器属性有四个特性描述它们的行为:

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,这个内部特性的值为true

  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true

  • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。

  • [[Set]]:设置函数,在写入属性时调用。默认值为 undefined

注意,访问器属性是不能直接定义的,只能通过Object.defineProperty()定义

访问器的典型使用场景,即设置一个属性值会导致一些其他变化发生

重要的:获取函数和设置函数都是非必须定义的,定义其中一个会发送以下情况:

  • 只定义获取函数:那么这个属性就是只读的,尝试修改属性会被忽略。但是严格模式下,尝试写入只定义了获取函数的属性会报错

  • 只定义设置函数:那么这个属性是不能读取的,非严格模式下读取会返回undefined,严格模式下会抛出错误

在不支持 Object.defineProperty()的浏览器中没有办法修改[[Configurable]]或[[Enumerable]]

注意 在 ECMAScript 5以前,开发者会使用两个非标准的访问创建访问器属性:__defineGetter__()和__defineSetter__()。这两个方法最早是 Firefox 引入的,后来 Safari、Chrome 和 Opera 也实现了。

定义多个属性

ECMAScript提供了Object.defineProperties()方法,用于在一个对象上定义多个属性。接收两个参数:

  • 需要添加或修改属性的对象

  • 描述符对象

let book = {}; 
Object.defineProperties(book, { 
 year_: { 
 	value: 2017 
 }, 
 edition: { 
 	value: 1 
 }, 
 year: { 
 	get() { 
 		return this.year_; 
 },
	set(newValue) { 
 		if (newValue > 2017) { 
 		this.year_ = newValue; 
 		this.edition += newValue - 2017; 
 		} 
 	} 
 }
});

读取属性的特性

使用 Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符,这个方法接收两个参数:

  • 需要读取属性特性的对象

  • 要取得其描述符的属性名

返回值是一个对象:

  • 如果是数据属性:包含configurable、enumerable、

    writable 和 value 属性的对象

  • 如果是访问器属性:含 configurable、enumerable、

    get 和 set 属性的对象

ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors()静态方法。这个方法实际上会在每个自有属性上调用 Object.getOwnPropertyDescriptor()并在一个新对象中返回它们

等于说,调用这个方法如果不传入指定的属性名称,会返回一个所有属性的特性对象组成的对象

合并对象

所谓"合并":把源对象所有的本地属性一起复制到目标对象上,这种操作也被称为"混入"(mixin),因为目标对象通过混入源对象的属性得到了增强

为了方便合并对象的操作,ECMAScript 6提供了Object.assign()方法,用于合并一个目标对象和多个源对象的属性

它接收两个参数:

  • 目标对象

  • 一个或多个源对象

具体的,assign()方法会把源对象上的可枚举属性(Object.propertyIsEnumerable()返回 true)和自有属性(Object.hasOwnProperty()返回 true)复制到目标对象上。以字符串或符号为键的属性会被复制

对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值

Object.assign()实际上对每个源对象执行的是浅复制

如果多个源对象都有相同的属性,则使用最后一个复制的值

不能在两个对象间转移获取函数(getter)设置函数(setter)

如果赋值期间出错,则会终止合并操作,这个终止并不会销毁之前的合并结果,并且assgin()方法是没有回滚的概念

对象标识及相等判定

在ESCMScript 6之前,有些特殊情况即使是全等操作符(===)也无能为力:

// 这些是===符合预期的情况
console.log(true === 1); // false 
console.log({} === {}); // false 
console.log("2" === 2); // false 
// 这些情况在不同 JavaScript 引擎中表现不同,但仍被认为相等
console.log(+0 === -0); // true 
console.log(+0 === 0); // true 
console.log(-0 === 0); // true 
// 要确定 NaN 的相等性,必须使用极为讨厌的 isNaN() 
console.log(NaN === NaN); // false 
console.log(isNaN(NaN)); // true

为了改善这类情况,ES6 提供了Object.is()方法,这个方法和全等操作符很像,但同时也考虑到了上述边界情况。

这个方法必须接收两个需要比较的参数

对于多个参数比较:

// 递归地利用相等性传递
function recursivelyCheckEqual(x, ...rest) { 
 return Object.is(x, rest[0]) && 
 (rest.length < 2 || recursivelyCheckEqual(...rest)); 
}

增强的对象语法

ECMAScript 6 为定义和操作对象新增了很多极其有用的语法糖特性。这些语法糖没有改变引擎的行为,但是极大的提升了对象处理的方便程度

1.属性简写

当处理属性的值时,如果传入的变量和属性名称相同,可以直接单写一个变量即可:

let obj = {}
const p1 = 1;
obj = {p1,p2:2}

如果没有找到同名的属性名,就会抛出引用错误ReferenceError

代码压缩程序会在不同作用域间保留属性名,以防止找不到引用,这里的意思是说作用域不会影响属性简写,比如:

function makePerson(name) { 
 return { 
 name 
 }; 
} 
let person = makePerson('Matt'); 
console.log(person.name); // Matt

这个例子说明,即使参数标识符只限定于函数作用域,编译器也会保留初始的 name 标识符

2.可计算属性

在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。换句话说,不能在对象字面量中直接动态命名属性

const nameKey = 'name'; 
const ageKey = 'age';
const jobKey = 'job'; 

let person = {}; 
person[nameKey] = 'Matt'; 
person[ageKey] = 27; 
person[jobKey] = 'Software engineer'; 
console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }

有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为 JavaScript 表达式而不是字符串来求值:

const nameKey = 'name'; 
const ageKey = 'age'; 
const jobKey = 'job'; 
let person = { 
 [nameKey]: 'Matt', 
 [ageKey]: 27, 
 [jobKey]: 'Software engineer' 
}; 
console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }

因为被当作 JavaScript 表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值:

const nameKey = 'name'; 
const ageKey = 'age'; 
const jobKey = 'job'; 
let uniqueToken = 0; 
function getUniqueKey(key) { 
 return `${key}_${uniqueToken++}`; 
} 
let person = { 
 [getUniqueKey(nameKey)]: 'Matt', 
 [getUniqueKey(ageKey)]: 27, 
 [getUniqueKey(jobKey)]: 'Software engineer' 
}; 
console.log(person); // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }

注意 可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。

3.简写方法名

给对象定义方法时,遵从的是以下

const obj = {
	setName: function(){
		return 'xxx';
	}
}

对于简写方法名,可以省略冒号+函数定义的格式,直接使用函数名来定义:

const obj = {
	setName(){
		return 'xxx';
	}
}

简写方法名对获取函数和设置函数也是适用的:

let person = { 
 name_: '', 
 get name() { 
 return this.name_; 
 }, 
 set name(name) { 
 this.name_ = name; 
 }, 
 sayName() { 
 console.log(`My name is ${this.name_}`); 
 } 
}; 
person.name = 'Matt'; 
person.sayName(); // My name is Matt

简写方法名与可计算属性键相互兼容:

const methodKey = 'sayName'; 
let person = { 
 [methodKey](name) { 
 console.log(`My name is ${name}`); 
 } 
} 
person.sayName('Matt'); // My name is Matt

对象解构

简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值

let person = { 
 name: 'Matt', 
 age: 27 
};
// 不使用对象解构
const pName = person.name;
const pAge = persion.age;

// 使用对象解构
const {name: pName, age: pAge} = person;
// 也可以使用简写
const {name, age} = persion;

解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是 undefined

解构赋值的同时定义默认值,这里定义的默认值和对象没有关系,只是允许声明变量的时候定义默认值而已

let person = { 
 name: 'Matt', 
 age: 27 
}; 
let { name, job='Software engineer' } = person; 
console.log(name); // Matt 
console.log(job); // Software engineer

解构在内部使用函数 ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象(核心)。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject()的定义),null和 undefined 不能被解构,否则会抛出错误,这里可以理解为null和undefined本身就没有什么属性可以被解构

let { length } = 'foobar'; 
console.log(length); // 6 
let { constructor: c } = 4; 
console.log(c === Number); // true 
let { _ } = null; // TypeError 
let { _ } = undefined; // TypeError

解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中:

let personName, personAge; 
let person = { 
 name: 'Matt', 
 age: 27 
}; 
({name: personName, age: personAge} = person); 
console.log(personName, personAge); // Matt, 27

1.嵌套解构

解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性。

这里有个现象:解构赋值的属性如果不是对象属性,并不会出现相同引用的问题,如果是嵌套对象属性,那么解构赋值的那个对象和被解构的对象中的对象属性是同一个引用

let person = { 
 name: 'Matt', 
 age: 27, 
 job: { 
 title: 'Software engineer' 
 } 
}; 
let personCopy = {}; 
({ 
 name: personCopy.name, 
 age: personCopy.age, 
 job: personCopy.job 
} = person); 
// 因为一个对象的引用被赋值给 personCopy,所以修改
// person.job 对象的属性也会影响 personCopy 
person.job.title = 'Hacker' 
console.log(person); 
// { name: 'Matt', age: 27, job: { title: 'Hacker' } } 
console.log(personCopy); 
// { name: 'Matt', age: 27, job: { title: 'Hacker' } }

解构赋值可以使用嵌套结构,以匹配嵌套的属性:可以通过{}的嵌套解构的形式,解构出被嵌套的属性

let person = { 
 name: 'Matt', 
 age: 27, 
 job: { 
 title: 'Software engineer' 
 } 
}; 
// 声明 title 变量并将 person.job.title 的值赋给它
let { job: { title } } = person; 
console.log(title); // Software engineer

在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样

注意是没有定义,也就是说,嵌套结构不适用于没有属性的源对象或目标对象,这里是不同于非嵌套解构的

let person = { 
 job: { 
 title: 'Software engineer' 
 } 
}; 
let personCopy = {}; 
// foo 在源对象上是 undefined 
({ 
 foo: { 
 bar: personCopy.bar 
 } 
} = person); 
// TypeError: Cannot destructure property 'bar' of 'undefined' or 'null'. 
// job 在目标对象上是 undefined 
({ 
 job: { 
 title: personCopy.job.title 
 } 
} = person); 
// TypeError: Cannot set property 'title' of undefined

2.部分解构

需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及

多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分

感觉这不是一个特性,像是一个错误说明,本身"部分解构"是存在的,我理解的是只有部分属性被解构为变量,但是这里提到的"部分解构"更像是一种错误机制,当赋值遇到错误时触发部分解构

let person = { 
 name: 'Matt', 
 age: 27 
}; 
let personName, personBar, personAge; 
try { 
 // person.foo 是 undefined,因此会抛出错误
 ({name: personName, foo: { bar: personBar }, age: personAge} = person); 
} catch(e) {} 
console.log(personName, personBar, personAge); 
// Matt, undefined, undefined

3.参数上下文匹配

在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量

这个语法糖分为两个部分便于记忆:形参部分,在对应参数使用{}来匹配对象属性,实参部分,直接传入对象即可

let person = { 
 name: 'Matt', 
 age: 27 
}; 
function printPerson(foo, {name, age}, bar) { 
 console.log(arguments); 
 console.log(name, age); 
} 
function printPerson2(foo, {name: personName, age: personAge}, bar) { 
 console.log(arguments); 
 console.log(personName, personAge); 
} 
printPerson('1st', person, '2nd'); 
// ['1st', { name: 'Matt', age: 27 }, '2nd'] 
// 'Matt', 27 
printPerson2('1st', person, '2nd'); 
// ['1st', { name: 'Matt', age: 27 }, '2nd'] 
// 'Matt', 27

创建对象

虽然使用Object构造函数对象字面量可以方便的创建对象,但这些方式也有明显不足:创建具

有同样接口的多个对象需要重复编写很多代码

概述

JavaScript在ES6中正式支持了类和继承,不过无论从哪一个方面来看,ES6 的类都仅仅是封装了 ES5.1 构造函数加原型继承的语法糖而已

工厂模式

工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程

简单来说,就是封装了创建对象的这一过程

function createPerson(name, age, job) { 
 let o = new Object(); 
 o.name = name; 
 o.age = age; 
 o.job = job; 
 o.sayName = function() { 
 console.log(this.name); 
 }; 
 return o; 
} 
let person1 = createPerson("Nicholas", 29, "Software Engineer"); 
let person2 = createPerson("Greg", 27, "Doctor");

工厂模式虽然可以解决创建多个对象的问题,但是没有解决对象的标识问题(新建对象的类型不确定)

构造函数模式

ECMAScript 中的构造函数是用于创建特定类型对象的(ES的构造函数就是能创建对象的函数

像 Object 和 Array 这样的原生构造函数,运行时可以直接在执行环境中使用。

当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

function Person(name, age, job){
	this.name = name;
	this.age = age;
	this.job = job;
	this.sayName = function(){
		console.log(this.name);
	}
}
const p1 = new Person("Nicholas", 29, "Software Engineer");
const p2 = new Person("Greg", 27, "Doctor")

Person()构造函数代替了 createPerson()工厂函数。实际上,Person()内部的代码跟 createPerson()基本是一样的,只是有如下区别:

  • 没有显示的创建对象

  • 属性和方法直接赋值给this

  • 没有return

按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头

要创建Person对象的实例,就要使用new关键字,以这种方式调用的构造函数会执行以下操作:

  1. 内存中创建一个新对象创建新对象

  2. 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性挂载原型对象

  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)指定this值

  4. 执行构造函数内部的代码(给新对象添加属性)执行函数内容

  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象 返回对象

每个通过new创建的对象实例,都有一个都有一个constructor 属性指向对象,constructor 属性本来就是用来表示对象类型的。不过,一般认为,instanceof操作符是确定对象更可靠的方式

定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处

构造函数不一定要写成函数声明的形式,赋值给变量的函数表达式也可以创建构造函数

const Person = function (name, age, job){
 this.name = name; 
 this.age = age; 
 this.job = job; 
 this.sayName = function() { 
 console.log(this.name); 
 };
}

在实例化时,如果不想传参,那么构造函数后面的括号()ke'jia只要有new操作符就可以调用响应的构造函数

function Person() { 
 this.name = "Jake"; 
 this.sayName = function() { 
 console.log(this.name); 
 }; 
}
const p1 = new Person;
const p2 = new Person();

1.构造函数也是函数

构造函数和普通函数唯一的区别就是调用方式的不同(构造函数通过new,普通函数直接函数名调用)

构造函数也是函数,并没有把某个函数定义为构造函数的方式

任何函数只要通过new调用的,就是构造函数,没有通过new调用的就是普通函数

这里提到三种函数调用方式(省略写法):

  • 通过new调用,调用者实际上是实例对象,this值指向实例对象

  • 通过函数名调用,这里调用者实际上可以认为是全局对象,因此this指向全局对象

  • 通过call/apply在另一个对象中调用,调用者实际上是这个对象,this值指向指定的这个对象

重点:在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或者没有使用 call()/apply()调用),this 始终指向 Global 对象(在浏览器中就是 window 对象)

2.构造函数的问题

构造函数的问题主要在于其定义的方法会在每个实例上都创建一遍。

对于前面的Person对象的例子而言,创建的实例p1和p2都有名为sayName()的方法,但是这两个方法不是同一个Function实例。

这是因为在JavaScript中,函数也是一个对象,因此定义函数时,都会初始化一个函数对象。逻辑上讲,这个构造函数应该是这样的:

function Person(name, age, obj){
	this.name = name;
	this.age = age;
	this.obj = obj;
	const sayName = new Function("console.log(this.name)");// 逻辑等价
}

因此,在每创建一个Person实例时,都会创建一个Function实例,但是sayName所做的事情都是一样的,没必要为每个实例都去创建一次Function实例

一种改进方法是把sayName放到构造函数外部:

function Person(name, age, obj, sayName){
	this.name = name;
	this.age = age;
	this.obj = obj;
	this.sayName = sayName;
}

function sayName(){
	console.log(this.name);
}
const p1 = new Person('hugo',18, 'doctor',sayName);
const p2 = new Person("Nicholas", 29, "Software Engineer",sayName);

p1.sayName === p2.sayName // true

不过这样,虽然避免了重复的创建Function实例,但随之而来的问题是:

  • 如果方法太多不好管理,并且难于区分哪些方法是哪些对象的

  • 这样创建的函数,会被挂载到全局对象,如果方法多了,全局对象上就会被挂载巨量的方法

这个新问题可以在原型模式中解决

原型模式

关于箭头函数的一些须知:

  1. 箭头函数不能作为构造函数使用,它不会创建prototype属性,也不会指定this值,因此箭头函数是无状态的

  2. 箭头函数不同于普通函数/构造函数的原因:设计上,ES更倾向于把箭头函数和普通函数区分开来,作为一种简单的、轻量级的函数是使用,也希望它更符合函数式编程

每个函数都会创建一个prototype属性

超级重点:每个函数中的prototype属性,不是函数的原型,是函数创建的实例的原型,函数的原型是Function.prototype

这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法

使用原型对象的好处在于定义在它上面的属性和方法可以被其对象的其他实例共享

这里就是说在所有创建的该对象的实例上,都可以访问到该对象的属性和方法

原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型:

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let person1 = new Person(); 
person1.sayName(); // "Nicholas" 
let person2 = new Person(); 
person2.sayName(); // "Nicholas" 
console.log(person1.sayName == person2.sayName); // true

通过构造函数Person创建对象实际上是空的,但是在这些实例共享的Person对象的原型上,设置了对应的值,因此所有的实例都可以访问这些属性和方法

1.理解原型

  • 无论何时,只要创建了一个函数,就会按照特定的规则为函数创建一个prototype属性(指向原型对象)。

  • 默认情况下,所有prototype属性都有一个constructor属性,指回与之关联的构造函数

  • 在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象

  • 脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型

关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有

个人理一下:

在原型的概念里面,一般会出现以下内容:

  • 构造函数,也就是通过非箭头函数声明的普通函数,大写表示

  • prototype属性,构造函数上的一个属性,指向对象原型

  • 对象实例,通过new一个构造函数得到对象

  • __proto__属性,对象实例上的一个属性,指向原型对象,非标准属性,浏览器实现中存在

  • constructor属性,原型对象上的一个属性,指向构造函数本身,由于构造函数的prototype属性和对象实例的__proto__都指向原型对象,所以它们都有这个属性

它们之间的关系:

可以看出,prototype和constructor是循环引用的

/** 
 * 如前所述,构造函数有一个 prototype 属性
 * 引用其原型对象,而这个原型对象也有一个
 * constructor 属性,引用这个构造函数
 * 换句话说,两者循环引用:
 */ 
console.log(Person.prototype.constructor === Person); // true

正常的原型链都会终止于Object,Object原型的原型是null

console.log(Person.prototype.__proto__ === Object.prototype); // true 
console.log(Person.prototype.__proto__.constructor === Object); // true 
console.log(Person.prototype.__proto__.__proto__ === null); // true 
console.log(Person.prototype.__proto__); 
// { 
// constructor: f Object(), 
// toString: ... 
// hasOwnProperty: ... 
// isPrototypeOf: ... 
// ... 
// }

这里为什么是__proto__而不是prototype?因为__proto__是原型链的连接属性,而prototype是原型对象

构造函数、原型对象和实例是三个完全不同的对象

console.log(person1 !== Person); // true 
console.log(person1 !== Person.prototype); // true 
console.log(Person.prototype !== Person); // true

实例通过__proto__连接到原型对象,它实际上指向隐藏特性[[Prototype]]

构造函数通过prototype属性连接到原型对象

所以再次强调:实例与构造函数没有直接联系,与原型对象有直接联系

console.log(person1.__proto__ === Person.prototype); // true 
conosle.log(person1.__proto__.constructor === Person); // true

同一个构造函数创建的两个实例,共享同一个原型对象

console.log(person1.__proto__ === person2.__proto__); // true

instanceof 检查实例的原型链中是否包含指定构造函数的原型

console.log(person1 instanceof Person); // true 
console.log(person1 instanceof Object); // true 
console.log(Person.prototype instanceof Object); // true

总结原型各个概念关系图:

虽然不是所有实现都对外暴露了[[Prototype]],但可以使用 isPrototypeOf()方法确定两个对象之间的这种关系。本质上,isPrototypeOf()会在传入参数的[[Prototype]]指向调用它的对象时返回 true

ECMAScript 的 Object 类型有一个方法叫 Object.getPrototypeOf(),返回参数的内部特性[[Prototype]]的值

Object.getPrototypeOf(Person) === Function.prototype // true

Object类型还有一个setPrototypeOf()方法,这个方法用于在指定对象上新增Object 类型还有一个 setPrototypeOf()方法,可以向实例的私有特性[[Prototype]]写入一个新值。这样就可以重写一个对象的原型继承关系:

let biped = { 
 numLegs: 2 
}; 
let person = { 
 name: 'Matt' 
}; 
Object.setPrototypeOf(person, biped); 
console.log(person.name); // Matt 
console.log(person.numLegs); // 2 
console.log(Object.getPrototypeOf(person) === biped); // true

警告 Object.setPrototypeOf()可能会严重影响代码性能。Mozilla 文档说得很清楚:“在所有浏览器和 JavaScript 引擎中,修改继承关系的影响都是微妙且深远的。这种影响并不仅是执行 Object.setPrototypeOf()语句那么简单,而是会涉及所有访问了那些修改过[[Prototype]]的对象的代码。”

另一个替换方案是通过Object.create()方法来创建一个新对象,并指定其原型:

 numLegs: 2 
}; 
let person = Object.create(biped); 
person.name = 'Matt'; 
console.log(person.name); // Matt 
console.log(person.numLegs); // 2 
console.log(Object.getPrototypeOf(person) === biped); // true

2.原型层级

在通过对象访问属性时,会按照这个属性的名称开始搜索,搜索开始于实例本身,指针会沿着实例的属性进行搜索,如果没有的话就移动到原型对象上继续寻找。

实例对象都会按照这种方式去寻找属性和方法,由于多个实例对象都指向同一个原型,因此实例使用原型的属性时,寻找到的属性或方法是同一个。这就是原型对象用于在各实例对象间共享属性和方法的原理

注意 前面提到的 constructor 属性只存在于原型对象,因此通过实例对象也是可以访问到的。

虽然不同实例可以共享原型对象上的属性和方法,但是这些实例是没有办法重写原型上的属性和方法的,如果修改同名的属性和方法,就会在实例上创建一个新的属性或方法

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let person1 = new Person(); 
let person2 = new Person(); 
person1.name = "Greg"; 
console.log(person1.name); // "Greg",来自实例
console.log(person2.name); // "Nicholas",来自原型

由于person1在前面重写定义了person1.name的值,person实例上创建了一个name属性,当person1去搜索name属性时,搜索截至到实例上就结束了,不会再去搜索原型对象,因此输出的值是Greg

只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。即使在实例上把这个属性设置为 null,也不会恢复它和原型的联系。不过,使用 delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let person1 = new Person(); 
let person2 = new Person(); 
person1.name = "Greg"; 
console.log(person1.name); // "Greg",来自实例
console.log(person2.name); // "Nicholas",来自原型
delete person1.name; 
console.log(person1.name); // "Nicholas",来自原型

hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自 Object的,会在属性存在于调用它的对象实例上时返回 true

function Person() {} 
Person.prototype.name = "Nicholas"; 

const p1 = new Person();
console.log(p1.hasOwnProperty("name"));// false

注意 ECMAScript 的 Object.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用 Object.getOwnPropertyDescriptor()

3.原型和in操作符

有两种方式可以使用in操作符:

  • 直接使用:单独使用时,in 操作符会在可以通过对象访问指定属性时返回 true,无论属性是在实例上还是原型上

  • 通过for-in循环使用:在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽原型中不可枚举([[Enumerable]]特性被设置为 false)属性的实例属性也会在 for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let person1 = new Person(); 
let person2 = new Person(); 
console.log(person1.hasOwnProperty("name")); // false 
console.log("name" in person1); // true 
person1.name = "Greg"; 
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty("name")); // true 
console.log("name" in person1); // true 
console.log(person2.name); // "Nicholas",来自原型
console.log(person2.hasOwnProperty("name")); // false 
console.log("name" in person2); // true 
delete person1.name; 
console.log(person1.name); // "Nicholas",来自原型
console.log(person1.hasOwnProperty("name")); // false 
console.log("name" in person1); // true

在上例中,name随时都可以通过实例或者原型访问到,因此使用in操作符始终返回的是true

in操作符可以和hasOwnPrototype()方法联用确定属性是否在原型上:

function hasPrototypeProperty(object, name){
	return !object.hasOwnPrototype(name) && (name in object);
}

如果想要获取一个对象上的所有属性,可以使用Object.keys()方法,这个方法接收一个对象参数,返回改对象上所有属性名称组成的字符串数组

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let keys = Object.keys(Person.prototype); 
console.log(keys); // "name,age,job,sayName" 
let p1 = new Person(); 
p1.name = "Rob"; 
p1.age = 31; 
let p1keys = Object.keys(p1); 
console.log(p1keys); // "[name,age]"

Object.keys()方法只返回当前对象上的属性,for-in中的in操作符返回当前对象和原型对象上的属性

如果想列出所有实例属性,无论是否可以枚举,都可以使用 Object.getOwnPropertyNames()

回忆一下:这个方法只能返回非符号键的属性名称,要获取符号键的属性名称,使用Object.getOwnPropertySymbol()方法,这个方法是ES6出现的,因为ES6出现的符号键是没有名称的概念的,因此Object.getOwnPropertyNames()方法不能返回以符号为键的属性

let keys = Object.getOwnPropertyNames(Person.prototype); 
console.log(keys); // "[constructor,name,age,job,sayName]"

注意,返回的结果中包含了一个不可枚举的属性 constructor。Object.keys()和 Object.getOwnPropertyNames()在适当的时候都可用来代替 for-in 循环

4.属性枚举顺序

for-in 循环、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()以及 Object.assign()在属性枚举顺序方面有很大区别

for-in 循环和 Object.keys()的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异

Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()以及 Object.assign() 的枚举顺序是确定的

这三个方法的枚举顺序规则:先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入

let k1 = Symbol('k1'), 
 k2 = Symbol('k2'); 
let o = { 
 1: 1, 
 first: 'first', 
 [k1]: 'sym2', 
 second: 'second', 
 0: 0 
}; 
o[k2] = 'sym2'; 
o[3] = 3; 
o.third = 'third'; 
o[2] = 2; 
console.log(Object.getOwnPropertyNames(o)); 
// ["0", "1", "2", "3", "first", "second", "third"] 
console.log(Object.getOwnPropertySymbols(o)); 
// [Symbol(k1), Symbol(k2)]

对象迭代

在 JavaScript 有史以来的大部分时间内,迭代对象属性都是一个难题。ECMAScript 2017 新增了两个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式

  • Object.values():传入一个对象,返回对象属性值组成的数组

  • Object.entries():传入一个对象,返回对象属性和属性值组成的键值对数组,键值对也是用数组存储的,返回其实是一个二维数组

const o = { 
 foo: 'bar', 
 baz: 1, 
 qux: {} 
}; 
console.log(Object.values(o));
// ["bar", 1, {}] 
console.log(Object.entries((o))); 
// [["foo", "bar"], ["baz", 1], ["qux", {}]]

注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制

 qux: {} 
}; 
console.log(Object.values(o)[0] === o.qux); 
// true 
console.log(Object.entries(o)[0][1] === o.qux); 
// true

符号属性会被忽略

const sym = Symbol(); 
const o = { 
 [sym]: 'foo' 
}; 
console.log(Object.values(o)); 
// [] 
console.log(Object.entries((o))); 
// []

1.其他原型语法

通过一个包含所有属性和方法的对象字面量来重写原型:

function Person() {} 
Person.prototype = {
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
};

这样写的问题在于,创建函数时默认创建的原型对象被覆盖了,原型对象上的constructor属性也被覆盖了,不在指向构造函数本身,虽然 instanceof 操作符还能可靠地返回值,但我们不能再依靠 constructor 属性来识别类型了

let friend = new Person(); 
console.log(friend instanceof Object); // true 
console.log(friend instanceof Person); // true
console.log(friend.constructor == Person); // false 
console.log(friend.constructor == Object); // true

如果 constructor 的值很重要,则可以像下面这样在重写原型对象时专门设置一下它的值:

function Person() {} 
Person.prototype = {
 constructor: Person,
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
};

要注意,以这种方式恢复 constructor 属性会创建一个[[Enumerable]]为 true 的属性。而原生 constructor 属性默认是不可枚举的。因此,如果你使用的是兼容 ECMAScript 的 JavaScript 引擎,那可能会改为使用 Object.defineProperty()方法来定义 constructor 属性

function Person() {} 
Person.prototype = { 
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
}; 
// 恢复 constructor 属性
Object.defineProperty(Person.prototype, "constructor", { 
 enumerable: false, 
 value: Person 
})

注意以这种方式回复的constructor属性会创建一个[[Enumerable]]为true的内部特性,但是元素的constructor属性是不可枚举的

因此,如果你使用的是兼容 ECMAScript 的 JavaScript 引擎,那可能会改为使用 Object.defineProperty()方法来定义 constructor 属性

function Person() {} 
Person.prototype = { 
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
}; 
// 恢复 constructor 属性
Object.defineProperty(Person.prototype, "constructor", { 
 enumerable: false, 
 value: Person 
});

2.原型的动态性

因为在原型上搜索值的过程是动态的,因此即便某些属性和方法后添加与示例创建之前,示例依旧可以访问到这些属性和方法

function Person(){};// Person对象

const p1 = new Person(); // 先创建对象实例p1

Person.prototype.sayHi = function() {
	console.log('say Hi!');
} // sayHi方法在p1实例后添加

p1.sayHi(); // p1实例搜索值是动态的,因此依旧能访问到sayHi方法

造成这样的原因是因为实例和原型之间的联系是松散的,实际上,实例和原型之间的连接就是一个简单的指针,而非副本

虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。记住,实例只有指向原型的指针,没有指向构造函数的指针。

function Person() {} 
let friend = new Person(); 
Person.prototype = { 
 constructor: Person, 
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
}; 
friend.sayName(); // 错误

发生错误的原因在于,实例是在重写原型之前创建的,所以实例还是指向对象最初的原型,最初的原型上没有sayName方法

重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型

3.原生对象原型

原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式

所有原生引用类型的构造函数(包括 Object、Array、String 等)都在原型上定义了实例方法。比如,数组实例的 sort()方法就是 Array.prototype 上定义的,而字符串包装对象的 substring()方法也是在 String.prototype 上定义的

console.log(typeof Array.prototype.sort); // "function" 
console.log(typeof String.prototype.substring); // "function"

这种模式的便利性在于,开发者自己也可以往原生原型对象上修改原型方法

// 给String类型创建一个验证传入参数是否是字符串子串的方法
String.prototype.subItem= function (text) { 
 return this.indexOf(text) === 0; 
}; 
let msg = "Hello world!"; 
console.log(msg.subItem("Hello")); // true

注意 尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。这样做很可能造成误会,而且可能引发命名冲突(比如一个名称在某个浏览器实现中不存在,在另一个实现中却存在)。另外还有可能意外重写原生的方法。推荐的做法是创建一个自定义的类,继承原生类型

4.原型的问题

  • 弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值

  • 共享特性会导致所有实例的行为都一样(最主要的问题)

原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,真正的问题来自包含引用值的属性

function Person() {} 
Person.prototype = { 
 constructor: Person, 
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 friends: ["Shelby", "Court"],
sayName() { 
 console.log(this.name); 
 } 
}; 
let person1 = new Person(); 
let person2 = new Person(); 
person1.friends.push("Van"); 
console.log(person1.friends); // "Shelby,Court,Van" 
console.log(person2.friends); // "Shelby,Court,Van" 
console.log(person1.friends === person2.friends); // true

一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。

继承

继承是面向对象编程中讨论最多的话题。很多面向对象语言都支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的

原型链

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法

function SuperType() { 
 this.property = true; 
} 
SuperType.prototype.getSuperValue = function() { 
 return this.property; 
}; 
function SubType() { 
 this.subproperty = false; 
} 
// 继承 SuperType 
SubType.prototype = new SuperType(); 
SubType.prototype.getSubValue = function () {
 return this.subproperty; 
}; 
let instance = new SubType(); 
console.log(instance.getSuperValue()); // true

SuperType 和 SubType这两个类型分别定义了一个属性和一个方法,这两个类型的主要区别是 SubType 通过创建 SuperType 的实例并将其赋值给自己的原型 SubTtype.prototype 实现了对 SuperType 的继承

这个赋值重写了 SubType 最初的原型,将其替换为SuperType 的实例。这意味着 SuperType 实例可以访问的所有属性和方法也会存在于SubType.prototype。

这样实现继承之后,代码紧接着又给 SubType.prototype,也就是这个 SuperType 的实例添加了一个新方法。最后又创建了 SubType 的实例并调用了它继承的 getSuperValue()方法

这个例子中继承的关键在于,SubType没有使用默认原型,而是将其替换为一个新的对象,并且这个对象是SuperType的实例

这样一来,SubType 的实例不仅能从 SuperType 的实例中继承属性和方法,而且还与 SuperType 的原型挂上了钩

于是 instance(通过内部的[[Prototype]])指向SubType.prototype,而 SubType.prototype(作为 SuperType 的实例又通过内部的[[Prototype]],这个[[Prototype]]实际上就是__proto__属性)指向 SuperType.prototype。

总结未完

1.默认原型

默认情况下,所有的原型都继承自Object,这也是通过原型链实现的。

任何函数的默认原型都是一个Object对象的实例,这意味着这个实例有一个内部指针指向Object.prototype。这就是为什么自定义类型能继承toValue、toString()等在内的所有默认方法的原因。

因此对于前面的原型链,完整的关系应该是:

SubType 继承 SuperType,而 SuperType 继承 Object。在调用 instance.toString()时,实际上调用的是保存在 Object.prototype 上的方法

2.原型与继承关系

原型与实例的关系可以通过两种方式来确定:

  • 使用instanceof操作符,如果一个实例的原型链中出现过相应的构造函数,则instanceof返回true

console.log(instance instanceof Object); // true 
console.log(instance instanceof SuperType); // true 
console.log(instance instanceof SubType); // true
  • 使用isPrototypeOf()方法:原型链中每一个原型都可以调用这个方法,主要原型链中包含原型,就返回true

console.log(Object.prototype.isPrototypeOf(instance)); // true 
console.log(SuperType.prototype.isPrototypeOf(instance)); // true 
console.log(SubType.prototype.isPrototypeOf(instance)); // true

3.关于方法

子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上

function SuperType() { 
 this.property = true; 
} 
SuperType.prototype.getSuperValue = function() { 
 return this.property; 
}; 
function SubType() { 
 this.subproperty = false; 
} 
// 继承 SuperType 
SubType.prototype = new SuperType(); 
// 新方法
SubType.prototype.getSubValue = function () { 
 return this.subproperty; 
}; 
// 覆盖已有的方法
SubType.prototype.getSuperValue = function () { 
 return false; 
}; 
let instance = new SubType(); 
console.log(instance.getSuperValue()); // false

另一个要理解的重点是,以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链

function SuperType() { 
 this.property = true; 
} 
SuperType.prototype.getSuperValue = function() { 
 return this.property; 
}; 
function SubType() { 
 this.subproperty = false; 
}
// 继承 SuperType 
SubType.prototype = new SuperType(); 
// 通过对象字面量添加新方法,这会导致上一行无效
SubType.prototype = { 
 getSubValue() { 
 return this.subproperty; 
 }, 
 someOtherMethod() { 
 return false; 
 } 
}; 
let instance = new SubType(); 
console.log(instance.getSuperValue()); // 出错!

4.原型链的问题

原型链虽然是实现继承的强大工具,但它也有问题。主要问题出现在原型中包含引用值的时候。前面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。

function SuperType() { 
 this.colors = ["red", "blue", "green"]; 
} 
function SubType() {} 
// 继承 SuperType 
SubType.prototype = new SuperType(); 
let instance1 = new SubType(); 
instance1.colors.push("black"); 
console.log(instance1.colors); // "red,blue,green,black" 
let instance2 = new SubType(); 
console.log(instance2.colors); // "red,blue,green,black"

在这个例子中,SubType通过原型链的方式继承了SuperType,同时也获得了colors的访问权,但是问题在于,当创建SubType的实例,并且实例对colors进行修改时,这样的影响会体现在另外一个实例instance2上。

我们想要的继承是:不同实例有一定的独立性

原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。

盗用构造函数

为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)

基本思路很简单:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()和 call()方法以新创建的对象为上下文执行构造函数

function SuperType() { 
 this.colors = ["red", "blue", "green"]; 
} 
function SubType() { 
 // 继承 SuperType 
 SuperType.call(this); 
} 
let instance1 = new SubType(); 
instance1.colors.push("black"); 
console.log(instance1.colors); // "red,blue,green,black" 
let instance2 = new SubType(); 
console.log(instance2.colors); // "red,blue,green"

这里就很巧妙,因为call方法的有点在于它可以指定执行函数时的this值,那么这里就把SubType中的this值传给了SuperType,所以SuperType构造函数的this其实是SubType,继承的方式其实等价于在SubType构造函数中执行this.colors = ["red", "blue", "green"];

这样就巧妙的避开了原型包含引用值导致的继承问题,因为每一次创建实例都会调用一次构造函数,构造函数又会初始化this值

1.传递参数

相比于原型链,盗用构造函数还有一个优点就是可以在子类构造函数中向父类构造函数传参

function SuperType(name){ 
 this.name = name; 
} 
function SubType() { 
 // 继承 SuperType 并传参
 SuperType.call(this, "Nicholas"); 
 // 实例属性
 this.age = 29; 
} 
let instance = new SubType(); 
console.log(instance.name); // "Nicholas"; 
console.log(instance.age); // 29

2.盗用构造函数的问题

盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用

组合继承

组合继承又被称为经典继承,综合了原型链和盗用构造函数,将两者的优点集中起来,基本思路就是,使用原型链继承原型上的属性和方法,使用盗用构造函数继承实例属性

继承原型上的属性和方法意味着后续在原型上新增方法和属性,其子类实例就可以共享新增的方法和属性

继承实例属性意味着可以传递参数和初始化属性

function SuperType(name){ 
 this.name = name; 
 this.colors = ["red", "blue", "green"]; 
} 
SuperType.prototype.sayName = function() { 
 console.log(this.name); 
}; 
function SubType(name, age){ 
 // 继承属性
 SuperType.call(this, name); 
 this.age = age; 
} 
// 继承方法
SubType.prototype = new SuperType(); 
SubType.prototype.sayAge = function() { 
 console.log(this.age); 
}; 
let instance1 = new SubType("Nicholas", 29); 
instance1.colors.push("black"); 
console.log(instance1.colors); // "red,blue,green,black" 
instance1.sayName(); // "Nicholas"; 
instance1.sayAge(); // 29 
let instance2 = new SubType("Greg", 27); 
console.log(instance2.colors); // "red,blue,green" 
instance2.sayName(); // "Greg"; 
instance2.sayAge(); // 27

组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力

原型式继承

2006 年,Douglas Crockford 写了一篇文章:《JavaScript 中的原型式继承》(“Prototypal Inheritance in JavaScript”)。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。文章最终给出了一个函数

function object(o) {
	function F() {};
	F.prototype = o;
	return new F();
}

这个 object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质上,object()是对传入的对象执行了一次浅复制。

let person = { 
 name: "Nicholas", 
 friends: ["Shelby", "Court", "Van"] 
}; 
let anotherPerson = object(person); 
anotherPerson.name = "Greg"; 
anotherPerson.friends.push("Rob"); 
let yetAnotherPerson = object(person); 
yetAnotherPerson.name = "Linda"; 
yetAnotherPerson.friends.push("Barbie"); 
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"

在这个例子中,person 对象定义了另一个对象也应该共享的信息,把它传给 object()之后会返回一个新对象。这个新对象的原型是 person,意味着它的原型上既有原始值属性又有引用值属性。这也意味着 person.friends 不仅是person 的属性,也会跟 anotherPerson 和 yetAnotherPerson 共享。这里实际上克隆了两个 person

原型式继承适用于这种情况:你有一个对象,想要在它的基础上再创建一个对象,你需要把这个对象先传给object,然后再对返回的对象进行适当的修改

ES5通过新增Object.create()方法将原型式继承的概念规范化了,这个方法接收两个参数:

  • 作为新对象原型的对象

  • 需要新增的属性,与 Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述

注意以这种方式添加的属性会遮蔽原型对象上的同名属性

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

寄生式继承

寄生式继承是接近于原型式继承的一种继承方式,背后的思路类似于寄生构造函数和工厂模式:

创建一个实现继承的函数,以某种方式来增强对象,然后返回这个对象

function object (o){
    function  F() {};
    F.prototype = o;
    return new F();
}
function createAnother(o) {
    let clone = object(o);
    clone.sayHi = function () {
        console.log(`Hi!${this.name}`);
    }
    return clone;
}

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用

注意:通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似

寄生组合式继承

组合式继承会进行两次构造函数的调用,一次是使用new调用,一次是通过call调用,两次调用带来了以下问题:

  • 属性冗余:通过观察可以发现,组合式继承的子类,其子类对象上和子类原型上会用相同的属性

  • 效率问题:由于两次调用会造成一定的效率问题,因为两次调用有部分工作是一样的

function SuperType(name) { 
 this.name = name; 
 this.colors = ["red", "blue", "green"]; 
} 
SuperType.prototype.sayName = function() { 
 console.log(this.name); 
}; 
function SubType(name, age){ 
 SuperType.call(this, name); // 第二次调用 SuperType() 
 this.age = age; 
} 
SubType.prototype = new SuperType(); // 第一次调用 SuperType() 
SubType.prototype.constructor = SubType; 
SubType.prototype.sayAge = function() { 
 console.log(this.age); 
};

通过下图展示这个过程:

如图所示,有两组 name 和 colors 属性:一组在实例上,另一组在 SubType 的原型上。这是调用两次 SuperType 构造函数的结果。好在有办法解决这个问题:

寄生组合式继承的思路是不通过调用父类构造函数来给子类原型赋值,而是取得父类原型的一个副本总结就是保留盗用构造函数继承属性,更换原型赋值方式

说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型

function inheritPrototype(subType, superType) { 
 let prototype = object(superType.prototype); // 创建对象
 prototype.constructor = subType; // 增强对象 
 subType.prototype = prototype; // 赋值对象
}

这个 inheritPrototype()函数实现了寄生式组合继承的核心逻辑。这个函数接收两个参数:子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的prototype 对象设置 constructor 属性,解决由于重写原型导致默认 constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。如下例所示,调用 inheritPrototype()就可以实现前面例子中的子类型原型赋值

 function SuperType(name) { 
 this.name = name; 
 this.colors = ["red", "blue", "green"]; 
} 
SuperType.prototype.sayName = function() { 
 console.log(this.name); 
}; 
function SubType(name, age) { 
 SuperType.call(this, name);
  this.age = age; 
} 
inheritPrototype(SubType, SuperType); 
SubType.prototype.sayAge = function() { 
 console.log(this.age); 
};

这里只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此 instanceof 操作符和isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式

ECMAScript 6 新引入的 class 关键字具有正式定义类的能力

类(class)是ECMAScript 中新的基础性语法糖结构

虽然 ECMAScript 6 类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念

类定义

与函数定义相似,类定义也有两种方式:

  • 类声明:class P {}

  • 类表达式 const P = class {}

与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能

console.log(FunctionExpression); // undefined 
var FunctionExpression = function() {}; 
console.log(FunctionExpression); // function() {} 
console.log(FunctionDeclaration); // FunctionDeclaration() {} 
function FunctionDeclaration() {} 
console.log(FunctionDeclaration); // FunctionDeclaration() {} 
console.log(ClassExpression); // undefined 
var ClassExpression = class {}; 
console.log(ClassExpression); // class {} 
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined 
class ClassDeclaration {} 
console.log(ClassDeclaration); // class ClassDeclaration {}

另外一个和函数不同的是:函数受函数作用域限制,类受块作用域限制

{ 
 function FunctionDeclaration() {} 
 class ClassDeclaration {} 
} 
console.log(FunctionDeclaration); // FunctionDeclaration() {} 
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined

类的构成

类的构成要素:构造函数方法,实例方法,获取函数,设置函数和静态类方法。但这些都不是必须的,空类也不会报错

默认情况下,类中定义的代码都在严格模式下执行

与大多数编程语言类的命名风格类似,ES类名大写,实例小写

// 空类定义,有效 
class Foo {} 
// 有构造函数的类,有效
class Bar { 
 constructor() {} 
} 
// 有获取函数的类,有效
class Baz { 
 get myBaz() {} 
} 
// 有静态方法的类,有效
class Qux { 
 static myQux() {} 
}

类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符

let Person = class PersonName { 
 identify() { 
 console.log(Person.name, PersonName.name); 
 } 
} 
let p = new Person(); 
p.identify(); // PersonName PersonName 
console.log(Person.name); // PersonName 
console.log(PersonName); // ReferenceError: PersonName is not defined

类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

constructor的作用:在使用new关键字创建类的实例的时候告诉解释器调用的函数的名称

1.实例化

使用new操作符实例化类的操作等于使用new调用其构造函数

使用new调用类的构造函数会执行以下操作:

  • 在内存中创建一个新对象

  • 新对象的[[prototype]]指针被赋值为构造函数的prototype属性

  • 构造函数内部的this值指向新对象

  • 执行构造函数内部的代码(给新对象添加属性)

  • 如果构造函数返回非空对象,则返回该对象,否则返回刚创建的新对象

class Animal {} 
class Person { 
 constructor() { 
 console.log('person ctor'); 
 } 
} 
class Vegetable { 
 constructor() { 
 this.color = 'orange'; 
 } 
} 
let a = new Animal(); 
let p = new Person(); // person ctor 
let v = new Vegetable(); 
console.log(v.color); // orange

类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的,加不加括号取决于要不要传入参数,不取决于是不是空类

默认情况下,类的构造函数会在执行后返回this对象。

构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的this对象,那么它就会被销毁。

不过,如果返回的不是this对象而是其他对象,那么这个对象(其他对象)不会通过instanceof检测出与类有关联(也就是说,这个其他对象和类是没有关联的),因为这个对象还保持着原型指针,并没有修改

class Person { 
 constructor(override) { 
 this.foo = 'foo'; 
 if (override) { 
 return { 
 bar: 'bar' 
 }; 
 } 
 } 
} 
let p1 = new Person(), 
 p2 = new Person(true); 
console.log(p1); // Person{ foo: 'foo' } 
console.log(p1 instanceof Person); // true 
console.log(p2); // { bar: 'bar' } 
console.log(p2 instanceof Person); // false

类构造函数和构造函数最大的区别在于:构造函数可以不使用new关键字调用,但是这样构造函数会把全局的this作为内部对象。类构造函数必须使用new关键字调用,否则将会抛出错误

function Person() {} 
class Animal {} 
// 把 window 作为 this 来构建实例
let p = Person(); 
let a = Animal(); 
// TypeError: class constructor Animal cannot be invoked without 'new'

类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用 new 调用)。因此,实例化之后可以在实例上引用它:

class Person {} 
// 使用类创建一个新实例
let p1 = new Person(); 
p1.constructor(); 
// TypeError: Class constructor Person cannot be invoked without 'new' 
// 使用对类构造函数的引用创建一个新实例
let p2 = new p1.constructor();

2.把类当成特殊的函数

ECMAScript 中没有正式的类这个类型。从各方面来看,ECMAScript 类就是一种特殊函数。声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数

类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身

class Person{} 
console.log(Person.prototype); // { constructor: f() } 
console.log(Person === Person.prototype.constructor); // true

与普通构造函数一样,可以使用 instanceof 操作符检查构造函数原型是否存在于实例的原型链中

class Person {} 
let p = new Person(); 
console.log(p instanceof Person); // true

在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用instanceof 操作符时会返回 false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会反转

class Person {} 
let p1 = new Person(); 
console.log(p1.constructor === Person); // true 
console.log(p1 instanceof Person); // true 
console.log(p1 instanceof Person.constructor); // false 
let p2 = new Person.constructor(); 
console.log(p2.constructor === Person); // false 
console.log(p2 instanceof Person); // false 
console.log(p2 instanceof Person.constructor); // true

类是 JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递

// 类可以像函数一样在任何地方定义,比如在数组中
let classList = [ 
 class { 
 constructor(id) { 
 this.id_ = id; 
 console.log(`instance ${this.id_}`); 
 } 
 } 
]; 
function createInstance(classDefinition, id) { 
 return new classDefinition(id); 
} 
let foo = createInstance(classList[0], 3141); // instance 3141

与立即调用函数表达式相似,类也可以立即实例化

// 因为是一个类表达式,所以类名是可选的
let p = new class Foo {
 constructor(x) { 
 console.log(x); 
 } 
}('bar'); // bar 
console.log(p); // Foo {}

实例、原型和类成员

类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员

1.实例成员

每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享

class Person { 
 constructor() { 
 // 这个例子先使用对象包装类型定义一个字符串
 // 为的是在下面测试两个对象的相等性
 this.name = new String('Jack'); 
 this.sayName = () => console.log(this.name); 
 this.nicknames = ['Jake', 'J-Dog'] 
 } 
} 
let p1 = new Person(), 
 p2 = new Person(); 
p1.sayName(); // Jack 
p2.sayName(); // Jack 
console.log(p1.name === p2.name); // false 
console.log(p1.sayName === p2.sayName); // false 
console.log(p1.nicknames === p2.nicknames); // false 
p1.name = p1.nicknames[0]; 
p2.name = p2.nicknames[1]; 
p1.sayName(); // Jake 
p2.sayName(); // J-Dog

2.原型方法与访问器

为了在示例间共享,类定义把在类中定义的方法作为原型方法

class Person { 
 constructor() { 
 // 添加到 this 的所有内容都会存在于不同的实例上
 this.locate = () => console.log('instance'); 
 }
 // 在类块中定义的所有内容都会定义在类的原型上
 locate() { 
 console.log('prototype'); 
 } 
} 
let p = new Person(); 
p.locate(); // instance 
Person.prototype.locate(); // prototype

可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据

class Person { 
 name: 'Jake' 
} 
// Uncaught SyntaxError: Unexpected token

类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:

class Person { 
 name: 'Jake' 
} 
// Uncaught SyntaxError: Unexpected token 
类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:
const symbolKey = Symbol('symbolKey'); 
class Person { 
 stringKey() { 
 console.log('invoked stringKey'); 
 } 
 [symbolKey]() { 
 console.log('invoked symbolKey'); 
 } 
 ['computed' + 'Key']() { 
 console.log('invoked computedKey'); 
 } 
} 
let p = new Person(); 
p.stringKey(); // invoked stringKey 
p[symbolKey](); // invoked symbolKey 
p.computedKey(); // invoked computedKey

类定义也支持获取和设置访问器,其行为和普通对象一样

class Person { 
 set name(newName) { 
 this.name_ = newName; 
 } 
 get name() { 
 return this.name_; 
 } 
} 
let p = new Person(); 
p.name = 'Jake'; 
console.log(p.name); // Jake

3.静态方法

可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。与原型成员类似,静态成员每个类上只能有一个。

为什么说静态成员每个类上只能有一个?

这里说的静态成员每个类上只能有一个不是说只能在类上定义一个静态属性或静态方法,这里说的是静态成员的副本只有一个,类和实例共享的只有这一个副本,因此说静态成员每个类上只有一个,并且这个副本实例不能直接访问,只能通过类名访问

说实例共享静态成员是指所有实例都共享同一个类级别的静态成员。这并不意味着你可以通过实例来直接访问静态成员,而是说静态成员是定义在类本身上的,并且对所有实例都是唯一的

静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。其他所有约定跟原型成员一样:

class Person { 
 constructor() { 
 // 添加到 this 的所有内容都会存在于不同的实例上
 this.locate = () => console.log('instance', this); 
 } 
 // 定义在类的原型对象上
 locate() { 
 console.log('prototype', this); 
 } 
 // 定义在类本身上
 static locate() { 
 console.log('class', this); 
 } 
} 
let p = new Person(); 
p.locate(); // instance, Person {} 
Person.prototype.locate(); // prototype, {constructor: ... } 
Person.locate(); // class, class Person {}

静态类方法非常适合作为实例工厂:

class Person { 
 constructor(age) { 
 this.age_ = age; 
 } 
 sayAge() { 
 console.log(this.age_); 
 } 
 static create() { 
 // 使用随机年龄创建并返回一个 Person 实例
 return new Person(Math.floor(Math.random()*100)); 
 } 
} 
console.log(Person.create()); // Person { age_: ... }
// 通过create方法创建实例
const p = Person.create();

4.非函数原型和类成员

虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

class Person { 
 sayName() { 
 console.log(`${Person.greeting} ${this.name}`); 
 } 
} 
// 在类上定义数据成员
Person.greeting = 'My name is';
// 在原型上定义数据成员
Person.prototype.name = 'Jake'; 
let p = new Person(); 
p.sayName(); // My name is Jake

注意 类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过 this引用的数据。

5.迭代器和生成器方法

类定义语法支持在原型和类本身定义生成器方法:

class Person { 
 // 在原型上定义生成器方法
 *createNicknameIterator() { 
 yield 'Jack'; 
 yield 'Jake'; 
 yield 'J-Dog'; 
 } 
 // 在类上定义生成器方法
 static *createJobIterator() { 
 yield 'Butcher'; 
 yield 'Baker'; 
 yield 'Candlestick maker'; 
 } 
} 
let jobIter = Person.createJobIterator(); 
console.log(jobIter.next().value); // Butcher 
console.log(jobIter.next().value); // Baker 
console.log(jobIter.next().value); // Candlestick maker 
let p = new Person(); 
let nicknameIter = p.createNicknameIterator(); 
console.log(nicknameIter.next().value); // Jack 
console.log(nicknameIter.next().value); // Jake 
console.log(nicknameIter.next().value); // J-Dog

因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

class Person { 
 constructor() { 
 this.nicknames = ['Jack', 'Jake', 'J-Dog']; 
 } 
 *[Symbol.iterator]() { 
 yield *this.nicknames.entries(); 
 } 
} 
let p = new Person(); 
for (let [idx, nickname] of p) { 
 console.log(nickname); 
}
// Jack 
// Jake 
// J-Dog

也可以只返回迭代器实例

// 也可以只返回迭代器实例:
class Person { 
 constructor() { 
 this.nicknames = ['Jack', 'Jake', 'J-Dog']; 
 } 
 [Symbol.iterator]() { 
 return this.nicknames.entries(); 
 } 
} 
let p = new Person(); 
for (let [idx, nickname] of p) { 
 console.log(nickname); 
} 
// Jack 
// Jake 
// J-Dog

继承

ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。

虽然类继承使用的是新语法,但背后依旧使用的是原型链

1.继承基础

ES6支持单继承。使用extends关键字,就可以继承任何拥有[[Construct]]和原型的对象

很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)

派生类会通过原型链访问到类和原型上定义的方。this的值会反映调用相应方法的类和实例

class Vehicle { 
 identifyPrototype(id) { 
 console.log(id, this); 
 }
static identifyClass(id) { 
 console.log(id, this); 
 } 
} 
class Bus extends Vehicle {} 
let v = new Vehicle(); 
let b = new Bus(); 
b.identifyPrototype('bus'); // bus, Bus {} 
v.identifyPrototype('vehicle'); // vehicle, Vehicle {} 
Bus.identifyClass('bus'); // bus, class Bus {} 
Vehicle.identifyClass('vehicle'); // vehicle, class Vehicle {}

注意 extends 关键字也可以在类表达式中使用,因此 let Bar = class extends Foo {}是有效的语法。

2.构造函数、homeObject()和super()

派生类的方法可以通过super()来引用它们的原型。这个关键字只能在派生类中使用,并且仅限于构造方法、静态方法和实例方法内部

class Vehicle { 
 constructor() { 
 this.hasEngine = true; 
 } 
} 
class Bus extends Vehicle { 
 constructor() { 
 // 不要在调用 super()之前引用 this,否则会抛出 ReferenceError 
 super(); // 相当于 super.constructor() 
 console.log(this instanceof Vehicle); // true 
 console.log(this); // Bus { hasEngine: true } 
 } 
} 
new Bus();

在静态方法中可以通过 super 调用继承的类上定义的静态方法:

class Vehicle { 
 static identify() { 
 console.log('vehicle'); 
 } 
} 
class Bus extends Vehicle { 
 static identify() { 
 super.identify(); 
 } 
} 
Bus.identify(); // vehicle

注意 ES6 给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为[[HomeObject]]的原型。

在使用 super 时要注意几个问题:

  • super 只能在派生类构造函数和静态方法中使用

  • 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法、

  • 调用 super()会调用父类构造函数,并将返回的实例赋值给 this

class Vehicle {} 
class Bus extends Vehicle { 
 constructor() { 
 super(); 
 console.log(this instanceof Vehicle); 
 } 
} 
new Bus(); // true

why?

当你在派生类的构造函数中访问 this 时,JavaScript 引擎会强制要求你先调用 super()。这是因为在 ES6 中,派生类的 this 是在父类的构造函数被调用之后才创建的。因此,必须首先调用 super() 来初始化父类的部分,然后才能使用 this

  • super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入

class Vehicle { 
 constructor(licensePlate) { 
 this.licensePlate = licensePlate; 
 } 
} 
class Bus extends Vehicle { 
 constructor(licensePlate) { 
 super(licensePlate); 
 } 
} 
console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }
  • 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数。

  • 在类构造函数中,不能在调用 super()之前引用 this。

  • 在类构造函数中,不能在调用 super()之前引用 this

  • 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象

class Vehicle {} 
class Car extends Vehicle {} 
class Bus extends Vehicle { 
 constructor() { 
 super(); 
 } 
} 
class Van extends Vehicle { 
 constructor() { 
 return {}; 
 } 
} 
console.log(new Car()); // Car {} 
console.log(new Bus()); // Bus {} 
console.log(new Van()); // {}

why?

不调用super,派生类没办法设置this值,要么在constructor中返回一个对象充当this值,要么调用super

3.抽象基类

有时候可能需要这样一个特殊的类,它可以供其它类继承,但是本身不会被实例化。虽然 ECMAScript 没有专门支持这种类的语法 ,但通过 new.target 也很容易实现。new.target 保存通过 new 关键字调用的类或函数。通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类的实例化

// 抽象基类 
class Vehicle { 
 constructor() { 
 console.log(new.target); 
 if (new.target === Vehicle) { 
 throw new Error('Vehicle cannot be directly instantiated');
 } 
 } 
} 
// 派生类
class Bus extends Vehicle {} 
new Bus(); // class Bus {} 
new Vehicle(); // class Vehicle {} 
// Error: Vehicle cannot be directly instantiated

另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法

// 抽象基类
class Vehicle { 
 constructor() { 
 if (new.target === Vehicle) { 
 throw new Error('Vehicle cannot be directly instantiated'); 
 } 
 if (!this.foo) { 
 throw new Error('Inheriting class must define foo()'); 
 } 
 console.log('success!'); 
 } 
} 
// 派生类
class Bus extends Vehicle { 
 foo() {} 
} 
// 派生类
class Van extends Vehicle {} 
new Bus(); // success! 
new Van(); // Error: Inheriting class must define foo()

4.继承内置类型

ES6 类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型

class SuperArray extends Array { 
 shuffle() { 
 // 洗牌算法
 for (let i = this.length - 1; i > 0; i--) { 
 const j = Math.floor(Math.random() * (i + 1)); 
 [this[i], this[j]] = [this[j], this[i]]; 
 } 
 } 
} 
let a = new SuperArray(1, 2, 3, 4, 5); 
console.log(a instanceof Array); // true 
console.log(a instanceof SuperArray); // true
console.log(a); // [1, 2, 3, 4, 5] 
a.shuffle(); 
console.log(a); // [3, 1, 4, 5, 2]

有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的

class SuperArray extends Array {} 
let a1 = new SuperArray(1, 2, 3, 4, 5); 
let a2 = a1.filter(x => !!(x%2)) 
console.log(a1); // [1, 2, 3, 4, 5] 
console.log(a2); // [1, 3, 5] 
console.log(a1 instanceof SuperArray); // true 
console.log(a2 instanceof SuperArray); // true

如果想覆盖这个默认行为,则可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类:\

class SuperArray extends Array { 
 static get [Symbol.species]() { 
 return Array; 
 } 
} 
let a1 = new SuperArray(1, 2, 3, 4, 5); 
let a2 = a1.filter(x => !!(x%2)) 
console.log(a1); // [1, 2, 3, 4, 5] 
console.log(a2); // [1, 3, 5] 
console.log(a1 instanceof SuperArray); // true 
console.log(a2 instanceof SuperArray); // false

5.类混入(多继承)

把不同类的行为集中到一个类是一种常见的 JavaScript 模式。虽然 ES6 没有显式支持多类继承,但通过现有特性可以轻松地模拟这种行为

注意 Object.assign()方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用Object.assign()就可以了。

在下面的代码片段中,extends 关键字后面是一个 JavaScript 表达式。任何可以解析为一个类或一个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值:

class Vehicle {} 
function getParentClass() { 
 console.log('evaluated expression'); 
 return Vehicle; 
} 
class Bus extends getParentClass() {} 
// 可求值的表达式

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。如果 Person 类需要组合 A、B、C,则需要某种机制实现 B 继承 A,C 继承 B,而 Person再继承 C,从而把 A、B、C 组合到这个超类中。实现这种模式有不同的策略。

一个策略是定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式:

class Vehicle {} 
let FooMixin = (Superclass) => class extends Superclass { 
 foo() { 
 console.log('foo'); 
 } 
}; 
let BarMixin = (Superclass) => class extends Superclass { 
 bar() { 
 console.log('bar'); 
 } 
}; 
let BazMixin = (Superclass) => class extends Superclass { 
 baz() { 
 console.log('baz'); 
 } 
}; 
class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {} 
let b = new Bus(); 
b.foo(); // foo 
b.bar(); // bar 
b.baz(); // baz

这里的混合的写法,有点类似于函数表达式,是类表达式,这里的class称作匿名类

通过写一个辅助函数,可以把嵌套调用展开:

function mix(BaseClass, ...Mixins) { 
 return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass); 
} 
class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}
let b = new Bus(); 
b.foo(); // foo 
b.bar(); // bar 
b.baz(); // baz

注意 很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。

总结

对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。下面的模式适用于创建对象:

  • 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了。

  • 使用构造函数模式可以自定义引用类型,可以使用 new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。

  • 原型模式解决了成员共享的问题,只要是添加到构造函数 prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。

JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性

除上述模式之外,还有以下几种继承模式。

  • 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。

  • 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。

  • 寄生组合继承被认为是实现基于类型继承的最有效方式。

ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。

0

评论区