JavaScript高级程序设计
第1章 什么是JavaScript
1.1 简短的历史回顾
1995年网景公司发布了Mocha(后来改名为LiveScript)。为了赶上发布时间与Sun公司结为开发联盟,共同完成LiveScript的开发。发布前改名为JavaScript。不久后微软发布了IE3,其中包含自己命名为JScript的JavaScript实现。这就意味着出现了两个版本的JavaScript。
1997年,第39技术委员会承担了"标准化一门通用、跨平台、厂商中立的脚本语言的语法和语义"的任务。他们花数月打造出ECMA-262,也就是ECMAScript这个新的脚本语言标准。
1.2 JavaScript的实现
完整的JavaScript包含以下几个部分:
核心(ECMAScript)
文档对象模型(DOM)
浏览器对象模型(BOM)
1.2.1 ECMAScript
ECMAScript即ECMA-262定义的语言,并不局限于Web浏览器。Web浏览器只是ECMAScript实现可能存在的一种宿主环境。
- ECMAScript版本
- ECMAScript符合性是什么意思
ECMA-262阐述了什么是ECMAScript符合性。要成为ECMAScript实现,必选满足下列条件:
- 支持ECMA-262中描述的所有"类型、值、对象、属性、函数,以及程序语法与语义";
- 支持Unicode字符标准
此外,符合性还可以满足下列要求。
- 增加ECMA-262中未提及的"额外的类型、值、对象、属性和函数"。ECMA-262所说的这些额外内容主要指规范中未给出的新对象或对象的新属性
- 支持ECMA-262中没有定义的"程序和正则表达式语法"
- 浏览器对ECMAScript的支持
1.2.2 DOM
1.2.3 BOM
1.3 JavaScript版本
1.4 小结
JavaScript得到了五大Web浏览器(IE、Firefox、Chrome、Safari和Opera)不同程度的支持。所有浏览器基本上对ES5提供了完善的支持,而对ES6和ES7的支持度也在不断提升。这些浏览器对DOM的支持各不相同
第2章 HTML中的JavaScript
2.1 <\script>元素
<\script>元素有下列8个属性:
- async: 可选。表示应该立即开始下载脚本,但不能阻止其他页面动作,只对外部脚本文件有效。
- charset: 可选。使用src属性指定的代码字符集。很少用
- crossorigin: 可选。配置相关请求的CORS(跨境资源共享)设置。默认不使用CORS。
- defer: 可选。表示脚本可以延迟到文档完全被解析和显示之后再执行。只对外部脚本文件有效。
- integrity: 可选。允许比对接收到的资源和指定的加密签名以验证子资源完整性。如果接收到的资源的签名与这个属性指定的签名不匹配,则页面会报错,脚本不会执行。这个属性可以用于确保内容发布网络不会提供恶意内容。
- language: 废弃。
- src: 可选。表示包含要执行的代码的外部文件。
- type: 可选。代替language,表示代码块中脚本语言的内容类型(也称MIME类型)“text/javascript”
2.1.1 标签位置
把所有的javascript文件都放在里,也就意味着必须把所有javascript代码都下载、解析和解释完成后,才能开始渲染页面(页面在浏览器解析到的起始标签时开始渲染)。对于需要很多JavaScript的页面,这会导致页面渲染明显延迟,在此期间浏览器窗口完全空白。因此应将所有的javascript文件放在元素中的页面内容后。
2.1.2 推迟执行脚本
2.1.3 异步执行脚本
区别:标记为async的脚本并不能保证能按照他们出现的次序执行,第二个脚本肯先于第一个脚本。
defer标记的脚本会按照顺序依次执行,第一个推迟的脚本会在第二个推迟的脚本之前执行。
2.1.4 动态加载脚本
let script = document.creatElement('script');
script.src = 'gibberish.js';
document.head.appendChild(script);
2.1.5 XHTML中的变化
XHTML(可扩展超文本标记语言)是将HTML作为XML的应用重新包装的结果,在XHTML中使用JavaScript必须指定type属性且值为text/javascript,HTML中则可以没有这个属性。
在XHTML中编写代码规则比HTML严格,这会影响使用
a < b语句中的小于号(<)会被解释为一个标签的开始,并且由于作为标签的开始后面不能有空格会导致语法错误
避免这个语法错误有两个方法:
把所有的小于号改成 (<)
把所有代码都包含到一个CDATA块中,在兼容XHTML的浏览器中,这样可以解决。但在不支持CDATA块的非XHTML兼容浏览器中则不行,为此必须使用JavaScript注释来抵消。
2.1.6 废弃的语法
2.2 行内代码与外部文件
推荐使用外部文件:
- 可维护性
- 缓存
- 适应未来
包含外部JavaScript文件的语法在HTMl和XHTML中一样
2.3 文档模式
2.4 <\noscript>元素
可以包含任何可以出现在中的HTML元素。
浏览器将显示包含在中的内容:
- 浏览器不支持脚本
- 浏览器对脚本的支持被关闭
任何一个条件被满足,包含在中的内容就会被渲染,否则浏览器不会渲染里面的内容。
第3章 语言基础
3.1 语法
3.1.1 区分大小写
3.1.2 标识符
就是变量、函数、属性或函数参数的名称。标识符可以由一个或多个下列字符组成:
- 第一个字符必须是一个字母、下划线(_)或美元符号($);
- 剩余的其他字符可以是字母、下划线、 美元符号或数字
3.1.3注释
3.1.4严格模式
ES5新增的概念,是一种不同的javascript解析和执行模式,ES3的一些不规范的写法在这个模式下会被处理,对于不安全的活动将抛出错误。要对整个脚本启用严格模式,在脚本的开头加上这一行:
“use strict”;
预处理指令,任何支持的javascript引擎看到它都会切换到严格模式。选择这种语法形式的目的是不来破坏ES3语法
也可以单独指定一个函数在严格模式下执行,只要把这个预处理指令放到函数体开头即可:
function doSometing() {
"use strict";
//函数体
}
3.1.5语句
3.2关键字与保留字
3.3 变量
3.3.1 var 关键字
- var 声明作用域
使用var操作符定义的变量会成为包含它的函数的局部变量(该变量在函数退出时被销毁)
function test() {
var message = "hi"; //局部变量
}
test();
console.log(message); //出错
- var声明提升
这个关键字声明的变量会自动提升到函数作用域顶部
function foo() {
console.log(age);
var age = 18;
}
foo(); //undefined
等价于 => function foo() {
var age;
console.log(age);
age = 18;
}
foo(); //undefined
- 反复多次使用var声明同一个变量也没有问题
function foo() {
var age = 18;
var age = 33;
var age = 55;
}
foo(); //55
3.3.2 let声明
与var的区别:let声明的范围是块作用域{},而var声明的范围是函数作用域。let声明的变量不会在作用域中被提升。let不能重复声明同一个变量(在同一作用域内不能声明2次)
var name = 'Matt';
console.log(window.name); //'Matt'
var age = 28;
console.log(window.age); //undefined
for循环中的let声明
for (var i = 0; i < 5; i++) { setTimeout(() => { console.log(i) }, 0) } //5,5,5,5,5
因为退出循环时,迭代变量保存的时导致循环退出的值,如果换成let i = 0;会输出//0,1,2,3,4
3.3.3 const声明
与let的区别:它声明变量时必须同时初始化变量,且值不可修改。
const声明的限制只适用于它指向的变量的引用。如果const变量引用的是一个对象,那么修改这个对象内部的属性并不违反const的限制
const person = {}; person.name = 'Matt'; //ok
如果想让整个对象都不能修改:
object.freeze({});console.log(); //undefined
不使用var const优先 let次之
3.4 数据类型
ECMAScript有6种简单数据类型:Undefined、Null、String、Number、Boolean、Symbol(符合)
复杂数据类型:Object[无序名值对的集合]
3.4.1 typeof操作符
确定任意变量的数据类型,返回字符串
null被认为是一个空对象的引用
“undefined” “boolean” “string” “function” “object” “symbol” “number”
let mer = 'some';console.log(typeof mer); //"string"
3.4.4 Boolean类型
Boolean()转型函数 true/false
let mer = 'Hello';console.log(Boolean(mer)); //true
Boolean true false
String 非空字符串 “”
Number 非零数值 0、NaN
Object 任意对象 null
Undefined 不存在 Undefined
3.4.5 Number类型
- NaN
NaN不是数值,表示本来要返回数值的操作失败了
在ECMAScript中,0、+0、-0相除会返回NaN;如果分子是非0值,分母是有符号0或无符号0,则会返回Infinity或-Infinity;
NaN有几个独特的属性。任何涉及NaN的操作始终返回NaN,NaN不等于包括NaN在内的任何值
console.log(NaN == NaN); //false
isNaN()函数,会尝试把里面的值转换为数值,任何不能转换为数值的值都会导致函数返回true
console.log(isNaN(NaN)); //trueconsole.log(isNaN(10)); //falseconsole.log(isNaN('10')); //falseconsole.log(isNaN('blue')); //trueconsole.log(isNaN(true)); //false
- 数值转换
有3个函数可以将非数值转换为数值:Number()、parseInt()、parseFloat()。Number()是转型函数,可用于任何数据类型,后面两个函数主要用于将字符串转换为数值
- Number()函数转换规则:
布尔值 : true => 1 、 false => 0
数值: 直接返回
null => 0
undefined => NaN
字符串:
let num1 = Number('Hello'); //NaNlet num2 = Number(''); //0let num3 = Number('0000011'); //11
- parseInt()得到整数,更专注于字符串是否包含数值模式。字符串最前面的空格会被忽略,从第一个非空字符串开始转换。如果第一个字符不是数值字符、加号或减号,parseInt()立即返回NaN
let num1 = parseInt('1234blue'); //1234let num2 = parseInt(''); //NaNlet num3 = parseInt('22.5'); //22let num4 = parseInt('0xA'); //10 16进制let num5 = parseInt('0xf'); //15 16进制let num6 = parseInt('AF', 16); //175let num7 = parseInt('0xAF'); //175let num8 = parseInt('AF'); //NaN
- parseFloat()和parseInt()相似 解析到字符串末尾或者解析到一个无效的浮点数值字符为止。意味着第二次出现的小数点无效
let num = parseFloat('22.24.5'); //22.4
区别:始终忽略开头的0,只解析十进制值,因此不能指定底数
let num = parseFloat('1234blue'); //1234let num = parseFloat('0xA'); //0let num = parseFloat('22.0'); //22let num = parseFloat('0.908.5'); //908.5let num = parseFloat('3.125e7'); //31250000
3.4.6 String类型
- 字符串的特点
ECMAScript中的字符串是不可变的,一旦创建,它的值就不能变了,要修改必须先销毁原始的字符串,然后将包含新值的另一个字符串保存到该变量
-
转换为字符串
- toString()返回当前字符串等价物
let age = 11;let a = age.toString();console.log(a); //"11"
true => “true”
null和undefined值没有toSring()方法
let num = 10;console.log(num.toString(16)); //"a"console.log(num.toString(2)); //"1010"
-
String()方法和toString()方法类似。区别:null和undefined
let v1 = null;let v2;console.log(String(v1)); //'null';console.log(String(v2)); //'undefined';
-
模板字面量 ``
保留换行符,可以跨行定义字符串
- 字符串插值
模板字面量不是字符串,而是一种特殊的JavaScript句法表达式,只不过求值后得到的是字符串。模板字面量在定义时立即求值并转换为字符串实例,任何插入的变量也会从它们最接近的作用域中取值。
let value = 5;let exponent = 'second';let inter = `${value} to the ${exponent} power is ${value * value}`;console.log(inter) //5 to the second power is 25
所有插入的值都会使用toSting()强制转型为字符串,而且任何JavaScript表达式都可以用于插值。
- 模板字面量标签函数
通过标签函数可以自定义插值行为,标签函数会接收被插值记号分隔后的模板和对每个表达式求值的结果。标签函数接收到的参数依次是原始字符串数组和对每个表达式求值的结果。这个函数的返回值是对模板字面量求值得到的字符串。
let a = 6;let b = 9;function sim (strings, aVa, bVa, sum) { console.log(strings); console.log(ava); console.log(bva); console.log(sum); return 'foobar';}let untag = `${a} + ${b} = ${a + b}`;let tag = sim` ${a} + ${b} = ${a + b}`;// ["", " + ", " = ", ""]// 6// 9// 15console.log(untag); // "6 + 9 = 15"console.log(tag); // "foobar"
- 原始字符串
使用模板字面量也可以直接获取原始的模板字面量内容(如换行符或Unicode字符),而不是被转换后的字符表示。为此可以使用默认的String.raw标签函数:
//Unicode示例// \u00A9是版权符号 ©console.log(`\u00A9`); //©console.log(String.raw`\u00A9`); //\u00A9//换行符示例console.log(`first line\nsecond line`);// first line// second lineconsole.log(String.raw`first line\nsecond line`); //"first line\nsecond line"// 对实际的换行符来说是不行的 它们不会被转换成转义序列的形式
另外也可以通过标签函数的第一个参数,即字符串数组的.raw属性获得每个字符串的原始内容:
function printRaw(strings) { for (const string of strings) { console.log(string); }}printRaw`\u00A9{'and'}\n`;// \u009A// \n
3.4.7 Symbol类型
3.5 操作符
3.5.1 一元操作符
只操作一个值的操作符
- 递增/递减操作符
let age = 29;let anotherAge = --age + 2;console.log(age); //28console.log(anotherAge); //30
let num1 =2;let num2 = 20;let num3 = num1-- + num2;let num4 = num1 + num2;console.log(num3); //22console.log(num4); //21
不限于整数(字符串、布尔值、浮点值、对象)
let s1 = "2";let s2 = "z";let b = false;let e = 1.1;console.log(s1++); //3console.log(s2++); //NaNconsole.log(b++); //1console.log(e--); //0.100000009
- 一元加和减+ -
如果将一元加应用到非数值则会执行与使用Number()转型函数一样的类型转换:布尔值false和true转换成0和1,字符串根据特殊规则进行解析,对象会调用它们的valueOf()和/或toString()方法以得到可以转换的值。
let s1 = '01';let s2 = '1.1';let s3 = 'z';let b = false;let f = 1.1;let o = { valueOf() { return -1; }};s1 = +s1; //值变成数值1s2 = +s2; //值变成数值1.1s3 = +s3; //值变成NaNb = +b; //值变成0f = +f; //不变,还是1.1o = +o; //值变成数值-1
3.5.3 布尔操作符
- 逻辑非(!) 可应用给ECMAScript中的任何值,始终返回布尔值
console.log(!false); //trueconsole.log(!'blue'); //falseconsole.log(!NaN); //trueconsole.log(!12345); //falseconsole.log(!0); //trueconsole.log(!''); //true
也可以用于把任意的值转换为布尔值(!!) => Boolean()
console.log(!!false); //falseconsole.log(!!'blue'); //trueconsole.log(!!NaN); //falseconsole.log(!!12345); //trueconsole.log(!!0); //falseconsole.log(!!''); //false
- 逻辑与(&&)
let found = true;let result = (found && find) //这里会出错console.log(result); 不会执行这一行let found = false; //后不会出错
- 逻辑或(||)
let found = true;let result = (found || find) //不会出错console.log(result); 会执行这一行let found = false; //后会出错
3.5.5 指数操作符
Math.pow() => **
console.log(Math.pow(3, 2)); //9console.log(3 ** 2); //9
指数赋值操作符 **=
let s = 3;s **= 2;console.log(s); //9
3.4.5 乘性操作符
- 乘法*
- 除法/
- 取模%
有不是数值的操作数,会在后台被使用Number()转型函数转换为数值
3.4.6 加性操作符
- 加法操作符
let a = 5 + 5;console.log(a); //10let b = 5 + '5';console.log(b); //'55'let num1 = 5;let num2 = 10;let m = "The sum of 5 and 10 is" + num1 + num2;console.log(m); //"The sum of 5 and 10 is 510"let m = "The sum of 5 and 10 is" + (num1 + num2);console.log(m); //"The sum of 5 and 10 is 15"
如果有任一操作数是对象、数值或布尔值,则调用它们的toString()方法以获取字符串
- 减法操作符
如果有任一操作数是对象、数值、布尔值、null或undefined。则先在后台使用Number()将其转换为数值,再进行上数学运算。如果转换结果是NaN,则减法的计算结果是NaN
let a = 5 - true; //4console.log(NaN - 1); //NaNconsole.log(5 - ''); //5console.log(5 - null); //5console.log(5 - '3'); //2
3.5.7 关系操作符
比较两个值的操作(<)、(>)、(<=)、(>=)这几个操作符都返回布尔值
let result1 = 5 > 3; //truelet result2 = 5 < 3; //false
执行规则:
如果操作数都是数值,则执行数值比较
如果操作符都是字符串,则逐个比较字符串中对应字符的编码
如果有任一操作上是数值,则将另一个操作数转换为数值,执行数值比较
如果有任一操作符是对象,(则调用其valueOf()方法,没有valueOf()方法则调用toSring()方法),取得结果后再根据前面的规则执行比较
如果有任一操作数是布尔值,则将其转换为数值再进行比较
let s = '23' < 3; //falselet s1 = "a" < 3; //false "a"转换为NaN,任何关系操作符在涉及比较NaN时都返回falselet s3 = NaN >= 4; //false
3.5.8 相等操作符
- 等于(==)和不等于(!=)
会先进行类型转换(强制类型转换)再确定操作数是否相等,遵循如下规则
- 如果任一操作数是布尔值,则将其转换为数值再进行比较 false=>0 true=>1
- 如果一个操作符是字符串,另一个操作符是数值,则尝试将字符串转换为数值,再进行比较
- 如果一个操作数是对象,另一个操作数不是,则调用对象的valueOf()方法取得其原始值,再根据前面的规则进行比较
- null和undefined相等 null和undefined不能转换为其他类型的值再进行比较
- 如果有任一操作数是NaN,则相等操作符返回false,不相等操作符返回true。即使两个操作符都是NaN,相等操作符也返回false
- 如果两个操作符都是对象,则比较它们是不是同一个对象,如果两个操作数都指向同一个对象,则相等操作符返回true
null == undefined "NaN" != NaN 5 != NaNNaN != NaNfalse == 0true == 1null != 0undefined != 0"5" == 5
- 全等(=)和不全等(!)
全等和不全等操作符与相等和不相等操作符类似,只是它们在比较时不转换操作数
let result = {"55" !== 55}; // truenull !== undefined //因为它们是不同的数据类型
3.6 语句
3.6.1 if语句
if (condition) statement1 else statement2
这里的条件(condition)可以是任何表达式,并且求值结果不一定是布尔值。ECMAScript会自动调用**Boolean()**函数将这个表达式的值转换为布尔值。
if (i > 25) { console.log(2)} else if (i < 0) { console.log(1)} else { console.log(3)}
3.6.2 do-while语句
后测试循环语句,即循环体中的代码执行后才会对退出的条件进行求值。换句话说,循环体中的代码至少执行一次
let i = 0;do { i += 2;} while(i < 10);
3.6.3 while语句
先测试循环语句
let i = 0;while (i < 10) { i += 2;}
3.6.4 for语句
先测试语句,只不过增加了进入循环前的初始化代码,以及循环执行后要执行的表达式
let count = 10;for (let i = 0; i < count; i++) { console.log(i);}
let count = 10;let i = 0;while (i < count) { console.log(i); i++;}
3.6.5 for-in语句
是一种严格的迭代语句,用于枚举对象中的非符号键属性
for (const propName in window) { doucument.write(propName);}
如果for-in循环要迭代的对象是null或undefined则不执行循环体
3.6.6 for-of语句
是一种严格的迭代语句,用于遍历可迭代对象的元素
for (const el of [3, 4, 5, 7]) { doucument.write(el);}
按照可迭代对象的next()方法产生值的顺序迭代元素,如果尝试迭代的变量不支持迭代,则for-of语句会抛出错误
3.6.7 标签语句
标签语句用于给语句加标签
start: for (let i = 0; i < count; i++) { console.log(i);}
可以在后面通过break或continue语句引用
3.6.8 break和continue语句
break和continue语句为执行循环代码提供了更严格的控制手段,其中,break语句用于立即退出循环,强制执行循环后的下一条语句。而continue语句也用于立即退出循环,但会再次从循环顶部开始执行。
let num = 0;for (let i = 1; i < 10; i++) { if (i % 5 == 0) { break; } num++;}console.log(num); //4
let num = 0;for (let i = 1; i < 10; i++) {if (i % 5 == 0) { continue; } num++;}console.log(num); //8
break和continue都可以与标签函数一起使用,返回代码中特定的位置。这通常是在嵌套循环中
let num = 0;outermost:for (let i = 0; i < 10; i++) { for (let j = 0; j < 10; j++) { if (i == 5 && j == 5) { break outermost; } num++; }}console.log(num); //55
let num = 0;outermost:for (let i = 0; i < 10; i++) { for (let j = 0; j < 10; j++) { if (i == 5 && j == 5) { continue outermost; } num++; }}console.log(num); //95
3.6.9 with语句
将代码作用域设置为特定的对象,主要场景是针对一个对象反复操作,这个时候将代码作用域设置为该对象能提供便利
let qs = location.search.substring(1);let hostName = location.hostname;let url = location.href;
with(location) { let qs = search.substring(1); let hostName = hostname; let url = href;}
在这个语句内部,每个变量首先会被认为是一个局部变量,如果没有找到该局部变量,则会搜索location对象,看它是否有一个同名的属性。如果有,则该变量会被求值为location对象的属性。严格模式下不允许使用with语句,会抛出错误。由于with语句影响性能且难于调试其中的代码,通常不推荐使用。
3.6.10 switch语句
是与if语句紧密相关的一种控制语句,可以用于所有数据类型,因此可以使用字符串甚至对象。其次,条件的值不需要是常量,也可以是变量或表达式。为了不必要的条件判断,最好给每个条件后面都加上break语句,default关键字用于在任何条件都没满足时指定默认执行的语句(相当于else语句)
switch ("hello world") { case "hello" + "world": console.log(1); break; case "goodbye": console.log(2); break; default: console.log(3);}
switch语句在比较每个条件的值时会使用全等操作符。因此不会强制转换数据类型
3.7 函数
可以封装语句,在任何地方、任何时间执行。不需要指定是否返回值,只要碰到return语句,函数就会立即停止执行并退出返回undefined,除了return语句之外没有任何特殊声明表明该函数有返回值
function关键字声明,后跟一组参数,然后时函数体
function sum (num1, num2) { return num1 + num2; console.log('hello'); //不会执行}const result = sum(5, 10) //15
第4章 变量、作用域与内存
4.1 原始值与引用值
ECMAScript变量可以包含两种不同类型的数据:原始值和引用值。原始值就是最简单的数据,引用值则是由多个值构成的对象。上一章讨论了6种原始值:Undefined、Null、String、Number、Symbol、Boolean。原始值的变量是按值访问的,我们操作的就是存储在变量中实际的值。引用值是保存在内存中的对象,JavaScript不允许直接访问内存中的位置,因此也就不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是该对象的引用而非实际的对象本身。为此,保存引用值的变量是按引用访问的。
4.1.1 动态属性
引用值可以随时添加、修改和删除其属性和方法。原始值不能有属性,尽管添加了属性不会报错
let name = "Jxx";name.age = 99;console.log(name.age); //undefined
原始类型的初始化可以只使用原始字面量形式。如果使用new关键字,则会创建一个Object类型的实例,但其行为类似原始值。
let name1 = "Jxx";let name2 = new String("Jxx");name1.age = 99;name2.age = 99;cosole.log(name1.age); //undefinedconsole.log(name2.age); //99console.log(typeOf name1); //Stringconsole.log(typeOf name2); //Object
4.1.2 复制值
在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置。
let num1 = 5;let num2 = num1;console.log(num2); //5
num1和num2中的5是完全独立的,互不干扰
在把引用值从一个变量赋值给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区别在于,这里的复制值实际上是一个指针,它指向存储在堆内存中的对象,因此一个对象上面的变化会在另一个对象中反映处理
let obj1 = new Object();let obj2 = obj1;obj1.name = "Jxx";console.log(obj2.name); //'Jxx'
4.1.3 传递参数
所有函数的参数的就是按值传递的
4.1.4 确定类型
typeOf操作符适合用来判断一个变量是否为原始类型,但它对引用值的作用不大(所有引用值都是Object的实例)通过instanceof操作符检测任何引用值和Object构造函数都会返回true,检测原型值始终返回false,因为原始值不是对象。
console.log(person instanceof Object); console.log(colors instanceof Array);
4.2 执行上下文与作用域
4.2.1 作用域链增强
4.2.2 变量声明
4.3 垃圾回收
第5章 基本引用类型
引用值(或者对象)是某个特定引用类型的实例,引用类型是把数据和功能组织到一起的结构,引用类型有时候也被称为对象定义,因为它们描述了自己的对象应有的属性和方法。新对象通过new操作符后跟一个构造函数来创建,构造函数就是用来创建新对象的函数
let now = new Date();
这行代码创建了引用类型Date的一个新实例,并将它保存在变量now中。Date()在这里就是构造函数,它负责创建一个只有默认属性和方法的简单对象。
5.1 Date
let now = new Date();
在不给Date构造函数传参数的情况下,创建的对象将保存当前的日期和时间。要基于其他时间和日期创造日期对象,必须传入其毫秒表示,为此ECMAScript提供了两个辅助方法:Date.parse()和Date.UTC()
- Date.parse()方法接收一个表示日期的字符串参数,尝试将这个字符串转换为表示该日期的毫秒数。所有实现都必须支持下列日期格式:
- “月/日/年”,如"3/31/2021"
- “月名 日, 年”,如"May 23, 2019"
- “周几 月名 日 年 时:分:秒 时区”,如"Tue May 23 2019 00:00:00 GTM-0700"
- “YYYY-MM-DDTHH:mm:ss.sssZ”,如"2019-05-23T00:00:00"只适用于兼容ES5的实现
let someDate = new Date(Date.parse("May 23, 2019"));let someDate = new Date("May 23, 2019"); //和上面的代码等价
如果传给Date.parse()的字符并不表示日期,则该方法会返回NaN。如果直接把表示日期的字符串传给Date构造函数,那么Date会在后台调用Date.parse()
- Date.UTC()方法也返回日期的毫秒表示,但使用的是跟Date.parse()不同的信息来生成这个值。传给Date.UTC()的参数是年、零起点月数(1月是0,2月是1,以此类推)、日(131)、时(023)、分、秒和毫秒。在这些参数中只有年和月是必须的,如果不提供日则默认为1,其他参数的默认值都是0
let yk = new Date(Date.TUC(2000, 0)); //GMT时间2000年1月1日0点let allFives = new Date(Date.TUC(2005, 4, 5, 17, 55, 55)); //GMT时间 2005年5月5日下午5点55分55秒
与Date.parse一样,Date.UTC()也会被构造函数隐式调用,但这种情况下创建的是本地时期
- Date.now()返回表示方法执行时日期和时间的毫秒数
let start = Date.now(); //起始时间doSomething(); //调用函数let stop = Date.now(); //结束时间result = stop - start;
5.1.1 继承的方法
Date类型重写了toLocalString()、toString()和valueOf()方法
- Date类型的toLocalString()方法返回与浏览器运行的本地环境一致的日期和时间
- totoSting()方法通常返回带时区信息的日期和时间
let date = new Date(2019, 1, 1);console.log(date.toLocalString()); //2/1/2019 12:00:00 AMconsole.log(date.toString()); //Thu Feb 1 2019 00:00:00 GMT-0800
- valueOf方法返回是日期的毫秒表示。(如果有任一操作符是对象,则调用其valueOf()方法)
let date1 = new Date(2019, 0, 1);let date2 = new Date(2019. 1. 1);console.log(date1 > date2); //false
5.1.2 日期格式化方法
Date类型有几个专门用于格式化日期的方法,它们都会返回字符串:
- toDateString()显示日期中的周几、月、日、年
let date1 = new Date(2019, 0, 1); console.log(date1.toDateString()); //Tue Jan 01 2019
- toTimeString()显示日期中的时、分、秒和时区
- toLocalDateString()
- toLocalTimeString()
- toUTCString()
5.1.3 日期/时间组件方法
Date类型剩下的方法直接涉及取得或设置日期值的特定部分。
getTime() 返回日期的毫秒表示,与valueOf()相同
setTime() 设置日期的毫秒表示,从而修改整个日期
getFullYear() 返回四位数年 2019
getUTCFullYear() 返回UTC日期的四位年数
setFullYear() 设置日期的年(year必须是四位数)
setUTCFullYear()
getMonth() getDate() getDay() getHours() getMinutes() getSeconds() getMilliseconds()
getTimezoneOffset() 返回以分钟计的UTC与本地时区的偏移量
5.2 RegExp
5.3 原始值包装类型
为了方便操作原始值,ECMAScript提供了3种特殊的引用类型:Boolean、Number、String。这些类型具有其他引用类型一样的特点,但也具有与各自原始类型对应的特殊行为。每当用到某个原始值的方法或属性时,后台都会创建一个相应原始包装类型的对象,从而暴露出操作原始值的各种方法。
let s1 = "some text";let s2 = s1.substring(2);
后台执行了以下3步:
- 创建一个String类型的实例;
- 调用实例上的特定方法;
- 销毁实例;
let s1 = new String("some text");let s2 = s1.substring(2);s1 = null;
引用类型与原始值包装类型的主要区别在于对象的生命周期。在通过new实例化引用类型后,得到的实例会在离开作用域时被销毁,而自动创建的原始值包装对象则只存在于访问它的那行代码执行期间。这意味着不能在运行时给原始值添加属性和方法。
let s1 = "some text";s1.color = "red";console.log(s1.color); //undefined
另外,Object构造函数作为一个工厂方法,能够根据传入值的类型返回相应原始值包装类型的实例
let obj = new Object("some text");console.log(obj instanceof String); //true
注意,使用new调用原始值包装类型的构造函数,与调用同名的转型函数并不一样
let value = "25";let number = Number(value);console.log(typeOf number); //"number"let obj = new Number(value);console.log(typeOf obj); //"object"
5.3.1 Boolean
let booleanObject = new Boolean(true/false);
不建议使用
5.3.2 Number
Number是对应数值的引用类型,要创建一个Number对象,就使用Number构造函数并传入一个数值
let numberObject = new Number(10);
Number重写了valueOf()、toLocalString()和toString()方法。valueOf()方法返回Number对象表示的原始数值,另外两个方法返回数值字符串。toString()方法可选的接收一个表示基数的参数,并返回相应基数形式的字符串字符
let num = 10;console.log(num.toString()); //"10"console.log(num.toString(2)); //"1010"console.log(num.toString(8)); //"12"console.log(num.toString(10)); //"10"console.log(num.toString(16)); //"a"
除了继承的方法,Number类型还提供了几个用于将数值格式化为字符串的方法
- toFixed()方法返回包含指定小数点位数的数值字符串
let num = 10;console.log(num.toFixed(2)); //"10.00"let num = 10.005;console.log(num.toFixed(2)); //"10.01" 四舍五入
- toExponential()方法返回以科学计数法表示的数值字符串,与toFixed()一样也接收一个参数,表示结果中小数的位数。
let num = 10;console.log(num.toExponential(1)); //"1.0e+1"
- toPrecision()方法会根据情况返回最合理的输出结果,这个方法接收一个参数,表示结果中数字的总位数(不包含指数)
let num = 99;console.log(num.toPrecision(1)); //"1e+2"console.log(num.toPrecision(2)); //"99"console.log(num.toPrecision(3)); //"99.0"
- isInteger()方法与安全整数
ES6新增了Number.isInteger()方法,用于辨别一个数值是否保存为整数。有时候,小数位的0可能会让人误以为数值是一个浮点值
console.log(Number.isInteger(1)); //trueconsole.log(Number.isInteger(1.00)); //trueconsole.log(Number.isInteger(1.01)); //false
5.3.3 String
-
charAt()方法返回给定索引位置的字符串,由传给方法的整数参数指定。
let message = "abcde";console.log(message.charAt(2)); //cconsole.log(message.charCodeAt(2)); //99
-
字符串操作方法
- concat()方法用于将一个或多个字符串拼接成一个新的字符串,相当于加号操作符(+)
let stringValue = "hello";let result = stringValue.concat("world");console.log(result); //"helloworld"console.log(stringValue); //"hello"let rs = stringValue.concat("world", "!");console.log(rs); // "helloworld!"
- ECMAScript提供了3个从字符串中提取子字符串的方法:slice()、substr()和substring()。第一个参数表示子字符串开始的位置,第二个参数表示子字符串结束的位置。对于slice()和substring()而言,第二个参数是提取结束的位置(即该位置之前的字符会被提取出来),对substr而言,第二个参数表示返回子字符串数量。省略第二个参数都意味着提取到字符串末尾,与concat()方法一样,slice()、substr()和substring()也不会修改调用它们的字符串,而只是会返回提取到的原始新字符串值
let stringValue = "hello world";console.log(stringValue.slice(3)); //"lo world"console.log(stringValue.substr(3)); //"lo world"console.log(stringValue.substring(3)); //"lo world"console.log(stringValue.slice(3, 7)); //"lo w"console.log(stringValue.substring(3, 7)); //"lo w"console.log(stringValue.substr(3, 7)); //"lo worl"
当某个参数是负值时,slice()方法会将所有负值参数都当成字符串长度加上负参数值,而substr()方法会将第一个负参数值当成字符串长度加上该值,第二个负参数值转换为0。substring()方法会将所有负参数值都转换为0
let stringValue = "hello world";console.log(stringValue.slice(-3)); //"rld"console.log(stringValue.substr(-3)); //"rld"console.log(stringValue.substring(-3)); //"hello world"console.log(stringValue.slice(3, -4)); //"lo w"console.log(stringValue.substr(3, -4)); //""console.log(stringValue.substring(3, -4)); //"hel" 等价于(0, 3)
-
字符串位置方法
indexOf()和lastIndexOf()方法,这两个方法从字符串中搜索传入的字符串,并返回位置(如果没有找到,则返回-1)
区别:indexOf()方法从字符串开头开始查找子字符串,而lastIndexOf()方法从字符串末尾开始查找子字符串
let stringValue = "hello world";console.log(stringValue.indexOf(o)); //4console.log(stringValue.lastIndexOf(o)); //7
这两个方法都可以接收可选的第二个参数,表示开始搜索的位置
console.log(stringValue.indexOf(o, 6)); //7console.log(stringValue.lastIndexOf(o, 6)); //4
let stringValue = "Lorem ipsum dolor sit amet, consectetur adipisicing elit";let positions = new Array();let pos = stringValue.indexOf("e");while(pos > -1) { positions.push(pos); pos = stringValue.indexOf("e", pos + 1);}console.log(positions); //[3, 24, 32, 35, 52]
- 字符串包含方法
startsWith()、endsWith()和includes()。这些方法都会从字符串中搜索传入的字符串,并返回一个表示是否包含的布尔值。它们的区别在于,startsWith()检查开始于索引0的匹配项,endsWith()检查开始于索引(string.length - substring.length)的匹配项,而includes()检查整个字符串
let message = "foobarbaz";console.log(message.startsWith("foo")); // trueconsole.log(message.startsWith("bar")); // falseconsole.log(message.endsWith("baz")); // trueconsole.log(message.endsWith("bar")); // falseconsole.log(message.includes("foo")); // trueconsole.log(message.includes("qux")); // falseconsole.log(message.startsWith("foo", 1)); // falseconsole.log(message.endsWith("bar", 6)); // trueconsole.log(message.includes("bar", 4)); //false
- trim()方法
这个方法会创建字符串的一个副本,删除前、后所有空格符,再返回结果。
let stringValue = " hello world ";let trimmedStringValue = stringValue.trim();console.log(stringValue); //" hello world "console.log(trimmedStringValue); //"hello world"
另外,trimLeft()方法和trimRight()方法分别用于从字符串开始和末尾清理空格符
- repeat()方法
这个方法接收一个整数参数,表示要将字符串复制多少次,然后返回拼接所有副本后的结果。
let stringValue = "na ";console.log(stringValue.repeat(16) + "batman");// na na na na na na na na na na na na na na na na batman
- padStart()和padEnd()方法
这两个方法会复制字符串,如果小于指定长度,则再相应一边填充字符,直到满足长度条件,第一个参数时长度,第二个参数是可选的填充字符串,默认为空格
let stringValue = "foo";console.log(stringValue.padStart(6)); //" foo"console.log(stringValue.padStart(9 , ".")); //"......foo"console.log(stringValue.padEnd(6)); //"foo "console.log(stringValue.padEnd(9 , ".")); //"foo......"
- 字符串迭代于解构
字符串的原型上暴露了一个@@iterator方法,表示可以迭代字符串的每个字符,可以像下面这样手动使用迭代器
let message = "abc";let stringIterator = message[Symbol.itetator];console.log(stringItetator.next()); //{value : "a", done: false}console.log(stringItetator.next()); //{value : "b", done: false}console.log(stringItetator.next()); //{value : "c", done: false}console.log(stringItetator.next()); //{value : undefined, done: true}
在for-of循环中可以通过这个迭代器按序访问每个字符
for (const c of "abcde") { console.log(c);}//a//b//c//d//e
有了这个迭代器之后,字符串就可以通过解构操作符来解构了。比如,可以方便地把字符串分割为字符数值
let message = "abcde";console.log([...message]); //["a", "b", "c", "d", "e"]
- 字符串大小写转换
toLowerCase()、toUpperCase()和toLocalUpperCase()、toLocalLowerCase()。后两个方法旨在基于特定地区实现。在很多地区,地区特定的方法与通用的方法是一样的,但是在少数语言中需要应用特殊规则,要使用地区特定的方法才能实现正确转换
let stringValue = "hello world";console.log(stringValue.toLocalUpperCase()); //"HELLO WORLD"console.log(stringValue.toUpperCase()); //"HELLO WORLD"console.log(stringValue.toLocalLowerCase()); //"hello world"console.log(stringValue.toLowerCase()); //"hello world"
- 字符串模式匹配方法
- localeCompare()方法
这个方法比较两个字符串,返回如下三个值中的一个
- 如果按照字母表顺序,字符串应该排在字符串参数后头,则返回正值
- 如果按照字母表顺序,字符串应该排在字符串参数前头,则返回负值
- 如果字符串与字符串参数相等,则返回0
let stringValue = "yellow";console.log(stringValue.localeCompare("brick")); //1console.log(stringValue.localeCompare("zoo")); //-1console.log(stringValue.localeCompare("yellow")); //0
5.4 单例内置对象
ECMA-262对内置对象的定义是“任何由ECMAScript实现提供、与宿主环境无关,并在ECMAScript程序开始执行时就存在的对象”。这就意味着,开发者不用显示的实例化内置对象,因为它们已经实例化好了。前面我们已经接触了大部分内置对象,包括Object、Array和String.
5.4.1 Global
Global对象是ECMAScript中最特别的对象,因为代码不会显示地访问它。它所针对的是不属于任何对象的属性和方法。事实上,不存在全局变量或全局函数这种东西。在全局作用域中定义的变量和函数都会变成Global对象的属性。前面提到的函数,包括isNaN()、isFinite()、parseInt()和parseFloat(),实际上都是Global对象的方法。
- URL编码方法
encodeURL()和encodeURIComponent()方法用于编码统一资源标识符(URI),以便传给浏览器。有效的URI不能包含某些字符,比如空格。使用URI编码方法来编码URI可以让浏览器能够理解它们,同时又以特殊的UTF-8编码替换掉所有无效字符。
encodeURI()方法用于对整个URI进行编码,比如"www.wrox.com/illegal value.js"。而encodeURIComponent()方法用于编码URI中单独的组件,比如前面URL中的"illegal value.js"。这两个方法主要区别是,encodeURI()不会编码属于URL组件的特殊字符,比如冒号、斜杠、问号、井号,而encodeURIComponent()会编码它发现的所有非标准字符。
let uri = "http://www.wrox.com/illegal value.js#start";console.log(encodeURI(uri)); //"http://www.wrox.com/illegal%20value.js#start."console.log(encodeURIComponent(uri)); //"http%3A%2F%2Fwww.worx.com%2Fillegal%20value.js%23start"
与encodeURI()和encodeURIComponent()相对的是decodeURI()和decodeURIComponent()。decodeURI只对encodeURI()编码过的字符串解码,decodeURIComponent()解码所有被encodeURIComponent()编码过的字符,基本上就是解码所有特殊值
let uri = "http%3A%2F%2Fwww.worx.com%2Fillegal%20value.js%23start";console.log(decodeUR(uri)); //"http%3A%2F%2Fwww.worx.com%2Fillegal value.js%23start"console.log(decodeURIComponent(uri)); //"http://www.wrox.com/illegal value.js#start"
- eval()方法
这个方法就是一个完整的ECMAScript解释器,它接收一个参数,即一个要执行的ECMAScript字符串。
eval("function sayHi() { console.log('hi'); }");sayHi();
通过eval()定义的任何变量和函数都不会被提升,这是因为在解析代码的时候,它们是被包含在一个字符串中的。它们只是在eval()执行的时候才会被创建,在严格模式下,在eval()内部创建的变量和函数无法被外部访问。换句话说,这样会报错。
- Global对象属性
undefined、NaN、Infinity等特殊值都是Global对象的属性。此外,所有原生引用类型构造函数,比如Object、Function也都是Global对象的属性
undefined NaN Infinity Object Array Function Boolean String Number Date RegExp Symbol Error …
- window对象
浏览器将window对象实现为Global对象的代理。因此,所有全局作用域中声明的变量和函数都变成了window的属性。
var color = "red";function sayColor() { console.log(window.color);}window.sayColor(); //"red"
另一种获取Global对象的方式是
let global = function() { return this;}();
5.4.2 Math
ECMAScript提供了Math对象作为保存数学公式、信息和计算的地方。
- Math对象属性
主要用于保存数学中的一些特殊值
Math.E 自然对数的基数e的值
Math.PI π的值
Math.SQRT2 2的平方根
- min()和max()方法
用于确定一组数值中的最大值和最小值,这两个方法都接收任意多个参数
let max = Math.max(3, 54, 45, 45, 67);console.log(max); //67let min = Math.min(3, 454, 3, 32, 32, 2);console.log(min); //2
- 舍入方法
用于把小数值舍人为整数的4个方法:Math.ceil()、Math.floor()、Math.round()和Math.fround()
- Math.cei()方法始终向上舍入为最接近的整数
- Math.floor()方法始终向下舍入为最接近的整数
- Math.round()方法执行四舍五入
- Math.fround()方法返回数值最接近的单精度(32位)浮点值表示
console.log(Math.ceil(25.9)); //26console.log(Math.ceil(25.5)); //26console.log(Math.ceil(25.1)); //26console.log(Math.floor(25.9)); //25console.log(Math.floor(25.5)); //25console.log(Math.floor(25.1)); //25console.log(Math.round(25.9)); //26console.log(Math.round(25.5)); //26console.log(Math.round(25.1)); //25console.log(Math.fround(25.9)); //25.899999618530273console.log(Math.fround(0.5)); //0.5console.log(Math.fround(0.4)); //0.4000000049604645
- random()方法
返回一个0~1范围内的随机数,其中包含0但不包含1。
如果想从1~10范围内随机选择一个数:
let num = Math.floor(Math.random() * 10 + 1);
如果想从2~10范围内随机选择一个数:
let num = Math.floor(Math.random() * 9 + 2);
function selectFrom(lowerValue, upperValue) { let choices = upperValue - lowerValue; return Math.floor(Math.random() * choices + lowerValue)}let num = selectForm(2, 10);console.log(num); //2~10范围内的值,其中包含2和10
如果是为了加密而需要生成随机数(传给生成器的输入需要较高的不确定性),那么建议使用window.crypto.getRandomValues().
第6章 集合引用类型
6.1 Object
显示地创建Object的实例有两种方式
- 使用new操作符和Object构造函数
let person = new Object();
- 使用对象字面量
let person = {};let person = { "name": "LM", 5: true }
数值属性会自动转换为字符串
属性一般是通过点语法来存取的,但也可以使用中括号来存取属性
console.log(person.name);console.log(person["name"]);
中括号的主要优势
- 可以通过变量访问
let woName = “name”;
console.log(person[woName]);
- 属性名中包含可能会导致语法错误的字符或关键字、保留字时
person[“first name”] = “LM”;
6.2 Array
ECMAScript数组是一组有序的数据,但跟其他语法不同的是,数组中每个槽位可以存储任意类型的数据。着意味着可以创建一个数组,它的第一个元素是字符串,第二个元素是对象,第三个是数值。ECMAScript数值也是动态大小的,会随着数据添加自动增长。
6.2.1 创建数组
-
let colors = new Array();let colors = new Array(20); //初始化length为20的数组let colors = new Array("red", "pink", "green");//可省略new操作符,结果一样
-
使用数组字面量
let colors = [];
与对象一样,在使用数组字面量表示法创建数组,不会调用Array构造函数。
from()和of()方法
Array构造函数还有两个ES6新增的用于创建数组的静态方法
from()用于将类数组结构(任何可迭代的结构)转换为数组实例(Array、String、Map、Set、Dom元素),或者有一个length属性和可索引元素的结构。
console.log(Array.from("Matt")); //["M", "a", "t", "t"]const s1 = { 0: 1, 1: 2, 2: 3, 3: 4, length: 4};console.log(Array.from(s1)); //[1, 2, 3, 4]
Array.of()可把一组参数转换为数组
console.log(Array.of(1, 2, 3, 4)); //[1, 2, 3, 4]console.log(Array.of(undefined)); //[undefined]
6.2.2 数组空位
使用数组字面量初始化数组时,可以使用一串逗号来创建空位。ECMAScript会将逗号之间相应索引位置的值当成空位
const options = [,,,,,]; //创建包含5个元素的数组console.log(options.length); //5console.log(options); //[,,,,,]视空位置为undefined
6.2.3 数组索引
6.2.4 检测数组
判断一个对象是不是数组,在只有一个网页的情况下,使用instanceof操作符足矣,但是如果网页里有很多个框架,则肯涉及两个不同的全局执行上下文,因此就会有两个不同版本的Array构造函数。
Array.isArray()方法不管它是在哪个全局执行上下文中创建的
6.2.5 迭代器方法
在ES6中,Array的原型上暴露了3个用于检索数组内容的方法:keys()、values()和entries()。keys()返回数组索引的迭代器,values()返回数组元素的迭代器,而entries()返回索引/值对的迭代器:
const a = ["foo", "bar", "baz", "qux"];//因为这些方法都返回迭代器,所以可以将它们的内容通过Array.from()直接转换为数组实例const akeys = Array.from(a.keys());const aValues = Array.from(a.values());const aEntries = Array.from(a.entries());console.log(aKeys); //[0, 1, 2, 3]console.log(aValues); //["foo", "bar", "baz", "qux"]console.log(aEntries); //[[0: "foo"], [1: "bar"], [2,"baz"], [3, "qux"]]
6.2.6 复制和填充方法
copyWith()和fill()方法。这两个方法的函数签名类似,都需要指定既有数组实例上的一个范围,包含开始索引,不包含结束索引。使用这个方法不会改变数组的大小。使用fill()可以向一个已有的数组中插入全部或部分相同的值。开始索引用于指定开始填充的位置,它是可选的。如果不提供结束索引,则一直填充到数组末尾。负值索引从数组末尾开始计算。也可以将负索引想象成数组长度加上它得到的一个正索引:
const zeroes = [0, 0, 0, 0, 0];zeroes.fill(5);console.log(zeroes); //[5,5,5,5,5]zeroes.fill(0);zeroes.fill(6, 3);console.log(zeroes); //[0,0,0,6,6]zeroes.fill(0);zeroes.fill(7, 1, 3);console.log(zeroes); //[0,7,7,0,0]zeroes.fill(0);fill()静默忽略超出数组边界、零长度及方向相反的索引范围:zeroes.fill(1, -10, -6);console.log(zeroes); //[0,0,0,0,0] zeroes.fill(1, 10 ,15);console.log(zeroes); //[0,0,0,0,0]zeroes.fill(2, 4, 2);consloe.log(zeroes); //[0,0,0,0,0]zeroes.fill(4, 3, 10);console.log(zeroes); //[0,0,0,4,4] 填充部分可用
与fill()不同,copyWith()会按照指定范围浅复制数组中的部分内容,然后将它们插入到指定索引开始的位置。开始索引和结束索引则与fill()使用同样的计算方法:
let ints, reset = () => ints = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; reset();ints.copyWith(5);console.log(ints); //[0,1,2,3,4,0,1,2,3,4] 复制索引0开始的内容,插入到索引5开始的位置reset();ints.copyWith(4, 0, 3);console.log(ints); //[0,1,2,3,0,1,2,7,8,9]
6.2.7 转换方法
join()方法接收一个参数,即字符串分隔符,返回包含所有项的字符串。
let colors = ["red", "green". "pink"];alert(colors.join(",")); //red,green,pinkalert(colors.join("||")); //red||green||pink不传任何参数或传入undefined,则仍然使用逗号作为分隔符
6.2.8 栈方法
数组对象可以像栈一样,也就是一种限制插入和删除只在栈的一个地方发生,即栈顶。ECMAScript数组提供了push()和pop()方法,以实现类似栈的行为。push()方法接收任意数量的参数,并将它们添加到数组末尾,返回数组的最新长度。pop()方法则用于删除数组的最一项,同时减少数组的length值,返回被删除的项。
let colors = new Array();let count = colors.push("red", "green");alert(count); //2count = colors.push("pink");alert(count); //3let item = colors.pop();alert(item); //blackalert(colors.length); //2
6.2.9 队列方法
以先进先出形式限制访问。shift(),它会删除数组的第一项并返回它,然后数组长度减1。unshift()在数组开头添加任意多个值,然后返回新的数组长度
6.2.10 排序方法
reverse()和sort()方法
reverse()方法就是将数组反向排序
let values = [1, ,2, ,3 ,4 ,5];values.reverse();console.log(values); //[5,4,3,2,1]
sort()会按照升序重新排列数组元素,即最小的值在前面,最大的值在后面。为此,sort)会在每一项上调用String()转型函数,然后比较字符串来绝对顺序。即使数组的元素都是数值,也会先把数组转换为字符串再比较、排序。
let values = [0, 1, 5, 10, 15];values.sort();console.log(values); //[0, 1, 10, 15, 5]
为此,sort()方法可以接收一个比较函数,用于判断哪个值应该排在前面。比较函数接收两个参数,如果第一个参数应该排在第二个参数前面,就返回负值;如果两个参数相对就返回0;如果第一个参数应该排在第二个参数后面,就返回正值。
function compare(value1, value2) { if (value1 < value2) { return -1; } else if (value1 > value2) { return 1; } else { return 0; }}let values = [0, 1, 5, 10, 15];values.sort(compare);console.log(values); //[15,10,5,1,0]
6.2.11 操作方法
concat()方法可以在现有数组全部元素基础上创建一个新数组。它首先会创建一个当前数组的副本,然后再把它的参数添加到副本末尾,最后返回这个新构建的数组。如果闯入一个或多个数组,则concat()会把这些数组的每一项都添加到结果数组。如果参数不是数组,则直接把它们添加到结果数组末尾。
let colors = ["red", "green", "pink"];let colors2 = colors.concat("yellow", ["black", "blue"]);console.log(colors); //["red", "green", "pink"]console.log(colors2); //["red", "green", "pink", "yellow", "black", "blue"]
slice()方法用于创建一个包含原有数组中一个或多个元素的新数组。slice()方法可以接收一个或两个参数:返回元素的开始索引和结束索引。如果只有一个参数,则slice()会返回该索引到数组末尾的所有元素。如果有两个参数,则slice()返回从开始索引到结束索引对应的所有元素,其中不包含结束索引对应的元素。记住,这个操作不影响原始数组。
let colors = ["red", "green", "blue", "yellow", "puprple"];let colors2 = colors.slice(1);let colors3 = colors.slice(1, 4);console.log(colors2); //["green", "blue", "yellow", "puprple"]console.log(colors3); //["green", "blue", "yellow"]
splice()方法的主要目的是在数组中间插入元素,但有3种不同的方式使用这个方法。
- 删除。需要给splice()传入2个参数:要删除的第一个元素的位置和要删除的元素数量。
- 插入。需要给splice()传入3个参数:开始位置、0(要删除的元素数量)和要插入的元素。第三个参数之后还可以传第四个、第五个参数,乃至任意多个要插入的元素。
- 替换。splice()在删除元素的同时可以在指定位置插入新元素,同样要传入3个参数:开始位置、要删除元素的数量和要插入的任意多个元素。要插入的元素数量不一定跟删除元素数量一致。
let colors = ["red", "green", "blue"];let removed = colors.splice(0, 1);console.log(colors); //["green", "blue"]console.log(removed); //["red"]removed = colors.splice(1, 0, "yellow", "orange");console.log(colors); //["green","yellow", "orange", "blue"]console.log(removed); //[""]removed = colors.splice(1, 1, "red", "purple");console.log(colors); //["green","red", "purple", "orange", "blue"]console.log(removed); //["yellow"]
6.2.12 搜索和位置方法
ECMAScript提供两类搜索数组的方法:按严格相等搜索和按断言函数搜索。
- 严格相等
indexOf()、lastIndexOf()和includes()。这些方法都接受两个参数:要查找的元素和一个可选的起始搜索位置。indexOf()和includes()方法从数组前头开始向后搜索,而lastIndexOf()从数组末尾开始向前搜索。
indexOf()和lastIndexOf()都返回要查找的元素在数组中的位置,如果没有找到则返回-1。Includes()返回布尔值,表示是否至少找到一个与指定元素匹配的项。在比较第一个参数跟数组每一项时,会使用全等(===)比较,也就是说两项必须严格相等。
let numbers = [1,2,3,4,5,4,3,2,1];console.log(numbers.indexOf(4)); //3console.log(numbers.lastIndexOf(4)); //5console.log(numbers.includes(4)); //trueconsole.log(numbers.indexOf(4, 4)); //5console.log(numbers.lastIndexOf(4, 4)); //3console.log(numbers.includes(4, 7)); //false
- 断言函数
ECMAScript也允许按照定义的断言函数搜索数组,每个索引都会调用这个函数。断言函数的返回值决定了相应索引的元素是否被认为匹配。断言函数接收3个参数:元素、索引和数组本身。其中元素是数组中当前搜索的元素,索引是当前元素的索引,而数组就是正在搜索的数组。断言函数返回真值,表示是否匹配。
find()和findIndex()方法使用了断言函数。这两个方法都是从数组的最小索引开始。find()返回第一个匹配的元素,findIndex()返回第一个匹配元素的索引。这两个方法也都接收第二个可选的参数,用于指定断言函数内部this的值。
const people =[{ name: "Matt", age: 27},{ name: "Nicholas", age: 29}];console.log(people.find((element, index, array) => element.age < 28)); //{name: "Matt", age: 27}console.log(people.findIndex((element, index, array) => element.age < 28)); //0
找到匹配项后,这两个方法都不再继续搜索。
6.2.13 迭代方法
ECMAScript为数组定义了5个迭代方法。每个方法接收两个参数:以每一项为参数运行的函数,以及可选的作为函数运行上下文的作用域对象(影响函数中this的值)。传给每个方法的函数接收3个参数:数组元素、元素索引和数组本身。
- every():对数组每一项都运行传入的函数,如果对每一项函数都返回true,则这个方法返回true。
- filter():对数组每一项都运行传入的函数,函数返回true的项会组成数组之后返回。
- forEach():对数组每一项都运行传入的函数,没有返回值。
- map():对数组每一项都运行传入的函数,返回有每次函数调用的结果构成的数组
- some():对数组每一项都运行传入的函数,如果有一项函数返回true,则这个方法返回true。
这些方法都不改变调用它们的数组
let numbers = [1,2,3,4,5,4,3,2,1];let everyResult = numbers.every((item, index, array) => item > 2)console.log(everyResult); //falselet everyResult = numbers.some((item, index, array) => item > 2)console.log(everyResult); //truelet everyResult = numbers.filter((item, index, array) => item > 2)console.log(everyResult); //[3,4,5,4,3]let everyResult = numbers.map((item, index, array) => item * 2)console.log(everyResult); //[2,4,6,8,10,8,6,4,2]let everyResult = numbers.forEach((item, index, array) => { //执行某些操作})
6.2.14 归并方法
ECMAScript为数组提供了两个归并方法:reduce()和reduceRight()。这两个方法都会迭代数组的所有项,并再次基础上构建一个最终返回值。reduce()方法从数组第一项开始遍历到最后一项。而reduceRight()从最后一项遍历至第一项。
这两个方法都接收两个参数:对每一项都会运行的归并函数,以及可选的以之为归并起点的初始值。传给reduce()和reduceRight()的函数接收4个参数:上一个归并值、当前项、当前项索引和数组本身。这个函数返回的任何值都会作为下一次调用同一个函数的第一个参数。如果没有给这两个方法传入可选的第二个参数(作为归并起点值),则第一次迭代将从数组的第二项开始,因此传给归并函数的第一个参数是数组的第一项,第二个参数是数组的第二项。
let values = [1,2,3,4];let sum = values.reduce((prev, cur, idnex, array) => { console.log(prev, cur, index); prev + cur;}, 0);// 0, 1, 0// 1, 2, 1// 3, 3, 2// 6, 4, 3console.log(sum); //10
第7章 迭代器与生成器
第8章 对象、类与面向对象编程
8.1 理解对象
一组属性的无序集合(一组没有特点顺序的值)。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值,值可以是数据或函数(名/值对)
8.1.1 属性的类型
ECMA-262使用一些内部特性来描述属性的特征。这些特性是由JavaScript实现引擎的规范定义的。因此,开发者不能在JavaScript中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。
属性分两种:数据属性和访问器属性
- 数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有4个特性描述它们的行为。
- [[Configurable]]:表示属性是否可以通过delete删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是true
- [[Enumerable]]:表示属性是否可以通过for-in循环返回。默认情况下所有直接定义在对象上的属性的这个特性都是true。
- [[Writeble]]:表示属性的值是否可以被修改。默认情况下所有直接定义在对象上的属性的这个特性都是true。
- [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值是undefined。
要修改属性的默认特性,就必须使用Object.defineProperty()方法。这个方法接收3个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable和value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。
let person= {};Object.defineProperty(person, "name", { writable: false, value: "Jxx"});console.log(person.name); //"Jxx"person.name = "LM";console.log(person.name); //"Jxx"
一个属性被定义为不可配置之后,就不能再变回可配置的了。再次调用Object.defineProperty()并修改任何非writable属性会导致错误。
let person = {};Object.defineProperty(person, "name", { configurable: false, value: "Jxx"});// 抛出错误Object.defineProperty(person, "name", { configurable: true, value: "LM"});
在调用Object.defineProperty()时,configurable、enumerable和writable的值如果不指定,则都默认为false。
- 访问器属性
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有4个特性描写它们的行为。
- [[Configurable]]:表示属性是否可以通过delete删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是true
- [[Enumerable]]:表示属性是否可以通过for-in循环返回。默认情况下所有直接定义在对象上的属性的这个特性都是true。
- [[Get]]:获取函数,在读取属性时调用。默认值为undefined
- [[Set]]:设置函数,在写入属性时调用。默认值为undefined
访问器属性是不能直接定义的,必须使用Object.defineProperty()
8.1.2 定义多个属性
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 } } } })
区别:所有属性都是同时定义的,并且数据属性的configurable、enumerable、writable特殊值都是false。
8.1.3 读取属性的特性
使用Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含configurable、enumerable、get和set属性,对于数据属性包含configurable、enumerable、writable和value属性。
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 } } } });let descriptor = Object.getOwnPropertyDescriptor(book, "year_");console.log(descriptor.value); //2017console.log(descriptor.configurable); //falseconsole.log(typeof descriptor.get); //"undefined"let descriptor = Object.getOwnPropertyDescriptor(book, "year");console.log(descriptor.value); //undefinedconsole.log(descriptor.enumerable); //falseconsole.log(typeof descriptor.get); //"function"
Object.getOwnPropertyDescriptors()静态方法。这个方法实际上会在每个自有属性上调用Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。
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 } } } });console.log(Object.getOwnPropertyDescriptors(book));{ edition: { configurable: false, enumerable: false, value: 1, writable: false }, year: { configurable: false, enumerable: false, get: f(), set: f(newValue) }, year_: { configurable: false, enumerable: false, value: 2017, writable: false }}
8.4.1 合并对象
就是把源对象所有的本地属性一起复制到目标对象上。Object.assign()方法,接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回true)和自有(Object.hasOwnProperty()返回ture)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值。
//简单复制let dest, src, result;dest = {};src = { id: "src" };result = Object.assign(dest, src);console.log(dest === result); //true Object.assign修改目标对象,也会返回修改后的目标对象console.log(dest !== src); //trueconsole.log(result); //{ id: src }console.log(dest); //{ id: src }
//多个源对象dest = {};result = Object.assi(dest, {a: 'foo'}, {b: 'bar'});console.log(result); //{a: foo, b: bar}
//获取函数与设置函数dest = { set a(val) { console.log(`Invoked dest setter with param ${val}`); }};src = { get a() { console.log('Invoked src getter') return 'foo'; }};Object.assign(dest, src);//调用src的获取方法,调用dest的设置方法并传入参数"foo",因为这里的设置函数不执行赋值操作,所有实际上并没有把值转移过来。console.log(dest); //{set a(val) {...}}
//覆盖属性dest = {id: "dest"};result = Object.assign(dest, {id: "src1", a: "foo"}, {id: "src2", b: "bar"});//Object.assign会覆盖重复的属性console.log(result); //{id: src2, a: foo, b: bar}
此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数。
//对象引用dest = {};src = {a: {}};Object.assign(dest, src);//浅复制意味着只会复制对象的引用console.log(dest); //{a: {}}console.log(dest.a === src.a); //true
如果赋值期间出错,则操作会中止并退出,同时抛出错误。Object.assign()没有"回滚"之前赋值的概念,因此它是一个尽力而为,可能只会完成部分复制的方法。
let dest, src, result;//错误处理dest = {};src = { a: 'foo', get b() { //Object.assign()在调用这个获取函数时会抛出错误 throw new Error(); }, c: 'bar'};try { Object.assign(dest, src);} catch(e) {}console.log(dest); //{a: foo}
8.1.5 对象标识及相等判定
在ECMAScript6之前,有些特殊情况即使是===操作符也无能为力:
console.log(+0 === -0); //trueconsole.log(+0 === 0); //trueconsole.log(-0 === 0); //true//要确定NaN的相等性。必须使用极为讨厌的isNaN()console.log(NaN === NaN); //falseconsole.log(isNaN(NaN)); //true
为了改善这类情况,新增了Object.is(),这个方法与===很像,但同时也考虑到了上面的情况。这个方法必须接收两个参数:
console.log(Object.is(true, 1)); //falseconsole.log(Object.is({}, {})); //falseconsole.log(Object.is("2", 2)); //falseconsole.log(Object.is(+0, -0)); //falseconsole.log(Object.is(0, -0)); //falseconsole.log(Object.is(+0, 0)); //trueconsole.log(Object.is(NaN, NaN)); //true
要检查超过两个值,递归地利用相等性传递即可:
function rex(x, ...rest) { return Object.is(x, rest[0]) && (rest.length < 2 || rex(...rest));}
8.1.6 增强的对象语法
- 属性值简写
在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。例如:
let name = 'Matt';let person = { name: name};console.log(person); //{name: 'Matt'}
为此,简写属性名语法出现了。简写属性名只要使用变量名(不用再写冒号)就会自动别解释为同名的属性键。如果没有找到同名变量,则会抛出ReferenceError。
let name = 'Matt';let person = { name};console.log(person.name); //Matt
- 可计算属性
在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。换句话说,不能在对象字面量中直接动态命名属性。
const nameKey = 'name';const ageKey = 'age';const jobKey = 'job';let person = {};person[nameKey] = 'Matt';person[ageKey] = 27;person[jobKey] = 'teacher';console.log(person); //{name: 'Matt', age: 27, job: 'teacher'}
有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为JavaScript表达式而不是字符串来求值:
const nameKey = 'name';const ageKey = 'age';const jobKey = 'job';let person = { [nameKey]: 'Matt', [ageKey]: 27, [jobKey]: 'teacher'};console.log(person); //{name: 'Matt', age: 27, job: 'teacher'}
因为被当作JavaScript表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值:
const nameKey = 'name';const ageKey = 'age';const jobKey = 'job';let uniqueToken = 0;function getUniqueKey(key) { return `${key}_${uniqueToken++}`}function person { [getUniqueKey(nameKey)]: 'Matt', [getUniqueKey(ageKey)]: 27, [getUniqueKey(jobKey)]: 'teacher'};console.log(person); //{name_0: 'Matt', age_1: 27, job_3: 'teacher'}
- 简写方法名
在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式
let person = { sayName: function(name) { console.log(`My name is ${name}`); }};person.sayName('Matt'); //My name is Matt
新的简写方法的语法遵循同样的模式,但开发者要放弃给函数表达式命名
let person = { sayName(name) { console.log(`My name is ${name}`); }}person.sayName('Matt'); //My name is Matt
简写方法名与可计算属性键相互兼容:
const methodKey = 'sayName';let person = { [methodKey](name) { console.log(`My name is ${name}`); }}person.sayName('Matt'); //My name is Matt
8.1.7 对象解构
ECMAScript6新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。
下面的例子展示了两段等价的代码,首先是不使用对象解构的:
let person = { name: 'Matt', age: 27};let personName = person.name;let personAge = person.age;console.log(personName); //Mattconsole.log(personAge); //27
然后,是使用对象解构的:
let person = { name: 'Matt', age: 27};let {name: personName, age: personAge} = person;console.log(personName); //Mattconsole.log(personAge); //27
使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。如果想让变量直接使用属性的名称,那么可以使用简写语法,比如:
let person = { name: 'Matt', age: 27};let {name, age} = person;console.log(name); //Mattconsole.log(age); //27
解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是undefined:
let person = { name: 'Matt', age: 27};let {name, job} = person;console.log(name); //Mattconsole.log(job); //undefined
也可以在解构赋值的同时定义默认值
let person = { name: 'Matt', age: 27};let {name, job='Teacher'} = person;console.log(name); //Mattconsole.log(job); //Teacher
解构在内部使用函数ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当初对象。这也意味着,null和undefined不能被解构,否则会抛出错误。
let {length} = 'foobar';console.log(length); //6let {constructor: c} = 4;console.log(c === Number); //truelet {_} = null; //TypeErrorlet {_} = undefined; //TypeError
解构并不要求变量必须在解构表达式中声明。不过,如果时给事先声明的变量赋值,则赋值表达式必须包含在一对括号中:
let personName, personAge;let person = { name: 'Matt', age: 27};({name: personName, age: personAge} = person);console.log(personName, personAge); //Matt, 27
- 嵌套解构
解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性:
let person = { name: 'Matt', age: 27, job: { title: 'Teacher' }};let personCopy = {};({name: personCopy.name, age: personCopy.age, job: personCopy.job} = person);//因为一个对象的引用被赋值给personCopy,所有修改person.job对象的属性也会影响personCopyperson.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: 'Teacher' }};//声明title变量并将person.job.title的值赋给它let {job: {title}} = person;console.log(title); //Teacher
const abc = { a: 'limin', b: { c: { k: 'k1' }, o: { i: 'i1' } }, m: { q: 'q1' }}const { a, b, m, b: { c }, b: { o }, m: { q }, b: { c: { k } } } = abc;
在外层属性没有定义的情况下不能使用嵌套结构。无论源对象还是目标对象都一样:
let person = { job: { title: 'Teacher' }};let personCopy = {};//foo在源对象上是undefined({foo: {bar: personCopy.bar}} = person); //TypeError//job在目标对象上是undefined({job: {title: personCopy.job.title}} = person); //TypeError
- 部分解构
需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分:
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
- 参数上下文匹配
在函数参数列表中也可以进行解构赋值。对参数的解构赋值不影响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', 27printPerson2('1st', person, '2nd');//['1st', {name: 'Matt', age: 27}, '2nd']//'Matt', 27
8.2 创建对象
虽然使用Object构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。
8.2.1 概述
ECMAScript5.1并没有正式支持面向对象的结构,比如类或继承。但是,巧妙地运用原型式继承可以成功地模拟同样的行为。ECMAScript6开始正式支持类和继承。ES6的类都仅仅是封装了ES5.1的构造函数加原型继承的语法糖而以。
8.2.2 工厂模式
用于抽象创建特定对象的过程。
function creatPerson(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 = creatPerson("Nicholas", 29, "Teacher");let person2 = creatPerson("Greg", 27, "Doctor");
这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。
8.2.3 构造函数模式
自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; this.sayName = function() { console.log(this.name); }}let person1 = new Person("Nicholas", 29, "Teacher");let person2 = new Person("Greg", 27, "Doctor");person1.sayName(); //Nicholasperson2.sayName(); //Greg
这个例子中,Person()构造函数替代了creatPerson()工厂函数。实际上,Person()内部的代码跟creatPerson()基本是一样的。只有如下区别:
- 没有显示地创建对象
- 属性和方法赋值给了this
- 没有return
要创建Person的实例,应使用new操作符。以这种方式调用构造函数会执行如下操作:
- 在内存中创建一个新对象
- 这个新对象内部的[[Prototype]]特性被赋值为构造函数的Prototype属性
- 构造函数内部的this被赋值为这个新对象
- 执行构造函数内部的代码
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
person1和person2分别保存着Person的不同实例。这两个对象都有一个constructor属性指向Person
console.log(person1.constructor == Person); //trueconsole.log(person2.constructor == Person); //true
constructor本来是用于标识对象类型的。不过一般认为instanceof操作符是确定对象类型更可靠的方法。前面例子中的每个对象都是Object的实例,同时也是Person的实例
console.log(person1 instanceof Person); //trueconsole.log(person2 instanceof Person); //trueconsole.log(person1 instanceof Object); //trueconsole.log(person2 instanceof Object); //true
定义自定义构造函数可以确保实例被标识为特定类型,在这个例子中person1和person2之所以也被认为是Object实例,是因为所有自定义对象都继承自Object,构造函数不一定要写出函数声明的形式。赋值给变量的函数表达式也可以表示构造函数:
let Person = function(name, age, job) { this.name = name; this.age = age; this.job = job; this.sayName = function() { console.log(this.name); };}let person1 = new Person("Nicholas", 29, "Teacher");let person2 = new Person("Greg", 27, "Doctor");
在实例化时,如果不想传参,那么构造函数后面的括号可加可不加。只要new操作符,就可以调用相应的构造函数:
function Person() { this.name = "Jake", this.sayName = function() { console.log(this.name); };}let person1 = new Person();let person2 = new Person;
- 构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。并没有把某个函数定义为构造函数的特殊语法。任何构造函数只要使用new操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数。
//作为构造函数let Person = new Person("Nicholas", 29, "Teacher");Person.sayName(); //"Nicholas"//作为函数调用person("Greg", 27, "Doctor");window.sayName(); //"Greg"//在另一个对象的作用域中调用let o = new Object();person.call(o, "LM", 33, "Nurse");o.sayName(); //"LM"
- 构造函数的问题
其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言,Person1和Person2都有名为sayName()的方法,但这两个方法不是同一个Function实例。我们知道,ECMAScript中的函数是对象,因此每次定义函数时,都会初始化一个对象。
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; this.sayName = new Function("console.log(this.name)"); //逻辑等价}
每个Person实例都会有自己的Function实例用于显示name属性。以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新Function实例的机制是一样的。因此不同实例上的函数虽然同名却不相等
console.log(Person1.sayName == Person2.sayName); //false
要解决这个问题,可以把函数定义转移到构造函数外部
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; this.sayName = sayName;}function sayName() { console.log(this.name);}let person1 = new Person("Nicholas", 29, "Teacher");let person2 = new Person("Greg", 27, "Doctor");
在构造函数内部,sayName属性等于全局变量sayName()函数。因为这一次sayName属性中包含的只是一个指向外部函数的指针,所以Person1和Person2共享了定义在全局作用域上的sayName()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好的聚集在一起。这个新问题可以通过原型模式来解决。
8.2.4 原型模式
每个函数都会创建一个prototype属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋值给对象实例的值,可以直接赋值给它们的原型,如下所示:
function Person() {} 或者 let Person = function() {}Person.prototype.name = "Nicholas";Person.prototype.age = 29;Person.prototype.job = "Teacher";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
- 理解原型
无论何时,只要创建一个函数,就会按照特点的规则为这个函数创建一个prototype属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。在自定义构造函数时,原型对象默认只会获得constructor属性,其他的所有方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但Firefox、Safari和Chrome会在每个对象上暴露**proto**属性,通过这个属性可以访问对象的原型。实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
let Person = functon() {}console.log(Person.prototype);/*{ constructor: f Person(), _Proto_: Object}*/console.log(Person.prototype.constructor === Person); //true//实例通过_Proto_链接到原型对象 它实际上指向隐藏特性[[Prototype]]//构造函数通过prototype属性链接到原型对象//实例与构造函数没有直接联系,与原型对象有直接联系let person1 = new Person(); person2 = new Person();console.log(person1._proto_ === Person.prototype); //trueconsole.log(person1._proto_.constructor === Person); //true//同一个构造函数创建的两个实例共享同一个原型对象consle.log(person1._proto_ === person2._proto_); //true//instanceof检查实例的原型链中是否包含指定构造函数的原型console.log(person1 instanceof Person); //trueconsole.log(person2 instanceof Person); //trueconsole.log(Person.prototype instanceof Object); //true
虽然不是所有实现都对外暴露了[[Prototype]],但可以使用**isPrototypeOf()**方法确定两个对象之间的这种关系。本质上,isPrototypeOf()会在传入参数的[[Prototype]]指向调用它的对象时返回true
console.log(Person.prototype.isPrototypeOf(person1)); //trueconsole.log(Person.prototype.isPrototypeOf(person2)); //true
ECMAScript的Object类型有一个方法叫Object.getPrototypeOf(),返回参数的内部特性[[Prototype]]的值
console.log(Object.getPrototypeOf(person1) === Person.prototype); //trueconsole.log(Object.getPrototypeOf(person1).name); //"Nicholas"
Object类型还有一个**setPrototypeOf()**方法,可以向实例的私有特性[[Prototype]]写入一个新值。这样就可以重写一个对象的原型继承关系:
let biped = { numLegs: 2};let person = { name: "Matt"};Object.setPrototypeOf(person, biped);console.log(person.name); //Mattconsole.log(person.numLegs); //2console.log(Object.getPrototypeOf(person) === biped); //true
Object.setPrototypeOf()可能会严重影响代码性能,可以通过**Object.create()**来创建一个对象,同时为其指定原型
let biped = { numLegs: 2};let person = Object.create(biped);person.name = 'Matt';console.log(person.name); //Mattconsole.log(person.numLegs); //2console.log(Object.getPrototypeOf(person) === biped); //true
- 原型层级
在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。虽然可以通过实例读取原型对象上的值,但不可能通过实例重新这些值。如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性
let Person = function() {}Person.prototype.name = "Nicholas";Person.prototype.age = 29;Person.prototype.job = "Teacher";Person.prototype.sayName = function() { console.log(this.name);};let person1 = new Person();let person2 = new Person();person1.name = "Jxx";console.log(person1.name); //"Jxx"console.log(person2.name); "Nicholas"
不过使用delete操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象
delete person1.name;console.log(person1.name); //"Nicholas"
**hasOwnProperty()**方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自Object的,会在属性存在于调用它的对象实例上时返回true
function Person() {}Person.prototype.name = "Nicholas";Person.prototype.age = 29;Person.prototype.job = "Teacher";Person.prototype.sayName = function() { console.log(this.name);};let person1 = new Person();let person2 = new Person();console.log(person1.hasOwnProperty("name")); //falseperson1.name = "Jxx";console.log(person1.name); //"Jxx"console.log(person1.hasOwnProperty("name")); //true
- 原型和in操作符
有两种方式使用in操作符:单独使用和在for-in循环中使用。在单独使用时,in操作符会在可以通过对象访问指定属性时返回true,无论该属性是在实例上还是在原型上
function Person() {}Person.prototype.name = "Nicholas";Person.prototype.age = 29;Person.prototype.job = "Teacher";Person.prototype.sayName = function() { console.log(this.name);};let person1 = new Person();let person2 = new Person();console.log(person1.hasOwnProperty("name")); //falseconsole.log("name" in person1); //trueperson1.name = 'Jxx';console.log(person1.name); //'Jxx'console.log(person1.hasOwnProperty("name")); //trueconsole.log("name" in person1); //true
在for-in循环中使用in操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性
function Person() {}Person.prototype.name = "Nicholas";Person.prototype.age = 29;Person.prototype.job = "Teacher";Person.prototype.sayName = function() { console.log(this.name);};let p1 = new Person();p1.name = "Rob";p1.age = 44;for (let keys in p1) { console.log(keys)}/*nameagejobsayName*/for (let keys in Person.prototype) { console.log(keys)}/*nameagejobsayName*/
要获得对象上所有可枚举的实例属性,可以使用Object.keys()方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组
let keys = Object.keys(Person.prototype);console.log(keys); //["name", "age", "job", "sayName"]let p1keys = Object.keys(p1);console.log(p1keys); //["name", "age"]
如果想列出所有实例属性,无论是否可以枚举,都可以使用Object.getOwnPropertyNames()
let keys = Object.getOwnPropertyNames(Person.prototype);console.log(keys); //["constructor", "name", "age", "job", "sayName"]let keys1 = Object.getOwnPropertyNames(p1);console.log(keys1); //["name", "age"]
- 属性枚举顺序
for-in循环、Object.keys()、Object.getOwnPropertyNames()以及Object.assign()在属性枚举顺序方面有很大的区别。for-in循环和Object.keys()的枚举顺序是不确定的,取决于JavaScript引擎,可能因浏览器而异。
Object.getOwnPropertyNames()和Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入
8.2.5 对象迭代
ECMAScript2017新增了两个静态方法,用于将对象内容转换为序列化的。这两个静态方法Object.values()和Object.entries()接收一个对象,返回它们内容的数值。Object.values()返回对象值的数值,Object.entries()返回键/值对的数值。
- 其他原型语法
每次定义一个属性或方法都会把Person.prototype重写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成了一个常见的做法
function Person() {}Person.prototype = { name: "NIcholas", age: 29, job: "Teacher", sayNames() { console.log(this.name) }}
有一个问题:这样重新之后,Person.prototype的constructor属性就不指向Person了。在创建函数时,也会创建它的prototype对象,同时会自动给这个原型的constructor属性赋值。而上面的写法完全重写了默认的prototype对象,因此其constructor属性也指向了完全不同的新对象(Object构造函数),不再指向原来的构造函数。虽然instanceof操作符还能可靠的返回值,但我们不能再依靠constructor属性来识别类型了
let friend = new Person();console.log(friend instanceof Object); //trueconsole.log(friend instanceof Person); //trueconsole.log(friend.constructor == Person); //falseconsole.log(friend.constructor == Object); //true
解决方法:
function Person() {}Person.prototype = { constructor: Person, name: "NIcholas", age: 29, job: "Teacher", sayNames() { console.log(this.name) }}
以这种方式恢复constructor属性会创建一个[[Enumerable]]为true的属性。而原生constructor属性默认是不可枚举的。解决方法:
function Person() {}Person.prototype = { name: "NIcholas", age: 29, job: "Teacher", sayNames() { console.log(this.name) }};//恢复constructor属性Object.defineProperty(Person.prototype, "constructor", { enumerable: false, value: Person})
- 原型的动态性
因为从原型上搜索值的过程是动态的,所有即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来
let friend = new Person();Person.protorype.sayHi = function() { console.log("hi")}frined.sayHi(); //"hi"
主要原因是实例于原型之间松散的联系。因为实例和原型之间的链接就是简单的指针,而不是保存的副本,所有会在原型上找到sayHi属性并返回这个属性保存的函数。实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重新整个原型会切断最初原型于构造函数的联系,但实例引用的仍然是最初的原型。记住,实例只有指向原型的指针,没有指向构造函数的指针。
function Person() {};let friend = new Person();Person.prototype = { constructor: Person, name: "Nicholas", age: 78, job: "Teacher", sayName() { console.log(this.name) }};friend.sayName(); //错误
这个例子中,Person的新实例是在重新原型对象之前创建的。在调用frend.sayName()的时候,会导致错误。这是因为friend指向的是原型还是最初的原型,而这个原型上并没有sayName属性。
重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。
- 原生对象原型
原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(包括Object、Array、String等)都在原型上定义了实例方法。比如,数组实例的sort()方法就是Array.prototype上定义的,而字符串包装对象的substring()方法也是在String.protorype上定义的
console.log(typeof Array.prototype.sort); //"function"console.log(typeof String.prototype.substring); //"function"
通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法。msg是个字符串,在读取它的属性时,后台会自动创建String的包装实例,从而找到并调用startsWith()方法
String.prototype.startsWith = function(text) { return this.indexOf(text) === 0;};let msg = "Hello world!"console.log(msg.starsWith("Hello")); //true
不推荐在产品环境中修改原生对象原型。可能引发命名冲突,另外还有可能意外重写原生的方法。推荐的做法是创建一个自定义的类,继承原生类型
- 原型的问题
首先它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型最大的问题。原型的最主要问题源自它的共享属性。我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性。
function Person() {}Person.prototype= { constructor: Person, name = "Nicholas", age = 29, job = "Teacher", friends: ["Jxx", "Lm"], sayName() { console.log(this.name); };let person1 = new Person();let person2 = new Person();person1.friends.push("Van");console.log(person1.friends); //["Jxx", "Lm", "Van"]console.log(person2.friends); //["Jxx", "Lm", "Van"]console.log(person1.friends === person2.friends); //true
如果有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因
8.3 继承
接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在ECMAScript中是不可能的,因为函数没有签名。实现继承是ECMAScript唯一支持的继承方式,而这主要是通过原型链实现的。
8.3.1 原型链
ECMA-262把原型链定义为ECMAScript的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。重温一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。
function SuperType() { this.property = true; //实例属性}SuperType.prototype.getSuperValue = function() { //原型方法 return this.prototype;};function SubType() { this.subproperty = false;}//继承SuperTypeSubType.prototype = new SuperType();SubType.portotype.getSubValue = function() { return this.subproperty;};let instance = new SubType();console.log(instance.getSuperValue()); //true
这个赋值重写了SubType最初的原型,将其替换为SuperType的实例。这意味着SuperType实例可以访问的所有属性和方法也会存在于SubType.prototype。这个例子中实现继承的关键,是SubType没有使用默认原型,而是将其替换成了一个新的对象。这个对象恰好是SuperType的实例。这样一来,SubType的实例不仅能从SuperType的实例中继承属性和方法,而且还与SuperType的原型挂上了钩。于是instance(通过内部的[[Prototype]])指向SubType.prototype,而SubType.prototype(作为SuperType的实例又通过内部的[[Prototype]])指向SuperType.prototype。
注意:getSuperValue()方法还在SuperType.prototype对象上,而property属性则在SubType.prototype上。这是因为getSuperValue()是一个原型方法,而property是一个实例属性。SubType.pototype现在是SuperType的一个实例,因此property才会储存在它上面。还要注意,由于SubType.prototype的constructor属性被重写为指向SuperType,所以instance.constructor也指向SuperType。
原型链扩展了前面描述的原型搜索机制。我们知道,在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索可以继承向上,搜索原型的原型。对前面的例子而言,调用instance.getSuperValue()经过了3步搜索:instance、SubType.prototype和SuperType.prototype,最后一步才找到这个方法。对属性和方法的搜索会一直持续到原型链的末端。
- 默认原型
实际上,原型链中还有一环。默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。任何函数的默认原型都是一个Object的实例,这意味着这个实例有一个内部指针指向Object.prototype。这也是为什么自定义类型能够继承包括toString()、valueOf()在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系。SubType继承SuperType,而SuperType继承Object。在调用instance.toString()时,实际上调用的时保存在Object.prototype上的方法。
- 原型与继承关系
原型与实例的关系可以通过两种方式来确定。第一种方式是使用instanceof操作符,如果一个实例的原型链中出现过相应的构造函数,则instanceof返回true
console.log(instance instanceof Object); //trueconsole.log(instance instanceof SuperType); //trueconsole.log(instance instanceof SubType); //true
从技术上讲,instace是Object、SuperType和SubType的实例,因为instance的原型链中包含这些构造函数的原型
确定这种关系的第二中方式是使用isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法,只要原型链中包含这个原型,这个方法就返回true
console.log(Object.prototype.isPrototypeOf(instance)); //trueconsole.log(SuperType.prototype.isPrototypeOf(instance)); //trueconsole.log(SubType.prototype.isPrototypeOf(instance)); //true
- 关于方法
子类有时候需要覆盖父类的方法,或增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上
function SuperType() { this.property = true; }SuperType.prototype.getSuperValue = function() { return this.prototype;};function SubType() { this.subproperty = false;}//继承SuperTypeSubType.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
后面在SubType实例上调用getSuperValue()时调用的时这个方法。而SuperType的实例仍然会调用最初的方法。重点在于上述两个方法都是在把原型赋值为SuperType的实例之后定义的
另一个要理解的重点:以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链
function SuperType() { this.property = true; }SuperType.prototype.getSuperValue = function() { return this.prototype;};function SubType() { this.subproperty = false;}//继承SuperTypeSubType.prototype = new SuperType();//通过对象字面量添加新方法,这会导致上一行无效SubType.prototype = { getSubValue() { return this.subproperty; }, someOtherMethod() { return false; }};let instance = new SubType();console.log(instance.getSuperValue()); //出错!
这段代码中,子类的原型在被赋值为SuperType的实例后,又被一个对象字面量覆盖了。覆盖后的原型是一个Object的实例,而不再是SuperType的实例。因此之前的原型链就断了。SubType和SuperType之间也没有关系了
- 原型链的问题
- 主要问题出现在原型中包含引用值的时候会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。
- 子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上原型中包含引用值的问题,就导致原型链基本不会被单独使用
8.3.2 盗用构造函数
为了解决原型包含引用值导致的继承问题,一种叫做"盗用构造函数"的技术在开发社区流行起来(这种技术有时也称"对象伪装"或"经典继承")。基本思路:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特点上下文中执行代码的简单对象,所有可以使用apply()和call()方法以新创建的对象为上下文执行构造函数。
function SuperType() { this.colors = ["red", "pink", "green"];}function SubType() { //继承SuperType Supertype.call(this);}let instance1 = new SubType();instance1.colors.push("blue");console.log(instance1.colors); //["red", "pink", "green", "blue"]let instance2 = new SuperType();console.log(instance2.colors); //["red", "pink", "green"]
这相当于新的SubType对象上运行了SuperType()函数中的所有初始化代码。结果就是每个实例都会有自己的colors属性。
- 传递参数
相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传承。
function SuperType(name) { this.name = name;}function SubType() { //继承SuperType并传承 SuperType.call(this, "Nicholas"); //实例属性 this.age = 23;}let instance = new SubType();console.log(instance.name); //"Nicholas"console.log(instance.age); //23
为确保SuperType构造函数不会覆盖SubType定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性
- 盗用构造函数的问题
主要缺点:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用
8.3.3 组合继承
综合了原型链和盗用构造函数,将两者的有点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
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", 23);instance1.colors.push("black");console.log(instance1.colors); //["red", "blue", "green", "black"]instance1.sayName(); //"Nicholas"instance1.sayAge(); //23let instance2 = new SubType("Jxx", 24);console.log(instance2.colors); ["red", "blue", "green"]instance2.sayName(); //"Jxx"instance2.sayAge(); //24
组合继承弥补了原型链和盗用构造函数的不足,是JavaScript中使用最多的继承模式。而组合继承也保留了instanceof操作符和isPrototypeOf()方法识别合成对象的能力
8.3.4 原型式继承
一种不涉及严格意义上的构造函数的继承方法。出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。
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"]
原型式继承适用于:你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给object(),然后再对返回的对象进行适当修改。ECMAScript5通过增加**Object.create()**方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create()与这里的object()方法效果相同
let person = { name: "Nicholas", friends: ["Shelby", "Court", "Van"]};let anotherPerson = Object.create(person);anotherPerson.name = "Greg";anotherPerson.friends.push("Rob");let yetAnotherPerson = Object.create(person);yetAnotherPerson.name = "Linda";yetAnotherPerson.friends.push("Barbie");console.log(person.friends); //["Shelby", "Court", "Van", "Rob", "Barbie"]
Object.create()方法的第二个参数与Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性
let person = { name: "Nicholas", friends: ["Shelby", "Court", "Van"]};let anotherPerson = Object.create(person, { name: { value: "Greg" }});console.log(anotherPerson.name); //"Greg"
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但是属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。
8.3.5 寄生式继承
与原型式继承比较接近的一种继承方式,寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象
function createAnother(original) { let clone = object(original); //通过调用函数创建一个新对象 clone.sayHi = function() { //以某种方式增强这个对象 console.log("hi") }; return clone; //返回这个对象}
在这段代码中,cerateAnother()函数接收一个参数,就是新对象的基准对象。这个对象original会被传给object()函数,然后将返回的新对象赋值给clone。接着clone对象添加一个新方法sayHi()。最好返回这个对象
let person = { name: "Nicholas", friends: ["Shelby", "Court", "Van"]};let anotherPerson = createAnother(person);anotherPerson.sayHi(); //"hi"
这个例子基于person对象返回了一个新对象,新返回的anotherPerson对象具有person的所有属性和方法,还有一个新方法叫sayHi()。寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。
通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似
8.3.6 寄生式组合继承
组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次是在创建子类原型时调用,另一次是在子类构造函数中调用
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);};
在上面的代码执行后,SubType.prototype上会有两个属性:name和colors。它们都是SuperType的实例属性,但现在成为了SubType的原型属性。在调用SubType构造函数时,也会调用SuperType构造函数,这一次会在新对象上创建实例属性name和colors。这两个实例属性会遮蔽原型上同名的属性。
有两组name和colors属性:一组在实例上,另一组在SubType原型上。这是调用两次SuperType构造函数的结果。解决问题的办法:寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路:不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的的新对象赋值给子类原型。
function inheritPrototype(subType, superType) { let prototype = object(superType.prototype); //创建对象 prototype.constructor = subType; //增强对象 subType.prototype = prototype; //赋值对象}
这个函数接收两个参数:子类构造函数和父类构造函数。这里只调用了一次SuperType构造函数,避免了SubType.prototype上不必要也用不到的属性,因此效率更高。而且原型链依然保持不变,因此instanceof操作符和isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式
function SuperType(name) { this.name = name; this.colors = ["red", "bule", "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)}
8.4 类
ECMAScript6新引入的class关键字具有正式定义类的能力。类(class)是ECMAScript中新的基础性语法糖结构。虽然ECMAScript6类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念
8.4.1 类定义
与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用class关键字加大括号:
//类声明class Person {}//类表达式const Animal = class {};
与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能:
console.log(FunctionExpression); //undefinedvar FunctionExpression = function() {};console.log(FunctionExpression); //function() {}console.log(ClassExpression); //undefinedvar ClassExpression = class {};console.log(ClassExpression); //calss {}console.log(FunctionDeclaration); //FunctionDeclaration() {};function FunctionDeclaration() {}console.log(FunctionDeclaration); //FunctionDeclaration() {};console.log(ClassDeclaration); //undefinedclass ClassDeclaration {}console.log(ClassDeclaration); //class ClassDeclaration {}
另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制:
{ function FunctionDeclaration() {} class ClassDeclaration {}}console.log(FunctionDeclaration); //FunctionDeclaratio() {}console.log(ClassDeclaration); //ReferenceError: ClassDeclaration is not defined
类的构成
类可以包含构造函数方法,实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。与构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例。
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过name属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符
let Person = class PersonName { identify() { console.log(Person.name, PersonName.name); }}let p = new Person();p.identify(); //PersonName PersonNameconsole.log(Person.name); //PersonNameconsole.log(PersonName); //ReferenceError: personName is not defined
8.4.2 类构造函数
constructor关键字用于在类定义块内部创建类的构造函数。方法名constructor会告诉解释器在使用new操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。
- 实例化
使用new操作符实例化Person的操作等于使用new调用其构造函数。唯一不同是JavaScript解释器知道使用new和类意味着应该使用constructor函数进行实例化。使用new调用类的构造函数会执行如下操作:
- 在内存中创建一个新对象
- 这个新对象内部的[[prototype]]指针被赋值为构造函数的prototype属性
- 构造函数内部的this被赋值为这个新对象(即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 ctorlet v = new Vegetable();console.log(v.color); //orange
类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的
class Person { constructor(name) { console.log(arguments.length); this.name = name || null; }}let p1 = new Person; //0console.log(p1.name); //nulllet p1 = new Person(); //0console.log(p1.name); //nulllet p1 = new Person('Jake'); //1console.log(p1.name); //Jake
默认情况下,类构造函数会在执行之后返回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); //trueconsole.log(p2); //{bar: 'bar'}console.log(p2 instanceof Person); //false
类构造函数与构造函数的主要区别是,调用类构造函数必须使用new操作符。而普通构造函数如果不使用new调用,那么就会以全局的this(通常是window)作为内部对象。调用类构造函数时如果忘了使用new则会抛出错误:
function Person() {}class Animal {}let p = Person(); //把window作为this来构建实例let a = Animal();//TypeError: class constructor Animal cannot be invoked without 'new'
类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用new调用)。因此,实例化之后可以在实例上引用它:
class Person {}let p1 = new Person();p1.constructor();//TypeError: class constructor Animal cannot be invoked without 'new'//使用对类构造函数的引用创建一个新实例let p2 = new p1.constructor();
- 把类当成特殊函数
ECMAScript类就是一种特殊函数。
class Person {}console.log(Person); //class Person {}console.log(typeof Person); //function
类标识符有prototype属性,而这个原型也有一个constructor属性指向类自身:
class Person {}console.log(Person.prototype); //{constructor: f()}console.log(Person === Person.prototype.constructor); //true
类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用new调用时就会被当成构造函数。重点在于,类中定义的constructor方法不会被当成构造函数,在对它使用instanceof操作符时会返回false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么instanceof操作符的返回值会反转:
class Person {}let p1 = new Person();console.log(p1.constructor === Person); //trueconsole.log(p1 instanceof Person); //trueconsole.log(p1 instanceof Person.constructor); //falselet p2 = new Person.constructor();console.log(p2.constructor === Person); //falseconsole.log(p2 instanceof Person); //falseconsole.log(p2 instanceof Person.constructor); //true
类也可以像其他对象或函数引用一样把类作为参数传递,类可以像函数一样在任何地方定义,比如在数组中
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'); //barconsole.log(p); //Foo{}
8.4.3 实例、原型和类成员
- 实例成员
每次通过new调用标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加"自有"属性。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:
class Person { constructor() { //这个例子先使用对象包装类型定义一个字符串,为的是在下面测试两个对象的相等性 this.name = new String('Jack'); this.sayName = () => console.log(this.name); this.nicknames = ['Jack', 'J-Dog'] }}let p1 = new Person(), p2 = new Person();p1.sayName(); //Jackp2.sayName(); //Jackconsole.log(p1.name === p2.name); //falseconsole.log(p1.sayName === p2.sayName); //falseconsole.log(p1.nickname === p2.nickname); //falsep1.name = p1.nicknames[0];p2.name = p2.nicknames[1];p1.sayName(); //Jackp2.sayName(); //J-Dog
- 原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。
class Person { constructor() { //添加到this的所有内容都会存在于不同的实例上 this.locate = () => console.log('instance'); } //在类块中定义的所有内容都会定义在类的原型上 locate() { console.log('prototype'); }}let p = new Person();p.locate(); //instancePerson.prototype.locate(); //prototype
可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据:
class Person { name: 'Jack'}//Uncaught SyntaxError: Unexpected token
类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:
class Person { ['conmputed' + 'Key'] () { console.log('invoked computedKey'); }}let p = new Person();p.computedKey(); //invoked computedKey
类定义也支持获取和设置访问器。语法于行为跟普通对象一样:
class Person { set name(newName) { this.name_ = newName; } get name() { return this.name_; }}let p = new Person();p.name = 'Jack';console.log(p.name); //Jack
- 静态类方法
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。与原型成员类似,静态成员每个类上只能有一个。**静态类成员在类定义中使用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 { 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引用的数据
- 迭代器与生成器方法
类定义语法支持在原型和类本身上定义生成器方法,因为支持生成器方法,所有可以通过添加一个默认的迭代器,把类实例变成可迭代对象,也可以只返回迭代器实例
8.4.4 继承
- 继承基础
ES6类支持单继承。使用extends关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,着意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容):
class Vehicle {}//继承类class Bus extends Vehicle {}let b = new Bus();console.log(b instanceof Bus); //trueconsole.log(b instanceof Vehicle); //truefunction Person() {}//继承普通构造函数class Engineer extends Person {}let e = new Engineer();console.log(e instanceof Engineer); //trueconsole.log(e instanceof Person); //true
**派生类(利用继承机制,新的类可以从已有的类中派生)**都会通过原型链访问到类和原型上定义的方法。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.identiryPrototype('vehicle'); //vehicle, Vehicle {}Bus.identifyClass('bus'); //bus, class Bus {}vehicle.identifyClass('vehicle'); //vehicle, class Vehicle {}
extends关键字也可以在类表达式中使用,因此let Bar = class extends Foo {}是有效的语法
- 构造函数、HomeObject和super()
派生类的方法可以通过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 identity() { console.log('vehicle'); }}class Bus extends Vehicle { static identify() { super.identify(); }}BUs.indetify(); //vehicle
注意:ES6给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在JavaScript引擎内部访问。super始终会定义为[[HomeObject]]的原型
在使用super时要注意的几个问题:
- super只能在派生类构造函数和静态方法中使用
class Vehicle { constructor() { super(); //SyntaxError: 'super' keyword unexpected }}
- 不能单独引用super关键字,要么用它调用构造函数,要么用它引用静态方法。
class Vehicle {}class Bus extends Vehicle { constructor() { super(); console.log(this instanceof Vehicle); console.log(this instanceof Bus) console.log(this) }}new Bus();//true//true//Bus {_proto_: Vehicle}
- 调用super()会调用父类构造函数,并将返回的实例赋值给this
class Vehicle {}class Bus extends Vehicle { constructor() { super(); console.log(this instanceof Vehicle); }}new Bus(); //ture
- super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入
class Vehicle { constructor() { this.licensePlate = licensePlate; }}class Bus extends Vehicle { constructor(licensePlate) { super(licensePlate); }}console.log(new Bus('1337H4X')); //Bus {licensePlate: '1337H4X'}
- 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的参数
class Vehicle { constructor(licesePlate) { this.licensePlate = licensePlate; }}class Bus extends Vehicle {}console.log(new Bus('1337H4X')); //Bus {licensePlaate: '1337H4X'}
- 在类构造函数中,不能在调用super()之前引用this
class Vehicle {}class Bus extends Vehicle{ constructor() { console.log(this); }}new Bus();//ReferenceError: Must call super constructor in derived class// before accessing 'this' or returning from derived constructor
- 如果在派生类中显式定义了构造函数,则要么必须在其中调用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()); //{}
- 抽象基类
有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。虽然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()
- 继承内置类型(基本类型+引用类型)
ES6类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:
class SuperArray extends Array { shuffle() { //洗牌算法 for (let i = this.length - 1; i> 0; i--) { const j = (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); //trueconsole.log(a instanceof SuperArray); //trueconsole.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]consolelog(a2); //[1,3,5]console.log(a1 instanceof SuperArray); //trueconsole.log(a2 instanceof SuperArray); //true
如果想覆盖这个默认行为,则可以覆盖Symbol.species访问器,这个访问器决定在创建返回的实例时使用的类:
class SuperArray extends Array { static get [Symble.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]consolelog(a2); //[1,3,5]console.log(a1 instanceof SuperArray); //trueconsole.log(a2 instanceof SuperArray); //false
- 类混入
把不同类的行为集中到一个类是一种常见的JavaScript模式。虽然ES6没有显式支持多类继承,但通过现有特性可以轻松地模拟这种行为。
注意:Object.assign()方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用Object.assign()就可以了
在下面的代码片段中,extends关键字后面是一个JavaScript表达式。任何可以解析为一个类或一个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值:
class Vehicle {}function getParentClass() { console.log('evaluated expression'); return Vehicle;}class Bus extends getParentClass() {} // evaluated expression//可求值的表达式console.log(getParentClass()) //class Vehicle {}
混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。如果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(); //barb.baz(); //baz
通过写一个辅助函数,可以把嵌套调用展开:
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') }};function min(BaseClass, ...Mixins) { return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);}class Bus extends min(Vehicle, FooMixin, BarMixin, BazMixin) {}let b = new Bus();b.foo(); //'foo'b.bar(); //barb.baz(); //baz
注意:很多JavaScript框架已经抛弃了混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。