
ES6
约 22593 字大约 75 分钟
2024-07-20
ECMAScript 6 简介
ECMAScript 6.0(以下简称 ES6)是 JavaScript 语言的下一代标准,已经在 2015 年 6 月正式发布了。它的目标,是使得 JavaScript 语言可以用来编写复杂的大型应用程序,成为企业级开发语言。
1. ECMAScript 和 JavaScript 的关系
一个常见的问题是,ECMAScript 和 JavaScript 到底是什么关系?
要讲清楚这个问题,需要回顾历史。1996 年 11 月,JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。次年,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript,这个版本就是 1.0 版。
该标准从一开始就是针对 JavaScript 语言制定的,但是之所以不叫 JavaScript,有两个原因。一是商标,Java 是 Sun 公司的商标,根据授权协议,只有 Netscape 公司可以合法地使用 JavaScript 这个名字,且 JavaScript 本身也已经被 Netscape 公司注册为商标。二是想体现这门语言的制定者是 ECMA,不是 Netscape,这样有利于保证这门语言的开放性和中立性。
因此,ECMAScript 和 JavaScript 的关系是,前者是后者的规格,后者是前者的一种实现(另外的 ECMAScript 方言还有 JScript 和 ActionScript)。日常场合,这两个词是可以互换的。
2. ES6 与 ECMAScript 2015 的关系
ECMAScript 2015(简称 ES2015)这个词,也是经常可以看到的。它与 ES6 是什么关系呢?
2011 年,ECMAScript 5.1 版发布后,就开始制定 6.0 版了。因此,ES6 这个词的原意,就是指 JavaScript 语言的下一个版本。
但是,因为这个版本引入的语法功能太多,而且制定过程当中,还有很多组织和个人不断提交新功能。事情很快就变得清楚了,不可能在一个版本里面包括所有将要引入的功能。常规的做法是先发布 6.0 版,过一段时间再发 6.1 版,然后是 6.2 版、6.3 版等等。
但是,标准的制定者不想这样做。他们想让标准的升级成为常规流程:任何人在任何时候,都可以向标准委员会提交新语法的提案,然后标准委员会每个月开一次会,评估这些提案是否可以接受,需要哪些改进。如果经过多次会议以后,一个提案足够成熟了,就可以正式进入标准了。这就是说,标准的版本升级成为了一个不断滚动的流程,每个月都会有变动。
标准委员会最终决定,标准在每年的 6 月份正式发布一次,作为当年的正式版本。接下来的时间,就在这个版本的基础上做改动,直到下一年的 6 月份,草案就自然变成了新一年的版本。这样一来,就不需要以前的版本号了,只要用年份标记就可以了。
ES6 的第一个版本,就这样在 2015 年 6 月发布了,正式名称就是《ECMAScript 2015 标准》(简称 ES2015)。2016 年 6 月,小幅修订的《ECMAScript 2016 标准》(简称 ES2016)如期发布,这个版本可以看作是 ES6.1 版,因为两者的差异非常小(只新增了数组实例的 includes 方法和指数运算符),基本上是同一个标准。根据计划,2017 年 6 月发布 ES2017 标准。
因此,ES6 既是一个历史名词,也是一个泛指,含义是 5.1 版以后的 JavaScript 的下一代标准,涵盖了 ES2015、ES2016、ES2017 等等,而 ES2015 则是正式名称,特指该年发布的正式版本的语言标准。本书中提到 ES6 的地方,一般是指 ES2015 标准,但有时也是泛指“下一代 JavaScript 语言”。
3. 语法提案的批准流程
任何人都可以向标准委员会(又称 TC39 委员会)提案,要求修改语言标准。
一种新的语法从提案到变成正式标准,需要经历五个阶段。每个阶段的变动都需要由 TC39 委员会批准。
Stage 0 - Strawman(展示阶段) Stage 1 - Proposal(征求意见阶段) Stage 2 - Draft(草案阶段) Stage 3 - Candidate(候选人阶段) Stage 4 - Finished(定案阶段) 一个提案只要能进入 Stage 2,就差不多肯定会包括在以后的正式标准里面。ECMAScript 当前的所有提案,可以在 TC39 的官方网站 GitHub.com/tc39/ecma262 查看。
4. ECMAScript 的历史
ES6 从开始制定到最后发布,整整用了 15 年。
前面提到,ECMAScript 1.0 是 1997 年发布的,接下来的两年,连续发布了 ECMAScript 2.0(1998 年 6 月)和 ECMAScript 3.0(1999 年 12 月)。3.0 版是一个巨大的成功,在业界得到广泛支持,成为通行标准,奠定了 JavaScript 语言的基本语法,以后的版本完全继承。直到今天,初学者一开始学习 JavaScript,其实就是在学 3.0 版的语法。
2000 年,ECMAScript 4.0 开始酝酿。这个版本最后没有通过,但是它的大部分内容被 ES6 继承了。因此,ES6 制定的起点其实是 2000 年。
为什么 ES4 没有通过呢?因为这个版本太激进了,对 ES3 做了彻底升级,导致标准委员会的一些成员不愿意接受。ECMA 的第 39 号技术专家委员会(Technical Committee 39,简称 TC39)负责制订 ECMAScript 标准,成员包括 Microsoft、Mozilla、Google 等大公司。
2007 年 10 月,ECMAScript 4.0 版草案发布,本来预计次年 8 月发布正式版本。但是,各方对于是否通过这个标准,发生了严重分歧。以 Yahoo、Microsoft、Google 为首的大公司,反对 JavaScript 的大幅升级,主张小幅改动;以 JavaScript 创造者 Brendan Eich 为首的 Mozilla 公司,则坚持当前的草案。
2008 年 7 月,由于对于下一个版本应该包括哪些功能,各方分歧太大,争论过于激烈,ECMA 开会决定,中止 ECMAScript 4.0 的开发,将其中涉及现有功能改善的一小部分,发布为 ECMAScript 3.1,而将其他激进的设想扩大范围,放入以后的版本,由于会议的气氛,该版本的项目代号起名为 Harmony(和谐)。会后不久,ECMAScript 3.1 就改名为 ECMAScript 5。
2009 年 12 月,ECMAScript 5.0 版正式发布。Harmony 项目则一分为二,一些较为可行的设想定名为 JavaScript.next 继续开发,后来演变成 ECMAScript 6;一些不是很成熟的设想,则被视为 JavaScript.next.next,在更远的将来再考虑推出。TC39 委员会的总体考虑是,ES5 与 ES3 基本保持兼容,较大的语法修正和新功能加入,将由 JavaScript.next 完成。当时,JavaScript.next 指的是 ES6,第六版发布以后,就指 ES7。TC39 的判断是,ES5 会在 2013 年的年中成为 JavaScript 开发的主流标准,并在此后五年中一直保持这个位置。
2011 年 6 月,ECMAScript 5.1 版发布,并且成为 ISO 国际标准(ISO/IEC 16262:2011)。
2013 年 3 月,ECMAScript 6 草案冻结,不再添加新功能。新的功能设想将被放到 ECMAScript 7。
2013 年 12 月,ECMAScript 6 草案发布。然后是 12 个月的讨论期,听取各方反馈。
2015 年 6 月,ECMAScript 6 正式通过,成为国际标准。从 2000 年算起,这时已经过去了 15 年。
目前,各大浏览器对 ES6 的支持可以查看kangax.github.io/compat-table/es6/。
Node.js 是 JavaScript 的服务器运行环境(runtime)。它对 ES6 的支持度更高。除了那些默认打开的功能,还有一些语法功能已经实现了,但是默认没有打开。使用下面的命令,可以查看 Node.js 默认没有打开的 ES6 实验性语法。
// Linux & Mac
$ node --v8-options | grep harmony
// Windows
$ node --v8-options | findstr harmony
5. Babel 转码器
Babel 是一个广泛使用的 ES6 转码器,可以将 ES6 代码转为 ES5 代码,从而在老版本的浏览器执行。这意味着,你可以用 ES6 的方式编写程序,又不用担心现有环境是否支持。下面是一个例子。
// 转码前
input.map((item) => item + 1)
// 转码后
input.map(function (item) {
return item + 1
})
上面的原始代码用了箭头函数,Babel 将其转为普通函数,就能在不支持箭头函数的 JavaScript 环境执行了。
下面的命令在项目目录中,安装 Babel。
$ npm install --save-dev @babel/core
2.let 和 const 命令
1. globalThis 对象
JavaScript 语言存在一个顶层对象,它提供全局环境(即全局作用域),所有代码都是在这个环境中运行。但是,顶层对象在各种实现里面是不统一的。
浏览器里面,顶层对象是 window,但 Node 和 Web Worker 没有 window。
浏览器和 Web Worker 里面,self 也指向顶层对象,但是 Node 没有 self。
Node 里面,顶层对象是 global,但其他环境都不支持。 同一段代码为了能够在各种环境,都能取到顶层对象,现在一般是使用 this 关键字,但是有局限性。
全局环境中,this 会返回顶层对象。但是,Node.js 模块中 this 返回的是当前模块,ES6 模块中 this 返回的是 undefined。
函数里面的 this,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this 会指向顶层对象。但是,严格模式下,这时 this 会返回 undefined。
不管是严格模式,还是普通模式,new Function('return this')(),总是会返回全局对象。但是,如果浏览器用了 CSP(Content Security Policy,内容安全策略),那么 eval、new Function 这些方法都可能无法使用。
综上所述,很难找到一种方法,可以在所有情况下,都取到顶层对象。下面是两种勉强可以使用的方法。
// 方法一
typeof window !== "undefined"
? window
: typeof process === "object" &&
typeof require === "function" &&
typeof global === "object"
? global
: this
// 方法二
var getGlobal = function () {
if (typeof self !== "undefined") {
return self
}
if (typeof window !== "undefined") {
return window
}
if (typeof global !== "undefined") {
return global
}
throw new Error("unable to locate global object")
}
ES2020 在语言标准的层面,引入 globalThis 作为顶层对象。也就是说,任何环境下,globalThis 都是存在的,都可以从它拿到顶层对象,指向全局环境下的 this。
垫片库 global-this 模拟了这个提案,可以在所有环境拿到 globalThis。
3.变量的解构赋值
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构
以前,为变量赋值,只能直接指定值。
let a = 1
let b = 2
let c = 3
ES6 允许写成下面这样。
let [a, b, c] = [1, 2, 3]
上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。
本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。
let [foo, [[bar], baz]] = [1, [[2], 3]]
foo // 1
bar // 2
baz // 3
let [, , third] = ["foo", "bar", "baz"]
third // "baz"
let [x, , y] = [1, 2, 3]
x // 1
y // 3
let [head, ...tail] = [1, 2, 3, 4]
head // 1
tail // [2, 3, 4]
let [x, y, ...z] = ["a"]
x // "a"
y // undefined
z // []
如果解构不成功,变量的值就等于 undefined。
let [foo] = []
let [bar, foo] = [1]
以上两种情况都属于解构不成功,foo 的值都会等于 undefined。
1. 用途
变量的解构赋值用途很多。
(1)交换变量的值
let x = 1
let y = 2
;[x, y] = [y, x]
上面代码交换变量 x 和 y 的值,这样的写法不仅简洁,而且易读,语义非常清晰。
(2)从函数返回多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
// 返回一个数组
function example() {
return [1, 2, 3]
}
let [a, b, c] = example()
// 返回一个对象
function example() {
return {
foo: 1,
bar: 2,
}
}
let { foo, bar } = example()
(3)函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来。
// 参数是一组有次序的值
function f([x, y, z]) { ... }
f([1, 2, 3]);
// 参数是一组无次序的值
function f({x, y, z}) { ... }
f({z: 3, y: 2, x: 1});
(4)提取 JSON 数据
解构赋值对提取 JSON 对象中的数据,尤其有用。
let jsonData = {
id: 42,
status: "OK",
data: [867, 5309],
}
let { id, status, data: number } = jsonData
console.log(id, status, number)
// 42, "OK", [867, 5309]
上面代码可以快速提取 JSON 数据的值。
(5)函数参数的默认值
jQuery.ajax = function (
url,
{
async = true,
beforeSend = function () {},
cache = true,
complete = function () {},
crossDomain = false,
global = true,
// ... more config
} = {}
) {
// ... do stuff
}
指定参数的默认值,就避免了在函数体内部再写 var foo = config.foo || 'default foo';这样的语句。
(6)遍历 Map 结构
任何部署了 Iterator 接口的对象,都可以用 for...of 循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。
const map = new Map()
map.set("first", "hello")
map.set("second", "world")
for (let [key, value] of map) {
console.log(key + " is " + value)
}
// first is hello
// second is world
(7)输入模块的指定方法
加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。
const { SourceMapConsumer, SourceNode } = require("source-map")
5.字符串的新增方法
1. 实例方法:includes(), startsWith(), endsWith()
传统上,JavaScript 只有 indexOf 方法,可以用来确定一个字符串是否包含在另一个字符串中。ES6 又提供了三种新方法。
- includes():返回布尔值,表示是否找到了参数字符串。
- startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
- endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。
let s = "Hello world!"
s.startsWith("Hello") // true
s.endsWith("!") // true
s.includes("o") // true
这三个方法都支持第二个参数,表示开始搜索的位置。
let s = "Hello world!"
s.startsWith("world", 6) // true
s.endsWith("Hello", 5) // true
s.includes("Hello", 6) // false
上面代码表示,使用第二个参数 n 时,endsWith 的行为与其他两个方法有所不同。它针对前 n 个字符,而其他两个方法针对从第 n 个位置直到字符串结束。
2. 实例方法:repeat()
repeat 方法返回一个新字符串,表示将原字符串重复 n 次。
"x".repeat(3) // "xxx"
"hello".repeat(2) // "hellohello"
"na".repeat(0) // ""
参数如果是小数,会被取整。
"na".repeat(2.9) // "nana"
如果 repeat 的参数是负数或者 Infinity,会报错。
"na".repeat(Infinity)
// RangeError
"na".repeat(-1)
// RangeError
但是,如果参数是 0 到-1 之间的小数,则等同于 0,这是因为会先进行取整运算。0 到-1 之间的小数,取整以后等于-0,repeat 视同为 0。
"na".repeat(-0.9) // ""
参数 NaN 等同于 0。
"na".repeat(NaN) // ""
如果 repeat 的参数是字符串,则会先转换成数字。
"na".repeat("na") // ""
"na".repeat("3") // "nanana"
3. 实例方法:padStart(),padEnd()
ES2017 引入了字符串补全长度的功能。如果某个字符串不够指定长度,会在头部或尾部补全。padStart()用于头部补全,padEnd()用于尾部补全。
"x".padStart(5, "ab") // 'ababx'
"x".padStart(4, "ab") // 'abax'
"x".padEnd(5, "ab") // 'xabab'
"x".padEnd(4, "ab") // 'xaba'
上面代码中,padStart()和 padEnd()一共接受两个参数,第一个参数是字符串补全生效的最大长度,第二个参数是用来补全的字符串。
如果原字符串的长度,等于或大于最大长度,则字符串补全不生效,返回原字符串。
"xxx".padStart(2, "ab") // 'xxx'
"xxx".padEnd(2, "ab") // 'xxx'
如果用来补全的字符串与原字符串,两者的长度之和超过了最大长度,则会截去超出位数的补全字符串。
"abc".padStart(10, "0123456789")
// '0123456abc'
如果省略第二个参数,默认使用空格补全长度。
"x".padStart(4) // ' x'
"x".padEnd(4) // 'x '
padStart()的常见用途是为数值补全指定位数。下面代码生成 10 位的数值字符串。
"1".padStart(10, "0") // "0000000001"
"12".padStart(10, "0") // "0000000012"
"123456".padStart(10, "0") // "0000123456"
另一个用途是提示字符串格式。
"12".padStart(10, "YYYY-MM-DD") // "YYYY-MM-12"
"09-12".padStart(10, "YYYY-MM-DD") // "YYYY-09-12"
6.正则的扩展
1. RegExp 构造函数
在 ES5 中,RegExp 构造函数的参数有两种情况。
第一种情况是,参数是字符串,这时第二个参数表示正则表达式的修饰符(flag)。
var regex = new RegExp("xyz", "i")
// 等价于
var regex = /xyz/i
第二种情况是,参数是一个正则表示式,这时会返回一个原有正则表达式的拷贝。
var regex = new RegExp(/xyz/i)
// 等价于
var regex = /xyz/i
但是,ES5 不允许此时使用第二个参数添加修饰符,否则会报错。
var regex = new RegExp(/xyz/, "i")
// Uncaught TypeError: Cannot supply flags when constructing one RegExp from another
ES6 改变了这种行为。如果 RegExp 构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。
new RegExp(/abc/gi, "i").flags
// "i"
上面代码中,原有正则对象的修饰符是 ig,它会被第二个参数 i 覆盖。
2. 字符串的正则方法
ES6 出现之前,字符串对象共有 4 个方法,可以使用正则表达式:match()、replace()、search()和 split()。
ES6 将这 4 个方法,在语言内部全部调用 RegExp 的实例方法,从而做到所有与正则相关的方法,全都定义在 RegExp 对象上。
- String.prototype.match 调用 RegExp.prototype[Symbol.match]
- String.prototype.replace 调用 RegExp.prototype[Symbol.replace]
- String.prototype.search 调用 RegExp.prototype[Symbol.search]
- String.prototype.split 调用 RegExp.prototype[Symbol.split]
7.数值的扩展
1. Number.isFinite(), Number.isNaN()
ES6 在 Number 对象上,新提供了 Number.isFinite()和 Number.isNaN()两个方法。
Number.isFinite()用来检查一个数值是否为有限的(finite),即不是 Infinity。
如果参数类型不是 NaN,Number.isNaN 一律返回 false。
它们与传统的全局方法 isFinite()和 isNaN()的区别在于,传统方法先调用 Number()将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,Number.isFinite()对于非数值一律返回 false, Number.isNaN()只有对于 NaN 才返回 true,非 NaN 一律返回 false。
isFinite(25) // true
isFinite("25") // true
Number.isFinite(25) // true
Number.isFinite("25") // false
isNaN(NaN) // true
isNaN("NaN") // true
Number.isNaN(NaN) // true
Number.isNaN("NaN") // false
Number.isNaN(1) // false
2. Number.parseInt(), Number.parseFloat()
ES6 将全局方法 parseInt()和 parseFloat(),移植到 Number 对象上面,行为完全保持不变。
// ES5的写法
parseInt("12.34") // 12
parseFloat("123.45#") // 123.45
// ES6的写法
Number.parseInt("12.34") // 12
Number.parseFloat("123.45#") // 123.45
这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。
Number.parseInt === parseInt // true
Number.parseFloat === parseFloat // true
3. Math 对象的扩展
1.Math.trunc()
Math.trunc 方法用于去除一个数的小数部分,返回整数部分。
Math.trunc(4.1) // 4
Math.trunc(4.9) // 4
Math.trunc(-4.1) // -4
Math.trunc(-4.9) // -4
Math.trunc(-0.1234) // -0
对于非数值,Math.trunc 内部使用 Number 方法将其先转为数值。
Math.trunc("123.456") // 123
Math.trunc(true) //1
Math.trunc(false) // 0
Math.trunc(null) // 0
对于空值和无法截取整数的值,返回 NaN。
Math.trunc(NaN); // NaN
Math.trunc('foo'); // NaN
Math.trunc(); // NaN
Math.trunc(undefined) // NaN
对于没有部署这个方法的环境,可以用下面的代码模拟。
Math.trunc =
Math.trunc ||
function (x) {
return x < 0 ? Math.ceil(x) : Math.floor(x)
}
4. BigInt 数据类型
8.函数的扩展
1. rest 参数
ES6 引入 rest 参数(形式为...变量名),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
function add(...values) {
let sum = 0
for (var val of values) {
sum += val
}
return sum
}
add(2, 5, 3) // 10
2. 箭头函数
1. 使用注意点
箭头函数有几个使用注意点。
(1)箭头函数没有自己的 this 对象(详见下文)。
(2)不可以当作构造函数,也就是说,不可以对箭头函数使用 new 命令,否则会抛出一个错误。
(3)不可以使用 arguments 对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
(4)不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。
上面四点中,最重要的是第一点。对于普通函数来说,内部的 this 指向函数运行时所在的对象,但是这一点对箭头函数不成立。它没有自己的 this 对象,内部的 this 就是定义时上层作用域中的 this。也就是说,箭头函数内部的 this 指向是固定的,相比之下,普通函数的 this 指向是可变的。
function foo() {
setTimeout(() => {
console.log("id:", this.id)
}, 100)
}
var id = 21
foo.call({ id: 42 })
// id: 42
上面代码中,setTimeout()的参数是一个箭头函数,这个箭头函数的定义生效是在 foo 函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时 this 应该指向全局对象 window,这时应该输出 21。但是,箭头函数导致 this 总是指向函数定义生效时所在的对象(本例是{id: 42}),所以打印出来的是 42。
下面例子是回调函数分别为箭头函数和普通函数,对比它们内部的 this 指向。
function Timer() {
this.s1 = 0
this.s2 = 0
// 箭头函数
setInterval(() => this.s1++, 1000)
// 普通函数
setInterval(function () {
this.s2++
}, 1000)
}
var timer = new Timer()
setTimeout(() => console.log("s1: ", timer.s1), 3100)
setTimeout(() => console.log("s2: ", timer.s2), 3100)
// s1: 3
// s2: 0
上面代码中,Timer 函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的 this 绑定定义时所在的作用域(即 Timer 函数),后者的 this 指向运行时所在的作用域(即全局对象)。所以,3100 毫秒之后,timer.s1 被更新了 3 次,而 timer.s2 一次都没更新。
由于箭头函数没有自己的 this,所以当然也就不能用 call()、apply()、bind()这些方法去改变 this 的指向。
9.数组的扩展
1.扩展运算符
扩展运算符(spread)是三个点(...)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。该运算符主要用于函数调用。
2.Array.form()和 Array.of()
Array.from()方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。
只要是部署了 Iterator 接口的数据结构,Array.from()都能将其转为数组。
Array.from("hello")
// ['h', 'e', 'l', 'l', 'o']
let namesSet = new Set(["a", "b"])
Array.from(namesSet) // ['a', 'b']
Array.of()方法用于将一组值,转换为数组。
Array.of(3, 11, 8) // [3,11,8]
Array.of(3) // [3]
Array.of(3).length // 1
这个方法的主要目的,是弥补数组构造函数 Array()的不足。因为参数个数的不同,会导致 Array()的行为有差异。Array.of()基本上可以用来替代 Array()或 new Array(),并且不存在由于参数不同而导致的重载。它的行为非常统一。
3.实例方法:find()、findIndex()、findLast()、findLastIndex()
数组实例的 find()方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为 true 的成员,然后返回该成员。如果没有符合条件的成员,则返回 undefined。这 4 个方法都可以接受第二个参数,用来绑定回调函数的 this 对象。
;[1, 4, -5, 10].find((n) => n < 0)
// -5
find()方法的回调函数可以接受三个参数,依次为当前的值、当前的位置和原数组。
10.对象的扩展
1.可枚举性
目前,有四个操作会忽略 enumerable 为 false 的属性。
- for...in 循环:只遍历对象自身的和继承的可枚举的属性。
- Object.keys():返回对象自身的所有可枚举的属性的键名。
- JSON.stringify():只串行化对象自身的可枚举的属性。
- Object.assign(): 忽略 enumerable 为 false 的属性,只拷贝对象自身的可枚举的属性。
2.新增方法
1. Object.is()
ES5 比较两个值是否相等,只有两个运算符:相等运算符()和严格相等运算符(=)。它们都有缺点,前者会自动转换数据类型,后者的 NaN 不等于自身,以及+0 等于-0。JavaScript 缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。
2. Object.assign()
Object.assign()方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。
Object.assign()方法的第一个参数是目标对象,后面的参数都是源对象。 注意,如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。
3. Object.hasOwn()
JavaScript 对象的属性分成两种:自身的属性和继承的属性。对象实例有一个 hasOwnProperty()方法,可以判断某个属性是否为原生属性。ES2022 在 Object 对象上面新增了一个静态方法 Object.hasOwn(),也可以判断是否为自身的属性。
Object.hasOwn()可以接受两个参数,第一个是所要判断的对象,第二个是属性名。
const foo = Object.create({ a: 123 })
foo.b = 456
Object.hasOwn(foo, "a") // false
Object.hasOwn(foo, "b") // true
上面示例中,对象 foo 的属性 a 是继承属性,属性 b 是原生属性。Object.hasOwn()对属性 a 返回 false,对属性 b 返回 true。
Object.hasOwn()的一个好处是,对于不继承 Object.prototype 的对象不会报错,而 hasOwnProperty()是会报错的。
12.运算符的扩展
1.链判断运算符 ?.
直接在链式调用的时候判断,左侧的对象是否为 null 或 undefined。如果是的,就不再往下运算,而是返回 undefined。
2.Null 判断运算符 ??
它的行为类似||,但是只有运算符左侧的值为 null 或 undefined 时,才会返回右侧的值,排除了 ''、false、0
3.#!命令
Unix 的命令行脚本都支持#!命令,又称为 Shebang 或 Hashbang。这个命令放在脚本的第一行,用来指定脚本的执行器.
ES2023 为 JavaScript 脚本引入了#!命令,写在脚本文件或者模块文件的第一行。
13.Symbol
ES5 的对象属性名都是字符串,这容易造成属性名的冲突。ES6 引入了一种新的原始数据类型Symbol,表示独一无二的值。它属于 JavaScript 语言的原生数据类型之一。
Symbol 值通过Symbol()函数生成。这就是说,对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的 Symbol 类型。凡是属性名属于 Symbol 类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。
Symbol()函数可以接受一个字符串作为参数,表示对 Symbol 实例的描述。这主要是为了在控制台显示,或者转为字符串时,比较容易区分。
let s1 = Symbol('foo');
let s2 = Symbol('bar');
s1 // Symbol(foo)
s2 // Symbol(bar)
s1.toString() // "Symbol(foo)"
s2.toString() // "Symbol(bar)"
s1和s2是两个 Symbol 值。如果不加参数,它们在控制台的输出都是Symbol(),不利于区分。有了参数以后,就等于为它们加上了描述,输出的时候就能够分清,到底是哪一个值。
14.Set 和 Map 数据结构
1. Set
ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。
Set本身是一个构造函数,用来生成 Set 数据结构。
const s = new Set();
[2, 3, 5, 4, 5, 2, 2].forEach(x => s.add(x));
for (let i of s) {
console.log(i);
}
// 2 3 5 4
1. Set 实例的属性和方法
Set 实例的方法分为两大类:操作方法(用于操作数据)和遍历方法(用于遍历成员)。下面先介绍四个操作方法。
- Set.prototype.add(value):添加某个值,返回 Set 结构本身。
- Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
- Set.prototype.has(value):返回一个布尔值,表示该值是否为Set的成员。
- Set.prototype.clear():清除所有成员,没有返回值。
2. WeakSet
WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别。
首先,WeakSet 的成员只能是对象和 Symbol 值,而不能是其他类型的值。
其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。
WeakSet 不能遍历,是因为成员都是弱引用,随时可能消失,遍历机制无法保证成员的存在,很可能刚刚遍历结束,成员就取不到了。WeakSet 的一个用处,是储存 DOM 节点,而不用担心这些节点从文档移除时,会引发内存泄漏。
15.Proxy
1. 概述
Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。
Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
var obj = new Proxy({}, {
get: function (target, propKey, receiver) {
console.log(`getting ${propKey}!`);
return Reflect.get(target, propKey, receiver);
},
set: function (target, propKey, value, receiver) {
console.log(`setting ${propKey}!`);
return Reflect.set(target, propKey, value, receiver);
}
});
上面代码对一个空对象架设了一层拦截,重定义了属性的读取(get)和设置(set)行为。这里暂时先不解释具体的语法,只看运行结果。对设置了拦截行为的对象obj,去读写它的属性,就会得到下面的结果。
obj.count = 1
// setting count!
++obj.count
// getting count!
// setting count!
// 2
上面代码说明,Proxy 实际上重载(overload)了点运算符,即用自己的定义覆盖了语言的原始定义。
ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例。
var proxy = new Proxy(target, handler);
Proxy 对象的所有用法,都是上面这种形式,不同的只是handler参数的写法。其中,new Proxy()表示生成一个Proxy实例,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。
下面是 Proxy 支持的拦截操作一览,一共 13 种。
- get(target,propKey,receiver): 拦截对象属性的读取,比如proxy.foo和proxy['foo']。
- set(target,propKey,value,receiver): 拦截对象属性的设置,比如proxy.foo = v或proxy['foo'] = v,返回一个布尔值。
- has(target, propKey):拦截propKey in proxy的操作,返回一个布尔值。
- deleteProperty(target, propKey):拦截delete proxy[propKey]的操作,返回一个布尔值。
- ownKeys(target):拦截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
- getOwnPropertyDescriptor(target, propKey):拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
- defineProperty(target, propKey, propDesc):拦截Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。
- preventExtensions(target):拦截Object.preventExtensions(proxy),返回一个布尔值。
- getPrototypeOf(target):拦截Object.getPrototypeOf(proxy),返回一个对象。
- isExtensible(target):拦截Object.isExtensible(proxy),返回一个布尔值。
- setPrototypeOf(target, proto):拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
- apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。
- construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)。
16.Reflect
1.概述
Reflect对象与Proxy对象一样,也是 ES6 为了操作对象而提供的新 API。Reflect对象的设计目的有这样几个。
(1) 将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上。现阶段,某些方法同时在Object和Reflect对象上部署,未来的新方法将只部署在Reflect对象上。也就是说,从Reflect对象上可以拿到语言内部的方法。
(2) 修改某些Object方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false。
(3) 让Object操作都变成函数行为。某些Object操作是命令式,比如name in obj和delete obj[name],而Reflect.has(obj, name)和Reflect.deleteProperty(obj, name)让它们变成了函数行为。
(4)Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。
Proxy(target, {
set: function(target, name, value, receiver) {
var success = Reflect.set(target, name, value, receiver);
if (success) {
console.log('property ' + name + ' on ' + target + ' set to ' + value);
}
return success;
}
});
上面代码中,Proxy方法拦截target对象的属性赋值行为。它采用Reflect.set方法将值赋值给对象的属性,确保完成原有的行为,然后再部署额外的功能。
下面是另一个例子。
var loggedObj = new Proxy(obj, {
get(target, name) {
console.log('get', target, name);
return Reflect.get(target, name);
},
deleteProperty(target, name) {
console.log('delete' + name);
return Reflect.deleteProperty(target, name);
},
has(target, name) {
console.log('has' + name);
return Reflect.has(target, name);
}
});
上面代码中,每一个Proxy对象的拦截操作(get、delete、has),内部都调用对应的Reflect方法,保证原生行为能够正常执行。添加的工作,就是将每一个操作输出一行日志。
17.Promise 对象
提示
- 无法取消 Promise,一旦新建它就会立即执行,无法中途取消
- 如果不设置回调函数,Promise 内部抛出的错误,不会反应到外部
- 当处于 pending 状态时,无法得知目前进展到哪一个阶段
1.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 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
注意,调用 resolve 或 reject 并不会终结 Promise 的参数函数的执行。 ``javascript new Promise((resolve, reject) => { resolve(1); console.log(2); }).then(r => { console.log(r); }); // 2 // 1
上面代码中,调用resolve(1)以后,后面的console.log(2)还是会执行,并且会首先打印出来。这是因为立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。
一般来说,调用resolve或reject以后,Promise 的使命就完成了,后继操作应该放到then方法里面,而不应该直接写在resolve或reject的后面。所以,最好在它们前面加上return语句,这样就不会有意外。
```javascript
new Promise((resolve, reject) => {
return resolve(1);
// 后面的语句不会执行
console.log(2);
})
2.Promise.prototype.catch()
Promise.prototype.catch()方法是.then(null, rejection)或.then(undefined, rejection)的别名,用于指定发生错误时的回调函数。
getJSON("/posts.json")
.then(function (posts) {
// ...
})
.catch(function (error) {
// 处理 getJSON 和 前一个回调函数运行时发生的错误
console.log("发生错误!", error)
})
上面代码中,getJSON()方法返回一个 Promise 对象,如果该对象状态变为 resolved,则会调用 then()方法指定的回调函数;如果异步操作抛出错误,状态就会变为rejected,就会调用catch()方法指定的回调函数,处理这个错误。另外,then()方法指定的回调函数,如果运行中抛出错误,也会被catch()方法捕获。
跟传统的 try/catch 代码块不同的是,如果没有使用 catch()方法指定错误处理的回调函数,Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应。
const someAsyncThing = function () {
return new Promise(function (resolve, reject) {
// 下面一行会报错,因为x没有声明
resolve(x + 2)
})
}
someAsyncThing().then(function () {
console.log("everything is great")
})
setTimeout(() => {
console.log(123)
}, 2000)
// Uncaught (in promise) ReferenceError: x is not defined
// 123
上面代码中,someAsyncThing()函数产生的 Promise 对象,内部有语法错误。浏览器运行到这一行,会打印出错误提示 ReferenceError: x is not defined,但是不会退出进程、终止脚本执行
,2 秒之后还是会输出 123。这就是说,Promise 内部的错误不会影响到 Promise 外部的代码,通俗的说法就是“Promise 会吃掉错误”
。
3.Promise.all()
Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。
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 的回调函数。
注意,如果作为参数的 Promise 实例,自己定义了 catch 方法,那么它一旦被 rejected,并不会触发 Promise.all()的 catch 方法。
const p1 = new Promise((resolve, reject) => {
resolve("hello")
})
.then((result) => result)
.catch((e) => e)
const p2 = new Promise((resolve, reject) => {
throw new Error("报错了")
})
.then((result) => result)
.catch((e) => e)
Promise.all([p1, p2])
.then((result) => console.log(result))
.catch((e) => console.log(e))
// ["hello", Error: 报错了]
上面代码中,p1 会 resolved,p2 首先会 rejected,但是 p2 有自己的 catch 方法,该方法返回的是一个新的 Promise 实例,p2 指向的实际上是这个实例。该实例执行完 catch 方法后,也会变成 resolved,导致 Promise.all()方法参数里面的两个实例都会 resolved,因此会调用 then 方法指定的回调函数,而不会调用 catch 方法指定的回调函数。
如果 p2 没有自己的 catch 方法,就会调用 Promise.all()的 catch 方法。
4.Promise.allSettled()
有时候,我们希望等到一组异步操作都结束了,不管每一个操作是成功还是失败,再进行下一步操作。但是,现有的 Promise 方法很难实现这个要求。
Promise.all()方法只适合所有异步操作都成功的情况,如果有一个操作失败,就无法满足要求。
为了解决这个问题,ES2020 引入了 Promise.allSettled()方法,用来确定一组异步操作是否都结束了(不管成功或失败)。所以,它的名字叫做”Settled“,包含了”fulfilled“和”rejected“两种情况。
Promise.allSettled()方法接受一个数组作为参数,数组的每个成员都是一个 Promise 对象,并返回一个新的 Promise 对象。只有等到参数数组的所有 Promise 对象都发生状态变更(不管是 fulfilled 还是 rejected),返回的 Promise 对象才会发生状态变更。
const promises = [fetch("/api-1"), fetch("/api-2"), fetch("/api-3")]
await Promise.allSettled(promises)
removeLoadingIndicator()
上面示例中,数组 promises 包含了三个请求,只有等到这三个请求都结束了(不管请求成功还是失败),removeLoadingIndicator()才会执行。
该方法返回的新的 Promise 实例,一旦发生状态变更,状态总是 fulfilled,不会变成 rejected。状态变成 fulfilled 后,它的回调函数会接收到一个数组作为参数,该数组的每个成员对应前面数组的每个 Promise 对象。
const resolved = Promise.resolve(42)
const rejected = Promise.reject(-1)
const allSettledPromise = Promise.allSettled([resolved, rejected])
allSettledPromise.then(function (results) {
console.log(results)
})
// [
// { status: 'fulfilled', value: 42 },
// { status: 'rejected', reason: -1 }
// ]
上面代码中,Promise.allSettled()的返回值 allSettledPromise,状态只可能变成 fulfilled。它的回调函数接收到的参数是数组 results。该数组的每个成员都是一个对象,对应传入 Promise.allSettled()的数组里面的两个 Promise 对象。
results 的每个成员是一个对象,对象的格式是固定的,对应异步操作的结果。
// 异步操作成功时
{status: 'fulfilled', value: value}
// 异步操作失败时
{status: 'rejected', reason: reason}
成员对象的 status 属性的值只可能是字符串 fulfilled 或字符串 rejected,用来区分异步操作是成功还是失败。如果是成功(fulfilled),对象会有 value 属性,如果是失败(rejected),会有 reason 属性,对应两种状态时前面异步操作的返回值。
下面是返回值的用法例子。
const promises = [fetch("index.html"), fetch("https://does-not-exist/")]
const results = await Promise.allSettled(promises)
// 过滤出成功的请求
const successfulPromises = results.filter((p) => p.status === "fulfilled")
// 过滤出失败的请求,并输出原因
const errors = results
.filter((p) => p.status === "rejected")
.map((p) => p.reason)
18.Iterator 和 for...of 循环
提示
- 它是一种接口,为各种不同的数据结构提供统一的访问机制
- 任何数据结构只要部署 Iterator 接口,就可以完成遍历操作
1. Iterator(遍历器)的概念
JavaScript 原有的表示“集合”的数据结构,主要是数组(Array)和对象(Object),ES6 又添加了 Map 和 Set。这样就有了四种数据集合,用户还可以组合使用它们,定义自己的数据结构,比如数组的成员是 Map,Map 的成员是对象。这样就需要一种统一的接口机制,来处理所有不同的数据结构
。
遍历器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。
Iterator 的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是 ES6 创造了一种新的遍历命令 for...of 循环,Iterator 接口主要供 for...of 消费。
2. 默认 Iterator 接口
Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即 for...of 循环(详见下文)。当使用 for...of 循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。
一种数据结构只要部署了 Iterator 接口,我们就称这种数据结构是“可遍历的”(iterable)。
ES6 规定,默认的 Iterator 接口部署在数据结构的 Symbol.iterator 属性,或者说,一个数据结构只要具有 Symbol.iterator 属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator 属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名 Symbol.iterator,它是一个表达式,返回 Symbol 对象的 iterator 属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内(参见《Symbol》一章)。
const obj = {
[Symbol.iterator]: function () {
return {
next: function () {
return {
value: 1,
done: true,
}
},
}
},
}
上面代码中,对象 obj 是可遍历的(iterable),因为具有 Symbol.iterator 属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有 next 方法。每次调用 next 方法,都会返回一个代表当前成员的信息对象,具有 value 和 done 两个属性。
ES6 的有些数据结构原生具备 Iterator 接口(比如数组),即不用任何处理,就可以被 for...of 循环遍历。原因在于,这些数据结构原生部署了 Symbol.iterator 属性(详见下文),另外一些数据结构没有(比如对象
)。凡是部署了 Symbol.iterator 属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象。
原生具备 Iterator 接口的数据结构如下。
- Array
- Map
- Set
- String
- TypedArray
- 函数的 arguments 对象
- NodeList 对象 下面的例子是数组的 Symbol.iterator 属性。
let arr = ["a", "b", "c"]
let iter = arr[Symbol.iterator]()
iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }
上面代码中,变量 arr 是一个数组,原生就具有遍历器接口,部署在 arr 的 Symbol.iterator 属性上面。所以,调用这个属性,就得到遍历器对象。
对于原生部署 Iterator 接口的数据结构,不用自己写遍历器生成函数,for...of 循环会自动遍历它们。除此之外,其他数据结构(主要是对象)的 Iterator 接口,都需要自己在 Symbol.iterator 属性上面部署,这样才会被 for...of 循环遍历。
对象(Object)之所以没有默认部署 Iterator 接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口,就等于部署一种线性转换。不过,严格地说,对象部署遍历器接口并不是很必要,因为这时对象实际上被当作 Map 结构使用,ES5 没有 Map 结构,而 ES6 原生提供了。
3. 调用 Iterator 接口的场合
有一些场合会默认调用 Iterator 接口(即 Symbol.iterator 方法),除了下文会介绍的 for...of 循环,还有几个别的场合。
(1)解构赋值
对数组和 Set 结构进行解构赋值时,会默认调用 Symbol.iterator 方法。
let set = new Set().add("a").add("b").add("c")
let [x, y] = set
// x='a'; y='b'
let [first, ...rest] = set
// first='a'; rest=['b','c'];
(2)扩展运算符
扩展运算符(...)也会调用默认的 Iterator 接口。
// 例一
var str = "hello"
;[...str] // ['h','e','l','l','o']
// 例二
let arr = ["b", "c"]
;["a", ...arr, "d"]
// ['a', 'b', 'c', 'd']
上面代码的扩展运算符内部就调用 Iterator 接口。
实际上,这提供了一种简便机制,可以将任何部署了 Iterator 接口的数据结构,转为数组。也就是说,只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组。
(3)yield*
yield*后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口。
let generator = function* () {
yield 1
yield* [2, 3, 4]
yield 5
}
var iterator = generator()
iterator.next() // { value: 1, done: false }
iterator.next() // { value: 2, done: false }
iterator.next() // { value: 3, done: false }
iterator.next() // { value: 4, done: false }
iterator.next() // { value: 5, done: false }
iterator.next() // { value: undefined, done: true }
(4)其他场合
由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口。下面是一些例子。
for...of
Array.from()
Map(), Set(), WeakMap(), WeakSet()(比如new Map([['a',1],['b',2]]))
Promise.all()
Promise.race()
4. 计算生成的数据结构
有些数据结构是在现有数据结构的基础上,计算生成的。比如,ES6 的数组、Set、Map 都部署了以下三个方法,调用后都返回遍历器对象。
- entries() 返回一个遍历器对象,用来遍历[键名, 键值]组成的数组。对于数组,键名就是索引值;对于 Set,键名与键值相同。Map 结构的 Iterator 接口,默认就是调用 entries 方法。
- keys() 返回一个遍历器对象,用来遍历所有的键名。
- values() 返回一个遍历器对象,用来遍历所有的键值。 这三个方法调用后生成的遍历器对象,所遍历的都是计算生成的数据结构。
let arr = ["a", "b", "c"]
for (let pair of arr.entries()) {
console.log(pair)
}
// [0, 'a']
// [1, 'b']
// [2, 'c']
5. 与其他遍历语法的比较
以数组为例,JavaScript 提供多种遍历语法。最原始的写法就是 for 循环。
for (var index = 0; index < myArray.length; index++) {
console.log(myArray[index])
}
这种写法比较麻烦,因此数组提供内置的 forEach 方法。
myArray.forEach(function (value) {
console.log(value)
})
这种写法的问题在于,无法中途跳出 forEach 循环,break 命令或 return 命令都不能奏效。
for...in 循环可以遍历数组的键名。
for (var index in myArray) {
console.log(myArray[index])
}
for...in 循环有几个缺点。
- 数组的键名是数字,但是 for...in 循环是以字符串作为键名“0”、“1”、“2”等等。
- for...in 循环不仅遍历数字键名,还会遍历手动添加的其他键,甚至包括原型链上的键。
- 某些情况下,for...in 循环会以任意顺序遍历键名。 总之,for...in 循环主要是为遍历对象而设计的,不适用于遍历数组。
for...of 循环相比上面几种做法,有一些显著的优点。
for (let value of myArray) {
console.log(value)
}
- 有着同 for...in 一样的简洁语法,但是没有 for...in 那些缺点。
- 不同于 forEach 方法,它可以与 break、continue 和 return 配合使用。
- 提供了遍历所有数据结构的统一操作接口。 下面是一个使用 break 语句,跳出 for...of 循环的例子。
for (var n of fibonacci) {
if (n > 1000) break
console.log(n)
}
上面的例子,会输出斐波纳契数列小于等于 1000 的项。如果当前项大于 1000,就会使用 break 语句跳出 for...of 循环。
21.async 函数
1. 含义
ES2017 标准引入了 async 函数,使得异步操作变得更加方便。
async 函数是什么?一句话,它就是 Generator 函数的语法糖。
前文有一个 Generator 函数,依次读取两个文件。
const fs = require("fs")
const readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function (error, data) {
if (error) return reject(error)
resolve(data)
})
})
}
const gen = function* () {
const f1 = yield readFile("/etc/fstab")
const f2 = yield readFile("/etc/shells")
console.log(f1.toString())
console.log(f2.toString())
}
上面代码的函数 gen 可以写成 async 函数,就是下面这样。
const asyncReadFile = async function () {
const f1 = await readFile("/etc/fstab")
const f2 = await readFile("/etc/shells")
console.log(f1.toString())
console.log(f2.toString())
}
一比较就会发现,async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await,仅此而已。
async 函数对 Generator 函数的改进,体现在以下四点。
(1)内置执行器。
Generator 函数的执行必须靠执行器,所以才有了 co 模块,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。
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 命令的语法糖。
2. 语法
async 函数的语法规则总体上比较简单,难点是错误处理机制。
返回 Promise 对象
async 函数返回一个 Promise 对象。
async 函数内部 return 语句返回的值,会成为 then 方法回调函数的参数。
async function f() {
return "hello world"
}
f().then((v) => console.log(v))
// "hello world"
上面代码中,函数 f 内部 return 命令返回的值,会被 then 方法回调函数接收到。
async 函数内部抛出错误,会导致返回的 Promise 对象变为 reject 状态。抛出的错误对象会被 catch 方法回调函数接收到。
async function f() {
throw new Error("出错了")
}
f().then(
(v) => console.log("resolve", v),
(e) => console.log("reject", e)
)
//reject Error: 出错了
Promise 对象的状态变化
async 函数返回的 Promise 对象,必须等到内部所有 await 命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到 return 语句或者抛出错误。也就是说,只有 async 函数内部的异步操作执行完,才会执行 then 方法指定的回调函数。
下面是一个例子。
async function getTitle(url) {
let response = await fetch(url)
let html = await response.text()
return html.match(/<title>([\s\S]+)<\/title>/i)[1]
}
getTitle("https://tc39.github.io/ecma262/").then(console.log)
// "ECMAScript 2017 Language Specification"
上面代码中,函数 getTitle 内部有三个操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行 then 方法里面的 console.log。
await 命令
任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行。
async function f() {
await Promise.reject("出错了")
await Promise.resolve("hello world") // 不会执行
}
上面代码中,第二个 await 语句是不会执行的,因为第一个 await 语句状态变成了 reject。
有时,我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个 await 放在 try...catch 结构里面,这样不管这个异步操作是否成功,第二个 await 都会执行。
async function f() {
try {
await Promise.reject("出错了")
} catch (e) {}
return await Promise.resolve("hello world")
}
f().then((v) => console.log(v))
// hello world
错误处理
如果 await 后面的异步操作出错,那么等同于 async 函数返回的 Promise 对象被 reject。
async function f() {
await new Promise(function (resolve, reject) {
throw new Error("出错了")
})
}
f()
.then((v) => console.log(v))
.catch((e) => console.log(e))
// Error:出错了
上面代码中,async 函数 f 执行后,await 后面的 Promise 对象会抛出一个错误对象,导致 catch 方法的回调函数被调用,它的参数就是抛出的错误对象。具体的执行机制,可以参考后文的“async 函数的实现原理”。
防止出错的方法,也是将其放在 try...catch 代码块之中。
async function f() {
try {
await new Promise(function (resolve, reject) {
throw new Error("出错了")
})
} catch (e) {}
return await "hello world"
}
3. async 函数的实现原理
async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里。
async function fn(args) {
// ...
}
// 等同于
function fn(args) {
return spawn(function* () {
// ...
})
}
所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器。
下面给出 spawn 函数的实现,基本就是前文自动执行器的翻版。
function spawn(genF) {
return new Promise(function (resolve, reject) {
const gen = genF()
function step(nextF) {
let next
try {
next = nextF()
} catch (e) {
return reject(e)
}
if (next.done) {
return resolve(next.value)
}
Promise.resolve(next.value).then(
function (v) {
step(function () {
return gen.next(v)
})
},
function (e) {
step(function () {
return gen.throw(e)
})
}
)
}
step(function () {
return gen.next(undefined)
})
})
}
4. 顶层 await
???
22.Class 的基本语法
提示
- class 用来定义一个类,是 ES5 的语法糖,class 写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已
- 类的所有方法都定义在类的 prototype 属性上面。包括构造函数
- 类的内部所有定义的方法,都是不可枚举的(non-enumerable)
- 类必须使用 new 调用,否则会报错。这是它跟普通构造函数的一个主要区别,后者不用 new 也可以执行
1.类的由来
ES6 的类,完全可以看作构造函数的另一种写法。
class Point {
// ...
}
typeof Point // "function"
Point === Point.prototype.constructor // true
构造函数的 prototype 属性,在 ES6 的“类”上面继续存在。事实上,类的所有方法都定义在类的 prototype 属性上面。
class Point {
constructor() {
// ...
}
toString() {
// ...
}
toValue() {
// ...
}
}
// 等同于
Point.prototype = {
constructor() {},
toString() {},
toValue() {},
}
上面代码中,constructor()、toString()、toValue()这三个方法,其实都是定义在 Point.prototype 上面。
因此,在类的实例上面调用方法,其实就是调用原型上的方法。
class B {}
const b = new B()
b.constructor === B.prototype.constructor // true
上面代码中,b 是 B 类的实例,它的 constructor()方法就是 B 类原型的 constructor()方法。
由于类的方法都定义在 prototype 对象上面,所以类的新方法可以添加在 prototype 对象上面。Object.assign()方法可以很方便地一次向类添加多个方法。
class Point {
constructor() {
// ...
}
}
Object.assign(Point.prototype, {
toString() {},
toValue() {},
})
prototype 对象的 constructor 属性,直接指向“类”的本身,这与 ES5 的行为是一致的。
Point.prototype.constructor === Point // true
另外,类的内部所有定义的方法,都是不可枚举的(non-enumerable)。
class Point {
constructor(x, y) {
// ...
}
toString() {
// ...
}
}
Object.keys(Point.prototype)
// []
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]
上面代码中,toString()方法是 Point 类内部定义的方法,它是不可枚举的。这一点与 ES5 的行为不一致。
var Point = function (x, y) {
// ...
}
Point.prototype.toString = function () {
// ...
}
Object.keys(Point.prototype)
// ["toString"]
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]
上面代码采用 ES5 的写法,toString()方法就是可枚举的。
2.实例属性的新写法
ES2022 为类的实例属性,又规定了一种新写法。实例属性现在除了可以定义在 constructor()方法里面的 this 上面,也可以定义在类内部的最顶层。
这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性。
class foo {
bar = "hello"
baz = "world"
constructor() {
// ...
}
}
上面的代码,一眼就能看出,foo 类有两个实例属性,一目了然。另外,写起来也比较简洁。
3.静态方法
提示
静态方法和属性只能通过类本身来调用,静态方法内部的 this 指向类本身。
类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上 static 关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。注意,如果静态方法包含 this 关键字,这个 this 指的是类,而不是实例。静态方法可以与非静态方法重名。父类的静态方法,可以被子类继承。
class Foo {
static classMethod() {
return "hello"
}
}
Foo.classMethod() // 'hello'
var foo = new Foo()
foo.classMethod()
// TypeError: foo.classMethod is not a function
4.私有属性的正式写法
提示
私有属性表示只能在类的内部进行调用;直接访问某个类不存在的私有属性会报错
ES2022 正式为 class 添加了私有属性,方法是在属性名之前使用#表示。
class IncreasingCounter {
#count = 0
get value() {
console.log("Getting the current value!")
return this.#count
}
increment() {
this.#count++
}
}
上面代码中,#count 就是私有属性,只能在类的内部使用(this.#count)。如果在类的外部使用,就会报错。
const counter = new IncreasingCounter()
counter.#count // 报错
counter.#count = 42 // 报错
上面示例中,在类的外部,读取或写入私有属性#count,都会报错。
注意,私有属性的属性名必须包括#,如果不带#,会被当作另一个属性。
class Point {
#x
constructor(x = 0) {
this.#x = +x
}
get x() {
return this.#x
}
set x(value) {
this.#x = +value
}
}
上面代码中,#x 就是私有属性,在 Point 类之外是读取不到这个属性的。由于井号#是属性名的一部分,使用时必须带有#一起使用,所以#x 和 x 是两个不同的属性。
5.new.target 属性
new 是从构造函数生成实例对象的命令。ES6 为 new 命令引入了一个 new.target 属性,该属性一般用在构造函数之中,返回 new 命令作用于的那个构造函数。如果构造函数不是通过 new 命令或 Reflect.construct()调用的,new.target 会返回 undefined,因此这个属性可以用来确定构造函数是怎么调用的。
function Person(name) {
if (new.target !== undefined) {
this.name = name
} else {
throw new Error("必须使用 new 命令生成实例")
}
}
// 另一种写法
function Person(name) {
if (new.target === Person) {
this.name = name
} else {
throw new Error("必须使用 new 命令生成实例")
}
}
var person = new Person("张三") // 正确
var notAPerson = Person.call(person, "张三") // 报错
上面代码确保构造函数只能通过 new 命令调用。
Class 内部调用 new.target,返回当前 Class。
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle)
this.length = length
this.width = width
}
}
var obj = new Rectangle(3, 4) // 输出 true
需要注意的是,子类继承父类时,new.target 会返回子类。
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle)
// ...
}
}
class Square extends Rectangle {
constructor(length, width) {
super(length, width)
}
}
var obj = new Square(3) // 输出 false
上面代码中,new.target 会返回子类。
利用这个特点,可以写出不能独立使用、必须继承后才能使用的类。
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error("本类不能实例化")
}
}
}
class Rectangle extends Shape {
constructor(length, width) {
super()
// ...
}
}
var x = new Shape() // 报错
var y = new Rectangle(3, 4) // 正确
上面代码中,Shape 类不能被实例化,只能用于继承。
注意,在函数外部,使用 new.target 会报错。
23.class 的继承
1.简介
Class 可以通过 extends 关键字实现继承,让子类继承父类的属性和方法。extends 的写法比 ES5 的原型链继承,要清晰和方便很多。
class Point {}
class ColorPoint extends Point {}
上面示例中,Point 是父类,ColorPoint 是子类,它通过 extends 关键字,继承了 Point 类的所有属性和方法。但是由于没有部署任何代码,所以这两个类完全一样,等于复制了一个 Point 类。
下面,我们在 ColorPoint 内部加上代码。
class Point {
/* ... */
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y) // 调用父类的constructor(x, y)
this.color = color
}
toString() {
return this.color + " " + super.toString() // 调用父类的toString()
}
}
上面示例中,constructor()方法和 toString()方法内部,都出现了 super 关键字。super 在这里表示父类的构造函数,用来新建一个父类的实例对象。
提示
ES6 规定,子类必须在 constructor()方法中调用 super(),否则就会报错。这是因为子类自己的 this 对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,添加子类自己的实例属性和方法。如果不调用 super()方法,子类就得不到自己的 this 对象。
为什么子类的构造函数,一定要调用 super()?原因就在于 ES6 的继承机制,与 ES5 完全不同。ES5 的继承机制,是先创造一个独立的子类的实例对象,然后再将父类的方法添加到这个对象上面,即“实例在前,继承在后”。ES6 的继承机制,则是先将父类的属性和方法,加到一个空的对象上面,然后再将该对象作为子类的实例,即“继承在前,实例在后”。这就是为什么 ES6 的继承必须先调用 super()方法,因为这一步会生成一个继承父类的 this 对象,没有这一步就无法继承父类。
如果子类没有定义 constructor()方法,这个方法会默认添加,并且里面会调用 super()。也就是说,不管有没有显式定义,任何一个子类都有 constructor()方法。
2.私有属性和私有方法的继承
父类所有的属性和方法,都会被子类继承,除了私有的属性和方法。
子类无法继承父类的私有属性,或者说,私有属性只能在定义它的 class 里面使用。
class Foo {
#p = 1
#m() {
console.log("hello")
}
}
class Bar extends Foo {
constructor() {
super()
console.log(this.#p) // 报错
this.#m() // 报错
}
}
上面示例中,子类 Bar 调用父类 Foo 的私有属性或私有方法,都会报错。
如果父类定义了私有属性的读写方法,子类就可以通过这些方法,读写私有属性。
class Foo {
#p = 1
getP() {
return this.#p
}
}
class Bar extends Foo {
constructor() {
super()
console.log(this.getP()) // 1
}
}
上面示例中,getP()是父类用来读取私有属性的方法,通过该方法,子类就可以读到父类的私有属性。
3.静态属性和静态方法的继承
父类的静态属性和静态方法,也会被子类继承。
class A {
static hello() {
console.log("hello world")
}
}
class B extends A {}
B.hello() // hello world
上面代码中,hello()是 A 类的静态方法,B 继承 A,也继承了 A 的静态方法。
注意,静态属性是通过软拷贝实现继承的。
class A {
static foo = 100
}
class B extends A {
constructor() {
super()
B.foo--
}
}
const b = new B()
B.foo // 99
A.foo // 100
上面示例中,foo 是 A 类的静态属性,B 类继承了 A 类,因此也继承了这个属性。但是,在 B 类内部操作 B.foo 这个静态属性,影响不到 A.foo,原因就是 B 类继承静态属性时,会采用浅拷贝,拷贝父类静态属性的值,因此 A.foo 和 B.foo 是两个彼此独立的属性。
但是,由于这种拷贝是浅拷贝,如果父类的静态属性的值是一个对象,那么子类的静态属性也会指向这个对象,因为浅拷贝只会拷贝对象的内存地址。
class A {
static foo = { n: 100 }
}
class B extends A {
constructor() {
super()
B.foo.n--
}
}
const b = new B()
B.foo.n // 99
A.foo.n // 99
上面示例中,A.foo 的值是一个对象,浅拷贝导致 B.foo 和 A.foo 指向同一个对象。所以,子类 B 修改这个对象的属性值,会影响到父类 A。
4.Object.getPrototypeOf()
Object.getPrototypeOf()方法可以用来从子类上获取父类。
class Point {
/*...*/
}
class ColorPoint extends Point {
/*...*/
}
Object.getPrototypeOf(ColorPoint) === Point
// true
因此,可以使用这个方法判断,一个类是否继承了另一个类。
23.Module 的语法
1.概述
提示
- ES6 模块之中,顶层的 this 指向 undefined,即不应该在顶层代码使用 this。
- commonJS 和 AMD 都是在运行时加载,加载时相当于整体引入模块,生成一个对象,引用对象上的属性和方法
- ES 模块是编译时加载,通过 export 显示指定输出的代码,再用 import 引入,可以做编译时优化
在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
// CommonJS模块
let { stat, exists, readfile } = require("fs")
// 等同于
let _fs = require("fs")
let stat = _fs.stat
let exists = _fs.exists
let readfile = _fs.readfile
上面代码的实质是整体加载 fs 模块(即加载 fs 的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。
ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。
// ES6模块
import { stat, exists, readFile } from "fs"
上面代码的实质是从 fs 模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。
由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
除了静态加载带来的各种好处,ES6 模块还有以下好处。
- 不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
- 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator 对象的属性。
- 不再需要对象作为命名空间(比如 Math 对象),未来这些功能可以通过模块提供。
2.export 命令
提示
- 输出变量、函数、类
- 使用 as 关键字对输出的变量重命名
模块功能主要由两个命令构成:export 和 import。export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。下面是一个 JS 文件,里面使用 export 命令输出变量。
// profile.js
export var firstName = "Michael"
export var lastName = "Jackson"
export var year = 1958
上面代码是 profile.js 文件,保存了用户信息。ES6 将其视为一个模块,里面用 export 命令对外部输出了三个变量。
export 的写法,除了像上面这样,还有另外一种。
// profile.js
var firstName = "Michael"
var lastName = "Jackson"
var year = 1958
export { firstName, lastName, year }
上面代码在 export 命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在 var 语句前)是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。
export 命令除了输出变量,还可以输出函数或类(class)。
export function multiply(x, y) {
return x * y
}
上面代码对外输出一个函数 multiply。
通常情况下,export 输出的变量就是本来的名字,但是可以使用 as 关键字重命名。
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
上面代码使用 as 关键字,重命名了函数 v1 和 v2 的对外接口。重命名后,v2 可以用不同的名字输出两次。
需要特别注意的是,export 命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 报错
export 1;
// 报错
var m = 1;
export m;
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量 m,还是直接输出 1。1 只是一个值,不是接口。正确的写法是下面这样。
// 写法一
export var m = 1
// 写法二
var m = 1
export { m }
// 写法三
var n = 1
export { n as m }
上面三种写法都是正确的,规定了对外的接口 m。其他脚本可以通过这个接口,取到值 1。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。
同样的,function 和 class 的输出,也必须遵守这样的写法。
// 报错
function f() {}
export f;
// 正确
export function f() {};
// 正确
function f() {}
export {f};
目前,export 命令能够对外输出的就是三种接口:函数(Functions), 类(Classes),var、let、const 声明的变量(Variables)。
另外,export 语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。
export var foo = "bar"
setTimeout(() => (foo = "baz"), 500)
上面代码输出变量 foo,值为 bar,500 毫秒之后变成 baz。
这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新,详见下文《Module 的加载实现》一节。
最后,export 命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的 import 命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。
function foo() {
export default "bar" // SyntaxError
}
foo()
上面代码中,export 语句放在函数之中,结果报错。
3.import 命令
使用 export 命令定义了模块的对外接口以后,其他 JS 文件就可以通过 import 命令加载这个模块。
// main.js
import { firstName, lastName, year } from "./profile.js"
function setName(element) {
element.textContent = firstName + " " + lastName
}
上面代码的 import 命令,用于加载 profile.js 文件,并从中输入变量。import 命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。
如果想为输入的变量重新取一个名字,import 命令要使用 as 关键字,将输入的变量重命名。
import { lastName as surname } from "./profile.js"
import 命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。
import { a } from "./xxx.js"
a = {} // Syntax Error : 'a' is read-only;
上面代码中,脚本加载了变量 a,对其重新赋值就会报错,因为 a 是一个只读的接口。但是,如果 a 是一个对象,改写 a 的属性是允许的。
import { a } from "./xxx.js"
a.foo = "hello" // 合法操作
上面代码中,a 的属性可以成功改写,并且其他模块也可以读到改写后的值。不过,这种写法很难查错,建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。
import 后面的 from 指定模块文件的位置,可以是相对路径,也可以是绝对路径。如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。
import { myMethod } from "util"
上面代码中,util 是模块文件名,由于不带有路径,必须通过配置,告诉引擎怎么取到这个模块。
注意,import 命令具有提升效果,会提升到整个模块的头部,首先执行。
foo()
import { foo } from "my_module"
上面的代码不会报错,因为 import 的执行早于 foo 的调用。这种行为的本质是,import 命令是编译阶段执行
的,在代码运行之前。
由于 import 是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
// 报错
import { 'f' + 'oo' } from 'my_module';
// 报错
let module = 'my_module';
import { foo } from module;
// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}
上面三种写法都会报错,因为它们用到了表达式、变量和 if 结构。在静态分析阶段,这些语法都是没法得到值的。
最后,import 语句会执行所加载的模块,因此可以有下面的写法。
import "lodash"
上面代码仅仅执行lodash模块
,但是不输入任何值。
如果多次重复执行同一句 import 语句,那么只会执行一次,而不会执行多次。
import "lodash"
import "lodash"
上面代码加载了两次 lodash,但是只会执行一次。
目前阶段,通过 Babel 转码,CommonJS 模块的 require 命令和 ES6 模块的 import 命令,可以写在同一个模块里面,但是最好不要这样做。因为 import 在静态解析阶段执行,所以它是一个模块之中最早执行的。下面的代码可能不会得到预期结果。
require("core-js/modules/es6.symbol")
require("core-js/modules/es6.promise")
import React from "React"
4.模块的整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
下面是一个 circle.js 文件,它输出两个方法 area 和 circumference。
// circle.js
export function area(radius) {
return Math.PI * radius * radius
}
export function circumference(radius) {
return 2 * Math.PI * radius
}
现在,加载这个模块。
// main.js
import { area, circumference } from "./circle"
console.log("圆面积:" + area(4))
console.log("圆周长:" + circumference(14))
上面写法是逐一指定要加载的方法,整体加载的写法如下。
import * as circle from "./circle"
console.log("圆面积:" + circle.area(4))
console.log("圆周长:" + circle.circumference(14))
注意,模块整体加载所在的那个对象(上例是 circle),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。
import * as circle from "./circle"
// 下面两行都是不允许的
circle.foo = "hello"
circle.area = function () {}
5.export default 命令
从前面的例子可以看出,使用 import 命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。
为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到 export default 命令,为模块指定默认输出。
// export-default.js
export default function () {
console.log("foo")
}
上面代码是一个模块文件 export-default.js,它的默认输出是一个函数。
其他模块加载该模块时,import 命令可以为该匿名函数指定任意名字。
// import-default.js
import customName from "./export-default"
customName() // 'foo'
上面代码的 import 命令,可以用任意名称指向 export-default.js 输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时 import 命令后面,不使用大括号。
7.export 与 import 的复合写法
如果在一个模块之中,先输入后输出同一个模块,import 语句可以与 export 语句写在一起。
export { foo, bar } from "my_module"
// 可以简单理解为
import { foo, bar } from "my_module"
export { foo, bar }
上面代码中,export 和 import 语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foo 和 bar 实际上并没有被导入当前模块,只是相当于对外转发了这两个接口
,导致当前模块不能直接使用 foo 和 bar。
8.import()
ES2020 提案 引入 import()函数,支持动态加载模块。
import(specifier)
上面代码中,import 函数的参数 specifier,指定所要加载的模块的位置。import 命令能够接受什么参数,import()函数就能接受什么参数,两者区别主要是后者为动态加载。
import()返回一个 Promise 对象。下面是一个例子。
const main = document.querySelector("main")
import(`./section-modules/${someVariable}.js`)
.then((module) => {
module.loadPageInto(main)
})
.catch((err) => {
main.textContent = err.message
})
import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与 import 语句不相同。import()类似于 Node.js 的 require()方法,区别主要是前者是异步加载,后者是同步加载。
24.module 的加载实现
1. ES6 模块与 CommonJS 模块的差异
提示
由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。
讨论 Node.js 加载 ES6 模块之前,必须了解 ES6 模块与 CommonJS 模块完全不同。
它们有三个重大差异。
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
- CommonJS 模块的 require()是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段。
第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的 import 有点像 Unix 系统的“符号连接”,原始值变了,import 加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
2. Node.js 的模块加载方法
2.1 概述
JavaScript 现在有两种模块。一种是 ES6 模块,简称 ESM;另一种是 CommonJS 模块,简称 CJS。
CommonJS 模块是 Node.js 专用的,与 ES6 模块不兼容。语法上面,两者最明显的差异是,CommonJS 模块使用 require()和 module.exports,ES6 模块使用 import 和 export。
它们采用不同的加载方案。从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。
Node.js 要求 ES6 模块采用.mjs 后缀文件名。也就是说,只要脚本文件里面使用 import 或者 export 命令,那么就必须采用.mjs 后缀名。Node.js 遇到.mjs 文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"。
如果不希望将后缀名改成.mjs,可以在项目的 package.json 文件中,指定 type 字段为 module。
{
"type": "module"
}
一旦设置了以后,该项目的 JS 脚本,就被解释成 ES6 模块。
# 解释成 ES6 模块
$ node my-app.js
如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs。如果没有 type 字段,或者 type 字段为 commonjs,则.js 脚本会被解释成 CommonJS 模块。
总结为一句话:.mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于 package.json 里面 type 字段的设置。
注意,ES6 模块与 CommonJS 模块尽量不要混用。require 命令不能加载.mjs 文件,会报错,只有 import 命令才可以加载.mjs 文件。反过来,.mjs 文件里面也不能使用 require 命令,必须使用 import。
2.2 package.json 的 main 字段
package.json 文件有两个字段可以指定模块的入口文件:main 和 exports。比较简单的模块,可以只使用 main 字段,指定模块加载的入口文件。
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}
上面代码指定项目的入口脚本为./src/index.js,它的格式为 ES6 模块。如果没有 type 字段,index.js 就会被解释为 CommonJS 模块。
然后,import 命令就可以加载这个模块。
// ./my-app.mjs
import { something } from "es-module-package"
// 实际加载的是 ./node_modules/es-module-package/src/index.js
上面代码中,运行该脚本以后,Node.js 就会到./node_modules 目录下面,寻找 es-module-package 模块,然后根据该模块 package.json 的 main 字段去执行入口文件。
这时,如果用 CommonJS 模块的 require()命令去加载 es-module-package 模块会报错,因为 CommonJS 模块不能处理 export 命令。
exports 字段的优先级高于 main 字段。它有多种用法。
(1)子目录别名
package.json 文件的 exports 字段可以指定脚本或子目录的别名。
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
上面的代码指定 src/submodule.js 别名为 submodule,然后就可以从别名加载这个文件。
import submodule from "es-module-package/submodule"
// 加载 ./node_modules/es-module-package/src/submodule.js
下面是子目录别名的例子。
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/": "./src/features/"
}
}
import feature from 'es-module-package/features/x.js';
// 加载 ./node_modules/es-module-package/src/features/x.js
如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。
// 报错
import submodule from "es-module-package/private-module.js"
// 不报错
import submodule from "./node_modules/es-module-package/private-module.js"
(2)main 的别名
exports 字段的别名如果是.,就代表模块的主入口,优先级高于 main 字段,并且可以直接简写成 exports 字段的值。
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}
由于 exports 字段只有支持 ES6 的 Node.js 才认识,所以可以用来兼容旧版本的 Node.js。
{
"main": "./main-legacy.cjs",
"exports": {
".": "./main-modern.cjs"
}
}
上面代码中,老版本的 Node.js (不支持 ES6 模块)的入口文件是 main-legacy.cjs,新版本的 Node.js 的入口文件是 main-modern.cjs。
(3)条件加载
利用.这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}
上面代码中,别名.的 require 条件指定 require()命令的入口文件(即 CommonJS 的入口),default 条件指定其他情况的入口(即 ES6 的入口)。
上面的写法可以简写如下。
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}
注意,如果同时还有其他别名,就不能采用简写,否则会报错。
{
// 报错
"exports": {
"./feature": "./lib/feature.js",
"require": "./main.cjs",
"default": "./main.js"
}
}
2.5 同时支持两种格式的模块
一个模块同时要支持 CommonJS 和 ES6 两种格式,也很容易。
如果原始模块是 ES6 格式,那么需要给出一个整体输出接口,比如 export default obj,使得 CommonJS 可以用 import()进行加载。
如果原始模块是 CommonJS 格式,那么可以加一个包装层。
import cjsModule from "../index.js"
export const foo = cjsModule.foo
上面代码先整体输入 CommonJS 模块,然后再根据需要输出具名接口。
你可以把这个文件的后缀名改为.mjs,或者将它放在一个子目录,再在这个子目录里面放一个单独的 package.json 文件,指明{ type: "module" }。
另一种做法是在 package.json 文件的 exports 字段,指明两种格式模块各自的加载入口。
"exports":{
"require": "./index.js",
"import": "./esm/wrapper.js"
}
上面代码指定 require()和 import,加载该模块会自动切换到不一样的入口文件。
2.7 加载路径
ES6 模块的加载路径必须给出脚本的完整路径,不能省略脚本的后缀名。import 命令和 package.json 文件的 main 字段如果省略脚本的后缀名,会报错。
// ES6 模块中将报错
import { something } from "./index"
为了与浏览器的 import 加载规则相同,Node.js 的.mjs 文件支持 URL 路径。
import "./foo.mjs?query=1" // 加载 ./foo 传入参数 ?query=1
上面代码中,脚本路径带有参数?query=1,Node 会按 URL 规则解读。同一个脚本只要参数不同,就会被加载多次,并且保存成不同的缓存。由于这个原因,只要文件名中含有:、%、#、?等特殊字符,最好对这些字符进行转义。
目前,Node.js 的 import 命令只支持加载本地模块(file:协议)和 data:协议,不支持加载远程模块。另外,脚本路径只支持相对路径,不支持绝对路径(即以/或//开头的路径)。
版权所有
版权归属:tuyongtao1