ECMAScript
是JavaScript
语言的规范,从2011年的ES5
.1到2015年的ES6
跨度比较大,这个版本更新的内容也是最多的,由此,本文主要针对ES6
提出的新特性和新方法进行梳理归纳。
什么是ECMAScript
ECMAScript是一种由Ecma国际(前身为欧洲计算机制造商协会)在标准ECMA-262中定义的脚本语言规范。这种语言在万维网上应用广泛,它往往被称为JavaScript或JScript,但实际上后两者是ECMA-262标准的实现和扩展。
ECMAScript是由网景的布兰登·艾克开发的一种脚本语言的标准化规范;最初命名为Mocha,后来改名为LiveScript,最后重命名为JavaScript[1]。1995年12月,升阳与网景联合发表了JavaScript[2]。1996年11月,网景公司将JavaScript提交给欧洲计算机制造商协会进行标准化。ECMA-262的第一个版本于1997年6月被Ecma组织采纳。ECMAScript是由ECMA-262标准化的脚本语言的名称。
尽管JavaScript和JScript与ECMAScript兼容,但包含超出ECMAScript的功能[3]。
通常,js又有web端和node本地两种实现:
在浏览器端或者说web端,javaScript
包含了:
- ECMAScript。
- Web API,即
Bom
和Dom
两部分。
在Node.js
中的javaScript
包含了:
- ECMAScript。
- Node API,包含
fs
模块、net
模块等等。
所以说
javaScript
是ecmaScript
的实现与扩展,扩展的部分就是提供的各种api。
ECMAScript
的发展历程
从1997年发布以来,es的迭代版本信息主要为:
版本 | 发表日期 | 与前版本的差异 |
---|---|---|
1 | 1997年6月 | 首版 |
2 | 1998年6月 | 格式修正,以使得其形式与ISO/IEC16262国际标准一致 |
3 | 1999年12月 | 强大的正则表达式,更好的词法作用域链处理,新的控制指令,异常处理,错误定义更加明确,数据输出的格式化及其它改变 |
4 | 放弃 | 由于关于语言的复杂性出现分歧,第4版本被放弃,其中的部分成为了第5版本及Harmony的基础 |
5 | 2009年12月 | 新增“严格模式(strict mode)”,一个子集用作提供更彻底的错误检查,以避免结构出错。澄清了许多第3版本的模糊规范,并适应了与规范不一致的真实世界实现的行为。增加了部分新功能,如getters及setters,支持JSON以及在对象属性上更完整的反射[4][5][6][7][8] |
5.1 | 2011年6月 | ECMAScript标5.1版形式上完全一致于国际标准ISO/IEC 16262:2011。 |
6 | 2015年6月 | ECMAScript 2015(ES2015),第 6 版,最早被称作是 ECMAScript 6(ES6),添加了类和模块的语法,其他特性包括迭代器,Python风格的生成器和生成器表达式,箭头函数,二进制数据,静态类型数组,集合(maps,sets 和 weak maps),promise,reflection 和 proxies。作为最早的 ECMAScript Harmony 版本,也被叫做ES6 Harmony。 |
7 | 2016年6月 | ECMAScript 2016(ES2016),第 7 版,多个新的概念和语言特性[9] |
8 | 2017年6月 | ECMAScript 2017(ES2017),第 8 版,多个新的概念和语言特性[10] |
9 | 2018年6月 | ECMAScript 2018 (ES2018),第 9 版,包含了异步循环,生成器,新的正则表达式特性和 rest/spread 语法。 |
10 | 2019年6月 | ECMAScript 2019 (ES2019),第 10 版 |
11 | 2020年6月 | ECMAScript 2020 (ES2020),第 11 版 |
从上面的版本更迭中有个特殊的阶段,就是2011年的es5.1
到2015年的es6
,由于间隔年份较大,语法和使用特性上的变化也最大,ECMAScript2015
的新特性在ES5.1
的基础上做了大量的优化,所以这也是本文将要探讨的内容。
let
和const
与var
的区别
let
和const
都是ES6
新增的变量申明关键字。与var
最主要的不同之处在于,变量作用域的不同。
在ES6
之前,js
的作用域一般分为:全局作用域和函数作用域。这样,有时候我们在使用回调函数等场景时往往由于变量的作用域问题,而不得不使用闭包特性来解决变量属于函数作用域的问题。
典型的一个问题,在循环中闭包的使用:
1 | var elements = [{}, {}, {}] |
我们构建了elements的onClick
函数并手动触发,但我们发现,给自的打印结果都是3
,而不是我们预期的0, 1, 2
。
原因是赋值给onClick
函数的是闭包,而这个参数i
是函数作用域,闭包指向的也就是循环结束后最终的i
值。
解决这个问题,我们需要再使用一层闭包,将i
作为参数时的作用域不再是for
循环中的函数作用域,而属于当前闭包:
1 | function eleClick(elements, i) { |
这样,我们就创建了一个eleClick
函数来应用闭包特性,也就独立维护了参数i
的作用域,不再是外层函数的作用域。
当然,可能我们更常使用匿名闭包,但都是一个原理:
1 | var elements = [{}, {}, {}] |
但是,有了es6
提供的let
变量申明关键字,我们不需要再使用闭包。除了上面说的全局作用局和函数作用域,es6
新增了块级作用域的概念,即更小单位的作用域,一般的代码块都可以拥有自己的变量作用域,比如一个循环体内部,一个if else
代码块内部,一个try catch
代码块内部,代码块中的变量将不会自动变量提升,作用域只属于当前代码块,使用let
或者const
申明的变量就是块级作用域变量。
我们将上面的循环案例中的var
换成let
试试看:
1 | var elements = [{}, {}, {}] |
效果显而易见,我们只是将i
声明为块作用域,这时,i
是只能在循环这个代码块中访问的,所以变量也不会提升,自然不会在循环结束后保留i = 3
,而且在循环外部我们将无法再访问这个变量。
总结let
与var
的区别就是:作用域不同,let
申明的变量是块级作用域,而var
申明的变量是函数作用域,且能自动提升(即可以使用后申明)。
而const
也是块级作用域,与let
的区别是,const
是只读变量申明,即申明的时候就需要初始化变量,且后续不能进行赋值操作。
1 | if(true){ |
最佳实践,建议不再使用
var
,主用const
,搭配使用let
。
数组和对象的解构赋值
解构赋值是
ES6
提出的一种新语法,通过解构赋值, 可以将属性/值从对象/数组中取出,赋值给其他变量。
对象和数组逐个对应表达式,或称对象字面量和数组字面量,提供了一种简单的定义一个特定的数据组的方法。
1 | const arr = [1, 2, 3, 4, 5] |
解构赋值使用了相同的语法,不同的是在表达式左边定义了要从原变量中取出什么变量。
1 | const arr = [1, 2, 3, 4, 5] |
数组解构
上面的例子已经很浅显的表示了解构的基本语法,我们只需要在赋值表达式左边使用相同的装箱构造就能达到解构的目的。
左边的构造会和右边的数据一一对应,并逐一进行赋值。
有时候数组太长,我们只希望解构其中部分数据可以这样写:
1 | // 解构部分 |
有时候数组过长,可以将剩余数组赋值给一个变量:
1 | const arr = [1, 2, 3, 4, 5] |
还可以给解构时的变量设置默认值(当解构赋值的值为undefined
时会使用默认值):
1 | const arr = [1, 2] |
使用数组解构的特性,我们还能用来交换变量:
1 | let a = 1; |
注意,这里的
;
不写会报Cannot access ‘b’ before initialization的错误。算是一个小bug?
利用解构的特性,我们可以复制一个数组:
1 | const arr = [1, 2, 3] |
对象解构
和数组的解构一样,我们在表达式左边书写对象的封装表达即可:
1 | const obj = { |
注意,我们使用对象的键作为解构赋值的提取关键字和变量名,可能存在这样的情况,关键字已经被占用了:
1 | const name = 'Zoom' |
同样,我们也能设置默认值:
1 | const {name: name2 = '', age = 0} = obj |
有时候对象里嵌套了数组,我们同样能进行嵌套解构:
1 | // 示例对象 |
模板字符串
模板字面量是允许嵌入表达式的字符串字面量。你可以使用多行字符串和字符串插值功能。它们在ES2015规范的先前版本中被称为“模板字符串“。
我们使用符号 ` 将字符串包裹起来即可。
使用模板字符串表示多行字符串,能保留原有的格式:
1 | const str = |
更方便的地方在于,我们能使用插值表达式进行字符串构造:
1 | const name = 'Jinx', |
更高级的一种用法是:带标签的模板字符串,标签使您可以用函数解析模板字符串。
标签函数的第一个参数包含一个字符串值的数组。其余的参数与表达式相关。最后,你的函数可以返回处理好的的字符串(或者它可以返回完全不同的东西)。
1 | const person = 'Mike'; |
字符串的扩展方法
ES2015
新增三个字符串的原型方法:includes、startsWith、endsWith。
includes
这个方法可以帮你判断一个字符串是否包含另外一个字符串。
语法:str.includes(searchString[, position])
searchString:搜索的字符串
position:从当前字符串的哪个索引位置开始搜寻子字符串,默认值为
0
。
1 | 'Blue Whale'.includes('blue'); // return false |
注:和数组的includes
的用法一致。
startsWith
这个方法用来判断字符串是否以某个字符串开头。
语法:str.startWidth(searchString[, startPosition])
变量意义同
includs
一样。
1 | console.log('Blue Whale'.startsWith('Blue')) // true |
endsWith
这个方法用来判断字符串是否以某个字符串开头。
语法:str.startWidth(searchString[, endPosition])
不同的是,endPosition是规定结束的位置,默认为字符串长度。
1 | console.log('Blue Whale'.endsWith('ale')) // true |
参数默认值
在ES2015之前,我们常常需要在函数内部进行参数默认值赋值:
1 | function add(num1, num2){ |
有了默认参数值这种语法结构,我们可以更为简单的书写代码:
1 | function add(num1=0, num2=0){ |
值得注意的是,带有默认值的参数必须写在非默认参数的后面。
剩余参数
当我们申明一个未知参数数量的函数时,我们常常使用内置的类数组对象arguments
来接受所有参数。
1 | function add(){ |
而ES6提供了一种新的展开语法,使用...
可以让我们直接封装剩余参数:
1 | function add(num = 0, ...others){ |
剩余参数会将多余未接受的参数放到一个数组中,最为一个参数传递到函数中。
展开操作符...
上面我们已经提到了剩余参数就是使用...
来实现的。此外...
操作符还有别的用法。
比如我们想要循环打印一个数组,你可能会这样来操作:
1 | const arr = [1, 2, 3] |
有了展开操作符,我们可以直接这样写:
1 | const arr = [1, 2, 3] |
箭头函数
箭头函数表达式的语法比函数表达式更简洁,并且没有自己的
this
,arguments
,super
或new.target
。箭头函数表达式更适用于那些本来需要匿名函数的地方,并且它不能用作构造函数。
让你的代码更简洁优美:
1 | // 一般写法 |
没有单独的this
对象
在箭头函数出现之前,每一个新函数根据它是被如何调用的来定义这个函数的this值:
- 如果是该函数是一个构造函数,this指针指向一个新的对象
- 在严格模式下的函数调用下,this指向undefined
- 如果是该函数是一个对象的方法,则它的this指针指向这个对象
- 等等
this
被证明是令人厌烦的面向对象风格的编程。
1 | function Person() { |
而通过箭头函数来书写可以解决这个问题:
1 | function Person() { |
通过 call 或 apply 调用时需要注意:由于 箭头函数没有自己的this指针,通过 call()
或 apply()
方法调用一个函数时,只能传递参数,他们的第一个参数会被忽略。
1 | var adder = { |
其他需要注意的点:
箭头函数不绑定Arguments 对象。因此,
arguments
只是引用了封闭作用域内的。箭头函数不能用作构造器,和
new
一起用会抛出错误。箭头函数没有
prototype
属性。yield
关键字通常不能在箭头函数中使用(除非是嵌套在允许使用的函数内)。因此,箭头函数不能用作函数生成器。写法
1
2
3
4
5var func = x => x * x;
// 简写函数 省略return
var func = (x, y) => { return x + y; };
//常规编写 明确的返回值
对象字面量的增强
在ES6
之前,我们在构建对象时需要严格按照键值对的写法:key: value
,现在我们可以更简洁自由得书写:
1 | const name = 'Jinx'; |
Object的新增的一些方法
Object.assign
Object.assign()
方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。
1 | const target = { a: 1, b: 2 }; |
借此,我们可以用来拷贝一个对象:
1 | const objCopy = Object.assign({}, obj); |
但需要注意的是,针对深拷贝,需要使用其他办法,因为 Object.assign()
拷贝的是(可枚举)属性值。
Object.is
Object.is()
方法判断两个值是否为同一个值。
判断两个值是否相等,我们常常使用==
或者===
来判断,那这个Object.is
又有什么不同呢?
Object.is
的规则如下:
对于严格比较运算符(===
)来说,仅当两个操作数的类型相同且值相等为 true,而对于被广泛使用的比较运算符(==
)来说,会在进行比较之前,将两个操作数转换成相同的类型。
与==
运算不同。 ==
运算符在判断相等前对两边的变量(如果它们不是同一类型) 进行强制转换 (这种行为的结果会将 "" == false
判断为 true
), 而 Object.is
不会强制转换两边的值。
与===
运算也不相同。 ===
运算符 (也包括 ==
运算符) 将数字 -0
和 +0
视为相等 ,而将Number.NaN
与NaN
视为不相等.
简单来说,Object.is
与===
严格程度很接近,但不同在于-0
和+0
前者视为不等,后者视为相等;而对于NaN
则是前者视为相等,后者视为不等。
1 | console.log(+0 === -0) // true |
代理对象Proxy
Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。
proxy
是代理的意思,正如引用描述的一样,Proxy 对象用于定义基本操作的自定义行为,即我们可以使用Proxy来自定义基本操作,也就达到了监听数据变化的作用。
语法
1 const p = new Proxy(target, handler)
- target:被 Proxy 代理虚拟化的对象。它常被作为代理的存储后端。根据目标验证关于对象不可扩展性或不可配置属性的不变量(保持不变的语义)。要使用
Proxy
包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。- handler:包含捕捉器(trap)的占位符对象,可译为处理器对象。一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理
p
的行为。
handler
对象是一个容纳一批特定属性的占位符对象。它包含有 Proxy
的各个捕获器(trap)。
handler对象的方法
handler.get()
:属性读取操作的捕捉器。handler.set()
:属性设置操作的捕捉器。handler.deleteProperty()
:delete
操作符的捕捉器。handler.apply()
:函数调用的捕捉器。- 等等
应用
一个简单的示例:
1 | const p = new Proxy({}, { |
通过代理,你可以轻松地验证向一个对象的传值。
1 | const validator = { |
与Object.defineProperty
的对比
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
其实defineProperty
也是一种代理对象,但proxy
更为强大,实现的捕捉器更多。
此外,proxy
对数组的捕获有更好地支持,比如set
方法能够监听数组的push
等赋值的方法。
统一对象的操作API: Reflect
Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers的方法相同。
Reflect
不是一个函数对象,因此它是不可构造的。
当我们使用Proxy
代理对象时,没有进行处理器对象proxy.handles
的初始化也能正常使用代理对象,这是因为默认处理器对象中的方法默认都是Reflect
提供的静态方法,此外,Reflect
对象中还提供了一些对象的方法。具体参考:Reflect - MDN。
可能你在使用中发现Reflect
中的方法都可能有别的写法,比如下面一些常见的对象操作方法:
1 | const obj = { |
可以发现,既有操作符也有对象方法,使用起来比较混乱,我们可以使用Reflect
中的方法来替代,会更具语义化:
1 | const obj = { |
Promise
Promise 对象用于表示一个异步操作的最终完成 (或失败), 及其结果值。
Promise
对象是ES6提出的一个用于解决传统异步编程回调函数嵌套过深等问题而设计的,Promise
的内容比较多,本文不在此展开,而会在另一篇文章中详细介绍,你可以访问这里进行查看:TODO
或者你可以浏览官方说明:Promise - MDN。
类class
ECMAScript 2015 中引入的 JavaScript 类实质上是 JavaScript 现有的基于原型的继承的语法糖。类语法不会为JavaScript引入新的面向对象的继承模型。
类Class
实际上是个“特殊的函数”,就像你能够定义的函数表达式和函数声明一样,类语法有两个组成部分:类表达式和类声明。
类的出现只是相较于原型链,能让我们更好地理解和处理面向对象的逻辑关系,本质还是一个函数。
类的定义
类的定义有两种方法,类似函数的定义,我们使用关键字class
进行声明,或者使用类表达式:
1 | class Person { |
类声明和类表达式的主体都执行在严格模式下。比如,构造函数,静态方法,原型方法,getter
和setter
都在严格模式下执行。
构造函数
constructor方法是一个特殊的方法,这种方法用于创建和初始化一个由class
创建的对象。一个类只能拥有一个名为 “constructor”的特殊方法。如果类包含多个constructor
的方法,则将抛出 一个SyntaxError
。
一个构造函数可以使用 super
关键字来调用一个父类的构造函数。
原型方法
参见方法定义。
1 | class Person { |
静态方法
static
关键字用来定义一个类的一个静态方法。调用静态方法不需要实例化该类,但不能通过一个类实例调用静态方法。静态方法通常用于为一个应用程序创建工具函数。
1 | class Person { |
静态方法不需要实例化就能调用,实例化之后反而不能调用是因为,静态方法是直接绑定在类(或者说函数)上面的,而普通方法都在原型链上。
共有字段和私有字段(实验阶段功能)
公共和私有字段声明是JavaScript标准委员会TC39提出的实验性功能(第3阶段)。浏览器中的支持是有限的,但是可以通过Babel等系统构建后使用此功能。
上面的声明可以写成:
1 | class Person { |
私有字段特殊在于,只能在类内部使用,其子类是不能访问这些私有字段的,具体如何实现可参考下一章节。
继承
在类声明时使用关键字extends
可以继承一个类。
1 | class Person { |
注意,如果子类中定义了构造函数,那么它必须先调用 super()
才能使用 this
。
除了继承一般类,也可以继承传统的基于函数的“类”。
1 | function Person(name, age){ |
但类不能继承常规对象(不可构造的)。如果要继承常规对象,可以改用 Reflect.setPrototypeOf()
或者Object.setPrototypeOf()
(可设置对象的原型(即内部的 [[Prototype]]
属性)为另一个对象或 null
,如果操作成功返回 true
,否则返回 false
。)
警告:由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的
[[Prototype]]
在**各个**浏览器和 JavaScript 引擎上都是一个很慢的操作。
1 | const Person = { |
派生类Species
你可能希望在派生数组类 MyArray
中返回 Array
对象。这种 species 方式允许你覆盖默认的构造函数。
例如,当使用像map()
返回默认构造函数的方法时,您希望这些方法返回一个父Array
对象,而不是MyArray
对象。Symbol.species
符号可以让你这样做:
1 | class MyArray extends Array { |
多继承Mix-ins
请参考另一篇文章:Javascript中的多继承与混合类Mixin
利用ES5语法实现ES6的类Class
为了更好地理解类的实现原理,我们尝试使用ES5
的语法进行实现。
类声明的实现
首先是实现类的定义,类的本质还是一个函数,所以定义类就是在定义一个函数:
1 | class Person { |
有时候为了保证Person
类能正常创建实例,我们可以加上这样的检测函数:
1 | /** |
Constructor[Symbol.hasInstance](obj)
用于判断某对象是否为某构造器的实例。- 在函数的定义中,函数名本身就相当于构造器本身,而
this
指向该类的实例,本质上就是一个对象。
原型方法的实现
原型方法顾名思义,就是绑定在原型链上的函数,比如新增一个原型方法say
:
1 | function Person(name, age) { |
为了写得更严谨一些,我们可以使用代理对象(ES6可以用Proxy
,这里是ES5版本,所以我们还是使用Object.defineProperty
)来实现原型链的函数绑定:
1 | /** |
静态方法的实现
静态方法与原型方法的不同之处就在于,静态方法是直接绑定在构造器
上的,所以直接不用实例就能调用,比如我们把say
方法改为今天方法,只需要修改代理对象的即可:(Person.prototype -> Person)。
1 | var Person = (function (name, age) { |
我们发现,这里使用了类表达式来申明,这是因为静态函数不需要创建实例就能使用,所以我们在申明函数时就需要绑定好静态方法与构造函数之间的关系。
字段的绑定
属性也就是同方法的绑定没有太大区别,一般属性绑定在实例对象上,而静态属性直接绑定在构造函数上。
1 | const Person = (function(){ |
继承的实现
当谈到继承时,JavaScript 只有一种结构:对象。每个实例对象( object )都有一个私有属性(称之为 proto )指向它的构造函数的原型对象(prototype )。该原型对象也有一个自己的原型对象( proto ) ,层层向上直到一个对象的原型对象为
null
。根据定义,null
没有原型,并作为这个原型链中的最后一个环节。几乎所有 JavaScript 中的对象都是位于原型链顶端的
Object
的实例。
由于篇幅过长,会另外开启一篇文章进行梳理,可以参考这里TODO。
此外,我们也可以使用下面的地址将ES6
代码转化为ES5
代码,方便我们对比各个版本之间的写法:
集合Set对象
Set
对象是值的集合,你可以按照插入的顺序迭代它的元素。 Set中的元素只会出现一次,即 Set 中的元素是唯一的。
你可以把Set
理解为没有重复元素的数组。语法如下:
new Set([iterable])
你可以传入一个可迭代对象
iterable
作为参数,这样,集合就能接手并不重复地把迭代对象中的元素加入到集合实例中。
集合Set
的api
和数组类似,比如常用的一些api
:
1 | const s = new Set([1, 2, 3]); |
1 | const s = new Set([1, 2, 3]); |
注:
for of
循环也是ES6
提出的一种新循环语句,只要实现可迭代接口,就能遍历任意数据类型。详细请参考后文。
映射集合Map对象
Map
对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者原始值) 都可以作为一个键或一个值。
说到键值对对象,我们很容易想到js
最普遍的Object
对象就是一种键值对对象,那为什么还需要Map
呢?我们先看一个例子:
1 | const obj = { |
可以发现,Object
这种映射只是字符串 - 数据
的映射,不管我们传入什么值作为键,都会被转为字符串或者Symbol
(ES6新增的一种数据类型,见后文)。
但Map
是真正的数据之间的映射,Map
的之间的键可能是任意类型的对象(数据),语法如下:
new Map([iterable])
Iterable 可以是一个
数组
或者其他 iterable 对象,其元素为键值对(两个元素的数组,例如: [[ 1, ‘one’ ],[ 2, ‘two’ ]])。 每个键值对都会添加到新的 Map。null
会被当做undefined。
1 | let say = 11; |
一个Map对象在迭代时会根据对象中元素的插入顺序来进行 — 一个
for...of
循环在每次迭代后会返回一个形式为[key,value]的数组。
一些基本的api
,和Set
很类似:
- clear():清空集合。
- delete(key):按照键删除该映射关系,成功则返回true。
- entries():返回一个新的
Iterator
对象,它按插入顺序包含了Map对象中每个元素的[key, value]
数组
。 - forEach():按插入顺序,为
Map
对象里的每一键值对调用一次callbackFn函数。 - get(key):根据映射关系的键返回对应的值。
- set(key, value):设置Map对象中键的值。返回该Map对象。
- has(key):判断该键是否存在对应的映射关系,注意只要设置了映射关系,就算值为
undefined
也返回true。 - keys():按插入顺序返回集合键的
Iterator
对象。 - values():按插入顺序返回集合值的
Iterator
对象。
总结一下Map
和Object
的区别:
- | Map | Object |
---|---|---|
意外的键 | Map 默认情况不包含任何键。只包含显式插入的键。 |
一个 Object 有一个原型, 原型链上的键名有可能和你自己在对象上的设置的键名产生冲突。注意: 虽然 ES5 开始可以用 Object.create(null) 来创建一个没有原型的对象,但是这种用法不太常见。 |
键的类型 | 一个 Map 的键可以是任意值,包括函数、对象或任意基本类型。 |
一个Object 的键必须是一个 String 或是Symbol 。 |
键的顺序 | Map 中的 key 是有序的。因此,当迭代的时候,一个 Map 对象以插入的顺序返回键值。 |
一个 Object 的键是无序的注意:自ECMAScript 2015规范以来,对象确实保留了字符串和Symbol键的创建顺序; 因此,在只有字符串键的对象上进行迭代将按插入顺序产生键。 |
Size | Map 的键值对个数可以轻易地通过size 属性获取 |
Object 的键值对个数只能手动计算 |
迭代 | Map 是 iterable 的,所以可以直接被迭代。 |
迭代一个Object 需要以某种方式获取它的键然后才能迭代。 |
性能 | 在频繁增删键值对的场景下表现更好。 | 在频繁添加和删除键值对的场景下未作出优化。 |
新数据类型Symbol
新增的一种原始数据类型,读作“符号”。该类型的性质在于这个类型的值可以用来创建匿名的对象属性。
symbol 数据类型具有非常明确的目的,并且因为其功能性单一的优点而突出;一个 symbol 实例可以被赋值到一个左值变量,还可以通过标识符检查类型,这就是它的全部特性。
【目的】唯一性:在 JavaScript 运行时环境中,一个符号类型值可以通过调用函数
Symbol()
创建,这个函数动态地生成了一个匿名,唯一的值。【特点】匿名性:当一个 symbol 类型的值在属性赋值语句中被用作标识符,该属性(像这个 symbol 一样)是匿名的;并且是不可枚举的。它同样不会出现在 “
Object.getOwnPropertyNames()
” 的返回数组里。这个属性可以通过创建时的原始 symbol 值访问到,或者通过遍历 “Object.getOwnPropertySymbols()
” 返回的数组。
Symbol 类具有一些静态属性,对于匿名命名来说,这带有一点讽刺意味。这类属性只有几个; 它们是所谓的“众所周知”的 symbol。 它们是在某些内置对象中找到的某些特定方法属性的 symbol。 暴露出这些 symbol 使得可以直接访问这些行为;这样的访问可能是有用的,例如在定义自定义类的时候。 普遍的 symbol 的例子有:“Symbol.iterator
”用于类似数组的对象,“Symbol.search
”用于字符串对象。
Symbol()
函数及其创建的 symbol 值可能对设计自定义类的编程人员有用。 symbol 值提供了一种自定义类可以创建私有成员的方式(匿名,所以不能被继承下去),并维护一个仅适用于该类的 symbol 注册表。 在类定义中,动态创建的 symbol 值将保存到作用域变量中,该变量只能在类定义中私有地使用。 没有 token 字符串; 作用域变量起到 token 的等同作用。
举个简单的例子,文章前文对比过Object
和Map
区别,其中一点就是Object
常常会造成不确定的键导致数据覆盖。比如:
main.js
向外暴露出一个obj
对象,但其他模块是不知道该对象有哪些键的,如果使用到了相同的键就会发生不必要的意外,一般的做法是约定自己内部使用的键,加上特殊的标记,比如b.js
的做法:加上特殊前缀:b_
。
1 | // --- main.js |
但symbol
的出现很好地解决了这一问题:
1 | // --- main.js |
for...of
循环语句
for...of
语句是一种新的循环语句,更好地说法叫迭代器,能遍历可迭代对象的所有元素,理论上可以遍历所有类型的对象,只要实现了可迭代接口(详情见后文)。基本语法如下:
for (variable of iterable) {
//statements
}
- variable:在每次迭代中,将不同属性的值分配给变量。
- iterable:被迭代枚举其属性的对象。
比如数组的迭代,我们常常使用forEach
来进行操作,但该函数由于是回调函数操作,无法结束循环,所以我们可以换成for...of
来遍历数组:
1 | const arr = [1, 2, 3, 5, 8, 9, 11]; |
此外,我们还可以迭代一些常见的数据对象:
1 | console.log('----迭代字符串----') |
对于for...of
的循环,可以由break
, throw continue
或return
终止。在这些情况下,迭代器关闭。
我们可以发现,for...of
可以迭代很多内置对象的实例,包括for in
无法枚举的Set
,Map
这些对象,实际上:
所以,for...in
能迭代的是具有可枚举属性的对象,然后再根据属性来获取对象的值,而for...of
能迭代一切实现了可迭代接口的对象,我们能直接迭代数组这些内置对象,是因为这些对象的意见内置实现了可迭代接口。
可能你已经发现了,我们常常使用到的Object
对象反而无法进行迭代,如果我们需要我们的自定义对象能够迭代,还需要我们自行实现可迭代接口,请直接阅读下一章节。
可迭代接口
可迭代接口或者说可迭代协议,允许 JavaScript 对象定义或定制它们的迭代行为,例如,在一个
for..of
结构中,哪些值可以被遍历到。一些内置类型同时是内置可迭代对象,并且有默认的迭代行为,比如Array
或者Map
,而其他内置类型则不是(比如Object
))。
要成为可迭代对象, 一个对象必须实现 **@@iterator**
方法。这意味着对象(或者它原型链上的某个对象)必须有一个键为 @@iterator
的属性,可通过常量 Symbol.iterator
访问该属性:
属性 | 值 |
---|---|
[Symbol.iterator] |
返回一个符合迭代器协议的对象的无参数函数。 |
注:此函数可以是普通函数,也可以是生成器函数,以便在调用时返回迭代器对象。 在此生成器函数的内部,可以使用yield
提供每个条目。(生成器函数详情可参考下一章节)
迭代器协议
只有实现了一个拥有以下语义(semantic)的 next()
方法,一个对象才能成为迭代器:
属性 | 值 |
---|---|
next |
一个无参数函数,返回一个应当拥有以下两个属性的对象:done (boolean)如果迭代器可以产生序列中的下一个值,则为 false 。(这等价于没有指定 done 这个属性。)如果迭代器已将序列迭代完毕,则为 true 。这种情况下,value 是可选的,如果它依然存在,即为迭代结束之后的默认返回值。value 迭代器返回的任何 JavaScript 值。done 为 true 时可省略。next() 方法必须返回一个对象,该对象应当有两个属性: done 和 value ,如果返回了一个非对象值(比如 false 或 undefined ),则会抛出一个 TypeError 异常("iterator.next() returned a non-object value" )。 |
总结来说就是,实现迭代器接口就是,需要实现一个[Symbol.iterator]
函数,该函数必须实现了next
函数,该netx
函数必须返回具有done
和value
属性的对象。
可能描述有点长,我们来简单实现一下就明了了,假设有这么一个对象需要我们使用for...of
进行迭代,我们希望能使用迭代器返回对应的name
和age
:
1 | const obj = { |
如果直接使用迭代器是会报错的:
1 | for(let o of obj) { // TypeError: obj is not iterable |
第一步:实现迭代器函数[Symbol.iterator]
:
1 | const obj = { |
第二步:该函数需要实现一个next
函数:
1 | const obj = { |
第三步:next
函数必须返回具有done
和value
属性的对象:
1 | const obj = { |
这样,我们就能使用迭代语句进行遍历了:
1 | for(let o of obj) { |
为何要实现可迭代接口
在上面的使用场景中,我们不需要实现迭代器接口也能实现对应的功能,但由于模块化的开发越来越普遍,可能这个自定义对象是别人给的,或者说是需要给别人的,也就是不同的人在维护,假设你没有实现可迭代接口,那么使用的人就需要自己遍历数据,但是对象数据是可能发生变更的,比如上面的obj
又新增了一个skills
字段需要遍历,那么每个使用到这个对象的人都需要修改自己的遍历器,这是比较麻烦的。
但是实现了可迭代接口,每个使用到的人只需要使用相同的for...of
这样的迭代语句就能拿到迭代器设计好的数据,也就是对外暴露统一的迭代方法,而无需让使用者担心数据变化带来困扰。
可以发现,迭代语句
for...of
完全可以使用普通的for
或者while
等实现,但这相当于一种设计思想,实现统一的接口能为我们解决很多应用场景上的麻烦和带来使用上的便利。
生成器Generator
生成器对象是由一个 generator function 返回的,并且它符合可迭代协议和迭代器协议。
单词generator
为“发电机”的意思,生成器也是这个道理,生成器函数能源源不断的提供数据,直到return
结束。
定义一个生成器函数
生成器函数的语法十分简单,只需要把普通函数的声明关键字function
变成function*
或function *
即可:
1 | function* foo(){ |
我们打印发现,函数返回了一个生成器对象,并非return
语句后的结果。上面我们说到,生成器对象实现了可迭代协议,所以我们需要使用next
函数取出数据:
1 | console.log(foo().next()) // { value: 100, done: true } |
yield关键字
当然,如果只是这样的迭代器效果,就不能体现生成器的特点了,一般迭代器需要和yield
关键字搭配使用。yield
关键字使生成器函数执行暂停,yield
关键字后面的表达式的值返回给生成器的调用者。它可以被认为是一个基于生成器的版本的return
关键字。
我们改造一下上面的函数:
1 | function* foo(){ |
虽然函数体没有写
retrun
语句,但和普通函数一样,有一个默认的return undefined
。
生成器的应用
生成器的特点就是能让函数分步执行(暂停执行),我们可以利用这个特点实现一个发号器,比如你到银行排队区号:
1 | // 发号器 |
当使用者获取号码时就相当于调用一次生成器的next
方法。
此外,生成器还可以解决异步编程的回调函数嵌套过深的问题,这部分内容将会和Promise
对象进行统一梳理,感兴趣的可以参考这里进行查看:TODO。
模块化Modules
模块化Modules属于语言层面的模块化标准。
早期的javascript
作为页面脚本语言是很小的,作为了实现简单的交互效果,但随着web
的发展,js
代码逐渐变得复杂,代码之间的交互也变得频繁,相互调用的情况愈来愈多,模块化开发是大势所趋。
由于篇幅过长,这里不再展开叙述,感兴趣的可以参考官网的模块化Modules。
ES2016新特性
Array.prototype.includes
该方法是新增的一个数组原型方法,用于判断数组是否包含某个元素,我们之前的做法是使用indexOf
方法来获取元素对应的下标来判断元素是否存在,但这里还是有有一些区别:
1 | const arr = [NaN, -0, false, null]; |
指数运算符**
以前我们实现指数运算需要借助Math.pow
函数,现在可以直接使用**
运算符了:
1 | console.log(Math.pow(2, 3) == 2 ** 3) // true |
ES2017新特性
ES2017
新增了Object
对象的三个扩展方法,新增字符串的两个原型方法,以及一些小特性。
Object.values
**Object.values()**
方法返回一个给定对象自身的所有可枚举属性值的数组,值的顺序与使用for...in
循环的顺序相同 ( 区别在于 for-in 循环枚举原型链中的属性 )。
1 | const obj = { |
也就是对应Object.keys
方法用来获取key
一样。
Object.entries
同Object.values
一样,如果想要同时获得键和值的迭代对象,就可以使用entries
:
1 | const obj = { |
Object.getOwnPropertyDescriptors
上文有说过Object.assign
方法,可以用于复制多个对象并组合为一个新的对象,但只能浅复制,比如对象上的geter
和setter
就无法正常复制:
1 | const obj = { |
这是因为Object.assign
复制时只是把fullName
当成普通的属性进行复制了,并没有复制为真正的getter
。
我们通过Object.getOwnPropertyDescriptors
就可以拿到这些真正的描述信息,从而进行复制:
1 | const obj = { |
1 | const obj = { |
注意:Object.defineProperties进行的也还是浅拷贝,要实现对象的深拷贝一般需要进行对象遍历再逐一赋值。
String.prototype.padStart & String.prototype.padEnd
1 |