likes
comments
collection
share

一文带你彻底搞懂JavaScript异步编程🍓

作者站长头像
站长
· 阅读数 12

前言

注意,本篇文章的内容篇幅比较长,如果已经学习过的可以通过目录查看想看的章节进行学习,也可以点赞收藏以便日后学习。

如果你还不是很了解异步的概念,你可以通过这篇文章进行学习, 跳转链接

至于为什么要学习异步编程,我想应该不用我说了吧,赶紧收藏起来躲被窝里学起来吧!!!!!!

一文带你彻底搞懂JavaScript异步编程🍓

一、回调函数

在上一篇文章中,所有的例子都是把函数当做独立不可分割的运作单元来使用的。在这些示例中,函数都是作为 回调 使用的,因为它是事件循环 "回头调用" 到程序中的目标,队列处理到这个项目的时候会运行它。

你应该清楚,到目前为止,回调是编写和处理 JavaScript 程序异步逻辑的最常用方式,确实,回调是这门语言中最基础的异步模式,接下来的这些方法都是基于 回调 的封装。但是回调函数也不是没有缺点,很多人因为更好的异步模式 promise而激动不已,但是,理解了 回调函数 的抽象的目标和原理,才能更有效地应用这种抽象机制。

1.1 定时器

一种最简单的异步操作就是在一定时间过后运行某些代码,我们看下面的例子🌰:

// 1
setTimeout(() => {
  // 3
}, 1000);
// 2

settimeout() 函数的第一个参数是一个函数,第二个参数是以毫秒为单位的时间间隔。在前面的代码中,程序会首先执行1的代码段,遇到 settimeout() 函数会被先挂起,等到3的代码执行完成,且是等到 1000 毫秒之后。它只会调用一次指定的回调函数,也就是执行2的代码。

1.2 事件

客户端 JavaScript 编程几乎全部是事件驱动的。也就是说,不是运行某些预定义的计算,而是等待用户做一些事,然后响应用户的动作。例如用户按下键盘、移动书把你、单机鼠标等行为,浏览器都会生成事件。事件驱动的 JavaScript 程序的特定上下文中为特定类型注册回调函数,而浏览器在指定的时间发生时调用这些函数。

这些函数叫作事件处理程序或者事件监听器,通过 addEventListener() 注册的:

<button>点击按钮</button>
<script>
  const button = document.querySelector("button");

  button.addEventListener("click", () => {
    console.log("你小子别碰我");
  });
</script>

在这个示例中,箭头函数是一个回调函数,调用 document,querySelector() 会返回一个对象,表示网页中特定的 DOM 元素。在这个元素上调用 addEventListener() 可以注册回调函数,它的一个参数是一个字符串,指定要注册的事件类型。如果用户点击了这个 DOM 元素,浏览器就会调用这个箭头函数,并给他传入了一个对象,其中包含有关时间的详细信息。

1.3 回调地狱

当多个异步函数一个接一个地执行,会产生回调地狱,那么什么是回调地狱呢,请看下面的例子:

setTimeout(() => {
  console.log(1);
  setTimeout(() => {
    console.log(2);
    setTimeout(() => {
      console.log(3);
      setTimeout(() => {
        console.log(4);
      }, 4000);
    }, 3000);
  }, 2000);
}, 1000);
// 1,2,3,4

这种代码常常被称为 回调地狱,有时也被称为毁灭金字塔,得名于嵌套缩进产生的横向三角形状。这种代码会造成复用性差,可维护性、扩展性差等等问题。

可能你现在希望有其他的 API 或其他语言机制来解决这些问题。最终,ES6带着一些极好的答案登场了。

二、Promise

“Give me a promise,I will not go anywhere,just stand here and wait for you.” 给我一个承诺,我哪里都不会去,就在原地等你。这句话用来形容 Promise 就再贴切不过了。

回调函数JavaScript 异步的基本单元。但是随着 JavaScript 越来越成熟,对于异步编程领域的发展,回调已经不够用了。

随着开发者规范撰写者绝望地清理它他们的代码和设计中由回调地狱引起的疯狂行为,Promise 风暴已经开始席卷 JavaScript 世界,Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。

2.1 什么是Promise

所谓 Promise,简单说就是一个容器,里面保存着某个未来才结束的时间,这里通常指的是异步操作的结果。从语法上说,Promise 是一个对象,从它可以获取一步操作消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。

一文带你彻底搞懂JavaScript异步编程🍓

Peomise 可以这样理解,张三饿了跟你说:我很饿,我想买点吃的。然后你看到了附近有一家买橘子的摊贩,你就跟张三说:你站在这里不要动,等我回来,我去给你买个橘子。这个买橘子的过程就像是 Promise 处理异步操作的过程,而对于张三来说这是一个等待着处理结果的过程,不管你是否买得到,无论是没买到(失败)的结果,还是买到了(成功)的结果。最终都会给张三作出回应。

对象的状态不受外界影响。Promise 对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。一旦状态改变,就不会再改变,任何时候都可以得到这个结果。Promise 对象的状态改变,只有两种可能:从 padding 变为 fulfilled或者从 pending 变为 rejected。只要这两种状态发生改变,其状态就无法改变了,会一直保存这个结果。

还是买橘子的情形,在你跟张三说去买橘子和去买橘子的时候状态为 pending,只有当你回到张三面前,无论你是否买到(失败或者成功),这个状态都不会改变了,你不会再去买了。

2.2 Promise的创建

Promise 对象是一个构造函数,它需要通过 new 操作符用来实例化 Promise,Promise 接收一个回调函数作为参数,该回调函数又接收两个参数,这两个参数分别是 resolverejected,但是二者只有其中一个被调用。Promise 对象接收的函数是一个自执行函数,一旦被传入就会被立即执行,被称之为 executor,用伪代码的形式可以这样表示:

class Promise {
  constructor(func) {
    // 其中省略了很多代码
    func();
  }
}

JavaScript 执行栈正在执行 Promise 的执行时函数的时候,其 Promise 的状态为 pending, resolve 函数的作用是将 Promise 对象的状态从 pending 变成 fulfilled,并将异步操作的结果,作为参数传递出去。而 rejected 函数的作用是将 Promise 对象的状态从 pending 变成 rejected,并将异步操作失败时的结果,作为参数传递出去。

请看以下代码,生成一个 Promise 实例:

const p = new Promise((resolve, reject) => {
  console.log(1); // 1
  resolve("success");
});
console.log(p);

调用了 resolve 函数,并传入了 "success" 字符串,通过打印,查看控制台,有以下的输出结果:

一文带你彻底搞懂JavaScript异步编程🍓

Promise 的状态变为了 fulfilled 也就是成功的回调,而成功的结果正是传入的字符串 "success",该实例通过原型链可以查找到 4 个原型方法,它们分别是 catchfinallythen等方法。

2.3 then方法

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

const p = new Promise((resolve, reject) => {
  resolve("success");
  reject(`error`);
});

p.then(resolve => {
  console.log(resolve, "成功"); // 输出 success 成功
}, reject => {
  console.log(reject,'失败'); // 不输出
});

Promise 领域,一个重要的细节是如何确定某个值是不是真正的 Promise。火证更直接地说,它是不是一个行为方式类似于 Promise 的值?

因此识别 Promise(或者行为类似于 Promise的东西)就是定义某种被称为 thenable 的东西,将其定义为任何具有 then(...) 方法的对象和函数。我们认为任何这样的值就是 Promise 一致的 thenable

根据一个变量具体拥有哪些属性,并对这个值的类型做出一些假定。我们可以通过类型检测这个变量是否属于 thenable 值,代码大致如下:

if (
  p !== null &&
  (typeof p === "object" || typeof p === "function") &&
  typeof p.then === "function"
) {
  // 这是一个 thenable
} else {
  // 不是 thenable
}

如果我们对在 Promise 创建过程中对 resolve 中传入一个普通的对象,那么我们可以通过 then 方法注册的一个函数参数中获取到该对象:

const object = { a: 1, b: 2 };

new Promise((res) => {
  res(object);
}).then((res) => {
  console.log(res); // { a: 1, b: 2 }
});

如果传进来的是一个对象,并且这个对象有 then 方法,那么也会执行该 then 方法,并且这个对象是实现了 thenable,那么也会执行由该 then 方法,而且是由该 then 方法决定后续状态,请看下面代码:

const object = {
  then: function (resolve, reject) {
    resolve("then 方法的返回结果");
  },
};

new Promise((resolve, rejected) => {
  resolve(object);
}).then((resolve) => {
  console.log(resolve, "成功"); // then 方法的返回结果 成功
},(reject) => {
  console.log(reject, "失败"); // 不输出
});

一文带你彻底搞懂JavaScript异步编程🍓

如果你试图使用恰好有 then(...) 函数的一个对象或函数值,但并不希望它被当做 Promisethenable,那就有点麻烦了,因为它会自动被识别为 thenable,并被按照规定的规则处理:

const foo = { then: function () {}, bar: function () {} };

const object = Object.create(foo);
object.foo = function () {
  return 1;
};
object.nickname = "moment";
console.log(object);
// { foo: [Function (anonymous)], nickname: 'moment' }

通过控制台打印,在 foo 对象中定义的 then 方法直接被忽略掉了。还有一些特别的情况,请看下面的代码:

Object.prototype.then = function () {};

const array = [1, 2, 3, 4, 5];
const object = {
  nickname: "moment",
  age: 7,
};

new Promise((res) => {
  res(object);
}).then((res) => {
  console.log(res);
});

object 会被认作 thenable。又因为 object 对象的原型上定义了 then(...) 方法,并且该 then(...) 方法并没有返回任何值,其状态也是 pending,通过控制台打印也正是如此,其输出结果为 Promise { <pending> }

一文带你彻底搞懂JavaScript异步编程🍓

then 方法可以接受两个回调函数,第一个回调函数是 Promise 对象的状态变为 fulfilled 时调用,第二个回调函数是 Promise 对象的状态变为 rejected 时调用,这两个参数可选,可以使用 undefined 进行代替。 这两个回调函数都接收一个参数,它们分别是成功时的结果和失败时的结果。

通过上面的例子可以看出,在 then 的第一个回调中通过打印 resolve ,打印结果正是前面回调函数 resolve 传进去的参数。但是为什么后面的 reject 不会打印呢,因为在 reject 前面已经调用了 resolve,这个时候 Promise 的状态已经变为了 fulfilled 状态,此时状态已经不会再改变,所以 then 方法的第二个回调函数 reject 不会被调用,也就不会被执行了。

如果 Promise 的创建过程中或在查看状态的过程中的任何一个时间点上出现了一个 JavaScript 异常错误,比如一个 TypeErrorRefenenError,这个错误会被捕捉,那么该错误会被 then 方法的 reject 接收,说明当在这里抛出错误的时候,Promise 对象的状态由 pending 变为 rejected,请看以下代码:

new Promise((resolve, rejected) => {
  throw '我是主动抛出的错误';
}).then((resolve) => {
  console.log(resolve, "成功"); // 不输出
},(reject) => {
  console.log(reject, "失败"); // 我是主动抛出的错误 失败
});
new Promise((resolve, rejected) => {
  foo();// 从来没有定义 foo 函数
}).then((resolve) => {
  console.log(resolve, "成功"); // 不输出
},(reject) => {
  console.log(reject, "失败"); // 我是主动抛出的错误 失败
});

在上面的代码中,foo 从来没有被定义,于是便发生了异常导致了 Promise 拒绝,通过 reject 捕捉到的结果如下:

一文带你彻底搞懂JavaScript异步编程🍓

但是如果在 Promise 状态敲定之后再 then 注册的回调中出现了 JavaScript 异常错误会怎样呢?即使这些异常不会被抛弃,但是你会发现,对它们的处理方式还是有点出乎意料的,需要进行一些深入研究才能理解:

new Promise((resolve, rejected) => {
  resolve(77)
}).then((resolve) => {
  foo(); // 从来没有定义 foo 函数
  console.log(resolve, "成功"); // 不输出
},(reject) => {
  console.log(reject, "失败"); // 这里也不输出
})

咦,你会发现控制台上直接报错了,报错的信息是 index.js:138 Uncaught (in promise) ReferenceError: foo is not defined at index.js,而且成功的结果也丢掉了。

原因很简单,因为 Promise 的状态已经变为 fulfilled 的了,状态已经敲定了就不能再更高了,所以在 then 方法注册的第二个函数中也就无法捕捉到他的错误。

注意这个是关键点,then 方法的调用他本身就返回一个 Promise 对象,你可以继续调用 then 方法,这就是我们常说的链式调用。我们通过继续调用 then 方法并能在 该方法中的第二个函数捕捉到 foo() 错误调用的提示了:

new Promise((resolve, rejected) => {
  resolve(77)
}).then((resolve) => {
  foo(); // 从来没有定义 foo 函数
  console.log(resolve, "成功"); // 不输出
},(reject) => {
  console.log(reject, "失败"); // 这里也不输出
}).then(undefined, rej => {
  console.log(rej);//ReferenceError: foo is not defined at index.js
})

2.4 resolve详解

resolve 的值不仅可以传递普通的值和对象,还可以传入一个 Promise,请看下面的例子:

const promise = new Promise((resolve, reject) => {});

new Promise((resolve, reject) => {
  resolve(promise);
}).then(
  (resolve) => {
    console.log(resolve, "成功");
  },
  (reject) => {
    console.log(reject);
  }
);

通过查看控制台,发现什么都没有输出,咦,什么原因呢?其实啊,事情是这样的,如果传入的是一个 Promise,那么当前的 Promise 状态是由传入的 Promise 状态所决定。因为传进去的 Promise 对象处于 padding,所以then方法那里啥也没有打印。我们通过打印发现,Promise 对象的状态正是 pending 状态,请看下图:

一文带你彻底搞懂JavaScript异步编程🍓

如果我们传进来的 Promise 对象调用 resolve,那么当前 Promise 对象的返回结果正是传进来的 Promise 对象的返回结果,如果传进来的是一个 reject,那么当前的 Promise 的状态变为 rejected,请看下图:

一文带你彻底搞懂JavaScript异步编程🍓

如果当前的 Promise 对象调用的是 reject 函数,如果传进来的 Promise对象是调用的 resolve 方法,那么当前 Promise 对象的结果是一个失败的 Promise 对象,而失败的原因正是传进来的 Promise 对象的成功的 Promise 对象........是不是很绕😲😲😲请看代码:

const promise = new Promise((resolve, reject) => {
  resolve("success");
});

 new Promise((resolve, rejected) => {
  rejected(promise);
}).then((resolve) => {
    console.log(resolve, "成功"); // 不输出
  },(reject) => {
    console.log(reject, "失败"); // Promise {<fulfilled>: 'success'} '失败'
  });

2.5 catch方法

catch(...) 方法是 then(null,reject) 或者 then(undefined,reject) 的别名,用于指定发生错误时的回调函数:

new Promise((resolve, reject) => {
  reject("error");
})
  .then((resolve) => {
    console.log(resolve); // 不输出
  })
  .catch((error) => {
    console.log(error); // error
  });

catch 方法中还能再抛出错误:

new Promise((resolve, reject) => {
  reject(1);
}).then((res) => {
  console.log(res);
}).catch((error) => {
  console.log(error); // 1
  foo(); // foo 为定义
}).catch((error) => {
  console.log(error);
  // ReferenceError: foo is not defined
});

上面代码中,第二个catch()方法用来捕获前一个catch()方法抛出的错误。

2.6 finally方法

finally(...)方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。这个方法可以减少 resolve(...)reject(...) 处理程序中出现冗余代码。但 finally(...) 处理程序没有办法知道Promise 的状态是 resolve 还是 rejected,所以这个方法主要用于添加清理代码。

new Promise((resolve, reject) => {
  resolve(111);
}).then((res) => {
  console.log(res); // 111
}).catch((error) => {
  console.log(error);
}).finally((res) => {
  console.log(res); // undefined
});

finally(...) 方法接收一个回调函数,但是这个回调函数不接收任何参数,所以在任何情况下对其输出的结果进行打印都为 undefined

2.7 all方法

在经典的编程属于中,门是这样一种机制要等待两个或更多 并行/并发 的任务都完成才能继续。它们的完成顺序并不重要,但是必须都要完成,门才能打开并让流程控制继续。在 Promise 中正是提供了这样的静态方法,这种模式被称为 all[...]

如果你想要同时发生两个 axios 请求,等它们不管以什么顺序全部完成之后,再发生第三个 axios 请求,你可以这样做:

const p1 = axios("https://www.你小子.1");
const p2 = axios("https://www.你小子.2");

Promise.all([p1, p2])
  .then((resolve) => {
    // 这里等待 p1和p2 完成并把它们的消息传入
    return axios("https://www.你小子.3/?value="+resolve.join(","))
  }, reject => {
    console.log(reject);
  }).then(resolve => {
    console.log(resolve);
  })

Promise.all(...) 接收一个参数,是一个数组,通常由 Promise 实例组成。Promise.all(...) 调用返回的 Promise 结果通常由传进去的参数所决定,如果传进去的所有 Promise 都是返回成功的结果,那么该静态方法的返回结果是由传进去的 Promise 实例返回的成功的结果值组成的数组,该数组的顺序与完成顺序无关,如果一个或多个 Promise 的状态是 rejected 的,那么该实例方法返回第一个 rejected 的结果,具体请看下列代码:

const p1 = Promise.resolve(111);
const p2 = Promise.resolve(222);
const p3 = Promise.resolve(333);

Promise.all([p1, p2, p3]).then(
  (resolve) => {
    console.log(resolve); // [ 111, 222, 333 ]
  },
  (reject) => {
    console.log(reject); // 不输出
  }
);

const p1 = Promise.reject(111);
const p2 = Promise.resolve(222);
const p3 = Promise.reject(333);

Promise.all([p1, p2, p3]).then(
  (resolve) => {
    console.log(resolve); // 不输出
  },
  (reject) => {
    console.log(reject); // 111
  }
);

值得注意的是,如果 rejected 状态的 Promise 实例被 catch(...) 方法捕捉到了,该方法返回一个新的 Promise 实例,那么该实例也会变成 fulfilled 状态。请看下面的代码:

const p1 = Promise.resolve(111);
const p2 = Promise.resolve(222);
const p3 = Promise.reject(333).catch((error) => {
  console.log(error); // 333
});

Promise.all([p1, p2, p3]).then(
  (resolve) => {
    console.log(resolve); // [ 111, 222, undefined ]
    // 因为 catch 并没有返回任何值,所以是 undefined
  },
  (reject) => {
    console.log(reject); // 不输出
  }
);

如果传进去的参数中有不是 Promise 实例的,会先调用 Promise.resolve(arg) 方法将其转化成 Promise 实例,例如:

const p1 = Promise.resolve(111);
const p2 = Promise.resolve(222);

Promise.all([p1, p2, 333]).then((resolve) => {
  console.log(resolve); // [ 111, 222, undefined ]
}); 

2.8 race方法

尽管 Promise.race([...])协调多个并发 Promise 的运行,并假定所有 Promise 都需要完成,但有时候也会想只应 "第一个跨过终点线的 Promise",而抛弃其他 Promise....等等,这不就是渣男吗? 这种模式传统上被称为门闩,但在 Promise 中被称为 竞态

race 方法同样是将多个 Promise 实例包装成一个实例,如果参数不是 Promise 实例,和 all([...]) 方法一样的处理方式,具体请看下面的代码:

const p1 = Promise.reject(111);
const p2 = Promise.resolve(222);

Promise.race([p1, p2])
  .then((resolve) => {
    console.log(resolve); // 不输出
  })
  .catch((error) => {
    console.log("失败:", error); // 失败: 111
  });

不管第一个 Promise 实例的状态是 fulfilled 还是 rejected 都会直接返回第一个实例的结果。

2.9 any

any([...]) 方法接收一组 Promise 实例作为参数,包装成一个新的 Promise 实例返回。如果有一个 Promise 实例的状态为 fulfilled 的,那么就返回该实例的返回结果。如果所有的实例都是 rejected 状态,那么包装实例就会变成 rejected 状态,具体请看代码:

const p1 = Promise.reject(111);
const p2 = Promise.reject(222);
const p3 = Promise.reject(333);

Promise.any([Promise.resolve(111), p1]).then((res) => {
  console.log(res); // 1
});

Promise.any([p1, p2, p3])
  .then((resolve) => {
    console.log(resolve); // 不输出
  })
  .catch((error) => {
    console.log("失败:", error);
    // [AggregateError: All promises were rejected]
});

2.10 allSettled方法

有时候,我们希望等到一组异步操作都结束了,不管每一个操作的结果是成功还是失败,再进行下一步操作,为了解决这个困境,ES2020 引入了 allSettled([...]) 方法,用来确定一组异步操作是否都结束了。其接收一个数组作为参数,还是和前面的 all([...]) 方法和 any 方法一样,但不同的是它只有等到参数数组的所有 Promise 对象都发生状态变更(不管是 fulfilled 还是 rejected),并且返回的新的 Promise 对象是一个数组对象,如果传进去的 Promise 实例是成功的结果,那么该数组对象包含 status 的值为 fulfilled,还有 value 属性,是成功的原因,反之,status 的值为 rejected,value 属性会变为 reason,其值是失败的结果,具体输出信息请看下面代码:

const p1 = Promise.reject(111);
const p2 = Promise.reject(222);
const p3 = Promise.resolve(333);

Promise.allSettled([p1, p2, p3]).then((resolve) => {
  console.log(resolve);
});

// 输出结果
// [
//   { status: "rejected", reason: 111 },
//   { status: "rejected", reason: 222 },
//   { status: "fulfilled", value: 333 },
// ];

2.11. wrap

Promise 本身并没有提供这个方法,但是多数 Promise 库确实提供了这个工具函数,它能使我们减少了回调地狱的问题出现,在之前的开发中,我们去获取文件的信息中需要这样做,请看下面代码:

const fs = require("fs");

fs.readFile("./moment/a.txt", (error, data) => {
  if (error) throw error;
  console.log(data.toString()); // 不是所有的牛奶都叫特仑苏
});

在之前我们要通过获取一个文件的信息并写到另外一个文件里,需要通过大量的回调,容易出现回调地狱的问题,但是自 Promise 出来之后,我们可以通过 Promise 原有的特性封装一个辅助工具,它接收一个函数,并返回一个新函数,返回的函数自动创建一个 Promise 并返回,不需要接收回到,因为该工具自动接收 Promise 错误或者成功,如果错误并且会自动抛出错误,具体代码实现如下:

Promise.wrap = function (fn) {
  return function () {
    var args = [].slice.call(arguments);
    return new Promise(function (resolve, reject) {
      fn.apply(
        null,
        args.concat(function (err, v) {
          if (err) {
            reject(err);
          } else {
            resolve(v);
          }
        })
      );
    }).catch((error) => {
      console.log(error);
    });
  };
};

返回的 Promise 的函数可以看作是一个 Promise 工厂,把需要回调的函数封装为支持 Promise 的函数,这个行为可以称为 Promise 工厂化,因此,这样的代码,我们可以把它编写成这样,具体代码如下:

const fs = require("fs");

const readFileThunk = Promise.wrap(fs.readFile);

readFileThunk("./moment/a.txt").then((res) => {
  console.log(res.toString()); // 不是所有的牛奶都叫特仑苏
});

这样我们就减少了回调的使用,避免了开发过程中引起的大量重复代码。

2.12 并发迭代

有些时候会需要在一列 Promise 中迭代,并对所有 Promise 都执行某个任务,非常类似于对同步数组可以做的那样,例如:forEach(...)map(...)some(...)。如果要对每个 Promise 执行的任务本身是同步的,那这些工具就可以正常工作,就像前面代码中的 forEach(...)

但如果这些任务从根本上是异步的,或者可以并发执行,那你通过实现一个异步工具 map(...),它接收一个数组的值,可以是 Promise 对象或者其他任何值,外加要在每个值上运行一个任务函数作为参数。

map(...) 方法本身返回一个新的 Promise 对象,其完成值是一个数组,该数组(保持映射顺序)保存任务函数执行之后的异步完成值,其实现代码请看下面过程:

Promise.map = function (values, callback) {
  return Promise.all(
    // values 接收一个 Promise 数组
     values.map((item) => {
      return new Promise((resolve) => {
        callback(item, resolve);
      });
    })
  );
};

在这个方法实现中,不能对异步错误进行处理,直接忽略掉,并按原值返回,下面展示一下如何在一组 Promise 上使用 map(...),代码如下:

const p1 = Promise.resolve(3);
const p2 = Promise.resolve(6);
const p3 = Promise.reject("error");

Promise.map([p1, p2, p3], function (values, done) {
  Promise.resolve(values).then((val) => {
    done(val * 2);
  }, done);
}).then((val) => {
  console.log(val); // [ 6, 12, 'error' ]
});

2.13 有并发限制的Promise请求

所谓并发请求,就是指在一个时间点多个请求同时执行。当并发的请求超过一定数量时,会造成网络堵塞,服务器压力大崩溃或者其他高并发问题,此时需要限制并发请求的数量。

假如等待请求接口20个,限制每次请求只能发出5个,即同一时刻最多只能有5个正在发送的请求,我们必须将同一时刻并发请求数量控制在 5 个以内,同时还要尽可能快速的拿到响应结果,直到全部请求完成。具体代码实现请看下面:

// 模拟异步请求
const request = (url) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`任务 ${url} 完成`);
    }, 1000);
  });
};

async function multipleController(tasks = [], max = 3) {
  // 并发控制栈
  const pool = [];
  for (let i = 0; i < tasks.length; i++) {
    // 生成异步任务
    const task = request(tasks[i]);
    // 添加到正在执行的任务数组
    pool.push(task);
    task.then((data) => {
      console.log(`${data}; 当前并发数: ${pool.length}`);
      // 当任务执行完毕, 将其从正在执行任务数组中移除
      pool.splice(pool.indexOf(task), 1);
    });

    // 当并发池满了, 就先去执行并发池中的任务, 有任务执行完成后, 再继续循环
    if (pool.length === max) {
      await Promise.race(pool);
    }
  }
}

multipleController([1, 2, 3, 4, 5, 6, 7, 8, 9], 3);

最终的输出结果请看下图:

一文带你彻底搞懂JavaScript异步编程🍓

三、迭代器

迭代的英文 "iteration" 源自拉丁文 itero,意思是 "重复""再来"。在软件开发领域,迭代 的意思是按照顺序反复多次执行一段程序,通常会有明确的终止条件。

3.1 理解迭代

迭代器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。 在 JavaScript 中,计数循环就是一种简单的迭代:

for (let i = 0; i < 10; i++) {
  console.log(i);
}

循环是迭代机制的基础,这是因为它可以指定迭代的次数,以及每次迭代要执行什么操作。每次循环都会在下一次迭代开始之前完成,而每次迭代的顺序都是事先定义好的。

迭代会在一个有序集合上进行。数组是 JavaScript 中有序集合的最典型例子。

“有序”可以理解为集合中所有项都可以按照既定的顺序被遍历 到,特别是开始和结束项有明确的定义。

const array = [1, 2, 3, 4, 5];

for (let index = 0; index < array.length; index++) {
  console.log(array[index]);
}

因为数组有已知的长度,且数组每一项都可以通过索引获取,所以整个数组可以通过递增索引来遍历。 由于如下原因,通过这种循环来执行例程并不理想。

  • 迭代之前需要事先知道如何使用数据结构。数组中的每一项都只能先通过引用取得数组对象,然后再通过 [] 操作符取得特定索引位置上的项。这种情况并不适用于所有数据结构。
  • 遍历顺序并不是数据结构固有的。通过递增索引来访问数据是特定于数组类型的方式,并不适用于其他具有隐式顺序的数据结构。

forEach(...) 方法解决单独记录索引的和通过数组对象取得值的问题,但是没有办法表示迭代何时终止,因此这个方法仅适用于数组,而且回调结构也比较笨拙。

3.2 默认 Iterator 接口

Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即 for...of 循环。当使用 for...of 循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。一种数据结构只要部署了 Iterator 接口,我们就称这种数据结构是可遍历的 Iterator

ES6 规定,默认的 Iterator 接口部署在数据结构的 Symbol.iterator 属性,或者说,一个数据结构只要具有 Symbol.iterator 属性,就可以认为是可遍历的 iterable

JavaScript 中很多内置类型都实现了 Iterator 接口:字符串数组映射arguments对象NodeList等DOM结合类型。例如通过浏览器打印 console.log([]),通过查找数组的原型便能查找到,请看下图,Array 内部定义了 Symbol.iterator 属性:

一文带你彻底搞懂JavaScript异步编程🍓

检查是否存在默认迭代器属性可以暴露这个工厂函数:

const num = 1;
const obj = { nickname: "moment" };
// 这两种类型没有实现迭代器工厂函数
console.log(num[Symbol.iterator]); // undefined
console.log(obj[Symbol.iterator]); // undefined

const str = "abc";
const arr = ["a", "b", "c"];
const map = new Map().set("a", 1).set("b", 2).set("c", 3);
const set = new Set().add("a").add("b").add("c");
const els = document.querySelectorAll("div");

// 这些类型都实现了迭代器工厂函数
console.log(str[Symbol.iterator]); // f values() { [native code] }
console.log(arr[Symbol.iterator]); // f values() { [native code] }
console.log(map[Symbol.iterator]); // f values() { [native code] }
console.log(set[Symbol.iterator]); // f values() { [native code] }
console.log(els[Symbol.iterator]); // f values() { [native code] }
// 调用这个工厂函数会生成一个迭代器
console.log(str[Symbol.iterator]()); // StringIterator {}
console.log(arr[Symbol.iterator]()); // ArrayIterator {}
console.log(map[Symbol.iterator]()); // MapIterator {}
console.log(set[Symbol.iterator]()); // SetIterator {}
console.log(els[Symbol.iterator]()); // ArrayIterator {}

3.3 迭代器的使用

迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象。迭代器 API 使用 next(...) 方法在可迭代对象中遍历数据。每次成功调用 next(...),都会返回一个 IteratorResult 对象,其中包含迭 代器返回的下一个值。若不调用 next(),则无法知道迭代器的当前位置。

next(...) 方法返回一个对象,表示当前数据成员的信息。这个对象具有 valuedone 两个属性,value属性返回当前位置的成员,done 属性是一个布尔值,表示遍历是否结束,即是否还有必要再一次调用next(...) 方法。

下面通过一组数组来演示:

const result = ["a", "b", "c", "d"];

const iterator = result[Symbol.iterator]();

console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: 'd', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

这里通过创建迭代器并调用 next(...) 方法按顺序迭代了数组,直至不再产生新值。迭代器并不知道怎么从可迭代对象中取得下一个值,也不知道可迭代对象有多大。只要迭代器到达 done: true 状态,表明该数组已经被遍历完成了,后续调用 next(...)就一直返回同样的值了,也就是 { value: undefined, done: true }

3.4 自定义迭代器

在日常开发中,我们想通过 for...of 去遍历对象的时候,发现报错了,如下:

const object = {
  hobby: ["basketball", "football", "eat", "play"],
};

for (const iterator of object) {
  console.log(iterator);
}
// TypeError: object is not iterable at Object.<anonymous>

之所以报了这样的错误,是因为 Object 原型对象本身并没有定义 Symbol.iterator 的属性,所以使用 for...of 遍历会报错,但是我们仍希望这样做的时候,我们可以通过自定义迭代器来实现:

const foo = {
  hobby: "play",
  nickname: "moment",
  age: 7,
  address: "广州",
  bar: function () {},
  [Symbol.iterator]: function () {
    let index = 0;

    const iterator = Object.keys(this);
    return {
      next: () => {
        if (index < iterator.length) {
          {
            return {
              done: false,
              value: { keys: iterator[index], value: this[iterator[index++]] },
            };
          }
        } else {
          return { done: true, value: undefined };
        }
      },
    };
  },
};

for (const { keys, value } of foo) {
  console.log(keys, "->", value);
}
// hobby -> play
// nickname -> moment
// age -> 7
// address -> 广州
// bar -> [Function: bar]

通过这样就可以遍历对象中所有的 key 了。

3.5 提前终止迭代器

可选的 return(...) 方法用于指定在迭代器提前关闭时执行的逻辑。执行迭代的结构在想让迭代器知 道它不想遍历到可迭代对象耗尽时,就可以“关闭”迭代器。可能的情况包括:

  • for...of 循环通过 breakcontinuereturnthrow 提前退出;
  • 解构赋值并未消费所有值;

return(...) 方法必须返回一个有效的 IteratorResult 对象,如下面的代码所示,内置语言结构在发现还有更多值可以迭代,但不会消费这些值时,会自动调用 return(...) 方法:

class Counter {
  constructor(limit) {
    this.limit = limit;
  }
  [Symbol.iterator]() {
    let count = 1,
      limit = this.limit;
    return {
      next() {
        if (count <= limit) return { done: false, value: count++ };
        else return { done: true };
      },
      return() {
        console.log("Exiting early");
        return { done: true };
      },
    };
  }
}
let counter = new Counter(5);

for (let i of counter) {
  if (i > 2) break;
  console.log(i); // 1 2 Exiting early
}

try {
  for (let i of counter) {
    if (i > 2) throw "err";
    console.log(i); // 1 2 Exiting early
  }
} catch (e) {}

let [x, y] = counter;
// Exiting early

如果迭代器没有关闭,则还可以继续从上次离开的地方继续迭代。比如,数组的迭代器就是不能关闭的:

let a = [1, 2, 3, 4, 5];
let iter = a[Symbol.iterator]();

for (let i of iter) {
  console.log(i); // 1  2  3
  if (i > 2) break;
}

for (let i of iter) console.log(i); // 4  5

四、Generator函数

Generator 函数是 ES6 提供的一种异步编程解方案,语法行为与传统函数完全不同。其拥有在一个函数块内暂停和恢复代码执行的 能力。这种新能力具有深远的影响,比如使用生成器可以自定义迭代器和实现协程。

Generator 函数有多种角度,语法上,首先可以把它理解成它是一个状态机,封装了多个内部状态。执行该函数会返回一个遍历器对象,也就是说该函数除了是状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

4.1 Generator函数的基本使用

生成器的形式是一个函数,函数名称前面加一个星号(*)表示它是一个生成器。只要是可以定义函数的地方,就可以定义生成器,使用示例如下:

// 生成器函数声明
function* generatorFn() {}
// 生成器函数表达式
const generatorFn = function* () {};
// 作为对象字面量方法的生成器函数
const foo = {
  *generatorFn() {},
};
// 作为类实例方法的生成器函数
class Foo {
  *generatorFn() {}
}
// 作为类静态方法的生成器函数
class Bar {
  static *generatorFn() {}
}

注意:牵头函数不能用来定义生成器函数,且标识生成器函数的星号不受两侧空格的影响。

调用生成器函数会产生一个生成器对象。生成器对象一开始处于暂停执行 suspended 的状态,中文意思是暂停。与迭代器相似,生成器对象也实现了 Iterator 接口,因此具有 next(...) 方法。调用这个方法会让生成器开始或恢复执行。详情请看下图:

一文带你彻底搞懂JavaScript异步编程🍓

通过查看输出,发现生成器函数上存在一个 next(...) 方法,该方法的返回值类似于迭代器,有一个 done 属性和一个 value 属性。函数体为空的生成器函数中间不会停留,调用一次 next(...) 方法就会让生成器函数到达 done: true 状态。

function* generatorFn() {}
const foo = generatorFn();

console.log(foo.next); // ƒ next() { [native code] }
console.log(foo.next()); // {value: undefined, done: true}

value 属性是生成器函数的返回值,默认值为 undefined,可以通过生成器函数的返回值指定:

function* generatorFn() {
  return 1;
}

const foo = generatorFn();

console.log(foo.next()); // {value: 1, done: true}
console.log(foo);

当调用 next(...) 方法返回的结果中 done 的值为 true 的时候,再次打印生成器函数的时候,发现生成器对象的状态处于 closed 下,请看下图:

一文带你彻底搞懂JavaScript异步编程🍓

生成器函数只会在初次调用 next(...) 方法后才开始执行,如果不调用,函数里面的代码不会执行,如下图所示:

function* generatorFn() {
  console.log(11111);
  return 1;
}

// 初次调用生成器函数并不会打印日志
const foo = generatorFn();
foo.next(); // 11111

4.2 使用yield实现输入和输出

由于 Generator 函数返回的遍历器对象,只有调用 next(...) 方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield 表达式就是暂停标志。

  • 遍历器对象的 next(...) 方法的运行逻辑如下:
  1. 遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值;
  2. 下一次调用 next(...) 方法时,再继续往下执行,直到遇到下一个 yield 表达式;
  3. 如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值;
  4. 如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined;

值得注意的是,yield 表达式后面的表达式,只有当调用next(...)方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation) 的语法功能。

function* generatorFn() {
  yield 123 + 456;
}

const foo = generatorFn(); // 579
console.log(foo.next());

上面代码中,yield 后面的表达式 123 + 456,不会立即求值,只会在 next(...) 方法将指针移到这一句时,才会求值。

除了可以作为函数的中间返回语句使用,yield 关键字还可以作为函数的中间参数使用。上一次让生成器函数暂停的 yield 关键字会接收到传给 next(...) 方法的第一个值。第一次调用 next(...) 方法传入的值不会被使用,因为这一次调用是为了开始执行生成器函数:

function* generatorFn(initial) {
  console.log(initial);
  console.log(yield);
  console.log(yield);
}
const generator = generatorFn("foo");

generator.next("bar"); // foo
generator.next("baz"); // baz
generator.next("qux"); // qux

也就是说,当创建生成器函数的时候,传进去的参数会被参数 initial 接收,第一次传进去的 "bar" 不会生效,它只是用来启动 *generatorFn(...) 的,也就是说,第一个 yidld 接收返回的结果是由第二个 next(...) 传进来的参数,其返回值可以作为下一段代码中使用,具体请看下面的例子:

function* generatorFn(initial) {
  let index = initial;

  // 此时 one 的值由第二个 next(...) 传进来的参数所决定
  // 而通过 next(...) 调用的 value 为 2
  const one = yield ++index;
  console.log(one);
  yield one;
}
const generator = generatorFn(1);

// 第一次调用 next(...) 传的参数不生效
console.log(generator.next("不生效")); // { value: 2, done: false }
console.log(generator.next("hi")); // { value: 'hi', done: false }

再看一个例子:

function* foo(x) {
  const y = x * (yield 1);
  return y;
}

const f = foo(6);

console.log(f.next()); // { value: 1, done: false }
console.log(f.next(7)); // { value: 42, done: true }

第一次调用的 next(...) 的返回值是 yield 表达式后面的值,也就是 { value: 1, done: false },而第二次 next(,,,) 方法传的值为 7,所以整个括号的值为 7,所以 y = 6 * 7,所以最终的结果输出 42 也就很正常了。

yield 关键字并非只能使用一次,比如以下代码就定义了一个无穷计数生成器函数:

function* generatorFn() {
  for (let i = 0; ; ++i) {
    yield i;
  }
}
let generatorObject = generatorFn();
console.log(generatorObject.next().value); // 0
console.log(generatorObject.next().value); // 1
console.log(generatorObject.next().value); // 2
console.log(generatorObject.next().value); // 3

next(...) 每调用一次 for 循环也就会执行一次,因此可以用该思路来实现自增长 ID

4.3 多个生成器实例

先来考虑一下这个场景:

let a = 1;
let b = 2;

function foo() {
  a++;
  b = b * a;
  a = b + 3;
}

function bar() {
  b--;
  a = 8 + b;
  b = a * 2;
}

如果是普通的 JavaScript 函数的话,显然,要么是 foo(...) 首先运行完毕,要么就是 bar(...) 首先运行完毕,但 foo(...)bar(...) 的语句不能交替执行。所以,前面的程序只有两者可能的输出。

但是使用生成器的话,它们可以彼此交替执行:

let a = 1;
let b = 2;

function* foo() {
  a++;
  yield;
  b = b * a;
  a = (yield b) + 3;
}

function* bar() {
  b--;
  yield;
  a = (yield 8) + b;
  b = a * (yield 2);
}

根据迭代器器控制 *foo(...)*bar(...) 调用的相对顺序不同,前面的程序有可能产生多种不同的结果。换句话说,通过两个生成器函数在共享的相同变量上的迭代交替执行,我们可以模拟理论上的多线程竞态条件环境

4.4 产生可迭代对象

在前面的内容说过,任何一个对象的内部实现了 Symbol>Iterator 方法,那么它就可以被 for...of 遍历。由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator 属性,从而使得该对象具有 Iterator 接口:

const moment = {};
moment[Symbol.iterator] = function* () {
  yield 1;
  yield 2;
  yield 3;
  return 4;
};

for (const iterator of moment) {
  console.log(iterator); //1 2 3 4
}

上面代码使用 for...of 循环,一次输出 3yidld 表达式的值。这里需要注意,一旦 next(...) 方法的返回对象的 done 属性为 true,for...of 循环就会终止,且不包含该返回对象,所以上面的代码的 return 语句返回的4并没有包含在输出里面。

你也可以使用 * 增强 yidle 的行为,让它能够迭代一个可迭代对象,从而一次产出一个值:

const moment = {};
moment[Symbol.iterator] = function* () {
  yield* [1, 2, 3, 4];
};

for (const iterator of moment) {
  console.log(iterator); //1 2 3 4
}

yield* 实际上只是将一个可迭代对象序列化为一连串可以单独产出的值,所以这跟把 yield 放到一个循环里没什么不同,请看下面的代码:

function* generator() {
  for (const x of [1, 2, 3]) {
    yield x;
  }
}
for (const x of generator()) {
  console.log(x); // 1 2 3
}

4.5 提前终止生成器

与迭代器类似,生成器也支持 "可关闭" 的概念。一个实现 Iterator 接口的对象一定有 next(...) 方法,还有一个可选的 return(...) 方法用于提前终止迭代器。生成器对象除了有这两个方法,还有第三个方法: throw()

return(...) 方法会强制生成器进入关闭状态。提供给 return(...) 方法的值,就是终止迭代器对象的值:

function* generator() {
  yield 1;
  yield 2;
  yield 3;
}

const g = generator();

console.log(g); // generator {<suspended>}
console.log(g.next()); // {value: 1, done: false}
console.log(g); // generator {<suspended>}
console.log(g.return("你小子")); // {value: "你小子", done: true}
console.log(g); // generator {<closed>}
console.log(g.next()); // { value: undefined, done: true }

与迭代器不同,所有生成器对象都有 return(...) 方法,只要通过它进入关闭状态,也就是上面输出的 closed,就无法恢复了。后续调用 next(...) 会显示 done: true 状态,而提供的任何返回值都不会被存储或传播。

如果生成器函数内部有 try...catch 代码快,且正在执行 try 代码快,那么 return(...) 方法会导致立即进入 finally 代码快,执行完成之后,整个函数才会结束, finally 之后的代码不会再执行了,具体请看下面代码:

function* foo() {
  yield 1;
  try {
    yield 2;
    yield 3;
  } finally {
    yield 4;
    yield 5;
  }
  // 不会执行了
  yield 6;
}
var g = foo();
console.log(g.next()); // { value: 1, done: false }
console.log(g.next()); // { value: 2, done: false }
console.log(g.return("你小子")); // { value: 4, done: false }
console.log(g.next()); // { value: 5, done: false }
// 该返回结果是 return 方法的参数
console.log(g.next()); // { value: '你小子', done: true }
console.log(g.next()); // { value: undefined, done: true }

throw(...) 方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭:

function* generator() {
  for (const x of [1, 2, 3]) {
    yield x;
  }
}
const g = generator();
console.log(g); // generator {<suspended>}
try {
  g.throw("你小子老犯错");
} catch (e) {
  console.log(e); // 你小子老犯错
}
console.log(g); // generator {<closed>}

但是如果在生成器内部处理了这个错误,那么生成器就不会关闭,而且还可以恢复执行。错误处理会跳过对应的 yield,注意这个点,下面会讨论,因此在这个例子中会跳过一个值,例如:

function* generator() {
  for (const x of [1, 2, 3]) {
    try {
      yield x;
    } catch (error) {
      console.log("内部处理了这个错误:", error); // 内部处理了这个错误: 又是你小子
    }
  }
}
const g = generator();

console.log(g.next()); // { value: 1, done: false }
console.log(g.throw("又是你小子")); // { value: 2, done: false }
console.log(g.next()); // { value: 3, done: false }

上面代码中,g.throw(...) 方法被捕获以后,它会自动执行了一次 next(...) 方法,所以会打印 { value: 2, done: false },也可以看到只要函数内部部署了 try...catch 代码快,那么变量器的 throw 方法抛出的错误会由 catch(...) 接收。

如果生成器函数之前没有调用过 next(...) 方法而直接调用 throw(...) 方法,那么会直接报错,这个错误是在全局捕捉到的错误:

function* generator() {
  for (const x of [1, 2, 3]) {
    try {
      yield x;
    } catch (error) {}
  }
}
const g = generator();

console.log(g.throw("又是你小子"));
console.log(g.next()); // 代码不会执行

五、异步迭代生成器

在前面的内容中我们讲解了生成器函数的基本使用以及特点,那么我们来看看生成器函数是如何解决异步问题的。

在前面说到的,解决异步的方案我们可以通过回调的方法来实现:

function request(x, y, callback) {
  ajax("https://www.你小子.com/?x=" + x + "&y=" + y, callback);
}

foo(7, 77, function (error, text) {
  if (error) {
    console.log("错误了:", error); // 失败的原因
  } else {
    console.log(text); // 成功后返回的数据
  }
});

5.1 异步迭代器初使用

如果想要通过生成器来表达同样的任务流程控制,可以这样实现:

function request(x, y) {
  ajax("https://www.你小子.com/?x=" + x + "&y=" + y, function (error, data) {
    if (error) {
      // 向 *main() 抛出一个错误
      g.throw(error);
    } else {
      // 用接收到的 data 恢复 *main()
      g.next(data);
    }
  });
}

function* main() {
  {
    try {
      const text = yield request(7, 77);
      console.log(text);
    } catch (error) {
      console.log(error);
    }
  }
}

const g = main();
// 启动生成器函数
console.log(g.next());

第一眼看上去,与之前的回调代码对比起来,这段代码更长一些,可能也更复杂一些,但是这并没有那么简单。首先,我们先来看一下最重要的一段代码:

const text = yield request(7, 77);
console.log(text);

这段代码在调用 next(...) 方法之前它不能工作,正是这一点使得我们看似阻塞同步的代码,实际上并不会阻塞整个程序,它只是暂停或阻塞了生成器本身的代码。

yield request(7,77)中,首先调用函数,它并没有返回值,所以默认返回 undefined,所以实际上是之后做的是 yield undefined,生成器会在 yield 处暂停,本质上是在提出一个问题:"我们应该返回什么值给text?" 我们再来看一下 request(...),如果这个 ajax 请求成功,我们调用 g.next(data),这个会用相应数据恢复生成器,意味着我们暂停的 yield 表达式直接接收到了这个值,然后随着生成器代码继续执行,这个值被赋值给局部变量 text

5.2 生成器结合Promise

在日常的开发中,我们难免会遇到的这样的场景,就是需要对数据发送请求,将这次获取到的数据作为下一次的网络请求的参数使用,具体代码如下:

function request(url) {
  return new Promise((resolve) => {
    // 模拟网络请求
    setTimeout(() => {
      resolve(url);
    }, 1000);
  });
}

request("www.你小子.com").then((resolve) => {
  console.log(resolve); // www.你小子.com
  request(resolve + "/广东").then((resolve) => {
    console.log(resolve); // www.你小子.com/广东
    request(resolve + "/广州").then((resolve) => {
      console.log(resolve); // www.你小子.com/广东/广州
    });
  });
});

可以看到,Promise 的写法只是回调函数的改进,异步任务的执行变得更清楚了,除此之外,并无新意,最大的问题还是代码冗余,大量的重复代码,并且是回调函数里面嵌套回调函数,依然会出现 回调地狱 的问题。

虽然你可以将代码可以修改成以下这种方式,但是还是存在大量的冗余代码,且阅读性差,请看下列代码:

request("www.你小子.com")
  .then((resolve) => {
    console.log(resolve); // www.你小子.com
    return request(resolve + "/广东");
  })
  .then((resolve) => {
    console.log(resolve); // www.你小子.com/广东
    return request(resolve + "/广州");
  })
  .then((resolve) => {
    console.log(resolve); // www.你小子.com/广东/广州
  });

因此,生成器的出现很好的解决了这个问题,我们可以这样定义,请看下列代码:

function request(url) {
  return new Promise((resolve) => {
    // 模拟网络请求
    setTimeout(() => {
      resolve(url);
    }, 1000);
  });
}

function* getData() {
  const result_1 = yield request("www.你小子.com");
  const result_2 = yield request(result_1 + "/广东");
  yield request(result_2 + "/广州");
}

const generator = getData();
generator.next().value.then((resolve) => {
  console.log(resolve); // www.你小子.com
  generator.next(resolve).value.then((resolve) => {
    console.log(resolve); // www.你小子.com/广东
    generator.next(resolve).value.then((resolve) => {
      console.log(resolve); // www.你小子.com/广东/广州
    });
  });
});

我们通过一次 yield 发送一次网络请求,并且将 yield 的返回值赋值给下一次的网络请求。但是下面的代码似乎又出现了 回调地狱 的问题,而且我们不希望每个生成器都要手动编写不同的 Promise 链!如果有一种方法可以实现重复迭代控制,每次会生成一个 Promise,等其 resolved 之后再继续,那该多好啊。

为了实现这个问题,我们可以自行定义一个工具函数,当然你也可以使用 co模块,该模块就是用于生成器函数的自动执行,具体代码如下:

function run(gen) {
  const args = [].slice.call(arguments, 1);
  // 在当前上下文中初始化生成器
  const it = gen.apply(this, args);

  // 返回一个Promise用于生成器完成
  return Promise.resolve().then(function handleNext(value) {
    // 对下一个yield出的值运行
    const next = it.next(value);

    return (function handleResult(next) {
      // 生成器运行完毕了吗?
      if (next.done) {
        return next.value;
      }
      // 否则继续运行
      return Promise.resolve(next.value).then(
        // 成功就恢复异步循环,把决议的值发回生成器
        handleNext,
        // 如果value是被拒绝的promise,把错误传回生成器
        function handleErr(err) {
          return Promise.resolve(it.throw(err)).then(handleResult);
        }
      );
    })(next);
  });
}

有了这个工具函数之后,我们就可以让其自动调用生成器函数了,最终的代码如下:

function request(url) {
  return new Promise((resolve) => {
    // 模拟网络请求
    setTimeout(() => {
      resolve(url);
    });
  });
}

function* getData() {
  const result_1 = yield request("www.你小子.com");
  const result_2 = yield request(result_1 + "/广东");
  const result = yield request(result_2 + "/广州");
  return result;
}

// 前面定义的工具函数
run(getData).then((res) => {
  console.log(res); // www.你小子.com/广东/广州
});

5.3 生成器委托

还有可能出现的情况是,你可能会从一个生成器调用另一个生成器,使用辅助函数 run(...),就像这样:

function request(url) {
  return new Promise((resolve) => {
    // 模拟网络请求
    setTimeout(() => {
      resolve(url);
    });
  });
}

function* foo() {
  const result_1 = yield request("www.你小子.com");
  const result_2 = yield request(result_1 + "/广东");
  const result = yield request(result_2 + "/广州");
  return result;
}

function* bar() {
  const result_1 = yield request("www.真不错.com");
  console.log(result_1); // 第一个执行 www.真不错.com
  const result = yield run(foo);
  console.log(result); // 第二个执行 www.你小子.com/广东/广州
  const result_2 = yield request("www.还得是你.com");
  console.log(result_2); // 第三个执行 www.还得是你.com
}

// 前面定义的工具函数
run(bar);

我们通过 run(...) 工具从 *bar() 内部运行 *foo()。如果从一个 run(...) 中调用 yield 出来的 Promise 到另一个实例中,它会自动暂停 *bar(),直到 *foo() 结束。

5.4 形实转换程序

在通用计算机科学领域,有一个早期的前 JavaScript 概念,称为形实转换程序 thunk。它是指用 JavaScript 中的 thunk 是指一个用于吊桶另外一个函数的函数,没有任何参数。换句话说,你用一个函数定义封装函数调用,包括你需要的人格参数,来定义这个调用的执行,那么这个封装函数就是一个形实转换程序,之后在执行这个 thunk 时,最终就是调用了原始的函数,举例来说:

function foo(x, y) {
  return x + y;
}

function thunk() {
  return foo(1, 2);
}

console.log(thunk()); // 3

同步的 thunk 是非常简单的,如果是异步的 thunk 呢?我们可以把这个狭窄的 thunk 定义扩展到包含让他接收一个回调,例如:

function foo(x, y, callback) {
  setTimeout(() => {
    callback(x + y);
  }, 1000);
}

function thunk(callback) {
  foo(1, 2, callback);
}

thunk(function (sum) {
  console.log(sum); // 3
});

正如所在,thunk(...) 只需要一个参数 callback(...),因为它已经有预先指定的值 12 作为 xy 可以传给 foo(...)thunk 就耐心地等待它完成工作所需的最后一部分:那个定时器回调。

但是你并不会想手工编写 thunk,所以可以这样编写一个工具来对其进行封装,例如:

function foo(x, y, callback) {
  setTimeout(() => {
    callback(x + y);
  }, 1000);
}

function thunkify(fn) {
  return function () {
    const args = [].slice.call(arguments);
    return function (callback) {
      args.push(callback);
      return fn.apply(null, args);
    };
  };
}

const bar = thunkify(foo);

const f = bar(1, 2);

f(function (sum) {
  console.log(sum);
});

那么这个 thunk 函数到底有什么用?回答是以前确实没什么用,但是 ES6 有了生成器函数,Thunk 函数现在可以用于生成器函数的自动流程管理,生成器函数自动执行:

function* gen() {
  // ...
}

var g = gen();
var res = g.next();

while(!res.done){
  console.log(res.value);
  res = g.next();
}

上面的代码中,生成器函数 gen() 会自动执行完成所有步骤,这并不适合异步操作,如果必须保证前一步执行完,才能执行后一步,上面的自动执行就不可行。这时 Thunk 函数就能派上用处。以读取文件为例,请看下面代码:

const fs = require("fs");
const readFileThunk = thunkify(fs.readFile);

function thunkify(fn) {
  return function () {
    const args = [].slice.call(arguments);
    return function (callback) {
      args.push(callback);
      return fn.apply(null, args);
    };
  };
}

function run(fn) {
  const gen = fn();
  function next(err, data) {
    const result = gen.next(data);
    if (result.done) return;
    result.value(next);
  }
  next();
}

function* generator() {
  const result_1 = yield readFileThunk("./moment/a.txt");
  console.log(result_1.toString()); // 11111
  const result = yield readFileThunk("./moment/b.txt");
  console.log(result.toString()); // 22222
}

run(generator);

上面代码中,函数 genetrator(...) 封装了 n 个异步的读取文件操作,只要执行 run(...) 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。

六、异步终极解决方案 async...await

异步函数,也称为 "async/await" (语法关键字),是 Promise 模式在 ECMAScript 函数中的应用。 async/awaitES8 规范新增的。这个特性从行为和语法上都增强了 JavaScript,让以同步方式写的代码能够异步执行。

6.1 async 函数

async 函数是使用 async 关键字声明的函数。async 函数是 AsyncFunction 构造函数的实例,并且其中允许使用 await 关键字。async 和 await 关键字让我们可以用一种更简洁的方式写出基于 Promise 的异步行为,而无需刻意地链式调用 Promise

其中 AsyncFunction 构造函数用力啊创建新的异步函数对象,在 JavaScript 中每个异步函数都是 AsyncFunction 对象。

async 函数的基本语法一下:

async function foo() {}

const foo = async () => {};

const foo = async function () {};

async 函数的定义和普通函数的定义方法没有什么区别,但是该异步函数的返回结果是一个 Promise,该函数在没有添加 await关键字的时候和普通函数的执行顺序是一样的,但是添加了这个关键字的时候就会产生令你连连称赞的化学反应。

async function foo() {}
console.log(foo()); // Promise { undefined }

即使该异步函数不传任何值,它的返回值也是一个 Promise 对象,因为该结果最终会被 Promise.resolve(...) 包裹。

那么如果该异步函数发生了异常呢,那它会怎么办?答案是异步函数中的异常,其错误的值会被 Promise.reject(...) 包裹并返回的,具体实例如下:

async function foo() {
  throw new TypeError("我说你错了就是错了");
}

foo().catch((error) => {
  console.log(error); // TypeError: 我说你错了就是错了 at foo
});
setTimeout(() => {
  console.log("后续代码继续运行"); // 1秒钟后续代码继续运行
}, 1000);

但是,在异步函数中使用 Promise.reject(...) 拒绝的 Promise 对象不会被捕获,并且中断后面的代码运行:

async function foo() {
  Promise.reject("我说你错了就是错了");
}

foo().catch((error) => {
  console.log(error);
});
setTimeout(() => {
  console.log("后续代码继续运行"); // 这段代码不会被执行
}, 1000);

在前面说到,每一个异步函数都是 AsyncFunction 对象,但是,AsyncFunction 并不是全局对象,我们通过下面的代码来获取它,并且看看它是怎么使用的:

function foo() {}
console.log(foo()); // undefined

const AsyncFunction = async function () {}.constructor;

const f = new AsyncFunction(foo);

console.log(f()); // Promise { undefined }

通过上面的代码可以发现,通过 AsyncFunction 实例化的过程中,把普通函数传进去新的实例对象就会返回一个新的异步函数了。

AsyncFunction 接收两个参数类型,一个是函数的参数,argument,另外一个是 functionBody,其包含 JavaScript 语句的字符串,这些语句组成了新函数的定义,其基本使用的代码如下:

function foo(x) {
  return x;
}

const AsyncFunction = async function () {}.constructor;

const fn = new AsyncFunction("a", "b", `return ${foo}(a) + ${foo}(b);`);

fn(10, 20).then((v) => {
  console.log(v); // 30
});

执行 AsyncFunction 构造函数的时候,会创建一个异步函数对象。但是这种方式不如使用 async function 表达式定义一个异步函数,然后再调用它来的高效,因为后者的函数是与其它代码一起被解释器解析的。值得注意的是 AsyncFunction 构造函数创建的异步函数并不会在当前上下文中创建闭包,其作用域始终是全局的。因此运行的时候只能访问它们自己的局部变量和全局变量,而不能访问 AsyncFunction 构造函数被调用的那个作用域中的变量。

6.2 await

因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await 关键字可以暂停异步函数代码的执行,等待期约解决。

await 操作符用于等待一个 Promise 兑现并获取它兑现之后的值,它只能在异步函数或者模块顶层中使用。await 后面紧跟着的是一个表达式,该表达式是一个要等待的 Promise 实例,thenable 对象或任意类型的值。

其返回值是 Promise 实例或 thenable 对象取得的处理结果,如果等待的值不符合 thenable,则返回表达式本身的值,如果拒绝 reject,其原因会被作为异常抛出。

const obj = {
  then: function (resolve) {
    resolve("你小子");
  },
};

function timer() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("等我执行完后面的代码再执行吧");
    }, 5000);
  });
}

async function foo() {
  const result_1 = await 7;
  console.log(result_1); // 7

  // 后面的代码会阻断后面的代码执行
  const result_2 = await timer();
  console.log(result_2); // 等我执行完后面的代码再执行吧

  const result_3 = await obj;
  console.log(result_3); // 你小子\

  const result_4 = await Promise.resolve("moment");
  console.log(result_4);
}

foo();

通过上面的代码可以知道,await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程,该行为与生成器中的 yield 关键字是一样的。await 关键字同样是尝试“解包” 对象的值,如果是 Promise 实例或者 thenable 它会像 then(...) 方法一样返回最终的 resolve 值,然后将这 个值传给表达式,再异步恢复异步函数的执行。

async function foo() {
  const result_4 = await Promise.reject("错误了");
  console.log(result_4);
  console.log("后续代码不再执行了");
}

foo();

在这个例子中,单独的 Promise.reject() 不会被异步函数捕获,而会抛出未捕获错误,并且中断后续代码执行,要想捕捉这个错误,你可以通过 tryc...atch 进行捕捉:

async function foo() {
  try {
    const result_4 = await Promise.reject("错误了");
  } catch (error) {
    console.log(error);
  }

  console.log("后续代码还能继续执行");
}

foo();

......try...catch 好丑......

6.3 async/await 总结

  • async/await 实际上就是生成器函数的语法糖;
  • script 标签中,await 关键字必须在异步函数中使用,不能单独使用;
  • 在模块的顶层,你可以单独使用关键字 await。也就是说一个模块如果包含用了  await  的子模块,该模块就会等待该子模块,这一过程并不会阻塞其它子模块,例如如下代码:
// fetch request
const colors = fetch("../data/colors.json").then((response) => response.json());

export default await colors;

参考文献

结尾

  • 文章有点长,本来想一次写完的,没想到已经写了15500字这么多了,在接下来的文章中将会讲解生成器函数的和async/await的本质,以及事件循环,如果想看的小伙伴可以关注一下,期末了,文章再写就挂科了😭😭😭
  • 如果文章的内容有错,欢迎评论区中指出;
  • 如果觉得写的不错可以点赞+收藏;
转载自:https://juejin.cn/post/7178768412582084664
评论
请登录