JavaScript编译过程

在学习作用域之前先简单了解一下JavaScript的编译、执行过程。

JavaScript被称之为解释性语言,与Java等这类编译语言区别在于:JavaScript代码写好了就可以直接立即执行,Java则需要相对较长时间的编译过程才可生成可执行的机器码。

但其实JavaScript也是有编译过程的,JavaScript使用的是一种即时编译的方式(JIT)。 JIT会把JavaScript代码中会多次运行的代码给编译器编译,生成编译后的代码并保存起来,在下次使用时使用编译好的代码。这其实是JavaScript运行环境采用的一种优化解决方案。 如果不这么做,大量重复的代码都会在运行前重复编译,这将极大的影响性能与运行效率。

JavaScript引擎也会对JavaScript代码在运行前进去预编译,在预编译的过程中会定义一套规则用来存储变量,对象,函数等,方便在之后的运行调用。这套规则就是作用域

JavaScript引擎在编译过种中要对代码进行词法分析、语法分析、代码生成、性能优化等等一系列工作。JIT就是这一过程中用来优化的一部分。

var a = 1;

var a = 1; 这行代码在运行前编译器都会做哪些事情?

编译器会把这行代码分成 var aa = 1 ,两个部分。

  1. 首先编译器会在相同作用域内查询是否已经存在一个叫 a 的变量,如是存在,编译器会忽略声明a,继续下一步编译;如果不存在,则在当前作用域声明一个变量,命名为a
  2. 然后编译器会为引擎生成运行时的代码,这些代码中包含处理a = 1的部分,引擎在处理a = 1的时候,同样也会查询作用域中是否存在a变量(会逐级向上一个作用域查找), 存在则赋值为2,不存在则抛出异常(严格模式下,如非严格模式则会隐式创建一个全局变量aLHS)。

LHS查询 & RHS查询

LHS 和 RHS 的含义是“赋值操作的左侧与右侧”,不过要注意并不单指“=”和左侧与右侧。 赋值操作还有其它的形式,因此可以理解为:LHS-赋值操作的目标是谁? RHS-谁是赋值操作的源头。

a = 1; 是对a LHS查询,a是赋值操作的目标,为a赋值为1. 如LHS查询失败,非严格模式下会隐式创建一个全局变量,严格模式下会抛出ReferenceError: a is not defined;

console.log(a) 是对a RHS查询,a是赋值的源头;如果在作用域链中没有查询到a,同样也会抛出ReferenceError: a is not defined;

作用域链

作用域是存储变量的一套规则,当代码运行时可能并不只是在一个作用域查询变量。 当一个作用域中包含另一个作用域的时候,就会存在作用域嵌套的情况。所以当内部的作用域无法找到某个变量的时候,引擎会在当前作用域的外层嵌套中继续查询;直到查到变量或者达到最外层的作用域为止。这就是作用域链接

var name = "rewa"; 

function sayHi(){
    console.log("hello,"+name);
}

sayHi(); // hello,rewa

如上述代码,sayHi函数作用域中并没有变量name;却能正常引用。就是因为引擎在上一层作用域找到并使用了变量name;

var name = "rewa"; 

function sayHi(){
    var name = "fang"; // 添加的代码
    console.log("hello,"+name);
}

sayHi(); // hello,fang

sayHi作用域中已经找到变量name时,引擎会停止向上层作用域查找,这叫作“遮蔽效应”,内部变量遮蔽外部作用域变量。

词法作用域

作用域有两种主要的工作模型。一种是最为最为普遍的,被大多数编程语言采用的词法作用域; 还有一种叫动态作用域

词法作用域就是在写代码时将变量和块作用域写在哪里作用域就在哪里,定义在词法阶段的作用域。JavaScript就是采用的词法作用域。

词法:就是组成代码块的字符串。比如:

var a = 1;

这行代码中,vara=2; 还有这中间的空格 都是词法单元。

编译器的第一个工作就是词法化,会把代码分解成一个一个词法单元;具体编译器在词法化阶段都做了哪些工作遵守哪些规则,根据不同编程语言而不同。JavaScript是怎么样的规则我特么也不清楚,等我研究清楚了;再来做一个笔记。

简单的说,词法作用域就是你写代码的时候,把变量a写在函数b中,那么编译器编译时b的作用域中就会包含有a变量,编译器会保持词法作用域不变。(也会有特殊情况)

如下代码:

var a = 1;
function foo(){
    var b = a + 2;
    function bar(){
        var c = b + 3;
        console.log(a,b,c)
    }
    bar();
}

foo(); // 1,3,6

这段代码编译后的作用域与你编写时的词法作用域是一致的。

全局作用域: 变量a, 函数 foo

函数foo()创建的作用域:变量b,函数bar

函数bar()创建的作用域:变量c

代码写在哪作用域就在哪。

了解词法作用域需要注意以下几点:

  • 无论函数在哪里被调用,如何被调用,函数的词法作用域都只由函数被声明时所处的位置决定。
  • 词法作用域查询只会查找一级标识符,比如上述代码中的变量a,b,c。如果访问foo.bar.baz,词法作用域只会查询foo。找到这个变量后,再访问属性bar,再到baz
  • 存在使词法作用域编译后不一致的方法,但会导致性能下降。

修改词法作用域的方法 eval & with (千万不要这么做)

eval

代码如下:

var a = 1;
function foo(str){
    eval(str);
    console.log(a);
}
foo('var a = 2;'); // 2

var a = 1; 会在函数foo中运行,变量a将包含作用域。 eval(...)函数接受一个字符串,并将字符串当作代码运行;就相当于把代码写在这个位置。

eval在严格模式下会抛出异常:

var a = 1;
function foo(str){
    "use strict"
    eval(str);
    console.log(a); // ReferenceError: a is not defined
}
foo('var a = 2;'); 

默认情况下,如果eval()中有包含声明,就会对所处的词法作用域进行修改;在严格模式下,eval()在运行时有其自己的词法作用域,那么将无法修改所在的作用域,如上述代码。

with
var obj = {
    a:1,
    b:2,
    c:3
}

obj.a = 11;
obj.b = 22;
obj.c = 33;

// with 也可以达到同样的效果
with(obj){
    a=111;
    b=222;
    c=333;
}
//这样 obj 被修改为:
{   
    a:111,
    b:222,
    c:333
}

with()接受一个参数,在这里是obj;此时with中作用域是obj, 可以访问obj中的属性。 这种方式赋值就变得简洁很多。

with可以为一个对象创建一个作用域,对象的属性会定义为这个作用域中的变量;不过with中的通过var声明的变量并不会成为这个作用域的成员,而是被声明到with所在的作用域中。这不正常了,代码使用with会变得很不容易控制。比如:

with(obj){
    a=111;
    b=222;
    c=333;
    d=444;
}

console.log(obj.d); // undefined
console.log(d); // 444

原来以为会添加在obj中的属性d,却被添加到了全局作用域中;这就可能与开发编写时的预期结果不符;也不符合词法作用域的规则。

所以evalwith都已经被禁止了,也不推荐使用。

这种不可预估词法作用域的特性,也带了一个严重的性能问题。 JavaScript引擎在编译阶段会进行性能优化。其中有一些优化依赖代码的词法,对词法进行静态分析,并预先确定所有变量与函数的定义位置,才能在执行过程中快速找到变量。

如果引擎在代码中发现了evalwith,它无法在词法分析阶段明确知道eval(...)接生什么代码;也无法知道传递给with用来创建新词法作用域的对象内容是什么。 那么优化未知的代码和词法作用域是没有意义的,引擎将放弃优化这一部分。

如果在代码中频繁使用evalwith,程序运行起来将会非常慢。

函数作用域

函数内部的变量和函数定义都可以封装起来,外部无法访问封装在函数内部的变量标识符。

如下代码:

function foo(){
    var a = 1;
    function sayHi(){
        console.log('Hello!')
    }
}

console.log(a); // ReferenceError:a is not defined
sayHi(); //ReferenceError: sayHi is not defined 

在函数外部访问其内部的变量与函数会抛出异常。

这样函数就可以行成一个相对独立的作用域,可以用函数来封装一个相对独立的功能。 把业务代码隐藏在函数内部实现,对外暴露接口;只要传入不同的参数就可以输入对应的结果。 所以很多情况下函数可以用来模拟Java语言中类的实现。

例如:

function shoot(who,score){
    //这里面可以包含更多逻辑
    function one(){
        console.log(who + '罚篮命中!到得' +score+ '分!');
    }
    function dunk(){
        console.log(who + '扣篮,获得' +score+ '分!');
    }
    function three(){
        console.log(who + '命中了一个' +score+ '分球!');
    }
    switch(score){
        case 1:
            one();
            break;
        case 2:
            dunk();
            break;
        case 3:
            three();
            break;
    }
}

shoot('Kobe',3); // Kobe投中了一个3分球!'
shoot('Lebron',2); // Lebron扣篮,获得2分!' 
shoot('Shaq',1); // Shaq罚篮命中!到得1分!' 

函数内部隐藏变量与函数的定义可以避免污染全局命名空间;比如当全局作用域中也有one dunk three 这些函数,并且内部实现不同;代码逻辑就会混乱。 而在上面的代码中,函数中定义的函数会遮蔽外部作用域的函数定义,只会调用到当前函数作用域中的同名函数。

但是即使如此,大量的函数声明同样也会污染全局全名空间。 当下流行的模块化就是解决这一问题的方案之一。不过在模块化出来之前,大多数情况可以使用立即执行函数(IIFE)来解决。 代码如下:


(function(){
    var name = 'kobe';
    console.log(name);
})();

当函数执行结束后,name变量会被垃圾回收; 且不会与外部的任何作用域产生冲突,因为整个函数都执行在一个立即执行函数中。它是一个块作用域,且本身也没有在作用域下创建任何标识符。

立即执行函数也可以接受参数,用来函数内部引用:

(function(name){
    console.log(name);
})('kobe');

JavaScript中除了函数作用域,还有其它块作用域。比如with也是块作用域;上面有过介绍with。 还有一个容易被忽略的块作用域 try/catch

try{
    undefined(); //抛出异常
}
catch(err){
    console.log(err); // 正常执行
}

console.log(err); //ReferenceError: err is not defined

err只能在catch中访问,在外部的引用会抛出异常。

对于块作用域,ES6中我们可以用let声明实现这种需求。

if(true){
    let a = 1;
    console.log(a); //1
}

console.log(a); //ReferenceError: a is not defined

if(){} 并不是块作用域,但上述代码中let可以让a变量成为仅if(){...}中的变量,外部不可访问。

这是不是像极了try/catch , 可letES6的标准;在ES6之前实现类似块作用域效果的方法可没这么轻松。 现在一般我们在编写ES6代码,想要运行在所有浏览器上需要通过转译。而转译器也会把类似let的声明,转为 try/catch的形式。

{
    let a = 1;
    console.log(a); // 1
}
console.log(a); //ReferenceError: a is not defined

转为:

try{
    throw 1;
}catch(a){
    console.log(a); //1
}
console.log(a); //ReferenceError: a is not defined

还有可能转译为:

{
    let _a = 1;  // 把{}中的 a 转为_a 
    console.log(_a); 
}
console.log(a); 


程序员阿宇
3.2k 声望791 粉丝

前端学习交流群:784783012 欢迎新手,进阶者