阅读 543

理解 JavaScript 中的作用域

前言

学习 JavaScript 也有一段时间,今天抽空总结一下作用域,也方便自己以后翻阅。

什么是作用域

如果让我用一句简短的话来讲述什么是作用域,我的回答是:

其实作用域的本质是一套规则,它定义了变量的可访问范围,控制变量的可见性和生命周期。

既然作用域是一套规则,那么究竟如何设置这些规则呢?

先不急,在这之前,我们先来理解几个概念。

编译到执行的过程

下面我们就拿这段代码来讲述 JavaScript 编译到执行的过程。

var a = 2;
复制代码

首先我们来看一下在这个过程中,几个功臣所需要做的事。

  1. 引擎(总指挥):

    从头到尾负责整个 JavaScript 程序的编译及执行过程。

  2. 编译器(劳工):

    1. 词法分析(分词)

      解析成词法单元,vara=2

    2. 语法分析(解析)

      将单词单元转换成抽象语法树(AST)。

    3. 代码生成

      将抽象语法树转换成机器指令。

  3. 作用域(仓库管理员):

    负责收集并维护所有生命的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

然后我们再来看,执行这段代码时,每个功臣是怎么协同工作的。

引擎:

其实这段代码有两个完全不同的声明,var aa = 2,一个由编译器在编译时处理,另一个则由引擎在运行时处理。

编译器:

  1. 一套编译器常规操作下来,到代码生成步骤。
  2. 遇到var a,会先询问作用域中是否已经存在同名变量,如果是,则忽略该声明,继续进行编译;否则它会要求作用域声明一个新的变量a
  3. 为引擎生成运行a = 2时所需的代码。

引擎:

会先询问作用域是否存在变量a,如果是,就会使用这个变量进行赋值操作;否则一直往外层嵌套作用域找(详见作用域嵌套),直至到全局作用域都没有时,抛出一个异常。

**总结:**变量的赋值操作会执行两个动作, 首先编译器会在当前作用域中声明一个变量( 如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量, 如果能够找到就会对它赋值。

LHS & RHS 查询

从上面可知,引擎在获得编译器给出的代码后,还会对作用域进行询问变量。

聪明的你肯定一眼就看出,LR的含义,它们分别代表左侧和右侧。

现在我们把代码改成这样:

var a = b;
复制代码

这时引擎对a进行 LHS 查询,对b进行 RHS 查询,但是LR并不一定指操作符的左右边,而应该这样理解:

LHS 是为了找到赋值的目标。 RHS 是赋值操作的源头。也就是 LHS 是为了找到变量这个容器本身,给它赋值,而 RHS 是为了取出这个变量的值。

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套,进而形成了一条作用域链。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。

词法作用域

作用域分为两种:

  1. 词法作用域(较为普遍,JavaScript所使用的也是这种)
  2. 动态作用域(使用较少,比如 Bash 脚本、Perl 中的一些模式等)

词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。

看以下代码,这个例子中有三个逐级嵌套的作用域。

var a = 2; // 作用域1 全局
function foo(){ 
    var b = a * 2; // 作用域2 局部
    function bar(){
		var c = a * b; // 作用域3 局部
    }
}
复制代码
  1. 作用域是由你书写代码所在位置决定的。
  2. 子级作用域可以访问父级作用域,而父级作用域则不能访问子级作用域。

引擎对作用域的查找

作用域查找会在找到第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。也就是说查找时会从运行所在的作用域开始,逐级往上查找,直到遇见第一个标识符为止。

全局变量(全局作用域下定义的变量)会自动变成全局对象(比如浏览器中的 window对象)。

var a = 1;
function foo(){
    var a = 2;
    console.log(a); // 2
    function bar(){
        var a = 3;
        console.log(a); // 3
        console.log(window.a); // 1
    }
}
复制代码

非全局的变量如果被遮蔽了,就无论如何都无法被访问到,所以在上述代码中,bar内的作用域无法访问到foo下定义的变量a

词法作用域查找只会查找一级标识符,比如ab,如果是foo.bar,词法作用域查找只会试图查找foo标识符,找到这个变量后,由对象属性访问规则接管属性的访问。

欺骗语法

虽然词法作用域是在代码编写时确定的,但还是有方法可以在引擎运行时动态修改词法作用域,有两种机制:

  1. eval
  2. with

eval

JavaScript 的 eval函数可以接受一个字符串参数并作为代码语句来执行, 就好像代码是原本就在那个位置一样,考虑以下代码:

function foo(str){
    eval(str) // 欺骗
    console.log(a);
}
var a = 1;
foo("var a = 2;"); // 2
复制代码

仿佛eval中传入的参数语句原本就在那一样,会创建一个变量a,并遮蔽了外部作用域的同名变量。

注意

  • eval通常被用来执行动态创建的代码,可以根据程序逻辑动态地将变量和函数以字符串形式拼接在一起之后传递进去。
  • 在严格模式下,eval无法修改所在的作用域。
  • eval相似的还有,setTimeoutsetIntervalnew Function

with

with通常被当作重复引用同一个对象中的多个属性的快捷方式, 可以不需要重复引用对象本身。

使用方法如下:

var obj1 = { a:1,b:2 };
function foo(obj){
    with(obj){
        a = 2;
        b = 3;
    }
}
foo(obj1);
console.log(obj1); // {a: 2, b: 3}
复制代码

然而考虑以下代码:

var obj2 = { a:1,b:2 };
function foo(obj){
    with(obj){
        a = 2;
        b = 3;
        c = 4;
    }
}
foo(obj2);
console.log(obj2); // {a: 2, b: 3}
console.log(c); // 4 不好,c被泄露到全局作用域下
复制代码

尽管with可以将对象处理为词法作用域,但是这样块内部正常的var操作并不会限制在这个块的作用域下,而是被添加到with所在的函数作用域下,而不通过var声明变量将视为声明全局变量。

性能

evalwith会在运行时修改或创建新的作用域,以此来欺骗其他书写时定义的词法作用域,然而 JavaScript 引擎会在编译阶段进行性能优化,有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有的变量和函数的定义位置,才能在执行过程中快速找到标识符。但是通过evalwith来欺骗词法作用域会导致引擎无法知道他们对词法作用域做了什么样的改动,只能对部分不进行优化,因此如果在代码中大量使用evalwith就会导致代码运行起来变得非常慢。

函数作用域和块作用域

函数作用域

在 JavaScript 中每声明一个函数就会创建一个函数作用域,同时属于这个函数的所有变量在整个函数的范围内都可以使用。

块作用域

从 ES3 发布以来,JavaScript 就有了块作用域,创建块作用域的几种方式有:

  • with

    上面已经讲了,这里不再复述。

  • try/catch

    try/catchcatch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

    try{
    	throw 2;
    }catch(a){
    	console.log(a);
    }
    复制代码
  • letconst

    ES6 引入的新关键词,提供了除 var 以外的变量声明方式,它们可以将变量绑定到所在的任意作用域中(通常是{}内部)。

    {
        let a = 2;
    }
    console.log(a); // ReferenceError: a is not defined
    复制代码

    注意:使用 letconst 进行的声明不会在块作用域中进行提升。

提升

考虑这段代码:

console.log( a ); 
var a = 2;
复制代码

输入结果是undefined,而不是ReferenceError

为什么呢?

前面说过,编译阶段时,会把声明分成两个动作,也就是只把var a部分进行提升。

所以第二段代码真正的执行顺序是:

var a; // 这时 a 是 undefined
console.log(a);
a = 2;
复制代码
  • 编译阶段时会把所有的声明操作提升,而赋值操作原地执行。
  • 函数声明会把整个函数提升,而不仅仅是函数名。

函数优先

虽然函数和变量都会被提升,但函数声明的优先级高于变量声明,所以:

foo(); // 1
var foo;
function foo(){
    console.log(1);
}
foo = function(){
    console.log(2);
}
复制代码

因为这个代码片段会被引擎理解为如下形式:

function foo(){
    console.log(1);
}
foo(); // 1
foo = function() { 
  console.log( 2 );
};
复制代码

这个值得一提的是,尽管var foo出现在function foo()...之前,但由于函数声明会被优先提升,所以它会被忽略(因为重复声明了)。 注意:

JavaScript 会忽略前面已经声明过的声明,不管它是变量还是函数,只要其名称相同。

后记

因为篇幅原因,有一部分内容只是大概提到,并没有太过于详细的讲解,如果你感兴趣,那么我推荐你看看**《你不知道的 JavaScript(上)》**这本书,书上对此内容有很详细的说明。

本文也是作者一边查看此书一边结合自己的理解来进行编写的。

其实作用域还有一个非常重要的概念,那就是闭包。但闭包也是 JavaScript 中的一个非常重要却又难以掌握的,所以需要另开一篇文章来介绍。

最后,我想说的就是,在这个框架工具流行的时代,我们往往会被这些新东西所吸引,却忽略了最本质的东西,诸诸不知,恰恰是这些我们所忽略的东西才是最重要的,所有的 JavaScript 框架工具都是基于这些内容。所以,不妨回过头来看看这些原生的东西,相信你会更上一层楼。

谢谢观看!

注:此文为原创文章,如需转载,请注明出处。

关注下面的标签,发现更多相似文章
评论