现代 JS 流程控制:从回调函数到 Promises 再到 A

来源:http://www.chinese-glasses.com 作者:Web前端 人气:123 发布时间:2020-03-31
摘要:因此,async/await不仅仅是删除回调和不必要的嵌套,更重要的是,它使异步编程模式看起来更像同步编程模式,这样代码对开发人员就会更友好。 前途光明吗? Promise 减少了回调地狱,

因此,async/await不仅仅是删除回调和不必要的嵌套,更重要的是,它使异步编程模式看起来更像同步编程模式,这样代码对开发人员就会更友好。

前途光明吗?

Promise 减少了回调地狱,但是引入了其他的问题。

教程常常不提,整个 Promise 链条是异步的,一系列的 Promise 函数都得返回自己的 Promise 或者在最终的 .then().catch() 或者 .finally() 方法里面执行回调。

我也承认:Promise 困扰了我很久。语法看起来比回调要复杂,好多地方会出错,调试也成问题。可是,学习基础还是很重要滴。

延伸阅读:

我们使用Promise.all是因为它允许我们传递任意数量的 Promise,并且会一起执行完,通过一个then函数把所有结果返回给我们。Promise 还有其他方法,例如Promise.any,Promise.some等,究竟选哪个,这取决于你是否使用 Promise 库或某些 Babel 插件,当然还取决于你的用例以及你要如何处理输出或被拒绝的可能(reject)。在任何情况下,模式都非常相似,你只需选择一个不同的 Promise 方法,就会得到不同的结果。

使用 Promise.all() 处理多个异步操作

Promise .then() 方法用于相继执行的异步函数。如果不关心顺序 – 比如,初始化不相关的组件 – 所有异步函数同时启动,直到最慢的函数执行 resolve,整个流程结束。

Promise.all() 适用于这种场景,它接收一个函数数组并且返回另一个 Promise。举例:

Promise.all([ async1, async2, async3 ]) .then(values => { // 返回值的数组 console.log(values); // (与函数数组顺序一致) return values; }) .catch(err => { // 任一 reject 被触发 console.log('error', err); });

1
2
3
4
5
6
7
8
Promise.all([ async1, async2, async3 ])
  .then(values => {           // 返回值的数组
    console.log(values);      // (与函数数组顺序一致)
    return values;
  })
  .catch(err => {             // 任一 reject 被触发
    console.log('error', err);
  });

任意一个异步函数 rejectPromise.all() 会立即结束。

function doAsyncToAll (values /* array */) { return Promise.all(values.map(async val = { let anotherVal = await doSomethingAsync(val) return doAnotherAsync(anotherValue * 2) }))}

使用 Promise.race() 处理多个异步操作

Promise.race()Promise.all() 极其相似,不同之处在于,当首个 Promise resolve 或者 reject 时,它将会 resolve 或者 reject。仅有最快的异步函数会被执行:

Promise.race([ async1, async2, async3 ]) .then(value => { // 单一值 console.log(value); return value; }) .catch(err => { // 任一 reject 被触发 console.log('error', err); });

1
2
3
4
5
6
7
8
Promise.race([ async1, async2, async3 ])
  .then(value => {            // 单一值
    console.log(value);
    return value;
  })
  .catch(err => {             // 任一 reject 被触发
    console.log('error', err);
  });
function doAsyncToAll (values /* array */) { return Promise.all(values.map(doSomethingAsync))}

Async/Await

Promise 看起来有点复杂,所以 ES2017 引进了 asyncawait。虽然只是语法糖,却使 Promise 更加方便,并且可以避免 .then() 链式调用的问题。看下面使用 Promise 的例子:

function connect() { return new Promise((resolve, reject) => { asyncDBconnect('http://localhost:1234') .then(asyncGetSession) .then(asyncGetUser) .then(asyncLogAccess) .then(result => resolve(result)) .catch(err => reject(err)) }); } // 运行 connect 方法 (自执行方法) (() => { connect(); .then(result => console.log(result)) .catch(err => console.log(err)) })();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function connect() {
 
  return new Promise((resolve, reject) => {
 
    asyncDBconnect('http://localhost:1234')
      .then(asyncGetSession)
      .then(asyncGetUser)
      .then(asyncLogAccess)
      .then(result => resolve(result))
      .catch(err => reject(err))
 
  });
}
 
// 运行 connect 方法 (自执行方法)
(() => {
  connect();
    .then(result => console.log(result))
    .catch(err => console.log(err))
})();

使用 async / await 重写上面的代码:

  1. 外部方法用 async 声明
  2. 基于 Promise 的异步方法用 await 声明,可以确保下一个命令执行前,它已执行完成

async function connect() { try { const connection = await asyncDBconnect('http://localhost:1234'), session = await asyncGetSession(connection), user = await asyncGetUser(session), log = await asyncLogAccess(user); return log; } catch (e) { console.log('error', err); return null; } } // 运行 connect 方法 (自执行异步函数) (async () => { await connect(); })();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function connect() {
 
  try {
    const
      connection = await asyncDBconnect('http://localhost:1234'),
      session = await asyncGetSession(connection),
      user = await asyncGetUser(session),
      log = await asyncLogAccess(user);
 
    return log;
  }
  catch (e) {
    console.log('error', err);
    return null;
  }
 
}
 
// 运行 connect 方法 (自执行异步函数)
(async () => { await connect(); })();

await 使每个异步调用看起来像是同步的,同时不耽误 JavaScript 的单线程处理。此外,async 函数总是返回一个 Promise 对象,因此它可以被其他 async 函数调用。

async / await 可能不会让代码变少,但是有很多优点:

  1. 语法更清晰。括号越来越少,出错的可能性也越来越小。
  2. 调试更容易。可以在任何 await 声明处设置断点。
  3. 错误处理尚佳。try / catch 可以与同步代码使用相同的处理方式。
  4. 支持良好。所有浏览器(除了 IE 和 Opera Mini )和 Node7.6+ 均已实现。

如是说,没有完美的…

这看起来既简单,又容易理解。我假设你已经对async和await很熟悉了,所以,我不打算介绍太多关于他们的细节。不过,你可以去MDN上复习更多的async/await。在这里,我们将重点介绍过去在 Promise 中使用的模式,以及这些模式如何转换为async/await。

通过回调实现异步

单线程产生了一个问题。当 JavaScript 执行一个“缓慢”的处理程序,比如浏览器中的 Ajax 请求或者服务器上的数据库操作时,会发生什么?这些操作可能需要几秒钟 – 甚至几分钟。浏览器在等待响应时会被锁定。在服务器上,Node.js 应用将无法处理其它的用户请求。

解决方案是异步处理。当结果就绪时,一个进程被告知调用另一个函数,而不是等待完成。这称之为回调,它作为参数传递给任何异步函数。例如:

doSomethingAsync(callback1); console.log('finished'); // 当 doSomethingAsync 完成时调用 function callback1(error) { if (!error) console.log('doSomethingAsync complete'); }

1
2
3
4
5
6
7
doSomethingAsync(callback1);
console.log('finished');
 
// 当 doSomethingAsync 完成时调用
function callback1(error) {
  if (!error) console.log('doSomethingAsync complete');
}

doSomethingAsync() 接收回调函数作为参数(只传递该函数的引用,因此开销很小)。doSomethingAsync() 执行多长时间并不重要;我们所知道的是,callback1() 将在未来某个时刻执行。控制台将显示:

finished doSomethingAsync complete

1
2
finished
doSomethingAsync complete

我们回到最后的模式。我们再次遍历一个列表,并对列表中的每项进行异步操作,但是这次,我们同一时间只执行一个操作。换句话说,在我们完成对第一项的操作之前,不能对第二项进行任何操作。

Promises

ES2015(ES6) 引入了 Promises。回调函数依然有用,但是 Promises 提供了更清晰的链式异步命令语法,因此可以串联运行(下个章节会讲)。

打算基于 Promise 封装,异步回调函数必须返回一个 Promise 对象。Promise 对象会执行以下两个函数(作为参数传递的)其中之一:

  • resolve:执行成功回调
  • reject:执行失败回调

以下例子,database API 提供了一个 connect() 方法,接收一个回调函数。外部的 asyncDBconnect() 函数立即返回了一个新的 Promise,一旦连接创建成功或失败,resolve()reject() 便会执行:

const db = require('database'); // 连接数据库 function asyncDBconnect(param) { return new Promise((resolve, reject) => { db.connect(param, (err, connection) => { if (err) reject(err); else resolve(connection); }); }); }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const db = require('database');
 
// 连接数据库
function asyncDBconnect(param) {
 
  return new Promise((resolve, reject) => {
 
    db.connect(param, (err, connection) => {
      if (err) reject(err);
      else resolve(connection);
    });
 
  });
 
}

Node.js 8.0 以上提供了 util.promisify() 功能,可以把基于回调的函数转换成基于 Promise 的。有两个使用条件:

  1. 传入一个唯一的异步函数
  2. 传入的函数希望是错误优先的(比如:(err, value) => …),error 参数在前,value 随后

举例:

// Node.js: 把 fs.readFile promise 化 const util = require('util'), fs = require('fs'), readFileAsync = util.promisify(fs.readFile); readFileAsync('file.txt');

1
2
3
4
5
6
7
// Node.js: 把 fs.readFile promise 化
const
  util = require('util'),
  fs = require('fs'),
  readFileAsync = util.promisify(fs.readFile);
 
readFileAsync('file.txt');

各种库都会提供自己的 promisify 方法,寥寥几行也可以自己撸一个:

// promisify 只接收一个函数参数 // 传入的函数接收 (err, data) 参数 function promisify(fn) { return function() { return new Promise( (resolve, reject) => fn( ...Array.from(arguments), (err, data) => err ? reject(err) : resolve(data) ) ); } } // 举例 function wait(time, callback) { setTimeout(() => { callback(null, 'done'); }, time); } const asyncWait = promisify(wait); ayscWait(1000);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// promisify 只接收一个函数参数
// 传入的函数接收 (err, data) 参数
function promisify(fn) {
  return function() {
      return new Promise(
        (resolve, reject) => fn(
          ...Array.from(arguments),
        (err, data) => err ? reject(err) : resolve(data)
      )
    );
  }
}
 
// 举例
function wait(time, callback) {
  setTimeout(() => { callback(null, 'done'); }, time);
}
 
const asyncWait = promisify(wait);
 
ayscWait(1000);

“随机”串行异步操作

同步循环中的异步等待

某些情况下,你想要在同步循环中调用异步函数。例如:

async function process(array) { for (let i of array) { await doSomething(i); } }

1
2
3
4
5
async function process(array) {
  for (let i of array) {
    await doSomething(i);
  }
}

不起作用,下面的代码也一样:

async function process(array) { array.forEach(async i => { await doSomething(i); }); }

1
2
3
4
5
async function process(array) {
  array.forEach(async i => {
    await doSomething(i);
  });
}

循环本身保持同步,并且总是在内部异步操作之前完成。

ES2018 引入异步迭代器,除了 next() 方法返回一个 Promise 对象之外,与常规迭代器类似。因此,await 关键字可以与 for ... of 循环一起使用,以串行方式运行异步操作。例如:

async function process(array) { for await (let i of array) { doSomething(i); } }

1
2
3
4
5
async function process(array) {
  for await (let i of array) {
    doSomething(i);
  }
}

然而,在异步迭代器实现之前,最好的方案是将数组每项 mapasync 函数,并用 Promise.all() 执行它们。例如:

const todo = ['a', 'b', 'c'], alltodo = todo.map(async (v, i) => { console.log('iteration', i); await processSomething(v); }); await Promise.all(alltodo);

1
2
3
4
5
6
7
8
const
  todo = ['a', 'b', 'c'],
  alltodo = todo.map(async (v, i) => {
    console.log('iteration', i);
    await processSomething(v);
});
 
await Promise.all(alltodo);

这样有利于执行并行任务,但是无法将一次迭代结果传递给另一次迭代,并且映射大数组可能会消耗计算性能。

总结

Promises, Promises

async / await 仍然依赖 Promise 对象,最终依赖回调。你需要理解 Promise 的工作原理,它也并不等同于 Promise.all()Promise.race()。比较容易忽视的是 Promise.all(),这个命令比使用一系列无关的 await 命令更高效。

看上去除了添加几个假装使你看起来很聪明并使用现代 JavaScript 的关键字外,其他的毛用没有。但实际上,你添加这几个关键字没有任何价值,反而还会导致 JavaScript 引擎可能会运行得更慢。但是,如果你的代码更复杂一些,async/await肯定可以提供一些好处:

现代 JS 流程控制:从回调函数到 Promises 再到 Async/Await

2018/09/03 · JavaScript · Promises

原文出处: Craig Buckler   译文出处:OFED   

JavaScript 通常被认为是异步的。这意味着什么?对开发有什么影响呢?近年来,它又发生了怎样的变化?

看看以下代码:

result1 = doSomething1(); result2 = doSomething2(result1);

1
2
result1 = doSomething1();
result2 = doSomething2(result1);

大多数编程语言同步执行每行代码。第一行执行完毕返回一个结果。无论第一行代码执行多久,只有执行完成第二行代码才会执行。

“随机”并行异步操作

异步链式调用

任何返回 Promise 的函数都可以通过 .then() 链式调用。前一个 resolve 的结果会传递给后一个:

asyncDBconnect('http://localhost:1234') .then(asyncGetSession) // 传递 asyncDBconnect 的结果 .then(asyncGetUser) // 传递 asyncGetSession 的结果 .then(asyncLogAccess) // 传递 asyncGetUser 的结果 .then(result => { // 同步函数 console.log('complete'); // (传递 asyncLogAccess 的结果) return result; // (结果传给下一个 .then()) }) .catch(err => { // 任何一个 reject 触发 console.log('error', err); });

1
2
3
4
5
6
7
8
9
10
11
asyncDBconnect('http://localhost:1234')
  .then(asyncGetSession)      // 传递 asyncDBconnect 的结果
  .then(asyncGetUser)         // 传递 asyncGetSession 的结果
  .then(asyncLogAccess)       // 传递 asyncGetUser 的结果
  .then(result => {           // 同步函数
    console.log('complete');  //   (传递 asyncLogAccess 的结果)
    return result;            //   (结果传给下一个 .then())
  })
  .catch(err => {             // 任何一个 reject 触发
    console.log('error', err);
  });

同步函数也可以执行 .then(),返回的值传递给下一个 .then()(如果有)。

当任何一个前面的 reject 触发时,.catch() 函数会被调用。触发 reject 的函数后面的 .then() 也不再执行。贯穿整个链条可以存在多个 .catch() 方法,从而捕获不同的错误。

ES2018 引入了 .finally() 方法,它不管返回结果如何,都会执行最终逻辑 – 例如,清理操作,关闭数据库连接等等。当前仅有 Chrome 和 Firefox 支持,但是 TC39 技术委员会已经发布了 .finally() 补丁

function doSomething() { doSomething1() .then(doSomething2) .then(doSomething3) .catch(err => { console.log(err); }) .finally(() => { // 清理操作放这儿! }); }

1
2
3
4
5
6
7
8
9
10
11
function doSomething() {
  doSomething1()
  .then(doSomething2)
  .then(doSomething3)
  .catch(err => {
    console.log(err);
  })
  .finally(() => {
    // 清理操作放这儿!
  });
}

如果你使用 reduce 的原因不仅仅是为了串行操作,那么你仍然可以继续使用。例如,如果你打算把所有操作的结果相加

JavaScript 之旅

异步编程是 JavaScript 无法避免的挑战。回调在大多数应用中是必不可少的,但是容易陷入深度嵌套的函数中。

Promise 抽象了回调,但是有许多句法陷阱。转换已有函数可能是一件苦差事,·then() 链式调用看起来很凌乱。

很幸运,async/await 表达清晰。代码看起来是同步的,但是又不独占单个处理线程。它将改变你书写 JavaScript 的方式,甚至让你更赏识 Promise – 如果没接触过的话。

1 赞 收藏 评论

图片 1

async function doAsyncToAllSequentially (values) { for (let val of values) { await doSomethingAsync(val) }}

回调地狱

通常,回调只由一个异步函数调用。因此,可以使用简洁、匿名的内联函数:

doSomethingAsync(error => { if (!error) console.log('doSomethingAsync complete'); });

1
2
3
doSomethingAsync(error => {
  if (!error) console.log('doSomethingAsync complete');
});

一系列的两个或更多异步调用可以通过嵌套回调函数来连续完成。例如:

async1((err, res) => { if (!err) async2(res, (err, res) => { if (!err) async3(res, (err, res) => { console.log('async1, async2, async3 complete.'); }); }); });

1
2
3
4
5
6
7
async1((err, res) => {
  if (!err) async2(res, (err, res) => {
    if (!err) async3(res, (err, res) => {
      console.log('async1, async2, async3 complete.');
    });
  });
});

不幸的是,这引入了回调地狱 —— 一个臭名昭著的概念,甚至有专门的网页介绍!代码很难读,并且在添加错误处理逻辑时变得更糟。

回调地狱在客户端编码中相对少见。如果你调用 Ajax 请求、更新 DOM 并等待动画完成,可能需要嵌套两到三层,但是通常还算可管理。

操作系统或服务器进程的情况就不同了。一个 Node.js API 可以接收文件上传,更新多个数据库表,写入日志,并在发送响应之前进行下一步的 API 调用。

async/await不允许我们脱离Promise.all或其组成部分来使用是把双刃剑。不好的是,async/await在后台隐藏了对 Promise 的使用,但是我们需要显式地使用 Promise 才能并行执行操作。好的一面是,这意味着我们不用学习任何新东西,我们只要在以前使用的基础上删掉传递给then回调的额外参数就行。然后,我们使用await假装我们的并行操作都是瞬间完成。

丑陋的 try/catch

如果执行失败的 await 没有包裹 try / catchasync 函数将静默退出。如果有一长串异步 await 命令,需要多个 try / catch 包裹。

替代方案是使用高阶函数来捕捉错误,不再需要 try / catch 了(感谢@wesbos的建议):

async function connect() { const connection = await asyncDBconnect('http://localhost:1234'), session = await asyncGetSession(connection), user = await asyncGetUser(session), log = await asyncLogAccess(user); return true; } // 使用高阶函数捕获错误 function catchErrors(fn) { return function (...args) { return fn(...args).catch(err => { console.log('ERROR', err); }); } } (async () => { await catchErrors(connect)(); })();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function connect() {
 
  const
    connection = await asyncDBconnect('http://localhost:1234'),
    session = await asyncGetSession(connection),
    user = await asyncGetUser(session),
    log = await asyncLogAccess(user);
 
  return true;
}
 
// 使用高阶函数捕获错误
function catchErrors(fn) {
  return function (...args) {
    return fn(...args).catch(err => {
      console.log('ERROR', err);
    });
  }
}
 
(async () => {
  await catchErrors(connect)();
})();

当应用必须返回区别于其它的错误时,这种作法就不太实用了。

尽管有一些缺陷,async/await 还是 JavaScript 非常有用的补充。更多资源:

我还记得以前执行异步操作需要在越来越深的回调地狱中使用回调的那些“好日子”。虽然回调地狱并没有完全成为过去,但是使用 Promise 来代替回调的嵌套已经显得简单多了。

单线程处理程序

JavaScript 是单线程的。当浏览器选项卡执行脚本时,其他所有操作都会停止。这是必然的,因为对页面 DOM 的更改不能并发执行;一个线程
重定向 URL 的同时,另一个线程正要添加子节点,这么做是危险的。

用户不容易察觉,因为处理程序会以组块的形式快速执行。例如,JavaScript 检测到按钮点击,运行计算,并更新 DOM。一旦完成,浏览器就可以自由处理队列中的下一个项目。

(附注: 其它语言比如 PHP 也是单线程,但是通过多线程的服务器比如 Apache 管理。同一 PHP 页面同时发起的两个请求,可以启动两个线程运行,它们是彼此隔离的 PHP 实例。)

我依稀记得 Promise 成为主流时的那些美好时光,我之前使用 jQuery 的 Deferred,后来 Promise 普及之后,我才在工作中把 Promise 库加到我们的项目中。那时我们已经有 Babel 了,所以我们甚至不需要再加一个 Promise 库。

上面的代码只会让我头脑混乱。令人惊讶的是,即使使用了 Promise ,我们也没有防止回调地狱的重现。即使我们使用了箭头函数,我们可以总是在一行内写完代码,但是这并没有让代码变得好理解。但是,使用async/await就可以让代码更简洁条理:

无论如何,Promise 在很大程度上实现了它的承诺,使得异步编程更加易于管理。如果关于 Promise 的用法你还不是很熟,可以在这里看到更多关于 Promise 的知识。当然,Promise 也有自己的弱点。很多时候,您要么需要嵌套 Promise ,要么需要将变量传递到外部,因为你需要的一些数据只在 Promise 的 handler 中可用。例如:

就我个人而言,我认为这很清楚,至少从回调内部可以进行映射,但是这里有些人可能会感到困惑。当我第一次开始使用async/await时,我在回调中看到await,这让我认为这些回调没有并行触发。这是人们在嵌套函数中使用async/await时经常会犯的一个错误,并且是与直接使用 Promise 相比,async/await显得可能不那么容易理解的实例。但是,当你使用嵌套异步函数时,稍微暴露一下可以帮助你更容易地发现问题,因此它们的内部函数与外部函数是分离的,并且await不会暂停外部函数。

async function getVals () { let val = await doSomethingAsync() let anotherVal = await doAnotherAsync(val) return 2 * anotherVal}

上面的代码可能会清除一些语法上的问题,但是并不能带来更好的可读性。好在我们已经度过了那些“好日子”,现在我们有了async和await,我们可以避免所有的那些废话。

function doAsyncToAll (values /* array */) { return Promise.all(values.map(val = { return doSomethingAsync(val) .then(anotherVal = doAnotherAsync(anotherValue * 2)) })) .then(newValues = newValues.join(','))}
async function getVals () { let [val, anotherVal] = await Promise.all([doSomethingAsync(), doAnotherAsync()]) return val + anotherVal}

总之,就像我说的,你已经用我们所说的第一个模式来实现了例子。这些操作是按顺序运行的,这意味着第二个操作要等到第一个操作完成后才能启动。这个模式可能与上面的示例不同,在使用 Promise 时,假设我们不会遇到前面的情况,即需要将多个值传递给后续操作:

以往来看,还有其他方法可以解决这种情况,但解决并不是最终目标:可读性和可维护性才是最重要的,通常这是async/await最方便的地方。编写通常也更简单,因为我们就是按照以前那种同步来写的。

跟最开始的 Promise 的例子相比,不需要在最终的 handler 中访问 val ,因此我们只需链式调用then即可,而不必费心将值传递给外部作用域。但是酷炫的是,在async/await代码版本中,我们除了把上面第一个async/await例子中,最后表达式的val +换成2 *外,其他的什么都用改:

这些不同层级的then调用真的会让一个人的头脑混乱,所以让我们用更现代的方式来实现它:

虽然上面的代码看着也还行,但是async/await更简洁条理。

迭代并行异步操作

本文由10bet发布于Web前端,转载请注明出处:现代 JS 流程控制:从回调函数到 Promises 再到 A

关键词:

最火资讯