# JavaScript 函数 & 异步

# 函数

JS 中一切都是对象,每个函数都是 Function 类型的实例对象。因为函数是对象,所以函数名就是指向函数对象的指针。

函数通常以函数声明的方式定义:

function sum(num1, num2) {
  return num1 + num2;
}

另一种定义函数的语法是函数表达式:

let sum = function(num1, num2) {
  return num1 + num2;
};

ECMAScript 6 新增了箭头函数 () => {}

  • 如果只有一个参数,可以不用括号。
  • 在没有参数,或者多个参数的情况下,才需要使用括号:
// 以下两种写法都有效
let double = (x) => { return 2 * x; };
let triple = x => { return 3 * x; };

// 没有参数需要括号
let getRandom = () => { return Math.random(); };

// 多个参数需要括号
let sum = (a, b) => { return a + b; };

// 无效的写法:
let multiply = a, b => { return a * b; };

如果不使用大括号,那么箭头后面就只能有一行代码。而且会隐式返回这行代码的值。

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

// 无效的写法:
let multiply = (a, b) => return a * b;
// 正确的写法:
let multiply = (a, b) => a * b;

最后一种定义函数的方式是使用 Function 构造函数。这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。

不推荐使用这种语法来定义函数,因为这段代码会被解释两次:第一次是将它当作常规 ECMAScript 代码,第二次是解释传给构造函数的字符串。这显然会影响性能。

不过,这种方式很好的展示出了函数是对象这个概念。

let sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐

JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。这个过程叫作函数声明提升。而函数表达式等其他方式,必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。

# 函数参数

ECMAScript 函数既不关心传入的参数个数,也不关心这些参数的数据类型。

# arguments 对象

在使用 function 关键字定义函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。

  • arguments 对象是一个类数组对象。
  • 要确定传进来多少个参数,可以访问 arguments.length 属性。

arguments 对象的长度是根据实际传入的参数个数决定,而非定义函数时给出的命名参数个数确定的。

  • 🌰 例如,如果只传入一个参数,那么 arguments[1] 的值为 undefined

arguments 对象还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。

一个典型的使用场景是在「 递归函数 」:

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

这个函数要正确执行就必须保证函数名是 factorial,从而导致了紧密耦合。使用 arguments.callee 就可以让函数体与函数名解耦:

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

在严格模式下访问 arguments.callee 会报错。

# 默认参数值

在 ES6 之后,在函数定义中的参数后面用 = 就可以为参数赋一个默认值

function makeKing(name = "Henry") {
  return `King ${name} VIII`;
}

console.log(makeKing("Louis")); // 'King Louis VIII'
console.log(makeKing()); // 'King Henry VIII'

给参数传 undefined 相当于没有传值,不过这样可以利用多个独立的默认值:

function makeKing(name = "Henry", numerals = "VIII") {
  return `King ${name} ${numerals}`;
}

console.log(makeKing()); // 'King Henry VIII'
console.log(makeKing("Louis")); // 'King Louis VIII'
console.log(makeKing(undefined, "VI")); // 'King Henry VI'

在使用默认参数时,arguments 对象的值不反映参数的默认值,只反映传给函数的参数。

function makeKing(name = "Henry") {
  name = "Louis";
  return `King ${arguments[0]}`;
}

console.log(makeKing()); // 'King undefined'
console.log(makeKing("Louis")); // 'King Louis'

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

  • 参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。
  • 参数存在于自己的作用域中,它们不能引用函数体的作用域。
function makeKing(name = "Henry", numerals = name) {
  return `King ${name} ${numerals}`;
}

// 等价于

function makeKing() {
  let name = "Henry";
  let numerals = name;
  return `King ${name} ${numerals}`;
}

console.log(makeKing()); // King Henry Henry

# 参数扩展 & 收集

扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。

在给函数传参时,有时候可能不需要传一个数组,而是要分别传入数组的元素。

如果不使用扩展操作符,想把一个数组的每一项分别作为参数传入函数,就得求助于 apply() 方法:

getSum.apply(null, values);

使用扩展操作符:

getSum(...values);

可以使用扩展操作符,把外部传入的数量可变的参数组合为一个数组。

function getSum(...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) {}

# 没有重载

ECMAScript 函数不支持重载。在其他语言比如 Java 中,一个函数可以有两个定义,只要函数签名( 接收参数的类型和数量 )不同就行。

如果在 ECMAScript 中定义了两个同名函数,则后定义的会覆盖先定义的。

function addSomeNumber(num) {
  return num + 100;
}

function addSomeNumber(num) {
  return num + 200;
}

let result = addSomeNumber(100); // 300

但是,可以通过检查参数的类型和数量,然后分别执行不同的逻辑来模拟函数重载。

# 函数内部变量

# this

在标准函数中,this 引用的是把函数当成方法调用的上下文对象

window.color = "red";
let o = {
  color: "blue",
};

function sayColor() {
  console.log(this.color);
}

sayColor(); // 'red'

o.sayColor = sayColor;
o.sayColor(); // 'blue'

在箭头函数中,this 引用的是定义箭头函数的上下文。

window.color = "red";
let o = {
  color: "blue",
};

let sayColor = () => console.log(this.color);

sayColor(); // 'red'

o.sayColor = sayColor;
o.sayColor(); // 'red'

# caller

函数内的 caller 属性引用的是调用当前函数的函数。

function outer() {
  inner();
}

function inner() {
  console.log(inner.caller); // 显示 outer() 函数的源代码,inner.caller 指向 outer()
}
outer();

如果要降低耦合度,则可以通过 arguments.callee.caller 来引用同样的值:

function outer() {
  inner();
}

function inner() {
  console.log(arguments.callee.caller);
}

outer();

严格模式下访问 arguments.callee 会报错。ECMAScript 5 也定义了 arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是 undefined。这是为了分清 arguments.caller 和函数的 caller 而故意为之的。也让第三方代码无法检测同一上下文中运行的其他代码。

# new.target

函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。new.target 属性可以检测函数是否被用 new 关键字调用。

  • 如果函数是正常调用的,则 new.target 的值是 undefined
  • 如果是使用 new 关键字调用的,则 new.target 将引用被调用的构造函数。
function King() {
  if (!new.target) {
    throw 'King must be instantiated using "new"';
  }
  console.log(new.target); // function King() {...}
  console.log('King instantiated using "new"');
}

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

箭头函数不能作为构造函数,所以也没有 new.target 属性。

# call(), apply(), bind()

apply()call() 这两个方法会设置调用函数时函数体内 this 对象的值,也就是函数执行时所处的上下文对象。

  • apply() 方法接收两个参数:函数内 this 的值和一个参数数组。
  • call() 方法第一个参数也是 this 的值,但要传给被调用函数的参数则是逐个传递的参数则是逐个传递的。
window.color = "red";
let o = {
  color: "blue",
};

function sayColor() {
  console.log(this.color);
}

sayColor(); // red

sayColor.call(this); // red
sayColor.call(window); // red
sayColor.call(o); // blue

bind() 方法会创建一个新的函数实例,其 this 值会被绑定到传给 bind() 的对象。之后,其余的参数作为传给被调用函数的参数,逐个传递。

window.color = "red";
var o = {
  color: "blue",
};

function sayColor() {
  console.log(this.color);
}
let objectSayColor = sayColor.bind(o);
objectSayColor(); // blue

# 闭包

闭包 」指引用了另一个函数作用域中变量的函数。

function createComparisonFunction(propertyName) {
  return function(object1, object2) {
    // 引用了外部函数的变量 propertyName
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];

    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

let compare = createComparisonFunction("name");
let result = compare({ name: "Nicholas" }, { name: "Matt" });

在内部函数被返回,它作用域链上保存这外层函数的变量对象,所以可以对外层函数作用域中的变量进行引用。

2020-09-14-16-34-11

createComparisonFunction() 执行完毕后,其执行上下文的作用域链会销毁,但它的变量对象仍然会保留在内存中。因为内部匿名函数的作用域链上保存这对外部函数变量对象的引用,而内部匿名函数还被 compare 变量引用。

compare 设置为等于 null 会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁。

# 立即调用的函数表达式

立即调用的函数表达式 IIFE,Immediately Invoked Function Expression。类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。

(function() {
  // 块级作用域
})();

ECMAScript 5 尚未支持块级作用域,使用 IIFE 可以模拟「 块级作用域 」,即在一个函数表达式内部声明变量,然后立即调用这个函数。

在 ECMAScript 6 以后,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 divs = document.querySelectorAll("div");

for (let i = 0; i < divs.length; ++i) {
  divs[i].addEventListener("click", function() {
    console.log(i);
  });
}

# 「 命名函数表达式 」实现递归函数

前面展示过用 arguments.callee 去实现递归函数中,函数体与函数名的解耦。

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) {
  if (num <= 1) {
    return 1;
  } else {
    return num * f(num - 1);
  }
};

# 私有变量

JavaScript 没有「 私有成员 」的概念,所有对象属性都公有的。

但是,有「 私有变量 」的概念。任何定义在函数或块中的变量,都可以认为是私有的。包括函数参数、局部变量,以及函数内部定义的其他函数。

基于这一点,就可以创建出能够访问私有变量的公有方法,称为「 特权方法 privileged method 」

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

  function privateFunction(num) {
    return num + 1;
  }

  // 特权方法
  this.publicMethod = function() {
    privateVariable = privateFunction(privateVariable);
    console.log(privateVariable);
    return privateVariable;
  };
}

定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。

这个例子中,当使用 new + MyObject 构造函数创造出来一个实例来,该私有变量只能通过提供的公用方法去间接访问。

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

  function privateFunction(num) {
    return num + 1;
  }

  // 特权方法
  this.publicMethod = function() {
    privateVariable = privateFunction(privateVariable);
    console.log(privateVariable);
    return privateVariable;
  };
}

# 模块模式

JavaScript 可以通过对象字面量来创建单例对象:

let singleton = {
  name: value,
  method() {
    // 方法的代码
  },
};

模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。

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

  function privateFunction() {
    return false;
  }

  // 特权/公有方法和属性
  return {
    publicProperty: true,

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

在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。

如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式。

# 异步编程

# 单线程

首先我们要知道, JavaScript 的最大特点就是 "单线程". 也就是说同一时间只能处理一个操作.

那么为什么要这样设计呢? JavaScript 作为浏览器的脚本语言, 主要用途是来处理用户交互, 以及操作 DOM. 这使得多线程的设计会导致很复杂的同步问题.

🌰 举例说, 如果 JavaScript 可以同时操纵两个线程. 一个线程添加在某个 DOM 节点上添加内容, 另一个线程在这个 DOM 节点下删除内容. 那么浏览器, 该听谁的呢? 所以 JavaScript 被设计成了单线程的.

# 同步 & 异步

单线程就意味着任务必须要排队, 一个一个得等待被执行. 那很明显的一个问题是, 如果有一个任务耗时过长, 那后面的任务就必须要等待. 如果是任务的计算量太大, 设备 CPU 处理能力不够, 必须耗时很长, 那还可以理解. 但如果任务是从网络中读取数据, 因为网速慢, 或其他原因导致等待响应时间过长, 那必然会导致程序运行效率, 和 CPU 利用率非常低下.

所以 JavaScript 的另一个特点就是 "非阻塞 I/O", 也称 "异步式 I/O".

当主线程遇到 I/O 操作时 (磁盘读写, 网络通信),不会以阻塞的方式等待 I/O 操作的完成, 或数据的返回. 而只是将 I/O 操作交给浏览器,然后自己继续执行下一条语句。 当浏览器完成 I/O 操作时,会将用以处理 I/O 操作结果的处理函数推入到一个任务队列, 等待主线程后续进行处理.

于是任务就分成, "同步任务", 和 "异步任务" 两种:

  • 同步任务: 在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务: 主线程交给浏览器去执行. 执行完毕后, 将用以处理异步操作结果的处理函数, 推入 "任务队列", 等待主线程处理.

# 回调函数

"回调" 的意思就是 "回头调用的函数". 函数中的代码是在 "将来" 的操作. 当相对应的 "事件" 被触发了, 函数会被执行.

回调是编写和处理 JavaScript 异步逻辑的最常用方式.

// 代码 A

setTimeout(function() {
  // 代码 B
}, 1000);

// 代码 C

上面的代码中使用了回调函数. 如果用日常口语去描述这段代码的运行方式, 大概是 "代码 A 先执行; 然后设定一个 1000 毫秒的延时事件; 代码 C 执行; 延时事件被触发, 代码 B 得到执行."

# 回调地狱

setTimeout(function() {
  console.log("1");
  setTimeout(function() {
    console.log("2");
    setTimeout(function() {
      console.log("3");
    }, 1000);
  }, 1000);
}, 1000);

这种多个回调函数嵌套在一起的代码, 被称作 "回调地狱".

被称为 "地狱" 的主要原因除了层层嵌套的代码难于阅读, 更主要的原因是: 回调函数的使用让代码执行顺序缺少 "顺序性". "非顺序(线性)" 的代码执行方式与我们大脑的顺序地思考方式不符.

doA(function() {
  doC();

  doD(function() {
    doF();
  });

  doE();
});

doB();

如果 doAdoD 接收两个回调函数, 会被异步调用. 那么上面代码的执行顺序为 A -> B -> C -> D -> E -> F. 当我们在线性(顺序)地追踪这段代码的执行顺序时,我们不得不从一个函数跳到下一个,再跳到下一个. 可以想象当异步代码更复杂时, 这种追踪的难度会成倍增加。

这种 "非顺序的" 执行步骤并不符合我们的大脑思考方式. 我们的大脑类似于单线程运行的事件循环队列, 它喜欢以顺序地, 同步地方式去理解, 思考事情.

在回调函数上, 我们大脑的工作方式和代码的执行方式发生了 "分歧". 回调函数的滥用会让代码变得更加难以理解、追踪、调试和维护。这是回调函数的主要问题所在.

除此之外, 嵌套的回调函数把异步代码的执行顺序写死了. 上面代码中, doD 的回调函数必须等 doA 的回调函数得到执行之后才能被执行. 假如它们两个之间没有顺序关系, 这种写死的顺序会增加代码的脆弱性.

类比来说, 假如你计划五点钟去吃饭, 六点钟去看电影. 它们之间没有绝对的顺序关系, 哪怕你五点钟没有去吃饭, 也不会影响你六点去看电影.

# 信任问题

当我们在第三方提供的工具中使用回调函数的时候, 实际上我们就把代码一部分的操控权交给了第三方. 这被称为 "控制反转". 这加大了代码不确定性.

假如你使用了一个第三方提供的用来验证账号登录状态的函数. 并在传入的回调函数中进行支付操作.

checkLogin(accountInfo, function() {
    payMoney();
})

看似一切都完美, 但是我们在这段代码中, 把支付操作的控制权交给了一个第三方.这会导致很多可能出现的错误情况:

  • 调用回调过早;
  • 调用回调过晚(或没有调用);
  • 调用回调的次数太少或太多;
  • 没有把所需的环境 / 参数成功传给你的回调函数;
  • 吞掉可能出现的错误或异常;
  • 等等;

 为了确保安全性, 常用的解决方案是对传入的参数,回调函数的调用情况设定相应的安全机制. 但这也加大了代码的复杂度.

function addNumbers(x, y) {
  // 确保输入为数字
  if (typeof x != "number" || typeof y != "number") {
    throw Error("Bad parameters");
  }

  // 如果到达这里,可以通过+安全的进行数字相加
  return x + y;
}

addNumbers(21, 21); // 42
addNumbers(21, "21"); // Error: "Bad parameters"

如果你还没有应用某种逻辑来解决所有这些控制反转导致的信任问题,那你的代码现在已经有了 bug,即使它们还没有给你造成损害。隐藏的 bug 也是 bug

# Promise

Promise ( 承诺 / 期约 ) 是一种封装和组合未来值的易于复用的机制。这套机制可以用来处理异步操作. ES6 使用这套机制实现了 Promise 对象。

所谓 Promise,简单说就是一个「 容器 」,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果. 从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。

# 三种状态: pending, fulfilled, rejected

先来简单解释一下 Promise 是个什么概念:

  • 想象中午你去麦当劳买汉堡, 你点餐的时候服务员是不会立刻给你汉堡的. 交完钱, 你会先得到一张带订单号的收据作为凭证。
  • 这张收据就是 Promise ( 承诺 / 期约 ) 服务员用它来向你承诺, 汉堡做好后会给你.。这个时候, 收据代表了你的汉堡。
  • 当汉堡做好, 服务员叫你的订单号的时候, 这张收据 ( 承诺 / 期约 ) 就可以用来换取你真正想要得到的汉堡。
  • 当然还会有另一种结果, 是汉堡不能卖给你了, 可能因为卖光了, 可能因为质量有问题, 反正就是不能给你了。

在 JavaScript 中, Promise 对象代表一个异步操作.

有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。

拿上面买汉堡类比, 拿着收据等待汉堡时, 状态为 "pending"; 拿到汉堡了, 状态为 "fulfilled"; 被通知汉堡卖光了, 状态为 "rejected".

一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise 对象的状态改变,只有两种可能:从 pending 变为 fulfilled 和从 pending 变为 rejected

# 基本用法

ES6 规定,Promise 对象是一个构造函数,用来生成 Promise 实例。

const myPromise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

Promise 构造函数接受一个函数作为参数,构造函数调用后该函数会立即执行。

该函数的两个参数分别是 resolvereject。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。Promise 构造函数调用后会立即执行。

  • resolve 函数的作用是,将 Promise 对象的状态从 “未完成” 变为 “成功”. 在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;
  • reject 函数的作用是,将 Promise 对象的状态从 “未完成” 变为 “失败”, 在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
myPromise.then(
  function(value) {
    // success
  },
  function(error) {
    // failure
  }
);

Promise 实例生成以后,可以用 then 方法分别指定 resolved 状态和 rejected 状态的回调函数。

  • then 函数是 Promise 状态改变时的回调函数.
  • then 方法可以接受两个回调函数作为参数。第一个回调函数是 Promise 对象的状态变为 resolved 时调用,第二个回调函数是 Promise 对象的状态变为 rejected 时调用。第二个函数是可选的.

再看另一个例子:

const p1 = new Promise(function(resolve, reject) {
  setTimeout(() => reject("An error"), 500);
});

const p2 = new Promise(function(resolve, reject) {
  setTimeout(() => resolve(p1), 100);
});

p2.then(
  (result) => console.log(result),
  (err) => console.log(err)
);
// 结果: An error

上面代码中,p1 是一个 Promise,500 毫秒之后变为 rejectedp2 的状态在 100 毫秒之后改变,resolve 方法返回的是 p1。由于 p2 返回的是另一个 Promise,导致 p2 自己的状态无效了,由 p1 的状态决定 p2 的状态。所以,后面的 then 语句都变成针对 p1 的状态。当 p1 变为 rejected. then 函数接收的第二个回调函数参数被调用.

# 链式调用

then 方法返回的是一个新的 Promise 实例。因此可以采用链式写法,即 then 方法后面再调用另一个 then 方法. 前面 then 函数中回调函数参数返回的值, 会作为后一个 then 函数的回调函数参数的参数.

采用链式调用,可以指定一组按照次序调用的回调函数。

getJSON("/post/1.json")
  .then(function(post) {
    return getJSON(post.commentURL);
  })
  .then(
    function funcA(comments) {
      console.log("resolved: ", comments);
    },
    function funcB(err) {
      console.log("rejected: ", err);
    }
  );

# Promise.resolve() & Promise.reject()

通过调用 Promise.resolve() 静态方法,可以实例化一个状态为 resolved 的 Promise 实例。

同理,调用 Promise.reject() 静态方法,可以实例化一个状态为 rejected 的 Promise 实例。

let p1 = new Promise((resolve, reject) => resolve());
// 等价于
let p2 = Promise.resolve();

Promise.resolvePromise.reject() 方法的参数分成四种情况:

1 - 参数是一个 Promise 实例:

  • 如果参数是 Promise 实例,那么 Promise.resolve 将不做任何修改、原封不动地返回这个实例。
let p = new Promise(() => {});
console.log(p === Promise.resolve(p)); // true

2 - 参数是一个 thenable 对象:

  • thenable 对象指的是具有 then 方法的对象。
  • Promise.resolve 方法会将这个对象转为 Promise 对象,然后立即调用 thenable 对象的 then 方法。
  • 但是,传递给 then() 的函数也总是会被异步调用,不会立即调用。会被置入到一个微任务队列中,等所有同步代码执行完再执行。
let thenable = {
  then: function(resolve, reject) {
    resolve(42);
  },
};

3 - 参数不是具有 then 方法的对象,或根本就不是对象:

  • 如果参数是一个原始值,或者是一个不具有 then 方法的对象,则 Promise.resolve 方法返回一个新的 Promise 对象,状态为 Resolved
  • 同样,传递到 then() 中的函数被置入到一个微任务队列中,而不是立即执行。
let p = Promise.resolve("Hello");

p.then(function(s) {
  console.log(s);
});

console.log(1);

// 1
// Hello

4 - 不带有任何参数:

  • 直接返回一个 Resolved 状态的 Promise 对象。
Promise.resolve().then(() => console.log(2));
console.log(1); // 1, 2

# catch, finally

catch 函数用于指定发生错误时的回调函数.

getJSON("/posts.json")
  .then(function(posts) {
    // ...
  })
  .catch(function(error) {
    // 处理 getJSON 和 前一个回调函数运行时发生的错误
    console.log("发生错误!", error);
  });

如果异步操作抛出错误,状态就会变为 rejected,就会调用 catch 方法指定的回调函数.

Promise 对象的错误具有 “冒泡” 性质,会一直向后传递,直到被捕获为止。也就是说,链式调用中, 无论多少个 Promise 对象, 任何一个出现错误, 都会被最后一个 catch 捕获.

一般来说,不要在 then 方法里面定义 reject 状态的回调函数(即 then 的第二个参数),总是使用 catch 方法。

const someAsyncThing = function() {
  return new Promise(function(resolve, reject) {
    // 下面一行会报错,因为x没有声明
    resolve(x + 2);
  });
};

someAsyncThing()
  .catch(function(error) {
    console.log("oh no", error);
  })
  .then(function() {
    console.log("carry on");
  });
// oh no [ReferenceError: x is not defined]
// carry on

catch 方法返回的还是一个 Promise 对象,因此后面还可以接着调用 then 方法。


finally 方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。

promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});

finally 方法的回调函数不接受任何参数,这意味着没有办法知道,前面的 Promise 状态到底是 fulfilled 还是 rejected。这表明,finally 方法里面的操作,应该是与状态无关的,不依赖于 Promise 的执行结果。

# Promise.all & Promise.race

Promise.all 方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

接受一个数组作为参数,数组里的每一项必须都是 Promise 实例,如果不是,就会先调用 Promise.resolve 方法,将参数转为 Promise 实例,再进一步处理。( Promise.all 方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例 )。

const p = Promise.all([p1, p2, p3]);

p 的状态由p1p2p3决定,分成两种情况:

  1. 只有p1p2p3的状态都变成 fulfilledp 的状态才会变成 fulfilled,此时p1p2p3的返回值组成一个数组,传递给 p 的回调函数。
  2. 只要p1p2p3之中有一个被 rejectedp 的状态就变成 rejected,此时第一个被 reject的实例的返回值,会传递给 p 的回调函数。

Promise.race 方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。

const p = Promise.race([p1, p2, p3]);

p.then((result) => {
  console.log(result);
});

上面代码中,只要 p1p2p3之中有一个实例率先改变状态,p 的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 p 的回调函数。

const p1 = new Promise((resolved, rejected) => {
  setTimeout(() => {
    resolved("1");
  }, 1000);
});

const p2 = new Promise((resolved, rejected) => {
  setTimeout(() => {
    resolved("2");
  }, 500);
});

const p3 = new Promise((resolved, rejected) => {
  setTimeout(() => {
    resolved("3");
  }, 3000);
});

const p = Promise.race([p1, p2, p3]);

p.then((result) => {
  console.log(result);
});

// 2

# 为什么用 Promise

现在再说说为什么用 Promise 机制.

首先回忆, 用回调函数处理异步操作, 缺乏顺序性和可信任性.

Promise 解决了我们因只用回调的代码而产生的 "控制反转" 问题. Promise 通过把回调的控制反转反转回来,我 们把控制权放在了一个可信任的系统(Promise)中,这种系统的设计目的就是为了使异步编码更清晰。

Promise 以顺序的方式表达异步流的一个更好的方法,这有助于我们的大脑更好地计划和维护异步 JavaScript 代码 .

# Generator

Generator 生成器可以作为一种异步编程解决方案.

Generator 具有暂停函数执行的能力,意味着可以把异步操作写在 yield 表达式里面,等到调用 next 方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在 yield 表达式下面

function* loadUI() {
  showLoadingScreen();
  yield loadUIDataAsync();
  hideLoadingScreen();
}
var loader = loadUI();

// 加载UI
loader.next();

// 卸载UI
loader.next();

上面代码, 第一次调用 next 方法,则会显示 Loading 界面(showLoadingScreen),并且异步加载数据(loadUIDataAsync)。等到数据加载完成,再一次使用 next 方法,则会隐藏 Loading 界面。

下面 👇 是用 Generator 处理 Ajax 请求:

function* main() {
  var result = yield request("http://some.url");
  var resp = JSON.parse(result);
  console.log(resp.value);
}

function request(url) {
  makeAjaxCall(url, function(response) {
    it.next(response);
  });
}

var it = main();
it.next();

# Async / Await

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。async 函数简单说就是 Generator 函数的语法糖。

const fs = require("fs");

const readFile = function(fileName) {
  return new Promise(function(resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) return reject(error);
      resolve(data);
    });
  });
};

const gen = function*() {
  const f1 = yield readFile("/etc/fstab");
  const f2 = yield readFile("/etc/shells");
  console.log(f1.toString());
  console.log(f2.toString());
};

上面代码的函数 gen 可以写成 async 函数,就是下面这样。async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await,仅此而已。

const asyncReadFile = async function() {
  const f1 = await readFile("/etc/fstab");
  const f2 = await readFile("/etc/shells");
  console.log(f1.toString());
  console.log(f2.toString());
};

# 基本用法

async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。await 命令后面一般是一个 Promise 对象. await 命令返回该对象的结果。如果不是 Promise 对象,就返回对应的值。

async 函数内部 return 语句返回的值,会成为 then 方法回调函数的参数。但只有 async 函数内部的操作全部执行完,才会执行 then 方法指定的回调函数。

const asyncFun = async function() {
  let a = await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Hello");
    }, 1000);
  });

  let b = await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("World");
    }, 1000);
  });

  return a + " " + b;
};

asyncFun().then((result) => {
  console.log(result); // Hello World
});

async / await 中真正起作用的是 await。如果不包含 await 关键字, async 函数执行跟普通函数没有什么区别:

async function foo() {
  console.log(2);
}

console.log(1);
foo();
console.log(3);

// 1
// 2
// 3

# 执行顺序

在碰到 await 关键字时,会先执行紧跟在后面的语句。然后暂停执行,并把当前自己这一行,以及之后的所有语句放入微任务队列。

async function foo() {
  console.log(2);
  await console.log(3);
  console.log(5);
}

console.log(1);
foo();
console.log(4);

// 1
// 2
// 3
// 4
// 5

await 后面的语句涉及 Promise 对象时,情况更加复杂:

async function foo() {
  console.log(2);
  let x = await Promise.resolve().then(() => {
    console.log(6);
    return 9;
  });
  console.log(x);
  console.log(10);
}

async function bar() {
  console.log(4);
  console.log(await 7);
  console.log(8);
}

console.log(1);
foo();
console.log(3);
bar();
console.log(5);

// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9
// 10

# 错误处理

如果 await 后面的异步操作出错,那么等同于 async 函数返回的 Promise 对象被 reject。防止出错的方法,也是将其放在 try...catch 代码块之中。

async function main() {
  try {
    const val1 = await firstStep();
    const val2 = await secondStep(val1);
    const val3 = await thirdStep(val1, val2);

    console.log("Final: ", val3);
  } catch (err) {
    console.error(err);
  }
}

# 利用并行执行

如果使用 await 时不留心,则很可能错过并行加速的机会。来看下面的例子,其中顺序等待了 5 个随机的超时。

async function randomDelay(id) {
  // 延迟0~1000毫秒
  const delay = Math.random() * 1000;
  return new Promise((resolve) =>
    setTimeout(() => {
      console.log(`${id} finished`);
      resolve();
    }, delay)
  );
}

async function foo() {
  const t0 = Date.now();
  await randomDelay(0);
  await randomDelay(1);
  await randomDelay(2);
  await randomDelay(3);
  await randomDelay(4);
  console.log(`${Date.now() - t0} ms elapsed`);
}
foo();

// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 2219 ms elapsed

就算这些 Promise 之间没有依赖,异步函数也会依次暂停,等待每个超时完成。这样可以保证执行顺序,但总执行时间会变长。

如果顺序不是必需保证的,那么可以先一次性初始化所有 Promise,然后再分别等待它们的结果。

async function randomDelay(id) {
  // 延迟0~1000毫秒
  const delay = Math.random() * 1000;
  return new Promise((resolve) =>
    setTimeout(() => {
      setTimeout(console.log, 0, `${id} finished`);
      resolve();
    }, delay)
  );
}

async function foo() {
  const t0 = Date.now();

  const p0 = randomDelay(0);
  const p1 = randomDelay(1);
  const p2 = randomDelay(2);
  const p3 = randomDelay(3);
  const p4 = randomDelay(4);

  await p0;
  await p1;
  await p2;
  await p3;
  await p4;

  setTimeout(console.log, 0, `${Date.now() - t0} ms elapsed`);
}
foo();

// 3 finished
// 4 finished
// 2 finished
// 0 finished
// 1 finished
// 657 ms elapsed

# 事件循环

JS 代码开始执行时,全局执行上下文会被创建,然后被推入调用栈. JavaScript 引擎会逐句执行最顶部的执行上下文中的代码.

在执行过程中, 同步任务逐句被执行. 当遇到了异步任务, JavaScript 引擎会将它们交给浏览器上对应的 Web API 去处理. 比如 Ajax 请求, 会被交给 Network 模块. 浏览器处理完毕之后, 会将用以处理结果的处理函数 (回调函数), 推入到任务队列中.

当调用栈中只剩全局执行上下文的时候, 主线程就会去查询任务队列了. 任务队列中的任务会被逐一取出放入调用栈执行. 当处理任务的时候, 又遇到了新的异步任务, 则会重复之前的操作. 也就是, 调用对应 Web API, 处理完毕后, 回调函数添加到任务队列末尾.

上面的步骤会一直重复, 直到任务队列完全清空了, 至此程序执行完毕. 而这个循环过程就被叫做 "事件循环".

2020-09-15-15-44-02

# 宏任务 & 微任务

上面只是笼统的说了下 "异步任务" 和 "任务队列" 的概念.

在浏览器中, 异步任务分成 "宏任务" (macro-task) 和 "微任务" (micro-task) 两种. 这两种任务也都各自有一条任务队列.

  • 宏任务: 包括 script(整体代码), setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering
  • 微任务: 包括 process.nextTick, Promise, Object.observe, MutationObserver

第一次事件循环从宏任务 macro-task 开始. 我们看到整体的 script 代码也算是一个宏任务. 那么从读取整体 script 代码开始算第一次循环。

之后全局执行上下文被创建, 推入执行栈. 直到最后执行栈只剩全局执行上下文时, 线程然后执行所有的 micro-task 队列中的任务。清空后, 线程从 macro-task 队列首部取一个任务, 然后到最后再清空 micro-task 队列.

之后再去 macro-task 队列去下一个任务. 这样一直循环, 直到 macro-task, micro-task 队列都清空了, 全局执行上下文出栈, 程序结束.

// 同步任务
console.log("0");

setTimeout(function() {
  // 宏任务
  console.log("1");

  new Promise(function(resolve, reject) {
    // 同步任务
    console.log("2");
    resolve();
  }).then(() => {
    // 微任务
    console.log("3");
  });
}, 0);

new Promise(function(resolve, reject) {
  // 同步任务
  console.log("4");
  resolve();
}).then(() => {
  // 微任务
  console.log("5");
});

// 同步任务
console.log("6");

// 最后结果: 0, 4, 6, 5, 1, 2, 3
async function async1() {
  // 同步任务
  console.log(2);

  // 同步任务 + 微任务
  // 先同步执行 async2(),然后先不处理返回值,暂停 await 这条语句及其之后所有语句,作为微任务放入队列。
  await async2();
  setTimeout(function() {
    // 宏任务
    console.log(9);
  }, 0);
  // 同步任务
  console.log(6);
}

async function async2() {
  // 同步任务
  console.log(3);
}

// 同步任务
console.log(1);

setTimeout(function() {
  // 宏任务
  console.log(8);
}, 0);

// 同步任务
async1();

new Promise(function(resolve) {
  // 同步任务
  console.log(4);
  resolve();
}).then(function() {
  // 微任务
  console.log(7);
});

// 同步任务
console.log(5);

// 最后结果: 1, 2, 3, 4, 5, 6, 7, 8, 9
上次更新: 9/16/2020, 7:11:53 PM