主流的 JavaScript 环境都是以单线程模式执行 JavaScript 代码。
单线程:JavaScript 执行环境中负责执行代码的线程只有一个。
采用单线程模式工作的原因
和设计初衷有关,JavaScript 最初仅仅是浏览器端的脚本语言,目的是为了实现页面上的动态交互。
页面交互的核心是 DOM 操作,这决定了 JavaScript 必须使用单线程模型,否则会出现线程同步问题。
线程同步问题:若 JavaScript 同时有多个线程工作,某个线程修改了 DOM 元素,另外一个线程同时又删除了这个元素。浏览器无法明确以哪个线程的结果为准。
- 优点:安全、简单
- 缺点:如果遇到特别耗时的任务,后面的任务需要排队等待,这会导致程序的执行被拖延,出现假死的情况。
console.log('foo')
for (let i = 0; i < 100000; i++) {
console.log('耗时操作')
}
console.log('等待耗时操作结束')
为了解决耗时任务阻塞执行的问题,JavaScript 将任务的执行模式分成了两种:
- 同步模式(Synchronous)
- 异步模式(Asynchronous)
我们重点学习的便是异步模式。
内容概要
- 同步模式与异步模式
- 事件循环与消息队列
- 异步编程的几种方式
- Promise 异步方案、宏任务/微任务队列
- Generator 异步方案、Async / Await 语法糖
同步模式 Synchronous
大多数任务都会以同步模式执行。
console.log('global begin')
function bar () {
console.log('bar task')
}
function foo () {
console.log('foo task')
bar()
}
foo()
console.log('global end')
JavaScript 调用栈
JavaScript 有一个调用栈(Call Stack),会记录当前正在做的事情。
以上面的代码为例,详细解释调用栈的工作流程。
开始时会将整块代码作为匿名函数压入调用栈。
然后开始逐行执行代码。
执行过程中,控制台打印 “bar task”,执行完之后,console.log
调用结束,弹出调用栈,继续往下执行。
函数和变量的声明不会产生任何调用,继续往下执行。
当遇到 foo
函数调用时,函数调用会压入调用栈,并开始执行 foo
函数。foo
函数内先是 将 console.log
调用栈执行。打印完之后再将 bar
函数压入调用栈,并执行函数内的代码。bar
函数内同样将 console.log
压入调用栈,执行,弹出调用栈,foo
函数执行结束,从调用栈中弹出。
最后将 console.log
压入调用栈,执行,弹出调用栈后,整体的代码全部结束,调用栈被清空。
当调用栈全部清空时,这一轮的工作就结束了。
存在问题
如果某一个任务执行时间过长,后面的任务会延迟,这称之为阻塞。
这种阻塞对用户而言,界面会出现卡顿或卡死现象。
必须使用异步模式解决程序中无法避免的耗时操作,如浏览器端的 AJAX 操作,或者 Node.js 端的大文件读写。
异步模式
异步模式不会等待这个任务结束才开始下一个任务。异步模式开启过后就立即往后执行下一个任务。
后续逻辑一般会通过回调函数的方式定义。
如果没有异步模式,单线程的 JavaScript 语言就无法同时处理大量耗时任务。
对于开发者而言,异步模式的代码执行顺序混乱,我们需要多看、多练、多思考。
我们以一段代码为例,分析异步模式的执行顺序。
console.log('global begin')
setTimeout(function timer1 () {
console.log('timer1 invoke')
}, 1800)
setTimeout(function timer2 () {
console.log('timer2 invoke')
setTimeout(function inner () {
console.log('inner invoke')
}, 1000)
}, 1000)
异步调用的过程相对复杂一些,我们需要了解一些概念
- Web APIs
- 事件循环 Event loop 和消息队列 Queue
异步调用的实现过程
加载整体代码,在调用栈中压入一个匿名的全局调用,依次执行每行代码。
对 console.log
这样的同步 API,仍然是压栈、执行 -> 打印、弹栈。
在调用 setTimeout
部分代码时,仍然是先压栈,因为 timer1 是异步调用,Web APIs 开启了一个 timer1 倒计时器,倒计时器是单独工作的,不受当前 JS 线程影响。
开启倒计时器过后,setimeout
调用执行完了,会被弹出调用栈,代码继续往下执行。
代码往下执行时,又遇到 settimeout
,同样是先压栈,开启 timer2
倒计时器,弹栈。
然后又遇到 console.log
,同样压栈、执行、弹栈,整体代码执行完毕,匿名函数弹栈,调用栈清空。
此时,调用栈 Call stack 已经不再工作。
Event loop 负责监听调用栈和消息队列。
当调用栈没有任务之后,Event loop 会从消息队列中取出第一个回调函数压入调用栈,但此时消息队列为空,因此并不会执行操作。
Web APIs 两个倒计时器开启后计时会不断减少,timer2
倒计时器会先结束,便会被放到消息队列 Queue,timer1
倒计时器结束后也会放到 Queue。
一旦消息队列中发生变化,事件循环 Event loop 监听到后会将消息队列中的第一个回调函数 timer2
压入调用栈 Call stack
,调用栈开启新一轮执行,执行过程与之前类似。
如果这个过程又遇到了异步调用,仍会开启倒计时器。
我们可以将调用栈 Call stack 认为是一个正在执行的工作表,Queue 是一个代办的工作表。
JavaScript 执行引擎先做完调用栈中 Call stack 的任务,然后再通过事件循环 Event loop 从消息队列 Queue 中取一个任务继续执行。我们可以往消息队列中添加任务,消息队列中的任务会排队等待事件循环 Event loop。
这里为了方便理解步骤有先后顺序,实际执行时有各自的 timeline,如倒计时器开启后便会自动倒计时,不会管 Call stack 和 Queue 的情况,如下图能够更好的描述 JavaSCript 的执行情况。
JavaScript 是单线程的,但是浏览器不是单线程的。内部的 API 会用单独的线程执行等待的操作。
这里的同步或异步模式指运行环境提供的 API 是以同步或异步模式的方式工作。
回调函数
回调函数是所有异步编程方案的根基。
什么是回调函数
以程序中的 Ajax 为例,当我们去调用 Ajax 操作,目的是为了拿到请求结果后进行处理,如显示在界面上。
我们不清楚请求在何时完成,所以我们需要将响应后需要执行的任务定义到一个函数之中,内部的 Ajax 请求完成之后自动执行这个任务。
这种由调用者定义,交给执行者执行的函数称之为回调函数。
function foo (callback) {
setTimeout(function () {
callback()
}, 3000)
}
foo(function () {
console.log('这就是一个回调函数')
console.log('调用者定义这个函数,执行者执行这个函数)
console.log('其实就是调用者告诉执行者异步任务结束后应该做什么')
})
回调函数不易于阅读,执行顺序较为混乱。
除了传递回调函数参数的方式,还有事件机制、发布订阅,其实也是基于回调函数的一些变体。