【译】JavaScript 引擎如何工作?从调用栈到 Promise,几乎有你需要知道的一切

484 阅读18分钟

原文地址

有没有想过浏览器如何读取和运行JavaScript代码? 这看起来很神奇,但你可以得到一些发生在幕后的线索。

让我们通过介绍JavaScript引擎的精彩世界来沉浸在其语言之中。

在Chrome中打开浏览器控制台,然后查看“Sources”标签。 你会看到一些盒子,其中有趣的一个叫Call Stack(在Firefox中,你可以在代码中插入一个断点后看到调用堆栈):

什么是调用堆栈? 似乎有很多事情要讲,即使它运行几行代码。 事实上,对Web浏览器JavaScript并不是开箱即用的。

编译和解释JavaScript代码是重要的一部分,这就是JavaScript引擎。最流行的JavaScript引擎是V8,谷歌Chrome和Node.js,使用SpiderMonkey的Firefox,使用JavaScriptCore的Safari / WebKit。

今天的,avaScript引擎是伟大的项目,并没有涵盖它们的每个方面。每个引擎机的工作内容中都有一些小部分,对我们来说很难。

其中一个组件是调用堆栈,它与全局内存和执行上下文一起使运行我们的代码成为可能。准备好了解它们?

目录

JavaScript引擎和全局内存

我提到JavaScript既是编译语言同时又是解释语言。信不信由你,JavaScript引擎在执行之前实际上只用几微妙编译了你的代码。

这听起来很神奇吗? 它被称作JIT(即时编译)。这本身就是一个很大的话题,另一本书不足以描述JIT的工作原理。但是现在我们可以跳过编译背后的理论,并专注于执行阶段,这并不会减少乐趣。

首先考虑以下代码:

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

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

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

**全局内存(也称为Heap)**是JavaScript引擎保存变量和函数声明的区域。所以,回到我们的例子,当引擎读取上面的代码时,全局内存中填充了两个绑定:

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

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

  • 全局执行上下文
  • 调用堆栈

让我们看看它们在下一节中的含义。

全局执行上下文和调用堆栈

你了解了JavaScript引擎如何读取变量和函数声明。它们最终在全局内存(Heap)中结束。

但是现在我们执行了一个JavaScript函数,引擎必须要处理它。怎么办?每个JavaScript引擎都有一个基本组件,叫做调用栈

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

一旦执行,如果某些其他功能仍然卡住,则无法离开调用堆栈。 请注意,在脑海中记着“JavaScript是单线程”有助于理解这个概念。。

但是现在让我们回到我们的例子。调用该函数时,引擎会在调用堆栈中推送该函数:

我喜欢将Call Stack视为一桶薯片。如果没有先吃掉顶部的薯片,就不能吃到底部的薯片!幸运的是我们的功能是同步的:它是一个简单的乘法,它可以快速计算出来。

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

想象全局执行上下文是个海洋,其中JavaScript全局函数像鱼一样游动。如此美妙!但那只是故事的一半。如果我们的函数有一些嵌套变量或一个或多个内部函数怎么办?

即使在如下的简单变体中,JavaScript引擎也会创建本地执行上下文:

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

请注意,我在函数pow中添加了一个名为fixed的变量。在这种情况下,本地执行上下文将包含一个用于保持固定的盒子。

我不太擅长在其他小盒子里画小小的盒子!你不得不用你的想象力。

本地执行上下文将出现在 pow 附近,包含在全局执行上下文中的绿色框内。还可以想象,对于嵌套函数的每个嵌套函数,引擎都会创建更多本地执行上下文。 这些盒子可以很快到达目的地!像俄罗斯套娃!

现在回到单线程故事怎么样? 这是什么意思?

JavaScript的单线程和其他有趣的故事

我们说 JavaScript是单线程的,因为有一个Call Stack处理我们的函数。也就是说,如果有其他函数等待执行,函数不能离开调用堆栈。

处理同步代码时,这不是问题。例如,两个数字之间的和是同步的,并以微秒为单位运行。但是网络调用和与外界的其他互动怎么办?

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

在此期间,了解到当浏览器加载某些JavaScript代码时,引擎会逐行读取并执行以下步骤:

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

到目前为止,您应该已经了解了每个JavaScript引擎基础上的同步机制。在接下来的部分中,您将看到异步代码在JavaScript中的工作原理以及它为何如此工作。

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

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

通过异步函数,与外界的每次互动都需要一些时间才能完成。 调用REST API或调用计时器是异步的,因为它们可能需要几秒钟才能运行。 使用我们到目前为止在引擎中的元素,现在有办法处理这种函数而不会阻塞调用堆栈,浏览器也是如此。

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

当我们运行异步函数时,浏览器会获取该函数并为我们运行它。 考虑如下的计时器:

setTimeout(callback, 10000);

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

我确定你看过setTimeout数百次,但你可能不知道它不是内置的JavaScript函数。也就是说,当JavaScript诞生时,语言中没有内置的setTimeout。

事实上,setTimeout是所谓的浏览器API的一部分,浏览器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!');
}

我们可以这样完成我们的插图:

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

每个异步函数在被推入调用堆栈之前必须通过回调队列。但谁推动了这个功能?还有另一个名为Event Loop的组件。

Event Loop现在只有一个工作:它应检查Call Stack是否为空。 如果回调队列中有一些功能,并且如果调用堆栈是空闲的,那么是时候将回调推送到调用堆栈。

完成后,执行该功能。 这是用于处理异步和同步代码的JavaScript引擎的大图:

想象一下,callback()已准备好执行。 当pow()完成时,Call Stack为空,Event Loop推送callback()。就是这样! 即使我简化了一些事情,如果你理解了上面的插图,那么你就可以理解所有的JavaScript了。

请记住:浏览器API,回调队列和事件循环是异步JavaScript的支柱。

如果您喜欢视频,我建议观看Philip Roberts无论如何都要看事件循环。这是Event Loop有史以来最好的解释之一。

坚持下去,因为异步JavaScript还没有完成。在接下来的部分中,我们将详细介绍ES6 Promises。

回调地狱和 ES6 Promises

回调函数在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 Promises。ES6 Promises是JavaScript语言的补充,旨在解决可怕的回调地狱。但无论如何,Promise是什么?

JavaScript Promise是未来事件的表示。承诺可以以成功结束:用行话说我们已经解决了(履行)。但如果Promise出错,我们会说它处于拒绝状态。 Promise也有一个默认状态:每个新的Promise都以挂起状态开始。可以创建自己的Promise吗?是。让我们进入下一节看看如何做。

创建和使用JavaScript Promises

要创建新的Promise,可以通过将回调函数传递给它来调用Promise构造函数。回调函数可以采用两个参数:resolvereject。让我们创建一个新的Promise,它将在5秒后解析(您可以在浏览器的控制台中尝试这些示例):

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,你就不能省略 resolve。换句话说,以下代码将无法工作,最终将以已解决的Promise结束:

//不能省略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是未来发生的事件的书签。事件以挂起状态启动,可以成功(已解决,已履行)或失败(已拒绝)。 Promise可以返回数据,然后通过附加到Promise来提取数据。在下一节中,我们将看到如何处理来自Promise的错误。

ES6 Promises中的错误处理

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有一种处理异步错误的方法,就像它们是同步的一样。如果你回忆起上一节中的 reject, 产生了一个拒绝的 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()来创建和拒绝Promise:

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

回顾一下:当一个Promise被填满时,then处理程序运行,而catch处理程序运行被拒绝的Promises。但这不是故事的结局。稍后我们将看到async / await如何与try / catch很好地协作。

ES6 Promises 组合:Promise.all,Promise.allSettled,Promise.any

Promises 并不意味着孤军奋战。 Promise API 提供了许多将 Promise 组合在一起的方法。其中最有用的是Promise.all,它接受一个 Promises 数组并返回一个Promise。当数组中的任何 Promise 是 reject 时,Promise.all 返回 reject。

只要数组中的一个Promise 有结果,Promise.race 就会是 resolves 或者 reject。如果其中一个 Promise 拒绝,它仍然会 reject。

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

但理论是Promise.any可以表明任何Promise是否都满足 resolve。与Promise.race的区别在于Promise.any不会reject,即使其中一个Promise 是 reject。

无论如何,两者中最有趣的是Promise.allSettled。它仍然需要一系列Promise,但如果其中一个Promise 是 reject,它不会短路。当您想要检查Promise数组是否全部返回,它是有用的,无论最终是不是拒绝。把它想象成 Promise.all 地对立面。

ES6 Promises和microtask队列

如果你记得以前的部分,**JavaScript中的每个异步回调函数都会在被推入调用堆栈之前在回调队列中结束。**但是在Promise中传递的回调函数有不同的命运:它们由Microtask Queue(微任务队列)处理,而不是由Callback Queue(回调队列)处理。

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

Jake Archibald在任务,微任务,队列和日程安排中更详细地介绍了这些机制,这是值得一读。

JavaScript引擎:它们如何工作?异步进化:从 Promises 到 async/await

JavaScript正在快速发展,每年我们都会不断改进语言。 Promises似乎是到达点,但是在ECMAScript 2017(ES8)中诞生了一种新的语法:async / await。

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

它只是一种基于Promises编写异步代码的新方法。让我们举个例子。之前我们用相应的保存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 处理程序。 运行上面的代码将导致“Catch me if you can”,然后是“I will run no matter what!”。

在现实世界中,我们不希望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。这些是必须与团队讨论的选择。但是如果你单独工作,无论你使用简单的Promises, 还是 async/await 它都是个人偏好的问题。

总结

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

JavaScript引擎有很多令人激动的部分:调用堆栈,全局内存,事件循环,回调队列。所有这些部分在完美调整中协同工作,以便在JavaScript中处理同步和异步代码。

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

为了简化异步代码流,ECMAScript 2015给我们带来了 Promise。Promise是一个异步对象,用于表示任何异步操作的失败或成功。但改进并没有止步于此。在2017年,async/await诞生了:它是Promise的一种风格弥补,使得编写异步代码成为可能,就好像它是同步的一样。

感谢阅读并敬请关注此博客!

pic