跳到主要内容

JavaScript 是怎么运行起来的

看起来这个问题最简单的答案是“解析 → 运行”。然而对于一门语言来说,“引擎解释与运行”都是最终结果的表象,真正处于原点的问题其实是:“JavaScript 运行的是什么?”。

从编译原理角度出发,语言有文法和语义,其中文法又分词法(Tokens,lexical grammar)和语法。要把这些串联起来的话,表面上,它是讲引用和执行过程;在底下,讲的是引擎对“JavaScript 是什么”的理解。

词法

文法是编译原理中对语言写法的一种规定,文法分为词法和语法。

词法的最小语义单元是:token,翻译为“标记”或“词”。从字符串到词的整个过程是没有结构的,只要符合词的规则,就构成词。词法分析技术上可以使用状态机或者正则表达式来进行。

一般来说,词法设计不会包含冲突。不过,JavaScript 中有一些特别之处:

  • 除法和正则表达式冲突问题

同样是斜杠运算符///= 是除法运算符,但是两个斜杠括起来就是正则表达式 /abc/

  • 字符串模板和 } 冲突问题

字符串模板语Hello, ${name},理论上,${}内部可以放任何 JavaScript 代码,但是因为这些代码最后需要以}结尾,所以,这部分代码不允许出现}运算符,但是有例外情况:

console.log(`Hello, ${function () {}}`);

输入分类

词法分析过程,JavaScript 源码文本会被从左到右扫描,并被转换成一系列输入元素:

  • WhiteSpace 空白字符
  • LineTerminator 换行符
  • Comment 注释
  • Token 词
    • IdentifierName 标识符名称,例如定义的变量名或关键字
    • Punctuator 符号,运算符和大括号等符号
    • NumbericLiteral 数字直接量,就是数字
    • StringLiteral 字符串直接量,就是直接用单引号或双引号引起来的字符串
    • Template 字符串模板,用反引号 ` 括起来的直接量

注:直接量(literal),就是程序中能直接使用的数据值。其他的可以具体参考一些表格。

语法

任何语言的核心都必然会描述这门语言最基本的工作原理,而描述的内容通常都要涉及这门语言的语法、操作符、数据类型、内置功能等用于构建复杂解决方案的基本概念。如前所述,ECMA-262 通过叫做 ECMAScript 的“伪语言”为我们描述了 JavaScript 的所有这些基本概念。

Javascript 语法

从文本到脚本

从更基础层面的线索讲起。JavaScript 的所谓“脚本代码”,在引擎层面看来,首先就是一段文本。在性质上,装载 a.js 执行与eval('...')执行并没有区别,它们的执行对象都被理解为一个“字符串”,也就是字符串这一概念本身所表示的、所谓的“字符序列”。

在字符序列这个层面上,最简单和最经济的处理逻辑是正向遍历,这也是为什么“语句解析器”的开发者总是希望“语言的设计者”能让他们“一次性地、不需要回归地”解析代码的原因。

回归(也就是查看之前“被 parser 过的代码”)就意味着解析器需要暂存旧数据,无法将解析器做得足够简洁,进而无法将解析器放在小存储的环境中。根本上来说,JavaScript 解析引擎是“逐字符”地处理代码文本的。

JavaScript 从“逐字符处理”得到的引擎可以理解的对象,称为记号(Tokens)。

一个记号是没有语义的,记号既可以是语言能识别的,也可以是语言不能识别的。唯有把这二者同时纳入语言范畴,那么这个语言才能识别所谓的“语法错误”。

记号,要么是可识别的,要么是不能识别的。并且,它们必须同时纳入语言范畴。这个“必须同时纳入”,决定了二者不是相互孤立的元素,而是同一体系下的东西,也就是所谓的“体系的完整性”。

引用与静态语言的处理

我们来看看 JavaScript 运行机制的表面线索。

引用(References)是静态语言与引擎之间的桥梁,它是 ECMAScript 规范中最大的一个挑战,你理解了“规范层面的引用(References)”,也就基本上理解了 ECMAScript 规范整个的叙述框架。这个框架的核心在于——ECMAScript 的目的是描述“引擎如何实现”,而不是“描述语言是什么”。

规范层面中的引用与引擎的核心设计有关。

在 JavaScript 语言层面,它希望引擎是一个执行器,更具体的描述是:引擎的核心是一个表达式计算的、连续的执行过程。表达式计算是整个 JavaScript 语言中最核心的预设,一旦超出这个预设,JavaScript 语言的结构体系就崩溃了。

所以,本质上来说,JavaScript 的所谓“语句能执行”也是一个或一组表达式计算过程,而且所有的计算都必须能描述成一个基本的模式:opCode -> opData,也就是用操作符去处理操作数。

这个相信你也明白了,这回到了我们计算理论最初的原点,是我们学习计算机这门课程最初的那个设定:计算实现的就是”计算求解“的过程。它的另一个公式化的表达就是著名的”算法 + 数据结构 = 程序“。

在这个概念集合中,最关键的点在于“执行过程最终是表达式计算”。因此,语句执行也是表达式计算,函数调用也是表达式计算,各种特殊执行结果还是表达式计算。

这些“计算”总会有一个返回值,是什么呢?

你可以参考文章里的这张图(vlaue =》 value),它说明了 JavaScript 中最核心的两种执行过程(它们都被称为 evaluating)是如何最终被统一的。

在语句执行的层面,它返回一个语句的完成状态,这个状态中包括了一个“value”域,它必须且必然会是 JavaScript 语言理解的类型,也就是 typeof() 所识别的所有的值。这样一来,任何“语句”“代码”或“代码文本”就都可以被执行了,并且都可以使用 console.log() 输出结果给你了。

这其中最重要的一件事是,在任何语句执行并得到结果时,如果它“当时”是一个所谓的“引用”,那么这个引用就必须先调用“GetValue(x)”来得到值,然后放到这个“value”域中去。因为“引用”是一个规范层面的东西,它不是 JavaScript 语言能理解的,也无法展示给开发者。

最后,ECMAScript 约定:可以在“value”域中放上 Empty,这表明语句执行“没有值”。它能表明有值,也能表明无值,仍然是“概念完整性”。

而到了表达式执行时(注意函数调用也是表达式执行的一种),这个过程又被重来了一回。不过表达式执行会返回两个东西:它要么直接返回一个“上面的完成结果所理解的值”,要么返回一个包含这样的值的“引用”。

在表达式执行这个体系里面,“没有东西”是所谓的“不可发现的引用(UnresolvableReference)”。所以,完整的概念集是:值(value)、引用(Reference)和不可发现的引用(UnresolvableReference)。一个不可发现的引用是能被处理的,例如 delete x,或者 typeof x。所有“能处理引用的”运算符都能处理它。

那么,为什么要有“引用”这么个东西呢?

你想想,如果没有引用,你就得将所有的东西都直接当成一个被处理的对象,例如用 1G 的内存来处理一个 1G 文本的记号。这显然不可行。我们可以用一个简单的法子来解决,就是加一个指针指向它,在不需要访问它的“内容”时,我们就访问这个指针好了。而引用,也就是所有在“不访问内容”的情况下,用于指向这个内容的一个结构。它叫什么名字其实都好、都行,重点的是:

  1. 它代表这个东西,r(x)。
  2. 它包含这个东西,所以可以 x = GetValue®。

所以本质上,引用还是指向值、代表值的一个概念,它只是“获得值的访问能力”的一个途径。最终的结果仍然指向原点:计算值、求值。