Skip to content

你并不了解 JavaScript:入门 - 第二版

第 1 章:什么是 JavaScript?

目前为止你还不了解 JS。我也不了解,反正不完全了解。我们都不了解。但我们都可以开始更好地了解 JS。

你并不了解 JavaScript(YDKJSY)系列第一本书的第一章中,我们将花一些时间来夯实地基,以便继续前行。我们需要从涵盖各种重要的背景细节开始,澄清一些关于语言真正是什么(和不是什么!)的神话和误解。

这是对如何组织和维护 JS 的特征和过程的宝贵见解;所有 JS 的开发者都应该了解它。如果你想了解 JS,这就是如何入门并开始这段旅程的第一步。

关于本书

我强调旅程这个词是因为了解 JS 不是目的,而是一个方向。无论你在这门语言上花了多少时间,你总能找到更好的东西来学习和理解它。所以不要把这本书看成是急于求成的东西。相反,耐心是生活的关键。

在这一章节之后,本书的其余部分列出了一张高层次的图谱用来说明你在使用 YDKJSY 书籍挖掘和研究 JS 时将发现什么。

特别是第四章指出了 JS 语言的三个主要支柱:作用域/闭包、原型/对象,以及类型/强制转换。JS 是一种广泛而复杂的语言,有许多特性和能力。但是所有的 JS 都是建立在这三个基本支柱之上的。

请记住,尽管这本书的标题是「入门」,但它不是作为一本初学者/入门书的。本书的主要工作是让你为在本系列的其他部分深入学习 JS 做好准备;它的写作是假设你在学习 YDKJSY 之前已经有了至少几个月的熟练的 JS 的经验。因此,为了从入门中获得最大的收益,请确保你拥有花大量的时间编写 JS 代码的经验。

即使你以前已经写过很多 JS,这本书也不应该被略过或跳过;要花时间充分理解这里的内容。不积跬步,无以至千里;不积小流,无以成江海

名字的由来

JavaScript 这个名字可能是最错误和容易被误解的编程语言名称。

这种语言与 Java 有关吗?它只是 Java 的脚本形式吗?它只用于编写脚本而不是真正的程序吗?

事实上,JavaScript 这个名字是营销诡计的一个产物。当 布兰登·艾克 (Brendan Eich) 第一次构思这种语言时,他将其命名为 Mocha。网景内部则使用 LiveScript。但到了公开命名该语言的时候,'JavaScript' 赢得了投票。

为什么呢?因为这种语言最初是为了吸引大部分 Java 程序员而设计的,而且「脚本 (Script) 」这个词在当时很流行,指的是轻量级程序。这些轻量级的「脚本」将是第一个在网络这个新事物上嵌入页面的!

换而言之,JavaScript 是一种营销策略,试图将这种语言定位为一种可接受的替代品,以替代当时更重、更知名的 Java。就这一点而言,它也可以很容易地被称为 'WebJava'。

JavaScript 的代码和 Java 的代码之间有一些表面上的相似之处。这些相似之处并不是共同发展的理念,而是因为两种语言都以 C 语言(在某种程度上,C++)的假设语法期望为目标的开发者。

例如,我们用{来开始一个代码块,用}来结束这个代码块,就像 C/C++ 和 Java 一样。我们还使用;来标明语句的结束。

在某些方面,法律关系甚至比语法更深。甲骨文公司(通过 Sun 公司),这个仍然拥有并运行 Java 的公司,也拥有 "JavaScript" 这个名字的官方商标(通过网景通信)。这个商标几乎从来没有被执行过,而且现在很可能也不能被执行。

出于这些原因,有些人建议我们使用 JS 而不是 JavaScript。这是一个非常常见的简写,如果不是一个官方语言品牌本身的良好候选者的话。事实上,这些书籍几乎完全使用 JS 来指代这种语言。

为了进一步将该语言与甲骨文公司拥有的商标拉开距离,TC39 规定并由 ECMA 标准机构正式确定的语言的官方名称是 ECMAScript。事实上,自 2016 年以来,官方语言名称也以修订年份作为后缀;截至本文撰写之时,为 ECMAScript 2019,或缩写为 ES2019。

换句话说,在你的浏览器或 Node.js 中运行的 JavaScript/JS,是基于 ES2019 标准的实现。

注意:
不要使用诸如 'JS6' 或 'ES8' 之类的术语来指代该语言。有些人会这样做,但这些术语只会使混乱持续下去。你应该坚持使用 'ES20xx' 或只是 'JS'。

无论你叫它 JavaScript、JS、ECMAScript 还是 ES2019,它都绝对不是 Java 语言的一个变体!

「Java 之于 JavaScript 就像雷锋之于雷峰塔。」 -Jeremy Keith, 2009 [1]

语言规范

我提到了管理 JS 的技术指导委员会 TC39,他们的主要任务是管理语言的官方规范。他们定期开会,对任何商定的修改进行表决,然后将其提交给标准组织 ECMA。

JS 的语法和行为是在 ES 规范中定义的。

ES2019 恰好是 JS 自 1995 年成立以来的第 10 个主要编号规范/修订版,因此在 ECMA 规范的官方 URL 中,您会发现 '10.0':

https://www.ecma-international.org/ecma-262/10.0/

TC39 委员会由 50 至约 100 名不同的人员组成,他们来自广泛的网络投资公司,如浏览器制造商(Mozilla、谷歌、苹果)和设备制造商(三星等)。委员会的所有成员都是志愿者,尽管他们中的许多人是这些公司的员工,但也可能因其在委员会的职责而获得部分报酬。

TC39 一般每隔一个月召开一次会议,通常为期三天,审查成员自上次会议以来所做的工作,讨论问题,并对提案进行表决。会议地点由愿意主办的成员公司轮流决定。

所有的 TC39 提案都要经过五个阶段的过程。当然,因为我们是程序员,所以是以 0 来开始的:阶段 0 到阶段 4。你可以在这里阅读更多关于该阶段过程的信息:https://tc39.es/process-document/

阶段 0 的意思是,TC39 的某个人认为这是一个有价值的想法,并计划支持和努力实现它。这意味着很多非 TC39 成员通过社交媒体或博客文章等非正式途径「提出」的想法实际上是「阶段 0 之前的」。你必须得到 TC39 成员的支持,它才能被正式视为「阶段 0」。

一旦一个提案达到「阶段 4」的状态,它就有资格被纳入下一年的语言修订中。一项提案可能需要几个月到几年的时间来完成这些阶段的工作。

所有提案都在 TC39 的 GitHub 存储库上进行公开管理:https://github.com/tc39/proposals

任何人,无论是否是 TC39 的成员,都欢迎参与这些公开讨论和提案的工作进程。但是只有 TC39 成员可以参加会议,并对提案和修改进行投票。因此,TC39 成员的声音对 JS 的走向有很大的影响。

与一些既定的、令人沮丧的神话传说相反,并不存在多个版本的 JavaScript。只有一个 JS,即由 TC39 和 ECMA 维护的官方标准。

早在 21 世纪初,当微软维护一个名为 'JScript' 的分支和逆向工程(但并不完全兼容)JS 版本时,JS 就在那时有了合法的「多个版本」。但是那些日子已经一去不复返了。今天对 JS 做出这样的断言是过时和不准确的。

所有主要的浏览器和设备制造商都承诺使他们的 JS 实现符合这一个中心规范。当然,引擎在不同时期实现的功能也不同。但是,V8 引擎(Chrome 的 JS 引擎)与 SpiderMonkey 引擎( Mozilla 的 JS 引擎)相比,在实现特定的功能时,不应该出现不同或不兼容的情况。

这意味着你可以学习一种版本的 JS,并在任何地方都使用它。

网络主宰着(JS)的一切

虽然运行 JS 的平台在不断扩大(从浏览器,到服务器(Node.js),到机器人,到灯泡,到......),但统治 JS 的还是环境是网络。换句话说,JS 如何在网络浏览器上实现,在所有的实际情况下,是唯一重要的现实。

在大多数情况下,规范中定义的 JS 和基于浏览器的 JS 引擎中运行的 JS 是一样的。但也有一些必须考虑的差异。

有时 JS 规范会规定一些新的或改进的行为,但这与基于浏览器的 JS 引擎的工作方式不完全匹配。这种不匹配是历史遗留问题: JS 引擎已经有 20 多年的可观察到的行为,围绕着那些已经被网络内容所依赖的功能的角落案例。因此,有时 JS 引擎会拒绝符合规范规定的变化,因为这会破坏现有的网络内容。

在这些情况下,往往 TC39 会反其道而行之,只是选择让规范符合网络的现实。例如,TC39 计划为数组增加一个contains(..)方法,但发现这个名字与一些网站仍在使用的旧 JS 框架相冲突,所以他们把这个名字改为不冲突的includes(..)。同样的情况也发生在被称为 "smooshgate" 的喜剧/悲剧性 JS 社区危机中,计划中的flatten(..)方法最终被改名为flat(..)

但偶尔,TC39 会决定规范应该在某些方面坚持到底,尽管基于浏览器的 JS 引擎不太可能符合规范。

解决方案是什么?附录 B,「用于 Web 浏览器的附加 ECMAScript 功能」。[2] JS 规范包括本附录,以详细说明官方 JS 规范与 JS 在网络上的实际情况之间的任何已知不匹配。换句话说,这些是只允网络 JS 的例外情况;其他 JS 环境必须遵守规范。

B.1 和 B.2 章节涵盖了网络 JS 的附加功能(语法和 API),这也是出于历史原因,但 TC39 并不打算在 JS 的核心中正式规定。例子包括0前缀的八进制字,全局的escape(..)/unescape(..)工具,字符串「助手」如anchor(..)blink(),以及 RegExpcompile(..)方法。

B.3 章节包括一些冲突,在这些冲突中,代码可能同时在网络和非网络 JS 引擎中运行,但其行为可能明显不同,导致不同的结果。大多数列出的变化涉及到代码在严格模式下运行时被标记为早期错误的情况。

附录 B Gotcha。「Gotcha,在计算机编程领域中是指在系统或程序、程序设计语言中,合法有效,但是会误解意思的构造,程式容易造成错误,或是一些易于使用但其结果不如期望的构造。字面上是 got you 的简写,常用于口语,直译为:『逮到你了』、『捉弄到你了』、『你中计了』、『骗到你了』。」 并不经常遇到,但为了未来的安全,避免这些结构仍然是个好主意。在可能的情况下,遵守 JS 规范,不要依赖只适用于某些 JS 引擎环境的行为。

(网络) JS 并非一切

这段代码是一个 JS 程序吗?

js
alert("Hello, JS!");
alert("Hello, JS!");

这要看你怎么看问题了。这里显示的 alert(..) 函数没有包括在 JS 规范中,但在所有的网络 JS 环境中都存在。然而,你不会在附录 B 中找到它,那么是什么原因呢?

各种 JS 环境(如浏览器 JS 引擎、Node.JS 等)将 API 添加到 JS 程序的全局作用域中,为您提供特定于环境的功能,例如能够在用户的浏览器中弹出警报样式框。

事实上,许多看起来像 JS 的 API,如 fetch(..)getCurrentLocation(..)getUserMedia(..),都是看起来像 JS 一样的 web API。在 Node.js 中,我们可以从各种内置模块访问数百个 API 方法,比如 fs.write(..)。.

另一个常见的例子是 console.log(..)(以及所有其他的 console.* 方法!)。这些都不是在 JS 中定义的,但由于它们的普遍效用,几乎每个 JS 环境都根据一个大致同意的共识来定义。

所以 alert(..)console.log(..) 并不是由 JS 定义的。但是它们看起来像 JS。它们是函数和对象方法,它们遵守 JS 的语法规则。它们背后的行为是由运行 JS 引擎的环境控制的,但从表面上看,它们肯定要遵守 JS 才能在 JS 地盘上玩耍。

人们抱怨的「JS 太不一致了!」的跨浏览器差异,大部分实际上是由于这些环境行为的工作方式不同,而不是 JS 本身的工作方式。

因此,alert(..) 调用 JS,但 alert 本身实际上只是一个客人,不是官方 JS 规范的一部分。

不一定是 JS 的问题

在浏览器的开发工具(或 Node)中使用控制台/REPL(读取(Read)-评估(Evaluate)-打印(Print)-循环(Loop)),乍看之下感觉是一个相当直接的 JS 环境。但其实不然。

开发者工具是......给开发者使用的工具。它们的主要目的是使开发者的生活更容易。他们优先考虑 DX(开发者体验)。这些工具的目标并不是准确和纯粹地反映严格规范的 JS 行为的所有细微差别。因此,如果你把控制台当作一个 JS 的环境,有许多怪异行为可能会成为「麻烦」。

顺便说一下,这种便利是件好事 我很高兴开发工具让开发者的生活更轻松 我很高兴我们有漂亮的用户体验,比如变量/属性的自动完成,等等。我只是指出,我们不能也不应该期望这种工具总是严格遵守 JS 程序的处理方式,因为这不是这些工具的目的。

由于这类工具在不同的浏览器中的行为各不相同,而且由于它们的变化(有时相当频繁),我不打算将任何具体细节「硬编码」到本文中,从而确保这本书的文本不会很快就会过时。

但我只是列举一些在不同的 JS 控制台环境中,在不同的时间点上出现的怪异行为的例子,以加强我关于在使用时不要假设本地 JS 行为的观点:

  • 在顶层「全局作用域」中的 varfunction 声明是否实际创建了一个真正的全局变量(以及镜像的 window 属性,反之亦然!)。
  • 「全局作用域」中的多个 letconst 声明会发生什么。
  • 独占一行的 "use strict"; (输入后按回车键 Enter)是否会为控制台会话的其余部分启用严格模式,就像在 .js 文件的第一行上一样,以及您是否可以在「第一行」之外使用 "use strict"; 并且仍然可以为该会话启用严格模式。
  • 非严格模式的 this 默认绑定对函数调用的作用,以及使用的「全局对象」是否会包含预期的全局变量。
  • 变量提升(见第二章,作用域与闭包)是如何工作的。
  • ....

开发者控制台并不试图假装是一个 JS 编译器,它处理你输入的代码与 JS 引擎处理 .js 文件的方式完全相同。它只是想让你轻松地快速输入几行代码并立即看到结果。这些是完全不同的用例,因此,期望一个工具能同样处理这两种情况是不合理的。

不要相信你在开发者控制台中看到的行为是代表精准的 JS 语义;对于这一点,请阅读规范。相反,把控制台看成是一个「JS 友好」的环境。这本身就很有用。

多面性

在编程语言方面,「范式」一词指的是一种广泛的(几乎是普遍的)思维方式和结构化代码的方法。在一个范式中,有无数的风格和形式的变化来区分程序,包括无数不同的库和框架,在任何给定的代码上留下他们独特的印记。

但是,无论一个程序的个人风格如何,围绕范式的大局划分几乎总是在任何程序的第一眼就能看出来。

典型的范式级代码类别包括面向过程、面向对象 (OO/classes) 和函数式 (FP):

  • 过程式风格以自上而下的方式组织代码,通过一组预先确定的操作进行线性进展,通常收集在一起的相关单元称为过程。

  • OO 风格通过将逻辑和数据收集在一起,形成称为类的单元来组织代码。

  • FP 式风格将代码组织成函数(相对于程序的纯计算),并将这些函数的自应性作为值。

范式既没有对错。它们是指导和塑造程序员如何处理问题和解决方案的方向,以及他们如何结构和维护他们的代码。

有些语言严重偏向于一种范式:C 语言是面向过程的,Java/C++ 几乎完全是面向对象的,而 Haskell 是彻头彻尾的函数式语言。

但是,许多语言也支持可以来自不同范式的代码模式,甚至可以从不同范式中进行混合和匹配。所谓的「多范式语言」提供了终极的灵活性。在某些情况下,一个程序甚至可以有两个或更多的这些范式的表达。

JavaScript 无疑是一种多范式的语言。你可以写面向过程的、面向对象的或函数式风格的代码,而且你可以在每一行的基础上做出这些决定,而不是被迫做一个全有或全无的选择。

过去 & 未来

JavaScript 的最基本的原则之一是保持向后兼容性。许多人对这个术语的含义感到困惑,并经常将其与一个相关但不同的术语混淆: 向前兼容

让我们把事情弄清楚。

向后兼容意味着,一旦某些东西被接受为有效的 JS,将来就不会有语言的变化导致该代码成为无效的 JS。比如在1995 年写的代码,无论它是多么原始或有限!今天应该仍然可以工作。正如 TC39 成员经常宣称的,「我们不会破坏网络!」

我们的想法是,JS 开发者可以放心的编写代码,他们的代码不会因为浏览器的更新发布而不可预测地停止工作。这使得为程序选择 JS 的决定在未来几年都是一个更明智和安全的投资。

这种「保证」不是小事。为了维护向后兼容性,该语言跨越了近 25 年的历史,创造了巨大的负担和一系列独特的挑战。你很难在计算机领域找到许多其他承诺向后兼容的例子。

坚持这一原则的代价不应该被随意忽视。它必然会对包括改变或扩展语言创造一个非常高的门槛;任何决定都会成为有效的永久性的,错误以及其他所有的东西。一旦它出现在 JS 中,就不能再去掉了,因为它可能会破坏程序,即使我们真的很想把它去掉!

这条规则也有一些小的例外。JS 有一些向后兼容的变化,但 TC39 在这样做的时候是非常谨慎的。他们研究网络上现有的代码(通过浏览器数据收集)来估计这种破坏的影响,浏览器最终决定并投票决定他们是否愿意为一个非常小规模的破坏承受来自用户的压力,而不是为更多的网站(和用户)修复或改进语言的某些方面的好处。

这类变化是罕见的,而且几乎总是在使用的角落里,在许多网站中不太可能被观察到的破坏。

后向兼容与它的对应物前向兼容进行比较。向前兼容是指在程序中加入新的语言,如果在旧的 JS 引擎中运行,不会导致该程序崩溃。JS 不是向前兼容的,尽管许多人希望如此,甚至错误地相信它是向前兼容的神话。

相比之下,HTML 和 CSS 是向前兼容的,但不是向后兼容的。如果你挖出一些 1995 年写的 HTML 或 CSS,它完全有可能在今天无法工作(或工作方式相同)。但是,如果你在 2010 年的浏览器中使用 2019 年的新功能,页面就不会「被破坏」,未被识别的 CSS/HTML 会被跳过,而其余的 CSS/HTML 会被正常处理。

在编程语言的设计中包含向前兼容似乎是可取的,但一般来说,这样做是不切实际的。标签 (HTML) 或样式 (CSS) 在本质上是声明性的,所以「跳过」不被承认的声明要容易得多,对其他被承认的声明的影响最小。

但是,如果一个编程语言引擎有选择地跳过它不理解的语句(甚至表达式!),就会出现混乱和非确定性,因为它不可能确保程序的后续部分不期望被跳过的部分得到处理。

尽管 JS 不是,也不可能是向前兼容的,但认识到 JS 的向后兼容是至关重要的,包括对网络的持久好处以及因此而对 JS 造成的限制和困难。

跳过差异

由于 JS 不是向前兼容的,这意味着在你能写的有效 JS 的代码和你的网站或应用程序需要支持的最古老的引擎之间总是有可能存在差异。如果你在 2016 年的引擎中运行一个使用 ES2019 功能的程序,你很有可能看到程序中断和崩溃。

如果该特性是一种新的语法,程序一般会完全无法编译和运行,通常会抛出一个语法错误。如果该特征是一个 API(如 ES6 的 Object.is(..)),程序可能会运行到某一点,但一旦遇到对未知 API 的引用,就会抛出一个运行时异常并停止。

这是否意味着 JS 开发者应该永远落后于进步的步伐,只使用他们需要支持的最古老的 JS 引擎环境的落后的代码?并不是这样!

但这确实意味着 JS 开发者需要特别注意解决这一差异。

对于新的和不兼容的语法,解决方案是转译。转译是一个社区发明的术语,用来描述使用一个工具将程序的源代码从一种形式转换为另一种形式(但仍为文本源代码)。通常,与语法有关的向前兼容问题是通过使用转译器(最常见的是使用 Babel (https://babeljs.io) 来解决)将较新的 JS 语法版本转换为等效的旧语法。

例如,一个开发人员可能会写一个代码片段:

js
if (something) {
    let x = 3;
    console.log(x);
} else {
    let x = 4;
    console.log(x);
}
if (something) {
    let x = 3;
    console.log(x);
} else {
    let x = 4;
    console.log(x);
}

这就是该应用程序的源代码中的代码。但当部署到公共网站时,Babel 转码器可能会将代码转换为这样的样子:

js
var x$0, x$1;
if (something) {
    x$0 = 3;
    console.log(x$0);
} else {
    x$1 = 4;
    console.log(x$1);
}
var x$0, x$1;
if (something) {
    x$0 = 3;
    console.log(x$0);
} else {
    x$1 = 4;
    console.log(x$1);
}

原来的片段依靠 letifelse 中创建块作用域的 x 变量,它们不会相互干扰。Babel 能产生的等效程序(只需最少的重新工作)只是选择用唯一的名字命名两个不同的变量,产生同样的不干涉结果。

注意:
let 关键字是在 ES6(2015 年)添加的。前面的转译例子只需要在应用程序需要在 ES6 之前的支持 JS 环境中运行时适用。这里的例子只是为了简单说明问题。当 ES6 还是比较新的时候,这种转译的需求是相当普遍的,但在 2020 年,需要支持 ES6 之前的环境就不那么常见了。因此,用于转换的「目标」是灵活的,只有在网站/应用程序决定停止支持某些旧的浏览器/引擎时才会使用。

你可能会问:为什么要麻烦地使用一个工具从较新的语法版本转换到较旧的版本?难道我们不能直接写两个变量而跳过使用 let 关键字吗?原因是,强烈建议开发者使用最新版本的 JS,这样他们的代码才会干净,才能最有效地传达其思想。

开发人员应该专注于编写干净的、新的语法形式,并让工具来处理产生该代码的向前兼容版本,以适合在最古老的支持的 JS 引擎环境中部署和运行。

填补差异

如果向前兼容的问题与新的语法无关,而是与最近才添加的缺失的 API 方法有关,最常见的解决方案是为那个缺失的 API 方法提供一个定义,就像旧环境已经有了它的原生定义一样。这种模式被称为 polyfill(又称 "shim")。

思考一下这段代码:

js
// getSomeRecords() 返回给我们一个 promise
// 它将获取数据
var pr = getSomeRecords();

// 当我们获取数据时,显示加载界面。
startSpinner();

pr.then(renderRecords) // 如果成功则渲染
    .catch(showError) // 如果失败显示错误
    .finally(hideSpinner); // 总是隐藏加载界面
// getSomeRecords() 返回给我们一个 promise
// 它将获取数据
var pr = getSomeRecords();

// 当我们获取数据时,显示加载界面。
startSpinner();

pr.then(renderRecords) // 如果成功则渲染
    .catch(showError) // 如果失败显示错误
    .finally(hideSpinner); // 总是隐藏加载界面

这段代码使用了 ES2019,即 promise 原型上的 finally(..) 方法。如果在 ES2019 之前的环境中使用此代码,则 finally(..) 方法将不存在,并且会发生错误。

在 ES2019 之前的环境中, finally(..) 的 polyfill 可以是这样的:

js
if (!Promise.prototype.finally) {
    Promise.prototype.finally = function f(fn) {
        return this.then(
            function t(v) {
                return Promise.resolve(fn()).then(function t() {
                    return v;
                });
            },
            function c(e) {
                return Promise.resolve(fn()).then(function t() {
                    throw e;
                });
            },
        );
    };
}
if (!Promise.prototype.finally) {
    Promise.prototype.finally = function f(fn) {
        return this.then(
            function t(v) {
                return Promise.resolve(fn()).then(function t() {
                    return v;
                });
            },
            function c(e) {
                return Promise.resolve(fn()).then(function t() {
                    throw e;
                });
            },
        );
    };
}
警告:
这只是对finally(..)的基本(不完全符合规范)polyfill 的一个简单说明。不要在你的代码中使用这个 polyfill;应尽可能地使用强大的官方 polyfill,例如 ES-Shim 中的 polyfills/shims。

if 语句通过防止它在任何 JS 引擎已经定义了该方法的环境下运行来保护 polyfill 定义。在旧的环境中,polyfill 被定义,但在新的环境中,if 语句被悄悄地跳过。

像 Babel 这样的转译器通常会检测你的代码需要哪些 polyfills,并自动为你提供。但有时你可能需要明确地包含或定义它们,这与我们刚才看的片段的工作原理类似。

始终使用最合适的功能来编写代码,以有效地传达其想法和意图。一般来说,这意味着使用最新的稳定 JS 版本。避免因为试图手动调整语法/API 的差距而对代码的可读性产生负面影响。这就是工具的作用!

转译和 polyfilling 是两种非常有效的技术,可以解决使用语言中最新的稳定功能的代码与网站或应用程序仍然需要支持的旧环境之间的差距。由于 JS 不会停止改进,这种差距将永远不会消失。这两种技术都应该作为每个 JS 项目的生产链的标准部分来使用和推广。

「解释」的本质

对于用 JS 编写的代码,有一个争论已久的问题:它是一个解释的脚本还是一个编译的程序?大多数人的看法似乎是 JS 是一种解释型(脚本)语言。但事实比这更复杂。

在编程语言的大部分历史中,「解释」语言和「脚本」语言一直被看不起,认为比它们的编译语言要差。造成这种争论的原因很多,包括认为缺乏性能优化,以及不喜欢某些语言的特点,例如脚本语言通常使用动态类型,而不是「更成熟」的静态类型语言。

被认为是「编译」的语言通常会产生一个可移植的(二进制)程序表示,然后再分发执行。由于我们没有真正观察到 JS 的那种模式(我们分发源代码,而不是二进制形式),许多人声称这使 JS 失去了分类的资格。实际上,在过去的几十年里,程序的「可执行」形式的分发模式已经变得多种多样,而且也不那么重要了;对于目前的问题来说,程序的哪种形式被传递出去已经不那么重要了。

这些误导性的说法和批评应该被搁置。清楚地了解 JS 是被解释还是被编译的真正原因,与如何处理错误的性质有关。

从历史上看,脚本语言或解释语言的执行方式一般是自上而下、逐行执行的;在开始执行之前,通常没有对程序进行初步的处理(见图 1)。

Interpreting a script to execute it
图 1:解释型/脚本的执行


在脚本语言或解释语言中,程序第 5 行的错误在第 1 至第 4 行已经执行后才会被发现。值得注意的是,第 5 行的错误可能是由于运行时的条件造成的,例如某些变量或数值不适合操作,也可能是由于该行的语句/命令格式不正确。根据上下文,将错误处理推迟到错误发生的那一行可能是一个理想的或不理想的效果。

如图 2 所示,与那些在执行之前确实要经过一个处理步骤(通常称为解析)的语言进行比较:

Parsing, compiling, and executing a program
图 2: 解析 + 编译 + 执行


在这种处理模式下,第 5 行的无效命令(比如语法错误)将在解析阶段被捕获,在任何执行开始之前,程序都不会运行。对于捕捉语法(或其他「静态」)错误,一般来说,最好是在任何部分执行之前知道它们。

那么,「解析」语言与「编译」语言有什么共同点?首先,所有的编译语言都是经过解析的。所以,解析过的语言在走向编译的道路上已经走了很远。在经典的编译理论中,解析之后剩下的最后一步是代码生成:产生一个可执行的形式。

常见的情况下,一旦任何源程序被完全解析,它的后续执行将以某种形式或方式包括从程序的解析形式,通常称为抽象语法树 (AST) — 到可执行形式的翻译。

换句话说,解析语言通常也在执行前进行代码生成,所以说在认知中,它们是编译语言也不为过。

JS 源代码在执行前被解析。该规范要求如此,因为它要求在代码开始执行之前报告「早期错误」,包括代码中系统确定的错误,如重复的参数名称。如果不对代码进行解析,这些错误就无法被识别。

那么,JS 是一种解释性语言,但它是编译过的吗?

答案是接近于「是」而不是「不是」。被解析的 JS 被转换为优化的(二进制)形式,该「代码」随后被执行(图 2);引擎在完成所有艰苦的解析工作后,通常不会切换回逐行执行(如图 1)模式 — 大多数语言/引擎不会这样做,因为这样做的效率非常低。

具体来说,这种「编译」产生一个二进制字节码(某种意义上),然后交给「JS 虚拟机」执行。有些人喜欢说这个虚拟机是「解释」字节码的。但这意味着 Java,以及其他十几种 JVM 驱动的语言,都是解释的,而不是编译的。当然,这与 Java 或其他语言是编译语言的典型论断相矛盾。

有趣的是,虽然 Java 和 JavaScript 是非常不同的语言,但解释/编译的问题在它们之间却有着相当密切的关系。

另一个问题是,JS 引擎可以对生成的代码进行多次 JIT(即时)处理/优化(解析后),这也可以合理地被称为「编译」或「解释」,这取决于个人观点。实际上,在 JS 引擎的内部,这是一个非常复杂的情况。

那么,这些琐碎的细节可以归结为什么呢?退一步讲,考虑一个 JS 程序的整个流程:

  1. 当一个程序离开开发者的编辑器后,它被 Babel 转译,然后被 Webpack 打包(也许还有其他的构建过程),然后以这种非常不同的形式交付给 JS 引擎。
  2. JS 引擎将代码解析成 AST。
  3. 然后,引擎将 AST 转换为一种字节码,即中介码(IR),然后由优化的 JIT 编译器进一步完善/转换。
  4. 最后,JS VM 执行该程序。

为了更直观地了解这些步骤:

Steps of JS compilation and execution
图 3:解析、编译和执行 JS


JS 的处理方式是更像一个解释的、逐行的脚本,如图 1,还是更像一个编译的语言,在执行前先进行一到几次处理(如图 2 和 3)?

我认为显而易见,在认知中,如果不是在现实中:JS 是一种编译语言

再说一遍,这很重要,因为 JS 是被编译的,在我们的代码被执行之前,我们就被告知了静态错误(如错误的语法)。这与我们在传统的「脚本」程序中得到的互动模式有很大的不同,可以说是更有帮助的!

Web Assembly (WASM)

推动 JS 发展的一个主要问题是性能,包括 JS 的解析/编译速度和编译后的代码的执行速度。

在 2013 年,来自 Mozilla 火狐的工程师展示了将虚幻 3 游戏引擎从 C 语言移植到 JS 的过程。这段代码能够以 60fps 的性能在浏览器的 JS 引擎中运行,是以 JS 引擎能够进行的一系列优化为前提的,因为虚幻引擎的 JS 版本的代码使用了名为 "ASM.js" 的一种倾向于 JS 语言子集的代码风格。

这个子集是有效的 JS,其编写方式在正常编码中有些不常见,但它向引擎发出了某些重要的类型化信息,使其能够进行关键的优化。ASM.js 是作为解决 JS 运行时性能的压力的一种方式被引入的。

但需要注意的是,ASM.js 从来就不是由开发人员编写的代码,而是一个从另一种语言(如 C)转译过来的程序的代表,这些「注释」是由工具软件自动插入的。

在 ASM.js 证明了工具创建的程序版本可以被 JS 引擎更有效地处理的有效性几年后,另一组工程师(最初也来自 Mozilla)发布了 Web Assembly (WASM)。

WASM 与 ASM.js 类似,它的初衷是为非 JS 程序(C 等)提供一个路径,将其转换为可以在 JS 引擎中运行的形式。与 ASM.js 不同的是,WASM 选择在程序执行前额外绕过 JS 解析/编译中的一些固有延迟,以一种完全不同于 JS 的形式表示程序。

WASM 是一种更类似于汇编的表示形式(名字的由来),可以被 JS 引擎处理,跳过 JS 引擎通常进行的解析/编译。WASM 目标程序的解析/编译是提前进行的 (AOT);分发的是一个二进制打包的程序,JS 引擎可以用最少的处理来执行。

WASM 的最初动机显然是潜在的性能改进。虽然这仍然是一个重点,但 WASM 的另一个动机是希望为网络平台带来更多非 JS 语言的平等性。例如,如果像 Go 这样的语言支持线程编程,但 JS(语言)不支持,那么 WASM 就有可能将这样的 Go 程序转换为 JS 引擎能够理解的形式,而不需要 JS 语言本身的线程功能。

换句话说,WASM 减轻了为 JS 添加功能的压力,这些功能主要/完全是为了被其他语言的转写程序使用。这意味着 JS 功能的开发可以(由 TC39)进行判断,而不被其他语言生态系统的兴趣/需求所歪曲,同时仍然让这些语言在网络上有一个可行的路径。

关于 WASM 的另一个正在出现有趣的用途,甚至与网络(web, WASM 中的 w)没有直接关系。WASM 正在发展成为一种跨平台的虚拟机(VM),程序可以被编译一次并在各种不同的系统环境中运行。

所以,WASM 并不只适用于网络,而且 WASM 也不是 JS。具有讽刺意味的是,尽管 WASM 在 JS 引擎中运行,但 JS 语言是最不适合用于 WASM 程序源的语言之一,因为 WASM 严重依赖静态类型信息。即使是 TypeScript (TS) — JS + 静态类型也不太适合(就目前而言)转译到 WASM,尽管像 AssemblyScript 这样的语言变体正试图弥补 JS/TS 和 WASM 之间的差距。

这本书不是关于 WASM 的,所以我不会花更多的时间来讨论它,只是想提出最后一点。有些人认为 WASM 指向了 JS 从网络中被删除或最小化的未来。这些人通常对 JS 怀有恶感,并希望有其他语言 — 任何其他语言!来取代它。由于 WASM 允许其他语言在 JS 引擎中运行,从表面上看,这并不是一个完全幻想的童话故事。

但让我简单地说明一下: WASM 不会取代 JS。WASM 极大地增强了网络(包括 JS)所能实现的功能。这是一件伟大的事情,与一些人是否会把它作为逃避编写 JS 的借口完全不相干。

严格模式的讨论

早在 2009 年,随着 ES5 的发布,JS 增加了严格模式作为鼓励更好的 JS 程序的选择机制。

严格模式的好处远远大于成本,但旧习难改,现有(又称「遗留」)代码库的惯性真的很难改变。因此,可悲的是,10 多年过去了,严格模式的可选性意味着它仍然不是 JS 程序员的默认模式。

为什么是严格模式?严格模式不应该被认为是对你不能做什么的限制,而应该被认为是对做事的最佳方式的指导,这样 JS 引擎就有了优化和有效运行代码的最佳机会。大多数 JS 代码都是由开发人员组成的团队完成的,所以严格模式的 严格性(以及像 linters 这样的工具!)往往有助于代码的协作,避免在非严格模式下出现的一些更有问题的错误。

大多数严格模式的控制都是以早期错误的形式出现的,也就是说,这些错误不是严格意义上的语法错误,但在编译时(代码运行前)仍会被抛出。例如,严格模式不允许将两个函数参数命名为相同的参数,这将导致一个早期错误。其他一些严格模式的控制只有在运行时才能观察到,比如 this 默认为 undefined 而不是全局对象。

最好的心态是,严格模式就像一个提醒你 JS 应该如何编写才能获得最高的质量和最好的性能的检查工具,而不是像一个只想违抗父母告诉他们不要做的事情的孩子一样,与严格模式对抗和争论。如果你发现自己感到束手束脚,试图绕过严格模式工作,这应该是一个响亮的红色警告标志,说明你需要后退并重新思考整个方法。

严格模式是通过一个特殊的编译器指令来开启每个文件的(除了注释/空白以外,在它前面不允许有任何东西):

js
// 在使用 use strict 之前
// 只允许使用空格和注释
"use strict";
// 文件的其余部分以严格模式运行
// 在使用 use strict 之前
// 只允许使用空格和注释
"use strict";
// 文件的其余部分以严格模式运行
警告:
需要注意的是,即使在严格模式编译器指令之前出现一个游离的 ;,也会使编译器指令失去作用;但不会出现错误,因为在语句位置有一个字符串字面表达式是有效的 JS,不过它也会默默地开启严格模式!

严格模式也可以在每个函数内开启,对其周围环境的规则完全相同:

js
function someOperations() {
    // 空格和注释在这里都可以
    "use strict";

    // 这里的所有代码将在严格模式下运行
}
function someOperations() {
    // 空格和注释在这里都可以
    "use strict";

    // 这里的所有代码将在严格模式下运行
}

有趣的是,如果一个文件开启了严格模式,那么函数级的严格模式是不允许的。所以你必须二选一。

唯一使用作用域模式的理由是,当你正在转换一个现有的非严格模式的程序文件,并且需要在一段时间内一点一点地进行修改时,才会对严格模式使用逐个功能的方法。否则,对整个文件/程序简单地打开严格模式会好得多。

很多人都想知道,是否会有一天 JS 将严格模式作为默认模式?答案是,几乎肯定不会。正如我们之前讨论的关于向后兼容性的问题,如果 JS 引擎的更新开始假定代码是严格模式,即使它没有被标记为严格模式,那么这些代码就有可能因为严格模式的控制而被破坏。

然而,有几个因素降低了严格模式这种非默认的「隐蔽性」对未来的影响。

首先,几乎所有转译的代码最终都是严格模式,即使原始的源代码没有这样写。生产中的大多数 JS 代码都被转译了,所以这意味着大多数 JS 已经遵守了严格模式。撤销这一假设是有可能的,但你必须自己做才行,所以这是几乎不可能的。

此外,一个广泛的转变正在发生,即更多/大多数新的 JS 代码正在使用 ES6 模块格式编写。ES6 模块假定为严格模式,因此此类文件中的所有代码都自动默认为严格模式。

综合来看,严格模式在很大程度上是事实上的默认模式,尽管从技术角度来看它实际上不是默认模式。

定义

JS 是 ECMAScript 标准的一个实现(截至本文撰写时为 ES2019 版本),由 TC39 委员会指导,ECMA 主持。它在浏览器和其他 JS 环境中运行,如 Node.js。

JS 是一种多范式语言,这意味着其语法和功能允许开发者混合和匹配(巧捷万端!)来自各种主要范式的概念,如面向过程、面向对象 (OO/classes) 和函数式 (FP)。

JS 是一种编译语言,意味着工具(包括 JS 引擎)在程序执行前会对其进行处理和验证(抛出存在的错误!)。

现在我们的语言已经定义了,让我们开始了解它的内在和外在。


  1. 'Java is to JavaScript as ham is to hamster.' 英文使用的是火腿 (ham) 和仓鼠 (hamster) 来比喻的。 ↩︎

  2. ECMAScript 2019 语言规范,附录 B:网络浏览器的附加 ECMAScript 功能https://www.ecma-international.org/ecma-262/10.0/#sec-additional-ecmascript-features-for-web-browsers (截至本文撰写时 2020年1月最新版) ↩︎