深入理解 ES6:https://oshotokill.gitbooks.io/understandinges6-simplified-chinese/content/?q=
函数
参数默认值如何影响 arguments 对象:
在 ES5 的非严格模式下, arguments 对象会反映出具名参数的变化:**
function mixArgs(first, second) {
console.log(first === arguments[0]);
console.log(second === arguments[1]);
first = "c";
second = "d";
console.log(first === arguments[0]);
console.log(second === arguments[1]);
}
mixArgs("a", "b");
// true
// true
// true
// true
在非严格模式下, arguments 对象总是会被更新以反映出具名参数的变化。因此当 first 与 second 变量被赋予新值时, arguments[0] 与 arguments[1] 也就相应地更新了,使得这里所有的 === 比较的结果都为 true 。
然而在 ES5 的严格模式下,关于 arguments 对象的这种混乱情况被消除了,它不再反映出具名参数的变化。在严格模式下重新使用上例中的函数:
function mixArgs(first, second) {
"use strict";
console.log(first === arguments[0]);
console.log(second === arguments[1]);
first = "c";
second = "d";
console.log(first === arguments[0]);
console.log(second === arguments[1]);
}
mixArgs("a", "b");
// true
// true
// false
// false
在使用 ES6 参数默认值的函数中, arguments 对象的表现总是会与 ES5 的严格模式一致,无论此时函数是否明确运行在严格模式下。参数默认值的存在触发了 arguments 对象与具名参数的分离。这是个细微但重要的细节,因为 arguments 对象的使用方式发生了变化。研究如下代码:
// 非严格模式
function mixArgs(first, second = "b") {
console.log(arguments.length);
console.log(first === arguments[0]);
console.log(second === arguments[1]);
first = "c";
second = "d";
console.log(first === arguments[0]);
console.log(second === arguments[1]);
}
mixArgs("a");
// 1
// true
// false
// false
// false
本例中 arguments.length 的值为 1 ,因为只给 mixArgs() 传递了一个参数。这也意味着 arguments[1] 的值是 undefined ,符合将单个参数传递给函数时的预期;这同时意味着 first 与 arguments[0] 是相等的。改变 first 和 second 的值不会对 arguments 对象造成影响,无论是否在严格模式下,所以你可以始终依据 arguments 对象来反映初始调用状态。
参数默认值表达式
let value = 5;
function getValue() {
return value++;
}
function add(first, second = getValue()) {
return first + second;
}
console.log(add(1, 1)); // 2
console.log(add(1)); // 6
console.log(add(1)); // 7
本例中 value 的初始值是 5 ,并且会随着对 getValue() 的每次调用而递增。首次调用 add(1) 返回的值为 6 ,再次调用则返回 7 ,因为 value 的值已经被增加了。由于 second 参数的默认值总是在 add() 函数被调用,而且未提供第二个参数时的情况下才被计算,因此就能随时更改该参数的值。
函数名
var doSomething = function doSomethingElse() {
// ...
};
var person = {
get firstName() {
return "Nicholas";
},
sayName: function() {
console.log(this.name);
}
};
console.log(doSomething.name); // "doSomethingElse"
console.log(person.sayName.name); // "sayName"
var descriptor = Object.getOwnPropertyDescriptor(person, "firstName");
console.log(descriptor.get.name); // "get firstName"
本例中的 doSomething.name 的值是 “doSomethingElse” ,因为该函数表达式自己拥有一个名称,并且此名称的优先级要高于赋值目标的变量名。 person.sayName() 的 name 属性值是 “sayName” ,正如对象字面量指定的那样。类似的, person.firstName 实际是个 getter 函数,因此它的名称是 “get firstName” ,以标明它的特征;同样, setter 函数也会带有 “set” 的前缀( getter 与 setter 函数都必须用 Object.getOwnPropertyDescriptor() 来检索)。
函数名称还有另外两个特殊情况。使用 bind() 创建的函数会在名称属性值之前带有 “bound” 前缀;而使用 Function 构造器创建的函数,其名称属性则会有 “anonymous” 前缀,正如此例:
var doSomething = function() {
// ...
};
console.log(doSomething.bind().name); // "bound doSomething"
console.log((new Function()).name); // "anonymous"
需要注意的是,函数的 name 属性值未必会关联到同名变量。 name 属性是为了在调试 时获得有用的相关信息,所以不能用 name 属性值去获取对函数的引用。
明确函数的双重用途
JS 为函数提供了两个不同的内部方法: [[Call]] 与 [[Construct]] ( [[Construct]] 就是指构造函数本身。) 。当函数未使用 new 进行调用时, [[call]] 方法会被执行,运行的是代码中显示的函数体。而当函数使用 new 进行调用时, [[Construct]] 方法则会被执行,负责创建一个被称为新目标的新的对象,并且使用该新目标作为 this 去执行函数体。拥有 [[Construct]] 方法的函数被称为构造器。
记住并不是所有函数都拥有 [[Construct]] 方法,因此不是所有函数都可以用 new 来调用。
如何检测函数是否通过 new 调用
在 ES5 中判断函数是不是使用了 new 来调用(即作为构造器),最流行的方式是使用
instanceof ,例如:
function Person(name) {
if (this instanceof Person) {
this.name = name; // 使用 new
} else {
throw new Error("You must use new with Person.");
}
}
var person = new Person("Nicholas");
var notAPerson = Person("Nicholas"); // 抛出错误
此处对 this 值进行了检查,来判断其是否为构造器的一个实例:若是,正常继续执行;否则抛出错误。这能奏效是因为 [[Construct]] 方法创建了 Person 的一个新实例并将其赋值给 this 。可惜的是,该方法并不绝对可靠,因为在不使用 new 的情况下 this 仍然可能是 Person 的实例,正如下例:
function Person(name) {
if (this instanceof Person) {
this.name = name; // 使用 new
} else {
throw new Error("You must use new with Person.");
}
}
var person = new Person("Nicholas");
var notAPerson = Person.call(person, "Michael"); // 奏效了!
调用 Person.call() 并将 person 变量作为第一个参数传入,这意味着将 Person 内部的 this 设置为了 person 。对于该函数来说,没有任何方法能将这种方式与使用 new 调用区分开来。
new.target 元属性:
为了解决这个问题, ES6 引入了 new.target 元属性。元属性指的是“非对象”(例如 new )上的一个属性,并提供关联到它的目标的附加信息。当函数的 [[Construct]] 方法被调用时, new.target 会被填入 new 运算符的作用目标,该目标通常是新创建的对象实例的构造器,并且会成为函数体内部的 this 值。而若 [[Call]] 被执行, new.target 的值则会是 undefined 。
function Person(name) {
if (typeof new.target !== "undefined") {
this.name = name; // 使用 new
} else {
throw new Error("You must use new with Person.");
}
}
var person = new Person("Nicholas");
var notAPerson = Person.call(person, "Michael"); // 出错!
块级函数
// ES6 behavior
if (true) {
console.log(typeof doSomething); // "function"
function doSomething() {
// ...
}
doSomething();
}
console.log(typeof doSomething); // "function"
在 es5 严格模式下使用块级函数会报错,但在 es 6中会被视为块级声明,并允许它在定义所在的代码块内部被访问。
es6 非严格模式下:
// ES6 behavior
// 注意这是在非严格模式下
if (true) {
console.log(typeof doSomething); // "function"
function doSomething() {
// ...
}
doSomething();
}
console.log(typeof doSomething); // "function"
ES6 在非严格模式下同样允许使用块级函数,但行为有细微不同。块级函数的作用域会被提升到所在函数或全局环境的顶部,而不是代码块的顶部。
es6 严格模式下:
"use strict";
if (true) {
console.log(typeof doSomething); // "function"
function doSomething() {
// ...
}
doSomething();
}
console.log(typeof doSomething); // "undefined"
块级函数会被提升到定义所在的代码块的顶部,因此 typeof doSomething 会返回 “function” ,即便该检查位于此函数定义位置之前。一旦 if 代码块执行完毕,doSomething() 也就不复存在。
箭头函数
ES6 最有意思的一个新部分就是箭头函数( arrow function )。箭头函数正如名称所示那样使用一个“箭头”( => )来定义,但它的行为在很多重要方面与传统的 JS 函数不同:
- 没有 this 、 super 、 arguments ,也没有 new.target 绑定: this 、 super 、arguments 、以及函数内部的 new.target 的值由所在的、最靠近的非箭头函数来决定。
- 不能被使用 new 调用: 箭头函数没有 [[Construct]] 方法,因此不能被用为构造函数,使用 new 调用箭头函数会抛出错误。
- 没有原型: 既然不能对箭头函数使用 new ,那么它也不需要原型,也就是没有 prototype 属性。
- 不能更改 this : this 的值在函数内部不能被修改,在函数的整个生命周期内其值会保持不变。
- 没有 arguments 对象: 既然箭头函数没有 arguments 绑定,你必须依赖于具名参数或剩余参数来访问函数的参数。
- 不允许重复的具名参数: 箭头函数不允许拥有重复的具名参数,无论是否在严格模式下;而相对来说,传统函数只有在严格模式下才禁止这种重复。
尾调用
尾调用优化允许某些函数的调用被优化,以保持更小的调用栈、使用更少的内存,并防止堆栈溢出。当能进行安全优化时,它会由引擎自动应用。不过你可以考虑重写递归函数,以便能够利用这种优化。
在 ES6 中对函数最有趣的改动或许就是一项引擎优化,它改变了尾部调用的系统。尾调用( tail call )指的是调用函数的语句是另一个函数的最后语句,就像这样:
function doSomething() {
return doSomethingElse(); // 尾调用
}
在 ES5 引擎中实现的尾调用,其处理就像其他函数调用一样:一个新的栈帧( stack frame )被创建并推到调用栈之上,用于表示该次函数调用。这意味着之前每个栈帧都被保留在内存中,当调用栈太大时会出问题。
es6 有何不同?
ES6 在严格模式下力图为特定尾调用减少调用栈的大小(非严格模式的尾调用则保持不变)。当满足以下条件时,尾调用优化会清除当前栈帧并再次利用它,而不是为尾调用创建新的栈帧**:
- 尾调用不能引用当前栈帧中的变量(意味着该函数不能是闭包);
2. 进行尾调用的函数在尾调用返回结果后不能做额外操作;
3. 尾调用的结果作为当前函数的返回值。
作为一个例子,下面代码满足了全部三个条件,因此能被轻易地优化:
"use strict";
function doSomething() {
// 被优化
return doSomethingElse();
}
下面的几种情况都是不允许的:
"use strict";
function doSomething() {
// 未被优化:缺少 return
doSomethingElse();
}
function doSomething() {
// 未被优化:调用并不在尾部
var result = doSomethingElse();
return result;
}
function doSomething() {
var num = 1,
func = () => num;
// 未被优化:此函数是闭包
return func();
}
对象
自有属性的枚举顺序
ES6 则严格定义了对象自有属性在被枚举时返回的顺序。这对 Object.getOwnPropertyNames() 与
Reflect.ownKeys (详见第十二章)如何返回属性造成了影响,还同样影响了 Object.assign() 处理属性的顺序。
for-in 循环的枚举顺序仍未被明确规定,因为并非所有的 JS 引擎都采用相同的方式。 而 Object.keys() 和 JSON.stringify() 也使用了与 for-in 一样的枚举顺序。
自有属性枚举时基本顺序如下:
1. 所有的数字类型键,按升序排列。
2. 所有的字符串类型键,按被添加到对象的顺序排列。
3. 所有的符号类型(详见第六章)键,也按添加顺序排列。
var obj = {
a: 1,
0: 1,
c: 1,
2: 1,
b: 1,
1: 1
};
obj.d = 1;
console.log(Object.getOwnPropertyNames(obj).join("")); // "012acbd"
Object.getOwnPropertyNames() 方法按 0 、 1 、 2 、 a 、 c 、 b 、 d 的顺序返回了 obj 对象的属性。注意,数值类型的键会被合并并排序,即使这未遵循在对象字面量中的顺序。字符串类型的键会跟在数值类型的键之后,按照被添加到 obj 对象的顺序,在对象字面量中定义的键会首先出现,接下来是此后动态添加到对象的键。
更强大的原型
对象原型的实际值被存储在一个内部属性 [[Prototype]] 上, Object.getPrototypeOf() 方法会返回此属性存储的值,而 Object.setPrototypeOf() 方法则能够修改该值。不过,使用 [[Prototype]] 属性的方式还不止这些。
使用 super 引用的简单原型访问
let person = {
getGreeting() {
return "Hello";
}
};
let dog = {
getGreeting() {
return "Woof";
}
};
let friend = {
// 使用 super 这里必须是 es6 的简写写法,不能是 es5 的这种:getGreeting: function() {}
getGreeting() {
return Object.getPrototypeOf(this).getGreeting.call(this) + ", hi!";
// 等价于下面的 super
// return super.getGreeting() + ", hi!";
}
};
// 将原型设置为 person
Object.setPrototypeOf(friend, person);
console.log(friend.getGreeting()); // "Hello, hi!"
console.log(Object.getPrototypeOf(friend) === person); // true
// 将原型设置为 dog
Object.setPrototypeOf(friend, dog);
console.log(friend.getGreeting()); // "Woof, hi!"
console.log(Object.getPrototypeOf(friend) === dog); // true
本例中 friend 上的 getGreeting() 调用了对象上的同名方法。 Object.getPrototypeOf() 方法确保了能调用正确的原型,并在其返回结果上附加了一个字符串;而附加的 call(this) 代码则能确保正确设置原型方法内部的 this 值。
调用原型上的方法时要记住使用 Object.getPrototypeOf() 与 .call(this) ,这有点复杂难懂,因此 ES6 才引入了 super 。简单来说, super 是指向当前对象的原型的一个指针,实际上就是 Object.getPrototypeOf(this) 的值。
调用 super.getGreeting() 等同于在上例的环境中使用 Object.getPrototypeOf(this).getGreeting.call(this) 。类似的,你能使用 super 引用来调用对象原型上的任何方法,只要这个引用是位于 es6 简写的方法之内。
正式的“方法”定义
在 ES6 之前,“方法”的概念从未被正式定义,它此前仅指对象的函数属性(而非数据属性)。 ES6 则正式做出了定义:方法是一个拥有 [[HomeObject]] 内部属性的函数,此内部属性指向该方法所属的对象。研究以下例子:
let person = {
// 方法
getGreeting() {
return "Hello";
}
};
// 并非方法
function shareGreeting() {
return "Hi!";
}
此例定义了拥有单个 getGreeting() 方法的 person 对象。由于 getGreeting() 被直接赋给了一个对象,它的 [[HomeObject]] 属性值就是 person 。 而另一方面, shareGreeting() 函数没有被指定 [[HomeObject]] 属性,因为它在被创建时并没有赋给一个对象。大多数情况下,这种差异并不重要,然而使用 super 引用时就完全不同了。
任何对 super 的引用都会使用 [[HomeObject]] 属性来判断要做什么。第一步是在 [[HomeObject]] 上调用 Object.getPrototypeOf() 来获取对原型的引用;接下来,在该原型上查找同名函数;最后,创建 this 绑定并调用该方法。这里有个例子:
let person = {
getGreeting() {
return "Hello";
}
};
// 原型为 person
let friend = {
getGreeting() {
return super.getGreeting() + ", hi!";
}
};
Object.setPrototypeOf(friend, person);
console.log(friend.getGreeting()); // "Hello, hi!"
调用 friend.getGreeting() 返回了一个字符串,也就是 person.getGreeting() 的返回值与
“, hi!” 的合并结果。此时 friend.getGreeting() 的 [[HomeObject]] 值是 friend ,并且 friend 的原型是 person ,因此 super.getGreeting() 就等价于 person.getGreeting.call(this) 。
可以看到我们可以使用 super 关键字来调用对象原型上的方法,所调用的方法会被设置好其内部的 this 绑定,以自动使用该 this 值来进行工作。
类
类声明:
class PersonClass {
// 等价于 PersonType 构造器
constructor(name) {
this.name = name;
}
// 等价于 PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
}
let person = new PersonClass("Nicholas");
person.sayName(); // 输出 "Nicholas"
es6 类与 es5 创建自定义类型的一些区别:
**
1. 类声明不会被提升,这与函数定义不同。类声明的行为与 let 相似,因此在程序的执行到达声明处之前,类会存在于暂时性死区内。
2. 类声明中的所有代码会自动运行在严格模式下,并且也无法退出严格模式。
3. 类的所有方法都是不可枚举的,这是对于自定义类型的显著变化,后者必须用 Object.defineProperty() 才能将方法改变为不可枚举。
4. 类的所有方法内部都没有 [[Construct]] ,因此使用 new 来调用它们会抛出错误。
5. 调用类构造器时不使用 new ,会抛出错误。
6. 试图在类的方法内部重写类名,会抛出错误。
es 6 类转换成下面的 es 5 的写法:
// 直接等价于 PersonClass
let PersonType2 = (function() {
"use strict";
const PersonType2 = function(name) {
// 确认函数被调用时使用了 new
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.name = name;
};
Object.defineProperty(PersonType2.prototype, "sayName", {
value: function() {
// 确认函数被调用时没有使用 new
if (typeof new.target !== "undefined") {
throw new Error("Method cannot be called with new.");
}
console.log(this.name);
},
enumerable: false,
writable: true,
configurable: true
});
return PersonType2;
})();
上面的实现注意三点:
- 首先要注意这里有两个 PersonType2 声明:一个在外部作用域的 let 声明,一个在 IIFE 内部的 const 声明。这就是为何在类(类的内部)的方法不能对类名进行重写、而类外部的代码则被允许。
class Foo {
constructor() {
Foo = "bar"; // 执行时抛出错误
}
}
// 但在类声明之后没问题
Foo = "baz";
另外,构造器函数检查了 new.target ,以保证被调用时使用了 new ,否则就抛出错误。
接下来,sayName() 方法被定义为不可枚举,并且此方法也检查了 new.target ,它则要保证在被调用时没有使用 new 。最后一步是将构造器函数返回出去。
类表达式:
let PersonClass = class {
// 等价于 PersonType 构造器
constructor(name) {
this.name = name;
}
// 等价于 PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
};
类表达式不需要在 class 关键字后使用标识符。除了语法差异,类表达式的功能等价于类声明。
同函数是一级公民一样,类也是一级公民:
function createObject(classDef) {
return new classDef();
}
let obj = createObject(
class {
sayHi() {
console.log("Hi!");
}
}
);
obj.sayHi(); // "Hi!"
此例中的 createObject() 函数被调用时接收了一个匿名类表达式作为参数,使用 new 创建了该类的一个实例,并将其返回出来。随后变量 obj 储存了所返回的实例。
类表达式的另一个有趣用途是立即调用类构造器,以创建单例( Singleton )。为此,你必须使用 new 来配合类表达式,并在表达式后面添加括号。例如:
let person = new (class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
})("Nicholas");
person.sayName(); // "Nicholas"
此处创建了一个匿名类表达式,并立即执行了它。此模式允许你使用类语法来创建单例,从而不留下任何可被探查的类引用(回忆一下 PersonClass 的例子,匿名类表达式只在类的内部创建了绑定,而外部无绑定)。类表达式后面的圆括号表示要调用前面的函数,并且还允许传入参数。
从表达式中派生类
在 ES6 中派生类的最强大能力,或许就是能够从表达式中派生类。只要一个表达式能够返回一个具有 [[Construct]] 属性以及原型的函数,你就可以对其使用 extends 。
extends 后面能接受任意类型的表达式(但必须返回具有[[Construct]]函数,否则报错),这带来了巨大可能性,例如动态地决定所要继承的类。例如:
let SerializableMixin = {
serialize() {
return JSON.stringify(this);
}
};
let AreaMixin = {
getArea() {
return this.length * this.width;
}
};
function mixin(...mixins) {
var base = function() {};
Object.assign(base.prototype, ...mixins);
return base;
}
class Square extends mixin(AreaMixin, SerializableMixin) {
constructor(length) {
super();
this.length = length;
this.width = length;
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x.serialize()); // "{"length":3,"width":3}"
此例使用了混入( mixin )而不是传统继承。 mixin() 函数接受代表混入对象的任意数量的参数,它创建了一个名为 base 的函数,并将每个混入对象的属性都赋值到新函数的原型上。此函数随后被返回,于是 Square 就能够对其使用 extends 关键字了。注意由于仍然使用了 extends ,你就必须在构造器内调用 super() 。
Square 的实例既有来自 AreaMixin 的 getArea() 方法,又有来自 SerializableMixin 的 serialize() 方法,这是通过原型继承实现的。 mixin() 函数使用了混入对象的所有自有属性,动态地填充了新函数的原型(记住:若多个混入对象拥有相同的属性,则只有最后添加的属性会被保留)。
任意表达式都能在 extends 关键字后使用,但并非所有表达式的结果都是一个有效的类。特别的,下列表达式类型会导致错误: null ; 生成器函数(详见第八章)。 试图使用结果为上述值的表达式来创建一个新的类实例,都会抛出错误,因为不存在[[Construct]] 可供调用。