为什么我们要理解基本原理
你可能想知道为什么有人会在 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()
的执行上下文,因为它的代码还没有完全执行。
这意味着在同一时间有许多函数执行上下文。然而,在任何时候,它们中只有一个在实际运行。
为了跟踪当前正在运行的函数,我们使用了一个堆栈,其中当前正在运行的函数执行上下文位于栈的顶部。
一旦它执行完毕,它将被从堆栈中弹出,下一个执行上下文的执行将继续,以此类推,直到执行堆栈为空。
这个栈被称为执行栈,如下图所示:
当执行堆栈为空时,我们之前讨论过的、从未被销毁的全局执行上下文就成为当前运行的执行上下文。
事件队列
还记得我说过,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 引擎可能正忙于执行代码,所以它将把事件放在一个队列中,称为事件队列。
我们已经谈过了执行栈,以及一旦相应函数中的代码执行完毕,当前运行的函数执行上下文是如何从堆栈中弹出的。
然后,下一个执行上下文恢复执行,直到它完成,以此类推,直到堆栈为空,全局执行上下文成为当前运行的执行上下文。
当执行栈中有代码要执行时,事件队列中的事件被忽略,因为引擎正忙于执行栈中的代码。
只有当它完成了,并且执行栈是空的,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
关键字)。
在接下来的两个相应章节中,将分别详细介绍这些内容。