JS 异步编程的 5 种解决方案
我们知道 JS 语言的执行环境是"单线程",所谓"单线程",就是指一次只能完成一件任务,这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。为了解决这个问题,JS 语言将任务的执行模式分成两种:同步 (Synchronous)
和异步 (Asynchronous)
。
下面就来讲一讲异步为什么很重要?如何使用异步来有效处理潜在的阻塞操作?
为什么需要异步?
通常来说,程序都是顺序执行,同一时刻只会发生一件事。如果一个函数依赖于另一个函数的结果,它只能等待那个函数结束才能继续执行,从用户的角度来说,整个程序才算运行完毕.
你可能知道,Javascript语言的执行环境是"单线程"(single thread)。
所谓"单线程",就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。
这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。
比如
Mac 用户可能会经历过这种旋转的彩虹光标(常称为沙滩球),操作系统通过这个光标告诉用户:“现在运行的程序正在等待其他的某一件事情完成,才能继续运行,都这么长的时间了,你一定在担心到底发生了什么事情”。
这是令人沮丧的体验,没有充分利用计算机的计算能力 — 尤其是在计算机普遍都有多核CPU的时代,坐在那里等待毫无意义,你完全可以在另一个处理器内核上干其他的工作,同时计算机完成耗时任务的时候通知你。这样你可以同时完成其他工作,这就是异步编程的出发点。你正在使用的编程环境(就web开发而言,编程环境就是web浏览器)负责为你提供异步运行此类任务的API。
1. 阻塞
异步技术非常有用,特别是在web编程。当浏览器里面的一个web应用进行密集运算还没有把控制权返回给浏览器的时候,整个浏览器就像冻僵了一样,这叫做 阻塞;这时候浏览器无法继续处理用户的输入并执行其他任务,直到web应用交回处理器的控制。
我们来看一些 阻塞 的例子。
例子: simple-sync.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Simple synchronous JavaScript example</title>
</head>
<body>
<button>Click me</button>
<script>
const btn = document.querySelector('button');
btn.addEventListener('click', () => {
let start = new Date();
let end;
for (let i = 0; i < 10000000; i++) {
let date = new Date();
end = date;
}
let time = end - start;
console.log('计算1千万个日期总耗时:' + time + 'ms');
let pElem = document.createElement('p');
pElem.textContent = '计算1千万个日期总耗时:' + time + 'ms';
document.body.appendChild(pElem);
});
</script>
</body>
</html>
复制代码
在按钮上添加了一个事件监听器,当按钮被点击,它就开始运行一个非常耗时的任务(计算1千万个日期,并在console里显示最终的耗时),然后在DOM里面添加一个段落。
运行这个例子的时候,打开JavaScript console,然后点击按钮 — 你会注意到,直到日期的运算结束,最终的耗时在console上显示出来,段落才会出现在网页上。
效果如下:
代码按照源代码的顺序执行,只有前面的代码结束运行,后面的代码才会执行。
Note: 这个例子不现实:在实际情况中一般不会发生,没有谁会计算1千万次日期,它仅仅提供一个非常直观的体验.
2. 同步
要理解什么是 异步 JavaScript ,我们应该从确切理解 同步 JavaScript 开始。
我们学的很多知识基本上都是同步的:运行代码,然后浏览器尽快返回结果。先看一个简单的例子
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Simple synchronous JavaScript example</title>
</head>
<body>
<button>Click me</button>
<script>
const btn = document.querySelector('button');
btn.addEventListener('click', () => {
alert('You clicked me!');
let pElem = document.createElement('p');
pElem.textContent = 'This is a newly-added paragraph.';
document.body.appendChild(pElem);
});
</script>
</body>
</html>
复制代码
效果如下:
这段代码, 一行一行的顺序执行:
-
先取得一个在DOM里面的
<button>
引用。 -
点击按钮的时候,添加一个
click
事件监听器:alert()
消息出现。- 一旦alert 结束,创建一个
<p>
元素。 - 给它的文本内容赋值。
- 最后,把这个段落放进网页。
每一个操作在执行的时候,其他任何事情都没有发生 — 网页的渲染暂停. 因为前篇文章提到过 JavaScript 是单线程. 任何时候只能做一件事情, 只有一个主线程,其他的事情都阻塞了,直到前面的操作完成。
所以上面的例子,点击了按钮以后,段落不会创建,直到在alert消息框中点击ok,段落才会出现,你可以自己试试
Note: 请记住,这个很重要,
alert()
在演示阻塞效果的时候非常有用,但是在正式代码里面,它就是一个噩梦。
3. 解决
为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步(Synchronous)
和异步(Asynchronous)
。
-
"同步模式"就是前面讲到的模式,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;"异步模式"则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。
-
"异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,"异步模式"甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。
简单来理解就是:同步按你的代码顺序执行,异步不按照代码顺序执行,异步的执行效率更高。
异步编程的几种方法
1. 回调函数
回调函数是异步编程最基本的方法。
回调函数的概念:
A callback is a function that is passed as an argument to another function and is executed after its parent function has completed.
译过来就是:
回调函数是作为参数传递给另一个函数并在其父函数完成后执行的函数。
下面是一个回调函数的例子:
function doSomething(msg, callback){
alert(msg);
if(typeof callback == "function")
callback();
}
doSomething("回调函数", function(){
alert("匿名函数实现回调!");
});
复制代码
我们再来看几个经典的回调函数代码,我保证你一定用过他们:
◾ 1. 异步请求的毁掉函授:
$.get("/try/ajax/demo_test.php",function(data,status){
alert("数据: " + data + "\n状态: " + status);
});
复制代码
◾ 2. 数组遍历的回调函数
array1.forEach(element => console.log(element));
复制代码
等等
回调函数的优点是简单、容易理解和部署,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(Coupling),流程会很混乱,而且每个任务只能指定一个回调函数。
回调函数 最致命的缺点,就是容易写出 回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:
ajax(url, () => {
// 处理逻辑
ajax(url1, () => {
// 处理逻辑
ajax(url2, () => {
// 处理逻辑
})
})
})
复制代码
2. 事件监听
另一种思路是采用事件驱动模式。任务的执行不取决于代码的顺序,而取决于某个事件是否发生。
事件监听的回调函数:
element.addEventListener("click", function(){
alert("Hello World!");
});
复制代码
上面这行代码的意思是,当 element 发生click事件,就执行传入的 function。
这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以"去耦合"(Decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。
3. 发布/订阅
发布-订阅模式其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知。
- 订阅者(Subscriber)把自己想订阅的事件 注册(Subscribe)到调度中心(Event Channel);
- 当发布者(Publisher)发布该事件(Publish Event)到调度中心,也就是该事件触发时,由 调度中心 统一调度(Fire Event)订阅者注册到调度中心的处理代码。
◾ 例子
比如我们很喜欢看某个公众号号的文章,但是我们不知道什么时候发布新文章,要不定时的去翻阅;这时候,我们可以关注该公众号,当有文章推送时,会有消息及时通知我们文章更新了。
上面一个看似简单的操作,其实是一个典型的发布订阅模式,公众号属于发布者,用户属于订阅者;用户将订阅公众号的事件注册到调度中心,公众号作为发布者,当有新文章发布时,公众号发布该事件到调度中心,调度中心会及时发消息告知用户。
◾ 发布/订阅模式的优点是对象之间解耦,异步编程中,可以更松耦合的代码编写;缺点是创建订阅者本身要消耗一定的时间和内存,虽然可以弱化对象之间的联系,多个发布者和订阅者嵌套一起的时候,程序难以跟踪维护。
想要手写实现发布/订阅模式的童鞋可以看我发的这篇文章:从零带你手写一个“发布-订阅者模式“ ,保姆级教学
4. Promise
Promise 是一种处理异步代码(而不会陷入回调地狱)的方式。
多年来,promise 已成为语言的一部分(在 ES2015 中进行了标准化和引入),并且最近变得更加集成,在 ES2017 中具有了 async 和 await。
异步函数 在底层使用了 promise,因此了解 promise 的工作方式是了解 async 和 await 的基础。
Promise
对象代表一个异步操作,有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)
一个 Promise
必然处于以下几种状态之一:
- 待定
(pending)
: 初始状态,既没有被兑现,也没有被拒绝。 - 已成功
(fulfilled)
: 意味着操作成功完成。 - 已拒绝
(rejected)
: 意味着操作失败。
当 promise 被调用后,它会以处理中状态 (pending)
开始。 这意味着调用的函数会继续执行,而 promise 仍处于处理中直到解决为止,从而为调用的函数提供所请求的任何数据。
被创建的 promise 最终会以被解决状态 (fulfilled)
或 被拒绝状态 (rejected)
结束,并在完成时调用相应的回调函数(传给 then 和 catch)。
● Promise 的链式调用
Promise 实例具有then
方法,也就是说,then
方法是定义在原型对象Promise.prototype
上的。它的作用是为 Promise 实例添加状态改变时的回调函数。前面说过,then
方法的第一个参数是resolved
状态的回调函数,第二个参数(可选)是rejected
状态的回调函数。
then
方法返回的是一个新的Promise
实例(注意,不是原来那个Promise
实例)。因此可以采用链式写法,即then
方法后面再调用另一个then
方法。
getJSON("/posts.json").then(function(json) {
return json.post;
}).then(function(post) {
// ...
});
复制代码
上面的代码使用then
方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。
采用链式的then
,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise
对象(即有异步操作),这时后一个回调函数,就会等待该Promise
对象的状态发生变化,才会被调用。
getJSON("/post/1.json").then(function(post) {
return getJSON(post.commentURL);
}).then(function (comments) {
console.log("resolved: ", comments);
}, function (err){
console.log("rejected: ", err);
});
复制代码
上面代码中,第一个then
方法指定的回调函数,返回的是另一个Promise
对象。这时,第二个then
方法指定的回调函数,就会等待这个新的Promise
对象状态发生变化。如果变为resolved
,就调用第一个回调函数,如果状态变为rejected
,就调用第二个回调函数。
如果采用箭头函数,上面的代码可以写得更简洁。
getJSON("/post/1.json").then(
post => getJSON(post.commentURL)
).then(
comments => console.log("resolved: ", comments),
err => console.log("rejected: ", err)
);
复制代码
如果想要更详细的学习 Promise ,可以参考我发的这几篇文章:
- 通俗易懂的Promise知识点总结,检验一下你是否真的完全掌握了promise?
- 手把手一行一行代码教你“手写Promise“,完美通过 Promises/A+ 官方872个测试用例
- 看了就会,手写 Promise 全部 API 教程,包括处于 TC39 第四阶段草案的 Promise.any()
5. async/await
async
和 await
关键字是最近添加到JavaScript语言里面的。它们是ECMAScript 2017
的一部分,简单来说,它们是基于promises的语法糖,使异步代码更易于编写和阅读。通过使用它们,异步代码看起来更像是老式同步代码,因此它们非常值得学习。
如果想要更详细的学习 async/await ,可以参考我发的这篇文章:
更多更全更详细 的 优质内容, 猛戳这里查看