经常听到JavaScript是异步的。这是什么意思?它如何影响开发?近年来这种方法有何变化?
请考虑以下代码:
result1 = doSomething1();
result2 = doSomething2(result1);
大多数语言同步处理每一行。第一行运行并返回结果。无论花多长时间,第二行都会在第一行完成后运行。
单线程处理
JavaScript在单个处理线程上运行。在浏览器选项卡中执行时,其他所有内容都会停止。这是必要的,因为在并行线程上不会发生对页面DOM的更改;将一个线程重定向到另一个URL,而另一个线程尝试追加子节点是危险的。
这对于用户来说一般是不明显的,因为每个小块操作都处理的很快。例如,JavaScript检测到按钮单击,运行计算并更新DOM。完成后,浏览器可以自由处理队列中的下一个任务。
使用回调进行异步
单线程引发了一个问题。当JavaScript调用“慢”进程(例如浏览器中的Ajax请求或服务器上的数据库操作)时会发生什么?这个操作可能需要几秒钟 - 甚至几分钟。浏览器在等待响应时会被锁定。在服务器上,Node.js应用程序将无法处理进一步的用户请求。
解决方案是异步处理。而不是等待完成,一个进程被告知在结果准备好时调用另一个函数。这称为回调,它作为参数传递给任何异步函数。例如:
doSomethingAsync(callback1);
console.log('finished');
// call when doSomethingAsync completes
function callback1(error) {
if (!error) console.log('doSomethingAsync complete');
}
doSomethingAsync()接受一个回调函数作为参数(只传递对该函数的引用,因此几乎没有开销)。无论doSomethingAsync()需要多长时间; 我们所知道的是,callback1()将来会在某个时刻执行。控制台将显示:
finished
doSomethingAsync complete
回调地狱(Callback Hell)
通常,回调只能由一个异步函数调用。因此可以使用简洁的匿名内联函数:
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.');
});
});
});
不幸的是,这引入了回调地狱 - 一个臭名昭着的概念,甚至有自己的网页!代码难以阅读,并且在添加错误处理逻辑时会变得更糟。
回调地狱在客户端编码中相对较少。如果你正在进行Ajax调用,更新DOM并等待动画完成,它可以深入两到三个级别,但它通常仍然可以管理。
操作系统或服务器进程的情况不同。Node.js API调用可以接收文件上载,更新多个数据库表,写入日志,并在发送响应之前进行进一步的API调用。
承诺(Promises)
ES2015(ES6)推出了Promises。回调仍然在是底层机制,但Promises提供了一个更清晰的语法,链接异步程序,使它们串行运行。
要使用基于Promise的执行过程,必须更改基于异步回调的函数,让它们立即返回Promise对象。该对象承诺在将来的某个时刻运行两个函数之一(作为参数传递):
-
resolve
:处理成功完成时运行的回调函数,和 -
reject
:发生故障时运行的可选回调函数。
在下面的示例中,数据库API提供了connect()
接受回调函数的方法。外部asyncDBconnect()
函数立即返回一个新的Promise,并运行任意resolve()
或reject()
一旦连接建立或者失败:
const db = require('database');
// connect to 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的替代方法。有几个条件:
- 必须将回调作为最后一个参数传递给异步函数,并且
- 回调函数必须支持错误处理,它的最后一个参数是发生错误时的回调函数。
例:
// Node.js: promisify fs.readFile
const
util = require('util'),
fs = require('fs'),
readFileAsync = util.promisify(fs.readFile);
readFileAsync('file.txt');
各种客户端库也提供promisify选项,但可以自己创建几个:
// promisify a callback function passed as the last parameter
// the callback function must accept (err, data) parameters
function promisify(fn) {
return function() {
return new Promise(
(resolve, reject) => fn(
...Array.from(arguments),
(err, data) => err ? reject(err) : resolve(data)
)
);
}
}
// example
function wait(time, callback) {
setTimeout(() => { callback(null, 'done'); }, time);
}
const asyncWait = promisify(wait);
ayscWait(1000);
异步链接
任何返回Promise的东西都可以启动一系列在.then()方法中定义的异步函数调用。每个都传递来自前一个的结果resolve:
asyncDBconnect('http://localhost:1234')
.then(asyncGetSession) // passed result of asyncDBconnect
.then(asyncGetUser) // passed result of asyncGetSession
.then(asyncLogAccess) // passed result of asyncGetUser
.then(result => { // non-asynchronous function
console.log('complete'); // (passed result of asyncLogAccess)
return result; // (result passed to next .then())
})
.catch(err => { // called on any reject
console.log('error', err);
});
同步功能也可以以.then()
块的形式执行。返回的值将传递给下一个.then()
(如果有)。
.catch()
方法定义了在reject
触发任何前一个函数时调用的函数。此时,不会.then()
运行其他方法。你可以.catch()
在整个链中使用多种方法来捕获不同的错误。
ES2018引入了一种.finally()
方法,无论结果如何都可以运行任何最终逻辑 - 例如,清理,关闭数据库连接等。目前仅支持Chrome和Firefox,但技术委员会39已发布了.finally()polyfill。
function doSomething() {
doSomething1()
.then(doSomething2)
.then(doSomething3)
.catch(err => {
console.log(err);
})
.finally(() => {
// tidy-up here!
});
}
使用Promise.all()进行多个异步调用
Promise .then()方法一个接一个地运行异步函数。如果顺序无关紧要 - 例如,初始化不相关的组件 - 同时启动所有异步函数并在最后(最慢)函数运行时完成更快resolve。
这可以通过实现Promise.all()。它接受一系列函数并返回另一个Promise。例如:
Promise.all([ async1, async2, async3 ])
.then(values => { // array of resolved values
console.log(values); // (in same order as function array)
return values;
})
.catch(err => { // called on any reject
console.log('error', err);
});
Promise.all()如果任何一个异步函数调用,则立即终止reject。
使用Promise.race的多个异步调用()
Promise.race()类似于Promise.all(),除了它会在第一个Promise解决或拒绝后立即解决或拒绝。只有执行地最快的基于Promise的异步函数才能完成:
Promise.race([ async1, async2, async3 ])
.then(value => { // single value
console.log(value);
return value;
})
.catch(err => { // called on any reject
console.log('error', err);
});
一个充满希望的未来?
Promises减少了回调地狱但引入了自己的问题。
教程经常没有提到整个Promise链是异步的。使用一系列承诺的最终要么返回自己的承诺或运行回调函数的任何功能.then()
,.catch()
或.finally()
方法。
我得承认:Promises让我困惑很长时间。语法通常看起来比回调更复杂,有很多错误,调试可能会有问题。但是,学习基础知识至关重要。
进一步Promises资源:
异步(Async)和等待(Await)
Promises可能还是用起来困难,因此ES2017引入了async
和await
。虽然它可能只是语法糖,但它使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))
});
}
// run connect (self-executing function)
(() => {
connect();
.then(result => console.log(result))
.catch(err => console.log(err))
})();
使用async/await重写:
- 外部函数必须以一个async语句开头,并且
- 必须把await放在基于Promise的异步调用函数的前面,以确保完成后再执行下一段代码。
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;
}
}
// run connect (self-executing async function)
(async () => { await connect(); })();
await有效地使每个调用看起来好像是同步的,而不是阻止JavaScript的单个处理线程。此外,async函数总是返回一个Promise,因此它们可以被其他async函数调用。
async/ await代码可能不会更短,但有相当大的好处:
- 语法更清晰。括号较少,错误较少。
- 调试更容易。可以在任何await语句上设置断点。
- 错误处理更好。try/catch块可以与同步代码一样使用。
- 支持很好。它在所有浏览器(IE和Opera Mini除外)和Node 7.6+中实现。
也就是说,并非所有都是完美的......
Promises,Promises
async/ await仍然依赖于Promises,最终依赖于回调。你需要了解Promises是如何工作的,并且没有直接等同于Promise.all()和Promise.race()的语法。这很容易让人忘了Promise.all(),但是比使用一系列不相关的await命令更有效。
同步循环中的异步等待
在某些时候,你可能尝试在同步循环内调用异步函数。例如:
async function process(array) {
for (let i of array) {
await doSomething(i);
}
}
它不会起作用。这个也不会:
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);
}
}
但是,在实现异步迭代器之前,最好通过map将数组转换成async函数的数组,并通过Promise.all()运行它们。例如:
const
todo = ['a', 'b', 'c'],
alltodo = todo.map(async (v, i) => {
console.log('iteration', i);
await processSomething(v);
});
await Promise.all(alltodo);
这样具有并行运行任务的好处,但是不能将一次迭代的结果传递给另一次迭代,并且映射大型数组可能有很大的性能消耗。
try/catch丑陋
如果遗漏了用try
/ catch
块把await
包起来,当发生错误时,async
将会直接退出。如果有一组很长的异步await
命令,则可能需要多个try
/ catch
块。
一种替代方案是用高阶函数捕获错误,因此try
/ catch
块变得不必要:
async function connect() {
const
connection = await asyncDBconnect('http://localhost:1234'),
session = await asyncGetSession(connection),
user = await asyncGetUser(session),
log = await asyncLogAccess(user);
return true;
}
// higher-order function to catch errors
function catchErrors(fn) {
return function (...args) {
return fn(...args).catch(err => {
console.log('ERROR', err);
});
}
}
(async () => {
await catchErrors(connect)();
})();
但是,当应用程序必须以不同的方式处理不同的错误的情况下,此方法可能不实用。
尽管有一些陷阱,async
/ await
是JavaScript的优雅补充。更多资源:
JavaScript之旅
异步编程是一项在JavaScript中无法避免的挑战。回调在大多数应用程序中都是必不可少的,但它很容易陷入深层嵌套的函数中。
Promises抽象回调,但有许多语法陷阱。转换现有功能可能是一件苦差事,.then()链条仍然看起来很乱。
幸运的是,async/ await提供了清晰度。代码看起来是同步的,但它不能独占单个处理线程。它会改变你编写JavaScript的方式,甚至可以让你欣赏Promises - 如果你以前没有!
网友评论