1.什么是异步?
(1)异步的作用
在前端编程中(甚至后端有时也是这样),我们在处理一些简短、快速的操作时,例如计算 1 + 1 的结果,往往在主线程中就可以完成。主线程作为一个线程,不能够同时接受多方面的请求。所以,当一个事件没有结束时,界面将无法处理其他请求。
现在有一个按钮,如果我们设置它的 onclick 事件为一个死循环,那么当这个按钮按下,整个网页将失去响应。
为了避免这种情况的发生,我们常常用子线程来完成一些可能消耗时间足够长以至于被用户察觉的事情,比如读取一个大文件或者发出一个网络请求。 因为子线程独立于主线程,所以即使出现阻塞也不会影响主线程的运行。 但是子线程有一个局限:一旦发射了以后就会与主线程失去同步,我们无法确定它的结束,如果结束之后需要处理一些事情,比如处理来自服务器的信息,我们是无法将它合并到主线程中去的。
为了解决这个问题,JavaScript 中的异步操作函数往往通过回调函数来实现异步任务的结果处理。 —— JavaScript 异步编程
(2)异步的概念
通常来说,程序都是顺序执行,同一时刻只会发生一件事情,如果一个函数依赖于另一个函数的结果,它只能等待那个函数结束才能继续执行。—— 通用异步编程概念
异步:异步就是从主线程发射一个子线程来完成任务。—— JavaScript 异步编程
(3)阻塞
异步技术非常有用,特别是在web编程。当浏览器里面的一个web应用进行密集运算还没有把控制权返回给浏览器的时候,整个浏览器就像冻僵了一样,这叫做阻塞;这时候浏览器无法继续处理用户的输入并执行其他任务,直到web应用交回处理器的控制。
为什么会出现上面这样阻塞的情况呢,原因就是JS是单线程的。
(4)线程
一个线程是一个基本的处理过程,程序用它来完成任务。每个线程一次只能执行一个任务:
|
|
但是现在的计算机大都有多个内核(core),因此可以同时执行多个任务。支持多线程的编程语言可以使用计算机的多个内核,发挥多内核的作用,同时完成多个任务:
|
|
(5)JS单线程
JavaScript 传统上是单线程的。即使有多个内核,也只能在单一线程上运行多个任务,此线程称为主线程(main thread)。
因为JS是单线程的,所以会导致代码阻塞,影响用户体验,于是JS获得一些工具来解决这个问题。通过 Web workers 可以把一些任务交给一个名为worker的单独的线程,这样就可以同时运行多个JavaScript代码块。一般来说,用一个worker来运行一个耗时的任务,主线程就可以处理用户的交互(避免了阻塞)。
理解:这样就相当于JS是双线程的,有两个线程可以执行任务了,一个主线程(Main thread),一个 worker 线程。
|
|
(6)Web workers
主线程和 worker 线程相互之间使用 postMessage() 方法来发送信息, 并且通过 onmessage 这个 event handler来接收信息(传递的信息包含在 Message 这个事件的data属性内) 。数据的交互方式为传递副本,而不是直接共享数据。
worker 不能做渲染,只能做算术这种苦活。
另外当一个函数依赖于几个在它之前运行的过程的结果,这就会成为问题。
在这种情况下,比如说Task A 正在从服务器上获取一个图片之类的资源,Task B 准备在图片上加一个滤镜。如果开始运行Task A 后立即尝试运行Task B,你将会得到一个错误,因为图像还没有获取到。
为了解决这些问题,浏览器允许我们异步运行某些操作。像Promises 这样的功能就允许让一些操作运行 (比如:从服务器上获取图片),然后等待直到结果返回,再运行其他的操作。
由于操作发生在其他地方,因此在处理异步操作的时候,主线程不会被阻塞。
经常用到异步的业务:从网络获取文件,访问数据库,从网络摄像头获得视频流,或者向VR头罩广播图像。
2.异步编程在JS中的实现
在JavaScript代码中,你经常会遇到两种异步编程风格:老派callbacks,新派promise。下面就来分别介绍。
(1)异步callbacks
异步callbacks 其实就是函数,只不过是作为参数传递给那些在后台执行的其他函数,当那些后台运行的代码结束,就调用callbacks函数,通知你工作已经完成。
也存在同步的回调函数,例如Array.prototype.forEach() :
|
|
在上面这个例子中,我们遍历一个希腊神的数组,并在控制台中打印索引和值。forEach() 需要的参数是一个回调函数,回调函数本身带有两个参数,数组元素和索引值。它无需等待任何事情,立即运行。
(2)Promises
Promises 是新派的异步代码,现代的web APIs经常用到。本质上,这是浏览器说“我保证尽快给您答复”的方式,因此得名“promise”,可以理解为浏览器给的一个承诺。
(3)Promises 对比 callbacks
promises与旧式callbacks有一些相似之处。它们本质上是一个返回的对象,您可以将回调函数附加到该对象上,而不必将回调作为参数传递给另一个函数。
Promise相对于callbacks优点:
1.您可以使用多个then()操作将多个异步操作链接在一起,并将其中一个操作的结果作为输入传递给下一个操作。这种链接方式对回调来说要难得多,会使回调以混乱的“末日金字塔”告终。 (也称为回调地狱)。
2.Promise总是严格按照它们放置在事件队列中的顺序调用。
3.错误处理要好得多——所有的错误都由块末尾的一个.catch()块处理,而不是在“金字塔”的每一层单独处理。
(4)事件队列
像promise这样的异步操作被放入事件队列中,事件队列在主线程完成处理后运行,这样它们就不会阻止后续JavaScript代码的运行。排队操作将尽快完成,然后将结果返回到JavaScript环境。
3.事件循环
(1)事件循环的概念
JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其它语言中的模型截然不同,比如 C 和 Java。
js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。
(2)运行时
接下来的内容解释了这个理论模型。现代JavaScript引擎实现并着重优化了以下描述的这些语义。
1.栈
函数调用形成了一个由若干帧组成的栈。
2.堆
对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
3.队列
一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
在 事件循环 期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。
之所以称之为 事件循环,是因为它经常按照类似如下的方式来被实现:
|
|
(3)事件循环特性
1.执行至完成
每一个消息完整地执行后,其它消息才会被执行。这为程序的分析提供了一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。
缺点:这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互,例如点击或滚动。为了缓解这个问题,浏览器一般会弹出一个“这个脚本运行时间过长”的对话框。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息
2.添加消息
在浏览器里,每当一个事件发生并且有一个事件监听器绑定在该事件上时,一个消息就会被添加进消息队列。如果没有事件监听器,这个事件将会丢失。所以当一个带有点击事件处理器的元素被点击时,就会像其他事件一样产生一个类似的消息。
函数 setTimeout 接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout 消息必须等待其它消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。
3.零延迟
零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数,知识表示最小延迟时间。其等待的时间取决于队列里待处理的消息数量。
4.多个运行时互相通信
一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。
5.永不阻塞
JavaScript的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。
4.宏任务macro task与微任务micro task
(1)宏任务
setInterval(),setTimeout(),requestAnimationFrame属于宏任务
(2)微任务
new Promise(),new MutaionObserver()属于微任务
在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。
微任务会在宏任务之前处理
看来JS也会利用时间管理中的两分钟法则啊
当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
5.node环境下的事件循环
(1)node环境与浏览器环境差异
在node中,事件循环表现出的状态与浏览器中大致相同。不同的是node中有一套自己的模型。node中事件循环的实现是依靠的libuv引擎。我们知道node选择chrome v8引擎作为js解释器,v8引擎将js代码分析后去调用对应的node api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上node中的事件循环存在于libuv引擎中。–详解JavaScript中的Event Loop(事件循环)机制
6.涉及到的面试题
(1)js执行机制,说一下event loop(事件循环)
1.Javascript是单线程的,所有的同步任务都会在主线程中执行
2.主线程之外,还有一个任务队列。每当一个异步任务有结果了,就往任务队列里塞一个事件。 当主线程中的任务,都执行完之后,系统会 “依次” 读取任务队列里的事件。与之相对应的异步任务进入主线程,开始执行
3.异步任务之间,会存在差异,所以它们执行的优先级也会有区别。大致分为 微任务(micro task,如:Promise、MutaionObserver等)和宏任务(macro task,如:setTimeout、setInterval、I/O等)。同一次事件循环中,微任务永远在宏任务之前执行
4.主线程会不断重复上面的步骤,直到执行完所有任务
(2)为什么要引入微任务,只有一种类型的任务不行么?
页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。
(3)宏任务和微任务有哪些?
id | 宏任务 | 微任务 |
---|---|---|
1 | script(整体代码) | new Promise().then(回调) |
2 | setTimeout() | MutationObserver(html5 新特性) |
3 | setInterval() | process.nextTick |
4 | postMessage | Object.observe |
5 | I/O | |
6 | UI rendering | |
7 | setImmediate |
(4)promise和setTimeout有什么区别,他们怎么执行,什么是宏任务什么是微任务,执行顺序又是怎么样的?
(5)setTimeout和setInterval区别,如何互相实现?
(6)浏览器的事件循环和 nodejs 事件循环的区别
(7)用 JavaScript 的异步实现 sleep 函数
(8)回调函数实现的机制是?
(9)为什么要使用回调函数?
(10)什么是回调函数?
(10)实现一个带并发限制的异步调度器Scheduler,保证同时运行的任务最多有两个
(11)异步加载js,简述异步加载的几种方式
(12)async 和 defer 的不同?
笔试题说结果:
|
|
|
|
|
|