开发做了这么多年,你真的了解 JS 工作机制吗

阅读数:4331 2019 年 7 月 16 日 18:02

开发做了这么多年,你真的了解JS工作机制吗

开发做了这么多年,你真的了解JS工作机制吗

本文的主题是 JavaScript,但不是讲它的功能,语法之类——相反,我要谈的是JS 的工作机制,以及与此相关的一些基本术语。下面进入主题。

相关术语

如果你曾看过 JS 的维基百科之类的资料,那么肯定会对一系列的术语印象深刻,诸如高级(high-level)、解释(interpreted)、JIT 编译、动态类型、基于原型(prototype-based)等等。其中有些术语很好理解,有经验的程序员肯定早就熟悉了;但也有些看起来很陌生。而且就算你不需要了解所有这些术语也能写代码,这些知识也肯定可以帮助你更好地理解语言和编程。所以想要理解 JS 的工作机制,一般来说先要学习这些术语的含义

从更高层级入手

JS 开发者并不怎么关心他们的代码是如何工作的,或者至少没这个必要。因为 JS 是一种高级语言。这意味着所有细节,例如数据如何存储在存储器(RAM)中或 CPU 执行指令的方式等,对程序员都是隐藏起来的。而“高”这个字表示的是语言提供的抽象或简化级别。

机器码

最底层的是机器码。很多人都知道,机器码只是以特定方式排列的一组 0 和 1,不同的排列方式对机器来说有不同的含义。有些可能表示特定指令、有些表示数据,诸如此类。

开发做了这么多年,你真的了解JS工作机制吗

汇编语言

机器码上面一级是汇编语言——也是最低级编程语言,只比机器码高级。与机器码相比,汇编代码的形式可以被人类理解。也就是说你能接触到的最底层语言就是汇编(用不着看机器码手册也能理解)。尽管如此,就算汇编语言具有“可读性”,使用ADD 或 MOV 等指令实际编写汇编代码也是一项非常艰巨的任务。甚至你需要为各个不同的目标处理器架构编写不同的汇编代码(例如桌面上的x86-64架构和移动设备上的ARM架构)!连操作系统都需要分别考虑!显然这和我们熟知的 JS 完全不是一回事吧。不管怎样,由于汇编代码仍然只是一个抽象,为了运行程序也要先编译才行,或者用一个名为汇编器的实用程序组装成机器码的形式。有意思的是许多汇编器甚至不是用纯汇编语言写的,很有趣不是吗。

高级

从汇编语言往上走,我们终于看到了许多人都非常熟悉的语言——最著名的是C 和 C++。在这个级别中,我们编写的代码与我们在 JS 中看到的代码更像一些。但我们仍然可以访问各种各样的“低级”(与 JS 相比)工具,也仍然需要用这些工具自己管理(分配 / 释放)内存。之后要通过名为编译器的程序将代码(间接)编译为机器码(中间会涉及汇编步骤)。注意汇编器和编译器的区别——编译器位于更高级别的抽象和机器代码之间,它能做的事情比汇编器多得多。这就是为什么 C 代码是“可移植的”,可以编写一次并编译到很多平台和架构中,类似的优势还有很多。

更高级

C++ 已经被认为是一种高级语言了,那么什么语言更高级呢?没错,就是JavaScript。JS 是一种在其引擎中运行的语言,最流行的引擎是 V8 ,这个引擎是用 C++ 编写的。这也是为什么 JS 一般被看作是一种解释性语言(不是完全正确,后文会具体说明)。这意味着你编写的 JS 代码不会被编译之后运行(像 C++ 那样),而是由一个名为解释器的程序运行。

如你所见,JS 确实是一种非常高级的语言。这有很多好处,主要优势在于程序员不必考虑那些当我们“失败”时就会变得可见的细节。这种高抽象级别的唯一缺点是性能损失。虽然 JS 速度很快,还在变得越来越快,但是大家都知道一段程序用 C++ 写(假设它写得很好)往往比用 JS 写的更快。但更高层次的抽象还是提高了开发人员的生产力,也让编程更加轻松一些。这是一种折衷方案,从这里也能看出为什么各种编程语言都有自己最适合的应用场景。

当然上面讲的这些都只是底层机制的简化描述,所以大概看一下就好。接下来我们将继续探索最高级别的抽象,也就是 JS 的工作机制。

设计

开发做了这么多年,你真的了解JS工作机制吗

图源: https://unsplash.com/?utm_source=ghost&utm_medium=referral&utmcampaign=api-credit

我在之前的文章中提到过,所有JS 实现(本质上只是不同的引擎,如 V8SpiderMonkey等)都要遵循同一份 ECMAScript 规范,以保持语言的完整兼容性。许多与 JS 相关的概念就源于这份规范。

动态类型和弱类型

在这份规范中有许多术语涉及到 JS 的设计及工作原理。我们由规范得知,JS 是动态和弱类型的语言。这意味着 JS 变量的类型是隐式解析的,可以在运行时更改(动态类型部分),并且它们不是非常严格地区分(弱类型部分)。因此存在像 TypeScript 这样更高级别的抽象,并且我们有了两个相等运算符——通常(==)和严格运算符(===)。动态类型在解释型语言中非常流行,而与之相反的静态类型则在编译语言中很受欢迎。

多范式

关于 JS 的另一个术语是多范式,JS 是一种多范式语言。这是因为 JS 允许你按照自己的方式编写代码。这意味着你的代码可以从声明和函数式变为命令式和面向对象类型,甚至可以混合使用这两种范式。编程范式的话题很大,深入探讨就要另开新文了。

原型继承

那么 JS 是如何实现“多范式”的呢?这里就要引入另一个对 JS 至关重要的概念——原型继承。现在你可能已经知道 JS 中的所有事物都是一个对象。你可能还知道面向对象编程和基于类的继承这些术语都是什么意思。接下来你必须知道,虽然原型继承可能看起来和基于类的集成很像,但它们实际上是完全不同的。在基于原型的语言中,对象的行为通过一个对象作为另一个对象的原型来复用。在这样的原型链中,当给定对象没有指定属性时,它会在其原型中查找,找不到就继续这个流程,直到它找到原型属性,或者找遍底层原型也没找到为止。

复制代码
const arr = [];
const arrPrototype = Object.getPrototypeOf(arr);
arr.push(1) // .push() originates in arrPrototype

你可能想知道基于原型的继承是否已经被 ES6 中基于类的继承取代(ES6 引入了类),答案是否定的。ES6 类只是基于原型继承概念的一个很好的语法糖。

实现细节

我们已经介绍了很多有趣的东西,但也只是刚刚触及了皮毛而已。我刚才提到的所有内容都是 ECMAScript 规范中的定义。但有趣的是,像事件循环甚至垃圾回收器这些都不在规范里。ECMAScript 只关注 JS 本身,实现细节则留给其他人解答(其他人主要是浏览器厂商)。这就是为什么虽然所有 JS 引擎都遵循相同的规范,但它们管理内存的方式可以不一样,是否做 JIT 编译也说不准,等等。那么这一切意味着什么呢?

JIT 编译

我们先来谈谈 JIT 。如前所述,将 JS 视为一种解释性语言是不对的。以前很多年 JS 的确是解释性的,但最近出现了一些变化,这种假设也随之过时了。许多流行的 JS 引擎为了使 JS 执行更快,引入了一种称为 Just-In-Time 编译的功能。简而言之,这意味着 JS 代码会在执行期间直接编译成机器码(至少 V8 是这样做的),不再有解释这一步。这个流程耗时稍长,但输出的结果性能更强。为了在有限的时间内完成工作,V8 实际上有两个编译器(不算与 WebAssembly 相关的内容)。其中一个是通用的,能够非常快地编译任何 JS 代码,但只输出性能一般的结果;而另一个编译速度有点慢,是用来编译常用代码的,其输出结果性能极高。当然,因为 JS 有动态类型的特性,这些编译器也不好做。所以类型不变的情况下第二个编译器的效果最好,能让你的代码运行起来快得多

但既然 JIT 这么快的话,为什么 JS 一开始不用它呢?我们也不太清楚,但我猜这是因为 JS 以前不需要那么多的性能提升,而且标准解释器更容易实现。在过去 JS 代码一般也就那么几行,就算用了 JIT 也可能因为编译开销反而损失一些性能。但如今浏览器(以及许多其他地方)使用的 JS 代码数量显著增加,JIT 编译肯定是走对了路。

事件循环

开发做了这么多年,你真的了解JS工作机制吗
图源: https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit

之前你可能听说过 JS 是在神秘的事件循环中运行的,但具体怎么回事你还没搞清楚。现在我们终于要探讨它的机制了,但首先需要了解一些背景知识。

调用栈和堆

在 JS 代码的执行过程中会分配两个内存区域——调用栈。第一个性能非常高,因此用于连续执行所提供的函数。每个函数调用在调用栈中创建一个所谓的“框架”,其中包含其局部变量的副本和 this。你可以通过 Chrome 调试器查看它。就像在其他与堆栈类似的数据结构中一样,调用栈的帧被推送或弹出堆栈,具体取决于正在执行或终止的新函数。你可能见过调用栈上限溢出错误,通常是由于某种形式的无限循环导致的。

谈到堆,就像现实生活中一样,JS 堆是存储本地范围之外对象的地方。它比调用栈慢得多。这就是为什么访问本地变量时速度可能会快很多。堆也是存放未被访问或使用的对象的地方,这种对象就是垃圾。有垃圾就要有垃圾回收器。需要时 JS 运行时的垃圾回收器就会激活,清理堆并释放内存。

单线程

现在我们知道了调用栈和堆都是什么意思,然后就可以讨论事件循环了。你可能知道 JS 是一种单线程语言。这也不是实际规范中的定义,属于实现细节的范畴。回顾历史,所有 JS 实现都是单线程的。你可能了解浏览器的Web Worker Node.js 子进程之类的东西——但它们并不能真正使 JS 本身变成多线程的。这两个功能确实提供了多线程能力,但它们都不是 JS 本身的一部分,而分别是 Web API 和 Node.js 运行时。

那么事件循环是如何工作的呢?其实很简单,JS 从不真正等待函数的返回值,而是监听传入的事件。这样一来,一旦 JS 检测到新发出的事件(比如说用户单击),就会调用指定的回调。然后 JS 只会等待同步代码完成执行,所有这些都在永无止境的非阻塞循环,也就是事件循环中重复。这是非常简化的解释,但作为基础知识来说足够了。

首先是同步

对事件循环来说,需要注意的是同步和异步代码不会被平等对待。相反,JS 首先执行同步代码,然后检查任务队列是否需要执行任何异步操作。下面是示例:

复制代码
setTimeout(() => console.log("Second"), 0);
console.log("First");
/* Console:
> "First"
> "Second"
*/

执行上面的代码片段时,你应该注意到虽然 setTimeout 排在第一位,并且它的超时时间是 0,它仍然会在同步代码之后执行。

如果你接触过异步代码,可能也了解过 Promise 。这里要注意一个小细节,Promise 有自己的特殊队列——微任务队列。这里只要记住这个微任务队列比通常的任务队列优先级更高。因此如果在队列中有任何 Promise 在等待,它将在任何其他异步操作(如 setTimeout)之前运行:

复制代码
setTimeout(() => console.log("Third"), 0);
Promise.resolve().then(() => console.log("Second"));
console.log("First");
/* Console:
> "First"
> "Second"
> "Third"
*/

知识好多!

如你所见,就算是基础内容也没那么简单。不过这些内容理解起来应该还是比较容易的,而且就算你不了解这些东西也能编写出优秀的 JS 代码。我认为只有事件循环的内容是必须了解的部分。但知识当然是越多越好。

英文原文: https://areknawo.com/javascript-from-the-inside-out/

收藏

评论

微博

用户头像
发表评论

注册/登录 InfoQ 发表评论