JavaScript引擎是如何工做的?从调用栈到Promise你须要知道的一切

翻译:疯狂的技术宅
原文: https://www.valentinog.com/bl...

本文首发微信公众号:前端先锋
欢迎关注,天天都给你推送新鲜的前端技术文章javascript


clipboard.png

你有没有想过浏览器是如何读取和运行 JavaScript 代码的吗?这看起来很神奇,但你能够学到一些发生在幕后的事情。让咱们经过介绍 JavaScript 引擎的精彩世界在这种语言中尽情畅游。前端

在 Chrome 中打开浏览器控制台,而后查看“Sources”标签。你会看到一个有趣的命名:Call Stack(在Firefox中,你能够在代码中插入一个断点后看到调用栈):java

clipboard.png

什么是调用栈(Call Stack)?看上去像是有不少东西正在运行,即便是只执行几行代码也是如此。实际上,并非在全部 Web 浏览器上都能对 JavaScript 作到开箱即用。node

有一个很大的组件来编译和解释咱们的 JavaScript 代码:它就是 JavaScript 引擎。最受欢迎的 JavaScript 引擎是V8,在 Google Chrome 和 Node.js 中使用,SpiderMonkey 用于 Firefox,以及 Safari/WebKit 所使用的 JavaScriptCore。git

今天的 JavaScript 引擎是个很杰出的工程,尽管它不可能覆盖浏览器工做的方方面面,可是每一个引擎都有一些较小的部件在为咱们努力工做。程序员

其中一个组件是调用栈,它与全局内存执行上下文一块儿运行咱们的代码。你准备好迎接他们了吗?github

JavaScript 引擎和全局内存

我认为 JavaScript 既是编译型语言又是解释型语言。信不信由你,JavaScript 引擎在执行以前实际上编译了你的代码。面试

是否是听起来很神奇?这种魔术被称为 JIT(即时编译)。它自己就是一个很大的话题,即便是一本书也不足以描述 JIT 的工做原理。可是如今咱们能够跳过编译背后的理论,专一于执行阶段,这仍然是颇有趣的。编程

先看如下代码:segmentfault

var num = 2;

function pow(num) {
    return num * num;
}

若是我问你如何在浏览器中处理上述代码?你会说些什么?你可能会说“浏览器读取代码”或“浏览器执行代码”。

现实中比那更加微妙。首先不是浏览器而是引擎读取该代码片断。 JavaScript引擎读取代码,当遇到第一行时,就会将一些引用放入全局内存中。

全局内存(也称为堆)是 JavaScript 引擎用来保存变量和函数声明的区域。因此回到前面的例子,当引擎读取上面的代码时,全局内存中被填充了两个绑定:

clipboard.png

即便例子中只有变量和函数,也要考虑你的 JavaScript 代码在更大的环境中运行:浏览器或在 Node.js 中。在这些环境中,有许多预约义的函数和变量,被称为全局。全局内存将比 num 和 pow 所占用的空间更多。记住这一点。

此时没有执行任何操做,可是若是尝试像这样运行咱们的函数会怎么样:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);

将会发生什么?如今事情变得有趣了。当一个函数被调用时,JavaScript 引擎会为另外两个盒子腾出空间:

  • 全局执行上下文环境
  • 调用栈

全局执行上下文和调用栈

在上一节你了解了 JavaScript 引擎是如何读取变量和函数声明的,他们最终进入了全局内存(堆)。

可是如今咱们执行了一个 JavaScript 函数,引擎必需要处理它。怎么处理?每一个 JavaScript 引擎都有一个基本组件,称为调用栈

调用栈是一个栈数据结构:这意味着元素能够从顶部进入,但若是在它们上面还有一些元素,就不能离开栈。 JavaScript 函数就是这样的。

当函数开始执行时,若是被某些其余函数卡住,那么它没法离开调用堆栈。请注意,由于这个概念有助于理解“JavaScript是单线程”这句话

可是如今让咱们回到上面的例子。 当调用该函数时,引擎会将该函数压入调用堆栈中:

clipboard.png

我喜欢将调用栈看做是一叠薯片。若是尚未先吃掉顶部的全部薯片,就吃不到到底部的薯片!幸运的是咱们的函数是同步的:它是一个简单的乘法,能够很快的获得计算结果。

同时,引擎还分配了全局执行上下文,这是 JavaScript 代码运行的全局环境。这是它的样子:

clipboard.png

想象一下全局执行环境做为一个海洋,其中 JavaScript 全局函数就像鱼同样在里面游泳。多么美好!但这只是故事的一半。若是函数有一些嵌套变量或一个或多个内部函数怎么办?

即便在下面的简单变体中,JavaScript 引擎也会建立本地执行上下文

var num = 2;
function pow(num) {
    var fixed = 89;
    return num * num;
}
pow(num);

请注意,我在函数 pow 中添加了一个名为 fixed 的变量。在这种状况下,本地执行上下文中将包含一个用于保持固定的框。我不太擅长在小方框里画更小的框!你如今必须运用本身的想象力。

本地执行上下文将出如今 pow 附近,包含在全局执行上下文中的绿色框内。 你还能够想象,对于嵌套函数中的每一个嵌套函数,引擎都会建立更多的本地执行上下文。这些框能够很快的到达它们该去的地方。

单线程的JavaScript

咱们说 JavaScript 是单线程的,由于有一个调用栈处理咱们的函数。也就是说,若是有其余函数等待执行,函数是不能离开调用栈的。

当处理同步代码时,这不是什么问题。例如,计算两个数字的和就是同步的,而且以微秒作为运行单位。可是当进行网络通讯和与外界的互动时呢?

幸运的是 JavaScript引擎被默认设计为异步。即便他们一次能够执行一个函数,也有一种方法可让外部实体执行较慢的函数:在咱们的例子中是浏览器。咱们稍后会探讨这个话题。

这时,你应该了解到当浏览器加载某些 JavaScript 代码时,引擎会逐行读取并执行如下步骤:

  • 使用变量和函数声明填充全局内存(堆)
  • 将每一个函数调用送到调用栈
  • 建立一个全局执行上下文,其在中执行全局函数
  • 建立了许多微小的本地执行上下文(若是有内部变量或嵌套函数)

到此为止,你脑子里应该有了一个 JavaScript 引擎同步机制的全景图。在接下来的部分中,你将看到异步代码如何在 JavaScript 中工做以及为何这样工做。

异步JavaScript,回调队列和事件循环

全局内存、执行上下文和调用栈解释了同步 JavaScript 代码在浏览器中的运行方式。然而咱们还错过了一些东西。当有异步函数运行时会发生什么?

我所指的异步函数是每次与外界的互动都须要一些时间才能完成的函数。例如调用 REST API 或调用计时器是异步的,由于它们可能须要几秒钟才能运行完毕。 如今的 JavaScript 引擎都有办法处理这种函数而不会阻塞调用堆栈,浏览器也是如此。

请记住,调用堆栈一次只能够执行一个函数,甚至一个阻塞函数均可以直接冻结浏览器。幸运的是,JavaScript 引擎很是智能,而且能在浏览器的帮助下解决问题。

当咱们运行异步函数时,浏览器会接受该函数并运行它。考虑下面的计时器:

setTimeout(callback, 10000);
function callback(){
    console.log('hello timer!');
}

你确定屡次见到过 setTimeout ,可是你可能不知道它不是一个内置的 JavaScript 函数。即当 JavaScript 诞生时,语言中并无内置的 setTimeout。

实际上 setTimeout 是所谓的 Browser API 的一部分,它是浏览器提供给咱们的便利工具的集合。多么体贴!这在实践中意味着什么?因为 setTimeout 是一个浏览器 API,该函数由浏览器直接运行(它会暂时出如今调用栈中,但会当即删除)。

而后 10 秒后浏览器接受咱们传入的回调函数并将其移动到回调队列。此时咱们的 JavaScript 引擎中还有两个框。请看如下代码:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);
setTimeout(callback, 10000);
function callback(){
    console.log('hello timer!');
}

能够这样画完成咱们的图:

clipboard.png

如你所见 setTimeout 在浏览器上下文中运行。 10秒后,计时器被触发,回调函数准备好运行。但首先它必须经过回调队列。回调队列是一个队列数据结构,顾名思义是一个有序的函数队列。

每一个异步函数在被送入调用栈以前必须经过回调队列。但谁推进了这个函数呢?还有另外一个名为 Event Loop 的组件。

Event Loop 如今只作一件事:它应检查调用栈是否为空。若是回调队列中有一些函数,而且若是调用栈是空闲的,那么这时应将回调送到调用栈。在完成后执行该函数。

这是用于处理异步和同步代码的 JavaScript 引擎的大图

clipboard.png

想象一下,callback() 已准备好执行。当 pow() 完成时,调用栈为空,事件循环推送 callback()。就是这样!即便我简化了一些东西,若是你理解了上面的图,那么就能够理解 JavaScript 的一切了。

请记住:Browser API、回调队列和事件循环是异步 JavaScript 的支柱

若是你喜欢视频,我建议去看 Philip Roberts 的视频:事件循环是什么。这是关于时间循环的最好的解释之一。

youtube: https://www.youtube.com/embed...

坚持下去,由于咱们尚未使用异步 JavaScript。在后面的内容中,咱们将详细介绍 ES6 Promises。

回调地狱和 ES6 的 Promise

JavaScript 中的回调函数无处不在。它们用于同步和异步代码。例如 map 方法:

function mapper(element){
    return element * 2;
}
[1, 2, 3, 4, 5].map(mapper);

mapper 是在 map 中传递的回调函数。上面的代码是同步的。但要考虑一个间隔:

function runMeEvery(){
    console.log('Ran!');
}
setInterval(runMeEvery, 5000);

该代码是异步的,咱们在 setInterval 中传递了回调 runMeEvery。回调在 JavaScript 中很广泛,因此近几年里出现了一个问题:回调地狱。

JavaScript中的回调地狱指的是编程的“风格”,回调嵌套在嵌套在…...其余回调中的回调中。正是因为 JavaScript 的异步性质致使程序员掉进了这个陷阱。

说实话,我历来没有碰到过极端的回调金字塔,也许是由于我重视代码的可读性,而且老是试着坚持这个原则。若是你发现本身掉进了回调地狱,那就说明你的函数太多了。

我不会在这里讨论回调地狱,若是你很感兴趣的话,给你推荐一个网站: callbackhell.com 更深刻地探讨了这个问题并提供了一些解决方案。咱们如今要关注的是 ES6 Promise。 ES6 Promise 是对 JavaScript 语言的补充,旨在解决可怕的回调地狱。但 Promise 是什么?

JavaScript Promise 是将来事件的表示。Promise 可以以 success 结束:用行话说就是它已经 resolved(已经完成)。但若是 Promise 出错,咱们会说它处于rejected状态。 Promise 也有一个默认状态:每一个新Promise都以 pending 状态开始。

建立和使用 Promise

要建立新的 Promise,能够经过将回调函数传给要调用的 Promise 构造函数的方法。回调函数可使用两个参数:resolvereject。让咱们建立一个新的 Promise,它将在5秒后 resolve(你能够在浏览器的控制台中尝试这些例子):

const myPromise = new Promise(function(resolve){
    setTimeout(function(){
        resolve()
    }, 5000)
});

如你所见,resolve 是一个函数,咱们调用它使 Promise 成功。下面的例子中 reject 将获得 rejected 的 Promise:

const myPromise = new Promise(function(resolve, reject){
    setTimeout(function(){
        reject()
    }, 5000)
});

请注意,在第一个示例中,你能够省略 reject ,由于它是第二个参数。可是若是你打算使用 reject,就不能省略 resolve。换句话说,如下代码将没法工做,最终将以 resolved 的 Promise 结束:

// Can't omit resolve !
const myPromise = new Promise(function(reject){
    setTimeout(function(){
        reject()
    }, 5000)
});

如今 Promise 看起来不是那么有用。这些例子不向用户打印任何内容。让咱们添加一些数据。 resolved 的和rejected 的 Promises 均可以返回数据。这是一个例子:

const myPromise = new Promise(function(resolve) {
  resolve([{ name: "Chris" }]);
});

但咱们仍然看不到任何数据。 要从 Promise 中提取数据,你还须要一个名为 then 的方法。它须要一个回调(真是具备讽刺意味!)来接收实际的数据:

const myPromise = new Promise(function(resolve, reject) {
  resolve([{ name: "Chris" }]);
});
myPromise.then(function(data) {
    console.log(data);
});

做为 JavaScript 开发人员,你将主要与来自外部的 Promises 进行交互。相反,库的开发者更有可能将遗留代码包装在 Promise 构造函数中,以下所示:

const shinyNewUtil = new Promise(function(resolve, reject) {
  // do stuff and resolve
  // or reject
});

在须要时,咱们还能够经过调用 Promise.resolve() 来建立和解决 Promise:

Promise.resolve({ msg: 'Resolve!'})
.then(msg => console.log(msg));

因此回顾一下,JavaScript Promise 是将来发生的事件的书签。事件以挂起状态开始,能够成功(resolved,fulfilled)或失败(rejected)。 Promise 能够返回数据,经过把 then 附加到 Promise 来提取数据。在下一节中,咱们将看到如何处理来自 Promise 的错误。

ES6 Promise 中的错误处理

JavaScript 中的错误处理一直很简单,至少对于同步代码而言。请看下面的例子:

function makeAnError() {
  throw Error("Sorry mate!");
}
try {
  makeAnError();
} catch (error) {
  console.log("Catching the error! " + error);
}

输出将是:

Catching the error! Error: Sorry mate!

错误在 catch 块中被捕获。如今让咱们尝试使用异步函数:

function makeAnError() {
  throw Error("Sorry mate!");
}
try {
  setTimeout(makeAnError, 5000);
} catch (error) {
  console.log("Catching the error! " + error);
}

因为 setTimeout,上面的代码是异步的。若是运行它会发生什么?

throw Error("Sorry mate!");
  ^
Error: Sorry mate!
    at Timeout.makeAnError [as _onTimeout] (/home/valentino/Code/piccolo-javascript/async.js:2:9)

此次输出是不一样的。错误没有经过 catch块。它能够自由地在栈中传播。

那是由于 try/catch 仅适用于同步代码。若是你感到好奇,能够在 Node.js 中的错误处理中获得该问题的详细解释。

幸运的是,Promise 有一种处理异步错误的方法,就像它们是同步的同样。

const myPromise = new Promise(function(resolve, reject) {
  reject('Errored, sorry!');
});

在上面的例子中,咱们能够用 catch 处理程序错误,再次采起回调:

const myPromise = new Promise(function(resolve, reject) {
  reject('Errored, sorry!');
});
myPromise.catch(err => console.log(err));

咱们也能够调用 Promise.reject() 来建立和 reject Promise:

Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err));

ES6 Promise 组合器:Promise.all,Promise.allSettled,Promise.any和它们的小伙伴

Promise 并非在孤军奋战。 Promise API 提供了一系列将 Promise 组合在一块儿的方法。其中最有用的是Promise.all,它接受一系列 Promise 并返回一个Promise。问题是当任何一个Promise rejected时,Promise.all 就会 rejects 。

Promise.race 在数组中的一个 Promise 结束后当即 resolves 或 reject。若是其中一个Promise rejects ,它仍然会rejects。

较新版本的 V8 也将实现两个新的组合器:Promise.allSettled Promise.anyPromise.any 仍处于提案的早期阶段:在撰写本文时,还不支持。

Promise.any 能够代表任何 Promise 是否 fullfilled。与 Promise.race 的区别在于 Promise.any 不会 reject,即便是其中一个Promise 被 rejected

最有趣的是 Promise.allSettled。它仍然须要一系列的 Promise,但若是其中一个 Promise rejects 的话 ,它不会被短路。当你想要检查 Promise 数组中是否所有已解决时,它是有用的。能够认为它老是和 Promise.all 对着干。

ES6 Promise 和 microtask 队列

若是你还记得前面的章节,JavaScript 中的每一个异步回调函数都会在被推入调用栈以前在回调队列中结束。可是在 Promise 中传递的回调函数有不一样的命运:它们由微任务队列处理,而不是由回调队列处理。

你应该注意一个有趣的现象:微任务队列优先于回调队列。当事件循环检查是否有任何新的回调准备好被推入调用栈时,来自微任务队列的回调具备优先权。

Jake Archibald 在任务、微任务、队列和时间表一文中更详细地介绍了这些机制,这是一篇很棒的文章。

异步的进化:从 Promise 到 async/await

JavaScript 正在快速发展,每一年咱们都会不断改进语言。Promise 彷佛是到达了终点,但 ECMAScript 2017(ES8)的新语法诞生了:async / await

async/await 只是一种风格上的改进,咱们称之为语法糖。 async/await 不会以任何方式改变 JavaScript(请记住,JavaScript 必须向后兼容旧浏览器,不该破坏现有代码)。

它只是一种基于 Promise 编写异步代码的新方法。让咱们举个例子。以前咱们用 then 的 Promise:

const myPromise = new Promise(function(resolve, reject) {
  resolve([{ name: "Chris" }]);
});
myPromise.then((data) => console.log(data))

如今使用async/await,咱们能够从另外一个角度看待用同步的方式处理异步代码。咱们能够将 Promise 包装在标记为 async 的函数中,而后等待结果:

const myPromise = new Promise(function(resolve, reject) {
  resolve([{ name: "Chris" }]);
});
async function getData() {
  const data = await myPromise;
  console.log(data);
}
getData();

如今有趣的是异步函数将始终返回 Promise,而且没人能阻止你这样作:

async function getData() {
  const data = await myPromise;
  return data;
}
getData().then(data => console.log(data));

怎么处理错误呢? async/await 提供的一个好处就是有机会使用 try/catch。 (参见异步函数中的异常处理及测试方法 )。让咱们再看一下Promise,咱们使用catch处理程序来处理错误:

const myPromise = new Promise(function(resolve, reject) {
  reject('Errored, sorry!');
});
myPromise.catch(err => console.log(err));

使用异步函数,咱们能够重构如下代码:

async function getData() {
  try {
    const data = await myPromise;
    console.log(data);
    // or return the data with return data
  } catch (error) {
    console.log(error);
  }
}
getData();

不是每一个人都会用这种风格。 try/catch 会使你的代码混乱。虽然用 try/catch还有另外一个问题要指出。请看如下代码,在try块中引起错误:

async function getData() {
  try {
    if (true) {
      throw Error("Catch me if you can");
    }
  } catch (err) {
    console.log(err.message);
  }
}
getData()
  .then(() => console.log("I will run no matter what!"))
  .catch(() => console.log("Catching err"));

哪一字符串会打印到控制台?请记住,try/catch是一个同步构造,但咱们的异步函数会产生一个 Promise。他们在两条不一样的轨道上行驶,就像两列火车。但他们永远不会碰面!也就是说,throw 引起的错误永远不会触发 getData() 的 catch 处理程序。运行上面的代码将致使 “抓住我,若是你能够”,而后“无论怎样我都会跑!”。

实际上咱们不但愿 throw 触发当前的处理。一种可能的解决方案是从函数返回 Promise.reject():

async function getData() {
  try {
    if (true) {
      return Promise.reject("Catch me if you can");
    }
  } catch (err) {
    console.log(err.message);
  }
}

如今错误将按预期处理:

getData()
  .then(() => console.log("I will NOT run no matter what!"))
  .catch(() => console.log("Catching err"));
"Catching err" // output

除此以外 async/await 彷佛是在 JavaScript 中构建异步代码的最佳方式。咱们能够更好地控制错误处理,代码看起来更清晰

我不建议把全部的 JavaScript 代码都重构为 async/await。这必须是与团队讨论以后的选择。可是若是你本身工做的话,不管你使用简单的 Promise 仍是 async/await 都是属于我的偏好的问题。

总结

JavaScript 是一种用于Web的脚本语言,具备先被编译而后再由引擎解释的特性。在最流行的 JavaScript 引擎中,有 Google Chrome 和 Node.js 使用的V8,为网络浏览器 Firefox 构建的 SpiderMonkey,由Safari使用的 JavaScriptCore

JavaScript 引擎有不少部分组成:调用栈、全局内存、事件循环和回调队列。全部这些部分在完美的调整中协同工做,以便在 JavaScript 中处理同步和异步代码。

JavaScript 引擎是单线程的,这意味着只有一个用于运行函数的调用堆栈。这种限制是 JavaScript 异步性质的基础:全部须要时间的操做必须由外部实体(例如浏览器)或回调函数负责。

为了简化异步代码流程,ECMAScript 2015 给咱们带来了 Promises。 Promise 是一个异步对象,用于表示异步操做的失败或成功。但改进并无止步于此。 2017年 async/await诞生了:它是 Promise 的一种风格上的弥补,能够用来编写异步代码,就好像它是同步的同样。


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章


欢迎继续阅读本专栏其它高赞文章: