异步 Javascript

Background 背景

JavaScript 是一种单线程编程语言,这意味着一次只能发生一件事情,即 JavaScript 引擎一次只能在一个线程中处理一个语句。虽然单线程语言不必担心并发问题而简化了代码编写,但是这也意味着必须在不阻塞主线程的情况下执行诸如网络访问之类的长时间操作。想象一下从 API 请求一些数据,这时候服务器可能需要一段时间来处理请求,而处理请求的同时会进行阻塞主线程,使网页无响应,这时就需要异步 JavaScript 来发挥作用了。使用异步 JavaScript(Callback,Promise 与 async / await),这样我们就可以执行长网络请求,而不会进行阻塞主线程,当然如果网络请求时间过长,我们就需要做一些优化渲染的工作(Loading|呼吸态等)来改进用户体验。

Synchronous JavaScript 工作原理

在深入研究异步 JavaScript 之前,先了解一下同步 JavaScript 代码是如何在 JavaScript 引擎内执行的。要了解同步代码如何在 JavaScript 引擎内执行,我们必须了解执行上下文和调用堆栈(也称为执行堆栈)的概念。

Execution Context 执行上下文

执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当任何代码在 JavaScript 中运行时,它都会在执行上下文中运行。功能代码在功能执行上下文内执行,而全局代码在全局执行上下文内执行。每个函数都有其自己的执行上下文。

Call Stack 调用堆栈

顾名思义,调用堆栈是具有 LIFO(后进先出)结构的堆栈,该结构用于存储在代码执行期间创建的所有执行上下文。JavaScript 具有单一调用堆栈,因为它是一种单线程编程语言。调用堆栈具有 LIFO 结构,这意味着只能从堆栈顶部添加或删除项目。

示例代码

const second = () => {
  console.log("Hello there!");
};

const first = () => {
  console.log("Hi there!");
  second();
  console.log("The End");
};

first();

代码执行过程

  • 代码执行时会先创建一个全局执行上下文(以 main()表示)并将其推送到调用堆栈的顶部。
  • 当 first()被调用时,它将被推到堆栈的顶部。
  • first()内部的第一句 console.log('Hi there!')将被推到堆栈的顶部并在完成后从堆栈中弹出。
  • 之后 second()开始被调用,因此 second()被推入堆栈的顶部。
  • second()内部的第一句 console.log('Hello there!')被推到堆栈顶部并在完成后从堆栈中弹出,second()函数执行完成,因此将从堆栈中弹出。
  • first()内部的最后一句 console.log('The End')被推到堆栈的顶部,并在完成后从堆栈中弹出,first()函数执行完成,因此将其从堆栈中删除。
  • 程序执行完成,因此全局执行上下文(main())从堆栈中弹出。

Asynchronous JavaScript 工作原理

了解同步 JavaScript 的工作原理之后,让我们回到异步 JavaScript。要了解异步 Javascript 我们需要先了解什么是 Blocking 锁。

Blocking 锁

假设我们以同步方式进行图像处理或网络请求

const processImage = (image) => {
  /**
   * doing some operations on image
   **/
  console.log("Image processed");
};

const networkRequest = (url) => {
  /**
   * requesting network resource
   **/
  return someData;
};

const greeting = () => {
  console.log("Hello World");
};

processImage(logo.jpg);
networkRequest("www.somerandomurl.com");
greeting();

代码执行过程

  • 进行图像处理和网络请求是需要时间的。因此 processImage()从被调用到执行结束将需要一段时间,而时间将具体取决于图像的大小。
  • processImage()函数完成后将从堆栈中弹出,networkRequest()函数将被调用并推入堆栈,与 processImage()的执行一样也需要一段时间来完成执行。
  • networkRequest()函数完成完成后将从堆栈中弹出,greeting()函数将被调用并推入堆栈,并且由于它仅包含一条 console.log 语句且 console.log 语句通常比较快,因此 greeting()函数将会被立即执行并返回。

在执行上述代码时我们需要等待 processImage()或 networkRequest()执行完成了才能进入下一步,而这就意味着这些函数阻止了调用堆栈或主线程。因此在这些函数执行时我们不能执行任何其他操作,这是相当不理想的。

Solution 解决方案

最简单的解决方案是异步回调。我们使用异步回调使我们的代码成为非阻塞的。

  • setTimeout 并不是 JavaScript 引擎的一部分,而是 Web API(在 Browser 浏览器中)和 C/C++ API(在 Node.js 中)的一部分。
  • Web API 和 Message Queue 消息队列 / 任务队列 Task Queue 并不是 JavaScript 引擎的一部分,它是浏览器的 JavaScript 运行环境或 JavaScript 的 Node.js 运行时环境的一部分,在 Node.js 中,Web API 被 C/C++ API 取代。
  • Event Loop 事件循环的工作是调查调用堆栈(Call Stack)并确定调用堆栈是否为空。调用堆栈为空时它将检查消息队列(Message Queue),以查看是否有任何待执行的回调。
const networkRequest = () => {
  setTimeout(() => {
    console.log("Async Code");
  }, 2000);
};
console.log("Hello World");
networkRequest();
console.log("The End");

代码执行过程(Browser 浏览器中)

  • 首先 console.log('Hello World')将被推到堆栈的顶部,并在完成后从堆栈中弹出。
  • 然后 networkRequest()函数被调用并被推到堆栈的顶部。
  • 之后 setTimeout()函数被调用并被推到堆栈的顶部。
  • setTimeout()将一个 2 秒的计时器推入 Web API 中,至此 setTimeout()在调用堆栈中执行完成并从堆栈中弹出。
  • 再之后 console.log('The End')将被推到堆栈的顶部并在执行完成后从堆栈中弹出。计时器过期将回调推到消息队列中,但回调并不会立即执行。
  • 最后事件循环检测到消息队列中包含一个回调,并且此时调用堆栈为空,因此事件循环会将回调推到堆栈的顶部,将 console.log('Async Code')其推到堆栈的顶部并在执行完成后从堆栈中弹出,至此回调执行完成,它将从堆栈中弹出,程序执行完成。

想要深入了解 Event Loop 事件循环,我们还需要了解 DOM 事件。

DOM 事件

消息队列(Message Queue)还包含来自 DOM 事件的回调,如点击事件和键盘事件的回调。

document.querySelector(".btn").addEventListener("click", (event) => {
  console.log("Button Clicked");
});

对于 DOM 事件,事件侦听器位于 Web API 环境中,等待某个事件(此例为 click 事件)发生,并且当该事件发生时,回调将被推到消息队列中等待执行。当事件循环检查调用堆栈为空时,事件循环会将回调推到堆栈中并在执行完成后从堆栈中弹出。

ES6 Job Queue/Micro-Task Queue 作业队列/微任务队列

ES6 引入了作业队列/微任务队列的概念,Promise 在 Javascript 中使用了该概念。消息队列和作业队列之间的区别在于,作业队列的优先级高于消息队列,这意味着作业队列/微任务队列中的 Promise 将在消息队列中的回调之前执行。

console.log("Script start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

new Promise((resolve, reject) => {
  resolve("Promise resolved");
})
  .then((res) => console.log(res))
  .catch((err) => console.log(err));

console.log("Script End");

输出

Script start
Script End
Promise resolved
setTimeout

我们可以看到 Promise 是在 setTimeout 之前执行的,因为 Promise 存储在比消息队列具有更高优先级的微任务队列中。

console.log("Script start");

setTimeout(() => {
  console.log("setTimeout 1");
}, 0);

setTimeout(() => {
  console.log("setTimeout 2");
}, 0);

new Promise((resolve, reject) => {
  resolve("Promise 1 resolved");
})
  .then((res) => console.log(res))
  .catch((err) => console.log(err));

new Promise((resolve, reject) => {
  resolve("Promise 2 resolved");
})
  .then((res) => console.log(res))
  .catch((err) => console.log(err));

console.log("Script End");

输出

Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2

我们可以再次看到 2 个 Promise 是在 2 个 setTimeout 的回调之前执行的,因为事件循环将微任务队列中的任务优先于消息队列/任务队列中的任务。当事件循环正在执行微任务队列中的任务时,如果一个 Promise 的状态变为了完成,另一个 Promise 将被添加到同一微任务队列的末尾,并且都将在消息队列的回调之前执行,不管微任务队列中的任务需要执行多少时间。

console.log("Script start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

new Promise((resolve, reject) => {
  resolve("Promise 1 resolved");
}).then((res) => console.log(res));

new Promise((resolve, reject) => {
  resolve("Promise 2 resolved");
})
  .then((res) => {
    console.log(res);
    return new Promise((resolve, reject) => {
      resolve("Promise 3 resolved");
    });
  })
  .then((res) => console.log(res));

console.log("Script End");

输出

Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout

因此微任务队列中的所有任务将在消息队列中的任务之前执行。也就是说事件循环将首先清空微任务队列,然后再执行消息队列中的任何回调。

Conclusion 结论

异步 JavaScript 的工作原理及其他概念(调用堆栈,事件循环,消息队列/任务队列与作业队列/微任务队列)共同构成了 JavaScript 运行时环境,而了解Javascript的运行时环境将能帮助我们更好地组织Javascript代码,从而使得开发者在代码可读性与性能追求上再接再厉。虽然不必学习所有这些概念就可以成为一名出色的 JavaScript 开发人员,但是了解这些原理与概念一定会对我们日常开发遇到的问题产生帮助的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容