异步编程在编程领域一直是重要的课题,对于前端程序来说,界面始终要保持与用户的交互性,用户的相应与程序的任务之间存在很多消息传递、方法调用、数据加载,异步机制在起到了很大的作用。从JavaScript最初发展开始,实现异步的形式也一直在变,从最初的回到函数和事件监听,到ECMAScript 6引入Promise
,再到后来的借用生成器(Generator)和目前最广泛使用的async
/await
函数修饰符,异步的方式逐步完善,代码风格愈发简约。
不可或缺的异步
JavaScript是单线程应用程序,我们大部分时候编写的代码是同步调用的,每次调用都会等待执行结果,拿到当前调用结果后进入下一步执行。当一个调用是轻量的时候不会出现问题,计算机指令“静默”地运行在计算机内核上。但当一些耗时地任务也在程序中同步地运行在程序中,就会造成JavaScript程序线程一直被占用,造成“卡顿”。诸如我们需要计算一个比较大地斐波那契数,在同步调用状态下就会长时间占用程序线程,使页面无法及时响应用户交互或者更新界面渲染。
// 斐波那契函数
// 当前求斐波那契数的时间复杂度 O(2^n)
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
fibonacci(15);
当我们在程序中调用了诸如求斐波那契数的耗时任务时,JavaScript线程被占用,页面无法及时响应用户交互,造成页面假死。这种情景下就需要异步方法来解除程序主线程的阻塞。
对于异步的定义,维基百科上有如下描述:
同步可以理解为:发出一个调用时,在没有得到结果之前,该调用就不返回;一旦调用返回,就得到返回值。换句话说,就是由调用者主动等待这个调用的结果。而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知或通过回调函数,让调用者能响应结果。
JavaScript程序通过异步方法实现,将一些耗时任务交给程序运行环境去执行,不等待任务的运行结果,运行环境执行完毕之后将结果“通知”到程序线程。通过这种方式,JavaScript程序解除程序线程的占用问题,避免前端页面进入“假死”状态。在前端开发中,比较耗时的操作,都基于异步方法进行,以下是一些常见的情景:
- 网络IO操作:包括网络静态资源请求、网络接口请求,
- 本地IO操作:本地文件读取
- 浏览器接口调用:包括
getUserMedia()
访问用户的媒体设备、navigator.geolocation
获取用户的地理位置等 - IndexedDB读写
在JavaScript语言的发展历程中,异步编程的方式也在不断演进完善,下面我们逐一讨论。
使用回调函数
在JavaScript中,函数作为“一等公民”,可以作为参数进行传递,这让JavaScript天然支持回调函数。开发人员可以很方便的定义一个函数,将这个函数延迟到需要执行它的时候去执行。例如当我们获取服务器数据的时候,常常可以使用回调函数来接收得到的数据并进行处理。
// 模拟异步操作的函数
function fetchData(callback) {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://api.example.com/data', true);
xhr.onload = function() {
if (xhr.status === 200) {
callback(null, xhr.responseText);
} else {
callback(xhr);
}
};
// 请求失败的回调
xhr.onerror = function() {
callback(Error('请求出错'));
};
}
// 定义操作数据的方法
function handleData(error, data) {
if(error) {
console.error(error);
return;
}
console.log('获取的数据:', data);
}
// 使用回调函数处理异步结果
fetchData(handleData);
代码中我们向https://api.example.com/data
请求服务端数据。获取服务端数据的时长是耗时长并且不可知的,JavaScript程序使用浏览器提供的API(XMLHTTPRequest
)创建请求并发送。将拿回数据的任务交给浏览器(运行环境),就不再等待数据结果,JavaScript程序线程解除了阻塞,可以正常相应用户交互或者进行渲染更新。当浏览器拿到后端数据后,通过执行已注册的回调函数将数据传递给JavaScript应用程序。如此,一次完整的异步的网络数据请求完成。
事件监听(发布/订阅模式的实现)
浏览器页面元素的事件监听也是异步实现的,我们在元素上注册事件函数,浏览器在捕获对应的用户交互行为时执行这些函数。这也是基于回调函数的发布/订阅模式的实现。
// 发布者
const button = document.querySelector('submit');
// 订阅者
button.addEventListener('click', () => {
console.log('Button clicked!');
});
评价
使用回调函数能够完成异步编程工作。当JavaScript程序规模变大,回调函数带来的问题也很明显。主要体现在以下几个方面:
- 回调函数传递链很容易变得很长,形成很深的嵌套结构,增加了项目的维护难度,我们称之为“回调地狱”;
- 回调函数的
this
值的不确定性,极容易引入逻辑错误; - 难以使用
try...catch
处理程序错误; - 无法处理函数返回值
使用Promise
为了摆脱回调异步函数引入的问题,JavaScript社区探索了Promise
方案,Promise
主要是解决回调函数的回调地狱问题,并不是完全取代回调机制,实际上,Promise
是对回调函数进行妥善安排的一种方案。Promise
方案最终在2015年于ECMAScript 6中标准化,成为原生JavaScript的一部分。本质上,Promise
是一个具备then()
方法的的thenable对象,它可以表示异步操作最终的完成(或失败)的状态以及其结果值,我们可以在它上面绑定回调函数,这样我们就不需要在一开始把回调函数作为参数传入这个函数了。
一个Promise
对象一共有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。其中fulfilled状态和rejected状态又称为settled状态。一个Promise
对象总会从创建之初的pending状态进入到fulfilled状态或者rejected状态。也可以说一个Promise
对象总会被settled。一个Promise
对象一旦被settled,其状态便不会再改变,并且对象中保存的数据可以反复读取。
1. 创建Promise
对象
在JavaScript中,Promise
被声明为一个class
,我们可以按照构造函数去创建它。
const promise = new Promise((resolve, reject) => {
// 使用 setTimeout() 模拟耗时任务
setTimeout(() => {
if(success) { // 伪代码,如果成功
resolve('success');
} else { // 失败分支
reject(Error('failed'));
}
}, 2000);
});
promise.then((message) => {
// handleSuccess(message);
console.log(message);
}, (error) => {
// handleError(error);
console.error(error);
});
Promise
构造函数接受一个executor
回调函数作为参数去初始化当前构造的Promise
对象。executor
回调函数定义了两个形参:resolve
和reject
。在executor
回调函数中,JavaScript程序调用复杂任务,然后退出。当复杂任务执行完毕后,会通过resolve
和reject
的调用通知任务调用结果,任务执行成功时使用resolve
调用,任务失败或者程序报错时使用reject
调用。promise
对象支持使用方法then
接收两个参数onfulfilled
和onrejected
,用于注册复杂服务处理结果的回调函数。executor
中调用resolve
会唤起onfulfilled
的调用,调用reject
会唤起onrejected
的调用。这便是Promise
最简单的使用。
2. Thenable对象
Promise
是一个具备then()
方法的的thenable对象。Promises/A+ 规范规定,Promise
对象不能直接保存thenable对象作为其最终结果值,而是会递归展开(unwrap)thenable对象,直到遇到非thenable值。Promise
对象的最终状态和结果值总是取决于then()
方法中注册的回调的执行状态。为了理解thenable对象,我们先看Promise.resolve()
和Promise.reject()
方法。通过静态方法Promise.resolve()
和Promise.reject()
我们可以快速地创建一个Promise
对象,并且它的状态就是分别是fulfilled和rejected的,Promise
对象的结果值就是调用两个静态方法时传入的参数。
Promise.resolve(30).then(value => {
console.log(value); // 输出:30
});
Promise.reject(33).catch(error => {
console.error(error); // 输出:33
});
如果Promise.resolve()
和Promise.reject()
接收的参数是一个thenable对象时,它们创建的Promise
对象并不会将thenable对象作为结果值保存,JavaScript会执行thenable对象中的then()
方法,then()
方法的执行结果决定了最终的Promise
对象的状态和结果值。请看下面示例代码:
const thenable = {
then: (resolve, reject) => {
reject(33);
}
};
const promise = Promise.resolve(thenable);
promise.catch(error => {
console.error(error); // 输出 33,promise 的状态并没有由 Promise.resolve() 的调用决定,而是由 thenable 对象中 then 方法的执行结果确定。
});
Promise
起源于社区,在成为正式标准前,许多库都使用了thenable对象,如果Promise
对象可以将thenable对象作为最终的结果值,那么就会出现Promise
对象状态不一致的问题或者出现无限等待的问题。为了避免这些问题,规范对thenable对象的行为做了如此规定。
3. 链式调用
Promise
支持链式调用,这个分为两个部分。首先是Promise
对象包含catch()
方法,catch(onrejected)
注册拒绝回调函数等价于then(onfulfilled,onrejected)
注册。下面代码和上面创建Promise
的代码是等价的。
const promise = new Promise((resolve, reject) => {
// 使用 setTimeout() 模拟耗时任务
setTimeout(() => {
if(success) { // 伪代码,如果成功
resolve('success');
} else { // 失败分支
reject(Error('failed'))
}
}, 2000);
});
promise.then((message) => {
// handleSuccess(message);
console.log(message);
}).catch((error) => {
// handleError(error);
console.error(error);
});
其次,Promise
对象的then
和catch
方法返回值也是Promise
对象,仍然可以继续调用then
和catch
方法。同时,两个方法的返回值会沿着调用链传递。当then
和catch
方法中的回调正常返回时,会进入两个方法创建的Promise
的then
方法中,当then
和catch
方法中的回调报错时,错误信息会被两个法创建的Promise
的catch
方法捕获。请看如下代码示例:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(30); // 将当前 Promise 对象状态置为 resolved
}, 20);
});
promise.then((value) => {
console.log(value); // 输出:30,promise 被 resolve 了
return value + 1; // 返回当前值加1,创建了新的 fulfilled 状态的 Promise 对象
}).then((value) => {
console.log(value); // 输出:31,上游 Promise 对象 onfulfilled 正常返回,
throw value + 3; // 创建了新的 rejected 状态的 Promise 对象
}).then((value) => {
console.log(value); // 被忽略, 上游 Promise 对象状态是 rejected
return value + 1;
}).catch((error) => {
console.error(error); // 输出:34,捕获上游 Promise 对象抛出的错误,错误值是 34
return error + 1; // 返回 error 值加1,创建了新的 fulfilled 状态的 Promise 对象
}).then((value) => {
console.log(value); // 输出:35,上游 Promise 对象 onfulfilled 正常返回,
return value + 1; // 返回当前值加1,创建了新的 fulfilled 状态的 Promise 对象
});
4. 编排异步任务处理顺序
因为对thenable对象的处理机制,开发人员可以在Promise
调用链上插入promise
对象,安排异步任务的执行顺序。示例代码如下:
const promise1 = new Promise((resolve, reject) => {
console.log('create promise1');
setTimeout(() => {
console.log('resolve promise1 with 30');
resolve(30);
}, 20);
});
promise1.then(value => {
console.log('promise1 value: ' + value);
const promise2 = new Promise((resolve, reject) => {
console.log('create promise2');
setTimeout(() => {
console.log('resolve promise2 with `value + 5`');
resolve(value + 5);
}, 20);
});
return promise2;
}).then(value => {
console.log('final value: ' + value);
});
代码运行会按顺序输出以下内容
resolve promise1 with 30
promise1 value: 30
create promise2
resolve promise2 with `value + 5`
final value: 35
5. 限制性的错误捕获
在Promise
中,只能通过对象的catch()
方法捕获错误,无法像同步代码那样使用try...catch
语句捕获错误。如果我们遗漏了catch()
方法的调用,可能发生的错误会中断程序的运行。
根据浏览器的事件循环模型,Promise
实现异步的方式是编排微任务的执行,每一个微任务都是一个单独的执行单元。而try...catch
语句是同步的,会在一个任务中完成,一个执行单元的结束,其中的try...catch
语句也会执行完毕。所以Promise
采用了catch()
方法的机制保证发生的错误被捕获。从语法形式上看,这给开发语言带来了不一致,并且增加了遗漏错误的可能。有关事件循环模型,我们单独再讨论。
评价
Promise
的链式调用机制一定程度上解决了多层嵌套的回调函数带来的混乱,作为一个对象,结果值一旦确定便永久保存在对象中,增强了复用能力。但同时,Promise
的错误处理机制增加了遗漏错误处理的可能。
借用生成器(Generator)
生成器(Generator)也是发源于JavaScript社区,它本是为迭代器的使用而生。当人们看到它暂停并保存程序运行状态以待恢复的特性时,便想到了将它运用到异步编程。
Generator对象由生成器函数创建,function*
声明会创建一个绑定到给定名称生成器函数。生成器函数可以退出,并在稍后重新进入,其上下文(变量绑定)会在重新进入时保存。Generator对象符合迭代器协议,当迭代器的next()
方法被调用时,生成器函数的主体会被执行,直到遇到一个yield
表达式,该表达式暂停函数的执行,并将表达式值返回给迭代器。每次迭代器调用next()
方法都会使生成器函数寻找下一个yield
表达式并从yield
表达式处获得返回值,直到生成器函数执行完毕。同时,next()
方法可以向生成器函数传递参数,该参数就会被当作上一个yield表达式的返回值。以下代码是Generator的简单使用示例:
function* gen() {
const a = yield 111;
console.log('inner a: ' + a);
const b = yield 222;
console.log('inner b: ' + b);
const c = yield 333;
console.log('inner c: ' + c);
}
const t = gen();
const a = t.next(1);
console.log('outer a: ' + a.value);
const b = t.next(2);
console.log('outer b: ' + b.value);
const c = t.next(3);
console.log('outer c: ' + c.value);
代码对生成器函数的调用呈现交替式的,程序的运行在调用方和生成器函数之间交替进行,运行代码会按顺序输出以下内容:
outer a: 111
inner a: 2
outer b: 222
inner b: 3
outer c: 333
人们可以利用生成器函数和调用方可以交替执行的特性,进行异步编程开发。请看下面一个异步读取文件的代码示例:
function readFileAsync(fileName) {
return new Promise((resolve, reject) => {
// 模拟异步读取
setTimeout(() => {
console.log(fileName + '读完了');
resolve(fileName);
}, 50);
})
}
function* readFiles() {
yield readFileAsync('first');
yield readFileAsync('second');
yield readFileAsync('third');
}
const g = readFiles()
const result = g.next()
result.value
.then(() => {
g.next()
})
.then(() => {
g.next()
});
运行代码,输出结果如下:
first读完了
second读完了
third读完了
生成器在从生成器函数取结果的过程中,仍然没有摆脱Promise
的.then()
的链式调用。此时可以加入一个co库中的自执行器,或者自己实现一个类似的自执行器。代码如下:
function myCo(gen) {
const generator = gen();
return new Promise((resolve, reject) => {
function next(data) {
try {
const result = generator.next(data);
const { value, done } = result;
if (done === true) {
resolve(value);
} else if (done === false && value instanceof Promise) {
value.then(function (val) {
next(val);
});
}
} catch (error) {
return reject(error);
}
}
next();
});
}
function readFileAsync(fileName) {
return new Promise((resolve, reject) => {
// 模拟异步读取
setTimeout(() => {
console.log(fileName + '读完了');
resolve(fileName);
}, 50);
})
}
function* readFiles() {
yield readFileAsync('first');
yield readFileAsync('second');
yield readFileAsync('third');
}
myCo(readFiles);
自执行器具备普遍性,适用于所有生成器函数。
评价
借用生成器(Generator)进行异步编程是基于Promise
的,但是它消除了Promise
中冗长的链式调用。额外的代价是需要引入一个自执行器。目前,人们很少再去使用借用生成器(Generator)进行异步编程,它最大的贡献是启发了async
/await
修饰符的实现。async
/await
修饰符是一种更为简洁的异步编程模式。
使用async
/await
修饰符
async
/await
在ECMAScript 2016中得到了提案,并在ECMAScript 2017中被标准化。async function
声明创建一个绑定到给定名称的新异步函数。函数体内允许使用await
关键字,这使得我们可以更简洁地编写基于Promise
的异步代码,并且避免了显式地配置Promise
链的需要。
在编程中使用async
/await
,可以近似的认为是生成器Generator + 自执行器(比如 co) + Promise
对象的封装。async
声明函数后会保证函数返回一个Promise
对象,使用await
修饰符调用一个Promise
对象会等待Promise
对象到完成(settled)状态,并且取出Promise
对象的最终结果值。也正是因为如此,await
可以修饰一个async
声明的函数的调用,也可以修饰一个返回了Promise
对象的函数的调用或者直接修饰一个Promise
对象(或其它thenable对象)对其进行等待并取出结果值。
上面读取文件的异步任务,我们可以使用async
/await
修饰符进行如下实现:
function readFileAsync(fileName) {
return new Promise((resolve, reject) => {
// 模拟异步读取
setTimeout(() => {
console.log(fileName + '读完了');
resolve(fileName);
}, 50);
})
}
async function readFiles() {
await readFileAsync('first');
await readFileAsync('second');
await readFileAsync('third');
}
readFiles();
同时,若该await
修饰调用的Promise
被拒绝(rejected),await
表达式会把拒绝的原因(reason)抛出。当前函数(await
修饰调用的函数)会出现在抛出的错误的栈追踪(stack trace)。这意味着可以使用try...catch
语句捕获抛出的错误。示例代码如下:
function readFileAsync(fileName) {
return new Promise((resolve, reject) => {
// 模拟异步读取
setTimeout(() => {
console.log(`reading: ` + fileName);
reject(Error('read file-' + fileName + ' failed.'));
}, 50);
})
}
try {
await readFileAsync('first');
} catch(err) {
console.log('caught error: ' + err);
}
运行代码,输出结果如下:
reading: first
caught error: Error: read file-first failed.
需要注意的是,await
只能在async
函数或者模块顶层作用域中使用。
评价
使用async
/await
,我们可以写出近似于同步代码风格的异步编程实现,代码的语义更清晰、简洁。
使用Worker
JavaScript异步编程在之所以如此重要,很大的一个原因是JavaScript是单线程运行的,程序线程长时间被占用就会造成页面“假死”。目前,HTML5推出了Web Worker API,在浏览器中,它可以单独申请一个运行线程,用于处理复杂费时的任务,减少JavaScript程序线程的占用。它可以通过消息机制与JavaScript主线程进行通信,完成任务的定义、运行和状态同步。使用Web Worker也属于异步编程范畴,我们在这里简单介绍它的使用,将来有机会我们在HTML模块中再详细探讨它。
Web Worker给了前端开发人员在不同线程中运行某些任务的能力,对于多线程编程,线程间的资源竞争、状态同步一直是复杂的问题,为了简化这些问题,Web Worker做了如下规定:JavaScript主线程和Web Worker线程间不共享变量,两者分别执行在各自的执行栈上。双方都使用postMessage()
方法发送各自的消息,使用onmessage
事件处理函数来响应消息(消息被包含在message
事件的data
属性中)。这意味着Web Worker不能访问DOM(窗口、文档、页面元素等等),也不能访问window
对象或使用window
对象的默认方法和属性。Web Worker可以使用XMLHttpRequest
(尽管responseXML
和channel
属性总是为空)或fetch
(没有这些限制)执行I/O,也可以使用WebSockets或者IndexedDB。
要创建一个Worker,只须调用new Worker(URL)
构造函数,函数参数URL
为指定的脚本。我们可以将文章开始的求斐波那契数的方法放到Worker中进行执行,代码示例如下。
创建一个fibonacci.js文件,提供运算斐波那契数的功能:
// 监听主线程中的消息。
// 如果消息中的 command 是 "fibonacci",则调用 `fibonacci()`
addEventListener("message", (message) => {
if (message.data.command === "get-fibonacci") {
getFibonacci(Number.parseInt(message.data.quota, 10));
}
});
// 计算斐波那契函数
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
function getFibonacci(n) {
const result = fibonacci(n);
// 完成后给主线程发送一条包含我们生成的质数数量的消息消息。
postMessage(result);
}
在JavaScript主程序代码中创建Worker,并提供发起计算斐波那契数command的方法,以及注册对应的监听结果回调。
// 使用 "fibonacci.js" 创建一个 worker
const worker = new Worker("./generate.js");
// 使用 worker 发起 get-fibonacci 请求,并注册运算成功后的回调。
function getFibonacciByWorker(n, onFinished) {
worker.postMessage({
command: "get-fibonacci",
quota: n,
});
worker.addEventListener("message", (message) => {
const fibonacci = Number.parseInt(message.data, 10);
onFinished(fibonacci);
});
}
如此,我们便将获取斐波那契数的耗时工作交给了Worker,在另外的线程中完成它,当前线程可正常处理主程序逻辑。