<!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>
本系列的主题是 JavaScript 深入系列,每期讲解一个技术要点。如果你还不了解各系列内容,文末点击查看全部文章,点我跳转到文末。
如果觉得本系列不错,欢迎 Star,你的支持是我创作分享的最大动力。
JS 异步编程的 5 种解决方案
我们知道 JS 语言的执行环境是"单线程",所谓"单线程",就是指一次只能完成一件任务,这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。为了解决这个问题,JS 语言将任务的执行模式分成两种:同步
(Synchronous)
和异步(Asynchronous)
。下面就来讲一讲异步为什么很重要?如何使用异步来有效处理潜在的阻塞操作?
为什么需要异步?
通常来说,程序都是顺序执行,同一时刻只会发生一件事。如果一个函数依赖于另一个函数的结果,它只能等待那个函数结束才能继续执行,从用户的角度来说,整个程序才算运行完毕.
你可能知道,Javascript语言的执行环境是"单线程"(single thread)。
所谓"单线程",就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。
这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。
比如
Mac 用户可能会经历过这种旋转的彩虹光标(常称为沙滩球),操作系统通过这个光标告诉用户:“现在运行的程序正在等待其他的某一件事情完成,才能继续运行,都这么长的时间了,你一定在担心到底发生了什么事情”。
这是令人沮丧的体验,没有充分利用计算机的计算能力 — 尤其是在计算机普遍都有多核CPU的时代,坐在那里等待毫无意义,你完全可以在另一个处理器内核上干其他的工作,同时计算机完成耗时任务的时候通知你。这样你可以同时完成其他工作,这就是异步编程的出发点。你正在使用的编程环境(就web开发而言,编程环境就是web浏览器)负责为你提供异步运行此类任务的API。
1. 阻塞
异步技术非常有用,特别是在web编程。当浏览器里面的一个web应用进行密集运算还没有把控制权返回给浏览器的时候,整个浏览器就像冻僵了一样,这叫做 阻塞;这时候浏览器无法继续处理用户的输入并执行其他任务,直到web应用交回处理器的控制。
我们来看一些 阻塞 的例子。
例子: simple-sync.html
在按钮上添加了一个事件监听器,当按钮被点击,它就开始运行一个非常耗时的任务(计算1千万个日期,并在console里显示最终的耗时),然后在DOM里面添加一个段落。
运行这个例子的时候,打开JavaScript console,然后点击按钮 — 你会注意到,直到日期的运算结束,最终的耗时在console上显示出来,段落才会出现在网页上。
效果如下:
代码按照源代码的顺序执行,只有前面的代码结束运行,后面的代码才会执行。
2. 同步
要理解什么是 异步 JavaScript ,我们应该从确切理解 同步 JavaScript 开始。
我们学的很多知识基本上都是同步的:运行代码,然后浏览器尽快返回结果。先看一个简单的例子
效果如下:
这段代码, 一行一行的顺序执行:
先取得一个在DOM里面的
<button>
引用。点击按钮的时候,添加一个
click
事件监听器:alert()
消息出现。<p>
元素。每一个操作在执行的时候,其他任何事情都没有发生 — 网页的渲染暂停. 因为前篇文章提到过 JavaScript 是单线程. 任何时候只能做一件事情, 只有一个主线程,其他的事情都阻塞了,直到前面的操作完成。
所以上面的例子,点击了按钮以后,段落不会创建,直到在alert消息框中点击ok,段落才会出现,你可以自己试试
3. 解决
为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步
(Synchronous)
和异步(Asynchronous)
。"同步模式"就是前面讲到的模式,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;"异步模式"则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。
"异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,"异步模式"甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。
简单来理解就是:同步按你的代码顺序执行,异步不按照代码顺序执行,异步的执行效率更高。
异步编程的几种方法
1. 回调函数
回调函数是异步编程最基本的方法。
回调函数的概念:
译过来就是:
下面是一个回调函数的例子:
我们再来看几个经典的回调函数代码,我保证你一定用过他们:
◾ 1. 异步请求的毁掉函授:
◾ 2. 数组遍历的回调函数
等等
回调函数的优点是简单、容易理解和部署,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(Coupling),流程会很混乱,而且每个任务只能指定一个回调函数。
回调函数 最致命的缺点,就是容易写出 回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:
2. 事件监听
另一种思路是采用事件驱动模式。任务的执行不取决于代码的顺序,而取决于某个事件是否发生。
事件监听的回调函数:
上面这行代码的意思是,当 element 发生click事件,就执行传入的 function。
这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以"去耦合"(Decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。
3. 发布/订阅
发布-订阅模式其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知。
◾ 例子
比如我们很喜欢看某个公众号号的文章,但是我们不知道什么时候发布新文章,要不定时的去翻阅;这时候,我们可以关注该公众号,当有文章推送时,会有消息及时通知我们文章更新了。
上面一个看似简单的操作,其实是一个典型的发布订阅模式,公众号属于发布者,用户属于订阅者;用户将订阅公众号的事件注册到调度中心,公众号作为发布者,当有新文章发布时,公众号发布该事件到调度中心,调度中心会及时发消息告知用户。
◾ 发布/订阅模式的优点是对象之间解耦,异步编程中,可以更松耦合的代码编写;缺点是创建订阅者本身要消耗一定的时间和内存,虽然可以弱化对象之间的联系,多个发布者和订阅者嵌套一起的时候,程序难以跟踪维护。
想要手写实现发布/订阅模式的童鞋可以看我发的这篇文章:从零开始带你手写一个“发布-订阅者模式“ ,保姆级教学
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
方法。上面的代码使用
then
方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。采用链式的
then
,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise
对象(即有异步操作),这时后一个回调函数,就会等待该Promise
对象的状态发生变化,才会被调用。上面代码中,第一个
then
方法指定的回调函数,返回的是另一个Promise
对象。这时,第二个then
方法指定的回调函数,就会等待这个新的Promise
对象状态发生变化。如果变为resolved
,就调用第一个回调函数,如果状态变为rejected
,就调用第二个回调函数。如果采用箭头函数,上面的代码可以写得更简洁。
如果想要更详细的学习 Promise ,可以参考我发的这几篇文章:
5. async/await
async
和await
关键字是最近添加到JavaScript语言里面的。它们是ECMAScript 2017
的一部分,简单来说,它们是基于promises的语法糖,使异步代码更易于编写和阅读。通过使用它们,异步代码看起来更像是老式同步代码,因此它们非常值得学习。如果想要更详细的学习 async/await ,可以参考我发的这篇文章:
参考
查看原文
查看全部文章
博文系列目录
交流
各系列文章汇总:https://github.com/yuanyuanbyte/Blog
我是圆圆,一名深耕于前端开发的攻城狮。