前端之ES6核心语法

前端之ES6核心语法
墨颜丶文章声明:本文来自阮一峰老师发表在ES6 入门教程 - ECMAScript 6入门上的和自己阅读后自己的编撰
ES6新特性
- | - |
---|---|
let和const命令 | 迭代器和生成器 |
es6的模板字符串 | Promise对象 |
增强的函数 | Proxy对象 |
扩展的字符串、对象、数组功能 | async的用法 |
解构赋值 | 类class |
Symbol | Map和Set |
模块化实现 |
let和const命令
let
是一个块级作用域
ES6 新增了let
命令,用来声明变量。它的用法类似于var
,但是所声明的变量,只在let
命令所在的代码块内有效。
1 | <script> |
上面代码在代码块之中,分别用let
和var
声明了两个变量。然后在代码块之外调用这两个变量,结果let
声明的变量报错,var
声明的变量返回了正确的值。这表明,let
声明的变量只在它所在的代码块有效。
let
声明变量,没有变量提升
1 | console.log(a); // undefined |
上述代码,为什么var
不会报错,是因为编译器在编译的时候,看到关键词var
时,会先提前声明
1 | console.log(a); // undefined |
let
不能重复声明变量
1 | var a=2; |
const
声明一个只读的常量。一旦声明,常量的值就不能改变。
1 | const PI = 3.1415; |
1 | const PI = 3.1415; |
上面代码表明改变常量的值会报错。
const
声明的变量不得改变值,这意味着,const
一旦声明变量,就必须立即初始化,不能留到以后赋值。
1 | const foo; |
上面代码表示,对于const
来说,只声明不赋值,就会报错。
const
的作用域与let
命令相同:只在声明所在的块级作用域内有效。
1 | if (true) { |
const
命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。
1 | if (true) { |
上面代码在常量MAX
声明之前就调用,结果报错。
const
声明的常量,也与let
一样不可重复声明。
1 | var message = "Hello!"; |
const
声明对象也一样,不可重复声明
1 | const person = { |
1 | const person = { |
要解决的问题
for
循环经典例子:
1 | // 声明一个数组 |
上面代码中,变量i
是var
命令声明的,在全局范围内都有效,所以全局只有一个变量i
。每一次循环,变量i
的值都会发生改变,而循环内被赋给数组arr
的函数内部的return i
,里面的i
指向的就是全局的i
。也就是说,所有数组arr
的成员里面的i
,指向的都是同一个i
,导致运行时输出的是最后一轮的i
的值,也就是 10。
如果使用let
,声明的变量仅在块级作用域内有效,最后输出的是 5。
1 | // 声明一个数组 |
不会污染全局变量
1 | var RegExp=10; |
总结
let
是一个块级作用域
let
声明变量,没有变量提升
let
不能重复声明变量
const
声明常量,一旦被声明无法修改,也没有变量提升,只在声明所在的块级作用域内有效
建议:在默认情况下用const
,而只有在你知道变量值需要被修改的情况下使用let
模板字符串
之前
1 | <div class="box"></div> |
ES6
1 | <div class="box"></div> |
变量的解构赋值
数组的解构赋值
基本用法
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。
以前,为变量赋值,只能直接指定值。
1 | let a = 1; |
ES6 允许写成下面这样。
1 | let [a, b, c] = [1, 2, 3]; |
上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。
本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。
1 | let [foo, [[bar], baz]] = [1, [[2], 3]]; |
如果解构不成功,变量的值就等于undefined
。
1 | let [foo] = []; |
以上两种情况都属于解构不成功,foo
的值都会等于undefined
。
另一种情况是不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。
1 | let [x, y] = [1, 2, 3]; |
上面两个例子,都属于不完全解构,但是可以成功。
如果等号的右边不是数组(或者严格地说,不是可遍历的结构,参见《Iterator》一章),那么将会报错。
1 | // 报错 |
上面的语句都会报错,因为等号右边的值,要么转为对象以后不具备 Iterator 接口(前五个表达式),要么本身就不具备 Iterator 接口(最后一个表达式)。
对于 Set 结构,也可以使用数组的解构赋值。
1 | let [x, y, z] = new Set(['a', 'b', 'c']); |
事实上,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。
1 | function* fibs() { |
上面代码中,fibs
是一个 Generator 函数(参见《Generator 函数》一章),原生具有 Iterator 接口。解构赋值会依次从这个接口获取值。
默认值
解构赋值允许指定默认值。
1 | let [foo = true] = []; |
对象的解构赋值
解构不仅可以用于数组,还可以用于对象。
1 | // ES5 |
对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
1 | let { bar, foo } = { foo: 'aaa', bar: 'bbb' }; |
上面代码的第一个例子,等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于undefined
。
如果解构失败,变量的值等于undefined
。
1 | let {foo} = {bar: 'baz'}; |
上面代码中,等号右边的对象没有foo
属性,所以变量foo
取不到值,所以等于undefined
。
对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。
1 | // 例一 |
上面代码的例一将Math
对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。例二将console.log
赋值到log
变量。
如果变量名与属性名不一致,必须写成下面这样。
1 | let { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
这实际上说明,对象的解构赋值是下面形式的简写。
1 | let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' }; |
也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
1 | let { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
上面代码中,foo
是匹配的模式,baz
才是变量。真正被赋值的是变量baz
,而不是模式foo
。
与数组一样,解构也可以用于嵌套结构的对象。
1 | let obj = { |
注意,这时p
是模式,不是变量,因此不会被赋值。如果p
也要作为变量赋值,可以写成下面这样。
1 | let obj = { |
下面是另一个例子。
1 | const node = { |
上面代码有三次解构赋值,分别是对loc
、start
、line
三个属性的解构赋值。注意,最后一次对line
属性的解构赋值之中,只有line
是变量,loc
和start
都是模式,不是变量。
下面是嵌套赋值的例子。
1 | let obj = {}; |
如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。
1 | // 报错 |
上面代码中,等号左边对象的foo
属性,对应一个子对象。该子对象的bar
属性,解构时会报错。原因很简单,因为foo
这时等于undefined
,再取子属性就会报错。
注意,对象的解构赋值可以取到继承的属性。
1 | const obj1 = {}; |
上面代码中,对象obj1
的原型对象是obj2
。foo
属性不是obj1
自身的属性,而是继承自obj2
的属性,解构赋值可以取到这个属性。
默认值
对象的解构也可以指定默认值。
1 | var {x = 3} = {}; |
默认值生效的条件是,对象的属性值严格等于undefined
。
1 | var {x = 3} = {x: undefined}; |
上面代码中,属性x
等于null
,因为null
与undefined
不严格相等,所以是个有效的赋值,导致默认值3
不会生效。
字符串的解构赋值
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
1 | const [a, b, c, d, e] = 'hello'; |
类似数组的对象都有一个length
属性,因此还可以对这个属性解构赋值。
1 | let {length : len} = 'hello'; |
数值和布尔值的解构赋值
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
1 | let {toString: s} = 123; |
上面代码中,数值和布尔值的包装对象都有toString
属性,因此变量s
都能取到值。
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefined
和null
无法转为对象,所以对它们进行解构赋值,都会报错。
1 | let { prop: x } = undefined; // TypeError |
函数参数的解构赋值
函数的参数也可以使用解构赋值。
1 | function add([x, y]){ |
上面代码中,函数add
的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量x
和y
。对于函数内部的代码来说,它们能感受到的参数就是x
和y
。
下面是另一个例子。
1 | [[1, 2], [3, 4]].map(([a, b]) => a + b); |
函数参数的解构也可以使用默认值。
1 | function move({x = 0, y = 0} = {}) { |
上面代码中,函数move
的参数是一个对象,通过对这个对象进行解构,得到变量x
和y
的值。如果解构失败,x
和y
等于默认值。
注意,下面的写法会得到不一样的结果。
1 | function move({x, y} = { x: 0, y: 0 }) { |
上面代码是为函数move
的参数指定默认值,而不是为变量x
和y
指定默认值,所以会得到与前一种写法不同的结果。
undefined
就会触发函数参数的默认值。
1 | [1, undefined, 3].map((x = 'yes') => x); |
用途
变量的解构赋值用途很多。
(1)交换变量的值
1 | let x = 1; |
上面代码交换变量x
和y
的值,这样的写法不仅简洁,而且易读,语义非常清晰。
(2)从函数返回多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
1 | // 返回一个数组 |
(3)函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来。
1 | // 参数是一组有次序的值 |
(4)提取 JSON 数据
解构赋值对提取 JSON 对象中的数据,尤其有用。
1 | let jsonData = { |
上面代码可以快速提取 JSON 数据的值。
(5)函数参数的默认值
1 | jQuery.ajax = function (url, { |
指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || 'default foo';
这样的语句。
(6)遍历 Map 结构
任何部署了 Iterator 接口的对象,都可以用for...of
循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。
1 | const map = new Map(); |
如果只想获取键名,或者只想获取键值,可以写成下面这样。
1 | // 获取键名 |
(7)输入模块的指定方法
加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。
1 | const { SourceMapConsumer, SourceNode } = require("source-map"); |
函数的扩展
函数参数的默认值
ES6 之前,不能直接为函数的参数指定默认值,只能采用变通的方法。
1 | // es5写法 |
默认的表达式也可以是一个函数
1 | // 2. a接受传参=10,b调用执行getVal(5) |
剩余参数
不具名参数的函数
1 | // ES5 写法 |
ES6 提供了一个方法rest 参数
,(形式为...变量名
),用于获取函数的多余参数,这样就不需要使用arguments
对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中, 解决了arguments 的问题。
1 | // keys = ["author","title","year"] |
arguments
对象不是数组,而是一个类似数组的对象。所以为了使用数组的方法,必须使用Array.from
先将其转为数组。rest 参数就不存在这个问题,它就是一个真正的数组,数组特有的方法都可以使用。下面是一个利用 rest 参数改写数组push
方法的例子。
1 | function checkArgs(...args){ |
知识点:
Object.create()
Object.create()
是一个 JavaScript 的内置方法,它创建一个新对象,使用现有的对象作为新创建的对象的[[Prototype]]
。这意味着新对象将继承现有对象的属性和方法。
这是 Object.create()
的基本语法:
1 | Object.create(proto, [propertiesObject]) |
参数:
proto
:必需的参数,用作新创建的对象的[[Prototype]]
。propertiesObject
:可选的参数,一个 JavaScript 对象,定义了额外的属性及对应值。
arguments
arguments 是一个特殊的对象,它表示函数被调用时传递给该函数的参数
1
2
3
4
5
6
7
8
9
10// 比如上述代码中的arguments
{
"0": {
"title": "es6教程",
"author": "墨颜",
"year": "2019"
},
"1": "author",
"2": "year"
}arguments.length
是一个 JavaScript 中的属性,它返回一个函数被调用时传递给该函数的参数的数量。
例如,如果你有一个函数 function myFunction() { console.log(arguments.length); }
,然后你调用 myFunction('one', 'two', 'three')
,那么控制台将输出 3
,因为 myFunction
被调用时传递了三个参数。
展开运算符
剩余运算符:把多个独立的合并到一个数组中
扩展运算符:将一个数组分割,并将各个项作为分离的参数传给函数
1 | const maxNum = Math.max(20,30,11,60) |
箭头函数
ES6 允许使用“箭头”(=>
)定义函数,比如匿名函数function(){} 等于 ()=>{}
。
1 | var f = v => v; |
如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。
1 | var f = () => 5; |
如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return
语句返回。
1 | var sum = (num1, num2) => { return num1 + num2; } |
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。
1 | // 报错 |
下面是一种特殊情况,虽然可以运行,但会得到错误的结果。
1 | let foo = () => { a: 1 }; |
上面代码中,原始意图是返回一个对象{ a: 1 }
,但是由于引擎认为大括号是代码块,所以执行了一行语句a: 1
。这时,a
可以被解释为语句的标签,因此实际执行的语句是1;
,然后函数就结束了,没有返回值。
如果箭头函数只有一行语句,且不需要返回值,可以采用下面的写法,就不用写大括号了。
1 | let fn = () => void doesNotReturn(); |
箭头函数可以与变量解构结合使用。
1 | const full = ({ first, last }) => first + ' ' + last; |
箭头函数使得表达更加简洁。
1 | const isEven = n => n % 2 === 0; |
上面代码只用了两行,就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目。
箭头函数的一个用处是简化回调函数。
1 | // 普通函数写法 |
另一个例子是
1 | // 普通函数写法 |
下面是 rest 参数与箭头函数结合的例子。
1 | const numbers = (...nums) => nums; |
再比如一个例子
1 | // 普通函数写法 |
ES5中的this指向:取决于调用该函数的上下文对象
1 | let PageHandle = { |
ES6:箭头函数是没有this指向的,箭头函数内部this只能通过查找作用域链来确定,一旦使用箭头函数,当前就不存在作用域链
1 | let PageHandle = { |
上述代码中=>
箭头函数是没有this指向的所以,所以他的作用域即为init: function (){}
,那么谁定义了init
,this
就指向了谁。
1 | let PageHandle = { |
如果init
的方法也改成箭头函数,里边的this
作用域即为PageHandle
,整个window定义了PageHandle
,所以即又会报错,所以给每个方法定义的时候不要使用箭头函数
知识点
1 | Element.addEventListener(event, callback, [useCapture]) |
其中:
event
是要监听的事件名称(例如 “click”)。callback
是当事件被触发时调用的函数。useCapture
是一个可选参数,表示是否在捕获阶段处理事件。如果为true
,则在捕获阶段处理事件;如果为false
,则在冒泡阶段处理事件。
使用箭头函数注意事项:
- 箭头函数没有自己的
this
对象(详见下文)。 - 不可以当作构造函数,也就是说,不可以对箭头函数使用
new
命令,否则会抛出一个错误。 - 不可以使用
arguments
对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。 - 不可以使用
yield
命令,因此箭头函数不能用作 Generator 函数。
上面四点中,最重要的是第一点。对于普通函数来说,内部的this
指向函数运行时所在的对象,但是这一点对箭头函数不成立。它没有自己的this
对象,内部的this
就是定义时上层作用域中的this
。也就是说,箭头函数内部的this
指向是固定的,相比之下,普通函数的this
指向是可变的。
1 | function foo() { |
上面代码中,setTimeout()
的参数是一个箭头函数,这个箭头函数的定义生效是在foo
函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时this
应该指向全局对象window
,这时应该输出21
。但是,箭头函数导致this
总是指向函数定义生效时所在的对象(本例是{id: 42}
),所以打印出来的是42
。
1 | let Person = () => { |
对象的扩展
属性的简洁表示法
ES6 允许在大括号里面,直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。
1 | const foo = 'bar'; |
上面代码中,变量foo
直接写在大括号里面。这时,属性名就是变量名, 属性值就是变量值。下面是另一个例子。
1 | // ES5 |
属性的赋值器(setter)和取值器(getter),事实上也是采用这种写法。
1 | let cart = { |
简洁写法在打印对象时也很有用。
1 | let user = { |
上面代码中,console.log
直接输出user
和foo
两个对象时,就是两组键值对,可能会混淆。把它们放在大括号里面输出,就变成了对象的简洁表示法,每组键值对前面会打印对象名,这样就比较清晰了。
注意,简写的对象方法不能用作构造函数,会报错。
1 | const obj = { |
上面代码中,f
是一个简写的对象方法,所以obj.f
不能当作构造函数使用
属性名表达式
对象的新增方法
Object.is()
ES5 比较两个值是否相等,只有两个运算符:相等运算符(==
)和严格相等运算符(===
)。它们都有缺点,前者会自动转换数据类型,后者的NaN
不等于自身,以及+0
等于-0
。JavaScript 缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。
ES6 提出“Same-value equality”(同值相等)算法,用来解决这个问题。Object.is
就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。
1 | Object.is('foo', 'foo') |
不同之处只有两个:一是+0
不等于-0
,二是NaN
等于自身。
1 | +0 === -0 //true |
ES5 可以通过下面的代码,部署Object.is
。
1 | Object.defineProperty(Object, 'is', { |
Object.assign()
Object.assign()
方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。
1 | const target = { a: 1 }; |
Object.assign()
方法的第一个参数是目标对象,后面的参数都是源对象。
注意,如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。
1 | const target = { a: 1, b: 1 }; |
如果只有一个参数,Object.assign()
会直接返回该参数。
1 | const obj = {a: 1}; |
如果该参数不是对象,则会先转成对象,然后返回。
1 | typeof Object.assign(2) // "object" |
由于undefined
和null
无法转成对象,所以如果它们作为参数,就会报错。
1 | Object.assign(undefined) // 报错 |
如果非对象参数出现在源对象的位置(即非首参数),那么处理规则有所不同。首先,这些参数都会转成对象,如果无法转成对象,就会跳过。这意味着,如果undefined
和null
不在首参数,就不会报错。
1 | let obj = {a: 1}; |
其他类型的值(即数值、字符串和布尔值)不在首参数,也不会报错。但是,除了字符串会以数组形式,拷贝入目标对象,其他值都不会产生效果。
1 | const v1 = 'abc'; |
上面代码中,v1
、v2
、v3
分别是字符串、布尔值和数值,结果只有字符串合入目标对象(以字符数组的形式),数值和布尔值都会被忽略。这是因为只有字符串的包装对象,会产生可枚举属性。
1 | Object(true) // {[[PrimitiveValue]]: true} |
上面代码中,布尔值、数值、字符串分别转成对应的包装对象,可以看到它们的原始值都在包装对象的内部属性[[PrimitiveValue]]
上面,这个属性是不会被Object.assign()
拷贝的。只有字符串的包装对象,会产生可枚举的实义属性,那些属性则会被拷贝。
Object.assign()
拷贝的属性是有限制的,只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性(enumerable: false
)。
1 | Object.assign({b: 'c'}, |
上面代码中,Object.assign()
要拷贝的对象只有一个不可枚举属性invisible
,这个属性并没有被拷贝进去。
属性名为 Symbol 值的属性,也会被Object.assign()
拷贝。
1 | Object.assign({ a: 'b' }, { [Symbol('c')]: 'd' }) |
常见用途
Object.assign()
方法有很多用处。
(1)为对象添加属性
1 | class Point { |
上面方法通过Object.assign()
方法,将x
属性和y
属性添加到Point
类的对象实例。
(2)为对象添加方法
1 | Object.assign(SomeClass.prototype, { |
上面代码使用了对象属性的简洁表示法,直接将两个函数放在大括号中,再使用assign()
方法添加到SomeClass.prototype
之中。
(3)克隆对象
1 | function clone(origin) { |
上面代码将原始对象拷贝到一个空对象,就得到了原始对象的克隆。
不过,采用这种方法克隆,只能克隆原始对象自身的值,不能克隆它继承的值。如果想要保持继承链,可以采用下面的代码。
1 | function clone(origin) { |
(4)合并多个对象
将多个对象合并到某个对象。
1 | const merge = |
如果希望合并后返回一个新对象,可以改写上面函数,对一个空对象合并。
1 | const merge = |
(5)为属性指定默认值
1 | const DEFAULTS = { |
上面代码中,DEFAULTS
对象是默认值,options
对象是用户提供的参数。Object.assign()
方法将DEFAULTS
和options
合并成一个新对象,如果两者有同名属性,则options
的属性值会覆盖DEFAULTS
的属性值。
注意,由于存在浅拷贝的问题,DEFAULTS
对象和options
对象的所有属性的值,最好都是简单类型,不要指向另一个对象。否则,DEFAULTS
对象的该属性很可能不起作用。
1 | const DEFAULTS = { |
上面代码的原意是将url.port
改成 8000,url.host
不变。实际结果却是options.url
覆盖掉DEFAULTS.url
,所以url.host
就不存在了。
数组的扩展
扩展运算符
扩展运算符(spread)是三个点(...
)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。
1 | console.log(...[1, 2, 3]) |
该运算符主要用于函数调用。
1 | function push(array, ...items) { |
上面代码中,array.push(...items)
和add(...numbers)
这两行,都是函数的调用,它们都使用了扩展运算符。该运算符将一个数组,变为参数序列。
扩展运算符与正常的函数参数可以结合使用,非常灵活。
1 | function f(v, w, x, y, z) { } |
扩展运算符后面还可以放置表达式。
1 | const arr = [ |
如果扩展运算符后面是一个空数组,则不产生任何效果。
1 | [...[], 1] |
注意,只有函数调用时,扩展运算符才可以放在圆括号中,否则会报错。
1 | (...[1, 2]) |
上面三种情况,扩展运算符都放在圆括号里面,但是前两种情况会报错,因为扩展运算符所在的括号不是函数调用。
扩展运算符的应用
(1)复制数组
数组是复合的数据类型,直接复制的话,只是复制了指向底层数据结构的指针,而不是克隆一个全新的数组。
1 | const a1 = [1, 2]; |
上面代码中,a2
并不是a1
的克隆,而是指向同一份数据的另一个指针。修改a2
,会直接导致a1
的变化。
ES5 只能用变通方法来复制数组。
1 | const a1 = [1, 2]; |
上面代码中,a1
会返回原数组的克隆,再修改a2
就不会对a1
产生影响。
扩展运算符提供了复制数组的简便写法。
1 | const a1 = [1, 2]; |
上面的两种写法,a2
都是a1
的克隆。
(2)合并数组
扩展运算符提供了数组合并的新写法。
1 | const arr1 = ['a', 'b']; |
不过,这两种方法都是浅拷贝,使用的时候需要注意。
1 | const a1 = [{ foo: 1 }]; |
上面代码中,a3
和a4
是用两种不同方法合并而成的新数组,但是它们的成员都是对原数组成员的引用,这就是浅拷贝。如果修改了引用指向的值,会同步反映到新数组。
(3)与解构赋值结合
扩展运算符可以与解构赋值结合起来,用于生成数组。
1 | // ES5 |
下面是另外一些例子。
1 | const [first, ...rest] = [1, 2, 3, 4, 5]; |
如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。
1 | const [...butLast, last] = [1, 2, 3, 4, 5]; |
(4)字符串
扩展运算符还可以将字符串转为真正的数组。
1 | [...'hello'] |
上面的写法,有一个重要的好处,那就是能够正确识别四个字节的 Unicode 字符。
1 | 'x\uD83D\uDE80y'.length // 4 |
上面代码的第一种写法,JavaScript 会将四个字节的 Unicode 字符,识别为 2 个字符,采用扩展运算符就没有这个问题。因此,正确返回字符串长度的函数,可以像下面这样写。
1 | function length(str) { |
凡是涉及到操作四个字节的 Unicode 字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
1 | let str = 'x\uD83D\uDE80y'; |
上面代码中,如果不用扩展运算符,字符串的reverse()
操作就不正确。
(5)实现了 Iterator 接口的对象
任何定义了遍历器(Iterator)接口的对象(参阅 Iterator 一章),都可以用扩展运算符转为真正的数组。
1 | let nodeList = document.querySelectorAll('div'); |
上面代码中,querySelectorAll()
方法返回的是一个NodeList
对象。它不是数组,而是一个类似数组的对象。这时,扩展运算符可以将其转为真正的数组,原因就在于NodeList
对象实现了 Iterator。
1 | Number.prototype[Symbol.iterator] = function*() { |
上面代码中,先定义了Number
对象的遍历器接口,扩展运算符将5
自动转成Number
实例以后,就会调用这个接口,就会返回自定义的结果。
对于那些没有部署 Iterator 接口的类似数组的对象,扩展运算符就无法将其转为真正的数组。
1 | let arrayLike = { |
上面代码中,arrayLike
是一个类似数组的对象,但是没有部署 Iterator 接口,扩展运算符就会报错。这时,可以改为使用Array.from
方法将arrayLike
转为真正的数组。
(6)Map 和 Set 结构,Generator 函数
扩展运算符内部调用的是数据结构的 Iterator 接口,因此只要具有 Iterator 接口的对象,都可以使用扩展运算符,比如 Map 结构。
1 | let map = new Map([ |
Generator 函数运行后,返回一个遍历器对象,因此也可以使用扩展运算符。
1 | const go = function*(){ |
上面代码中,变量go
是一个 Generator 函数,执行后返回的是一个遍历器对象,对这个遍历器对象执行扩展运算符,就会将内部遍历得到的值,转为一个数组。
如果对没有 Iterator 接口的对象,使用扩展运算符,将会报错。
1 | const obj = {a: 1, b: 2}; |
Array.from()
Array.from()
方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。
下面是一个类似数组的对象,Array.from()
将它转为真正的数组。
1 | let arrayLike = { |
实际应用中,常见的类似数组的对象是 DOM 操作返回的 NodeList 集合,以及函数内部的arguments
对象。Array.from()
都可以将它们转为真正的数组。
1 | // NodeList 对象 |
上面代码中,querySelectorAll()
方法返回的是一个类似数组的对象,可以将这个对象转为真正的数组,再使用filter()
方法。
只要是部署了 Iterator 接口的数据结构,Array.from()
都能将其转为数组。
值得提醒的是,扩展运算符(...
)也可以将某些数据结构转为数组。
1 | // arguments对象 |
扩展运算符背后调用的是遍历器接口(Symbol.iterator
),如果一个对象没有部署这个接口,就无法转换。Array.from()
方法还支持类似数组的对象。所谓类似数组的对象,本质特征只有一点,即必须有length
属性。因此,任何有length
属性的对象,都可以通过Array.from()
方法转为数组,而此时扩展运算符就无法转换。
1 | Array.from({ length: 3 }); |
上面代码中,Array.from()
返回了一个具有三个成员的数组,每个位置的值都是undefined
。扩展运算符转换不了这个对象。
Array.of()
Array.of()
方法用于将一组值,转换为数组。
1 | Array.of(3, 11, 8) // [3,11,8] |
这个方法的主要目的,是弥补数组构造函数Array()
的不足。因为参数个数的不同,会导致Array()
的行为有差异。
1 | Array() // [] |
上面代码中,Array()
方法没有参数、一个参数、三个参数时,返回的结果都不一样。只有当参数个数不少于 2 个时,Array()
才会返回由参数组成的新数组。参数只有一个正整数时,实际上是指定数组的长度。
Array.of()
基本上可以用来替代Array()
或new Array()
,并且不存在由于参数不同而导致的重载。它的行为非常统一。
1 | Array.of() // [] |
Array.of()
总是返回参数值组成的数组。如果没有参数,就返回一个空数组。
Array.of()
方法可以用下面的代码模拟实现。
1 | function ArrayOf(){ |
copyWithin()
数组实例的copyWithin()
方法,在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法,会修改当前数组。
1 | Array.prototype.copyWithin(target, start = 0, end = this.length) |
它接受三个参数。
- target(必需):从该位置开始替换数据。如果为负值,表示倒数。
- start(可选):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
- end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示从末尾开始计算。
这三个参数都应该是数值,如果不是,会自动转为数值。
1 | [1, 2, 3, 4, 5].copyWithin(0, 3) |
上面代码表示将从 3 号位直到数组结束的成员(4 和 5),复制到从 0 号位开始的位置,结果覆盖了原来的 1 和 2。
find()
数组实例的find()
方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true
的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined
。
1 | let num = [1,4,-5,10,-11].find((n) => { |
上面代码找出数组中第一个小于 0 的成员。
1 | [1, 5, 10, 15].find(function(value, index, arr) { |
上面代码中,find()
方法的回调函数可以接受三个参数,依次为当前的值、当前的位置和原数组。
findIndex()
数组实例的findIndex()
方法的用法与find()
方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1
。
1 | let num = [1,4,-5,10,-11].findIndex(n => n < 0) |
这两个方法都可以接受第二个参数,用来绑定回调函数的this
对象。
1 | function f(v){ |
上面的代码中,find()
函数接收了第二个参数person
对象,回调函数中的this
对象指向person
对象。
entries()、keys()和values()
ES6 提供三个新的方法——entries()
,keys()
和values()
——用于遍历数组。它们都返回一个遍历器对象,可以用for...of
循环进行遍历,唯一的区别是keys()
是对键名的遍历、values()
是对键值的遍历,entries()
是对键值对的遍历。
1 | for (let index of ['a', 'b'].keys()) { |
如果不使用for...of
循环,可以手动调用遍历器对象的next
方法,进行遍历。
1 | let letter = ['a', 'b', 'c']; |
includes()
Array.prototype.includes
方法返回一个布尔值,表示某个数组是否包含给定的值,与字符串的includes
方法类似。ES2016 引入了该方法。
1 | [1, 2, 3].includes(2) // true |
该方法的第二个参数表示搜索的起始位置,默认为0
。如果第二个参数为负数,则表示倒数的位置,如果这时它大于数组长度(比如第二个参数为-4
,但数组长度为3
),则会重置为从0
开始。
1 | [1, 2, 3].includes(3, 3); // false |
没有该方法之前,我们通常使用数组的indexOf
方法,检查是否包含某个值。
1 | [1,2,3].indexOf(2); // 1 |
indexOf
方法有两个缺点,一是不够语义化,它的含义是找到参数值的第一个出现位置,所以要去比较是否不等于-1
,表达起来不够直观。二是,它内部使用严格相等运算符(===
)进行判断,这会导致对NaN
的误判。
Symbol
ES5 的对象属性名都是字符串,这容易造成属性名的冲突。比如,你使用了一个他人提供的对象,但又想为这个对象添加新的方法(mixin 模式),新方法的名字就有可能与现有方法产生冲突。如果有一种机制,保证每个属性的名字都是独一无二的就好了,这样就从根本上防止属性名的冲突。这就是 ES6 引入Symbol
的原因。
ES6 引入了一种新的原始数据类型Symbol
,表示独一无二的值。它属于 JavaScript 语言的原生数据类型之一,其他数据类型是:undefined
、null
、布尔值(Boolean)、字符串(String)、数值(Number)、大整数(BigInt)、对象(Object)。
Symbol 值通过Symbol()
函数生成。这就是说,对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的 Symbol 类型。凡是属性名属于 Symbol 类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。
1 | let s = Symbol(); |
上面代码中,变量s
就是一个独一无二的值。typeof
运算符的结果,表明变量s
是 Symbol 数据类型,而不是字符串之类的其他类型。
注意,Symbol()
函数前不能使用new
命令,否则会报错。这是因为生成的 Symbol 是一个原始类型的值,不是对象,所以不能使用new
命令来调用。另外,由于 Symbol 值不是对象,所以也不能添加属性。基本上,它是一种类似于字符串的数据类型。
Symbol()
函数可以接受一个字符串作为参数,表示对 Symbol 实例的描述。这主要是为了在控制台显示,或者转为字符串时,比较容易区分。
1 | let s1 = Symbol('foo'); |
上面代码中,s1
和s2
是两个 Symbol 值。如果不加参数,它们在控制台的输出都是Symbol()
,不利于区分。有了参数以后,就等于为它们加上了描述,输出的时候就能够分清,到底是哪一个值。
如果 Symbol 的参数是一个对象,就会调用该对象的toString()
方法,将其转为字符串,然后才生成一个 Symbol 值。
1 | const obj = { |
注意,Symbol()
函数的参数只是表示对当前 Symbol 值的描述,因此相同参数的Symbol
函数的返回值是不相等的。
1 | // 没有参数的情况 |
上面代码中,s1
和s2
都是Symbol()
函数的返回值,而且参数相同,但是它们是不相等的。事实上,如果调用100次Symbol()
,会得到100个互不相等的值。
Symbol 值不能与其他类型的值进行运算,会报错。
1 | let sym = Symbol('My symbol'); |
但是,Symbol 值可以显式转为字符串。
1 | let sym = Symbol('My symbol'); |
另外,Symbol 值也可以转为布尔值,但是不能转为数值。
1 | let sym = Symbol(); |
Set 集合数据类型
ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。
Set
本身是一个构造函数,用来生成 Set 数据结构。
Set
结构不会添加重复的值。
Set
函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。
1 | // 例一 |
上面代码中,例一和例二都是Set
函数接受数组作为参数,例三是接受类似数组的对象作为参数。
上面代码也展示了一种去除数组重复成员的方法。
1 | // 去除数组的重复成员 |
上面的方法也可以用于,去除字符串里面的重复字符。
1 | [...new Set('ababbc')].join('') |
向 Set 加入值的时候,不会发生类型转换,所以5
和"5"
是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符(===
),主要的区别是向 Set 加入值时认为NaN
等于自身,而精确相等运算符认为NaN
不等于自身。
1 | let set = new Set(); |
上面代码向 Set 实例添加了两次NaN
,但是只会加入一个。这表明,在 Set 内部,两个NaN
是相等的。
另外,两个对象总是不相等的。
1 | let set = new Set(); |
上面代码表示,由于两个空对象不相等,所以它们被视为两个值。
Set 实例的属性和方法
Set 结构的实例有以下属性。
Set.prototype.constructor
:构造函数,默认就是Set
函数。Set.prototype.size
:返回Set
实例的成员总数。
Set 实例的方法分为两大类:操作方法(用于操作数据)和遍历方法(用于遍历成员)。下面先介绍四个操作方法。
Set.prototype.add(value)
:添加某个值,返回 Set 结构本身。Set.prototype.delete(value)
:删除某个值,返回一个布尔值,表示删除是否成功。Set.prototype.has(value)
:返回一个布尔值,表示该值是否为Set
的成员。Set.prototype.clear()
:清除所有成员,没有返回值。
上面这些属性和方法的实例如下。
1 | s.add(1).add(2).add(2); |
下面是一个对比,判断是否包括一个键,Object
结构和Set
结构写法的不同。
1 | // 对象的写法 |
Array.from()
方法可以将 Set 结构转为数组。
1 | const items = new Set([1, 2, 3, 4, 5]); |
这就提供了去除数组重复成员的另一种方法。
1 | function dedupe(array) { |
遍历操作
forEach()
Set 结构的实例与数组一样,也拥有forEach
方法,用于对每个成员执行某种操作,没有返回值。
1 | let set = new Set([1, 4, 9]); |
上面代码说明,forEach
方法的参数就是一个处理函数。该函数的参数与数组的forEach
一致,依次为键值、键名、集合本身(上例省略了该参数)。这里需要注意,Set 结构的键名就是键值(两者是同一个值),因此第一个参数与第二个参数的值永远都是一样的。
另外,forEach
方法还可以有第二个参数,表示绑定处理函数内部的this
对象。
遍历的应用
扩展运算符(...
)内部使用for...of
循环,所以也可以用于 Set 结构。
1 | let set = new Set(['red', 'green', 'blue']); |
扩展运算符和 Set 结构相结合,就可以去除数组的重复成员。
1 | let arr = [3, 5, 2, 2, 5, 5]; |
而且,数组的map
和filter
方法也可以间接用于 Set 了。
1 | let set = new Set([1, 2, 3]); |
因此使用 Set 可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。
1 | let a = new Set([1, 2, 3]); |
如果想在遍历操作中,同步改变原来的 Set 结构,目前没有直接的方法,但有两种变通方法。一种是利用原 Set 结构映射出一个新的结构,然后赋值给原来的 Set 结构;另一种是利用Array.from
方法。
1 | // 方法一 |
上面代码提供了两种方法,直接在遍历操作中改变原来的 Set 结构。
Map数据类型
JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。
1 | const data = {}; |
上面代码原意是将一个 DOM 节点作为对象data
的键,但是由于对象只接受字符串作为键名,所以element
被自动转为字符串[object HTMLDivElement]
。
为了解决这个问题,ES6 提供了 Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果你需要“键值对”的数据结构,Map 比 Object 更合适。
1 | const m = new Map(); |
上面代码使用 Map 结构的set
方法,将对象o
当作m
的一个键,然后又使用get
方法读取这个键,接着使用delete
方法删除了这个键,clear
直接清空m
。
面的例子展示了如何向 Map 添加成员。作为构造函数,Map 也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。
1 | const map = new Map([ |
上面代码在新建 Map 实例时,就指定了两个键name
和title
。
Map
构造函数接受数组作为参数,实际上执行的是下面的算法。
1 | const items = [ |
迭代器Iterator 和 for…of 循环
Iterator
JavaScript 原有的表示“集合”的数据结构,主要是数组(Array
)和对象(Object
),ES6 又添加了Map
和Set
。这样就有了四种数据集合,用户还可以组合使用它们,定义自己的数据结构,比如数组的成员是Map
,Map
的成员是对象。这样就需要一种统一的接口机制,来处理所有不同的数据结构。
遍历器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。
Iterator 的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是 ES6 创造了一种新的遍历命令for...of
循环,Iterator 接口主要供for...of
消费。
Iterator 的遍历过程是这样的。
(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。
(2)第一次调用指针对象的next
方法,可以将指针指向数据结构的第一个成员。
(3)第二次调用指针对象的next
方法,指针就指向数据结构的第二个成员。
(4)不断调用指针对象的next
方法,直到它指向数据结构的结束位置。
每一次调用next
方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value
和done
两个属性的对象。其中,value
属性是当前成员的值,done
属性是一个布尔值,表示遍历是否结束。
下面是一个模拟next
方法返回值的例子。
1 | const items = ['one','two','three'] |
上面代码中,变量items
是一个数组,原生就具有遍历器接口,部署在items
的Symbol.iterator
属性上面。所以,调用这个属性,就得到遍历器对象。
ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator
属性,或者说,一个数据结构只要具有Symbol.iterator
属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator
属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名Symbol.iterator
,它是一个表达式,返回Symbol
对象的iterator
属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内。
原生具备 Iterator 接口的数据结构如下。
- Array
- Map
- Set
- String
- TypedArray
- 函数的 arguments 对象
- NodeList 对象
一个对象如果要具备可被for...of
循环调用的 Iterator 接口,就必须在Symbol.iterator
的属性上部署遍历器生成方法(原型链上的对象具有该方法也可)。
1 | class RangeIterator { |
上面代码是一个类部署 Iterator 接口的写法。Symbol.iterator
属性对应一个函数,执行后返回当前对象的遍历器对象。
for…of
ES6 借鉴 C++、Java、C# 和 Python 语言,引入了for...of
循环,作为遍历所有数据结构的统一的方法。
一个数据结构只要部署了Symbol.iterator
属性,就被视为具有 iterator 接口,就可以用for...of
循环遍历它的成员。也就是说,for...of
循环内部调用的是数据结构的Symbol.iterator
方法。
for...of
循环可以使用的范围包括数组、Set 和 Map 结构、某些类似数组的对象(比如arguments
对象、DOM NodeList 对象)、后文的 Generator 对象,以及字符串。
数组
数组原生具备iterator
接口(即默认部署了Symbol.iterator
属性),for...of
循环本质上就是调用这个接口产生的遍历器,可以用下面的代码证明。
1 | const arr = ['red', 'green', 'blue']; |
上面代码中,空对象obj
部署了数组arr
的Symbol.iterator
属性,结果obj
的for...of
循环,产生了与arr
完全一样的结果。
for...of
循环可以代替数组实例的forEach
方法。
1 | const arr = ['red', 'green', 'blue']; |
JavaScript 原有的for...in
循环,只能获得对象的键名,不能直接获取键值。ES6 提供for...of
循环,允许遍历获得键值。
1 | var arr = ['a', 'b', 'c', 'd']; |
上面代码表明,for...in
循环读取键名,for...of
循环读取键值。如果要通过for...of
循环,获取数组的索引,可以借助数组实例的entries
方法和keys
方法(参见《数组的扩展》一章)。
for...of
循环调用遍历器接口,数组的遍历器接口只返回具有数字索引的属性。这一点跟for...in
循环也不一样。
1 | let arr = [3, 5, 7]; |
上面代码中,for...of
循环不会返回数组arr
的foo
属性。
Set 和 Map 结构
Set 和 Map 结构也原生具有 Iterator 接口,可以直接使用for...of
循环。
1 | var engines = new Set(["Gecko", "Trident", "Webkit", "Webkit"]); |
上面代码演示了如何遍历 Set 结构和 Map 结构。值得注意的地方有两个,首先,遍历的顺序是按照各个成员被添加进数据结构的顺序。其次,Set 结构遍历时,返回的是一个值,而 Map 结构遍历时,返回的是一个数组,该数组的两个成员分别为当前 Map 成员的键名和键值。
1 | let map = new Map().set('a', 1).set('b', 2); |
对象
对于普通的对象,for...of
结构不能直接使用,会报错,必须部署了 Iterator 接口后才能使用。但是,这样情况下,for...in
循环依然可以用来遍历键名。
1 | let es6 = { |
上面代码表示,对于普通的对象,for...in
循环可以遍历键名,for...of
循环会报错。
一种解决方法是,使用Object.keys
方法将对象的键名生成一个数组,然后遍历这个数组。
1 | for (var key of Object.keys(someObject)) { |
另一个方法是使用 Generator 函数将对象重新包装一下。
1 | const obj = { a: 1, b: 2, c: 3 } |
生成器Generator
Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。
Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。
执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。
形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function
关键字与函数名之间有一个星号;二是,函数体内部使用yield
表达式,定义不同的内部状态(yield
在英语里的意思就是“产出”)。
1 | function* helloWorldGenerator() { |
上面代码定义了一个 Generator 函数helloWorldGenerator
,它内部有两个yield
表达式(hello
和world
),即该函数有三个状态:hello,world 和 return 语句(结束执行)。
然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是上一章介绍的遍历器对象(Iterator Object)。
下一步,必须调用遍历器对象的next
方法,使得指针移向下一个状态。也就是说,每次调用next
方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield
表达式(或return
语句)为止。换言之,Generator 函数是分段执行的,yield
表达式是暂停执行的标记,而next
方法可以恢复执行。
1 | hw.next() |
上面代码一共调用了四次next
方法。
第一次调用,Generator 函数开始执行,直到遇到第一个yield
表达式为止。next
方法返回一个对象,它的value
属性就是当前yield
表达式的值hello
,done
属性的值false
,表示遍历还没有结束。
第二次调用,Generator 函数从上次yield
表达式停下的地方,一直执行到下一个yield
表达式。next
方法返回的对象的value
属性就是当前yield
表达式的值world
,done
属性的值false
,表示遍历还没有结束。
第三次调用,Generator 函数从上次yield
表达式停下的地方,一直执行到return
语句(如果没有return
语句,就执行到函数结束)。next
方法返回的对象的value
属性,就是紧跟在return
语句后面的表达式的值(如果没有return
语句,则value
属性的值为undefined
),done
属性的值true
,表示遍历已经结束。
第四次调用,此时 Generator 函数已经运行完毕,next
方法返回对象的value
属性为undefined
,done
属性为true
。以后再调用next
方法,返回的都是这个值。
总结一下,调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的next
方法,就会返回一个有着value
和done
两个属性的对象。value
属性表示当前的内部状态的值,是yield
表达式后面那个表达式的值;done
属性是一个布尔值,表示是否遍历结束。
ES6 没有规定,function
关键字与函数名之间的星号,写在哪个位置。这导致下面的写法都能通过。
1 | function * foo(x, y) { ··· } |
由于 Generator 函数仍然是普通函数,所以一般的写法是上面的第三种,即星号紧跟在function
关键字后面。本书也采用这种写法。
yield 表达式
由于 Generator 函数返回的遍历器对象,只有调用next
方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield
表达式就是暂停标志。
遍历器对象的next
方法的运行逻辑如下。
(1)遇到yield
表达式,就暂停执行后面的操作,并将紧跟在yield
后面的那个表达式的值,作为返回的对象的value
属性值。
(2)下一次调用next
方法时,再继续往下执行,直到遇到下一个yield
表达式。
(3)如果没有再遇到新的yield
表达式,就一直运行到函数结束,直到return
语句为止,并将return
语句后面的表达式的值,作为返回的对象的value
属性值。
(4)如果该函数没有return
语句,则返回的对象的value
属性值为undefined
。
需要注意的是,yield
表达式后面的表达式,只有当调用next
方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。
1 | function* gen() { |
上面代码中,yield
后面的表达式123 + 456
,不会立即求值,只会在next
方法将指针移到这一句时,才会求值。
yield
表达式与return
语句既有相似之处,也有区别。相似之处在于,都能返回紧跟在语句后面的那个表达式的值。区别在于每次遇到yield
,函数暂停执行,下一次再从该位置继续向后执行,而return
语句不具备位置记忆的功能。一个函数里面,只能执行一次(或者说一个)return
语句,但是可以执行多次(或者说多个)yield
表达式。正常函数只能返回一个值,因为只能执行一次return
;Generator 函数可以返回一系列的值,因为可以有任意多个yield
。从另一个角度看,也可以说 Generator 生成了一系列的值,这也就是它的名称的来历(英语中,generator 这个词是“生成器”的意思)。
Generator 函数可以不用yield
表达式,这时就变成了一个单纯的暂缓执行函数。
1 | function* f() { |
上面代码中,函数f
如果是普通函数,在为变量generator
赋值时就会执行。但是,函数f
是一个 Generator 函数,就变成只有调用next
方法时,函数f
才会执行。
另外需要注意,yield
表达式只能用在 Generator 函数里面,用在其他地方都会报错。
1 | (function (){ |
上面代码在一个普通函数中使用yield
表达式,结果产生一个句法错误。
另外,yield
表达式如果用在另一个表达式之中,必须放在圆括号里面。
1 | function* demo() { |
yield
表达式用作函数参数或放在赋值表达式的右边,可以不加括号。
1 | function* demo() { |
应用
Generator 可以暂停函数执行,返回任意表达式的值。这种特点使得 Generator 有多种应用场景。
异步操作的同步化表达
Generator 函数的暂停执行的效果,意味着可以把异步操作写在yield
表达式里面,等到调用next
方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在yield
表达式下面,反正要等到调用next
方法时再执行。所以,Generator 函数的一个重要实际意义就是用来处理异步操作,改写回调函数。
1 | function* loadUI() { |
上面代码中,第一次调用loadUI
函数时,该函数不会执行,仅返回一个遍历器。下一次对该遍历器调用next
方法,则会显示Loading
界面(showLoadingScreen
),并且异步加载数据(loadUIDataAsynchronously
)。等到数据加载完成,再一次使用next
方法,则会隐藏Loading
界面。可以看到,这种写法的好处是所有Loading
界面的逻辑,都被封装在一个函数,按部就班非常清晰。
Ajax 是典型的异步操作,通过 Generator 函数部署 Ajax 操作,可以用同步的方式表达。
1 | function* main() { |
上面代码的main
函数,就是通过 Ajax 操作获取数据。可以看到,除了多了一个yield
,它几乎与同步操作的写法完全一样。注意,makeAjaxCall
函数中的next
方法,必须加上response
参数,因为yield
表达式,本身是没有值的,总是等于undefined
。
下面是另一个例子,通过 Generator 函数逐行读取文本文件。
1 | function* numbers() { |
上面代码打开文本文件,使用yield
表达式可以手动逐行读取文件。
控制流管理
如果有一个多步操作非常耗时,采用回调函数,可能会写成下面这样。
1 | step1(function (value1) { |
采用 Promise 改写上面的代码。
1 | Promise.resolve(step1) |
上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量 Promise 的语法。Generator 函数可以进一步改善代码运行流程。
1 | function* longRunningTask(value1) { |
然后,使用一个函数,按次序自动执行所有步骤。
1 | scheduler(longRunningTask(initialValue)); |
注意,上面这种做法,只适合同步操作,即所有的task
都必须是同步的,不能有异步操作。因为这里的代码一得到返回值,就继续往下执行,没有判断异步操作何时完成。如果要控制异步的操作流程,详见后面的《异步操作》一章。
下面,利用for...of
循环会自动依次执行yield
命令的特性,提供一种更一般的控制流管理的方法。
1 | let steps = [step1Func, step2Func, step3Func]; |
上面代码中,数组steps
封装了一个任务的多个步骤,Generator 函数iterateSteps
则是依次为这些步骤加上yield
命令。
将任务分解成步骤之后,还可以将项目分解成多个依次执行的任务。
1 | let jobs = [job1, job2, job3]; |
上面代码中,数组jobs
封装了一个项目的多个任务,Generator 函数iterateJobs
则是依次为这些任务加上yield*
命令。
最后,就可以用for...of
循环一次性依次执行所有任务的所有步骤。
1 | for (var step of iterateJobs(jobs)){ |
再次提醒,上面的做法只能用于所有步骤都是同步操作的情况,不能有异步操作的步骤。如果想要依次执行异步的步骤,必须使用后面的《异步操作》一章介绍的方法。
for...of
的本质是一个while
循环,所以上面的代码实质上执行的是下面的逻辑。
1 | var it = iterateJobs(jobs); |
为不具备 Iterator 接口提供遍历操作
1 | function* objectEntries(obj){ |
利用 Generator 函数,可以在任意对象上部署 Iterator 接口。
1 | // 定义一个名为 iterEntries 的生成器函数,它接受一个对象作为参数 |
上述代码中,myObj
是一个普通对象,通过iterEntries
函数,就有了 Iterator 接口。也就是说,可以在任意对象上部署next
方法。
下面是一个对数组部署 Iterator 接口的例子,尽管数组原生具有这个接口。
1 | // 定义一个名为 makeSimpleGenerator 的生成器函数,它接受一个数组作为参数 |
Promise对象
Promise 的含义
Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise
对象。
所谓Promise
,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。
Promise
对象有以下两个特点。
(1)对象的状态不受外界影响。Promise
对象代表一个异步操作,有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise
这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise
对象的状态改变,只有两种可能:从pending
变为fulfilled
和从pending
变为rejected
。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对Promise
对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
注意,为了行文方便,本章后面的resolved
统一只指fulfilled
状态,不包含rejected
状态。
有了Promise
对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise
对象提供统一的接口,使得控制异步操作更加容易。
Promise
也有一些缺点。首先,无法取消Promise
,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise
内部抛出的错误,不会反应到外部。第三,当处于pending
状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
如果某些事件不断地反复发生,一般来说,使用 Stream 模式是比部署Promise
更好的选择。
基本用法
ES6 规定,Promise
对象是一个构造函数,用来生成Promise
实例。
下面代码创造了一个Promise
实例。
1 | const promise = new Promise(function(resolve, reject) { |
Promise
构造函数接受一个函数作为参数,该函数的两个参数分别是resolve
和reject
。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。
resolve
函数的作用是,将Promise
对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject
函数的作用是,将Promise
对象的状态从“未完成”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
Promise
实例生成以后,可以用then
方法分别指定resolved
状态和rejected
状态的回调函数。
1 | promise.then(function(value) { |
then
方法可以接受两个回调函数作为参数。第一个回调函数是Promise
对象的状态变为resolved
时调用,第二个回调函数是Promise
对象的状态变为rejected
时调用。这两个函数都是可选的,不一定要提供。它们都接受Promise
对象传出的值作为参数。
下面是一个Promise
对象的简单例子。
1 | const pro = new Promise(function(resolve,reject){ |
上面代码不能传参
1 | function timeOut(ms){ |
上面代码中,timeOut
方法返回一个Promise
实例,表示一段时间以后才会发生的结果。过了指定的时间(ms
参数)以后,Promise
实例的状态变为resolved
,就会触发then
方法绑定的回调函数。
Promise 新建后就会立即执行。
1 | let promise = new Promise(function(resolve, reject) { |
上面代码中,Promise 新建后立即执行,所以首先输出的是Promise
。然后,then
方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行,所以resolved
最后输出。
下面是异步加载图片的例子。
1 | function loadImageAsync(url) { |
上面代码中,使用Promise
包装了一个图片加载的异步操作。如果加载成功,就调用resolve
方法,否则就调用reject
方法。
下面是一个用Promise
对象实现的 Ajax 操作的例子。
1 | getJSON = function(url){ |
上面代码中,getJSON
是对 XMLHttpRequest
对象的封装,用于发出一个针对 JSON
数据的 HTTP 请求,并且返回一个Promise
对象。需要注意的是,在getJSON
内部,resolve
函数和reject
函数调用时,都带有参数。
Promise.prototype.then()
Promise 实例具有then
方法,也就是说,then
方法是定义在原型对象Promise.prototype
上的。它的作用是为 Promise 实例添加状态改变时的回调函数。前面说过,then
方法的第一个参数是resolved
状态的回调函数,第二个参数是rejected
状态的回调函数,它们都是可选的。
then
方法返回的是一个新的Promise
实例(注意,不是原来那个Promise
实例)。因此可以采用链式写法,即then
方法后面再调用另一个then
方法。
1 | getJSON("/posts.json").then(function(json) { |
上面的代码使用then
方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。
采用链式的then
,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise
对象(即有异步操作),这时后一个回调函数,就会等待该Promise
对象的状态发生变化,才会被调用。
1 | getJSON("/post/1.json").then(function(post) { |
上面代码中,第一个then
方法指定的回调函数,返回的是另一个Promise
对象。这时,第二个then
方法指定的回调函数,就会等待这个新的Promise
对象状态发生变化。如果变为resolved
,就调用第一个回调函数,如果状态变为rejected
,就调用第二个回调函数。
如果采用箭头函数,上面的代码可以写得更简洁。
1 | getJSON("/post/1.json").then( |
Promise.prototype.catch()
Promise.prototype.catch()
方法是.then(null, rejection)
或.then(undefined, rejection)
的别名,用于指定发生错误时的回调函数。
1 | getJSON('/posts.json').then(function(posts) { |
上面代码中,getJSON()
方法返回一个 Promise 对象,如果该对象状态变为resolved
,则会调用then()
方法指定的回调函数;如果异步操作抛出错误,状态就会变为rejected
,就会调用catch()
方法指定的回调函数,处理这个错误。另外,then()
方法指定的回调函数,如果运行中抛出错误,也会被catch()
方法捕获。
1 | p.then((val) => console.log('fulfilled:', val)) |
下面是一个例子。
1 | const promise = new Promise(function(resolve, reject) { |
上面代码中,promise
抛出一个错误,就被catch()
方法指定的回调函数捕获。注意,上面的写法与下面两种写法是等价的。
1 | // 写法一 |
比较上面两种写法,可以发现reject()
方法的作用,等同于抛出错误。
如果 Promise 状态已经变成resolved
,再抛出错误是无效的。
1 | const promise = new Promise(function(resolve, reject) { |
上面代码中,Promise 在resolve
语句后面,再抛出错误,不会被捕获,等于没有抛出。因为 Promise 的状态一旦改变,就永久保持该状态,不会再变了。
Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch
语句捕获。
1 | getJSON('/post/1.json').then(function(post) { |
上面代码中,一共有三个 Promise 对象:一个由getJSON()
产生,两个由then()
产生。它们之中任何一个抛出的错误,都会被最后一个catch()
捕获。
一般来说,不要在then()
方法里面定义 Reject 状态的回调函数(即then
的第二个参数),总是使用catch
方法。
1 | // bad |
上面代码中,第二种写法要好于第一种写法,理由是第二种写法可以捕获前面then
方法执行中的错误,也更接近同步的写法(try/catch
)。因此,建议总是使用catch()
方法,而不使用then()
方法的第二个参数。
Promise.all()
Promise.all()
方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。
1 | const p = Promise.all([p1, p2, p3]); |
上面代码中,Promise.all()
方法接受一个数组作为参数,p1
、p2
、p3
都是 Promise 实例,如果不是,就会先调用下面讲到的Promise.resolve
方法,将参数转为 Promise 实例,再进一步处理。另外,Promise.all()
方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例。
p
的状态由p1
、p2
、p3
决定,分成两种情况。
(1)只有p1
、p2
、p3
的状态都变成fulfilled
,p
的状态才会变成fulfilled
,此时p1
、p2
、p3
的返回值组成一个数组,传递给p
的回调函数。
(2)只要p1
、p2
、p3
之中有一个被rejected
,p
的状态就变成rejected
,此时第一个被reject
的实例的返回值,会传递给p
的回调函数。
下面是一个具体的例子。
1 | // 生成一个Promise对象的数组 |
上面代码中,promises
是包含 6 个 Promise 实例的数组,只有这 6 个实例的状态都变成fulfilled
,或者其中有一个变为rejected
,才会调用Promise.all
方法后面的回调函数。
下面是另一个例子。
1 | // 比如 加载静态资源 |
Promise.race()
Promise.race()
方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。
1 | const p = Promise.race([p1, p2, p3]); |
上面代码中,只要p1
、p2
、p3
之中有一个实例率先改变状态,p
的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p
的回调函数。
Promise.race()
方法的参数与Promise.all()
方法一样,如果不是 Promise 实例,就会先调用下面讲到的Promise.resolve()
方法,将参数转为 Promise 实例,再进一步处理。
下面是一个例子,如果指定时间内没有获得结果,就将 Promise 的状态变为reject
,否则变为resolve
。
1 | const p = Promise.race([ |
上面代码中,如果 5 秒之内fetch
方法无法返回结果,变量p
的状态就会变为rejected
,从而触发catch
方法指定的回调函数。
下面是一个代码示例:
1 | // race() 某个异步请求设置超时时间,并在超时后执行相应操作 |
Promise.prototype.finally()
finally()
方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。该方法是 ES2018 引入标准的。
1 | promise |
上面代码中,不管promise
最后的状态,在执行完then
或catch
指定的回调函数以后,都会执行finally
方法指定的回调函数。
下面是一个例子,服务器使用 Promise 处理请求,然后使用finally
方法关掉服务器。
1 | // 示例:不管请求结果如何,请求完成都关闭服务器监听 |
finally
方法的回调函数不接受任何参数,这意味着没有办法知道,前面的 Promise 状态到底是fulfilled
还是rejected
。这表明,finally
方法里面的操作,应该是与状态无关的,不依赖于 Promise 的执行结果。
async函数
async 函数是什么?一句话,它就是 Generator 函数的语法糖。
一比较就会发现,async
函数就是将 Generator 函数的星号(*
)替换成async
,将yield
替换成await
,仅此而已。
async
函数对 Generator 函数的改进,体现在以下四点。
(1)内置执行器。
Generator 函数的执行必须靠执行器,所以才有了co
模块,而async
函数自带执行器。也就是说,async
函数的执行,与普通函数一模一样,只要一行。
1 | asyncReadFile(); |
上面的代码调用了asyncReadFile
函数,然后它就会自动执行,输出最后结果。这完全不像 Generator 函数,需要调用next
方法,或者用co
模块,才能真正执行,得到最后结果。
(2)更好的语义。
async
和await
,比起星号和yield
,语义更清楚了。async
表示函数里有异步操作,await
表示紧跟在后面的表达式需要等待结果。
(3)更广的适用性。
co
模块约定,yield
命令后面只能是 Thunk 函数或 Promise 对象,而async
函数的await
命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
(4)返回值是 Promise。
async
函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then
方法指定下一步的操作。
进一步说,async
函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await
命令就是内部then
命令的语法糖。
基本用法
async
函数返回一个 Promise 对象,可以使用then
方法添加回调函数。当函数执行的时候,一旦遇到await
就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。
下面是一个例子。
1 | async function f() { |
函数前面的async
关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个Promise
对象。
下面是另一个例子,指定多少毫秒后输出一个值。
1 | function timeout(ms) { |
上面代码指定 50 毫秒以后,输出hello world
。
由于async
函数返回的是 Promise 对象,可以作为await
命令的参数。所以,上面的例子也可以写成下面的形式。
1 | async function timeout(ms) { |
async 函数有多种使用形式。
1 | // 函数声明 |
语法
async
函数的语法规则总体上比较简单,难点是错误处理机制。
返回 Promise 对象
async
函数返回一个 Promise 对象。
async
函数内部return
语句返回的值,会成为then
方法回调函数的参数。
1 | async function f() { |
上面代码中,函数f
内部return
命令返回的值,会被then
方法回调函数接收到。
async
函数内部抛出错误,会导致返回的 Promise 对象变为reject
状态。抛出的错误对象会被catch
方法回调函数接收到。
1 | async function f() { |
错误处理
如果await
后面的异步操作出错,那么等同于async
函数返回的 Promise 对象被reject
。
1 | async function f() { |
上面代码中,async
函数f
执行后,await
后面的 Promise 对象会抛出一个错误对象,导致catch
方法的回调函数被调用,它的参数就是抛出的错误对象。具体的执行机制,可以参考后文的“async 函数的实现原理”。
防止出错的方法,也是将其放在try...catch
代码块之中。
1 | async function f() { |
如果有多个await
命令,可以统一放在try...catch
结构中。
1 | async function main() { |
下面的例子使用try...catch
结构,实现多次重复尝试。
1 | const superagent = require('superagent'); |
上面代码中,如果await
操作成功,就会使用break
语句退出循环;如果失败,会被catch
语句捕捉,然后进入下一轮循环。
实例:按顺序完成异步操作
实际开发中,经常遇到一组异步操作,需要按照顺序完成。比如,依次远程读取一组 URL,然后按照读取的顺序输出结果。
Promise 的写法如下。
1 | function logInOrder(urls) { |
上面代码使用fetch
方法,同时远程读取一组 URL。每个fetch
操作都返回一个 Promise 对象,放入textPromises
数组。然后,reduce
方法依次处理每个 Promise 对象,然后使用then
,将所有 Promise 对象连起来,因此就可以依次输出结果。
这种写法不太直观,可读性比较差。下面是 async 函数实现。
1 | async function logInOrder(urls) { |
上面代码确实大大简化,问题是所有远程操作都是继发。只有前一个 URL 返回结果,才会去读取下一个 URL,这样做效率很差,非常浪费时间。我们需要的是并发发出远程请求。
1 | async function logInOrder(urls) { |
上面代码中,虽然map
方法的参数是async
函数,但它是并发执行的,因为只有async
函数内部是继发执行,外部不受影响。后面的for..of
循环内部使用了await
,因此实现了按顺序输出。
实例:获取天气 现在now数据
1 | getJSON = function (url) { |
class类
基本用法
JavaScript 语言中,生成实例对象的传统方法是通过构造函数。下面是一个例子。
1 | function Point(x, y) { |
上面这种写法跟传统的面向对象语言(比如 C++ 和 Java)差异很大,很容易让新学习这门语言的程序员感到困惑。
ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过class
关键字,可以定义类。
基本上,ES6 的class
可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的class
写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。上面的代码用 ES6 的class
改写,就是下面这样。
1 | class Point { |
上面代码定义了一个“类”,可以看到里面有一个constructor()
方法,这就是构造方法,而this
关键字则代表实例对象。这种新的 Class 写法,本质上与本章开头的 ES5 的构造函数Point
是一致的。
Point
类除了构造方法,还定义了一个toString()
方法。注意,定义toString()
方法的时候,前面不需要加上function
这个关键字,直接把函数定义放进去了就可以了。另外,方法与方法之间不需要逗号分隔,加了会报错。
ES6 的类,完全可以看作构造函数的另一种写法。
1 | class Point { |
上面代码表明,类的数据类型就是函数,类本身就指向构造函数。
使用的时候,也是直接对类使用new
命令,跟构造函数的用法完全一致。
1 | class Bar { |
构造函数的prototype
属性,在 ES6 的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype
属性上面。
1 | class Point { |
上面代码中,constructor()
、toString()
、toValue()
这三个方法,其实都是定义在Point.prototype
上面。
示例:
1 | class Person { |
继承
Class 可以通过extends
关键字实现继承,让子类继承父类的属性和方法。extends 的写法比 ES5 的原型链继承,要清晰和方便很多。
1 | class Point { |
上面示例中,Point
是父类,ColorPoint
是子类,它通过extends
关键字,继承了Point
类的所有属性和方法。但是由于没有部署任何代码,所以这两个类完全一样,等于复制了一个Point
类。
1 | class Animal { |
super
这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。
调用super()
的作用是形成子类的this
对象,把父类的实例属性和方法放到这个this
对象上面。子类在调用super()
之前,是没有this
对象的,任何对this
的操作都要放在super()
的后面。
注意,这里的super
虽然代表了父类的构造函数,但是因为返回的是子类的this
(即子类的实例对象),所以super
内部的this
代表子类的实例,而不是父类的实例,这里的super()
相当于A.prototype.constructor.call(this)
(在子类的this
上运行父类的构造函数)。
ES6模块化实现
ES6 模块不是对象,而是通过export
命令显式指定输出的代码,再通过import
命令输入。
模块功能主要由两个命令构成:export
和import
。export
命令用于规定模块的对外接口,import
命令用于输入其他模块提供的功能。
export
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export
关键字输出该变量。下面是一个 JS 文件,里面使用export
命令输出变量。
1 | // profile.js |
上面代码是profile.js
文件,保存了用户信息。ES6 将其视为一个模块,里面用export
命令对外部输出了三个变量。
export
的写法,除了像上面这样,还有另外一种。
1 | // profile.js |
上面代码在export
命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在var
语句前)是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。
export
命令除了输出变量,还可以输出函数或类(class)。
1 | export function multiply(x, y) { |
上面代码对外输出一个函数multiply
。
通常情况下,export
输出的变量就是本来的名字,但是可以使用as
关键字重命名。
1 | function v1() { ... } |
上面代码使用as
关键字,重命名了函数v1
和v2
的对外接口。重命名后,v2
可以用不同的名字输出两次。
需要特别注意的是,export
命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
1 | // 报错 |
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量m
,还是直接输出 1。1
只是一个值,不是接口。正确的写法是下面这样。
1 | // 写法一 |
上面三种写法都是正确的,规定了对外的接口m
。其他脚本可以通过这个接口,取到值1
。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。
同样的,function
和class
的输出,也必须遵守这样的写法。
1 | // 报错 |
目前,export 命令能够对外输出的就是三种接口:函数(Functions), 类(Classes),var、let、const 声明的变量(Variables)。
另外,export
语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。
1 | export var foo = 'bar'; |
上面代码输出变量foo
,值为bar
,500 毫秒之后变成baz
。
import
profile.js
文件内容
1 | export var firstName = 'Michael'; |
使用export
命令定义了模块的对外接口以后,其他 JS 文件就可以通过import
命令加载这个模块。
前提我们需要在当前script
指定类型type="module"
1 | <script type="module"> |
上面代码的import
命令,用于加载profile.js
文件,并从中输入变量。import
命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js
)对外接口的名称相同。
如果想为输入的变量重新取一个名字,import
命令要使用as
关键字,将输入的变量重命名。
1 | import { lastName as surname } from './profile.js'; |
模块整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*
)指定一个对象,所有输出值都加载在这个对象上面。
1 | <script type="module"> |
export default
为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default
命令,为模块指定默认输出。
1 | export var firstName = 'Michael'; |
上面代码是一个模块文件profile.js
,它的默认输出是一个函数。
其他模块加载该模块时,import
命令可以为该匿名函数指定任意名字。
1 | import customName from './profile.js'; |
上面代码的import
命令,可以用任意名称指向profile.js
输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时import
命令后面,不使用大括号。