# JavaScript 中的异步编程与实现方式
# 一、JS 单线程异步
JavaScript 的执行机制是单线程异步,适合 IO 密集型,不适合 CPU 密集型,但是,为什么是异步的喃,异步由何而来的呢?我们将在这里逐渐讨论实现。
# 1. 线程和进程的区分
在了解 JS 单线程异步前,先来了解一下什么是线程和进程
。

# 2. 浏览器是多进程
浏览器是由多个进程构成的,其中包括了最重要的浏览器渲染进程(浏览器内核)
Broswer 进程:浏览器的主进程,唯一,负责创建和销毁其他的进程、网络资源的下载管理、浏览器界面的展示、前进后退
GPU 进程:用于 3D 的绘制
浏览器渲染进程(浏览器的内核):内部由多个线程构成,没打开一个网页就会创建一个新的进程,主要用于页面的渲染,脚本处理,事件处理等。
第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建。
# 3. 渲染进程(浏览器内核)是多线程的
浏览器的渲染进程是有多个线程组成的,页面的渲染、Javascript 的执行、事件的循环等都存在于这个进程内
🌟 GUI 渲染线程:负责渲染浏览器页面,当界面需要重回(Repaint)或由于某种操作发生了回流(Reflow)时,该线程就会执行
🌟 Javascript 引擎线程:也成为 Javascript 内核,负责处理 Javascript 脚本程序,解析 Javascript 脚本、运行代码等
🌟 事件触发线程:用来控制浏览器事件循环,注意这个不归 Javascript 引擎线程管,当事件触发时,该线程会把事件添加到待处理队列的队尾,等待 Javascript 引擎处理。
定时器触发线程:setTimeout 和 setInterval 所在的线程.
异步的 http 请求线程:在 XMLHttpResquest 连线后通过浏览器新开一个线程请求,将检测到的状态变更时,如果设置有回调函数,异步线程就产生状态变更的事件,将这个回调放入时间队列中,再有 Javascript 引擎执行。
🔔【注意注意】GUI 渲染线程与 Javascript 引擎线程是互斥的,当 Javascript 引擎执行时 GUI 线程会被挂起(相当于冻结了)。
GUI 更新会被保存在一个队列中等到 Javascript 引擎空闲的时候立即被执行。所以如果 Javascript 执行的时间过长,会导致页面的渲染不流畅,导致页面渲染加载阻塞
# 4. 单线程的 Javascript
【先说结论】为什么 Javascript 是单线程的:避免 DOM 渲染的冲突
所谓单线程就是指,Javascript 引擎中负责解析和执行 Javascript 代码的线程唯一,同一时间上只能执行一件任务
为什么需要引入单线程呢???
浏览器需要渲染 DOM 元素
Javascript 可以修改 DOM 结构
Javascript 在执行时,浏览器的 DOM 渲染会暂时中断
考虑到这里,如果 Javascript 时多线程的,相当于可以同时执行多段 Javascript,如果这多段 Javascript 是在操作同一个 DOM 元素就会导致冲突
# 5. 同步和异步
简单说一下同步和异步,具体深入的知识还需要下去钻研哈~
- 同步(Synchronous):是程序发出调用的时候,一致等待直到返回结果,没有结果之前不会返回,也就是说,同步是调用者主动等待调用的过程
- 异步(Asynchronous):是程序发出调用后,马上返回,但是不会马上得到返回的结果。调用者不必主动等待,当被调用者得到结果后会主动通知调用者。
# 6. Javascript 的异步执行机制
【推荐视频】【瞎眼动画片】JavaScript 的异步执行机制
根据上述图片,俩阐述一下消息队列以及事件循环的概念。
Javascript 的执行与解析是在一个线程中进行的,我们先将这个线程称作主线程。
当主线程执行到一个异步任务的时候(上图的 setTimeout),就会发起相关处理定时器的线程的异步调用。
定时器相关的线程开始处理这段异步任务(定时器从此刻就开始进行倒计时)
异步任务执行完毕后,会将其回调函数放在消息队列的队尾
当事件循环监听到主线程的同步任务执行完毕后,会从消息队列的对头开始轮询取出回调函数放在调用栈中执行
💦(1)消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息(消息指的就是注册异步任务时添加的回调函数)
💦(2)事件循环:当主线程的同步任务执行完毕后,会从消息队列中循环相继取出回调函数放在主线程的调用栈中执行
# 二、JavaScript 异步编程的方式
随着 JavaScript 的发展和精进,出现了很多种处理 JS 异步编程的实现方式,JS 的异步编程机制可以分为一下几种(每一种在后续都会有专门的文章来详细探讨,这里只是做出简单的介绍:
# 1. 回调函数的方式
在早期的 Javascript 中(ES6 之前),只支持使用回调来来处理异步操作。在使用回调函数处理异步的缺点是
- 多个回调函数进行嵌套会造成回调地狱
- 上下两层回调函数的代码耦合度太高,不利于代码的维护。
- 不能使用 try catch 捕获错误,不能直接 return
ajax(urlA, () => {
// 处理逻辑
ajax(urlB, () => {
// 处理逻辑
ajax(urlC, () => {
// 处理逻辑
});
});
});
# 2. Promise 的方式
所谓 Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。
从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。
Promise 的三种状态
- pending:Promise 对象实例创建时候的初始状态
- resolved:成功状态
- rejected:失败状态
- Promise 的执行顺序 当我们在构造 Promise 的时候,构造函数内部的代码是立即执行的
new Promise((resolve, reject) => {
console.log("new Promise");
resolve("success");
});
console.log("end");
// new Promise
// end
- 改写 ajax 的回调地狱:
ajax(urlA)
.then((res) => {
console.log(res);
return ajax(urlB); // 包装成 Promise.resolve(urlB)
})
.then((res) => {
console.log(res);
return ajax(urlC); // 包装成 Promise.resolve(urlC)
})
.catch(function(err) {
console.log("error");
});
优点:
- 解决了回调地狱
- 能够通过回调函数捕获错误
缺点:
- 无法取消 Promise,一旦新建它就会执行,无法中途取消
- 如果不设置回调函数,Promise 内部抛出的错误,不会反应到外部
- 当处于 Pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
# 3. Generator 的方式
Generator 最大的特点就是可以控制函数的执行。
- function 关键字与函数名之间有一个星号;
- 函数体内部使用 yield 表达式,定义不同的内部状态;
- next 指针移向下一个状态,返回一个部署了 Iterator 接口的遍历器对象,用来操作内部指针。以后,每次调用遍历器对象的 next 方法,就会返回一个有着 value 和 done 两个属性的对象。value 属性表示当前的内部状态的值,是 yield 语句后面那个表达式的值;done 属性是一个布尔值,表示是否遍历结束。
- yield 可暂停函数,next 方法可执行函数,每次返回的是 yield 后的表达式结果。
- yield 表达式始终返回 undefined。next 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。
function* foo(x) {
let y = 2 * (yield x + 1);
let z = yield y / 3;
return x + y + z;
}
let it = foo(5);
console.log(it.next()); // => {value: 6, done: false}
console.log(it.next(12)); // => {value: 8, done: false}
console.log(it.next(13)); // => {value: 42, done: true}
同样可以解决回调地狱的问题。
function* fetch() {
yield ajax(urlA, () => {});
yield ajax(urlB, () => {});
yield ajax(urlC, () => {});
}
let it = fetch();
let result1 = it.next();
let result2 = it.next();
let result3 = it.next();
优点:
- 可分步执行并得到异步操作的结果;
- 可知晓异步操作所处的过程;
- 可切入修改异步操作的过程。
缺点:
- 仍然需要使用异步的思维去阅读代码;
- 手动迭代 Generator 函数较为麻烦。
# 4. Async / Await 的方式
async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。当函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。
- 正常情况下,await 命令后是一个 Promise 对象。如果不是,会被转成一个立即 resolve 的 Promise 对象。
- await 只能用在 async 函数中,不能用在普通函数中
- await 后面可能存在 reject,需要进行 try…catch 代码块中
例如:请求两个文件,毫无关系,可以通过并发请求
let fs = require('fs');
function read(file) {
return new Promise(function(resolve, reject) {
fs.readFile(file, 'utf8', function(err, data) {
if (err) reject(err);
resolve(data);
})
})
}
function readAll() {
read1();
read2(); // 这个函数同步执行
}
async function read1() {
let r = await read('1.txt','utf8');
console.log(r);
}
async function read2() {
let r = await read('2.txt','utf8');
console.log(r);
}
readAll(); // 2.txt 3.txt
- 优点:
- 处理 then 的调用链,能够更清晰准确的写出代码;
- 能优雅地解决回调地狱问题;
- 适用性更广泛,async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值; 语义性更强,使得异步代码读起来像同步代码,async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。
- 缺点:
- 多个没有依赖性的异步代码使用 await 时, 会导致性能上的降低。