Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ES6 - async #48

Open
jtwang7 opened this issue Oct 8, 2021 · 0 comments
Open

ES6 - async #48

jtwang7 opened this issue Oct 8, 2021 · 0 comments

Comments

@jtwang7
Copy link
Owner

jtwang7 commented Oct 8, 2021

参考文章:

目录

内容

async 函数对 Generator 函数的 4 点改进

async 函数是 Generator 函数的语法糖。两者差异主要体现在以下几点:

  1. 内置执行器
    Generator 函数的执行必须靠执行器,例如第三方的 co 模块;而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。async 函数会自动执行,遇到异步时会转移执行权直到异步完成,再继续执行后续的同步代码,输出最后结果。这完全不像 Generator 函数,需要调用 next 方法,或者用 co 模块,才能真正执行,得到最后结果。
  2. 更好的语义
    async 和 await,比起 * 和 yield,语义更清楚了。async 表示函数里有异步操作,或者表示当前函数被声明为了异步函数,await 表示紧跟在后面的表达式需要等待结果。
  3. 更广的适用性
    co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
  4. 返回值是 Promise。
    async 函数的返回值会被包装为 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。后续代码获取 Promise 返回值后可以用 then 方法指定下一步的操作。

async 基本执行流程

声明为 async 的函数,在执行的时候,一旦遇到 await 就会暂停执行后续代码,冻结和保存当前的变量和执行位置,函数执行上下文会弹出栈,将线程交由后面的代码执行,直到 await 后面的异步操作完成,将冻结的变量恢复并重新入栈,从上次暂停的位置继续执行后续代码。此外,async 函数最终会将返回值包装为一个 Promise 对象返回,我们可以使用 then 方法为其添加回调函数。该回调函数会在 async 函数内部所有异步代码执行后调用。

async 的声明形式

  • 函数声明
  • 函数表达式
  • 作为对象方法
  • 作为类的方法
  • 箭头函数
// 函数声明
async function foo() {}

// 函数表达式
const foo = async function () {};

// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)

// Class 的方法
class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jake').then();

// 箭头函数
const foo = async () => {};

async 的 Promise 返回值

async 函数会将 return 的值包装为一个 Promise 对象返回。

  • async 函数内部 return 语句返回的值,会作为 then 方法回调函数的参数传入;
  • async 函数内部抛出的错误,会导致返回的 Promise 对象变为 reject 状态,抛出的错误对象会被 catch 方法回调函数接收到;
// 内部返回值,会传入 then 回调作为参数
async function f() {
  return 'hello world';
}

f().then(v => console.log(v))
// "hello world"

// 内部抛出错误,错误信息会传入 catch 回调
async function f() {
  throw new Error('出错了');
}

f().then(
  v => console.log('resolve', v),
  e => console.log('reject', e)
)
//reject Error: 出错了

async 的 Promise 对象状态变化

async 函数返回的 Promise 对象,必须等到内部所有 await 命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到 return语句或者抛出错误才会提前发生状态变更。

也就是说,只有 async 函数内部的异步操作执行完,才会执行 then 方法指定的回调函数。

// 函数getTitle内部有三个操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行then方法里面的console.log
async function getTitle(url) {
  let response = await fetch(url);
  let html = await response.text();
  return html.match(/<title>([\s\S]+)<\/title>/i)[1];
}
getTitle('https://tc39.github.io/ecma262/').then(console.log)
// "ECMAScript 2017 Language Specification"

async 的 await 命令

await 后面允许是一个 Promise 对象 / 基础类型 / thenable 对象
正常情况下,await 命令后面是一个 Promise 对象,await 会等待 Promise 状态改变后返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。若 await 命令后面是一个 thenable 对象(即定义了then方法的对象),那么 await 会将其等同于 Promise 对象处理。

此外,任何一个 await 语句后面的 Promise 对象变为 reject 状态,都会中断整个 async 函数的执行,Promise.reject 的参数会被 catch 注册的回调函数接收到。

async function f() {
  await Promise.reject('出错了');
  await Promise.resolve('hello world'); // 不会执行
}

async 的错误捕获

async 函数内部 Promise 状态变为 rejected 时,会导致函数直接返回错误信息,跳出函数体。实际上跳出循环是错误机制引发的,错误发生后,下一个执行就是将错误信息传递给最近的错误捕获机制,假设最近的错误捕获在函数体内,那么就不会跳出函数体,而是直接跳转到 catch 所在的代码块执行逻辑,其中错误代码到 catch 代码块之间的所有代码都被静默忽略,catch 捕获错误后会执行之后的代码。
假如我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个 await 放在 try...catch 结构里面,在内部就捕获错误信息,从而避免错误信息为了被外部错误机制捕获而跳出函数体。

async function f() {
  try {
    await Promise.reject('出错了');
  } catch(e) {
  }
  return await Promise.resolve('hello world');
}

f()
.then(v => console.log(v))
// hello world

另一种方法是直接在 Promise 对象后面添加 catch 回调:

async function f() {
  await Promise.reject('出错了')
    .catch(e => console.log(e));
  return await Promise.resolve('hello world');
}

f()
.then(v => console.log(v))
// 出错了
// hello world

async 的使用事项

  1. 将 await 命令包裹在 try ... catch ... 中,可以避免某个 Promise 异步执行出错导致跳出 async 函数
  2. 多个 await 命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。推荐使用 Promise.all()
let foo = await getFoo();
let bar = await getBar();
// getFoo 和 getBar 是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo完成以后,才会执行getBar

// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
  1. await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。
async function dbFuc(db) {
  let docs = [{}, {}, {}];

  // 报错
  docs.forEach(function (doc) {
    await db.post(doc); // await 外层函数不是 async 函数
  });
}
  1. 注意 for 循环和 forEach 内调用 await 命令的差异
    forEach 内的 await 命令会并发执行,因为 forEach 执行的是启动 async 函数,而不会等待内部的 await 返回结果
function dbFuc(db) { //这里不需要 async
  let docs = [{}, {}, {}];

  // 可能得到错误结果
  docs.forEach(async function (doc) {
    await db.post(doc);
  });
}

for 循环会等待循环体内的代码执行完毕后,进行下一次循环,因此它会等待内部的 await 结果,因此循环是继发执行的。

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  for (let doc of docs) {
    await db.post(doc);
  }
}
  1. async 函数保留了运行堆栈
const a = () => {
  b().then(() => c());
};

上面代码中,函数a内部运行了一个异步任务b()。当b()运行的时候,函数a()不会中断,而是继续执行。等到b()运行结束,可能a()早就运行结束了,b()所在的上下文环境已经消失了。如果b()或c()报错,错误堆栈将不包括a()。

现在将这个例子改成async函数。

const a = async () => {
  await b();
  c();
};

上面代码中,b()运行的时候,a()是暂停执行,上下文环境都保存着。一旦b()或c()报错,错误堆栈将包括a()。

async 的实现原理

async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里。

async function fn(args) {
  // ...
}

// 等同于
function fn(args) {
  return spawn(function* () {
    // ...
  });
}

// 其中 spawn 自执行器实现如下:
function spawn(genFn) {
  return new Promise((resolve, reject) => {
    const gen = genFn(); // 生成遍历器对象
    const step = function (nextFn) {
      let next;
      try {
        next = nextFn(); // nextFn 为遍历器对象调用 next() 的封装方法;next 是返回的遍历器成员对象 {value: xxx, done: xxx}
      } catch (err) {
        return rejected(err);
      }
      // 若遍历完成,则将 generator 函数最终 return 的结果传递给外部注册的回调函数
      if (next.done) {
        return resolve(next.value);
      }
      // 若没有遍历完成,则递归遍历过程:每次的结果都会被 Promise.resolve() 包裹成 Promise 对象,并传递给注册的回调
      Promise.resolve(next.value).then((v) => {
        step(function () { return gen.next(v) }); // 获得结果后,递归执行 next
      }, (err) => {
        step(function () { return gen.throw(err) }) // 若执行出错,则调用 throw 抛出错误
      })
    }
    step(function () { return gen.next(undefined) }) // 第一次启动 generator 递归执行
  })
}

async 与其他异步处理方法的比较

回调函数

回调函数执行异步处理容易引发“回调地狱”

Promise

Promise 采用链式调用,一定程度上解决了回调地狱,但引入了许多 Promise 的 API,导致操作本身的语义不容易被看到。

Generator

Generator 找到了一种异步代码同步实现的书写方式,语义上比 Promise 更清晰,但是它必须引入一个自执行器来自动执行 Generator 函数。自执行器的引入就导致我们代码依赖于第三方库,此外,co 模块要求返回一个 Promise 对象,而且必须保证 yield语句后面的表达式,必须返回一个 Promise。

async

async 相较于 Generator 做了进一步改进,更加语义化、广泛化,同时它将 Generator 写法中的自动执行器,改在语言层面提供,不暴露给用户。

async 顶层 await 命令的相关提案

为什么要解决顶层 await ?

根据语法规格,我们知道现阶段 await 命令只能出现在 async 函数内部,否则都会报错。这也就意味着,在模块最外层,我们不能使用 await 命令,因为最外层是模块的作用域而非函数作用域,无法添加 async。

目前,有一个语法提案,允许在模块的顶层独立使用 await 命令。这个提案的目的,是借用 await 解决模块异步加载的问题。

模块异步加载

现存在一个文件,它内部向外暴露了一个异步操作的结果:

// awaiting.js
let output;
async function main() {
  const dynamic = await import(someMission);
  const data = await fetch(url);
  output = someProcess(dynamic.default, data);
}
main();
export { output };

output 变量的值会在异步操作结束后返回,没结束时其他模块使用它,得到的是 undefined;

ES6 模块暴露的是变量的引用,因此另一模块暴露变量值发生变化时,会被相应调用模块响应;CommonJS 暴露的是值的拷贝,因此不存在变量值发生改变,以上述为例,output 将始终是 undefined

// usage.js
import { output } from "./awaiting.js";

function outputPlusValue(value) { return output + value }

console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100)), 1000);

上述代码中,模块引入了 output 变量,如果 awaiting.js 里面的异步操作没执行完,加载进来的 output 的值就是 undefined。
为了让使用的模块能够在异步操作结束后才使用异步操作的结果变量,目前的解决方法,就是让原始模块输出一个 Promise 对象,从这个 Promise 对象判断异步操作有没有结束。

// awaiting.js
let output;
export default (async function main() {
  const dynamic = await import(someMission);
  const data = await fetch(url);
  output = someProcess(dynamic.default, data);
})();
export { output };

上面代码中,awaiting.js除了输出output,还默认输出一个 Promise 对象(async 函数立即执行后,返回一个 Promise 对象),其他模块使用时, 从这个 Promise 对象判断异步操作是否结束:

// usage.js
import promise, { output } from "./awaiting.js";

function outputPlusValue(value) { return output + value }

promise.then(() => {
  console.log(outputPlusValue(100));
  setTimeout(() => console.log(outputPlusValue(100)), 1000);
});

上面代码中,将awaiting.js对象的输出,放在promise.then()里面,这样就能保证异步操作完成以后,才去读取output。

通过 Promise 对象解决模块异步加载时存在的问题

其他模块调用模块异步加载的结果,需要遵守一个额外的使用协议,就是通过 Promise 对象来加载和获取最终的结果。一旦忘了要用 Promise 加载,只使用正常的加载方法,依赖这个模块的代码就可能出错。而且,这也容易导致这个依赖链上的所有模块都要使用 Promise 加载。
顶层的await命令,就是为了解决这个问题。它保证只有异步操作完成,模块才会输出值。

顶层 await 使用

// awaiting.js
const dynamic = import(someMission);
const data = fetch(url);
export const output = someProcess((await dynamic).default, await data); // 在异步模块中,使用了顶层 await 命令

顶层 await命令保证了模块在顶层调用 await 命令时不报错,同时只有等到异步操作完成,这个模块才会输出值。

此时,加载模块的写法如下。

// usage.js
import { output } from "./awaiting.js";
function outputPlusValue(value) { return output + value }

console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100)), 1000);

其他模块使用异步模块加载的结果时,不再需要关心依赖模块内部是否存在异步操作,异步操作是否已经完成。因此上面代码的写法,与普通的模块加载完全一样。
模块的加载会等待依赖模块(上例是awaiting.js)的异步操作完成,才执行后面的代码,有点像暂停在那里。所以,它总是会得到正确的output,不会因为加载时机的不同,而得到不一样的值。

注意,顶层await只能用在 ES6 模块,不能用在 CommonJS 模块。这是因为 CommonJS 模块的require()是同步加载,如果有顶层await,就没法处理加载了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant