JS ES6新特性的注意点

一、块级作用域与函数声明

在ES5中,块级作用域中是不能写函数声明的。

1
2
3
4
5
if(false){
function f(){
console.log("123");
}
}

上面的例子在ES5中是非法的。但是浏览器并不一定会报错,因为没有完全执行这个规范。
而在ES6中,块级作用域里的函数声明相当于使用了let关键字,在块级作用域外是无法调用的。
举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
function f(){
console.log(123);
}
{
if(false){
function f(){
console.log(321);
}

}
f();
}

上面的代码,在ES5中执行会打印321,因为浏览器对这段代码的解析是:

1
2
3
4
5
6
7
{
function f(){
console.log(321);
}
if(false){}
f();
}

而在ES6的浏览器中,虽然按规范应该打印外层作用域的123,但实际上会报错。因为浏览器为了兼容老版本,仍然不遵守ES6的规范,使用的是下面的规则:

  1. 允许在块级作用域内声明函数。
  2. 函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
  3. 同时,函数声明还会提升到所在的块级作用域的头部。

    所以,ES6浏览器中的实际代码是这样的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    var f = undefined;
    if(false){
    f = function(){
    console.log(321);
    }
    }
    f();
    }

这才导致实际结果是f is not a function错误。

因此,虽然ES6支持块级作用域声明函数,但还是尽量不要这么做。

二、对象的解构赋值究竟把值赋给了谁?

ES6出现了一个很方便的特性:解构赋值。它的写法如下:

1
2
3
let [x,y,z] = [1,2,3];
let {a,b,c} = {a:1,b:2,c:3};
//这时,x=a=1,y=b=2,z=c=3

例子中对象的解构赋值写法是一种简写,实际上应该是:

1
let {a:a,b:b,c:c} = {a:1,b:2,c:3};

分析{key:value}形式,key是用来匹配右侧对象的键名,而value才是真正被赋值的变量。

三、字符串的新方法

ES6中,对字符串扩展了新的方法:includes()、startsWith()和endsWith()。

  1. includes():返回布尔值,表示是否找到了参数字符串。
  2. startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
  3. endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。
    这三个方法都接收第二个参数,表示开始搜索的位置。而endsWith()的第二个参数表示的是在前n个字符组成的字符串中搜索。

此外还有repeat(n)方法,用来将字符串重复n次。如果n不是整数,会使用Math.floor()方法取整。如果是NaN,当作0处理,其他类型先转成number类型,使用的是parseInt方法。

还有两个实用的方法是padStart(length,str)和padEnd(length,str),能够在字符串前或尾填充指定字符串达到指定长度。如果省略第二个参数,默认为空格。

四、模板字符串

ES6中使用反引号强化字符串的表现,这被成为模板字符串。在模板字符串中,空格、换行都会得到保留。而且还能调用变量:${变量名}

同时,模板字符串还能用来当作函数参数。

1
2
3
4
function hello(msg){
console.log("hello "+msg);
}
hello`whiteyin`;//hello whiteyin

不过,如果模板字符串当作函数参数时有使用到变量,函数实际接收到的参数就不是一个转换后的字符串。以上面的例子来说:

1
2
3
4
var name = "whiteyin";
hello`我是${name}。`;
//相当于
hello(['我是','。'],name);

也就是说,模板字符串中的变量在计算后会依次被添加到最后一个参数上,而其他非变量的字符串会按顺序插入一个数组,这个数组始终是函数的第一个参数。

五、正则表达式的新修饰符

u修饰符

为了匹配超过\uFFFF的四字节unicode编码,可以使用u修饰符。

1
2
/𠮷{2}/.test('𠮷𠮷') // false
/𠮷{2}/u.test('𠮷𠮷') // true

y修饰符

y修饰符也称为粘连修饰符,与g修饰符功能类似,但是比它严格。

1
2
3
4
5
6
7
8
9
var s = 'aaa_aa_a';
var r1 = /a+/g;
var r2 = /a+/y;

r1.exec(s) // ["aaa"]
r2.exec(s) // ["aaa"]

r1.exec(s) // ["aa"]
r2.exec(s) // null

例子中,使用y修饰符匹配时只会匹配第一组aaa,再次匹配剩余字符串_aa_a时,因为第一个位置是_所以匹配失败。而g修饰符会将剩余字符串当作新串,仍能匹配到aa

实际上,y修饰符的设计目的就是使^匹配符全局有效。

六、对有穷和NaN判断的优化

ES6新增两个Number的方法isFinite和isNaN。之前已经有两个同名方法注册在全局下,我们用window.isFinite和window.isNaN来表示。

1
2
3
4
5
isFinite("123");//true
Number.isFinite("123");//false

isNaN("a");//true
Number.isNaN("a");//false

相比较与window.isFinite和window.isNaN,Number.isFinite和Number.isNaN会对所有不是number类型参数返回false。

七、函数参数默认值

ES5中想要给函数参数设置默认值,可以使用||运算符:

1
2
3
4
5
function f(x,y){
x = x||1;
y = y||"2";
console.log(x,y);
}

但是这样会导致一个问题,如果x或y的值是falsy值,则会取值错误:

1
f(3,"");//输出3,"2"

而在ES6中,可以使用默认参数语法:

1
2
3
function f(x=1,y="2"){
console.log(x,y);
}

这样的写法很方便,也很容易懂。但是会有一些规则要遵守:

  1. 不能在函数体中使用let或const再次声明与形参同名的变量:

    1
    2
    3
    4
    function f(x=1){
    let x;
    }
    f();//Identifier 'x' has already been declared
  2. 使用默认参数时不能有多个同名形参:

    1
    function f(x=1,x,y){}//Duplicate parameter name not allowed in this context
  3. 如果有默认值的形参后面没有设置默认值的参数,那么调用函数时不能省略这种参数:

    1
    2
    3
    function f(x,y=1,z){}
    f(1,,3);//报错
    f(1,undefined,3);//没问题

    使用默认参数会对函数的length属性有影响。因为length的值会变成没有默认值的参数个数。比如:

    1
    2
    function f(a,b,c=1){}
    f.length;//2

如果一个设置默认值的参数不是尾参数,那么它后面的所有参数都不会计入length中。

1
2
function f(c=1,a,b){}
f.length;//0

如果默认参数值是一个函数,这个函数可能形成了一个闭包,它记住的是函数所在的作用域。

1
2
3
4
5
6
7
8
9
10
var a = 1;
function f(){
var a = 2;
function g(h=()=>{console.log(a)}){
var a = 3;
h();
}
g();
}
f();//输出2

还有个更复杂的例子:

1
2
3
4
5
6
function f(x,y=()=>{x=3;}){
x=1;
y();
console.log(x);
}
f();//3

应该说,f()这个圆括号内形成一个作用域:

1
2
3
4
5
6
7
8
9
f(
{//块作用域
let x;
let y=function(){
x = 3;
};
return [x,y];
}
)

大概是这样吧,y相当于一个闭包函数,记住了x的引用,所以后面调用时会修改这个引用。

不过这种规则确实违反了我的正常认知,所以我觉得还是不要这么写比较好。

八、函数的名字

ES6的函数新增了一个name属性,用来读取函数的名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
//具名函数返回函数名
function f(){}
f.name;//f
//匿名函数赋给变量
var f = function(){}
f.name;//f
//具名函数赋给变量
var g = function f(){}
g.name;//f
//变量函数赋给变量
var f = function(){}
var g = f;
g.name;//f

总结一下就是name属性应该是跟函数地址绑定,如果这个地址的函数没有名字,那么在声明或赋值语句执行后会设置name属性值,此后不管是什么变量指向这个地址,都不会改变name属性的值。

九、数组空位

如果我们使用new Array(10);来创建一个数组,返回的是一个有10个空位的数组,length=10。这里空位的概念并不等同于undefined,因为空位没有值,而undefined是有值的。

在ES5中,数组方法对空位的处理是不一样的,可以总结如下:

  1. forEach(), filter(), reduce(), every() 和some()都会跳过空位。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // forEach方法
    [,'a'].forEach((x,i) => console.log(i)); // 1

    // filter方法
    ['a',,'b'].filter(x => true) // ['a','b']

    // every方法
    [,'a'].every(x => x==='a') // true

    // reduce方法
    [1,,2].reduce((x,y) => return x+y) // 3

    // some方法
    [,'a'].some(x => x !== 'a') // false
  2. map()会跳过空位,但会保留这个值

    1
    2
    // map方法
    [,'a'].map(x => 1) // [,1]
  3. join()和toString()会将空位视为undefined,而undefined和null会被处理成空字符串。

    1
    2
    3
    4
    5
    // join方法
    [,'a',undefined,null].join('#') // "#a##"

    // toString方法
    [,'a',undefined,null].toString() // ",a,,"

    在ES6新增的方法中,空位会被处理成undefined。尽管如此,还是应该拒绝出现空位的情况。

十、Object.is()

在ES6之前,比较两个值相等只能使用=====,这两种方法各有各的缺点。
首先,==会判断左右两边值的类型,如果不同则会类型转换。而===在判断-0和+0比较时会返回true,而NaN===NaN会返回false。

为了能够得到更符合人直觉的严格比较,ES6提出新的比较方法Object.is(),它与===表现类似,但是弥补了上述的两个缺点。如果浏览器没有支持这个方法,可以使用下面的代码polyfill:

1
2
3
4
5
6
7
8
9
10
11
Object.defineProperty(Object,"is",{
value:function(x,y){
if(x===y){
return x!==0 || 1/x===1/y;
}
return x!==x&&y!==y;
},
configurable:true,
enumerable:true,
writable:true
});

十一、super的this绑定

ES6中,super关键字指向当前对象的原型对象,同时它只能在对象的方法中调用,不能在其他地方使用。

如果要调用原型对象的属性:

1
2
3
4
5
6
7
8
9
10
var proto = {
foo:1
}
var obj = {
foo(){
console.log(super.foo);
}
}
Object.setPrototypeOf(obj,proto);
obj.foo();//1

但是如果要调用原型对象的方法,那会有一个this指向的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var proto = {
foo:1,
bar(){
console.log(this.foo);
}
}
var obj = {
foo:2,
bar(){
super.bar();
}
}
Object.setPrototypeOf(obj,proto);
obj.bar();//2

也就是说,super.bar实质上是Object.getPrototypeOf(this).bar.call(this);从而this仍指向当前对象。

十二、Symbol与Symbol.for

Symbol是ES6中新增的原始类型,用来表示独一无二的值。使用方法是const s = Symbol("123");。一般都要给Symbol函数传递一个字符串用来标记该Symbol变量的名字。如果传入的是对象,根据toPrimitive原则要调用toString方法。

尽管传入参数可以标记不同名的Symbol变量,但是同名的Symbol变量,相互之间并不是等价的。

1
2
3
const s1 = Symbol("1");
const newS1 = Symbol("1");
s1 === newS1;//false

也就是说,这种方法创建的Symbol并不是唯一命名的。
如果想要创建一个Symbol,并且如果之前已经有相同命名的Symbol则使用已有的Symbol,否则重新创建一个。可以使用Symbol.for()。

1
2
3
4
5
6
const s1 = Symbol("123");
const s2 = Symbol.for("123");
const s3 = Symbol.for("123");

s1 === s2;//false
s2 === s3;//true

这个例子表明,s2和s3这两个使用Symbol.for()创建的Symbol变量实质上是等价的。原理就是Symbol()创建的变量不会在全局中登记,而Symbol.for()创建的变量会在全局中登记,当下次调用Symbol.for()时,会检测是否登记过该变量。

1
2
const s1 = Symbol();//Symbol()
const s2 = Symbol.for();//Symbol(undefined)

另外,如果不给这两个函数传参,得到的值也是不一样的。

如果要获取某个登记过的Symbol值,可以使用Symbol.keyFor()方法。

1
2
3
4
5
const s = Symbol.for("123");
Symbol.keyFor(s);//123

const s2 = Symbol("123");
Symbol.keyFor(s2);//undefined

对于没有登记过的Symbol会返回undefined。传入其他类型参数会报错TypeError。

十三、Set和Map

Set中没有重复值,事实上这是因为Set的键名就是值。

1
2
3
4
5
6
7
8
9
10
11
12
const arr = [1,"2",{},[],null,undefined,true];
const set = new Set(arr);
set.forEach((key,value)=>console.log(key,"+",value));
/*输出
//1":"1
//1:1
//{}:{}
//[]:[]
//null":"null
//undefined":"undefined
//true":"true
*/

另一方面,Set中的键值对的顺序与添加时的顺序一致。

Map是对原始对象的升级,因为原始对象的属性名都是字符串类型,也就是字符串:值,而Map的属性名可以是任意值,也就是值:值

比较Set和Map的CURD接口:

类型 Set Map
新增/修改 add set
删除 delete delete和clear
查找 没有 get
检测存在 has has
默认遍历方法 values entries
元素个数 size属性 size属性

ES6给出的Set接口中并没有获取某一特定值的方法,感觉也没有这么个需求。毕竟键值相同,如果知道要找什么值,那为什么还要遍历Set去找这个值呢?反正就是没有这个需要。

关于WeakSet和WeakMap

这两个是弱引用的Set或Map,成员的键名只能是对象,因为WeakSet的键与值一样,所以它的成员只能是对象。如果这些对象在外部引用消除,垃圾回收了,WeakSet或WeakMap里的引用也会消除。也就是说,这两个数据类型里的引用会被垃圾回收机制忽视。由于不知道什么时候会垃圾回收,所以这两个数据结构可能每次返回值都不一样。因此,没有给他们遍历接口,也没有size属性。而利用这个特性,一旦键名对象不再被外部需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。

十四、生成器Generator

Generator是ES6提出的一种异步解决方案,也可以看成是一个状态机,可以生成一个遍历器对象。声明使用function*表达形式,内部的状态跳转使用yield关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//定义一个Generator函数
function* gen(){
console.log("第一次运行");
console.log("第一次中断");
yield 1;
console.log("第二次运行");
console.log("第二次中断");
yield 2;
console.log("第三次运行");
console.log("运行结束");
return 3;
}
//执行该函数,返回一个迭代器对象
const g = gen();
console.log(g);
//{[[GeneratorStatus]]: "suspended"},这里GeneratorStatus是该状态机当前的状态
//遍历方式一:执行next
g.next();//第一次运行 第一次中断 {value: 1, done: false}
g.next();//第二次运行 第二次中断 {value: 2, done: false}
g.next();//第三次运行 运行结束 {value: 3, done: true}

//遍历方式二:for...of
for(let obj of g){
console.log(obj);
}
/*第一次运行
第一次中断
1
第二次运行
第二次中断
2
第三次运行
运行结束*/

console.log(g);
//{[[GeneratorStatus]]: "closed"},最后状态改变了

可以看出几个特点:

  1. 每遍历一次,返回一个对象,有两个属性:value和done。value代表yield或return的返回值;done代表迭代器是否结束迭代。
  2. Generator返回的迭代器对象有一个内部属性GeneratorStatus,初始值为suspended,而在done属性为true后会被置为closed。
  3. 用for…of遍历迭代器对象会忽略最后return的返回值,或是说成done为true的对象的value值。
    Generate有三种方法:next()、throw()和return()。在我看来,后两种方法效果类似。
    首先,next()方法让Generate状态机的状态向前移动一步,可以传入一个参数,相当于上一个状态返回的结果,而这可以作为下一个状态的输入值。next()的返回值是一个对象。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function* gen(){
    var y = yield 1;
    console.log(y);
    }
    var g = gen();
    g.next();

    //如果第二次不传参数
    g.next();//undefined

    //如果第二次传入参数
    g.next("我是y");//我是y

这是因为,next方法执行后,遇到yield关键字,会执行它后面的表达式1。而var y = ...赋值语句并不会执行,而是等到下次调用时在赋值。但是等到下一次调用next时,因为不传参数就是传入undefined,相当于var y = undefined,所以打印出来的就是undefined。如果我们传入一个字符串”我是y”,那么相当于var y = "我是y";,也就打印出响应值。
我感觉Generator每一次状态转换都是对一个执行过程的分段与冻结,第一次调用next时在var y = ...处冻结,将yield 1的状态封存,1这个返回值由于没有变量接收所以无法引用。而第二次调用next时,从冻结处开始运行,原本var y = ...希望右边是1,但是因为这是上一个状态的事,所以在这个状态无法获取到。因此,需要next传入这个值。
可以理解成next是上一个状态与下一个状态的邮差,其参数是上一个状态要发送给下一个状态的邮件,大概就是这样。

接下来是throw(),调用throw方法后,会手动抛出一个错误,同时迭代器g的GeneratorStatus会被修改为closed。

1
2
3
4
5
6
7
8
9
10
11
12
function * gen(){
console.log("start");
yield 1;
console.log("end");
yield 2;
}

const g = gen();
g.next();//{value:1,done:false}
console.log(g);//{[[GeneratorStatus]]: "suspended"}
g.throw(new Error("出错了"));//Uncaught Error 出错了
console.log(g);//这一步没有打印,实际上应该是{[[GeneratorStatus]]: "closed"}

之所以最后一步执行,是因为上一步抛出错误,且没有catch语句捕获错误,所以程序中断。如果我们在Generator函数中加入try…catch语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function* gen(){
console.log("start");
yield 1;
console.log("second");
try{
yield 2;
}catch(e){
console.log("catch");
}
yield 3;
console.log("end");
}

const g = gen();
g.next();
//start
//{value:1}
g.next();
//second
//{value:2}
g.throw(new Error("hi"));
//catch
//{value:3}
console.log(g);
//{[[GeneratorStatus]]: "suspended"}
g.next();
//end
//{value:undefined,done:true}

当g.throw()被调用时,刚好是try块里的yield语句,这里抛出的错误new Error("hi")被catch捕获,因此程序没有中断,并且继续向下执行到yield 3,返回3,同时也没有设置{[[GeneratorStatus]]: "closed"}
看上去throw方法会在Generator下一个状态的开始处抛出一个错误,如果被捕获则继续执行,否则报错终止运行。

最后,return()方法是让Generator函数强制执行return,也就是相当于将yield替换成return关键字。当然,与return关键字的作用一样,return()方法会将迭代器的GeneratorStatus设置为closed。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* gen(){
yield console.log("start");
yield console.log("second");
yield console.log("end");
}
const g = gen();
g.next();
//start
//{done:false}
g.return("hi");
//{value:"hi",done:true}
console.log(g);
//{[GeneratorStatus]:"closed"}
g.next();
//{value: undefined, done: true}

这里的return()方法,会将Generator函数的后续状态全部抹除,跳到一个全新的终态。这也能解释为什么second、end字符串没有打印出来,而且迭代器的状态被修改为closed。

最后的最后,如果要将一个Generator插入另一个Generator,需要使用yield*表达式。
总结一下,我认为理解Generator需要理解DFA有穷自动机的概念,这样有很多特性就能想清楚了。

十五、Iterator

一个对象具有Iterator接口有以下几个条件:

  1. 有一个[Symbol.iterator]属性,该属性值为一个函数;
  2. 该函数返回值是一个对象,该对象具有next属性,值为一个函数;
  3. 该next函数有一个返回值,形式为{value:…,next:…}。

    或是使用Array.from将类数组对象转成数组。又或是将一个Generator函数赋值给[Symbol.iterator]。