编译原理

尽管 JavaScript 经常被归类为“动态”或“解释执行”的语言,但实际上它是一门编译语言。JavaScript 引擎进行的编译步骤和传统编译语言非常相似,但有些地方可能比预想的要复杂。

传统编译流程:

  • 分词/此法分析(Tokenizing/Lexing)

    这个过程会将有字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如:var a = 2;这段程序通常会被分解成词法单元:vara=2;空格是否会被当成词法单元,取决于空格在这门语言种是否具有意义。

  • 解析/语法分析(Parsing)

    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。

    var a = 2的 AST 为:

    VariableDeclaration
    --Identifier = a
    --AssignmentExpression
    ----NumericLiteral = 2
    
  • 代码生成

    将 AST 转换为可执行代码的过程被称为代码生成。这个过程与语言、目标平台等息息相关。简单来说就是将 AST 转换为一组机器指令,用来创建一个叫做 a 的变量(包括分配内存等),并将值 2 存储在 a 中。

JavaScript 的编译

JavaScript 的编译由 JavaScript 引擎来负责(包括执行)。编译通常由三个部分组成:

  • 引擎:从头到尾负责整个 JavaScript 的编译以及执行;
  • 编译器:负责语法分析以及代码生成;
  • 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

在我们看来var a = 2;这是一个普通的变量声明。而在 JavaScript 引擎看来这里有两个完全不同的声明:

  1. var a,编译器会寻找当前作用域中是否有同样的声明。如果有,则忽略该声明,并继续编译;否则它会在当前作用域(全局/函数作用域)的集合中声明一个新的变量,并命名为 a。
  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码用来处理赋值(a = 2)操作。引擎会在当前作用域中查找变量 a。如果能找到,则为其赋值;如果找不到,则继续向上查找(作用域链)。

由于编译的第一步操作会寻找所有的var关键词声明,无论它在代码的什么位置,都会声明好。在代码真正运行时,所有声明都已经声明好了,哪怕它是在其他操作的下面,都可以直接进行。这就是var关键词的声明提升。

a = 2;
console.log(a);
var a;

LHS 和 RHS

编译器在编译过程的第二步生成了代码,引擎执行它时,就会查找变量 a 来判断它是否已经声明过。但引擎如何进行查找,影响最终查找的结果。

LHS 和 RHS 分别对应的是左侧查找与右侧查找。左右两侧分别代表一个赋值操作的左侧和右侧。也就说,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。

例如:a = 2,这里进行的就是 LHS 查询。这里不关心 a 的当前值,只想找到 a 并为其赋一个值。

而:console.log(a),这里进行的是 RHS 查询。因为这里需要取到 a 的值,而不是为其赋值。

“赋值操作的左侧和右侧”并不一定代表就是=的左右两侧,赋值操作还有其他多种形式。因此,可以在概念上理解为“查询被赋值的目标(LHS)”以及”查询目标的值(RHS)“。

小测验:

寻找 LHS 查询(3处)以及 RHS 查询(4处)。

function foo(a) {
  var b = a;
  return a + b;
}
var c = foo(2);

LHS:

  • var c = foo(...):为变量 c 赋值
  • foo(2):传递参数时,为形参 a 赋值 2
  • var b = a:为变量 b 赋值

RHS:

  • var c = foo(...):查询foo()
  • var b = a:(为变量 b 赋值时)取得 a 的值
  • return a + b:取得 a 与 b(两次)

异常

通过详细的了解异常可以准确的确定发生的问题所在。

在 LHS 查询时,如果到作用域顶部还没有查询到声明,则作用域会热心的帮我们(隐式)创建一个全局变量(非严格模式下)。

而在 RHS 查询时,如果在作用域顶部还没有查询到声明,就会抛出一个 ReferenceError 异常。

在严格模式下,LHS 如果没有找到声明,引擎会抛出一个和 RHS 类似的 ReferenceError 异常。

无论是 LHS 还是 RHS 都是查询一个引用,而没有查询到对应的引用时,就会得到(引用)ReferenceError 异常。

接下来,如果 RHS 查询到了一个变量,但是我们尝试对这个变量的值进行不合理的操作。例如对一个非函数进行函数调用,或者对对象中不存在的属性进行引用。那么引擎会抛出另外一个异常,叫做 TypeError。

闭包

闭包是基于词法作用域书写代码时所产生的自然结果。闭包的主要定义:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

JavaScript 使用的是词法作用域模型,另一种作用域模型是动态作用域。

仔细来看,闭包的主要定义有:

  • 函数记住并可以访问所在的词法作用域
  • 在当前词法作用域之外执行也能继续访问所在的词法作用域

来看一个例子:

function foo() {
  const a = 123;

  function bar() {
    console.log(a);
  }

  bar();
}
foo();

这段代码看起来好像符合闭包的一部分定义,虽然bar()函数并没有脱离当前的词法作用域执行。但是它依然记住了foo()的词法作用域,并能访问。

它确实满足闭包定义的一部分(很重要的一部分),从技术上讲,也许是,但并不能完全断定这就是闭包。通常我们所见到的与认为闭包的情况就是满足所有定义的时候:

function foo() {
  const a = 321;

  function bar() {
    console.log(a);
  }

  return bar;
}
// 同理
// foo()();
const baz = foo()
baz();

因为垃圾收集机制,当一个函数执行结束后,通常它的整个内部作用域会被销毁。当我们的foo()函数执行结束后,看上去它的内容不会再被使用,所以很自然的考虑会被回收。

但闭包的神奇之处就在这里,它会阻止这一切的发生。当barreturn出去之后,在其词法作用域的外部依然能够访问foo()的内部作用域。bar依然持有对该作用域的引用,这个引用就叫作闭包。

这也是经常见到说闭包会影响性能的主要原因。某些情况下,它确实会影响到性能,例如过度多的返回本不需要的函数,甚至是嵌套。这会导致本不需要的作用域没有被回收。

常见的闭包

上述将一个函数return出来的案例是最常见的闭包案例。但在我们的代码中,也有些其他非常常见的闭包。不过平时可能没有太过去注意它。

先来回顾一下定义:

无论通过何种手段将内部函数传递到词法作用域之外,它都会保留对改内部词法作用域的引用,无论在何处执行这个函数都会使其闭包。

function waitAMinute(msg: string) {
  setTimeout(() => {
    console.log(msg);
  }, 1000);
}
waitAMinute('嘤嘤嘤');
function btnClick(selector: string, msg: string) {
  $(selector).click(() => {
    alert(msg);
  });
}
btnClick('#btn_1', 'hah');
btnClick('#btn_2', 'got you');

版权声明:本文为defectink原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/defectink/p/javascript-compile-and-closure.html