落笔 Blog

落笔
Be a hero of your own
  1. 首页
  2. 书籍系列
  3. 红宝书学习记录
  4. 正文

十 十一章:函数&期约与异步函数

2023年1月19日 835点热度 0人点赞 0条评论

函数

箭头函数

  1. 如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号

  2. 箭头函数也可以不用大括号,但这样会改变函数的行为。使用大括号就说明包含“函数体”,可以在一个函数中包含多条语句,跟常规的函数一样。如果不使用大括号,那么箭头后面就只能有一行代码,比如一个赋值操作,或者一个表达式。而且,省略大括号会隐式返回这行代码的值:

  // 以下两种写法都有效,而且返回相应的值
  let double = (x) => { return 2 * x; }; 
  let triple = (x) => 3 * x; 

// 可以赋值
let value = {}; 
let setName = (x) => x.name = "Matt"; 
setName(value); 
console.log(value.name); // "Matt"

// 无效的写法:
let multiply = (a, b) => return a * b;
  1. 箭头函数不能使用 arguments、super 和new.target,也不能用作构造函数。此外,箭头函数也没有 prototype 属性。

函数名

  1. 因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着一个函数可以有多个名称
  2. ECMAScript 6 的所有函数对象都会暴露一个只读的 name 属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用 Function 构造函数创建的,则会标识成"anonymous":
function foo() {} 
let bar = function() {}; 
let baz = () => {}; 
console.log(foo.name); // foo 
console.log(bar.name); // bar 
console.log(baz.name); // baz 
console.log((() => {}).name); //(空字符串)
console.log((new Function()).name); // anonymous
  1. 如果函数是一个获取函数、设置函数,或者使用 bind()实例化,那么标识符前面会加上一个前缀:
function foo() {} 
console.log(foo.bind(null).name); // bound foo

let dog = { 
years: 1, 
get age() { 
return this.years; 
}, 
set age(newAge) { 
this.years = newAge; 
} 
}

let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age');

console.log(propertyDescriptor.get.name); // get age 
console.log(propertyDescriptor.set.name); // set age

理解参数

在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值,arguments 对象是一个类数组对象(但不是 Array 的实例),可以访问 arguments.length 属性。(还有一个必须理解的重要方面,那就是 arguments 对象可以跟命名参数一起使用)
arguments 对象的另一个有意思的地方就是,它的值始终会与对应的命名参数同步。

function doAdd(num1, num2) { 
arguments[1] = 10; 
console.log(arguments[0] + num2); 
}

这个 doAdd()函数把第二个参数的值重写为 10。因为 arguments 对象的值会自动同步到对应的命名参数,所以修改 arguments[1]也会修改 num2 的值,因此两者的值都是 10。但这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已。另外还要记住一点:如果只
传了一个参数,然后把 arguments[1]设置为某个值,那么这个值并不会反映到第二个命名参数。这是因为 arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。

箭头函数中的参数

虽然箭头函数中没有 arguments 对象,但可以在包装函数中把它提供给箭头函数:

function foo() { 
let bar = () => { 
console.log(arguments[0]); // 5 
}; 
bar(); 
} 
foo(5);

tips:ECMAScript 中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。

没有重载

在其他语言比如 Java 中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。如前所述,ECMAScript 函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。即:后面定义的同名函数会覆盖之前定义的。

默认参数值

ECMAScript 6 之支持显式定义默认参数了。下面就是与前面代码等价的 ES6 写法,只要在函数定义中的参数后面用=就可以为参数赋一个默认值:

function makeKing(name = 'Henry') { 
return `King ${name} VIII`; 
} 
console.log(makeKing('Louis')); // 'King Louis VIII' 
console.log(makeKing()); // 'King Henry VIII'
  1. 给参数传 undefined 相当于没有传值,不过这样可以利用多个独立的默认值
  2. 在使用默认参数时,arguments 对象的值不反映参数的默认值,只反映传给函数的参数。
function makeKing(name = 'Henry') { 
name = 'Louis'; //修改命名参数也不会影响 arguments 对象
 return `King ${arguments[0]}`; 
} 
console.log(makeKing()); // 'King undefined'  
console.log(makeKing('Louis')); // 'King Louis'
  1. 默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值:
let romanNumerals = ['1', '2', '3', '4', '5', '6']; 
let ordinality = 0; 
function getNumerals() { 
// 每次调用后递增
 return romanNumerals[ordinality++]; 
} 
function makeKing(name = 'hyh', numerals = getNumerals()) { 
return `King {name}{numerals}`; 
} 
console.log(makeKing()); // 'King hyh 1' =>return romanNumerals[0++] => 输出romanNumerals[0] 后 ordinality == 1
console.log(makeKing('Louis', '8')); // 'King Louis 8' => 不关getNumerals函数的事,ordinality自然也不变,就是传入参数
console.log(makeKing()); // 'King hyh 2' =>return romanNumerals[1++] => 输出romanNumerals[1] => 2
console.log(makeKing()); // 'King hyh 3 =>return romanNumerals[2++] => 输出romanNumerals[2] => 3

即:函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。

默认参数作用域与暂时性死区

给多个参数定义默认值实际上跟使用 let 关键字顺序声明变量一样。默认参数会按照定义它们的顺序依次被初始化,所以后定义默认值的参数可以引用先定义的参数。

function makeKing(name = 'Henry', numerals = name) { 
return `King {name}{numerals}`; 
} 
console.log(makeKing()); // King Henry Henry

参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。像这样就会抛出错误:

// 调用时不传第一个参数会报错
function makeKing(name = numerals, numerals = 'VIII') { 
return `King {name}{numerals}`; 
}

参数扩展与收集

扩展操作符最有用的场景就是函数定义中的参数列表,在这里它可以充分利用这门语言的弱类型及参数长度可变的特点。扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。
es5场景,传入数组参数:

let values = [1, 2, 3, 4]; 
function getSum() { //求和函数的参数是个数组
 let sum = 0; 
for (let i = 0; i < arguments.length; ++i) { //打开数组相加求和
 sum += arguments[i]; 
} 
return sum; 
}
console.log(getSum.apply(null, values)); // 10

这样很麻烦,在 ECMAScript 6 中,可以通过扩展操作符极为简洁地实现这种操作。
上述的求和仅需(且并不妨碍在其前面或后面再传其他的值):

console.log(getSum(...values)); // 10

console.log(getSum(-1, ...values)); // 9 
console.log(getSum(...values, 5)); // 15 
console.log(getSum(-1, ...values, 5)); // 14 
console.log(getSum(...values, ...[5,6,7])); // 28

对函数中的 arguments 对象而言,它并不知道扩展操作符的存在,而是按照调用函数时传入的参数接收每一个值

arguments 对象只是消费扩展操作符的一种方式。在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数:

function getProduct(a, b, c = 1) { 
return a * b * c; 
}

let getSum = (a, b, c = 0) => { 
return a + b + c; 
}

console.log(getProduct(...[1,2])); // 2 
console.log(getProduct(...[1,2,3])); // 6 
console.log(getProduct(...[1,2,3,4])); // 6

console.log(getSum(...[0,1])); // 1 
console.log(getSum(...[0,1,2])); // 3 
console.log(getSum(...[0,1,2,3])); // 3

收集参数

在构思函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组,收集参数的结果会得到一个 Array 实例。

function getSum(...values) { //定义的时候收集了参数,得到一个 Array 实例,即:values为参数数组
 // 顺序累加 values 中的所有值
 // 初始值的总和为 0  
 return values.reduce((x, y) => x + y, 0); //在参数数组里进行累加
} 
console.log(getSum(1,2,3)); // 6

收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变,所以只能把它作为最后一个参数:

// 不可以
function getProduct(...values, lastValue) {}

// 可以
function ignoreFirst(firstValue, ...values) { 
console.log(values); 
}

ignoreFirst(); // [] 
ignoreFirst(1); // [] //此时参数firstValue赋值为1,values仍是空数组
ignoreFirst(1,2); // [2] //此时参数firstValue赋值为1,values为:[2]
ignoreFirst(1,2,3); // [2, 3]

箭头函数虽然不支持 arguments 对象,但支持收集参数的定义方式,因此也可以实现与使用arguments 一样的逻辑:

let getSum = (...values) => { //这就是箭头函数获取arguments的方法,没有arguments但可以通过这样获取到
 return values.reduce((x, y) => x + y, 0); 
} 
console.log(getSum(1,2,3)); // 6

另外,普通函数使用收集参数并不影响 arguments 对象,它仍然反映调用时传给函数的参数。
个人总结:反正就是很好用,很多地方都用得到,很关键

函数声明与函数表达式

函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升(function declaration hoisting)

// 没问题 
console.log(sum(10, 10)); 
function sum(num1, num2) { 
return num1 + num2; 
}

// 会出错
console.log(sum(10, 10)); 
let sum = function(num1, num2) { 
return num1 + num2; 
};

函数作为值

因为函数名在 ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。来看下面的例子:

function callSomeFunction(someFunction, someArgument) { 
return someFunction(someArgument); 
}

function add10(num) { 
return num + 10; 
}

let result1 = callSomeFunction(add10, 10); 
console.log(result1); // 20

function getGreeting(name) { 
return "Hello, " + name; 
}

let result2 = callSomeFunction(getGreeting, "Nicholas"); 
console.log(result2); // "Hello, Nicholas"

这个函数接收两个参数。第一个参数应该是一个函数,第二个参数应该是要传给这个函数的值。callSomeFunction()函数是通用的,第一个参数传入的是什么函数都可以,而且它始终返回调用作为第一个参数传入的函数的结果。(要注意的是,如果是访问函数而不是调用函数,那就必须不带括号)所以传给 callSomeFunction()的必须是 add10 和 getGreeting,而不能是它们的执行结果。

从一个函数中返回另一个函数也是可以的,而且非常有用。例如,假设有一个包含对象的数组,而我们想按照任意对象属性对数组进行排序。为此,可以定义一个 sort()方法需要的比较函数,它接收两个参数,即要比较的值。但这个比较函数还需要想办法确定根据哪个属性来排序。这个问题可以通过定义一个根据属性名来创建比较函数的函数来解决。比如:

function createComparisonFunction(propertyName) { 
return function(object1, object2) { 
let value1 = object1[propertyName]; 
let value2 = object2[propertyName]; 
if (value1 < value2) { 
return -1; 
} else if (value1 > value2) { 
return 1; 
} else { 
return 0; 
} 
}; 
}

这个函数的语法乍一看比较复杂,但实际上就是在一个函数中返回另一个函数,注意那个 return操作符。内部函数可以访问 propertyName 参数,并通过中括号语法取得要比较的对象的相应属性值。取得属性值以后,再按照 sort()方法的需要返回比较值就行了。这个函数可以像下面这样使用:

let data = [ 
{name: "Zachary", age: 28}, 
{name: "Nicholas", age: 29} 
]; 
data.sort(createComparisonFunction("name")); //先是createComparisonFunction("name")返回-1/1/0,sort根据这个排序
console.log(data[0].name); // Nicholas  
data.sort(createComparisonFunction("age")); 
console.log(data[0].name); // Zachary

结合了sort的排序原理,很关键

函数内部

在 ECMAScript 5 中,函数内部存在两个特殊的对象:arguments 和 this。ECMAScript 6 又新增了 new.target 属性。

arguments

arguments 对象其实还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。
阶乘函数一般定义成递归调用的,就像下面这个例子一样。只要给函数一个名称,而且这个名称不会变,这样定义就没有问题。但是,这个函数要正确执行就必须保证函数名是 factorial,从而导致了紧密耦合。使用 arguments.callee 就可以让函数逻辑与函数名解耦:

//经典阶乘函数:
function factorial(num) { 
if (num <= 1) { 
return 1; 
} else { 
return num * factorial(num - 1); 
} 
}

//使用arguments.callee 
function factorial(num) { 
if (num <= 1) { 
return 1; 
} else { 
return num * arguments.callee(num - 1); 
} 
}

this

普通函数里this指向,在函数被调用时才能确定,指向它的调用者。
箭头函数里this指向,this指向的是定义箭头函数的上下文。

在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。

function King() { 
this.royaltyName = 'Henry'; 
// this 引用 King 的实例
 setTimeout(() => console.log(this.royaltyName), 1000); 
}

function Queen() { 
this.royaltyName = 'Elizabeth'; 
// this 引用 window 对象
 setTimeout(function() { console.log(this.royaltyName); }, 1000); 
}

new King(); // Henry 
new Queen(); // undefined

caller

ECMAScript 5 也会给函数对象上添加一个属性:caller。
在严格模式下访问 arguments.callee 会报错。ECMAScript 5 也定义了 arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是 undefined。这是为了分清 arguments.caller和函数的 caller 而故意为之的。而作为对这门语言的安全防护,这些改动也让第三方代码无法检测同一上下文中运行的其他代码。

new.target

ECMAScript 6 新增了检测函数是否使用 new 关键字调用的 new.target 属性。如果函数是正常调用的,则 new.target 的值是 undefined;如果是使用 new关键字调用的,则 new.target 将引用被调用的构造函数。

function King() { 
if (!new.target) { 
throw 'King must be instantiated using "new"' 
} 
console.log('King instantiated using "new"'); 
}

new King(); // King instantiated using "new" 
King(); // Error: King must be instantiated using "new"

函数属性与方法

每个函数都有两个属性:length和 prototype。
其中,length 属性保存函数定义的命名参数的个数
prototype 属性也许是 ECMAScript 核心中最有趣的部分。prototype 是保存引用类型所有实例方法的地方。

函数还有两个方法:apply()和 call()。
apply()方法接收两个参数:函数内 this 的值和一个参数数组。第二个参数可以是 Array 的实例,但也可以是 arguments 对象。
call()方法与 apply()的作用一样,只是传参的形式不同。第一个参数跟 apply()一样,也是 this值,而剩下的要传给被调用函数的参数则是逐个传递的。
即:如果想直接传 arguments对象或者一个数组,那就用 apply();否则,就用 call()。

ECMAScript 5 出于同样的目的定义了一个新方法:bind()。bind()方法会创建一个新的函数实例,其 this 值会被绑定到传给 bind()的对象。

函数表达式

  1. 函数声明,关键特点是函数声明提升
  2. 函数表达式,这样创建的函数叫作匿名函数(anonymous funtion),未赋值给其他变量的匿名函数的 name 属性是空字符串。
    创建函数并赋值给变量的能力也可以用于在一个函数中把另一个函数当作值返回。

递归

上文

function factorial(num) { 
if (num <= 1) { 
return 1; 
} else { 
return num * arguments.callee(num - 1); 
} 
}

在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。此时,可以使用命名函数表达式(named function expression)达到目的。

const factorial = (function f(num) { //创建了一个命名函数表达式 f(),然后将它赋值给了变量 factorial
 if (num <= 1) { 
return 1; 
} else { 
return num * f(num - 1); 
} 
});

尾调用优化

ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。
具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:

function outerFunction() { 
return innerFunction(); // 尾调用
}

在 ES6 优化之前,执行这个例子会在内存中发生如下操作。
(1) 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
(2) 执行 outerFunction 函数体,到 return 语句。计算返回值必须先计算 innerFunction。
(3) 执行到 innerFunction 函数体,第二个栈帧被推到栈上。
(4) 执行 innerFunction 函数体,计算其返回值。
(5) 将返回值传回 outerFunction,然后 outerFunction 再返回值。
(6) 将栈帧弹出栈外。
在 ES6 优化之后,执行这个例子会在内存中发生如下操作。
(1) 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
(2) 执行 outerFunction 函数体,到达 return 语句。为求值返回语句,必须先求值 innerFunction。
(3) 引擎发现把第一个栈帧弹出栈外也没问题,因为 innerFunction 的返回值也是 outerFunction
的返回值。
(4) 弹出 outerFunction 的栈帧。
(5) 执行到 innerFunction 函数体,栈帧被推到栈上。
(6) 执行 innerFunction 函数体,计算其返回值。
(7) 将 innerFunction 的栈帧弹出栈外。
很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是 ES6 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

尾调用优化的条件

尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
代码在严格模式下执行;
外部函数的返回值是对尾调用函数的调用;
尾调用函数返回后不需要执行额外的逻辑;
尾调用函数不是引用外部函数作用域中自由变量的闭包。

尾调用优化的代码

下面是一个通过递归计算斐波纳契数列的函数,显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。结果,fib(n)的栈帧数的内存复杂度是 O(2n)。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:

function fib(n) { 
if (n < 2) { 
return n; 
} 
return fib(n - 1) + fib(n - 2); 
} 
console.log(fib(0)); // 0 
console.log(fib(1)); // 1 
console.log(fib(2)); // 1 
console.log(fib(3)); // 2 
console.log(fib(4)); // 3 
console.log(fib(5)); // 5 
console.log(fib(6)); // 8

//可以当参数比较大的时候,会爆栈,浏览器会卡死
console.log(fib(100)); //爆栈,浏览器卡死

当然,解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。不过,也可以保持递归实现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:

"use strict"; 
// 基础框架 
function fib(n) { 
return fibImpl(0, 1, n); 
} 
// 执行递归
function fibImpl(a, b, n) { 
if (n === 0) { 
return a; 
} 
return fibImpl(b, a + b, n - 1); //满足尾调用优化的条件,这样一次就一个帧栈,不会爆栈
}

console.log(fib(0)); // 0 
console.log(fib(1)); // 1 
console.log(fib(100)); //354224848179262000000

闭包

引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

this 对象

在闭包中使用 this 会让代码变复杂。如果内部函数没有使用箭头函数定义,则 this 对象会在运行时绑定到执行函数的上下文。如果在全局函数中调用,则 this 在非严格模式下等于 window,在严格模式下等于 undefined。

window.identity = 'The Window'; 
let object = { 
identity: 'My Object', 
getIdentityFunc() { 
return function() { 
return this.identity; 
}; 
} 
}; 
console.log(object.getIdentityFunc()()); // 'The Window'

//那么如何引用外层的this
window.identity = 'The Window'; 
let object = { 
identity: 'My Object', 
getIdentityFunc() { 
let that = this; //这里引用
 return function() { 
return that.identity; 
}; 
} 
}; 
console.log(object.getIdentityFunc()()); // 'My Object'

即:this 和 arguments 都是不能直接在内部函数中访问的。如果想访问包含作用域中的 arguments 对象,则同样需要将其引用先保存到闭包能访问的另一个变量中。
在一些特殊情况下,this 值可能并不是我们所期待的值。比如下面这个修改后的例子:

window.identity = 'The Window'; 
let object = { 
identity: 'My Object', 
getIdentity () { 
return this.identity; 
} 
};

object.getIdentity(); // 'My Object' 
//按照规范,object.getIdentity 和(object.getIdentity)是相等的
(object.getIdentity)(); // 'My Object'  
 //行执行了一次赋值,然后再调用赋值后的结果。因为赋值表达式的值是函数本身,this 值不再与任何对象绑定
(object.getIdentity = object.getIdentity)(); // 'The Window'

内存泄漏

在一些版本的 IE 中,把 HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。来看下面的例子:

function assignHandler() { 
let element = document.getElementById('someElement'); 
element.onclick = () => console.log(element.id); //匿名函数引用着 assignHandler()的活动对象,阻止了对
} //element 的引用计数归零,即:内存不会被回收

function assignHandler() { 
let element = document.getElementById('someElement'); 
let id = element.id; //闭包改为引用一个保存着 element.id 的变量 id,从而消除了循环引用。
 element.onclick = () => console.log(id); //闭包还是会引用包含函数的活动对象
 element = null; //把 element 设置为 null。这样就解除了对这个 COM 对象的引用
}

立即调用的函数表达式

立即调用的匿名函数又被称作立即调用的函数表达式(IIFE,Immediately Invoked Function Expression)。
使用 IIFE 可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。ECMAScript 5 尚未支持块级作用域,使用 IIFE模拟块级作用域是相当普遍的。
在 ECMAScript 6 以后,IIFE 就没有那么必要了,因为块级作用域中的变量无须 IIFE 就可以实现同样的隔离。下面展示了两种不同的块级作用域形式:

// 内嵌块级作用域 
{ 
let i; 
for (i = 0; i < count; i++) { 
console.log(i); 
} 
} 
console.log(i); // 抛出错误

// 循环的块级作用域
for (let i = 0; i < count; i++) { 
console.log(i); 
} 
console.log(i); // 抛出错误

实例用途,它锁定参数值。比如:

//正常写法达不到目的
let divs = document.querySelectorAll('div'); 
// 达不到目的! 
for (var i = 0; i < divs.length; ++i) { //i变量并不会被限制在 for 循环的块级作用域内
 divs[i].addEventListener('click', function() { 
console.log(i); 
}); 
}

//es6之前解决方案:IIFE
let divs = document.querySelectorAll('div'); 
for (var i = 0; i < divs.length; ++i) { 
divs[i].addEventListener('click', (function(frozenCounter) {  
 return function() { //传入每次循环的当前索引,从而“锁定”点击时应该显示的索引值:
 console.log(frozenCounter); 
}; 
})(i)); 
}

//es6之后有块级作用域let,方便很多
let divs = document.querySelectorAll('div'); 
for (let i = 0; i < divs.length; ++i) { 
divs[i].addEventListener('click', function() {
console.log(i); 
}); 
}

私有变量

严格来讲,JavaScript 没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。

静态私有变量

特权方法可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:

(function() { 
// 私有变量和私有函数
 let privateVariable = 10;

function privateFunction() { 
return false; 
}

// 构造函数
 MyObject = function() {};

// 公有和特权方法
 MyObject.prototype.publicMethod = function() { 
privateVariable++; 
return privateFunction(); 
}; 
})();

tips:使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。

模块模式

let singleton = function() { 
// 私有变量和私有函数
 let privateVariable = 10;

function privateFunction() { 
return false; 
}

// 特权/公有方法和属性
 return { //它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。
 publicProperty: true, //本质上,对象字面量定义了单例对象的公共接口。

publicMethod() { 
privateVariable++; 
return privateFunction(); 
} 
}; 
}();

//执行访问
console.log(singleton.publicProperty) //true
console.log(singleton.privateVariable) //undefined 访问不到

模块增强模式

是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。来看下面的例子:

let singleton = function() { 
// 私有变量和私有函数
 let privateVariable = 10;

function privateFunction() { 
return false; 
}

// 创建对象
 let object = new CustomType(); //给某个实例添加额外属性或方法的场景

// 添加特权/公有属性和方法
 object.publicProperty = true; 
object.publicMethod = function() { 
privateVariable++; 
return privateFunction(); 
};

// 返回对象
 return object; 
}();

期约与异步函数

异步编程

以往的异步编程模式:

  1. 异步返回值
    假设 setTimeout 操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。
function double(value, callback) { 
setTimeout(() => callback(value * 2), 1000); 
}

double(3, (x) => console.log(`I was given: ${x}`)); 
// I was given: 6(大约 1000 毫秒之后)

这里的 setTimeout 调用告诉 JavaScript 运行时在 1000 毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。

  1. 失败的处理
异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调:
function double(value, success, failure) { 
setTimeout(() => { 
try { 
if (typeof value !== 'number') { 
throw 'Must provide number as first argument'; 
} 
success(2 * value); 
} catch (e) { 
failure(e); 
} 
}, 1000); 
} 
const successCallback = (x) => console.log(`Success: {x}`); 
const failureCallback = (e) => console.log(`Failure:{e}`);

double(3, successCallback, failureCallback); 
double('b', successCallback, failureCallback);

// Success: 6(大约 1000 毫秒之后)
// Failure: Must provide number as first argument(大约 1000 毫秒之后)

这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。

  1. 嵌套异步回调
    如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这就要求嵌套回调:
function double(value, success, failure) { 
setTimeout(() => { 
try { 
if (typeof value !== 'number') { 
throw 'Must provide number as first argument'; 
} 
success(2 * value); 
} catch (e) { 
failure(e); 
} 
}, 1000);
}

const successCallback = (x) => { //开始成功,失败的嵌套回调,如果继续,则"地狱回调"出现,很难维护
 double(x, (y) => console.log(`Success: {y}`)); 
};

const failureCallback = (e) => console.log(`Failure:{e}`);

double(3, successCallback, failureCallback); 
// Success: 12(大约 1000 毫秒之后)

期约(Promise)

Promise基础

可以通过 new 操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数(后面马上会介绍),下面的例子使用了一个空函数对象来应付一下解释器:

let p = new Promise(() => {}); //setTimeout的第一个参数只能放一个无参的函数更像放了一个函数指针在那
setTimeout(console.log, 0, p); // Promise <pending>

//之所以说是应付解释器,是因为如果不提供执行器函数,就会抛出 SyntaxError。
let p = new Promise(); //TypeError: Promise resolver undefined is not a function
  1. Promise状态机
    Promise是一个有状态的对象,可能处于如下 3 种状态之一:
    待定(pending)
    兑现(fulfilled,有时候也称为“解决”,resolved)
    拒绝(rejected)
    只要从pending转换为fulfilled或rejected,Promise的状态就不再改变。重要的是,Promise的状态是私有的,不能直接通过 JavaScript 检测到。这主要是为了避免根据读取到的Promise状态,以同步方式处理Promise对象。

  2. 解决值、拒绝理由及Promise用例
    某些情况下,这个状态机就是Promise可以提供的最有用的信息。知道一段异步代码已经完成,对于其他代码而言已经足够了。比如,假设期约要向服务器发送一个 HTTP 请求。请求返回 200~299 范围内的状态码就足以让期约的状态变为“fulfilled”。类似地,如果请求返回的状态码不在 200~299 这个范围内,那么就会把期约状态切换为“rejected”。
    在另外一些情况下,Promise封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问这个值。相应地,如果Promise被拒绝,程序就会期待Promise状态改变时可以拿到拒绝的理由。比如,假设期约向服务器发送一个 HTTP 请求并预定会返回一个 JSON。如果请求返回范围在 200~299 的状态码,则足以让Promise的状态变为fulfilled。此时Promise内部就可以收到一个 JSON 字符串。类似地,如果请求返回的状态码不在 200~299 这个范围内,那么就会把Promise状态切换为rejected。此时拒绝的理由可能是一个 Error对象,包含着 HTTP 状态码及相关错误消息。
    为了支持这两种用例,每个期约只要状态切换为fulfilled,就会有一个私有的内部值(value)。类似地,每个期约只要状态切换为rejected,就会有一个私有的内部理由(reason)。无论是值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为 undefined。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。

  3. 通过执行函数控制期约状态
    由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 resolve()和 reject()。调用resolve()会把状态切换为兑现,调用 reject()会把状态切换为拒绝。另外,调用 reject()也会抛出错误(后面会讨论这个错误)。

let p1 = new Promise((resolve, reject) => resolve()); 
setTimeout(console.log, 0, p1); // Promise <resolved>  
//setTimeout的第一个参数只能放一个无参的函数更像放了一个函数指针在那,第三个参数是给第一个函数传参

let p2 = new Promise((resolve, reject) => reject()); 
setTimeout(console.log, 0, p2); // Promise <rejected>
// Uncaught error (in promise)

在前面的例子中,并没有什么异步操作,因为在初始化期约时,执行器函数已经改变了每个期约的状态。这里的关键在于,执行器函数是同步执行的。
添加 setTimeout 可以推迟切换状态:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000)); 
// 在 console.log 打印期约实例的时候,还不会执行超时回调(即 resolve())
setTimeout(console.log, 0, p); // Promise <pending>

无论 resolve()和 reject()中的哪个被调用,状态转换都不可撤销了。于是继续修改状态会静默失败
为避免期约卡在待定状态,可以添加一个定时退出功能。比如,可以通过 setTimeout 设置一个10 秒钟后无论如何都会拒绝期约的回调:

let p = new Promise((resolve, reject) => { 
setTimeout(reject, 10000); // 10 秒后调用 reject() 
// 执行函数的逻辑
}); 
setTimeout(console.log, 0, p); // Promise <pending> 
setTimeout(console.log, 11000, p); // 11 秒后再检查状态
// (After 10 seconds) Uncaught error 
// (After 11 seconds) Promise <rejected>
  1. Promise.resolve()
    Promise并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve()静态方法,可以实例化一个解决的Promise。下面两个期约实例实际上是一样的:
let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();
//这两个都是一样的,实例化一个Promise,状态为fulfilled,值为undefined

这个解决的Promise的值对应着传给 Promise.resolve()的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个期约:

setTimeout(console.log, 0, Promise.resolve()); 
// Promise <resolved>: undefined

setTimeout(console.log, 0, Promise.resolve(3));
// Promise <resolved>: 3

// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4, 5, 6)); 
// Promise <resolved>: 4

对这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似于一个空包装。因此,Promise.resolve()可以说是一个幂等方法,如下所示:

let p = Promise.resolve(7); 
setTimeout(console.log, 0, p === Promise.resolve(p)); // true
setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p))); // true

这个幂等性会保留传入期约的状态:

let p = new Promise(() => {}); 
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 0, Promise.resolve(p)); // Promise <pending>

注意,这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。因此,也可能导致不符合预期的行为:

let p = Promise.resolve(new Error('foo')); 
setTimeout(console.log, 0, p); 
// Promise <resolved>: Error: foo
  1. Promise.reject()
    与 Promise.resolve()类似,Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获),即:下面两个期约实例实际上是一样的:
let p1 = new Promise((resolve, reject) => reject()); 
let p2 = Promise.reject();

同样:这个拒绝的Promise约的理由就是传给 Promise.reject()的第一个参数。这个参数也会传给后续的拒绝处理程序
关键在于,Promise.reject()并没有照搬 Promise.resolve()的幂等逻辑。如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由:

setTimeout(console.log, 0, Promise.reject(Promise.resolve())); 
// Promise <rejected>: Promise <resolved>
  1. 同步/异步执行的二元性
    Promise 的设计很大程度上会导致一种完全不同于 JavaScript 的计算模式。下面的例子完美地展示了这一点,其中包含了两种模式下抛出错误的情形:
//第一个 try/catch 抛出并捕获了错误,
try { 
throw new Error('foo'); 
} catch(e) { 
console.log(e); // Error: foo 
}

//第二个 try/catch 抛出错误却没有捕获到。
try { 
Promise.reject(new Error('bar')); 
} catch(e) { 
console.log(e); 
} 
// Uncaught (in promise) Error: bar

因为第二个没有通过异步模式捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
Promise.reject()的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,try/catch 块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法。

Promise的实例方法

Promise实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理Promise成功和失败的结果,连续对Promise求值,或者添加只有Promise进入终止状态时才会执行的代码

  1. 实现 Thenable 接口
    ECMAScript 的 Promise 类型实现了 Thenable 接口。这个简化的接口跟 TypeScript 或其他包中的接口或类型定义不同,它们都设定了 Thenable 接口更具体的形式。

  2. Promise.prototype.then()
    Promise.prototype.then()是为期约实例添加处理程序的主要方法。这个 then()方法接收最多两个参数:onResolved 处理程序和 onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入fulfilled”和“reject”状态时执行。
    而且,传给 then()的任何非函数类型的参数都会被静默忽略。

function onResolved(id) { 
setTimeout(console.log, 0, id, 'resolved'); 
} 
function onRejected(id) { 
setTimeout(console.log, 0, id, 'rejected'); 
} 
let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000)); 
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000)); 
// 非函数处理程序会被静默忽略,不推荐
p1.then('gobbeltygook'); //Promise {<pending>} [[PromiseState]]: "pending" [[PromiseResult]]: undefined
// 不传 onResolved 处理程序的规范写法
p2.then(null, () => onRejected('p2')); //3s后输出 p2 rejected

Promise.prototype.then()方法返回一个新的期约实例:

let p1 = new Promise(() => {}); 
let p2 = p1.then(); //p1为Promise <pending> 可是实例then方法的返回值不是原本的Promise,而是一个新的一样的
setTimeout(console.log, 0, p1); // Promise <pending> 
setTimeout(console.log, 0, p2); // Promise <pending> 
setTimeout(console.log, 0, p1 === p2); // false

这个Promise.prototype.then()返回的新实例基于 onResovled 处理程序的返回值构建。换句话说,该处理程序的返回值会通过Promise.resolve()包装来生成新期约。如果没有提供这个处理程序,则 Promise.resolve()就会包装上一个期约解决之后的值。如果没有显式的返回语句,则 Promise.resolve()会包装默认的返回值 undefined。

let p1 = Promise.resolve('foo');

// 若调用 then()时不传处理程序,则原样向后传
let p2 = p1.then();
setTimeout(console.log, 0, p2); // Promise <resolved>: foo

//通过Promise.resolve()包装来生成新期约,这些都没有显示的值
let p3 = p1.then(() => undefined); 
let p4 = p1.then(() => {}); 
let p5 = p1.then(() => Promise.resolve()); 
setTimeout(console.log, 0, p3); // Promise <resolved>: undefined 
setTimeout(console.log, 0, p4); // Promise <resolved>: undefined 
setTimeout(console.log, 0, p5); // Promise <resolved>: undefined

//如果有显式的返回值,则 Promise.resolve()会包装这个值
let p6 = p1.then(() => 'bar'); 
let p7 = p1.then(() => Promise.resolve('bar')); 
setTimeout(console.log, 0, p6); // Promise <resolved>: bar 
setTimeout(console.log, 0, p7); // Promise <resolved>: bar

//同时也会保留返回的Promise的状态
let p8 = p1.then(() => new Promise(() => {})); 
let p9 = p1.then(() => Promise.reject()); 
// Uncaught (in promise): undefined 提醒你错误没有捕获
setTimeout(console.log, 0, p8); // Promise <pending> 
setTimeout(console.log, 0, p9); // Promise <rejected>: undefined

//抛出异常会返回拒绝的期约:
let p10 = p1.then(() => { throw 'baz'; }); 
// Uncaught (in promise) baz 
setTimeout(console.log, 0, p10); // Promise <rejected> baz

//返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的Promise中:
let p11 = p1.then(() => Error('qux')); 
setTimeout(console.log, 0, p11); // Promise <resolved>: Error: qux

onRejected 处理程序也与之类似:onRejected 处理程序返回的值也会被 Promise.resolve()包装。乍一看这可能有点违反直觉,但是想一想,onRejected 处理程序的任务不就是捕获异步错误吗?因此,拒绝处理程序在捕获错误后不抛出异常是符合期约的行为,应该返回一个解决期约。

let p1 = Promise.reject('foo');

// 调用 then()时不传处理程序则原样向后传
let p2 = p1.then(); 
// Uncaught (in promise) foo
setTimeout(console.log, 0, p2); // Promise <rejected>: foo

// 这些都一样,也会被 Promise.resolve()包装,状态表示fulfilled,捕获上一个reject的promise成功
let p3 = p1.then(null, () => undefined); 
let p4 = p1.then(null, () => {}); 
let p5 = p1.then(null, () => Promise.resolve()); 
setTimeout(console.log, 0, p3); // Promise <resolved>: undefined 
setTimeout(console.log, 0, p4); // Promise <resolved>: undefined 
setTimeout(console.log, 0, p5); // Promise <resolved>: undefined

// 这些都一样,有显示的返回值
let p6 = p1.then(null, () => 'bar'); 
let p7 = p1.then(null, () => Promise.resolve('bar')); 
setTimeout(console.log, 0, p6); // Promise <resolved>: bar 
setTimeout(console.log, 0, p7); // Promise <resolved>: bar

// Promise.resolve()保留返回的期约
let p8 = p1.then(null, () => new Promise(() => {})); 
let p9 = p1.then(null, () => Promise.reject()); 
// Uncaught (in promise): undefined 
setTimeout(console.log, 0, p8); // Promise <pending> 
setTimeout(console.log, 0, p9); // Promise <rejected>: undefined

//抛出异常会返回拒绝的期约
let p10 = p1.then(null, () => { throw 'baz'; }); 
// Uncaught (in promise) baz 
setTimeout(console.log, 0, p10); // Promise <rejected>: baz

//返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的Promise中:
let p11 = p1.then(null, () => Error('qux')); 
setTimeout(console.log, 0, p11); // Promise <resolved>: Error: qux

总结:除非then的 onResovled 或者 onRejected处理函数返回一个新的Promise / 抛出异常 ,否则返回的Promise状态都是

  1. Promise.prototype.catch()
    Promise.prototype.catch()方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用 Promise.prototype. then(null, onRejected)。
    同样:Promise.prototype.catch()返回一个新的Promise实例,在返回新Promise实例方面,Promise.prototype.catch()的行为与 Promise.prototype.then()
    的 onRejected 处理程序是一样的。

  2. Promise.prototype.finally()
    无论promise状态咋样,都会走这里,这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码,主要用于添加清理代码。
    同样:Promise.prototype.finally()方法返回一个新的期约实例,但这个新Promise实例不同于 then()或 catch()方式返回的实例。
    因为 onFinally 被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。

let p1 = Promise.resolve('foo');

// 这里都会原样后传
let p2 = p1.finally(); 
let p3 = p1.finally(() => undefined); 
let p4 = p1.finally(() => {}); 
let p5 = p1.finally(() => Promise.resolve()); 
let p6 = p1.finally(() => 'bar'); 
let p7 = p1.finally(() => Promise.resolve('bar')); 
let p8 = p1.finally(() => Error('qux')); 
setTimeout(console.log, 0, p2); // Promise <resolved>: foo 
setTimeout(console.log, 0, p3); // Promise <resolved>: foo 
setTimeout(console.log, 0, p4); // Promise <resolved>: foo 
setTimeout(console.log, 0, p5); // Promise <resolved>: foo 
setTimeout(console.log, 0, p6); // Promise <resolved>: foo 
setTimeout(console.log, 0, p7); // Promise <resolved>: foo 
setTimeout(console.log, 0, p8); // Promise <resolved>: foo

// Promise.resolve()保留返回的期约
let p9 = p1.finally(() => new Promise(() => {})); 
let p10 = p1.finally(() => Promise.reject()); 
// Uncaught (in promise): undefined 
setTimeout(console.log, 0, p9); // Promise <pending> 
setTimeout(console.log, 0, p10); // Promise <rejected>: undefined

let p11 = p1.finally(() => { throw 'baz'; }); 
// Uncaught (in promise) baz 
setTimeout(console.log, 0, p11); // Promise <rejected>: baz

//返回待定期约的情形并不常见,这是因为只要期约一解决,新期约仍然会原样后传初始的期约:

let p1 = Promise.resolve('foo'); 
// 忽略解决的值
let p2 = p1.finally( 
() => new Promise((resolve, reject) => setTimeout(() => resolve('bar'), 100))); 
setTimeout(console.log, 0, p2); // Promise <pending> 
setTimeout(() => setTimeout(console.log, 0, p2), 200); 
// 200 毫秒后:
// Promise <resolved>: foo
  1. 非重入Promise方法
    当Promise进入fulfilled / rejected时,与该状态相关的处理程序仅仅会被排期,而非立即执行。
// 创建解决的期约
let p = Promise.resolve();

// 添加解决处理程序
// 直觉上,这个处理程序会等期约一解决就执行
p.then(() => console.log('onResolved handler')); //then()会把 onResolved 处理程序推进消息队列

// 同步输出,证明 then()已经返回
console.log('then() returns');

// 实际的输出:
// then() returns 
// onResolved handler

先添加处理程序后解决期约也是一样的。

let synchronousResolve; 
// 创建一个期约并将解决函数保存在一个局部变量中
let p = new Promise((resolve) => { // new Promise()里的先同步执行
 synchronousResolve = function() { //定义synchronousResolve函数,执行依此:输出1,状态改变,p给赋值成 
console.log('1: invoking resolve()'); //Promise <resolve>,输出2
 resolve();  
 console.log('2: resolve() returns');  
 }; 
}); 
p.then(() => console.log('4: then() handler executes')); //这里p还没确认Promise状态,then()的处理方法也会进入消息队列
synchronousResolve(); //执行synchronousResolve函数,按上诉说明顺序输出:1,赋值p,2
console.log('3: synchronousResolve() returns'); //输出3,如何同步队列执行完毕,执行消息队列里的:输出4
// 实际的输出:
// 1: invoking resolve() 
// 2: resolve() returns 
// 3: synchronousResolve() returns 
// 4: then() handler executes

/所以:上诉代码去掉synchronousResolve(); 函数不执行,p一直在Promise <pending>状态,p.then中的也不会执行,只会输出3

非重入(进入消息队列)适用于 onResolved/onRejected 处理程序、catch()处理程序和 finally()处理程序。

  1. 邻近处理程序的执行顺序
    如果给Promise添加了多个处理程序,当Promise状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是 then()、catch()还是 finally()添加的处理程序都是如此。

  2. 传递解决值和拒绝理由
    到了fulfilled / rejected状态后,Promise会提供其解决值(如果fulfilled)或其拒绝理由(如果rejected)给相关状态的处理程序。

let p1 = new Promise((resolve, reject) => resolve('foo')); 
p1.then((value) => console.log(value)); // foo 
let p2 = new Promise((resolve, reject) => reject('bar')); 
p2.catch((reason) => console.log(reason)); // bar

//同样的:Promise.resolve()和 Promise.reject()在被调用时就会接收解决值和拒绝理由。
let p1 = Promise.resolve('foo'); 
p1.then((value) => console.log(value)); // foo 
let p2 = Promise.reject('bar'); 
p2.catch((reason) => console.log(reason)); // bar
  1. 拒绝期约与拒绝错误处理
    拒绝期约类似于 throw()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝:
let p1 = new Promise((resolve, reject) => reject(Error('1'))); 
let p2 = new Promise((resolve, reject) => { throw Error('2'); }); 
let p3 = Promise.resolve().then(() => { throw Error('3'); }); 
let p4 = Promise.reject(Error('4'));

setTimeout(console.log, 0, p1); // Promise <rejected>: Error: 1 
setTimeout(console.log, 0, p2); // Promise <rejected>: Error: 2 
setTimeout(console.log, 0, p3); // Promise <rejected>: Error: 3
setTimeout(console.log, 0, p4); // Promise <rejected>: Error: 4
// 也会抛出 4 个未捕获错误

最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的。
上面抛出的4个未捕获错误如下:

Uncaught (in promise) Error: 1
 at <anonymous>:1:50
 at new Promise (<anonymous>)
 at <anonymous>:1:10
Uncaught (in promise) Error: 2
 at <anonymous>:2:51
 at new Promise (<anonymous>)
 at <anonymous>:2:10
Uncaught (in promise) Error: 4
 at <anonymous>:4:25
Uncaught (in promise) Error: 3
 at <anonymous>:3:47

注意错误的顺序:Promise.resolve().then()的错误最后才出现,这是因为它需要在运行时消息队列中添加处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。
这个例子同样揭示了异步错误有意思的副作用。正常情况下,在通过 throw()关键字抛出错误时,JavaScript 运行时的错误处理机制会停止执行抛出错误之后的任何指令:(俗称:Promise 会吃掉错误)

throw Error('foo'); 
console.log('bar'); // 这一行不会执行
// Uncaught Error: foo

//在Promise 会吃掉错误
Promise.reject(Error('foo')); 
console.log('bar'); 
// bar 
// Uncaught (in promise) Error: foo

且异步错误只能通过异步的 onRejected 处理程序捕获:

// 正确 
Promise.reject(Error('foo')).catch((e) => {}); 
// 不正确
try { 
Promise.reject(Error('foo')); 
}catch(e) {}

这不包括捕获执行函数中的错误,在解决或拒绝期约之前,仍然可以使用 try/catch 在执行函数中捕获错误:

let p = new Promise((resolve, reject) => { 
try { 
throw Error('foo'); 
} catch(e) {
 console.log(e)
} 
resolve('bar'); 
}); 
setTimeout(console.log, 0, p);

// Error: foo
// Promise <resolved>: bar

对比正常的try/catch 和 promise的then()和 catch()的 onRejected 处理程序

//同步下抛出错误,正常顺序
console.log('begin synchronous execution'); 
try { 
throw Error('foo'); 
} catch(e) { 
console.log('caught error', e); 
} 
console.log('continue synchronous execution');

// begin synchronous execution 
// caught error Error: foo 
// continue synchronous execution

//Promise里抛出错误,看具体而言
new Promise((resolve, reject) => { 
console.log('begin asynchronous execution'); 
reject(Error('bar')); 
}).catch((e) => { //reject传参,catch接收了
 console.log('caught error', e); //之后的catch里没有改变promise状态,没有参数,默认<resolved>:undefined
}).then(() => { //catch没有传参,then没有接收
 console.log('continue asynchronous execution'); 
}); 
// begin asynchronous execution 
// caught error Error: bar 
// continue asynchronous execution

Promise连锁与Promise合成

多个Promise组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:Promise连锁与Promise合成。前者就是一个Promise接一个Promise地拼接(异步的串行),后者则是将多个Promise组合为一个期约(异步的并行)。

  1. 期约连锁
    说白了就是串行,没啥难点

  2. 期约图
    因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。

下面的例子展示了一种期约有向图,也就是二叉树:
// A 
// / \ 
// B C 
// /\ /\ 
// D E F G

let A = new Promise((resolve, reject) => { 
console.log('A'); 
resolve(); 
});

let B = A.then(() => console.log('B')); 
let C = A.then(() => console.log('C'));

B.then(() => console.log('D')); 
B.then(() => console.log('E')); 
C.then(() => console.log('F')); 
C.then(() => console.log('G')); 
// A 
// B 
// C 
// D 
// E 
// F 
// G

日志的输出语句是对二叉树的层序遍历(因为期约的处理程序是先添加到消息队列,然后才逐个执行,因此构成了层序遍历。)

  1. Promise.all()和 Promise.race()
    Promise.all()静态方法创建的期约会在一组Promise全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新Promise:
let p1 = Promise.all([ 
Promise.resolve(), 
Promise.resolve() 
]);

// 可迭代对象中的元素会通过 Promise.resolve()转换为期约
let p2 = Promise.all([3, 4]);

// 空的可迭代对象等价于 Promise.resolve() 
let p3 = Promise.all([]);

// 无效的语法
let p4 = Promise.all(); 
// TypeError: cannot read Symbol.iterator of undefined

合成的Promise只会在每个包含的Promise都解决之后才解决,如果至少有一个包含的Promise待定,则合成Promise也会待定。如果有一个包含的Promise拒绝,则合成的Promise也会拒绝,如果所有的Promise都成功解决,则合成的Promise的解决值就是所有包含的Promise解决值的数组,按照迭代器顺序:

// 永远待定
let p1 = Promise.all([new Promise(() => {})]); 
setTimeout(console.log, 0, p1); // Promise <pending>

// 一次拒绝会导致最终期约拒绝
let p2 = Promise.all([ 
Promise.resolve(), 
Promise.reject(), 
Promise.resolve() 
]); 
setTimeout(console.log, 0, p2); // Promise <rejected> 
// Uncaught (in promise) undefined

//所有成功,返回数组解决值
let p = Promise.all([ 
Promise.resolve(3), 
Promise.resolve(), 
Promise.resolve(4) 
]); 
p.then((values) => setTimeout(console.log, 0, values)); // [3, undefined, 4]

//如果有Promise拒绝,则第一个拒绝的Promise会将自己的理由作为合成Promise的拒绝理由。之后再拒绝的Promise不会影响最终Promise的拒绝理由。

Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。
Promise.race()不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,Promise.race()就会包装其解决值或拒绝理由并返回新期约:

// 解决先发生,超时后的拒绝被忽略
let p1 = Promise.race([ 
Promise.resolve(3), 
new Promise((resolve, reject) => setTimeout(reject, 1000)) 
]); 
setTimeout(console.log, 0, p1); // Promise <resolved>: 3

// 拒绝先发生,超时后的解决被忽略
let p2 = Promise.race([ 
Promise.reject(4), 
new Promise((resolve, reject) => setTimeout(resolve, 1000)) 
]); 
setTimeout(console.log, 0, p2); // Promise <rejected>: 4 
// 迭代顺序决定了落定顺序
let p3 = Promise.race([ 
Promise.resolve(5), 
Promise.resolve(6), 
Promise.resolve(7) 
]); 
setTimeout(console.log, 0, p3); // Promise <resolved>: 5
  1. 串行Promise合成
    将多个函数合成为一个函数,比如:
function addTwo(x) {return x + 2;} 
function addThree(x) {return x + 3;} 
function addFive(x) {return x + 5;}

function addTen(x) { 
return addFive(addTwo(addThree(x))); 
} 
console.log(addTen(7)); // 17

类似地,Promise也可以像这样合成起来,渐进地消费一个值,并返回一个结果:

function addTwo(x) {return x + 2;} 
function addThree(x) {return x + 3;} 
function addFive(x) {return x + 5;}

function addTen(x) { 
return Promise.resolve(x) 
.then(addTwo) 
.then(addThree) 
.then(addFive); 
} 
addTen(8).then(console.log); // 18

使用 Array.prototype.reduce()可以写成更简洁的形式:

function addTwo(x) {return x + 2;} 
function addThree(x) {return x + 3;} 
function addFive(x) {return x + 5;}

function addTen(x) { 
return [addTwo, addThree, addFive] 
.reduce((promise, fn) => promise.then(fn), Promise.resolve(x)); 
} 
addTen(8).then(console.log); // 18

这种模式可以提炼出一个通用函数,可以把任意多个函数作为处理程序合成一个连续传值的期约连锁。这个通用的合成函数可以这样实现:

function addTwo(x) {return x + 2;} 
function addThree(x) {return x + 3;} 
function addFive(x) {return x + 5;} 
function compose(...fns) { 
return (x) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x)) 
} 
let addTen = compose(addTwo, addThree, addFive);

期约扩展

ES6 期约实现是很可靠的,但它也有不足之处。比如,很多第三方期约库实现中具备而 ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。

  1. 期约取消
    实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。这可以用到 Kevin Smith 提到的“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态。
class CancelToken { 
constructor(cancelFn) { //把解决方法暴露给了 cancelFn 参数。这样,外部代码就可以向构造函数
 this.promise = new Promise((resolve, reject) => { //中传入一个函数,从而控制什么情况下可以取消期约。
 cancelFn(resolve); 
}); 
} 
}

这个类大概可以这样使用:

<button id="start">Start</button> 
<button id="cancel">Cancel</button>

<script> 
class CancelToken { 
 constructor(cancelFn) { 
 this.promise = new Promise((resolve, reject) => { 
 cancelFn(() => { 
 setTimeout(console.log, 0, "delay cancelled"); 
 resolve(); 
 }); 
 }); 
 } 
} 
const startButton = document.querySelector('#start'); 
const cancelButton = document.querySelector('#cancel'); 
function cancellableDelayedResolve(delay) { 
 setTimeout(console.log, 0, "set delay"); 
 return new Promise((resolve, reject) => { 
 const id = setTimeout((() => { 
 setTimeout(console.log, 0, "delayed resolve"); 
 resolve(); 
 }), delay);
const cancelToken = new CancelToken((cancelCallback) => 
 cancelButton.addEventListener("click", cancelCallback)); 
 cancelToken.promise.then(() => clearTimeout(id)); 
 }); 
} 
startButton.addEventListener("click", () => cancellableDelayedResolve(1000)); 
</script>

每次单击“Start”按钮都会开始计时,并实例化一个新的 CancelToken 的实例。此时,“Cancel”按钮一旦被点击,就会触发令牌实例中的期约解决。而解决之后,单击“Start”按钮设置的超时也会被取消。

  1. 期约进度通知
    某些情况下,监控期约的执行进度会很有用。ECMAScript 6 期约并不支持进度追踪,但是可以通过扩展来实现。
    一种实现方式是扩展 Promise 类,为它添加 notify()方法,如下所示:
class TrackablePromise extends Promise { 
constructor(executor) { 
const notifyHandlers = []; 
super((resolve, reject) => { 
return executor(resolve, reject, (status) => { 
notifyHandlers.map((handler) => handler(status)); 
}); 
}); 
this.notifyHandlers = notifyHandlers; 
} 
notify(notifyHandler) { 
this.notifyHandlers.push(notifyHandler); 
return this; 
} 
}

这样,TrackablePromise 就可以在执行函数中使用 notify()函数了。可以像下面这样使用这个函数来实例化一个期约:

let p = new TrackablePromise((resolve, reject, notify) => { 
function countdown(x) { 
if (x > 0) { 
notify(`${20 * x}% remaining`); 
setTimeout(() => countdown(x - 1), 1000); 
} else { 
resolve(); 
} 
} 
countdown(5); 
});

这个期约会连续5次递归地设置1000毫秒的超时。每个超时回调都会调用notify()并传入状态值。假设通知处理程序简单地这样写:

let p = new TrackablePromise((resolve, reject, notify) => { 
function countdown(x) { 
if (x > 0) { 
notify(`${20 * x}% remaining`); 
setTimeout(() => countdown(x - 1), 1000); 
} else { 
resolve(); 
} 
} 
countdown(5); 
}); 
p.notify((x) => setTimeout(console.log, 0, 'progress:', x)); 
p.then(() => setTimeout(console.log, 0, 'completed')); 
// (约 1 秒后)80% remaining 
// (约 2 秒后)60% remaining 
// (约 3 秒后)40% remaining 
// (约 4 秒后)20% remaining 
// (约 5 秒后)completed

notify()函数会返回期约,所以可以连缀调用,连续添加处理程序。多个处理程序会针对收到的每条消息分别执行一遍,如下所示:

p.notify((x) => setTimeout(console.log, 0, 'a:', x)) 
.notify((x) => setTimeout(console.log, 0, 'b:', x)); 
p.then(() => setTimeout(console.log, 0, 'completed')); 
// (约 1 秒后) a: 80% remaining 
// (约 1 秒后) b: 80% remaining 
// (约 2 秒后) a: 60% remaining 
// (约 2 秒后) b: 60% remaining 
// (约 3 秒后) a: 40% remaining 
// (约 3 秒后) b: 40% remaining 
// (约 4 秒后) a: 20% remaining 
// (约 4 秒后) b: 20% remaining 
// (约 5 秒后) completed

异步函数

async/await

ES8 的 async/await 旨在解决利用异步结构组织代码的问题。为此,ECMAScript 对函数进行了扩展,为其增加了两个新关键字:async 和 await。

  1. async
    async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上
    使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通 JavaScript 函数的正常行为。正如下面的例子所示,foo()函数仍然会在后面的指令之前被求值,不过,异步函数如果使用 return 关键字返回了值(如果没有 return 则会返回 undefined),这个值会被 Promise.resolve()包装成一个期约对象。
async function foo() { 
console.log(1); 
return 3; //当然,直接返回一个期约对象也是一样的
} 
// 给返回的期约添加一个解决处理程序
foo().then(console.log);
console.log(2); 
// 1 
// 2 
// 3

异步函数的返回值期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如果返回的是实现 thenable 接口的对象,则这个对象可以由提供给 then()的处理程序“解包”。如果不是,则返回值就被当作已经解决的期约。下面的代码演示了这些情况:

// 返回一个原始值 
async function foo() { 
return 'foo'; 
} 
foo().then(console.log); 
// foo

// 返回一个没有实现 thenable 接口的对象
async function bar() { 
return ['bar']; 
} 
bar().then(console.log); 
// ['bar']

// 返回一个实现了 thenable 接口的非期约对象
async function baz() { 
const thenable = { 
then(callback) { callback('baz'); } 
}; 
return thenable; 
} 
baz().then(console.log); 
// baz

// 返回一个期约
async function qux() { 
return Promise.resolve('qux'); 
} 
qux().then(console.log); 
// qux

与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约:

async function foo() { 
console.log(1); 
throw 3; 
} 
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2); 
// 1 
// 2 
// 3
  1. await
    使用 await关键字可以暂停异步函数代码的执行,等待期约解决。
    await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。这个行为与生成器函数中的 yield 关键字是一样的。await 关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。
// 异步打印"foo"
async function foo() { 
console.log(await Promise.resolve('foo')); 
} 
foo(); 
console.log(1)
// 1
// foo

// 异步打印"bar" 
async function bar() { 
return await Promise.resolve('bar'); 
} 
bar().then(console.log); 
console.log(1)
// 1 
// bar

// 1000 毫秒后异步打印"baz" 
async function baz() { 
await new Promise((resolve, reject) => setTimeout(resolve, 1000,1)).then(console.log)
 console.log('baz'); 
} 
baz()
console.log(2)
// 2
// 1(1000 毫秒后)
// baz

await 关键字期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如果是实现 thenable 接口的对象,则这个对象可以由 await 来“解包”。如果不是,则这个值就被当作已经解决的期约。下面的代码演示了这些情况:

// 等待一个原始值 
async function foo() { 
console.log(await 'foo'); 
} 
foo(); 
// foo

// 等待一个没有实现 thenable 接口的对象
async function bar() { 
console.log(await ['bar']); 
} 
bar(); 
// ['bar']

// 等待一个实现了 thenable 接口的非期约对象
async function baz() { 
const thenable = { 
then(callback) { callback('baz'); } 
}; 
console.log(await thenable); 
} 
baz(); 
// baz

// 等待一个期约
async function qux() { 
console.log(await Promise.resolve('qux')); 
} 
qux(); 
// qux

//等待会抛出错误的同步操作,会返回拒绝的期约:
async function foo() { 
console.log(1); 
await (() => { throw 3; })(); 
} 
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2); 
// 1 
// 2 
// 3

//对拒绝的期约使用 await 则会释放(unwrap)错误值(将拒绝期约返回)
async function foo() { 
console.log(1); 
await Promise.reject(3); 
console.log(4); // 这行代码不会执行
} 
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log); 
console.log(2); 
// 1 
// 2 
// 3
  1. await 的限制
    await 关键字必须在异步函数中使用,不能在顶级上下文如