React全家桶

程序员成长之旅 · 程序员成长之旅 · 45136 字

React全家桶

React简介

React是什么?

用于构建用户界面的JavaScript库.

React只关注界面, 或者说是视图

即, 是一个将数据渲染为HTMl视图的开源JavaScript库.

React开发者

由Facebook开发, 且开源

  1. 期初由Facebook软件工程师Jordan Walke创建

  2. 与2011年部署于Facebook的newsfeed

  3. 随后在2012年部署于Instagram

  4. 2013年5月宣布开源

React已开发10余年, 正在被大厂广泛使用

为什么学

  1. 原生JavaScript操作DOM繁琐, 效率低(DOM-API 操作UI)

  2. 使用JavaScript直接操作DOM, 浏览器会进行大量的重绘重排

  3. 原生JavaScript没有组件化模块方案, 代码复用率第

  4. 虽然JS可以模块化, 但是css和html没法模块化

React的特点

  1. 采用组件化模式, 声明式编码, 提高开发效率以及组件复用率

  2. 声明式编码: 我们告诉React指令, React自动完成指令需要完成的步骤

  3. 当然, 要告诉React的指令是提前规定好的, 我们要进行学习

  4. 在React Native中可以使用React语法进行移动端开发. 只懂JS也可以进行移动端开发

  5. 使用虚拟DOM + 优秀的Diffing算法, 尽量减少与真实DOM的交互

  6. DOM的增删改查都在虚拟DOM中进行, 每次增删改查操作先和旧虚拟DOM进行比较, 然后将有差异的部分应用在真实DOM

需要掌握的JavaScript基础知识

前置知识学习

判断this指向

this在JavaScript中表示当前执行代码的环境对象. 一般需要区分的是在函数中出现的this指向的对象是哪个. 主要场景有:

call、apply、bind的作用是改变函数运行时this的指向,所以先说清楚this。

以下是函数的调用方法:

方法调用模式:

当一个函数被保存为对象的一个方法时,如果调用表达式包含一个提取属性的动作,那么它就是被当做一个方法来调用,此时的this被绑定到这个对象。


var a = 1

var obj1 = {

a:2,

fn:function(){

console.log(this.a)

}

}

obj1.fn()//2

此时的this是指obj1这个对象,obj1.fn()实际上是obj1.fn.call(obj1),事实上谁调用这个函数,this就是谁。补充一下,DOM对象绑定事件也属于方法调用模式,因此它绑定的this就是事件源DOM对象。如:


document.addEventListener('click', function(e){

console.log(this);

setTimeout(function(){

console.log(this);

}, 200);

}, false);

点击页面,依次输出:document和window对象

解析:点击页面监听click事件属于方法调用,this指向事件源DOM对象,即obj.fn.apply(obj),setTimeout内的函数属于回调函数,可以这么理解,f1.call(null,f2),所以this指向window。

函数调用模式:

就是普通函数的调用,此时的this被绑定到window


function fn1(){

console.log(this)//window

}

fn1()

function fn1(){

function fn2(){

console.log(this)//window

}

fn2()

}

fn1()

var a = 1

var obj1 = {

a:2,

fn:function(){

console.log(this.a)

}

}

var fn1 = obj1.fn

fn1()//1

obj1.fn是一个函数function(){console.log(this.a)},此时fn1就是不带任何修饰的函数调用,function(){console.log(this.a)}.call(undefined),按理说打印出来的 this 应该就是 undefined 了吧,但是浏览器里有一条规则:

如果你传的 context 就 null 或者 undefined,那么 window 对象就是默认的 context(严格模式下默认 context 是 undefined)

因此上面的this绑定的就是window,它也被称为隐性绑定。

如果你希望打印出2,可以修改fn1()fn1.call(obj1),显示地绑定this为obj1


var a = 1

function f1(fn){

fn()

console.log(a)//1

}

f1(f2)

function f2(){

var a = 2

}

改写代码如下:


var a = 1

function f1(){

(function (){var a = 2})()

console.log(a)//1

}

f1()

仍旧是最普通的函数调用,f1.call(undefined),this指向window,打印出的是全局的a。

借此,我们终于可以解释为什么setTimeout总是丢失this了,因为它也就是一个回调函数而已。


setTimeout(function() {

console.log(this)//window

function fn(){

console.log(this)//window

}

fn()

}, 0);

构造器调用模式:

new一个函数时,背地里会将创建一个连接到prototype成员的新对象,同时this会被绑定到那个新对象上


function Person(name,age){

// 这里的this都指向实例

this.name = name

this.age = age

this.sayAge = function(){

console.log(this.age)

}

}

var dot = new Person('Dot',2)

dot.sayAge()//2

call

call 方法第一个参数是要绑定给this的值,后面传入的是一个参数列表。当第一个参数为null、undefined的时候,默认指向window。


var arr = [1, 2, 3, 89, 46]

var max = Math.max.call(null, arr[0], arr[1], arr[2], arr[3], arr[4])//89

可以这么理解:


obj1.fn()

obj1.fn.call(obj1);

fn1()

fn1.call(null)

f1(f2)

f1.call(null,f2)

看一个例子:


var obj = {

message: 'My name is: '

}

function getName(firstName, lastName) {

console.log(this.message + firstName + ' ' + lastName)

}

getName.call(obj, 'Dot', 'Dolby')

apply

apply接受两个参数,第一个参数是要绑定给this的值,第二个参数是一个参数数组。当第一个参数为null、undefined的时候,默认指向window。


var arr = [1,2,3,89,46]

var max = Math.max.apply(null,arr)//89

可以这么理解:


obj1.fn()

obj1.fn.apply(obj1);

fn1()

fn1.apply(null)

f1(f2)

f1.apply(null,f2)

是不是觉得和前面写的call用法很像,事实上apply 和 call 的用法几乎相同, 唯一的差别在于:当函数需要传递多个变量时, apply 可以接受一个数组作为参数输入, call 则是接受一系列的单独变量。

看一个例子:


var obj = {

message: 'My name is: '

}

function getName(firstName, lastName) {

console.log(this.message + firstName + ' ' + lastName)

}

getName.apply(obj, ['Dot', 'Dolby'])// My name is: Dot Dolby

可以看到,obj 是作为函数上下文的对象,函数 getName 中 this 指向了 obj 这个对象。参数 firstName 和 lastName 是放在数组中传入 getName 函数。

call和apply可用来借用别的对象的方法,这里以call()为例:


var Person1  = function () {

this.name = 'Dot';

}

var Person2 = function () {

this.getname = function () {

console.log(this.name);

}

Person1.call(this);

}

var person = new Person2();

person.getname();       // Dot

从上面我们看到,Person2 实例化出来的对象 person 通过 getname 方法拿到了 Person1 中的 name。因为在 Person2 中,Person1.call(this) 的作用就是使用 Person1 对象代替 this 对象,那么 Person2 就有了 Person1 中的所有属性和方法了,相当于 Person2 继承了 Person1 的属性和方法。

对于什么时候该用什么方法,其实不用纠结。如果你的参数本来就存在一个数组中,那自然就用 apply,如果参数比较散乱相互之间没什么关联,就用 call。像上面的找一组数中最大值的例子,当然是用apply合理。

bind

和call很相似,第一个参数是this的指向,从第二个参数开始是接收的参数列表。区别在于bind方法返回值是函数以及bind接收的参数列表的使用。


var obj = {

name: 'Dot'

}

function printName() {

console.log(this.name)

}

var dot = printName.bind(obj)

console.log(dot) // function () { … }

dot()  // Dot

bind 方法不会立即执行,而是返回一个改变了上下文 this 后的函数。而原函数 printName 中的 this 并没有被改变,依旧指向全局对象 window。


function fn(a, b, c) {

console.log(a, b, c);

}

var fn1 = fn.bind(null, 'Dot');

fn('A', 'B', 'C');            // A B C

fn1('A', 'B', 'C');           // Dot A B

fn1('B', 'C');                // Dot B C

fn.call(null, 'Dot');      // Dot undefined undefined

call 是把第二个及以后的参数作为 fn 方法的实参传进去,而 fn1 方法的实参实则是在 bind 中参数的基础上再往后排。

有时候我们也用bind方法实现函数珂里化,以下是一个简单的示例:


var add = function(x) {

return function(y) {

return x + y;

};

};

var increment = add(1);

var addTen = add(10);

increment(2);

// 3

addTen(2);

// 12

在低版本浏览器没有 bind 方法,我们也可以自己实现一个。


if (!Function.prototype.bind) {

Function.prototype.bind = function () {

var self = this,                        // 保存原函数

context = [].shift.call(arguments), // 保存需要绑定的this上下文

args = [].slice.call(arguments);    // 剩余的参数转为数组

return function () {                    // 返回一个新函数

self.apply(context, [].concat.call(args, [].slice.call(arguments)));

}

}

}

应用场景


var arr = [1,2,3,89,46]

var max = Math.max.apply(null,arr)//89

var min = Math.min.apply(null,arr)//1

var trueArr = Array.prototype.slice.call(arrayLike)

var arr1 = [1,2,3];

var arr2 = [4,5,6];

var total = [].push.apply(arr1, arr2);//6

// arr1 [1, 2, 3, 4, 5, 6]

// arr2 [4,5,6]

function isArray(obj){

return Object.prototype.toString.call(obj) == '[object Array]';

}

isArray([]) // true

isArray('dot') // false

function Person(name,age){

// 这里的this都指向实例

this.name = name

this.age = age

this.sayAge = function(){

console.log(this.age)

}

}

function Female(){

Person.apply(this,arguments)//将父元素所有方法在这里执行一遍就继承了

}

var dot = new Female('Dot',2)

function log(){

console.log.apply(console, arguments);

}

// 当然也有更方便的 var log = console.log()

总结

call、apply和bind函数存在的区别:

bind返回对应函数, 便于稍后调用; apply, call则是立即调用。

除此外, 在 ES6 的箭头函数下, call 和 apply 将失效, 对于箭头函数来说:


class

类是用于创建对象的模板。他们用代码封装数据以处理该数据。 JS 中的类建立在原型上,但也具有某些语法和语义未与 ES5 类相似语义共享。

定义类

实际上,类是“特殊的函数”,就像你能够定义的函数表达式函数声明一样,类语法有两个组成部分:类表达式类声明

函数表达式

let function_expression = function [name]([param1[, param2[, ..., paramN]]]) {

statements

};
函数声明

function name([param,[, param,[..., param]]]) {

[statements]

}
类声明

定义类的一种方法是使用类声明。要声明一个类,你可以使用带有class关键字的类名(这里是“Rectangle”)。


class Rectangle {

constructor(height, width) {

this.height = height;

this.width = width;

}

}

Copy to Clipboard

提升

函数声明类声明之间的一个重要区别在于,函数声明会提升,类声明不会。你首先需要声明你的类,然后再访问它,否则类似以下的代码将抛出ReferenceError


let p = new Rectangle(); // ReferenceError

class Rectangle {}

什么是提升


/**

* 正确的方式:先声明函数,再调用函数 (最佳实践)

*/

function catName(name) {

console.log("我的猫名叫 " + name);

}

catName("Tigger");

/*

以上代码的执行结果是:"我的猫名叫 Tigger"

*/

/**

* 不推荐的方式:先调用函数,再声明函数

*/

catName("Chloe");

function catName(name) {

console.log("我的猫名叫 " + name);

}

/*

代码执行的结果是:"我的猫名叫 Chloe"

*/

译者注:函数和变量相比,会被优先提升。这意味着函数会被提升到更靠前的位置。

只有声明被提升

JavaScript 只会提升声明,不会提升其初始化。如果一个变量先被使用再被声明和赋值的话,使用时的值是 undefined。参见例子:


console.log(num); // Returns undefined

var num;

num = 6;
类表达式

类表达式是定义类的另一种方法。类表达式可以命名或不命名。命名类表达式的名称是该类体的局部名称。(不过,可以通过类的 (而不是一个实例的) name 属性来检索它)。


// 未命名/匿名类

let Rectangle = class {

constructor(height, width) {

this.height = height;

this.width = width;

}

};

console.log(Rectangle.name);

// output: "Rectangle"

// 命名类

let Rectangle = class Rectangle2 {

constructor(height, width) {

this.height = height;

this.width = width;

}

};

console.log(Rectangle.name);

// 输出: "Rectangle2"

Copy to Clipboard

备注:表达式也同样受到类声明部分中提到的类型提升的限制。

类体和方法定义

一个类的类体是一对花括号/大括号 {} 中的部分。这是你定义类成员的位置,如方法或构造函数。

严格模式

类声明和类表达式的主体都执行在严格模式下。比如,构造函数,静态方法,原型方法,getter 和 setter 都在严格模式下执行。

什么是严格模式

备注:有时你会看到非严格模式,被称为“sloppy mode”。这不是一个官方术语,但以防万一,你应该意识到这一点。

ECMAScript 5严格模式是采用具有限制性 JavaScript 变体的一种方式,从而使代码隐式地脱离“马虎模式/稀松模式/懒散模式“(sloppy)模式。

严格模式不仅仅是一个子集:它的产生是为了形成与正常代码不同的语义。

不支持严格模式与支持严格模式的浏览器在执行严格模式代码时会采用不同行为。

所以在没有对运行环境展开特性测试来验证对于严格模式相关方面支持的情况下,就算采用了严格模式也不一定会取得预期效果。严格模式代码和非严格模式代码可以共存,因此项目脚本可以渐进式地采用严格模式。

严格模式对正常的 JavaScript 语义做了一些更改。

  1. 严格模式通过抛出错误来消除了一些原有静默错误

  2. 严格模式修复了一些导致 JavaScript 引擎难以执行优化的缺陷:有时候,相同的代码,严格模式可以比非严格模式下运行得更快

  3. 严格模式禁用了在 ECMAScript 的未来版本中可能会定义的一些语法。

如果你想改变你的代码,让其工作在具有限制性 JavaScript 环境中,请参阅转换成严格模式

调用严格模式

严格模式可以应用到整个脚本或个别函数中。不要在封闭大括弧 {} 内这样做,在这样的上下文中这么做是没有效果的。在 evalFunction 、内联事件处理属性、 WindowTimers.setTimeout() (en-US) 方法中传入的脚本字符串,其行为类似于开启了严格模式的一个单独脚本,它们会如预期一样工作。

为脚本开启严格模式

为整个脚本文件开启严格模式,需要在所有语句之前放一个特定语句 "use strict"; (或 'use strict';


// 整个脚本都开启严格模式的语法

"use strict";

var v = "Hi!  I'm a strict mode script!";

这种语法存在陷阱,有一个大型网站已经被它坑倒了:不能盲目的合并冲突代码。试想合并一个严格模式的脚本和一个非严格模式的脚本:合并后的脚本代码看起来是严格模式。反之亦然:非严格合并严格看起来是非严格的。合并均为严格模式的脚本或均为非严格模式的都没问题,只有在合并严格模式与非严格模式有可能有问题。建议按一个个函数去开启严格模式(至少在学习的过渡期要这样做).

您也可以将整个脚本的内容用一个函数包括起来,然后在这个外部函数中使用严格模式。这样做就可以消除合并的问题,但是这就意味着您必须要在函数作用域外声明一个全局变量。

为函数开启严格模式

同样的,要给某个函数开启严格模式,得把 "use strict"; (或 'use strict'; ) 声明一字不漏地放在函数体所有语句之前。


function strict() {

// 函数级别严格模式语法

'use strict';

function nested() {

return "And so am I!";

}

return "Hi!  I'm a strict mode function!  " + nested();

}

function notStrict() {

return "I'm not strict.";

}

更多特性请看: 严格模式 - JavaScript | MDN (mozilla.org)

构造函数

constructor方法是一个特殊的方法,这种方法用于创建和初始化一个由class创建的对象。一个类只能拥有一个名为 “constructor”的特殊方法。如果类包含多个constructor的方法,则将抛出 一个SyntaxError

一个构造函数可以使用 super 关键字来调用一个父类的构造函数。

原型方法

参见方法定义


class Rectangle {

// constructor

constructor(height, width) {

this.height = height;

this.width = width;

}

// Getter

get area() {

return this.calcArea()

}

// Method

calcArea() {

return this.height * this.width;

}

}

const square = new Rectangle(10, 10);

console.log(square.area);

// 100

Copy to Clipboard

静态方法

static 关键字用来定义一个类的一个静态方法。调用静态方法不需要实例化 (en-US)该类,但不能通过一个类实例调用静态方法。静态方法通常用于为一个应用程序创建工具函数。


class Point {

constructor(x, y) {

this.x = x;

this.y = y;

}

static displayName = "Point";

static distance(a, b) {

const dx = a.x - b.x;

const dy = a.y - b.y;

return Math.hypot(dx, dy);

}

}

const p1 = new Point(5, 5);

const p2 = new Point(10,10);

p1.displayName;

// undefined

p1.distance;

// undefined

console.log(Point.displayName);

// "Point"

console.log(Point.distance(p1, p2));

// 7.0710678118654755

Copy to Clipboard

用原型和静态方法绑定 this

当调用静态或原型方法时没有指定 this 的值,那么方法内的 this 值将被置为 undefined。即使你未设置 "use strict" ,因为 class 体内部的代码总是在严格模式下执行。


class Animal {

speak() {

return this;

}

static eat() {

return this;

}

}

let obj = new Animal();

obj.speak(); // Animal {}

let speak = obj.speak;

speak(); // undefined

Animal.eat() // class Animal

let eat = Animal.eat;

eat(); // undefined

Copy to Clipboard

如果上述代码通过传统的基于函数的语法来实现,那么依据初始的 this 值,在非严格模式下方法调用会发生自动装箱。若初始值是 undefinedthis 值会被设为全局对象。

严格模式下不会发生自动装箱,this 值将保留传入状态。


function Animal() { }

Animal.prototype.speak = function() {

return this;

}

Animal.eat = function() {

return this;

}

let obj = new Animal();

let speak = obj.speak;

speak(); // global object

let eat = Animal.eat;

eat(); // global object

Copy to Clipboard

实例属性

实例的属性必须定义在类的方法里:


class Rectangle {

constructor(height, width) {

this.height = height;

this.width = width;

}

}

静态的或原型的数据属性必须定义在类定义的外面。


Rectangle.staticWidth = 20;

Rectangle.prototype.prototypeWidth = 25;
字段声明

**警告:**公共和私有字段声明是 JavaScript 标准委员会TC39提出的实验性功能(第 3 阶段)。浏览器中的支持是有限的,但是可以通过Babel等系统构建后使用此功能。

公有字段声明

使用 JavaScript 字段声明语法,上面的示例可以写成:


class Rectangle {

height = 0;

width;

constructor(height, width) {

this.height = height;

this.width = width;

}

}

通过预先声明字段,类定义变得更加自我记录,并且字段始终存在。

在声明字段时,我们不需要像 letconstvar 这样的关键字。

正如上面看到的,这个字段可以用也可以不用默认值来声明。

私有字段声明

使用私有字段,可以按以下方式细化定义。


class Rectangle {

#height = 0;

#width;

constructor(height, width) {

this.#height = height;

this.#width = width;

}

}

从类外部引用私有字段是错误的。它们只能在类里面中读取或写入。通过定义在类外部不可见的内容,可以确保类的用户不会依赖于内部,因为内部可能在不同版本之间发生变化。

**备注:**私有字段仅能在字段声明中预先定义。

私有字段不能通过在之后赋值来创建它们,这种方式只适用普通属性。

更多信息,请看class fields.

使用 extends 扩展子类

extends 关键字在 类声明类表达式 中用于创建一个类作为另一个类的一个子类。


class Animal {

constructor(name) {

this.name = name;

}

speak() {

console.log(`${this.name} makes a noise.`);

}

}

class Dog extends Animal {

constructor(name) {

super(name); // 调用超类构造函数并传入 name 参数

}

speak() {

console.log(`${this.name} barks.`);

}

}

var d = new Dog('Mitzie');

d.speak();// 'Mitzie barks.'

Copy to Clipboard

如果子类中定义了构造函数,那么它必须先调用 super() 才能使用 this

也可以继承传统的基于函数的“类”:


function Animal (name) {

this.name = name;

}

Animal.prototype.speak = function () {

console.log(this.name + ' makes a noise.');

}

class Dog extends Animal {

speak() {

super.speak();

console.log(this.name + ' barks.');

}

}

var d = new Dog('Mitzie');

d.speak();//Mitzie makes a noise.  Mitzie barks.

Copy to Clipboard

请注意,类不能继承常规对象(不可构造的)。如果要继承常规对象,可以改用Object.setPrototypeOf()


var Animal = {

speak() {

console.log(this.name + ' makes a noise.');

}

};

class Dog {

constructor(name) {

this.name = name;

}

}

Object.setPrototypeOf(Dog.prototype, Animal);// 如果不这样做,在调用 speak 时会返回 TypeError

var d = new Dog('Mitzie');

d.speak(); // Mitzie makes a noise.

Copy to Clipboard

Species

你可能希望在派生数组类 MyArray 中返回 Array对象。这种 species 方式允许你覆盖默认的构造函数。

例如,当使用像map()返回默认构造函数的方法时,您希望这些方法返回一个父Array对象,而不是MyArray对象。Symbol.species 符号可以让你这样做:


class MyArray extends Array {

// Overwrite species to the parent Array constructor

static get [Symbol.species]() { return Array; }

}

var a = new MyArray(1,2,3);

var mapped = a.map(x => x * x);

console.log(mapped instanceof MyArray);

// false

console.log(mapped instanceof Array);

// true

Copy to Clipboard

使用 super 调用超类

super 关键字用于调用对象的父对象上的函数。


class Cat {

constructor(name) {

this.name = name;

}

speak() {

console.log(this.name + ' makes a noise.');

}

}

class Lion extends Cat {

speak() {

super.speak();

console.log(this.name + ' roars.');

}

}

Copy to Clipboard

Mix-ins / 混入

抽象子类或者 mix-ins 是类的模板。 一个 ECMAScript 类只能有一个单超类,所以想要从工具类来多重继承的行为是不可能的。子类继承的只能是父类提供的功能性。因此,例如,从工具类的多重继承是不可能的。该功能必须由超类提供。

一个以超类作为输入的函数和一个继承该超类的子类作为输出可以用于在 ECMAScript 中实现混合:


var calculatorMixin = Base => class extends Base {

calc() { }

};

var randomizerMixin = Base => class extends Base {

randomize() { }

};

Copy to Clipboard

使用 mix-ins 的类可以像下面这样写:


class Foo { }

class Bar extends calculatorMixin(randomizerMixin(Foo)) { }

ES6语法规范

ECMAScript 6 简介

网道(WangDoc.com),互联网文档计划

ECMAScript 6.0(以下简称 ES6)是 JavaScript 语言的下一代标准,已经在 2015 年 6 月正式发布了。它的目标,是使得 JavaScript 语言可以用来编写复杂的大型应用程序,成为企业级开发语言。

目录 [隐藏]

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)。日常场合,这两个词是可以互换的。

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 语言”。

语法提案的批准流程

任何人都可以向标准委员会(又称 TC39 委员会)提案,要求修改语言标准。

一种新的语法从提案到变成正式标准,需要经历五个阶段。每个阶段的变动都需要由 TC39 委员会批准。

一个提案只要能进入 Stage 2,就差不多肯定会包括在以后的正式标准里面。ECMAScript 当前的所有提案,可以在 TC39 的官方网站GitHub.com/tc39/ecma262查看。

本书的写作目标之一,是跟踪 ECMAScript 语言的最新进展,介绍 5.1 版本以后所有的新语法。对于那些明确或很有希望,将要列入标准的新语法,都将予以介绍。

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

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

配置文件.babelrc

Babel 的配置文件是.babelrc,存放在项目的根目录下。使用 Babel 的第一步,就是配置这个文件。

该文件用来设置转码规则和插件,基本格式如下。


{

"presets": [],

"plugins": []

}

presets字段设定转码规则,官方提供以下的规则集,你可以根据需要安装。


# 最新转码规则

$ npm install --save-dev @babel/preset-env

# react 转码规则

$ npm install --save-dev @babel/preset-react

然后,将这些规则加入.babelrc


{

"presets": [

"@babel/env",

"@babel/preset-react"

],

"plugins": []

}

注意,以下所有 Babel 工具和模块的使用,都必须先写好.babelrc

命令行转码

Babel 提供命令行工具@babel/cli,用于命令行转码。

它的安装命令如下。


$ npm install --save-dev @babel/cli

基本用法如下。


# 转码结果输出到标准输出

$ npx babel example.js

# 转码结果写入一个文件

# --out-file 或 -o 参数指定输出文件

$ npx babel example.js --out-file compiled.js

# 或者

$ npx babel example.js -o compiled.js

# 整个目录转码

# --out-dir 或 -d 参数指定输出目录

$ npx babel src --out-dir lib

# 或者

$ npx babel src -d lib

# -s 参数生成source map文件

$ npx babel src -d lib -s

babel-node

@babel/node模块的babel-node命令,提供一个支持 ES6 的 REPL 环境。它支持 Node 的 REPL 环境的所有功能,而且可以直接运行 ES6 代码。

首先,安装这个模块。


$ npm install --save-dev @babel/node

然后,执行babel-node就进入 REPL 环境。


$ npx babel-node

> (x => x * 2)(1)

2

babel-node命令可以直接运行 ES6 脚本。将上面的代码放入脚本文件es6.js,然后直接运行。


# es6.js 的代码

# console.log((x => x * 2)(1));

$ npx babel-node es6.js

2

@babel/register 模块

@babel/register模块改写require命令,为它加上一个钩子。此后,每当使用require加载.js.jsx.es.es6后缀名的文件,就会先用 Babel 进行转码。


$ npm install --save-dev @babel/register

使用时,必须首先加载@babel/register


// index.js

require('@babel/register');

require('./es6.js');

然后,就不需要手动对index.js转码了。


$ node index.js

2

需要注意的是,@babel/register只会对require命令加载的文件转码,而不会对当前文件转码。另外,由于它是实时转码,所以只适合在开发环境使用。

polyfill

Babel 默认只转换新的 JavaScript 句法(syntax),而不转换新的 API,比如IteratorGeneratorSetMapProxyReflectSymbolPromise等全局对象,以及一些定义在全局对象上的方法(比如Object.assign)都不会转码。

举例来说,ES6 在Array对象上新增了Array.from方法。Babel 就不会转码这个方法。如果想让这个方法运行,可以使用core-jsregenerator-runtime(后者提供generator函数的转码),为当前环境提供一个垫片。

安装命令如下。


$ npm install --save-dev core-js regenerator-runtime

然后,在脚本头部,加入如下两行代码。


import 'core-js';

import 'regenerator-runtime/runtime';

// 或者

require('core-js');

require('regenerator-runtime/runtime');

Babel 默认不转码的 API 非常多,详细清单可以查看babel-plugin-transform-runtime模块的definitions.js文件。

浏览器环境

Babel 也可以用于浏览器环境,使用@babel/standalone模块提供的浏览器版本,将其插入网页。


<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

<script type="text/babel">

// Your ES6 code

</script>

注意,网页实时将 ES6 代码转为 ES5,对性能会有影响。生产环境需要加载已经转码完成的脚本。

Babel 提供一个REPL 在线编译器,可以在线将 ES6 代码转为 ES5 代码。转换后的代码,可以直接作为 ES5 代码插入网页运行。

let 和 const 命令

网道(WangDoc.com),互联网文档计划

目录 [隐藏]

let 命令

基本用法

ES6 新增了let命令,用来声明变量。它的用法类似于var,但是所声明的变量,只在let命令所在的代码块内有效。


{

let a = 10;

var b = 1;

}

a // ReferenceError: a is not defined.

b // 1

上面代码在代码块之中,分别用letvar声明了两个变量。然后在代码块之外调用这两个变量,结果let声明的变量报错,var声明的变量返回了正确的值。这表明,let声明的变量只在它所在的代码块有效。

for循环的计数器,就很合适使用let命令。


for (let i = 0; i < 10; i++) {

// ...

}

console.log(i);

// ReferenceError: i is not defined

上面代码中,计数器i只在for循环体内有效,在循环体外引用就会报错。

下面的代码如果使用var,最后输出的是10


var a = [];

for (var i = 0; i < 10; i++) {

a[i] = function () {

console.log(i);

};

}

a[6](); // 10

上面代码中,变量ivar命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一次循环,变量i的值都会发生改变,而循环内被赋给数组a的函数内部的console.log(i),里面的i指向的就是全局的i。也就是说,所有数组a的成员里面的i,指向的都是同一个i,导致运行时输出的是最后一轮的i的值,也就是 10。

如果使用let,声明的变量仅在块级作用域内有效,最后输出的是 6。


var a = [];

for (let i = 0; i < 10; i++) {

a[i] = function () {

console.log(i);

};

}

a[6](); // 6

上面代码中,变量ilet声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。


for (let i = 0; i < 3; i++) {

let i = 'abc';

console.log(i);

}

// abc

// abc

// abc

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域(同一个作用域不可使用 let 重复声明同一个变量)。

不存在变量提升

var命令会发生“变量提升”现象,即变量可以在声明之前使用,值为undefined。这种现象多多少少是有些奇怪的,按照一般的逻辑,变量应该在声明语句之后才可以使用。

为了纠正这种现象,let命令改变了语法行为,它所声明的变量一定要在声明后使用,否则报错。


// var 的情况

console.log(foo); // 输出undefined

var foo = 2;

// let 的情况

console.log(bar); // 报错ReferenceError

let bar = 2;

上面代码中,变量foovar命令声明,会发生变量提升,即脚本开始运行时,变量foo已经存在了,但是没有值,所以会输出undefined。变量barlet命令声明,不会发生变量提升。这表示在声明它之前,变量bar是不存在的,这时如果用到它,就会抛出一个错误。

暂时性死区

只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。


var tmp = 123;

if (true) {

tmp = 'abc'; // ReferenceError

let tmp;

}

上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。

ES6 明确规定,如果区块中存在letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。


if (true) {

// TDZ开始

tmp = 'abc'; // ReferenceError

console.log(tmp); // ReferenceError

let tmp; // TDZ结束

console.log(tmp); // undefined

tmp = 123;

console.log(tmp); // 123

}

上面代码中,在let命令声明变量tmp之前,都属于变量tmp的“死区”。

“暂时性死区”也意味着typeof不再是一个百分之百安全的操作。


typeof x; // ReferenceError

let x;

上面代码中,变量x使用let命令声明,所以在声明之前,都属于x的“死区”,只要用到该变量就会报错。因此,typeof运行时就会抛出一个ReferenceError

作为比较,如果一个变量根本没有被声明,使用typeof反而不会报错。


typeof undeclared_variable // "undefined"

上面代码中,undeclared_variable是一个不存在的变量名,结果返回“undefined”。所以,在没有let之前,typeof运算符是百分之百安全的,永远不会报错。现在这一点不成立了。这样的设计是为了让大家养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。

有些“死区”比较隐蔽,不太容易发现。


function bar(x = y, y = 2) {

return [x, y];

}

bar(); // 报错

上面代码中,调用bar函数之所以报错(某些实现可能不报错),是因为参数x默认值等于另一个参数y,而此时y还没有声明,属于“死区”。如果y的默认值是x,就不会报错,因为此时x已经声明了。


function bar(x = 2, y = x) {

return [x, y];

}

bar(); // [2, 2]

另外,下面的代码也会报错,与var的行为不同。


// 不报错

var x = x;

// 报错

let x = x;

// ReferenceError: x is not defined

上面代码报错,也是因为暂时性死区。使用let声明变量时,只要变量在还没有声明完成前使用,就会报错。上面这行就属于这个情况,在变量x的声明语句还没有执行完成前,就去取x的值,导致报错”x 未定义“。

ES6 规定暂时性死区和letconst语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在 ES5 是很常见的,现在有了这种规定,避免此类错误就很容易了。

总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

不允许重复声明

let不允许在相同作用域内,重复声明同一个变量。


// 报错

function func() {

let a = 10;

var a = 1;

}

// 报错

function func() {

let a = 10;

let a = 1;

}

因此,不能在函数内部重新声明参数。


function func(arg) {

let arg;

}

func() // 报错

function func(arg) {

{

let arg;

}

}

func() // 不报错

块级作用域

为什么需要块级作用域?

ES5 只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景。

第一种场景,内层变量可能会覆盖外层变量。


var tmp = new Date();

function f() {

console.log(tmp);

if (false) {

var tmp = 'hello world';

}

}

f(); // undefined

上面代码的原意是,if代码块的外部使用外层的tmp变量,内部使用内层的tmp变量。但是,函数f执行后,输出结果为undefined,原因在于变量提升,导致内层的tmp变量覆盖了外层的tmp变量。

第二种场景,用来计数的循环变量泄露为全局变量。


var s = 'hello';

for (var i = 0; i < s.length; i++) {

console.log(s[i]);

}

console.log(i); // 5

上面代码中,变量i只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。

ES6 的块级作用域

let实际上为 JavaScript 新增了块级作用域。


function f1() {

let n = 5;

if (true) {

let n = 10;

}

console.log(n); // 5

}

上面的函数有两个代码块,都声明了变量n,运行后输出 5。这表示外层代码块不受内层代码块的影响。如果两次都使用var定义变量n,最后输出的值才是 10。

ES6 允许块级作用域的任意嵌套。


{{{{

{let insane = 'Hello World'}

console.log(insane); // 报错

}}}};

上面代码使用了一个五层的块级作用域,每一层都是一个单独的作用域。第四层作用域无法读取第五层作用域的内部变量。

内层作用域可以定义外层作用域的同名变量。


{{{{

let insane = 'Hello World';

{let insane = 'Hello World'}

}}}};

块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。


// IIFE 写法

(function () {

var tmp = ...;

...

}());

// 块级作用域写法

{

let tmp = ...;

...

}

块级作用域与函数声明

函数能不能在块级作用域之中声明?这是一个相当令人混淆的问题。

ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。


// 情况一

if (true) {

function f() {}

}

// 情况二

try {

function f() {}

} catch(e) {

// ...

}

上面两种函数声明,根据 ES5 的规定都是非法的。

但是,浏览器没有遵守这个规定,为了兼容以前的旧代码,还是支持在块级作用域之中声明函数,因此上面两种情况实际都能运行,不会报错。

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。


function f() { console.log('I am outside!'); }

(function () {

if (false) {

// 重复声明一次函数f

function f() { console.log('I am inside!'); }

}

f();

}());

上面代码在 ES5 中运行,会得到“I am inside!”,因为在if内声明的函数f会被提升到函数头部,实际运行的代码如下。


// ES5 环境

function f() { console.log('I am outside!'); }

(function () {

function f() { console.log('I am inside!'); }

if (false) {

}

f();

}());

ES6 就完全不一样了,理论上会得到“I am outside!”。因为块级作用域内声明的函数类似于let,对作用域之外没有影响。但是,如果你真的在 ES6 浏览器中运行一下上面的代码,是会报错的,这是为什么呢?


// 浏览器的 ES6 环境

function f() { console.log('I am outside!'); }

(function () {

if (false) {

// 重复声明一次函数f

function f() { console.log('I am inside!'); }

}

f();

}());

// Uncaught TypeError: f is not a function

上面的代码在 ES6 浏览器中,都会报错。

原来,如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式

注意,上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let处理。

根据这三条规则,浏览器的 ES6 环境中,块级作用域内声明的函数,行为类似于var声明的变量。上面的例子实际运行的代码如下。


// 浏览器的 ES6 环境

function f() { console.log('I am outside!'); }

(function () {

var f = undefined;

if (false) {

function f() { console.log('I am inside!'); }

}

f();

}());

// Uncaught TypeError: f is not a function

考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。


// 块级作用域内部的函数声明语句,建议不要使用

{

let a = 'secret';

function f() {

return a;

}

}

// 块级作用域内部,优先使用函数表达式

{

let a = 'secret';

let f = function () {

return a;

};

}

另外,还有一个需要注意的地方。ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。


// 第一种写法,报错

if (true) let x = 1;

// 第二种写法,不报错

if (true) {

let x = 1;

}

上面代码中,第一种写法没有大括号,所以不存在块级作用域,而let只能出现在当前作用域的顶层,所以报错。第二种写法有大括号,所以块级作用域成立。

函数声明也是如此,严格模式下,函数只能声明在当前作用域的顶层。


// 不报错

'use strict';

if (true) {

function f() {}

}

// 报错

'use strict';

if (true)

function f() {}

const 命令

基本用法

const声明一个只读的常量。一旦声明,常量的值就不能改变。


const PI = 3.1415;

PI // 3.1415

PI = 3;

// TypeError: Assignment to constant variable.

上面代码表明改变常量的值会报错。

const声明的变量不得改变值,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。


const foo;

// SyntaxError: Missing initializer in const declaration

上面代码表示,对于const来说,只声明不赋值,就会报错。

const的作用域与let命令相同:只在声明所在的块级作用域内有效。


if (true) {

const MAX = 5;

}

MAX // Uncaught ReferenceError: MAX is not defined

const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。


if (true) {

console.log(MAX); // ReferenceError

const MAX = 5;

}

上面代码在常量MAX声明之前就调用,结果报错。

const声明的常量,也与let一样不可重复声明。


var message = "Hello!";

let age = 25;

// 以下两行都会报错

const message = "Goodbye!";

const age = 30;

本质

const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。


const foo = {};

// 为 foo 添加一个属性,可以成功

foo.prop = 123;

foo.prop // 123

// 将 foo 指向另一个对象,就会报错

foo = {}; // TypeError: "foo" is read-only

上面代码中,常量foo储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。

下面是另一个例子。


const a = [];

a.push('Hello'); // 可执行

a.length = 0;    // 可执行

a = ['Dave'];    // 报错

上面代码中,常量a是一个数组,这个数组本身是可写的,但是如果将另一个数组赋值给a,就会报错。

如果真的想将对象冻结,应该使用Object.freeze方法。


const foo = Object.freeze({});

// 常规模式时,下面一行不起作用;

// 严格模式时,该行会报错

foo.prop = 123;

上面代码中,常量foo指向一个冻结的对象,所以添加新属性不起作用,严格模式时还会报错。

除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。


var constantize = (obj) => {

Object.freeze(obj);

Object.keys(obj).forEach( (key, i) => {

if ( typeof obj[key] === 'object' ) {

constantize( obj[key] );

}

});

};

ES6 声明变量的六种方法

ES5 只有两种声明变量的方法:var命令和function命令。ES6 除了添加letconst命令,后面章节还会提到,另外两种声明变量的方法:import命令和class命令。所以,ES6 一共有 6 种声明变量的方法。

顶层对象的属性

顶层对象,在浏览器环境指的是window对象,在 Node 指的是global对象。ES5 之中,顶层对象的属性与全局变量是等价的。


window.a = 1;

a // 1

a = 2;

window.a // 2

上面代码中,顶层对象的属性赋值与全局变量的赋值,是同一件事。

顶层对象的属性与全局变量挂钩,被认为是 JavaScript 语言最大的设计败笔之一。这样的设计带来了几个很大的问题,首先是没法在编译时就报出变量未声明的错误,只有运行时才能知道(因为全局变量可能是顶层对象的属性创造的,而属性的创造是动态的);其次,程序员很容易不知不觉地就创建了全局变量(比如打字出错);最后,顶层对象的属性是到处可以读写的,这非常不利于模块化编程。另一方面,window对象有实体含义,指的是浏览器的窗口对象,顶层对象是一个有实体含义的对象,也是不合适的。

ES6 为了改变这一点,一方面规定,为了保持兼容性,var命令和function命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。


var a = 1;

// 如果在 Node 的 REPL 环境,可以写成 global.a

// 或者采用通用方法,写成 this.a

window.a // 1

let b = 1;

window.b // undefined

上面代码中,全局变量avar命令声明,所以它是顶层对象的属性;全局变量blet命令声明,所以它不是顶层对象的属性,返回undefined

globalThis 对象

JavaScript 语言存在一个顶层对象,它提供全局环境(即全局作用域),所有代码都是在这个环境中运行。但是,顶层对象在各种实现里面是不统一的。

同一段代码为了能够在各种环境,都能取到顶层对象,现在一般是使用this关键字,但是有局限性。

综上所述,很难找到一种方法,可以在所有情况下,都取到顶层对象。下面是两种勉强可以使用的方法。


// 方法一

(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

变量的解构赋值

网道(WangDoc.com),互联网文档计划

目录 [隐藏]

数组的解构赋值

基本用法

ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

以前,为变量赋值,只能直接指定值。


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

另一种情况是不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。


let [x, y] = [1, 2, 3];

x // 1

y // 2

let [a, [b], d] = [1, [2, 3], 4];

a // 1

b // 2

d // 4

上面两个例子,都属于不完全解构,但是可以成功。

如果等号的右边不是数组(或者严格地说,不是可遍历的结构,参见《Iterator》一章),那么将会报错。


// 报错

let [foo] = 1;

let [foo] = false;

let [foo] = NaN;

let [foo] = undefined;

let [foo] = null;

let [foo] = {};

上面的语句都会报错,因为等号右边的值,要么转为对象以后不具备 Iterator 接口(前五个表达式),要么本身就不具备 Iterator 接口(最后一个表达式)。

对于 Set 结构,也可以使用数组的解构赋值。


let [x, y, z] = new Set(['a', 'b', 'c']);

x // "a"

事实上,只要某种数据结构具有Iterator 接口,都可以采用数组形式的解构赋值。


function* fibs() {

let a = 0;

let b = 1;

while (true) {

yield a;

[a, b] = [b, a + b];

}

}

let [first, second, third, fourth, fifth, sixth] = fibs();

sixth // 5

上面代码中,fibs是一个 Generator 函数(参见《Generator 函数》一章),原生具有 Iterator 接口。解构赋值会依次从这个接口获取值。

默认值

解构赋值允许指定默认值


let [foo = true] = [];

foo // true

let [x, y = 'b'] = ['a']; // x='a', y='b'

let [x, y = 'b'] = ['a', undefined]; // x='a', y='b'

注意,ES6 内部使用严格相等运算符(===),判断一个位置是否有值。所以,只有当一个数组成员严格等于undefined,默认值才会生效。


let [x = 1] = [undefined];

x // 1

let [x = 1] = [null];

x // null

上面代码中,如果一个数组成员是null,默认值就不会生效,因为null不严格等于undefined

如果默认值是一个表达式,那么这个表达式是惰性求值的即只有在用到的时候,才会求值


function f() {

console.log('aaa');

}

let [x = f()] = [1];

上面代码中,因为x能取到值所以函数f根本不会执行。上面的代码其实等价于下面的代码。


let x;

if ([1][0] === undefined) {

x = f();

} else {

x = [1][0];

}

默认值可以引用解构赋值的其他变量,但该变量必须已经声明。


let [x = 1, y = x] = [];     // x=1; y=1

let [x = 1, y = x] = [2];    // x=2; y=2

let [x = 1, y = x] = [1, 2]; // x=1; y=2

let [x = y, y = 1] = [];     // ReferenceError: y is not defined

上面最后一个表达式之所以会报错,是因为xy做默认值时,y还没有声明。

对象的解构赋值

简介

解构不仅可以用于数组,还可以用于对象。


let { foo, bar } = { foo: 'aaa', bar: 'bbb' };

foo // "aaa"

bar // "bbb"

对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值


let { bar, foo } = { foo: 'aaa', bar: 'bbb' };

foo // "aaa"

bar // "bbb"

let { baz } = { foo: 'aaa', bar: 'bbb' };

baz // undefined

上面代码的第一个例子,等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于undefined

如果解构失败,变量的值等于undefined


let {foo} = {bar: 'baz'};

foo // undefined

上面代码中,等号右边的对象没有foo属性,所以变量foo取不到值,所以等于undefined

对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量


// 例一

let { log, sin, cos } = Math;

// 例二

const { log } = console;

log('hello') // hello

上面代码的例一将Math对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。例二将console.log赋值到log变量。

如果变量名与属性名不一致,必须写成下面这样。


let { foo: baz } = { foo: 'aaa', bar: 'bbb' };

baz // "aaa"

let obj = { first: 'hello', last: 'world' };

let { first: f, last: l } = obj;

f // 'hello'

l // 'world'

这实际上说明,对象的解构赋值是下面形式的简写(参见《对象的扩展》一章)。


let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' };

也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量真正被赋值的是后者,而不是前者


let { foo: baz } = { foo: 'aaa', bar: 'bbb' };

baz // "aaa"

foo // error: foo is not defined

上面代码中,foo是匹配的模式,baz才是变量。真正被赋值的是变量baz,而不是模式foo

与数组一样,解构也可以用于嵌套结构的对象。


let obj = {

p: [

'Hello',

{ y: 'World' }

]

};

let { p: [x, { y }] } = obj;

x // "Hello"

y // "World"

注意,这时p是模式,不是变量,因此不会被赋值如果p也要作为变量赋值,可以写成下面这样


let obj = {

p: [

'Hello',

{ y: 'World' }

]

};

let { p, p: [x, { y }] } = obj;

x // "Hello"

y // "World"

p // ["Hello", {y: "World"}]

下面是另一个例子。


const node = {

loc: {

start: {

line: 1,

column: 5

}

}

};

let { loc, loc: { start }, loc: { start: { line }} } = node;

line // 1

loc  // Object {start: Object}

start // Object {line: 1, column: 5}

上面代码有三次解构赋值,分别是对locstartline三个属性的解构赋值。注意,最后一次对line属性的解构赋值之中,只有line是变量,locstart都是模式,不是变量。

下面是嵌套赋值的例子。


let obj = {};

let arr = [];

({ foo: obj.prop, bar: arr[0] } = { foo: 123, bar: true });

obj // {prop:123}

arr // [true]

如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。


// 报错

let {foo: {bar}} = {baz: 'baz'};

上面代码中,等号左边对象的foo属性,对应一个子对象。该子对象的bar属性,解构时会报错。原因很简单,因为foo这时等于undefined,再取子属性就会报错。

注意,对象的解构赋值可以取到继承的属性。


const obj1 = {};

const obj2 = { foo: 'bar' };

Object.setPrototypeOf(obj1, obj2);

const { foo } = obj1;

foo // "bar"

上面代码中,对象obj1的原型对象是obj2foo属性不是obj1自身的属性,而是继承自obj2的属性,解构赋值可以取到这个属性。

默认值

对象的解构也可以指定默认值。


var {x = 3} = {};

x // 3

var {x, y = 5} = {x: 1};

x // 1

y // 5

var {x: y = 3} = {};

y // 3

var {x: y = 3} = {x: 5};

y // 5

var { message: msg = 'Something went wrong' } = {};

msg // "Something went wrong"

默认值生效的条件是,对象的属性值严格等于undefined


var {x = 3} = {x: undefined};

x // 3

var {x = 3} = {x: null};

x // null

上面代码中,属性x等于null,因为nullundefined不严格相等,所以是个有效的赋值,导致默认值3不会生效。

注意点

(1)如果要将一个已经声明的变量用于解构赋值,必须非常小心。


// 错误的写法

let x;

{x} = {x: 1};

// SyntaxError: syntax error

上面代码的写法会报错,因为 JavaScript 引擎会将{x}理解成一个代码块,从而发生语法错误只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。


// 正确的写法

let x;

({x} = {x: 1});

上面代码将整个解构赋值语句,放在一个圆括号里面,就可以正确执行。关于圆括号与解构赋值的关系,参见下文。

(2)解构赋值允许等号左边的模式之中,不放置任何变量名。因此,可以写出非常古怪的赋值表达式。


({} = [true, false]);

({} = 'abc');

({} = []);

上面的表达式虽然毫无意义,但是语法是合法的,可以执行

(3)由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。


let arr = [1, 2, 3];

let {0 : first, [arr.length - 1] : last} = arr;

first // 1

last // 3

上面代码对数组进行对象解构。数组arr0键对应的值是1[arr.length - 1]就是2键,对应的值是3。方括号这种写法,属于“属性名表达式”(参见《对象的扩展》一章)。

字符串的解构赋值

字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组对象


const [a, b, c, d, e] = 'hello';

a // "h"

b // "e"

c // "l"

d // "l"

e // "o"

类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值


let {length : len} = 'hello';

len // 5

数值和布尔值的解构赋值

解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。


let {toString: s} = 123;

s === Number.prototype.toString // true

let {toString: s} = true;

s === Boolean.prototype.toString // true

上面代码中,数值和布尔值的包装对象都有toString属性,因此变量s都能取到值。

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefinednull无法转为对象,所以对它们进行解构赋值,都会报错。


let { prop: x } = undefined; // TypeError

let { prop: y } = null; // TypeError

函数参数的解构赋值

函数的参数也可以使用解构赋值。


function add([x, y]){

return x + y;

}

add([1, 2]); // 3

上面代码中,函数add的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量xy。对于函数内部的代码来说,它们能感受到的参数就是xy

下面是另一个例子。


[[1, 2], [3, 4]].map(([a, b]) => a + b);

// [ 3, 7 ]

函数参数的解构也可以使用默认值。


function move({x = 0, y = 0} = {}) {

return [x, y];

}

move({x: 3, y: 8}); // [3, 8]

move({x: 3}); // [3, 0]

move({}); // [0, 0]

move(); // [0, 0]

上面代码中,函数move的参数是一个对象,通过对这个对象进行解构,得到变量xy的值。如果解构失败,xy等于默认值。

注意,下面的写法会得到不一样的结果


function move({x, y} = { x: 0, y: 0 }) {

return [x, y];

}

move({x: 3, y: 8}); // [3, 8]

move({x: 3}); // [3, undefined]

move({}); // [undefined, undefined]

move(); // [0, 0]

上面代码是为函数move的参数指定默认值,而不是为变量xy指定默认值,所以会得到与前一种写法不同的结果。

undefined就会触发函数参数的默认值。


[1, undefined, 3].map((x = 'yes') => x);

// [ 1, 'yes', 3 ]

圆括号问题

解构赋值虽然很方便,但是解析起来并不容易。对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。

由此带来的问题是,如果模式中出现圆括号怎么处理。ES6 的规则是,只要有可能导致解构的歧义,就不得使用圆括号。

但是,这条规则实际上不那么容易辨别,处理起来相当麻烦。因此,建议只要有可能,就不要在模式中放置圆括号。

不能使用圆括号的情况

以下三种解构赋值不得使用圆括号。

(1)变量声明语句


// 全部报错

let [(a)] = [1];

let {x: (c)} = {};

let ({x: c}) = {};

let {(x: c)} = {};

let {(x): c} = {};

let { o: ({ p: p }) } = { o: { p: 2 } };

上面 6 个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。

(2)函数参数

函数参数也属于变量声明,因此不能带有圆括号。


// 报错

function f([(z)]) { return z; }

// 报错

function f([z,(x)]) { return x; }

(3)赋值语句的模式


// 全部报错

({ p: a }) = { p: 42 };

([a]) = [5];

上面代码将整个模式放在圆括号之中,导致报错。


// 报错

[({ p: a }), { x: c }] = [{}, {}];

上面代码将一部分模式放在圆括号之中,导致报错。

可以使用圆括号的情况

可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。


[(b)] = [3]; // 正确

({ p: (d) } = {}); // 正确

[(parseInt.prop)] = [3]; // 正确

上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是p,而不是d;第三行语句与第一行语句的性质一致。

用途

变量的解构赋值用途很多。

(1)交换变量的值


let x = 1;

let y = 2;

[x, y] = [y, x];

上面代码交换变量xy的值,这样的写法不仅简洁,而且易读,语义非常清晰。

(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

如果只想获取键名,或者只想获取键值,可以写成下面这样


// 获取键名

for (let [key] of map) {

// ...

}

// 获取键值

for (let [,value] of map) {

// ...

}

(7)输入模块的指定方法

加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。


const { SourceMapConsumer, SourceNode } = require("source-map");

字符串的扩展

网道(WangDoc.com),互联网文档计划

本章介绍 ES6 对字符串的改造和增强,下一章介绍字符串对象的新增方法。

目录 [隐藏]

字符的 Unicode 表示法

ES6 加强了对 Unicode 的支持,允许采用\uxxxx形式表示一个字符,其中xxxx表示字符的 Unicode 码点。


"\u0061"

// "a"

但是,这种表示法只限于码点在\u0000~`\uFFFF`之间的字符。超出这个范围的字符,必须用两个双字节的形式表示。


"\uD842\uDFB7"

// "𠮷"

"\u20BB7"

// " 7"

上面代码表示,如果直接在\u后面跟上超过0xFFFF的数值(比如\u20BB7),JavaScript 会理解成\u20BB+7。由于\u20BB是一个不可打印字符,所以只会显示一个空格,后面跟着一个7

ES6 对这一点做出了改进,只要将码点放入大括号,就能正确解读该字符。


"\u{20BB7}"

// "𠮷"

"\u{41}\u{42}\u{43}"

// "ABC"

let hello = 123;

hell\u{6F} // 123

'\u{1F680}' === '\uD83D\uDE80'

// true

上面代码中,最后一个例子表明,大括号表示法与四字节的 UTF-16 编码是等价的。

有了这种表示法之后,JavaScript 共有 6 种方法可以表示一个字符。


'\z' === 'z'  // true

'\172' === 'z' // true

'\x7A' === 'z' // true

'\u007A' === 'z' // true

'\u{7A}' === 'z' // true

字符串的遍历器接口

ES6 为字符串添加了遍历器接口(详见《Iterator》一章),使得字符串可以被for...of循环遍历


for (let codePoint of 'foo') {

console.log(codePoint)

}

// "f"

// "o"

// "o"

除了遍历字符串,这个遍历器最大的优点是可以识别大于0xFFFF的码点,传统的for循环无法识别这样的码点。


let text = String.fromCodePoint(0x20BB7);

for (let i = 0; i < text.length; i++) {

console.log(text[i]);

}

// " "

// " "

for (let i of text) {

console.log(i);

}

// "𠮷"

上面代码中,字符串text只有一个字符,但是for循环会认为它包含两个字符(都不可打印),而for...of循环会正确识别出这一个字符。

直接输入 U+2028 和 U+2029

JavaScript 字符串允许直接输入字符,以及输入字符的转义形式。举例来说,“中”的 Unicode 码点是 U+4e2d,你可以直接在字符串里面输入这个汉字,也可以输入它的转义形式\u4e2d,两者是等价的。


'中' === '\u4e2d' // true

但是,JavaScript 规定有5个字符,不能在字符串里面直接使用,只能使用转义形式。

举例来说,字符串里面不能直接包含反斜杠,一定要转义写成\\或者\u005c

这个规定本身没有问题,麻烦在于 JSON 格式允许字符串里面直接使用 U+2028(行分隔符)和 U+2029(段分隔符)。这样一来,服务器输出的 JSON 被JSON.parse解析,就有可能直接报错。


const json = '"\u2028"';

JSON.parse(json); // 可能报错

JSON 格式已经冻结(RFC 7159),没法修改了。为了消除这个报错,ES2019 允许 JavaScript 字符串直接输入 U+2028(行分隔符)和 U+2029(段分隔符)。


const PS = eval("'\u2029'");

根据这个提案,上面的代码不会报错。

注意,模板字符串现在就允许直接输入这两个字符。另外,正则表达式依然不允许直接输入这两个字符,这是没有问题的,因为 JSON 本来就不允许直接包含正则表达式。

JSON.stringify() 的改造

根据标准,JSON 数据必须是 UTF-8 编码。但是,现在的JSON.stringify()方法有可能返回不符合 UTF-8 标准的字符串。

具体来说,UTF-8 标准规定,0xD8000xDFFF之间的码点,不能单独使用,必须配对使用。比如,\uD834\uDF06是两个码点,但是必须放在一起配对使用,代表字符𝌆。这是为了表示码点大于0xFFFF的字符的一种变通方法。单独使用\uD834\uDF06这两个码点是不合法的,或者颠倒顺序也不行,因为\uDF06\uD834并没有对应的字符。

JSON.stringify()的问题在于,它可能返回0xD8000xDFFF之间的单个码点。


JSON.stringify('\u{D834}') // "\u{D834}"

为了确保返回的是合法的 UTF-8 字符,ES2019 改变了JSON.stringify()的行为。如果遇到0xD8000xDFFF之间的单个码点,或者不存在的配对形式,它会返回转义字符串,留给应用自己决定下一步的处理。


JSON.stringify('\u{D834}') // ""\\uD834""

JSON.stringify('\uDF06\uD834') // ""\\udf06\\ud834""

模板字符串

传统的 JavaScript 语言,输出模板通常是这样写的(下面使用了 jQuery 的方法)。


$('#result').append(

'There are <b>' + basket.count + '</b> ' +

'items in your basket, ' +

'<em>' + basket.onSale +

'</em> are on sale!'

);

上面这种写法相当繁琐不方便,ES6 引入了模板字符串解决这个问题。


$('#result').append(`

There are <b>${basket.count}</b> items

in your basket, <em>${basket.onSale}</em>

are on sale!

`);

模板字符串(template string)是增强版的字符串,用反引号(`)标识。它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。


// 普通字符串

`In JavaScript '\n' is a line-feed.`

// 多行字符串

`In JavaScript this is

not legal.`

console.log(`string text line 1

string text line 2`);

// 字符串中嵌入变量

let name = "Bob", time = "today";

`Hello ${name}, how are you ${time}?`

上面代码中的模板字符串,都是用反引号表示。如果在模板字符串中需要使用反引号,则前面要用反斜杠转义。


let greeting = `\`Yo\` World!`;

如果使用模板字符串表示多行字符串,所有的空格和缩进都会被保留在输出之中。


$('#list').html(`

<ul>

<li>first</li>

<li>second</li>

</ul>

`);
上面代码中,所有模板字符串的空格和换行,都是被保留的,比如``<ul>` `标签前面会有一个换行。如果你不想要这个换行,可以使用`trim`方法消除它。

$('#list').html(`

<ul>

<li>first</li>

<li>second</li>

</ul>

`.trim());

模板字符串中嵌入变量需要将变量名写在${}之中


function authorize(user, action) {

if (!user.hasPrivilege(action)) {

throw new Error(

// 传统写法为

// 'User '

// + user.name

// + ' is not authorized to do '

// + action

// + '.'

`User ${user.name} is not authorized to do ${action}.`);

}

}

大括号内部可以放入任意的 JavaScript 表达式,可以进行运算,以及引用对象属性


let x = 1;

let y = 2;

`${x} + ${y} = ${x + y}`

// "1 + 2 = 3"

`${x} + ${y * 2} = ${x + y * 2}`

// "1 + 4 = 5"

let obj = {x: 1, y: 2};

`${obj.x + obj.y}`

// "3"

模板字符串之中还能调用函数。


function fn() {

return "Hello World";

}

`foo ${fn()} bar`

// foo Hello World bar

如果大括号中的值不是字符串,将按照一般的规则转为字符串。比如,大括号中是一个对象,将默认调用对象的toString方法。

如果模板字符串中的变量没有声明,将报错。


// 变量place没有声明

let msg = `Hello, ${place}`;

// 报错

由于模板字符串的大括号内部,就是执行 JavaScript 代码,因此如果大括号内部是一个字符串,将会原样输出。


`Hello ${'World'}`

// "Hello World"

模板字符串甚至还能嵌套


const tmpl = addrs => `

<table>

${addrs.map(addr => `

<tr><td>${addr.first}</td></tr>

<tr><td>${addr.last}</td></tr>

`).join('')}

</table>

`;

上面代码中,模板字符串的变量之中,又嵌入了另一个模板字符串,使用方法如下。


const data = [

{ first: '<Jane>', last: 'Bond' },

{ first: 'Lars', last: '<Croft>' },

];

console.log(tmpl(data));

// <table>

//

//   <tr><td><Jane></td></tr>

//   <tr><td>Bond</td></tr>

//

//   <tr><td>Lars</td></tr>

//   <tr><td><Croft></td></tr>

//

// </table>

如果需要引用模板字符串本身,在需要时执行,可以写成函数


let func = (name) => `Hello ${name}!`;

func('Jack') // "Hello Jack!"

上面代码中,模板字符串写成了一个函数的返回值。执行这个函数,就相当于执行这个模板字符串了。

实例:模板编译

下面,我们来看一个通过模板字符串,生成正式模板的实例。


let template = `

<ul>

<% for(let i=0; i < data.supplies.length; i++) { %>

<li><%= data.supplies[i] %></li>

<% } %>

</ul>

`;

上面代码在模板字符串之中,放置了一个常规模板。该模板使用<%...%>放置 JavaScript 代码,使用<%= ... %>输出 JavaScript 表达式。

怎么编译这个模板字符串呢?

一种思路是将其转换为 JavaScript 表达式字符串。


echo('<ul>');

for(let i=0; i < data.supplies.length; i++) {

echo('<li>');

echo(data.supplies[i]);

echo('</li>');

};

echo('</ul>');

这个转换使用正则表达式就行了。


let evalExpr = /<%=(.+?)%>/g;

let expr = /<%([\s\S]+?)%>/g;

template = template

.replace(evalExpr, '`); \n  echo( $1 ); \n  echo(`')

.replace(expr, '`); \n $1 \n  echo(`');

template = 'echo(`' + template + '`);';

然后,将template封装在一个函数里面返回,就可以了。


let script =

`(function parse(data){

let output = "";

function echo(html){

output += html;

}

${ template }

return output;

})`;

return script;

将上面的内容拼装成一个模板编译函数compile


function compile(template){

const evalExpr = /<%=(.+?)%>/g;

const expr = /<%([\s\S]+?)%>/g;

template = template

.replace(evalExpr, '`); \n  echo( $1 ); \n  echo(`')

.replace(expr, '`); \n $1 \n  echo(`');

template = 'echo(`' + template + '`);';

let script =

`(function parse(data){

let output = "";

function echo(html){

output += html;

}

${ template }

return output;

})`;

return script;

}

compile函数的用法如下。


let parse = eval(compile(template));

div.innerHTML = parse({ supplies: [ "broom", "mop", "cleaner" ] });

//   <ul>

//     <li>broom</li>

//     <li>mop</li>

//     <li>cleaner</li>

//   </ul>

标签模板

模板字符串的功能,不仅仅是上面这些。它可以紧跟在一个函数名后面该函数将被调用来处理这个模板字符串。这被称为“标签模板”功能(tagged template)。


alert`hello`

// 等同于

alert(['hello'])

标签模板其实不是模板,而是函数调用的一种特殊形式。“标签”指的就是函数,紧跟在后面的模板字符串就是它的参数。

但是,如果模板字符里面有变量,就不是简单的调用了,而是会将模板字符串先处理成多个参数,再调用函数。


let a = 5;

let b = 10;

tag`Hello ${ a + b } world ${ a * b }`;

// 等同于

tag(['Hello ', ' world ', ''], 15, 50);

上面代码中,模板字符串前面有一个标识名tag,它是一个函数。整个表达式的返回值,就是tag函数处理模板字符串后的返回值。

函数tag依次会接收到多个参数。


function tag(stringArr, value1, value2){

// ...

}

// 等同于

function tag(stringArr, ...values){

// ...

}

tag函数的第一个参数是一个数组,该数组的成员是模板字符串中那些没有变量替换的部分,也就是说,变量替换只发生在数组的第一个成员与第二个成员之间、第二个成员与第三个成员之间,以此类推。

tag函数的其他参数,都是模板字符串各个变量被替换后的值。由于本例中,模板字符串含有两个变量,因此tag会接受到value1value2两个参数。

tag函数所有参数的实际值如下。

也就是说,tag函数实际上以下面的形式调用。


tag(['Hello ', ' world ', ''], 15, 50)

我们可以按照需要编写tag函数的代码。下面是tag函数的一种写法,以及运行结果。


let a = 5;

let b = 10;

function tag(s, v1, v2) {

console.log(s[0]);

console.log(s[1]);

console.log(s[2]);

console.log(v1);

console.log(v2);

return "OK";

}

tag`Hello ${ a + b } world ${ a * b}`;

// "Hello "

// " world "

// ""

// 15

// 50

// "OK"

下面是一个更复杂的例子。


let total = 30;

let msg = passthru`The total is ${total} (${total*1.05} with tax)`;

function passthru(literals) {

let result = '';

let i = 0;

while (i < literals.length) {

result += literals[i++];

if (i < arguments.length) {

result += arguments[i];

}

}

return result;

}

msg // "The total is 30 (31.5 with tax)"

上面这个例子展示了,如何将各个参数按照原来的位置拼合回去。

passthru函数采用 rest 参数的写法如下。


function passthru(literals, ...values) {

let output = "";

let index;

for (index = 0; index < values.length; index++) {

output += literals[index] + values[index];

}

output += literals[index]

return output;

}

“标签模板”的一个重要应用,就是过滤 HTML 字符串,防止用户输入恶意内容。


let message =

SaferHTML`<p>${sender} has sent you a message.</p>`;

function SaferHTML(templateData) {

let s = templateData[0];

for (let i = 1; i < arguments.length; i++) {

let arg = String(arguments[i]);

// Escape special characters in the substitution.

s += arg.replace(/&/g, "&amp;")

.replace(/</g, "&lt;")

.replace(/>/g, "&gt;");

// Don't escape special characters in the template.

s += templateData[i];

}

return s;

}

上面代码中,sender变量往往是用户提供的,经过SaferHTML函数处理,里面的特殊字符都会被转义。


let sender = '<script>alert("abc")</script>'; // 恶意代码

let message = SaferHTML`<p>${sender} has sent you a message.</p>`;

message

// <p>&lt;script&gt;alert("abc")&lt;/script&gt; has sent you a message.</p>

标签模板的另一个应用,就是多语言转换(国际化处理)。


i18n`Welcome to ${siteName}, you are visitor number ${visitorNumber}!`

// "欢迎访问xxx,您是第xxxx位访问者!"

模板字符串本身并不能取代 Mustache 之类的模板库,因为没有条件判断和循环处理功能,但是通过标签函数,你可以自己添加这些功能。


// 下面的hashTemplate函数

// 是一个自定义的模板处理函数

let libraryHtml = hashTemplate`

<ul>

#for book in ${myBooks}

<li><i>#{book.title}</i> by #{book.author}</li>

#end

</ul>

`;

除此之外,你甚至可以使用标签模板,在 JavaScript 语言之中嵌入其他语言。


jsx`

<div>

<input

ref='input'

onChange='${this.handleChange}'

defaultValue='${this.state.value}' />

${this.state.value}

</div>

`

上面的代码通过jsx函数,将一个 DOM 字符串转为 React 对象。你可以在 GitHub 找到jsx函数的具体实现

下面则是一个假想的例子,通过java函数,在 JavaScript 代码之中运行 Java 代码。


java`

class HelloWorldApp {

public static void main(String[] args) {

System.out.println("Hello World!"); // Display the string.

}

}

`

HelloWorldApp.main();

模板处理函数的第一个参数(模板字符串数组),还有一个raw属性。


console.log`123`

// ["123", raw: Array[1]]

上面代码中,console.log接受的参数,实际上是一个数组。该数组有一个raw属性,保存的是转义后的原字符串。

请看下面的例子。


tag`First line\nSecond line`

function tag(strings) {

console.log(strings.raw[0]);

// strings.raw[0] 为 "First line\\nSecond line"

// 打印输出 "First line\nSecond line"

}

上面代码中,tag函数的第一个参数strings,有一个raw属性,也指向一个数组。该数组的成员与strings数组完全一致。比如,strings数组是["First line\nSecond line"],那么strings.raw数组就是["First line\\nSecond line"]。两者唯一的区别,就是字符串里面的斜杠都被转义了。比如,strings.raw 数组会将\n视为\\n两个字符,而不是换行符。这是为了方便取得转义之前的原始模板而设计的。

模板字符串的限制

前面提到标签模板里面,可以内嵌其他语言。但是,模板字符串默认会将字符串转义,导致无法嵌入其他语言。

举例来说,标签模板里面可以嵌入 LaTEX 语言。


function latex(strings) {

// ...

}

let document = latex`

\newcommand{\fun}{\textbf{Fun!}}  // 正常工作

\newcommand{\unicode}{\textbf{Unicode!}} // 报错

\newcommand{\xerxes}{\textbf{King!}} // 报错

Breve over the h goes \u{h}ere // 报错

`

上面代码中,变量document内嵌的模板字符串,对于 LaTEX 语言来说完全是合法的,但是 JavaScript 引擎会报错。原因就在于字符串的转义。

模板字符串会将\u00FF\u{42}当作 Unicode 字符进行转义,所以\unicode解析时报错;而\x56会被当作十六进制字符串转义,所以\xerxes会报错。也就是说,\u\x在 LaTEX 里面有特殊含义,但是 JavaScript 将它们转义了。

为了解决这个问题,ES2018 放松了对标签模板里面的字符串转义的限制。如果遇到不合法的字符串转义,就返回undefined,而不是报错,并且从raw属性上面可以得到原始字符串。


function tag(strs) {

strs[0] === undefined

strs.raw[0] === "\\unicode and \\u{55}";

}

tag`\unicode and \u{55}`

上面代码中,模板字符串原本是应该报错的,但是由于放松了对字符串转义的限制,所以不报错了,JavaScript 引擎将第一个字符设置为undefined,但是raw属性依然可以得到原始字符串,因此tag函数还是可以对原字符串进行处理。

注意,这种对字符串转义的放松,只在标签模板解析字符串时生效,不是标签模板的场合,依然会报错。


let bad = `bad escape sequence: \unicode`; // 报错

函数的扩展

网道(WangDoc.com),互联网文档计划

目录 [隐藏]

函数参数的默认值

基本用法

ES6 之前,不能直接为函数的参数指定默认值,只能采用变通的方法。


function log(x, y) {

y = y || 'World';

console.log(x, y);

}

log('Hello') // Hello World

log('Hello', 'China') // Hello China

log('Hello', '') // Hello World

上面代码检查函数log()的参数y有没有赋值,如果没有,则指定默认值为World。这种写法的缺点在于,如果参数y赋值了,但是对应的布尔值为false,则该赋值不起作用。就像上面代码的最后一行,参数y等于空字符,结果被改为默认值。

为了避免这个问题,通常需要先判断一下参数y是否被赋值,如果没有,再等于默认值。


if (typeof y === 'undefined') {

y = 'World';

}

ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。


function log(x, y = 'World') {

console.log(x, y);

}

log('Hello') // Hello World

log('Hello', 'China') // Hello China

log('Hello', '') // Hello

可以看到,ES6 的写法比 ES5 简洁许多,而且非常自然。下面是另一个例子。


function Point(x = 0, y = 0) {

this.x = x;

this.y = y;

}

const p = new Point();

p // { x: 0, y: 0 }

除了简洁,ES6 的写法还有两个好处:首先,阅读代码的人,可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本在对外接口中,彻底拿掉这个参数,也不会导致以前的代码无法运行。

参数变量是默认声明的所以不能用letconst再次声明


function foo(x = 5) {

let x = 1; // error

const x = 2; // error

}

上面代码中,参数变量x是默认声明的,在函数体中,不能用letconst再次声明,否则会报错。

使用参数默认值时,函数不能有同名参数


// 不报错

function foo(x, x, y) {

// ...

}

// 报错

function foo(x, x, y = 1) {

// ...

}

// SyntaxError: Duplicate parameter name not allowed in this context

另外,一个容易忽略的地方是,参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的


let x = 99;

function foo(p = x + 1) {

console.log(p);

}

foo() // 100

x = 100;

foo() // 101

上面代码中,参数p的默认值是x + 1。这时,每次调用函数foo(),都会重新计算x + 1,而不是默认p等于 100。

与解构赋值默认值结合使用

参数默认值可以与解构赋值的默认值,结合起来使用


function foo({x, y = 5}) {

console.log(x, y);

}

foo({}) // undefined 5

foo({x: 1}) // 1 5

foo({x: 1, y: 2}) // 1 2

foo() // TypeError: Cannot read property 'x' of undefined

上面代码只使用了对象的解构赋值默认值,没有使用函数参数的默认值。只有当函数foo()的参数是一个对象时,变量xy才会通过解构赋值生成。如果函数foo()调用时没提供参数,变量xy就不会生成,从而报错通过提供函数参数的默认值,就可以避免这种情况


function foo({x, y = 5} = {}) {

console.log(x, y);

}

foo() // undefined 5

上面代码指定,如果没有提供参数,函数foo的参数默认为一个空对象

下面是另一个解构赋值默认值的例子。


function fetch(url, { body = '', method = 'GET', headers = {} }) {

console.log(method);

}

fetch('http://example.com', {})

// "GET"

fetch('http://example.com')

// 报错

上面代码中,如果函数fetch()的第二个参数是一个对象,就可以为它的三个属性设置默认值这种写法不能省略第二个参数如果结合函数参数的默认值,就可以省略第二个参数这时,就出现了双重默认值


function fetch(url, { body = '', method = 'GET', headers = {} } = {}) {

console.log(method);

}

fetch('http://example.com')

// "GET"

上面代码中,函数fetch没有第二个参数时,函数参数的默认值就会生效然后才是解构赋值的默认值生效,变量method才会取到默认值GET

注意,函数参数的默认值生效以后,参数解构赋值依然会进行


function f({ a, b = 'world' } = { a: 'hello' }) {

console.log(b);

}

f() // world

上面示例中,函数f()调用时没有参数,所以参数默认值{ a: 'hello' }生效,然后再对这个默认值进行解构赋值,从而触发参数变量b的默认值生效

作为练习,大家可以思考一下,下面两种函数写法有什么差别


// 写法一

function m1({x = 0, y = 0} = {}) {

return [x, y];

}

// 写法二

function m2({x, y} = { x: 0, y: 0 }) { // 当不传入任何参数时`{ x: 0, y: 0 }`会生效, 但是传入任何对象时, `{ x: 0, y: 0 }`就会失效, 如果传入对象中没有键值或者缺少键值时, 也就不会访问到`{ x: 0, y: 0 }`提供的默认值了

return [x, y];

}

// 函数没有参数的情况

m1() // [0, 0]

m2() // [0, 0]

// x 和 y 都有值的情况

m1({x: 3, y: 8}) // [3, 8]

m2({x: 3, y: 8}) // [3, 8]

// x 有值,y 无值的情况

m1({x: 3}) // [3, 0]

m2({x: 3}) // [3, undefined]

// x 和 y 都无值的情况

m1({}) // [0, 0];

m2({}) // [undefined, undefined]

m1({z: 3}) // [0, 0]

m2({z: 3}) // [undefined, undefined]

参数默认值的位置

通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的


// 例一

function f(x = 1, y) {

return [x, y];

}

f() // [1, undefined]

f(2) // [2, undefined]

f(, 1) // 报错

f(undefined, 1) // [1, 1]

// 例二

function f(x, y = 5, z) {

return [x, y, z];

}

f() // [undefined, 5, undefined]

f(1) // [1, 5, undefined]

f(1, ,2) // 报错

f(1, undefined, 2) // [1, 5, 2]

上面代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数,而不省略它后面的参数,除非显式输入undefined

如果传入undefined,将触发该参数等于默认值,null则没有这个效果


function foo(x = 5, y = 6) {

console.log(x, y);

}

foo(undefined, null)

// 5 null

上面代码中,x参数对应undefined,结果触发了默认值,y参数等于null,就没有触发默认值

函数的 length 属性

length 属性指明函数的形参个数

指定了默认值以后,函数的length属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真


(function (a) {}).length // 1

(function (a = 5) {}).length // 0

(function (a, b, c = 5) {}).length // 2

上面代码中,length属性的返回值,等于函数的参数个数减去指定了默认值的参数个数。比如,上面最后一个函数,定义了 3 个参数,其中有一个参数c指定了默认值,因此length属性等于3减去1,最后得到2

这是因为length属性的含义是,该函数预期传入的参数个数某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,后文的 rest 参数也不会计入length属性。


(function(...args) {}).length // 0

如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了


(function (a = 0, b, c) {}).length // 0

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

作用域

一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失这种语法行为,在不设置参数默认值时,是不会出现的


var x = 1;

function f(x, y = x) {

console.log(y);

}

f(2) // 2

上面代码中,参数y的默认值等于变量x。调用函数f时,参数形成一个单独的作用域。在这个作用域里面,默认值变量x指向第一个参数x,而不是全局变量x,所以输出是2

再看下面的例子。


let x = 1;

function f(y = x) {

let x = 2;

console.log(y);

}

f() // 1

上面代码中,函数f调用时,参数y = x形成一个单独的作用域。这个作用域里面,变量x本身没有定义,所以指向外层的全局变量x。函数调用时,函数体内部的局部变量x影响不到默认值变量x

如果此时,全局变量x不存在,就会报错


function f(y = x) {

let x = 2;

console.log(y);

}

f() // ReferenceError: x is not defined

下面这样写,也会报错


var x = 1;

function foo(x = x) {

// ...

}

foo() // ReferenceError: Cannot access 'x' before initialization

上面代码中,参数x = x形成一个单独作用域实际执行的是let x = x由于暂时性死区的原因,这行代码会报错

上面那句话的意思就是说, 实际上参数x = x实际上就是函数内部执行了let x = x, 但是由于let的特性暂时性死区(即变量在创建前(创建中)不能访问), 所以报错了

注意, x = x和函数外变量var x = 1;没有关系, 因为x = x实际上执行了let x = x, 如果执行成功的话, x的作用于只在函数内

如果参数的默认值是一个函数,该函数的作用域也遵守这个规则。请看下面的例子。


let foo = 'outer';

function bar(func = () => foo) {

let foo = 'inner';

console.log(func());

}

bar(); // outer

上面代码中,函数bar的参数func的默认值是一个匿名函数,返回值为变量foo函数参数形成的单独作用域里面,并没有定义变量foo,所以foo指向外层的全局变量foo,因此输出outer

如果写成下面这样,就会报错


function bar(func = () => foo) {

let foo = 'inner';

console.log(func());

}

bar() // ReferenceError: foo is not defined

上面代码中,匿名函数里面的foo指向函数外层,但是函数外层并没有声明变量foo,所以就报错了。

下面是一个更复杂的例子。


var x = 1;

function foo(x, y = function() { x = 2; }) { // 匿名函数会会指向同一个作用于的变量x, 即参数中的x

var x = 3; // 重新声明了x, 这里的x和上方的x没关系

y();

console.log(x);

}

foo() // 3

x // 1

上面代码中,函数foo的参数形成一个单独作用域。这个作用域里面,首先声明了变量x,然后声明了变量yy的默认值是一个匿名函数。这个匿名函数内部的变量x,指向同一个作用域的第一个参数x。函数foo内部又声明了一个内部变量x,该变量与第一个参数x由于不是同一个作用域,所以不是同一个变量,因此执行y后,内部变量x和外部全局变量x的值都没变

如果将var x = 3var去除,函数foo的内部变量x就指向第一个参数x,与匿名函数内部的x是一致的,所以最后输出的就是2,而外层的全局变量x依然不受影响


var x = 1;

function foo(x, y = function() { x = 2; }) {

x = 3;

y();

console.log(x);

}

foo() // 2

x // 1

应用

利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误


function throwIfMissing() {

throw new Error('Missing parameter');

}

function foo(mustBeProvided = throwIfMissing()) {

return mustBeProvided;

}

foo()

// Error: Missing parameter

上面代码的foo函数,如果调用的时候没有参数,就会调用默认值throwIfMissing函数,从而抛出一个错误

从上面代码还可以看到,参数mustBeProvided的默认值等于throwIfMissing函数的运行结果注意函数名throwIfMissing之后有一对圆括号),这表明参数的默认值不是在定义时执行,而是在运行时执行如果参数已经赋值,默认值中的函数就不会运行

另外,可以将参数默认值设为undefined,表明这个参数是可以省略的。


function foo(optional = undefined) { ··· }

rest 参数

ES6 引入rest 参数形式为...变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中


function add(...values) {

let sum = 0;

for (var val of values) { // 遍历values的值, 这个用法非常好, 应该学习

sum += val;

}

return sum;

}

add(2, 5, 3) // 10

上面代码的add函数是一个求和函数,利用 rest 参数,可以向该函数传入任意数目的参数

下面是一个 rest 参数代替arguments变量的例子。


// arguments变量的写法

function sortNumbers() {

return Array.from(arguments).sort();

}

// rest参数的写法

const sortNumbers = (...numbers) => numbers.sort();

上面代码的两种写法,比较后可以发现,rest 参数的写法更自然也更简洁。

arguments对象不是数组,而是一个类似数组的对象所以为了使用数组的方法必须使用Array.from先将其转为数组rest 参数就不存在这个问题,它就是一个真正的数组数组特有的方法都可以使用。下面是一个利用 rest 参数改写数组push方法的例子。


function push(array, ...items) {

items.forEach(function(item) {

array.push(item);

console.log(item);

});

}

var a = [];

push(a, 1, 2, 3)

注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。


// 报错

function f(a, ...b, c) {

// ...

}

函数的length属性,不包括 rest 参数


(function(a) {}).length  // 1

(function(...a) {}).length  // 0

(function(a, ...b) {}).length  // 1

严格模式

从 ES5 开始,函数内部可以设定为严格模式。


function doSomething(a, b) {

'use strict';

// code

}

ES2016 做了一点修改,规定只要函数参数使用了默认值、解构赋值、或者扩展运算符那么函数内部就不能显式设定为严格模式,否则会报错。


// 报错

function doSomething(a, b = a) {

'use strict';

// code

}

// 报错

const doSomething = function ({a, b}) {

'use strict';

// code

};

// 报错

const doSomething = (...a) => {

'use strict';

// code

};

const obj = {

// 报错

doSomething({a, b}) {

'use strict';

// code

}

};

这样规定的原因是,函数内部的严格模式,同时适用于函数体和函数参数是,函数执行的时候先执行函数参数然后再执行函数体这样就有一个不合理的地方,只有从函数体之中才能知道参数是否应该以严格模式执行但是参数却应该先于函数体执行


// 报错

function doSomething(value = 070) {

'use strict';

return value;

}

上面代码中,参数value的默认值是八进制数070,但是严格模式下不能用前缀0表示八进制,所以应该报错但是实际上,JavaScript 引擎会先成功执行value = 070然后进入函数体内部,发现需要用严格模式执行,这时才会报错

虽然可以先解析函数体代码,再执行参数代码,但是这样无疑就增加了复杂性。因此,标准索性禁止了这种用法只要参数使用了默认值、解构赋值、或者扩展运算符,就不能显式指定严格模式

两种方法可以规避这种限制。第一种是设定全局性的严格模式,这是合法的。


'use strict';

function doSomething(a, b = a) {

// code

}

第二种是把函数包在一个无参数的立即执行函数里面。


const doSomething = (function () {

'use strict';

return function(value = 42) {

return value;

};

}());

name 属性

函数的name属性,返回该函数的函数名


function foo() {}

foo.name // "foo"

这个属性早就被浏览器广泛支持,但是直到 ES6,才将其写入了标准

需要注意的是,ES6 对这个属性的行为做出了一些修改如果将一个匿名函数赋值给一个变量,ES5 的name属性,会返回空字符串,而 ES6 的name属性会返回实际的函数名。


var f = function () {};

// ES5

f.name // ""

// ES6

f.name // "f"

上面代码中,变量f等于一个匿名函数,ES5 和 ES6 的name属性返回的值不一样。

如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的name属性都返回这个具名函数原本的名字。


const bar = function baz() {};

// ES5

bar.name // "baz"

// ES6

bar.name // "baz"

Function构造函数返回的函数实例,name属性的值为anonymous


(new Function).name // "anonymous"

bind返回的函数,name属性值会加上bound前缀。


function foo() {};

foo.bind({}).name // "bound foo"

(function(){}).bind({}).name // "bound "

箭头函数

基本用法

ES6 允许使用“箭头”(=>)定义函数。


var f = v => v;

// 等同于

var f = function (v) {

return v;

};

如果箭头函数不需要参数需要多个参数,就使用一个圆括号代表参数部分


var f = () => 5;

// 等同于

var f = function () { return 5 };

var sum = (num1, num2) => num1 + num2;

// 等同于

var sum = function(num1, num2) {

return num1 + num2;

};

如果箭头函数的代码块部分多于一条语句就要使用大括号将它们括起来并且使用return语句返回


var sum = (num1, num2) => { return num1 + num2; }

由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。


// 报错

let getTempItem = id => { id: id, name: "Temp" };

// 不报错

let getTempItem = id => ({ id: id, name: "Temp" });

下面是一种特殊情况,虽然可以运行,但会得到错误的结果。


let foo = () => { a: 1 }; // 意思就是说, 这个匿名函数其实是在执行一个由{}包裹的代码块, 而这个代码块中将语句1设为了标签a, 这个标签a的语句就是1, 这是没有意义的, 而且最后函数没有返回值, 所以执行的结果因为没有返回值那么就是undifined

foo() // undefined

上面代码中,原始意图是返回一个对象{ a: 1 },但是由于引擎认为大括号是代码块,所以执行了一行语句a: 1这时,a可以被解释为语句的标签,因此实际执行的语句是1;,然后函数就结束了,没有返回值。

标记语句可以和 breakcontinue 语句一起使用。标记就是在一条语句前面加个可以引用的标识符(identifier)。


let str = '';

loop1:

for (let i = 0; i < 5; i++) {

   if (i === 1) {

     continue loop1;

   }

   str = str + i;

}

console.log(str);

// expected output: "0234"


语法


label :

    statement


label

     任何不属于保留关键字的 JavaScript 标识符。

statement

     JavaScript 语句。break 可用于任何标记语句,而 continue 可用于循环标记语句。


var i, j;

loop1:

for (i = 0; i < 3; i++) {      //The first for statement is labeled "loop1"

    loop2:

    for (j = 0; j < 3; j++) {   //The second for statement is labeled "loop2"

       if (i == 1 && j == 1) {

          break loop1; // 当执行该语句时, 会将这个那个loop1代码块跳出, 从行为上就是跳出了两层循环, 如果直接使用break就只能跳出一层循环

       }

       console.log("i = " + i + ", j = " + j);

    }

}

// Output is:

//   "i = 0, j = 0"

//   "i = 0, j = 1"

//   "i = 0, j = 2"

//   "i = 1, j = 0"

// Notice the difference with the previous continue example


如果箭头函数只有一行语句,且不需要返回值,可以采用下面的写法,就不用写大括号了。


let fn = () => void doesNotReturn();

箭头函数可以与变量解构结合使用


const full = ({ first, last }) => first + ' ' + last;

// 等同于

function full(person) {

return person.first + ' ' + person.last;

}

箭头函数使得表达更加简洁。


const isEven = n => n % 2 === 0; // 传入n, 返回n % 2 === 0的布尔判断结果

const square = n => n * n; // 传入n, 返回n*n的值

上面代码只用了两行,就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目

箭头函数的一个用处是简化回调函数


// 普通函数写法

[1,2,3].map(function (x) {

return x * x;

});

// 箭头函数写法

[1,2,3].map(x => x * x); // 和上方意义相等, 即想map函数传入一个实参, 这个实参是匿名函数, 参数是x, 返回值是x*x的值

另一个例子是


// 普通函数写法

var result = values.sort(function (a, b) {

return a - b;

});

// 箭头函数写法

var result = values.sort((a, b) => a - b);

下面是 rest 参数与箭头函数结合的例子。


const numbers = (...nums) => nums;

numbers(1, 2, 3, 4, 5)

// [1,2,3,4,5]

const headAndTail = (head, ...tail) => [head, tail];

headAndTail(1, 2, 3, 4, 5)

// [1,[2,3,4,5]]

使用注意点

箭头函数有几个使用注意点

(1)箭头函数没有自己的this对象(详见下文)。

(2)不可以当作构造函数也就是说,不可以对箭头函数使用new命令,否则会抛出一个错误。

(3)不可以使用arguments对象该对象在函数体内不存在。如果要用,可以用 rest 参数代替

(4)不可以使用yield命令,因此箭头函数不能用作 Generator 函数

function* 生成器函数

在 ES6 中定义一个生成器函数很简单,在 function 后跟上「*」即可:


function* foo1() { };

function *foo2() { };

function * foo3() { };

foo1.toString(); // "function* foo1() { }"

foo2.toString(); // "function* foo2() { }"

foo3.toString(); // "function* foo3() { }"

foo1.constructor; // function GeneratorFunction() { [native code] }


调用生成器函数会产生一个生成器(generator)。生成器拥有的最重要的方法是 next(),用来迭代:


function* foo() { };

var bar = foo();

bar.next(); // Object {value: undefined, done: true}


上面第 2 行的语句看上去是函数调用,但这时候函数代码并没有执行;一直要等到第 3 行调用 next 方法才会执行。next 方法返回一个拥有 value 和 done 两个字段的对象。

生成器函数通常和 yield 关键字同时使用。函数执行到每个 yield 时都会中断并返回 yield 的右值(通过 next 方法返回对象中的 value 字段)。下次调用 next,函数会从 yield 的下一个语句继续执行。等到整个函数执行完,next 方法返回的 done 字段会变成 true。下面看一个简单的例子:


function* list() {

     for(var i = 0; i < arguments.length; i++) {

         yield arguments[i];

     }

     return "done.";

}

var o = list(1, 2, 3);

o.next(); // Object {value: 1, done: false}

o.next(); // Object {value: 2, done: false}

o.next(); // Object {value: 3, done: false}

o.next(); // Object {value: "done.", done: true}

o.next(); // Error: Generator has already finished


可以看到,每次调用 next 方法,都会得到当前 yield 的值。函数执行完之后,再调用 next 方法会产生异常。

上面四点中,最重要的是第一点。对于普通函数来说,内部的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指向固定化,绑定this使得它不再可变,这种特性很有利于封装回调函数。下面是一个例子,DOM 事件的回调函数封装在一个对象里面。


var handler = {

id: '123456',

init: function() {

document.addEventListener('click',

event => this.doSomething(event.type), false);

},

doSomething: function(type) {

console.log('Handling ' + type  + ' for ' + this.id);

}

};

阅读到这里


上面代码的init()方法中,使用了箭头函数,这导致这个箭头函数里面的this,总是指向handler对象。如果回调函数是普通函数,那么运行this.doSomething()这一行会报错,因为此时this指向document对象。

总之,箭头函数根本没有自己的this,导致内部的this就是外层代码块的this。正是因为它没有this,所以也就不能用作构造函数。

下面是 Babel 转箭头函数产生的 ES5 代码,就能清楚地说明this的指向。


// ES6

function foo() {

setTimeout(() => {

console.log('id:', this.id);

}, 100);

}

// ES5

function foo() {

var _this = this;

setTimeout(function () {

console.log('id:', _this.id);

}, 100);

}

上面代码中,转换后的 ES5 版本清楚地说明了,箭头函数里面根本没有自己的this,而是引用外层的this

请问下面的代码之中,this的指向有几个?


function foo() {

return () => {

return () => {

return () => {

console.log('id:', this.id);

};

};

};

}

var f = foo.call({id: 1});

var t1 = f.call({id: 2})()(); // id: 1

var t2 = f().call({id: 3})(); // id: 1

var t3 = f()().call({id: 4}); // id: 1

答案是this的指向只有一个,就是函数foothis,这是因为所有的内层函数都是箭头函数,都没有自己的this,它们的this其实都是最外层foo函数的this。所以不管怎么嵌套,t1t2t3都输出同样的结果。如果这个例子的所有内层函数都写成普通函数,那么每个函数的this都指向运行时所在的不同对象。

除了this,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:argumentssupernew.target


function foo() {

setTimeout(() => {

console.log('args:', arguments);

}, 100);

}

foo(2, 4, 6, 8)

// args: [2, 4, 6, 8]

上面代码中,箭头函数内部的变量arguments,其实是函数fooarguments变量。

另外,由于箭头函数没有自己的this,所以当然也就不能用call()apply()bind()这些方法去改变this的指向。


(function() {

return [

(() => this.x).bind({ x: 'inner' })()

];

}).call({ x: 'outer' });

// ['outer']

上面代码中,箭头函数没有自己的this,所以bind方法无效,内部的this指向外部的this

长期以来,JavaScript 语言的this对象一直是一个令人头痛的问题,在对象方法中使用this,必须非常小心。箭头函数”绑定”this,很大程度上解决了这个困扰。

不适用场合

由于箭头函数使得this从“动态”变成“静态”,下面两个场合不应该使用箭头函数。

第一个场合是定义对象的方法,且该方法内部包括this


const cat = {

lives: 9,

jumps: () => {

this.lives--;

}

}

上面代码中,cat.jumps()方法是一个箭头函数,这是错误的。调用cat.jumps()时,如果是普通函数,该方法内部的this指向cat;如果写成上面那样的箭头函数,使得this指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致jumps箭头函数定义时的作用域就是全局作用域。

再看一个例子。


globalThis.s = 21;

const obj = {

s: 42,

m: () => console.log(this.s)

};

obj.m() // 21

上面例子中,obj.m()使用箭头函数定义。JavaScript 引擎的处理方法是,先在全局空间生成这个箭头函数,然后赋值给obj.m,这导致箭头函数内部的this指向全局对象,所以obj.m()输出的是全局空间的21,而不是对象内部的42。上面的代码实际上等同于下面的代码。


globalThis.s = 21;

globalThis.m = () => console.log(this.s);

const obj = {

s: 42,

m: globalThis.m

};

obj.m() // 21

由于上面这个原因,对象的属性建议使用传统的写法定义,不要用箭头函数定义。

第二个场合是需要动态this的时候,也不应使用箭头函数。


var button = document.getElementById('press');

button.addEventListener('click', () => {

this.classList.toggle('on');

});

上面代码运行时,点击按钮会报错,因为button的监听函数是一个箭头函数,导致里面的this就是全局对象。如果改成普通函数,this就会动态指向被点击的按钮对象。

另外,如果函数体很复杂,有许多行,或者函数内部有大量的读写操作,不单纯是为了计算值,这时也不应该使用箭头函数,而是要使用普通函数,这样可以提高代码可读性。

嵌套的箭头函数

箭头函数内部,还可以再使用箭头函数。下面是一个 ES5 语法的多重嵌套函数。


function insert(value) {

return {into: function (array) {

return {after: function (afterValue) {

array.splice(array.indexOf(afterValue) + 1, 0, value);

return array;

}};

}};

}

insert(2).into([1, 3]).after(1); //[1, 2, 3]

上面这个函数,可以使用箭头函数改写。


let insert = (value) => ({into: (array) => ({after: (afterValue) => {

array.splice(array.indexOf(afterValue) + 1, 0, value);

return array;

}})});

insert(2).into([1, 3]).after(1); //[1, 2, 3]

下面是一个部署管道机制(pipeline)的例子,即前一个函数的输出是后一个函数的输入。


const pipeline = (...funcs) =>

val => funcs.reduce((a, b) => b(a), val);

const plus1 = a => a + 1;

const mult2 = a => a * 2;

const addThenMult = pipeline(plus1, mult2);

addThenMult(5)

// 12

如果觉得上面的写法可读性比较差,也可以采用下面的写法。


const plus1 = a => a + 1;

const mult2 = a => a * 2;

mult2(plus1(5))

// 12

箭头函数还有一个功能,就是可以很方便地改写 λ 演算。


// λ演算的写法

fix = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))

// ES6的写法

var fix = f => (x => f(v => x(x)(v)))

(x => f(v => x(x)(v)));

上面两种写法,几乎是一一对应的。由于 λ 演算对于计算机科学非常重要,这使得我们可以用 ES6 作为替代工具,探索计算机科学。

尾调用优化

什么是尾调用?

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。


function f(x){

return g(x);

}

上面代码中,函数f的最后一步是调用函数g,这就叫尾调用。

以下三种情况,都不属于尾调用。


// 情况一

function f(x){

let y = g(x);

return y;

}

// 情况二

function f(x){

return g(x) + 1;

}

// 情况三

function f(x){

g(x);

}

上面代码中,情况一是调用函数g之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。


function f(x){

g(x);

return undefined;

}

尾调用不一定出现在函数尾部,只要是最后一步操作即可。


function f(x) {

if (x > 0) {

return m(x)

}

return n(x);

}

上面代码中,函数mn都属于尾调用,因为它们都是函数f的最后一步操作。

尾调用优化

尾调用之所以与其他调用不同,就在于它的特殊的调用位置。

我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到AB的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。


function f() {

let m = 1;

let n = 2;

return g(m + n);

}

f();

// 等同于

function f() {

return g(3);

}

f();

// 等同于

g(3);

上面代码中,如果函数g不是尾调用,函数f就需要保存内部变量mn的值、g的调用位置等信息。但由于调用g之后,函数f就结束了,所以执行到最后一步,完全可以删除f(x)的调用帧,只保留g(3)的调用帧。

这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。

注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。


function addOne(a){

var one = 1;

function inner(b){

return b + one;

}

return inner(a);

}

上面的函数不会进行尾调用优化,因为内层函数inner用到了外层函数addOne的内部变量one

注意,目前只有 Safari 浏览器支持尾调用优化,Chrome 和 Firefox 都不支持。

尾递归

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。


function factorial(n) {

if (n === 1) return 1;

return n * factorial(n - 1);

}

factorial(5) // 120

上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度 O(n) 。

如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。


function factorial(n, total) {

if (n === 1) return total;

return factorial(n - 1, n * total);

}

factorial(5, 1) // 120

还有一个比较著名的例子,就是计算 Fibonacci 数列,也能充分说明尾递归优化的重要性。

非尾递归的 Fibonacci 数列实现如下。


function Fibonacci (n) {

if ( n <= 1 ) {return 1};

return Fibonacci(n - 1) + Fibonacci(n - 2);

}

Fibonacci(10) // 89

Fibonacci(100) // 超时

Fibonacci(500) // 超时

尾递归优化过的 Fibonacci 数列实现如下。


function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {

if( n <= 1 ) {return ac2};

return Fibonacci2 (n - 1, ac2, ac1 + ac2);

}

Fibonacci2(100) // 573147844013817200000

Fibonacci2(1000) // 7.0330367711422765e+208

Fibonacci2(10000) // Infinity

由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 亦是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。这就是说,ES6 中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。

递归函数的改写

尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量total,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算5的阶乘,需要传入两个参数51

两个方法可以解决这个问题。方法一是在尾递归函数之外,再提供一个正常形式的函数。


function tailFactorial(n, total) {

if (n === 1) return total;

return tailFactorial(n - 1, n * total);

}

function factorial(n) {

return tailFactorial(n, 1);

}

factorial(5) // 120

上面代码通过一个正常形式的阶乘函数factorial,调用尾递归函数tailFactorial,看起来就正常多了。

函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。


function currying(fn, n) {

return function (m) {

return fn.call(this, m, n);

};

}

function tailFactorial(n, total) {

if (n === 1) return total;

return tailFactorial(n - 1, n * total);

}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120

上面代码通过柯里化,将尾递归函数tailFactorial变为只接受一个参数的factorial

第二种方法就简单多了,就是采用 ES6 的函数默认值。


function factorial(n, total = 1) {

if (n === 1) return total;

return factorial(n - 1, n * total);

}

factorial(5) // 120

上面代码中,参数total有默认值1,所以调用时不用提供这个值。

总结一下,递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持“尾调用优化”的语言(比如 Lua,ES6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。

严格模式

ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。

这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。

尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。


function restricted() {

'use strict';

restricted.caller;    // 报错

restricted.arguments; // 报错

}

restricted();

尾递归优化的实现

尾递归优化只在严格模式下生效,那么正常模式下,或者那些不支持该功能的环境中,有没有办法也使用尾递归优化呢?回答是可以的,就是自己实现尾递归优化。

它的原理非常简单。尾递归之所以需要优化,原因是调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。怎么做可以减少调用栈呢?就是采用“循环”换掉“递归”。

下面是一个正常的递归函数。


function sum(x, y) {

if (y > 0) {

return sum(x + 1, y - 1);

} else {

return x;

}

}

sum(1, 100000)

// Uncaught RangeError: Maximum call stack size exceeded(…)

上面代码中,sum是一个递归函数,参数x是需要累加的值,参数y控制递归次数。一旦指定sum递归 100000 次,就会报错,提示超出调用栈的最大次数。

蹦床函数(trampoline)可以将递归执行转为循环执行。


function trampoline(f) {

while (f && f instanceof Function) {

f = f();

}

return f;

}

上面就是蹦床函数的一个实现,它接受一个函数f作为参数。只要f执行后返回一个函数,就继续执行。注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。

然后,要做的就是将原来的递归函数,改写为每一步返回另一个函数。


function sum(x, y) {

if (y > 0) {

return sum.bind(null, x + 1, y - 1);

} else {

return x;

}

}

上面代码中,sum函数的每次执行,都会返回自身的另一个版本。

现在,使用蹦床函数执行sum,就不会发生调用栈溢出。


trampoline(sum(1, 100000))

// 100001

蹦床函数并不是真正的尾递归优化,下面的实现才是。


function tco(f) {

var value;

var active = false;

var accumulated = [];

return function accumulator() {

accumulated.push(arguments);

if (!active) {

active = true;

while (accumulated.length) {

value = f.apply(this, accumulated.shift());

}

active = false;

return value;

}

};

}

var sum = tco(function(x, y) {

if (y > 0) {

return sum(x + 1, y - 1)

}

else {

return x

}

});

sum(1, 100000)

// 100001

上面代码中,tco函数是尾递归优化的实现,它的奥妙就在于状态变量active。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum返回的都是undefined,所以就避免了递归执行;而accumulated数组存放每一轮sum执行的参数,总是有值的,这就保证了accumulator函数内部的while循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。

函数参数的尾逗号

ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。

此前,函数定义和调用时,都不允许最后一个参数后面出现逗号。


function clownsEverywhere(

param1,

param2

) { /* ... */ }

clownsEverywhere(

'foo',

'bar'

);

上面代码中,如果在param2bar后面加一个逗号,就会报错。

如果像上面这样,将参数写成多行(即每个参数占据一行),以后修改代码的时候,想为函数clownsEverywhere添加第三个参数,或者调整参数的次序,就势必要在原来最后一个参数后面添加一个逗号。这对于版本管理系统来说,就会显示添加逗号的那一行也发生了变动。这看上去有点冗余,因此新的语法允许定义和调用时,尾部直接有一个逗号。


function clownsEverywhere(

param1,

param2,

) { /* ... */ }

clownsEverywhere(

'foo',

'bar',

);

这样的规定也使得,函数参数与数组和对象的尾逗号规则,保持一致了。

Function.prototype.toString()

ES2019 对函数实例的toString()方法做出了修改。

toString()方法返回函数代码本身,以前会省略注释和空格。


function /* foo comment */ foo () {}

foo.toString()

// function foo() {}

上面代码中,函数foo的原始代码包含注释,函数名foo和圆括号之间有空格,但是toString()方法都把它们省略了。

修改后的toString()方法,明确要求返回一模一样的原始代码。


function /* foo comment */ foo () {}

foo.toString()

// "function /* foo comment */ foo () {}"

catch 命令的参数省略

JavaScript 语言的try...catch结构,以前明确要求catch命令后面必须跟参数,接受try代码块抛出的错误对象。


try {

// ...

} catch (err) {

// 处理错误

}

上面代码中,catch命令后面带有参数err

很多时候,catch代码块可能用不到这个参数。但是,为了保证语法正确,还是必须写。ES2019 做出了改变,允许catch语句省略参数。


try {

// ...

} catch {

// ...

}

JavaScript 模块

这篇指南会给你入门 JavaScript 模块的全部信息。

模块化的背景

JavaScript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 JavaScript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。

因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。Node.js 已经提供这个能力很长时间了,还有很多的 JavaScript 库和框架已经开始了模块的使用(例如,CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 WebpackBabel)。

好消息是,最新的浏览器开始原生支持模块功能了,这是本文要重点讲述的。这会是一个好事情 —- 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。

浏览器支持

使用 JavaScript 模块依赖于 importexport,浏览器兼容性如下(绿色方框中的数字对应相应平台上支持该功能的发布版本):

import

[Report problems with this compatibility data on GitHub](https://github.com/mdn/browser-compat-data/issues/new?mdn-url=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FJavaScript%2FGuide%2FModules&metadata= MDN+page+report+details<%2Fsummary> *+Query%3A+javascript.statements.import *+Report+started%3A+2022-06-26T05%3A12%3A37.824Z <%2Fdetails>&title=javascript.statements.import+-+&template=data-problem.yml)

|                      |     desktop      |      mobile      |          server          |                   |                  |                    |                  |                          |                  |                    |                    |                  |                   |                              |

| :------------------- | :--------------: | :--------------: | :----------------------: | :---------------: | :--------------: | :----------------: | :--------------: | :----------------------: | :--------------: | :----------------: | :----------------: | :--------------: | :---------------: | ---------------------------- |

|                      |      Chrome      |       Edge       |         Firefox          | Internet Explorer |      Opera       |       Safari       |  Chrome Android  |   Firefox for Android    |  Opera Android   |   Safari on iOS    |  Samsung Internet  | WebView Android  |       Deno        | Node.js                      |

| import             | 61Toggle history | 16Toggle history |     60Toggle history     | NoToggle history  | 48Toggle history | 10.1Toggle history | 61Toggle history |     60Toggle history     | 45Toggle history | 10.3Toggle history | 8.0Toggle history  | 61Toggle history | 1.0Toggle history | 13.2.0footnoteToggle history |

| Available in workers | 80Toggle history | 80Toggle history | NofootnoteToggle history | NoToggle history  | NoToggle history |  15Toggle history  | 80Toggle history | NofootnoteToggle history | NoToggle history |  15Toggle history  | 13.0Toggle history | 80Toggle history | 1.0Toggle history | NoToggle history             |

Legend

export

[Report problems with this compatibility data on GitHub](https://github.com/mdn/browser-compat-data/issues/new?mdn-url=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FJavaScript%2FGuide%2FModules&metadata= MDN+page+report+details<%2Fsummary> *+Query%3A+javascript.statements.export *+Report+started%3A+2022-06-26T05%3A12%3A37.832Z <%2Fdetails>&title=javascript.statements.export+-+&template=data-problem.yml)

|                                                              |     desktop      |      mobile      |      server      |                   |                  |                    |                  |                     |                  |                    |                    |                  |                   |                              |

| :----------------------------------------------------------- | :--------------: | :--------------: | :--------------: | :---------------: | :--------------: | :----------------: | :--------------: | :-----------------: | :--------------: | :----------------: | :----------------: | :--------------: | :---------------: | ---------------------------- |

|                                                              |      Chrome      |       Edge       |     Firefox      | Internet Explorer |      Opera       |       Safari       |  Chrome Android  | Firefox for Android |  Opera Android   |   Safari on iOS    |  Samsung Internet  | WebView Android  |       Deno        | Node.js                      |

| export                                                     | 61Toggle history | 16Toggle history | 60Toggle history | NoToggle history  | 48Toggle history | 10.1Toggle history | 61Toggle history |  60Toggle history   | 45Toggle history | 10.3Toggle history | 8.0Toggle history  | 61Toggle history | 1.0Toggle history | 13.2.0footnoteToggle history |

| default keyword with export | 61Toggle history | 16Toggle history | 60Toggle history | NoToggle history  | 48Toggle history | 10.1Toggle history | 61Toggle history |  60Toggle history   | 45Toggle history | 10.3Toggle history | 8.0Toggle history  | NoToggle history | 1.0Toggle history | 13.2.0footnoteToggle history |

| export * as namespace | 72Toggle history | 79Toggle history | 80Toggle history | NoToggle history  | 60Toggle history | 14.1Toggle history | 72Toggle history |  80Toggle history   | 51Toggle history | 14.5Toggle history | 11.0Toggle history | NoToggle history | 1.0Toggle history | 13.2.0footnoteToggle history |

Legend

介绍一个例子

为了演示模块的使用,我们创建了一个 simple set of examples ,你可以在 Github 上找到。这个例子演示了一个简单的模块的集合用来在 web 页面上创建了一个 `` 标签,在 canvas 上绘制 (并记录有关的信息) 不同形状。

这的确有点简单,但是保持足够简单能够清晰地演示模块。

**备注:**如果你想去下载这个例子在本地运行,你需要通过本地 web 服务器去运行。

基本的示例文件的结构

在我们的第一个例子 (see basic-modules) 文件结构如下:


index.html

main.mjs

modules/

canvas.mjs

square.mjs

**备注:**在这个指南的全部示例项目的文件结构是基本相同的;需要熟悉上面的内容

modules 目录下的两个模块的描述如下:

canvas.mjs


— 包含与设置画布相关的功能:

- `create()` — 在指定 ID 的包装器 [``](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/div) 内创建指定 `width` 和 `height` 的画布,该 ID 本身附加在指定的父元素内。返回包含画布的 2D 上下文和包装器 ID 的对象。

- `createReportList()` — 创建一个附加在指定包装器元素内的无序列表,该列表可用于将报告数据输出到。返回列表的 ID。

- ```

square.mjs

— 包含:

**备注:**在原生 JavaScript 模块中,扩展名 .mjs 非常重要,因为使用 MIME-type 为 javascript/esm 来导入文件(其他的 JavaScript 兼容 MIME-type 像 application/javascript 也可以),它避免了严格的 MIME 类型检查错误,像 "The server responded with a non-JavaScript MIME type"。除此之外,.mjs 的扩展名很明了(比如这个就是一个模块,而不是一个传统 JavaScript 文件),还能够和其他工具互相适用。看这个 Google's note for further details

.mjs.js

纵观此文,我们使用 .js 扩展名的模块文件,但在其它一些文章中,你可能会看到 .mjs 扩展名的使用。V8 推荐了这样的做法,比如有下列理由:

但是我们决定继续使用 .js 扩展名,未来可能会更改。为了使模块可以在浏览器中正常地工作,你需要确保你的服务器能够正常地处理 Content-Type 头,其应该包含 JavaScript 的 MIME 类型 text/javascript。如果没有这么做,你可能会得到 一个严格 MIME 类型检查错误:“The server responded with a non-JavaScript MIME type(服务器返回了非 JavaScript MIME 类型)”,并且浏览器会拒绝执行相应的 JavaScript 代码。多数服务器可以正确地处理 .js 文件的类型,但是 .mjs 还不行。已经可以正常响应 .mjs 的服务器有 GitHub 页面 和 Node.js 的 http-server

如果你已经在使用相应的环境了,那么一切正常。或者如果你还没有,但你知道你在做什么(比如你可以配置服务器以为 .mjs 设置正确的 Content-Type)。但如果你不能控制提供服务,或者用于公开文件发布的服务器,这可能会导致混乱。

为了学习和保证代码的可移植的目的,我们建议使用 .js

如果你认为使用 .mjs 仅用于模块带来的清晰性非常重要,但不想引入上面描述的相应问题,你可以仅在开发过程中使用 .mjs,而在构建过程中将其转换为 .js

另注意:

- ``<script type="module">` ` 属性用于指示引入的模块,你会在下面看到。

导出模块的功能

为了获得模块的功能要做的第一件事是把它们导出来。使用 export 语句来完成。

最简单的方法是把它(指上面的 export 语句)放到你想要导出的项前面,比如:


export const name = 'square';

export function draw(ctx, length, x, y, color) {

ctx.fillStyle = color;

ctx.fillRect(x, y, length, length);

return {

length: length,

x: x,

y: y,

color: color

};

}

Copy to Clipboard

你能够导出函数,varletconst, 和等会会看到的类。export 要放在最外层;比如你不能够在函数内使用 export

一个更方便的方法导出所有你想要导出的模块的方法是在模块文件的末尾使用一个 export 语句,语句是用花括号括起来的用逗号分割的列表。比如:


export { name, draw, reportArea, reportPerimeter };

Copy to Clipboard

导入功能到你的脚本

你想在模块外面使用一些功能,那你就需要导入他们才能使用。最简单的就像下面这样的:


import { name, draw, reportArea, reportPerimeter } from '/js-examples/modules/basic-modules/modules/square.mjs';

使用 import 语句,然后你被花括号包围的用逗号分隔的你想导入的功能列表,然后是关键字 from,然后是模块文件的路径。模块文件的路径是相对于站点根目录的相对路径,对于我们的 basic-modules 应该是 /js-examples/modules/basic-modules

当然,我们写的路径有一点不同 -- 我们使用点语法意味 “当前路径”,跟随着包含我们想要找的文件的路径。这比每次都要写下整个相对路径要好得多,因为它更短,使得 URL 可移植 -- 如果在站点层中你把它移动到不同的路径下面仍然能够工作(修订版 1889482)。

那么看看例子吧:


/js/examples/modules/basic-modules/modules/square.mjs

变成了


./modules/square.mjs

你可以在 main.mjs 中看到这些。

**备注:**在一些模块系统中你可以忽略文件扩展名(比如 '/model/squre')。这在原生 JavaScript 模块系统中不工作。此外,记住你需要包含最前面的正斜杠。 (修订版 1889482)

因为你导入了这些功能到你的脚本文件,你可以像定义在相同的文件中的一样去使用它。下面展示的是在 main.mjs 中的 import 语句下面的内容。


let myCanvas = create('myCanvas', document.body, 480, 320);

let reportList = createReportList(myCanvas.id);

let square1 = draw(myCanvas.ctx, 50, 50, 100, 'blue');

reportArea(square1.length, reportList);

reportPerimeter(square1.length, reportList);

Copy to Clipboard

应用模块到你的 HTML

现在我们只需要将 main.mjs 模块应用到我们的 HTML 页面。 这与我们将常规脚本应用于页面的方式非常相似,但有一些显着的差异。

首先,你需要把 type="module" 放到 `` 标签中,来声明这个脚本是一个模块:


<script type="module" src="main.mjs"></script>

你导入模块功能的脚本基本是作为顶级模块。如果省略它,Firefox 就会给出错误“SyntaxError: import declarations may only appear at top level of a module。

你只能在模块内部使用 importexport 语句;不是普通脚本文件。

**备注:**您还可以将模块导入内部脚本,只要包含 `type="module"`,例如 ``<script type="module">`  //include script here `</script>` `。

其他模块与标准脚本的不同

默认导出 versus 命名导出

到目前为止我们导出的功能都是由 named exports 组成 —- 每个项目(无论是函数,常量等)在导出时都由其名称引用,并且该名称也用于在导入时引用它。

还有一种导出类型叫做 default export —- 这样可以很容易地使模块提供默认功能,并且还可以帮助 JavaScript 模块与现有的 CommonJS 和 AMD 模块系统进行互操作(正如 ES6 In Depth: Modules by Jason Orendorff 的模块中所解释的那样;搜索“默认导出”)。

看个例子来解释它如何工作。在我们的基本模块 square.mjs 中,您可以找到一个名为 randomSquare() 的函数,它创建一个具有随机颜色,大小和位置的正方形。我们想作为默认导出,所以在文件的底部我们这样写:


export default randomSquare;

Copy to Clipboard

注意,不要大括号。

我们可以把 export default 放到函数前面,定义它为一个匿名函数,像这样:


export default function(ctx) {

...

}

Copy to Clipboard

在我们的 main.mjs 文件中,我们使用以下行导入默认函数:


import randomSquare from './modules/square.mjs';

Copy to Clipboard

同样,没有大括号,因为每个模块只允许有一个默认导出,我们知道 randomSquare 就是需要的那个。上面的那一行相当于下面的缩写:


import {default as randomSquare} from './modules/square.mjs';

Copy to Clipboard

**备注:**重命名导出项的 as 语法在下面的 重命名导出与导入 部分中进行了说明。

避免命名冲突

到目前为止,我们的 canvas 图形绘制模块看起来工作的很好。但是如果我们添加一个绘制其他形状的比如圆形或者矩形的模块会发生什么?这些形状可能会有相关的函数比如 draw()reportArea(),等等;如果我们用相同的名字导入不同的函数到顶级模块文件中,我们会收到冲突和错误。

幸运的是,有很多方法来避免。我们将会在下一个节看到。

重命名导出与导入

在你的 importexport 语句的大括号中,可以使用 as 关键字跟一个新的名字,来改变你在顶级模块中将要使用的功能的标识名字。因此,例如,以下两者都会做同样的工作,尽管方式略有不同:


// inside module.mjs

export {

function1 as newFunctionName,

function2 as anotherNewFunctionName

};

// inside main.mjs

import { newFunctionName, anotherNewFunctionName } from '/modules/module.mjs';

Copy to Clipboard


// inside module.mjs

export { function1, function2 };

// inside main.mjs

import { function1 as newFunctionName,

function2 as anotherNewFunctionName } from '/modules/module.mjs';

Copy to Clipboard

让我们看一个真实的例子。在我们的 重命名 目录中,您将看到与上一个示例中相同的模块系统,除了我们添加了 circle.mjstriangle.mjs 模块以绘制和报告圆和三角形。

在每个模块中,我们都有 export 相同名称的功能,因此每个模块底部都有相同的导出语句:


export { name, draw, reportArea, reportPerimeter };

Copy to Clipboard

将它们导入 main.mjs 时,如果我们尝试使用


import { name, draw, reportArea, reportPerimeter } from './modules/square.mjs';

import { name, draw, reportArea, reportPerimeter } from './modules/circle.mjs';

import { name, draw, reportArea, reportPerimeter } from './modules/triangle.mjs';

Copy to Clipboard

浏览器会抛出一个错误,例如“SyntaxError: redeclaration of import name”(Firefox)。

相反,我们需要重命名导入,使它们是唯一的:


import { name as squareName,

draw as drawSquare,

reportArea as reportSquareArea,

reportPerimeter as reportSquarePerimeter } from './modules/square.mjs';

import { name as circleName,

draw as drawCircle,

reportArea as reportCircleArea,

reportPerimeter as reportCirclePerimeter } from './modules/circle.mjs';

import { name as triangleName,

draw as drawTriangle,

reportArea as reportTriangleArea,

reportPerimeter as reportTrianglePerimeter } from './modules/triangle.mjs';

Copy to Clipboard

请注意,您可以在模块文件中解决问题,例如


// in square.mjs

export { name as squareName,

draw as drawSquare,

reportArea as reportSquareArea,

reportPerimeter as reportSquarePerimeter };

Copy to Clipboard


// in main.mjs

import { squareName, drawSquare, reportSquareArea, reportSquarePerimeter } from '/js-examples/modules/renaming/modules/square.mjs';

Copy to Clipboard

它也会起作用。你使用什么样的风格取决于你,但是单独保留模块代码并在导入中进行更改可能更有意义。当您从没有任何控制权的第三方模块导入时,这尤其有意义。

创建模块对象

上面的方法工作的挺好,但是有一点点混乱、亢长。一个更好的解决方是,导入每一个模块功能到一个模块功能对象上。可以使用以下语法形式:


import * as Module from '/modules/module.mjs';

Copy to Clipboard

这将获取 module.mjs 中所有可用的导出,并使它们可以作为对象模块的成员使用,从而有效地为其提供自己的命名空间。例如:


Module.function1()

Module.function2()

etc.

Copy to Clipboard

再次,让我们看一个真实的例子。如果您转到我们的 module-objects 目录,您将再次看到相同的示例,但利用上述的新语法进行重写。在模块中,导出都是以下简单形式:


export { name, draw, reportArea, reportPerimeter };

Copy to Clipboard

另一方面,导入看起来像这样:


import * as Canvas from './modules/canvas.mjs';

import * as Square from '/./modules/square.mjs';

import * as Circle from './modules/circle.mjs';

import * as Triangle from './modules/triangle.mjs';

Copy to Clipboard

在每种情况下,您现在可以访问指定对象名称下面的模块导入。


let square1 = Square.draw(myCanvas.ctx, 50, 50, 100, 'blue');

Square.reportArea(square1.length, reportList);

Square.reportPerimeter(square1.length, reportList);

Copy to Clipboard

因此,您现在可以像以前一样编写代码(只要您在需要时包含对象名称),并且导入更加整洁。

模块与类(class)

正如我们之前提到的那样,您还可以导出和导入类;这是避免代码冲突的另一种选择,如果您已经以面向对象的方式编写了模块代码,那么它尤其有用。

您可以在我们的 classes 目录中看到使用 ES 类重写的形状绘制模块的示例。 例如,square.mjs 文件现在包含单个类中的所有功能:


class Square {

constructor(ctx, listId, length, x, y, color) {

...

}

draw() {

...

}

...

}

Copy to Clipboard

然后我们导出:


export { Square };

Copy to Clipboard

main.mjs 中,我们像这样导入它:


import { Square } from './modules/square.mjs';

Copy to Clipboard

然后使用该类绘制我们的方块:


let square1 = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, 'blue');

square1.draw();

square1.reportArea();

square1.reportPerimeter();

Copy to Clipboard

合并模块

有时你会想要将模块聚合在一起。您可能有多个级别的依赖项,您希望简化事物,将多个子模块组合到一个父模块中。这可以使用父模块中以下表单的导出语法:


export * from 'x.mjs'

export { name } from 'x.mjs'

Copy to Clipboard

**备注:**这实际上是导入后跟导出的简写,即“我导入模块 x.mjs,然后重新导出部分或全部导出”。

有关示例,请参阅我们的 module-aggregation。 在这个例子中(基于我们之前的类示例),我们有一个名为 shapes.mjs 的额外模块,它将 circle.mjssquare.mjsriangle.mjs 中的所有功能聚合在一起。 我们还将子模块移动到名为 shapes 的 modules 目录中的子目录中。 所以模块结构现在是这样的:


modules/

canvas.mjs

shapes.mjs

shapes/

circle.mjs

square.mjs

triangle.mjs

在每个子模块中,输出具有相同的形式,例如,


export { Square };

Copy to Clipboard

接下来是聚合部分。 在 shapes.mjs 里面,我们包括以下几行:


export { Square } from '/js-examples/modules/module-aggregation/modules/shapes/square.mjs';

export { Triangle } from '/js-examples/modules/module-aggregation/modules/shapes/triangle.mjs';

export { Circle } from '/js-examples/modules/module-aggregation/modules/shapes/circle.mjs';

Copy to Clipboard

它们从各个子模块中获取导出,并有效地从 shapes.mjs 模块中获取它们。

**备注:**即使 shapes.mjs 文件位于 modules 目录中,我们仍然需要相对于模块根目录编写这些 URL,因此需要 /modules/。 这是使用 JavaScript 模块时混淆的常见原因。

备注:shapes.mjs 中引用的导出基本上通过文件重定向,并且实际上并不存在,因此您将无法在同一文件中编写任何有用的相关代码。

所以现在在 main.mjs 文件中,我们可以通过替换来访问所有三个模块类


import { Square } from './modules/square.mjs';

import { Circle } from './modules/circle.mjs';

import { Triangle } from './modules/triangle.mjs';

Copy to Clipboard

使用以下单行:


import { Square, Circle, Triangle } from './modules/shapes.mjs';

Copy to Clipboard

动态加载模块

浏览器中可用的 JavaScript 模块功能的最新部分是动态模块加载。这允许您仅在需要时动态加载模块,而不必预先加载所有模块。这有一些明显的性能优势;让我们继续阅读,看看它是如何工作的。

这个新功能允许您将 import() 作为函数调用,将其作为参数传递给模块的路径。它返回一个 promise,它用一个模块对象来实现(参见创建模块对象),让你可以访问该对象的导出,例如


import('/modules/myModule.mjs')

.then((module) => {

// Do something with the module.

});

Copy to Clipboard

我们来看一个例子。在 dynamic-module-imports 目录中,我们有另一个基于类示例的示例。但是这次我们在示例加载时没有在画布上绘制任何东西。相反,我们包括三个按钮 -- “圆形”,“方形”和“三角形” -- 按下时,动态加载所需的模块,然后使用它来绘制相关的形状。

在这个例子中,我们只对 index.htmlmain.mjs 文件进行了更改 -- 模块导出保持与以前相同。

main.mjs中,我们使用document.querySelector()调用获取了对每个按钮的引用,例如:


let squareBtn = document.querySelector('.square');

Copy to Clipboard

然后,我们为每个按钮附加一个事件监听器,以便在按下时,相关模块被动态加载并用于绘制形状:


squareBtn.addEventListener('click', () => {

import('/js-examples/modules/dynamic-module-imports/modules/square.mjs').then((Module) => {

let square1 = new Module.Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, 'blue');

square1.draw();

square1.reportArea();

square1.reportPerimeter();

})

});

Copy to Clipboard

请注意,由于 promise 履行会返回一个模块对象,因此该类成为对象的子特征,因此我们现在需要使用 Module 访问构造函数。在它之前,例如 Module.Square( ... )

故障排除

如果为了你的模块有问题,这里有一些提示有可能帮助到你。如果你发现更多的内容欢迎添加进来!

其他知识点补充

自调用函数

函数表达式可以 "自调用"。

自调用表达式会自动调用。

如果表达式后面紧跟 () ,则会自动调用。

不能自调用声明的函数。

通过添加括号,来说明它是一个函数表达式:

实例


(function () {

var x = "Hello!!";   // 我将调用自己

})();

以上函数实际上是一个 匿名自我调用的函数 (没有函数名)。

Array.prototype.reduce()

reduce() 方法对数组中的每个元素按序执行一个由您提供的 reducer 函数,每一次运行 reducer将先前元素的计算结果作为参数传入最后将其结果汇总为单个返回值

第一次执行回调函数时,不存在“上一次的计算结果”。如果需要回调函数从数组索引为 0 的元素开始执行,则需要传递初始值。否则,数组索引为 0 的元素将被作为初始值 initialValue,迭代器将从第二个元素开始执行(索引为 1 而不是 0)。

下面的例子能够帮助你理解 reduce() 的用处——计算数组所有元素的总和:

即如果传入initialValue参数, 那么迭代器就会从数组第一个元素开始执行(并且其previousValue就是initialValue). 否则如果不传入initialValue参数, 那么迭代器就会从第二个元素开始执行, 而实际上其previousValue就是第0个元素, currentValue就是第一个元素


const array1 = [1, 2, 3, 4];

// 0 + 1 + 2 + 3 + 4

const initialValue = 0;

const sumWithInitial = array1.reduce(

(previousValue, currentValue) => previousValue + currentValue,

initialValue

);

console.log(sumWithInitial);

// expected output: 10

冒泡和捕获

让我们从一个示例开始。

处理程序(handler)被分配给了 ``<div>` `,但是如果你点击任何嵌套的标签(例如 ``<em>` ` 或 ``<code>` `),该处理程序也会运行:

<div onclick="alert('The handler!')">

<em>If you click on <code>EM</code>, the handler on <code>DIV</code> runs.</em>

</div>
这是不是有点奇怪?如果实际上点击的是 ``<em>` `,为什么在 ``<div>` ` 上的处理程序会运行?

冒泡

冒泡(bubbling)原理很简单。

当一个事件发生在一个元素上,它会首先运行在该元素上的处理程序,然后运行其父元素上的处理程序,然后一直向上到其他祖先上的处理程序。

假设我们有 3 层嵌套 FORM > DIV > P,它们各自拥有一个处理程序:


<style>

body * {

margin: 10px;

border: 1px solid blue;

}

</style>

<form onclick="alert('form')">FORM

<div onclick="alert('div')">DIV

<p onclick="alert('p')">P</p>

</div>

</form>
点击内部的 ``<p>` ` 会首先运行 `onclick`:
1. 在该 ``<p>` ` 上的。
2. 然后是外部 ``<div>` ` 上的。
3. 然后是外部 ``<form>` ` 上的。
  1. 以此类推,直到最后的 document 对象。
因此,如果我们点击 ``<p>` `,那么我们将看到 3 个 alert:`p` → `div` → `form`。

这个过程被称为“冒泡(bubbling)”,因为事件从内部元素“冒泡”到所有父级,就像在水里的气泡一样。

*几乎* 所有事件都会冒泡。

这句话中的关键词是“几乎”。

例如,focus 事件不会冒泡。同样,我们以后还会遇到其他例子。但这仍然是例外,而不是规则,大多数事件的确都是冒泡的。

event.target

父元素上的处理程序始终可以获取事件实际发生位置的详细信息。

引发事件的那个嵌套层级最深的元素被称为目标元素,可以通过 event.target 访问。

注意与 this(=event.currentTarget)之间的区别:

例如,如果我们有一个处理程序 `form.onclick`,那么它可以“捕获”表单内的所有点击。无论点击发生在哪里,它都会冒泡到 ``<form>` ` 并运行处理程序。

form.onclick 处理程序中:

- `this`(=`event.currentTarget`)是 ``<form>` ` 元素,因为处理程序在它上面运行。

一探究竟:

结果

`<iframe class="code-tabs__result" src="https://zh.javascript.info/article/bubbling-and-capturing/bubble-target/" style="display: block; border: 0px; width: 794px; height: 220px; background: var(--iframeBg);">` `</iframe>`
`event.target` 可能会等于 `this` —— 当点击事件发生在 ``<form>` ` 元素上时,就会发生这种情况。

停止冒泡

冒泡事件从目标元素开始向上冒泡。通常,它会一直上升到 ``<html>` `,然后再到 `document` 对象,有些事件甚至会到达 `window`,它们会调用路径上所有的处理程序。

但是任意处理程序都可以决定事件已经被完全处理,并停止冒泡。

用于停止冒泡的方法是 event.stopPropagation()

例如,如果你点击 ``<button>` `,这里的 `body.onclick` 不会工作:

<body onclick="alert(`the bubbling doesn't reach here`)">

<button onclick="event.stopPropagation()">Click me</button>

</body>

event.stopImmediatePropagation()

如果一个元素在一个事件上有多个处理程序,即使其中一个停止冒泡,其他处理程序仍会执行。

换句话说,event.stopPropagation() 停止向上移动,但是当前元素上的其他处理程序都会继续运行。

有一个 event.stopImmediatePropagation() 方法,可以用于停止冒泡,并阻止当前元素上的处理程序运行。使用该方法之后,其他处理程序就不会被执行。

不要在没有需要的情况下停止冒泡!

冒泡很方便。不要在没有真实需求时阻止它:除非是显而易见的,并且在架构上经过深思熟虑的。

有时 event.stopPropagation() 会产生隐藏的陷阱,以后可能会成为问题。

例如:

  1. 我们创建了一个嵌套菜单,每个子菜单各自处理对自己的元素的点击事件,并调用 stopPropagation,以便不会触发外部菜单。

  2. 之后,我们决定捕获在整个窗口上的点击,以追踪用户的行为(用户点击的位置)。有些分析系统会这样做。通常,代码会使用 document.addEventListener('click'…) 来捕获所有的点击。

  3. 我们的分析不适用于被 stopPropagation 所阻止点击的区域。太伤心了,我们有一个“死区”。

通常,没有真正的必要去阻止冒泡。一项看似需要阻止冒泡的任务,可以通过其他方法解决。其中之一就是使用自定义事件,稍后我们会介绍它们此外,我们还可以将我们的数据写入一个处理程序中的 event 对象,并在另一个处理程序中读取该数据,这样我们就可以向父处理程序传递有关下层处理程序的信息。

捕获

事件处理的另一个阶段被称为“捕获(capturing)”。它很少被用在实际开发中,但有时是有用的。

DOM 事件标准描述了事件传播的 3 个阶段:

  1. 捕获阶段(Capturing phase)—— 事件(从 Window)向下走近元素。

  2. 目标阶段(Target phase)—— 事件到达目标元素。

  3. 冒泡阶段(Bubbling phase)—— 事件从元素上开始冒泡。

下面是在表格中点击 ``<td>` ` 的图片,摘自规范:
也就是说:点击 ``<td>` `,事件首先通过祖先链向下到达元素(捕获阶段),然后到达目标(目标阶段),最后上升(冒泡阶段),在途中调用处理程序。

之前,我们只讨论了冒泡,因为捕获阶段很少被使用。通常我们看不到它。

使用 on 属性或使用 HTML 特性(attribute)或使用两个参数的 addEventListener(event, handler) 添加的处理程序,对捕获一无所知,它们仅在第二阶段和第三阶段运行。

为了在捕获阶段捕获事件,我们需要将处理程序的 capture 选项设置为 true


elem.addEventListener(..., {capture: true})

// 或者,用 {capture: true} 的别名 "true"

elem.addEventListener(..., true)

capture 选项有两个可能的值:

请注意,虽然形式上有 3 个阶段,但第 2 阶段(“目标阶段”:事件到达元素)没有被单独处理:捕获阶段和冒泡阶段的处理程序都在该阶段被触发。

让我们来看看捕获和冒泡:


<style>

body * {

margin: 10px;

border: 1px solid blue;

}

</style>

<form>FORM

<div>DIV

<p>P</p>

</div>

</form>

<script>

for(let elem of document.querySelectorAll('*')) {

elem.addEventListener("click", e => alert(`Capturing: ${elem.tagName}`), true);

elem.addEventListener("click", e => alert(`Bubbling: ${elem.tagName}`));

}

</script>

上面这段代码为文档中的 每个 元素都设置了点击处理程序,以查看哪些元素上的点击事件处理程序生效了。

如果你点击了 ``<p>` `,那么顺序是:
  1. HTMLBODYFORMDIV(捕获阶段第一个监听器):

  2. P(目标阶段,触发两次,因为我们设置了两个监听器:捕获和冒泡)

  3. DIVFORMBODYHTML(冒泡阶段,第二个监听器)。

有一个属性 event.eventPhase,它告诉我们捕获事件的阶段数。但它很少被使用,因为我们通常是从处理程序中了解到它。

要移除处理程序,removeEventListener 需要同一阶段

如果我们 addEventListener(..., true),那么我们应该在 removeEventListener(..., true) 中提到同一阶段,以正确删除处理程序。

同一元素的同一阶段的监听器按其设置顺序运行

如果我们在同一阶段有多个事件处理程序,并通过 addEventListener 分配给了相同的元素,则它们的运行顺序与创建顺序相同:


elem.addEventListener("click", e => alert(1)); // 会先被触发

elem.addEventListener("click", e => alert(2));

总结

当一个事件发生时 —— 发生该事件的嵌套最深的元素被标记为“目标元素”(event.target)。

每个处理程序都可以访问 event 对象的属性:

任何事件处理程序都可以通过调用 event.stopPropagation() 来停止事件,但不建议这样做,因为我们不确定是否确实不需要冒泡上来的事件,也许是用于完全不同的事情。

捕获阶段很少使用,通常我们会在冒泡时处理事件。这背后有一个逻辑。

在现实世界中,当事故发生时,当地警方会首先做出反应。他们最了解发生这件事的地方。然后,如果需要,上级主管部门再进行处理。

事件处理程序也是如此。在特定元素上设置处理程序的代码,了解有关该元素最详尽的信息。特定于 ``<td>` ` 的处理程序可能恰好适合于该 ``<td>` `,这个处理程序知道关于该元素的所有信息。所以该处理程序应该首先获得机会。然后,它的直接父元素也了解相关上下文,但了解的内容会少一些,以此类推,直到处理一般性概念并运行最后一个处理程序的最顶部的元素为止。

冒泡和捕获为“事件委托”奠定了基础 —— 一种非常强大的事件处理模式,我们将在下一章中进行研究。

冒泡和捕获的原文见: 冒泡和捕获 (javascript.info)

事件委托

基本概念

事件委托,通俗地来讲,就是把一个元素响应事件(click、keydown......)的函数委托到另一个元素;

一般来讲,会把一个或者一组元素的事件委托到它的父层或者更外层元素上,真正绑定事件的是外层元素,当事件响应到需要绑定的元素上时,会通过事件冒泡机制从而触发它的外层元素的绑定事件上,然后在外层元素上去执行函数。

举个例子,比如一个宿舍的同学同时快递到了,一种方法就是他们都傻傻地一个个去领取,还有一种方法就是把这件事情委托给宿舍长,让一个人出去拿好所有快递,然后再根据收件人一一分发给每个宿舍同学;

在这里,取快递就是一个事件,每个同学指的是需要响应事件的 DOM 元素,而出去统一领取快递的宿舍长就是代理的元素,所以真正绑定事件的是这个元素,按照收件人分发快递的过程就是在事件执行中,需要判断当前响应的事件应该匹配到被代理元素中的哪一个或者哪几个。

事件冒泡

前面提到 DOM 中事件委托的实现是利用事件冒泡的机制,那么事件冒泡是什么呢?

在 document.addEventListener 的时候我们可以设置事件模型:事件冒泡、事件捕获,一般来说都是用事件冒泡的模型;

img

如上图所示,事件模型是指分为三个阶段:

委托的优点

  1. 减少内存消耗

试想一下,若果我们有一个列表,列表之中有大量的列表项,我们需要在点击列表项的时候响应一个事件;


<ul id="list">

<li>item 1</li>

<li>item 2</li>

<li>item 3</li>

......

<li>item n</li>

</ul>

// ...... 代表中间还有未知数个 li

如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能;

因此,比较好的方法就是把这个点击事件绑定到他的父层,也就是 ul 上,然后在执行事件的时候再去匹配判断目标元素;

所以事件委托可以减少大量的内存消耗,节约效率。

  1. 动态绑定事件

比如上述的例子中列表项就几个,我们给每个列表项都绑定了事件;

在很多时候,我们需要通过 AJAX 或者用户操作动态的增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件;

如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的;

所以使用事件在动态绑定事件的情况下是可以减少很多重复工作的。