本文的主题是 JavaScript,但不是讲它的功能,语法之类——相反,我要谈的是 JS 的工作机制,以及与此相关的一些基本术语。下面进入主题。
相关术语
如果你曾看过 JS 的维基百科之类的资料,那么肯定会对一系列的术语印象深刻,诸如高级(high-level)、解释(interpreted)、JIT 编译、动态类型、基于原型(prototype-based)等等。其中有些术语很好理解,有经验的程序员肯定早就熟悉了;但也有些看起来很陌生。而且就算你不需要了解所有这些术语也能写代码,这些知识也肯定可以帮助你更好地理解语言和编程。所以想要理解 JS 的工作机制,一般来说先要学习这些术语的含义…
从更高层级入手
JS 开发者并不怎么关心他们的代码是如何工作的,或者至少没这个必要。因为 JS 是一种高级语言。这意味着所有细节,例如数据如何存储在存储器(RAM)中或 CPU 执行指令的方式等,对程序员都是隐藏起来的。而“高”这个字表示的是语言提供的抽象或简化级别。
机器码
最底层的是机器码。很多人都知道,机器码只是以特定方式排列的一组 0 和 1,不同的排列方式对机器来说有不同的含义。有些可能表示特定指令、有些表示数据,诸如此类。
汇编语言
机器码上面一级是汇编语言——也是最低级编程语言,只比机器码高级。与机器码相比,汇编代码的形式可以被人类理解。也就是说你能接触到的最底层语言就是汇编(用不着看机器码手册也能理解)。尽管如此,就算汇编语言具有“可读性”,使用 ADD 或MOV等指令实际编写汇编代码也是一项非常艰巨的任务。甚至你需要为各个不同的目标处理器架构编写不同的汇编代码(例如桌面上的 x86-64 架构和移动设备上的 ARM 架构)!连操作系统都需要分别考虑!显然这和我们熟知的 JS 完全不是一回事吧。不管怎样,由于汇编代码仍然只是一个抽象,为了运行程序也要先编译才行,或者用一个名为汇编器的实用程序组装成机器码的形式。有意思的是许多汇编器甚至不是用纯汇编语言写的,很有趣不是吗。
高级
从汇编语言往上走,我们终于看到了许多人都非常熟悉的语言——最著名的是 C 和 C++。在这个级别中,我们编写的代码与我们在 JS 中看到的代码更像一些。但我们仍然可以访问各种各样的“低级”(与 JS 相比)工具,也仍然需要用这些工具自己管理(分配/释放)内存。之后要通过名为编译器的程序将代码(间接)编译为机器码(中间会涉及汇编步骤)。注意汇编器和编译器的区别——编译器位于更高级别的抽象和机器代码之间,它能做的事情比汇编器多得多。这就是为什么 C 代码是“可移植的”,可以编写一次并编译到很多平台和架构中,类似的优势还有很多。
更高级
C++已经被认为是一种高级语言了,那么什么语言更高级呢?没错,就是 JavaScript。JS 是一种在其引擎中运行的语言,最流行的引擎是V8,这个引擎是用 C++编写的。这也是为什么 JS 一般被看作是一种解释性语言(不是完全正确,后文会具体说明)。这意味着你编写的 JS 代码不会被编译之后运行(像 C++那样),而是由一个名为解释器的程序运行。
如你所见,JS 确实是一种非常高级的语言。这有很多好处,主要优势在于程序员不必考虑那些当我们“失败”时就会变得可见的细节。这种高抽象级别的唯一缺点是性能损失。虽然 JS 速度很快,还在变得越来越快,但是大家都知道一段程序用 C++写(假设它写得很好)往往比用 JS 写的更快。但更高层次的抽象还是提高了开发人员的生产力,也让编程更加轻松一些。这是一种折衷方案,从这里也能看出为什么各种编程语言都有自己最适合的应用场景。
当然上面讲的这些都只是底层机制的简化描述,所以大概看一下就好。接下来我们将继续探索最高级别的抽象,也就是 JS 的工作机制。
设计
图源:https://unsplash.com/?utm_source=ghost&utm_medium=referral&utmcampaign=api-credit
我在之前的文章中提到过,所有 JS 实现(本质上只是不同的引擎,如 V8 和 SpiderMonkey 等)都要遵循同一份ECMAScript规范,以保持语言的完整兼容性。许多与 JS 相关的概念就源于这份规范。
动态类型和弱类型
在这份规范中有许多术语涉及到 JS 的设计及工作原理。我们由规范得知,JS 是动态和弱类型的语言。这意味着 JS 变量的类型是隐式解析的,可以在运行时更改(动态类型部分),并且它们不是非常严格地区分(弱类型部分)。因此存在像 TypeScript 这样更高级别的抽象,并且我们有了两个相等运算符——通常(==)和严格运算符(===)。动态类型在解释型语言中非常流行,而与之相反的静态类型则在编译语言中很受欢迎。
多范式
关于 JS 的另一个术语是多范式,JS 是一种多范式语言。这是因为 JS 允许你按照自己的方式编写代码。这意味着你的代码可以从声明和函数式变为命令式和面向对象类型,甚至可以混合使用这两种范式。编程范式的话题很大,深入探讨就要另开新文了。
原型继承
那么 JS 是如何实现“多范式”的呢?这里就要引入另一个对 JS 至关重要的概念——原型继承。现在你可能已经知道 JS 中的所有事物都是一个对象。你可能还知道面向对象编程和基于类的继承这些术语都是什么意思。接下来你必须知道,虽然原型继承可能看起来和基于类的集成很像,但它们实际上是完全不同的。在基于原型的语言中,对象的行为通过一个对象作为另一个对象的原型来复用。在这样的原型链中,当给定对象没有指定属性时,它会在其原型中查找,找不到就继续这个流程,直到它找到原型属性,或者找遍底层原型也没找到为止。
你可能想知道基于原型的继承是否已经被 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 编译肯定是走对了路。
事件循环
图源: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 排在第一位,并且它的超时时间是 0,它仍然会在同步代码之后执行。
如果你接触过异步代码,可能也了解过Promise。这里要注意一个小细节,Promise 有自己的特殊队列——微任务队列。这里只要记住这个微任务队列比通常的任务队列优先级更高。因此如果在队列中有任何 Promise 在等待,它将在任何其他异步操作(如 setTimeout)之前运行:
知识好多!
如你所见,就算是基础内容也没那么简单。不过这些内容理解起来应该还是比较容易的,而且就算你不了解这些东西也能编写出优秀的 JS 代码。我认为只有事件循环的内容是必须了解的部分。但知识当然是越多越好。
英文原文:https://areknawo.com/javascript-from-the-inside-out/
活动推荐:
2023年9月3-5日,「QCon全球软件开发大会·北京站」 将在北京•富力万丽酒店举办。此次大会以「启航·AIGC软件工程变革」为主题,策划了大前端融合提效、大模型应用落地、面向 AI 的存储、AIGC 浪潮下的研发效能提升、LLMOps、异构算力、微服务架构治理、业务安全技术、构建未来软件的编程语言、FinOps 等近30个精彩专题。咨询购票可联系票务经理 18514549229(微信同手机号)。
评论