【译】JavaScript 是如何工作的(上)

原文地址:How JavaScript Works. Why understanding the fundamentals is… | by Ionel Hindorean | Better Programming

为什么我们要理解基本原理

你可能想知道为什么有人会在 2019 年费心写一篇关于 JavaScript 核心的长文。

这是因为我相信,如果没有对基础知识的扎实了解,很容易在JS生态系统中迷失方向,而且几乎不可能探索更高级的内容。

了解 JavaScript 的工作原理可以使阅读和编写代码变得更容易,减少挫折感,让你专注于你的应用程序的逻辑,而不是与语言的语法作斗争。

它是如何工作的?

计算机不懂JavaScript,浏览器才懂。

除了处理网络请求、监听鼠标点击、解释 HTML 和 CSS 以在屏幕上绘制像素外,浏览器还内置有一个 JavaScript 引擎。

JavaScript 引擎是一个用 C++ 编写的程序,它逐字逐句地查看所有的 JavaScript 代码,并将其 “转化 “为计算机 CPU 能够理解和执行的东西–机器代码

这个过程是同步进行的,也就是说,他们在一条时间线上,而且是按顺序进行。

他们这样做是因为机器代码很难,而且不同的 CPU 制造商的机器代码指令是不同的。所以,他们把所有这些麻烦从JavaScript 开发者那里抽象出来,否则,网络开发会更难,更不受欢迎,我们也不会有像 Medium 这样的东西,让我们可以写像这样的文章(而我现在就在睡觉)。

JavaScript 引擎可以机器的地浏览每一行 JavaScript,一遍又一遍(解释器),或者它可以变得更聪明,检测出一些东西,比如经常被调用并且总是产生相同结果的函数。

然后,它可以把这些东西编译成机器代码,只需一次,这样下次遇到它时,它就会运行已经编译好的代码,这就快多了(及时编译)。

或者,它可以提前将整个东西编译成机器代码,然后执行(见编译器)。

V8 就是这样一个 JavaScript 引擎,谷歌在2008年将其开源。2009年,一个叫 Ryan Dahl 的人想到用 V8 来创建 Node.js,这是一个在浏览器之外的 JavaScript 运行环境,这意味着该语言也可以用于服务器端应用。

函数执行上下文

像其他语言一样,JavaScript 对于函数、变量、数据类型,以及这些数据类型可以存储的确切数值,在代码中哪些地方可以访问,哪些地方不可以,等等都有自己的规则。

这些规则由一个名为 Ecma International 的组织定义标准,它们共同构成了语言规范文件(你可以在这里找到最新版本)。

因此,当引擎将 JavaScript 代码转换为机器代码时,它需要考虑这些规范。

如果代码中包含一个非法的赋值,或者它试图访问一个变量,而根据语言的规范,这个变量不应该从代码的特定部分被访问,怎么办?

每次函数被调用时,它都需要弄清所有这些事情。它通过创建一个被称为 “执行上下文 “的包装来实现这一目的。

为了更具体一些,避免将来出现混淆,我将把这个称为函数执行上下文,因为每次调用函数都会创建一个。不要被这个术语所吓倒,暂时不要想太多,后面会详细说明。

只要记住,它决定了一些事情,比如。”在那个特定的函数中,哪些变量是可以访问的,在它里面这个值是什么,哪些变量和函数在它里面被声明?”

全局执行上下文

但是,并不是所有的 JavaScript 代码都在一个函数里面(尽管大部分代码都在里面)。

在任何函数之外,在全局层面上也可能有代码,因此,JavaScript 引擎首先要做的一件事就是创建一个全局执行上下文。

这就像一个函数执行上下文,在全局层面上起到同样的作用,但它有一些特殊性。

比如,有且只有一个全局执行上下文,在执行开始时创建,所有的 JavaScript 代码都在其中运行。

全局执行上下文创建了两个东西,这两个东西对它来说是特定的,即使没有代码要执行。

  • 一个全局对象。当 JavaScript 在浏览器内运行时,这个对象是窗口对象。当它在浏览器外运行时,就像在 Node.js 中那样,它将是类似 global 的对象。不过为了简单起见,我将在本文中使用 window

  • 一个特殊的变量 this

在全局执行上下文中,也只有 this,这实际上等于全局对象 window。它基本上是一个对 window 的引用。

this === window // logs true
复制代码

全局执行上下文和函数执行上下文之间的另一个微妙区别是,任何在全局层面上声明的变量或函数(在任何函数之外),都会自动作为属性附加到窗口对象上,并隐含在特殊变量 this 上。

尽管函数也有特殊变量 this,但在函数执行环境中不会发生这种情况。

foo; // 'bar'
window.foo; // 'bar'
this.foo; // 'bar'
(window.foo === foo && this.foo === foo && window.foo === this.foo) // true
复制代码

所有的 JavaScript 内置变量和函数都附着在全局窗口对象上: setTimeout(), localStorage, scrollTo(), Math, fetch(),等等。这就是为什么它们可以在代码的任何地方被访问。

执行栈

我们知道,每次函数被调用时都会创建一个函数执行上下文。

由于即使是最简单的 JavaScript 程序也有相当多的函数调用,所有这些函数执行上下文都需要以某种方式进行管理。

请看下面的例子:

function a() {
  // some code
}

function b() {
  // some code
}

a();
b();
复制代码

当遇到函数 a() 的调用时,如上所述创建一个函数执行上下文,并执行该函数内的代码。

当代码的执行完成后返回语句或到达函数的包围},函数 a() 的函数执行上下文被销毁。

然后,会遇到 b() 的调用,对函数 b() 重复同样的过程。

但这种情况很少发生,即使在非常简单的 JavaScript 程序中。大多数情况下,会有一些函数在其他函数中被调用:

function a() {
  // some code
  b();
  // some more code
}

function b() {
  // some code
}

a();
复制代码

在这种情况下,a() 的函数执行上下文被创建,但就在 a() 的执行过程中,遇到了 b() 的调用。

b() 创建了一个全新的函数执行上下文,但是没有破坏 a() 的执行上下文,因为它的代码还没有完全执行。

这意味着在同一时间有许多函数执行上下文。然而,在任何时候,它们中只有一个在实际运行。

为了跟踪当前正在运行的函数,我们使用了一个堆栈,其中当前正在运行的函数执行上下文位于栈的顶部。

一旦它执行完毕,它将被从堆栈中弹出,下一个执行上下文的执行将继续,以此类推,直到执行堆栈为空。

这个栈被称为执行栈,如下图所示:

image.png

当执行堆栈为空时,我们之前讨论过的、从未被销毁的全局执行上下文就成为当前运行的执行上下文。

事件队列

还记得我说过,JavaScript 引擎只是浏览器的一个组件,与渲染引擎或网络层并列。

这些组件都有内置的 Hooks,引擎用这些 Hooks 来通信,以启动网络请求,在屏幕上绘制像素,或者监听鼠标点击。

当你在 JavaScript 中使用类似 fetch 的东西来做一个 HTTP 请求时,引擎实际上会将其传达给网络层。每当请求的响应到来时,网络层将把它传回给 JavaScript 引擎。

但这可能需要几秒钟的时间,当请求正在进行时,JavaScript 引擎会做什么?

简单地停止执行任何代码,直到响应到来?继续执行剩下的代码,每当响应到来时,就停止一切并执行其回调?当回调完成后,继续执行它离开的地方?

以上都不是,尽管第一个可以通过使用 await 来实现。

在多线程语言中,这可以通过一个线程在当前运行的执行环境中执行代码,另一个线程执行事件的回调来处理。但这在 JavaScript 中是不可能的,因为它是单线程的。

为了理解这实际上是如何工作的,让我们考虑一下我们之前看过的 a()b() 函数,但是增加一个点击处理程序和一个 HTTP 请求处理程序。

function a() {
  // some code
  b();
  // some more code
}

function b() {
  // some code
}

function httpHandler() {
  // some code here
}

function clickHandler() {
  // some more code here
}

a();
复制代码

JavaScript 引擎从浏览器的其他组件收到的任何事件,如鼠标点击或网络响应,都不会被立即处理。

在这一点上,JavaScript 引擎可能正忙于执行代码,所以它将把事件放在一个队列中,称为事件队列。

image.png

我们已经谈过了执行栈,以及一旦相应函数中的代码执行完毕,当前运行的函数执行上下文是如何从堆栈中弹出的。

然后,下一个执行上下文恢复执行,直到它完成,以此类推,直到堆栈为空,全局执行上下文成为当前运行的执行上下文。

当执行栈中有代码要执行时,事件队列中的事件被忽略,因为引擎正忙于执行栈中的代码。

只有当它完成了,并且执行栈是空的,JavaScript 引擎才会处理事件队列中的下一个事件(当然,如果有的话),并且会调用它的处理程序。

由于这个处理程序是一个 JavaScrip t函数,它的处理就像 a()b() 的处理一样,也就是说,一个函数的执行上下文被创建并推到执行栈中。

如果该处理程序反过来调用另一个函数,那么另一个函数的执行上下文就会被创建并推到堆栈的顶部,以此类推。
只有当执行栈再次为空时,JavaScript 引擎才会再次检查事件队列中的新事件。

这同样适用于键盘和鼠标事件。当鼠标被点击时,JavaScript 引擎会得到一个点击事件,把它放在事件队列中,只有当执行栈为空时才会执行它的处理程序。

你可以通过把下面的代码复制到你的浏览器控制台,轻松地看到这个过程:

function documentClickHandler() {
  console.log('CLICK!!!');
}

document.addEventListener('click', documentClickHandler);

function a() {
  const fiveSecondsLater = new Date().getTime() + 5000;
  while (new Date().getTime() < fiveSecondsLater) {}
}

a();
复制代码

while 循环只是让引擎忙碌五秒钟,不用太担心。在这五秒钟内开始点击文档上的任何地方,你会看到没有任何东西被记录到控制台。

当五秒钟过去,执行栈为空时,第一次点击的处理程序被调用。

由于这是一个函数,一个函数执行上下文被创建,推送到堆栈,执行,并从堆栈中弹出。然后,第二次点击的处理程序被调用,以此类推。

实际上,setTimeout()(和 setInterval() )的情况也是如此。你提供给 setTimeout() 的处理程序实际上被放在事件队列中。

这意味着,如果你将超时设置为 0,但执行堆栈上还有代码要执行,那么 setTimeout() 的处理程序只有在堆栈为空时才会被调用,这可能是许多毫秒之后。

setTimeout(() => {
  console.log('TIMEOUT HANDLER!!!');
}, 0);

const fiveSecondsLater = new Date().getTime() + 5000;
while (new Date().getTime() < fiveSecondsLater) {}
复制代码

注意:被放入事件队列的代码被称为异步的。这是否是一个好的术语是另一个话题,但人们就是这样称呼它的,所以我想你必须习惯于它。

函数执行上下文步骤

现在我们已经熟悉了JavaScript程序的执行周期,让我们再深入了解一下函数执行上下文到底是如何创建的。

它发生在两个步骤中:创建步骤和执行步骤。

创建步骤 “设置了一些东西”,以便代码可以被执行,而执行步骤实际上是执行它。

在创建步骤中发生的两件事非常重要:

  • 确定 scope.
  • 确定值。(我将假设你已经熟悉 JavaScript 中的 this 关键字)。

在接下来的两个相应章节中,将分别详细介绍这些内容。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享