JavaScript

JavaScript中的异步编程与实现方式

理解并总结JSt中的异步编程与实现方式

Yixuan Lang
2021-09-21
6 min

# JavaScript 中的异步编程与实现方式

async-js-timer

# 一、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 的异步执行机制

JS异步执行机制1

JS异步执行机制2

根据上述图片,俩阐述一下消息队列以及事件循环的概念。

  • 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 时, 会导致性能上的降低。

# 参考文章

深入浅出 JavaScript 异步编程

前端必知必会之 JS 单线程与异步

JavaScript 的单线程执行及其异步机制矛盾否?

JavaScript 单线程异步的背后——事件循环机制