JavaScript 编译原理、编译器、引擎及作用域

首先,JavaScript确实是一门编译型语言,与C等典型编译型语言相比,区别在于JavaScript的编译过程(通常)是在实际执行前进行的,而且并不会产生可移植的编译结果。

一、编译过程

通常的编译过程:

通常的编译过程分为以下三步

1. 分词与词法分析:

把输入的字符串分解为一些对编程语言有意义的代码块(词法单元)。

2. 解析与语法分析:

将上一步的词法单元集合分析并最终转换为一个由元素逐级嵌套所组成的代表了程序语法结构的树,称为抽象语法树(Abstract Syntax Tree,AST)

3. 代码生成:

将上一步的AST转换为可执行代码

JavaScript的编译过程:

JavaScript具体的编译过程如下

1. 分词/词法分析:

这个过程会将字符串分割为有意义的代码块,这些代码块称之为词法单元。例如变量的声明:

var a = 2;

这行代码会被分为以下词法单元:vara=2(空格算不算词法单元取决于空格对于该编程语言是否具有意义);这些零散的词法单元会组成一个词法单元流(数组)进行解析。

2. 解析/与法分析:

这个过程会将词法单元流转换成一棵抽象语法树(Abstract Syntax Tree,AST)在线解析工具
"var a = 2;"的词法单元流就会被解析为下面的AST:

`var a = 2;`对应的AST

3. 代码生成:

将AST转化为可执行的代码。

二、JavaScript编译特点:

JavaScript引擎中的编译器做的事情与上述类似,但是由于编译就在代码执行前,所以JavaScript编译执行效率就比一般静态语言敏感得多,故而也非常复杂。
JavaScript引擎在这部分做了许多优化:

  • 一是对语法分析和代码生成阶段进行优化(例如针对冗余元素进行优化),目的是提高编译后的执行效率。
  • 二是对编译过程进行优化(如JIT,延迟编译或者重编译),目的是缩短编译过程,保证性能最佳。

JavaScript 中的编译器、引擎和作用域

  • 编译器:负责语法分析和代码生成。
  • 引擎:负责整个过程中JavaScript的编译及执行过程。浏览器不同,其引擎也不同,比如Chrome采用的是v8,Safari采用的是SquirrelFish Extreme。
  • 作用域:负责收集并维护所有的标识符(变量)简析JavaScript中的作用域与作用域链

JavaScript编译过程具体分析

1. 一个具体的例子:

var a = 2;

首先进行词法分析,然后将词法单元流交给编译器生成AST,再有编译器生成可执行的代码

  1. 编译器遇到 var a;,编译器询问:同一作用域集存在同名变量 ? 忽略该声明,继续编译 : 要求作用域在当前作用域的集合生命一个名为a的新变量。

  2. 编译器会为引擎的运行生成一系列代码,这些代码用于为变量a进行赋值操作。引擎会询问:当前作用域存在这个变量 ? 进行赋值操作 : 查找这个变量(从当前作用域向上查找,直到全局作用域,如果还是没有,就会抛出一个异常)。

  3. LHSRHS,当引擎执行编译器给的代码(赋值操作)时,会通过查找这个变量来判断这个变量是否已经声明,这个过程需要作用域的协助,而查找的方式分为两种:LHS(“赋值操作的目标是谁”)和RHS(”谁是赋值操作的源头“)。

    • LHS:赋值操作的左侧,试图查找到变量的容器本身,从而可以对其赋值,即找到复制操作的目标。

    • RHS:另外一种查找,可以简单理解为复制操作的右侧,其查找目标为取到目标的源值,即找到这个变量具体的值而非容器。
      LHS与RHS举例

      var a;  //RHS引用
      a = 2;  //LHS引用
      alert(a);  //RHS引用
      /** 这段代码块既有RHS引用也有LHS引用,
        * 2被当作函数参数传递给foo()时,
        * 2会被分配给变量a(a = 2);
        */
      function foo(a){
        alert(a);
      }
      foo(2);
      

    区分RHS和LHS也很重要,尤其分析异常时。例如下面:

     function foo(a){
       alert(a + b); 
       b = a;
     }
     foo(2);
    

    第一次对b进行RHS查询会查询不到这个变量,因为它是一个未声明的变量,在所有作用域都无法找到var b;;此时引擎会抛出一个异常(ReferenceError)。在非严格模式下,当引擎进行LHS查询查询不到某个变量时,全局作用域会创建一个同名的变量交给引擎,当然这个变量具有全局作用域;而在严格模式下,引擎会抛出ReferenceError异常。总结一下就是:

    • RHS未找到:引擎会抛出错误RefrenceError
    • LHS未找到:引擎(或引擎中的编译器)会帮你在顶层作用域声明一个具有该名称的变量。(严格模式除外)。

    举个例子:

    var a;//LHS 寻找a,未找到,通知作用域声明一个新变量,命名为a
    a=2;//LHS 找到a并给其赋值2
    console.log(a);//RHS找到a的值2,并将其输出
    

2. 关于词法作用域:

JavaScript其根据一套规则来管理变量的查找与引用,词法作用域就是其使用的规则,在编译器进行词法化时,会根据你写代码时将变量和块作用域写在哪里,来决定规则的内容。这其中又包含了块作用域这个概念,不展开讲,只要记住ES6之前没有块作用域,只有函数有作用域,即:函数内部是一个独立的块作用域。(有个特例:catch语句块内也是独立的作用域。)

3. 关于变量提升:

明白了编译器和引擎执行之间的分工,其实你应该就不会觉得变量提升是如此之诡异了,因为引擎拿到代码的时候,编译器已经做了一些转换,编译器干嘛要干这个事情?因为它要在第一步就找到所有的声明,并且用合适的作用域将他们关联起来,这也正是词法作用域的核心。表现为: 包括变量和函数在内的所有声明都会在当前块作用域内被首先处理,即类似于提升到最前面声明,但是复制处理操作因为是在执行阶段,因此编译阶段他们原地待命等待执行。

  • 变量和函数在内的声明都在任何代码执行前被处理。声明操作在编译阶段时进行的,而赋值操作是在等到执行阶段才执行。

    //代码块1
    var a = 2;
    alert(a);  //  输出2
    //代码块2 
    b = 2;
    var b;
    alert(b);  //输出2
    //代码块3
    alert(c);  //输出undefined
    var c = 2;
    //代码块4
    var d;
    alert(d); //输出undefined
    d = 2;
    

    代码块2,4等价于代码块1,3(除了变量名不同,内存地址不同);这个过程就好像变量和函数声明的代码被移动到了最上面,这个过程就叫提升

  • 函数声明可以提升,函数表达式不能提升

    //函数声明可以提升
    foo();  // 输出2;
    function foo(){
      alert(2); 
    }
    //函数表达式不可提升
    bar();  // TypeError
    var bar = function f1(){
      alert(2);
    }
    
  • 函数声明优先于变量声明提升,出现在后面的函数声明可以覆盖之前的声明

    foo();  // 输出3
    function foo(){
      alert(1);
    }
    var foo = function bar(){
      alert(2);
    }
    function foo(){
      alert(3);
    }
    

三、三兄弟合作

第一版

下面我们以一个最简单的例子var a = 2;来进行分析:

  1. 编译器出马,先进行词法分析,将该赋值操作拆分:var a;/a=2;。第一步 var a,编译器可以处理,他会先询问变量管家——作用域:存在一个该名称的变量 ? 继续编译 : 通知作用域声明一个新变量,命名为a
  2. 编译器继续为引擎进行代码生成,这些代码主要用来处理a=2这个赋值操作。
  3. 引擎拿到可执行代码,然后询问作用域:当前有一个叫a的变量吗 ? 使用这个变量,赋值给他 : 继续往上级作用域查找。如果到根作用域仍然找不到,引擎直接报错抛异常。

第二版

有了上面的基础知识,我们把三兄弟的合作再细化一下,例子也升级一下,用上面赋值并输出的例子。

  1. 编译器:作用域,我需要对a进行LHS查找,你见过么?
  2. 作用域:我这找到根都没看到啊,要不咱声明一个吧!
  3. 编译器:好,建好了,那我生成代码了,引擎,给你你要的代码。
  4. 引擎:收到,咦,需要一个a啊,作用域,帮我LHS找一下有没有?
  5. 作用域: 找到了,编译器已经帮忙声明了。
  6. 引擎:好的,那我对它赋值。
  7. 引擎:作用域,不要意思,我碰到一个console,需要RHS引用。
  8. 作用域: 找到了,是个内置对象,拿走不谢。
  9. 引擎: 好的作用域,对了能在帮我确认一下a的RHS么?
  10. 作用域:确认好了,没变,拿去用吧,他的值是2
  11. 引擎:好咧,我把2传递给log(..)

四、关于JavaScript引擎

讲讲虚拟机与引擎

1. 虚拟机是什么?

JavaScript通常被称为虚拟机。虚拟机是指在既定的计算机系统中运用软件模拟的具有完整硬件系统功能的计算机系统。根据虚拟机的模拟以及代替现实计算机的精确度,它们被分为了很多种类型。其中流程虚拟机的功能性较低,只能运行一道程序或流程。比如,Wine就是一种可以让你在Linux计算机上运行Windows运用程序的流程虚拟机,但它并不能提供一套完整的Windows操作系统。
JavaScript引擎是一种为解释和执行JavaScript代码而专门设计的流程虚拟机。
注解:将通过布局网页使浏览器运行的布局引擎和解释,执行代码的较低水平的JavaScript引擎作对比是相当重要的。以下是一些比较到位的阐述。

2. JavaScript引擎完成了什么事?

JavaScript的基本工作就是将开发者写的JavaScript代码转换成能被浏览器理解甚至能在应用程序上运用的最优化的快捷代码。事实上,JavaScriptCore称自己是优化的虚拟机。
更确切地说,每一个 JavaScript 引擎都实现了ECMAScript的一种版本,而JavaScript 是它的一种方言叫法。JavaScript引擎会随着ECMAScript的发展而进步,因为如此多不同的引擎都要与不同的浏览器一起运作,如headless browser(如PhantomJS), 或者像Node.js这样的执行环境。
通过以上对虚拟机的定义,将JavaScript引擎称为流程虚拟机也就是理所当然的了。因为它唯一的目的就是要读取和编译JavaScript代码。然而这并不意味着它只是一种简单的引擎。举个例子,JavaScriptCore有6个分区来分析、翻译、优化以及垃圾收集JavaScript代码。

3. JavaScript引擎是如何运作的?

这取决于引擎。WebKit的JavaScriptCore以及谷歌的V8引擎之所以能引起我们的兴趣是因为NativeScript对它们起了杠杠作用。这两种引擎不同程度地掌握着过程码。
JavaScriptCore是通过一系列的步骤去编译和优化脚本。
它会对词汇进行分析,将其分解成一系列的记号,或附上某些既定的意义。这些记号之后会被分析程序理出句法,并嵌入句法树。之后4个JIT(及时)程序开始运行、分析和执行那些分析程序产生的字节码。
简单讲,JavaScript引擎将你的源代码分解成串(又叫做分词),再将那些串转换成为编译器能够理解的字节码,然后将其执行。
用C++编写的谷歌的V8引擎也能编译和解释JavaScript源代码,掌握内存记忆配置且可垃圾回收残留物。
它由2个能够将源代码直接转换成计算机代码的编译器组成。

  • Full-codegen:输出非优化代码的快速编译器。
  • Crankshaft:输出快速,优化代码的慢速编译器。

如果Crankshaft认为由Full-codegen产生的不够优化的代码需要优化,它将会取而代之,这就是“crankshafting”流程。
有趣的事实:”机轴“是运用在自动化产业中内部氧化引擎里的一个完整的部分。这种众所周知的引擎在更高级的载体也就是V8中有所运用。
若计算机代码是由编译过程产生的,那么这个引擎就会在浏览器中显示出所有的ECMA标准规定的数据类型、操作员、客体和功能,或者任何需要用到它们的运行环境,如NativeScript.

4. JavaScript引擎有哪些?

在编译器中,差不多有37%是由市场因素组成的,品牌重塑是你在编辑器上可以做的不多的事情之一,因此引擎被赋予了迎合市场的名字:SquirrelFish, Nitro, SFX…
在市场因素对为引擎进行命名以及重命名有着重要影响的情况下,对JavaScript引擎发展史上的重大事件做记录是很有必要的。

Browser, Headless Browser, or Runtime JavaScript Engine
Mozilla Spidermonkey
Chrome V8
Safari ** JavaScriptCore *
IE and Edge Chakra
PhantomJS JavaScriptCore
HTMLUnit Rhino
TrifleJS V8
Node.js *** V8
io.js *** V8

* JavaScriptCore被重写为SquirrelFish,也被重塑成SquirrelFishExtreme品牌,又叫做Nitro。然而,在WebKit安装启用下的JavaScript的确也被称为JavaScript引擎(如Safari)。
** iOS开发者应该认识到Mobile Safari包含Nitro,但UIWebView并不包括JIT编译器,所以这个过程会比较慢。然而,有了iOS8系统,开发者可以运用包含Nitro的WKWebView来大幅度加速这个运行速度。Hybrid App的开发者应该会觉得比较轻松。
*** 之所以有做出将io.js从Node.js中分离出来的一个因素是和可以支持这个项目的V8版本有关。这将会是一个挑战,就像这边所列出来的一样。

五、关于作用域

作用域范围

传统的类C的语言作用域是块级作用域block-level scope,一个花括号就是一个作用域,而对于JavaScript来讲,作用域是函数级function-level scop。JavaScript语言的作用域仅存在于函数范围中。

  • 全局作用域

    在JavaScript代码中的任何地方都有定义的变量被称为全局变量,其也拥有全局作用域。一般来说,不在任何函数体内定义的变量以及未定义就直接赋值的变量拥有全局作用域。事实上,JavaScript默认拥有一个全局对象window,声明一个全局变量,就是为window对象的同名属性赋值。如下面代码所示。

    function fun1(){ }
    var a = 1;
    console.log(window.a);//1
    console.log(window.fun1); // function fun1(){}
    
  • 函数级作用域

    在JavaScript中,任何定义在函数体内的变量或者函数都将处于函数作用域中,这些变量也无法被在函数外部使用。函数内部声明的所有变量在函数体内始终是可见的,在JavaScript函数定义中,JavaScript在预编译阶段中会先扫描整个函数体的语句,将所有声明的变量“提升”到函数顶部。

    function test(o) {
      var i = 0; //  i在整个函数体内均是有定义的
      console.log(j); //j在里面有定义,但是没有赋值
      console.log(k); //k在里面有定义,但是没有赋值。
      if (typeof o == "object") {
        var j = 0;
        for (var k = 0; k < 10; k++) {
          console.log(k);
        }
        console.log(k); // 输出10;
      };
      console.log(j); //若o为对象类型,则为0;否则为undefined
    };
    

    当函数体内局部变量和函数体外的变量重名的话,内部局部变量将会遮盖同名的全局变量。

    var scope = "global";
    
    function f() {
      console.log(scope); //undefined
      var scope = "local";
      console.log(scope); //local;
    }
    //如前面所说的,“变量提升”,所有的变量将会预先编译,且赋值为undefined。
    

    JavaScript函数内的嵌套函数可以访问外层函数的变量,但是外层函数访问不了嵌套函数的变量。

    var a = 1;
    
    function fun4() {
      var b = 1;
      console.log(a);
      console.log(c); //报错 
      function fun5() {
        console.log(a); //1
        console.log(b); //1
        var c = 3;
      }
    }
    fun4();
    

    事实上,无论是函数作用域中的覆盖问题还是变量的访问权限,起作用的是作用域链。

作用域链

在JavaScript的全局变量和函数中都有一个与之关联的作用域链,这个作用域链是一个对象列表或是链表,其中定义了变量或函数的作用域中的变量,并且作用域链的作用就是用来解析标识符。在ECMA-262标准第三版中,[[scope]]这个内部属性包含了该链表,其决定了变量的访问权限。先创建一个全局函数:

function add(a,b){
  var sum = a + b;
  return sum;
}

全局函数sum()创建的时候,实际上其只保存一个作用域链,其作用域链会插入一个Global object(全局对象),该全局对象拥有所有的全局变量和函数,包括window,document,sum()等等:

函数创建时的作用域链

执行的时候,每个执行上下文都有自己的作用域链,当执行上下文被创建的时候,作用域链会被初始化为当前运行函数的[[scope]]属性中的对象。这些值会按照出现在函数的顺序被复制到执行上下文的作用域链上,该过程一旦完成,一个被称之为活动对象的新对象就为执行上下文创建好了。活动对象作用函数运行时的变量对象,包含了所有的局部变量,命名函数,参数集合以及this。然后该对象被推入作用域链的最前端。

所以当sum函数执行的时候,即var total = sum(5,10)的时候,其全新的作用域链将如下图所示:


函数add执行时的作用域链

对于嵌套函数而言,其作用域链上至少有三个对象,一个为嵌套函数的调用对象,一个为外部函数的调用对象,还有一个为全局对象。同时由于每次执行函数时,其执行上下文都是独一无二的,所以在每次调用外部函数的时候,嵌套函数的作用域链也是不同的。

同时在函数执行的时候,每次遇到变量,其都会进行标识符的解析以此来决定从哪里获取或存储数据。在这个过程中,其会首先搜索执行上下文的作用域链,从作用域链的头部开始,再到作用域链的底部结束。如果在这个过程中找到了变量,则使用该变量;如果没有找到,则会抛出异常;如果有同名的变量存在作用域链不同的位置,那以先获取到的变量为准。

作用域提升

  • 变量提升

    对JavaScript解释器而言,所有的函数和变量声明都会被提升到最前面, 并且变量声明永远在前面,赋值在声明过程之后。比如:

    var x = 10;
    function x(){};
    console.log(x); // 10
    

    实际上被解释为:

    var x;
    function x(){};
    x = 10;
    console.log(x); // 10、
    
  • 函数提升

    函数的声明方式主要由两种:声明式变量式声明式会自动将声明放在前面,并且执行赋值过程。而变量式则是先将声明提升,然后到赋值处再执行赋值。比如:

    function test() {
      foo(); // TypeError "foo is not a function"
      bar(); // "this will run!"
      var foo = function () { // function expression assigned to local variable 'foo'
        alert("this won't run!");
      }
      function bar() { // function declaration, given the name 'bar'
        alert("this will run!");
      }
    }
    test();
    

    实际上等价于:

    function test() {
      var foo;
      var bar;
      bar = function () { // function declaration, given the name 'bar'
        alert("this will run!");
      }
      foo(); // TypeError "foo is not a function"
      bar(); // "this will run!"
      foo = function () { // function expression assigned to local variable 'foo'
        alert("this won't run!");
      }
    }
    test();
    

    主要注意的地方:带有命名的函数变量式声明,是不会提升到作用域范围内的,比如:

    var baz = function spam() {};
    baz(); // vaild
    spam(); // ReferenceError "spam is not defined"
    

参考资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容