JS中的作用域&作用域链

70 阅读7分钟

作用域是什么

这里引用《你不知道的javascript》中对作用域概念的解释:

几乎所有编程语言最基本的功能之一,就是能够储存变量当中的值,并且能在之后对这个值进行访问或修改。事实上,正是这种储存和访问变量的值的能力将状态带给了程序。 若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但它会受到高度限制,做不到非常有趣。 但是将变量引入程序会引起几个很有意思的问题,也正是我们将要讨论的:这些变量住在哪里?换句话说,它们储存在哪里?最重要的是,程序需要时如何找到它们? 这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域

简单来说,作用域 指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。

作用域类型

ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数作用域。ES6的到来,为我们提供了块级作用域,可通过新增命令letconst来体现。

全局作用域

全局作用域为程序的最外层作用域,一直存在。

函数作用域

函数作用域在函数被定义时创建,包含在父级函数作用域 / 全局作用域内。

/* 全局作用域开始 */
var a = 1;

function func () { /* func 函数作用域开始 */
  var a = 2;
  console.log(a);
}                  /* func 函数作用域结束 */

func(); // => 2

console.log(a); // => 1

/* 全局作用域结束 */


块级作用域

什么是块级作用域呢?简单来说,花括号内 {...} 的区域就是块级作用域区域。

很多语言本身都是支持块级作用域的。上面我们说,javascript 中大部分情况下,只有两种作用域类型:全局作用域函数作用域,那么 javascript 中有没有块级作用域呢?来看下面的代码:

if (true) {
  var a = 1;
}

console.log(a); // 结果???

运行后会发现,结果还是 1,花括号内定义并赋值的 a 变量跑到全局了。这足以说明,javascript 不是原生支持块级作用域的。

但是 ES6 标准提出了使用 letconst 代替 var 关键字,来创建块级作用域。也就是说,上述代码改成如下方式,块级作用域是有效的:

if (true) {
  let a = 1;
}

console.log(a); // ReferenceError

作用域链

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或者抵达最外层的作用域(全局作用域)。我们把这种作用域的嵌套机制,称为作用域链。

以下面代码为例,在 bar 函数内部,会做三次 RHS 查询从而分别获取到 a b c 三个变量的值。bar 内部作用域中只能获取到变量 c 的值,ab 都是从外部 foo 函数的作用域中获取到的。

function foo(a) {
  var b = a * 2;

  function bar(c) {
    console.log( a, b, c );
  }

  bar(b * 3);
}

foo(2); // 2 4 12

上面的代码一共有三层作用域嵌套,它们的关系示意如下:

作用域的两种工作模型之——词法作用域和动态作用域

词法作用域Lexical Scopes)是 javascript 中使用的作用域模型,也是最普遍使用的一种作用域模型,词法作用域 也可以被叫做 静态作用域;与之相对的还有 动态作用域,也仍有一些编程语言在使用动态作用域(比如Bash脚本、Perl中的一些模式等)。

简单来说,词法作用域就是定义在词法阶段的作用域,也就是说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的,除非使用了一些欺骗词法作用域的方法)。

看下下面这段代码,思考一下它的运行结果是什么

var name = 'judy';

function showName() {
    console.log(name);
}

function changeName() {
    var name = 'sara';
    showName();
}

changeName();

因为JS使用的是词法(静态)作用域模型,这段代码执行时:

  • showName函数的函数作用域内查找是否有局部变量 name
  • 发现没找到name,于是根据书写的位置,查找上层作用域(全局作用域),找到了 name 的值是 judy,所以结果会打印出judy

而同样的代码,在动态作用域模型下,会按照以下方式执行

  • showName函数的函数作用域内查找是否有局部变量name,没有找到;
  • 于是沿着函数调用栈、在调用了 showName 的地方继续找name。找到changeName的作用域内刚好,changeName里有一个 name,于是这个name就会被showName引用。

最终,这段代码执行的结果就是sara

欺骗(修改)词法作用域

既然JS遵循词法作用域,那有什么办法能够在代码运行中将已经划分好的作用域修改掉吗?答案是肯定的。JS中有两种机制来实现这个目的。但一般是不会允许你在代码中这么做的,毕竟欺骗词法作用域也会导致性能的下降。

  1. eval

    js中的eval函数接收一个字符串作为参数。当 eval 拿到一个字符串入参后,它会把这段字符串的内容当做一段 js 代码,插入自己被调用的那个位置。

  2. with

    以下引用《你不知道的Javascript(上卷)》对with进行的讲解;

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

    var obj = { 
      a: 1,
      b: 2,
      c: 3 
    };
    // 重复 "obj" 调用
    obj.a = 2;
    obj.b = 3;
    obj.c = 4;
    // 简单的快捷方式
    with (obj) {
      a = 3;
      b = 4;
      c = 5;
    }
    
    

    但实际上这不仅仅是为了方便地访问对象属性,考虑如下代码:

    function foo(obj) {
      with (obj) {
        a = 2;
      }
    }
    
    var o1 = {
      a: 3
    };
    
    var o2 = {
      b: 3
    };
    
    foo( o1 );
    console.log( o1.a ); // 2
    
    foo( o2 );
    console.log( o2.a ); // undefined
    console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!
    

    这个例子中创建了o1o2两个对象。其中一个具有a属性,另外一个没有。foo(..) 函数接受一个obj参数,该参数是一个对象引用,并对这个对象引用执行了with(obj) {..}。 在with块内部,我们写的代码看起来只是对变量a进行简单的词法引用,实际上就是一个 LHS 引用,并将2赋值给它。

    当我们将o1传递进去,a=2赋值操作找到了o1.a并将2赋值给它,这在后面的 console.log(o1.a) 中可以体现。而当o2传递进去,o2并没有a属性,因此不会创建这个属性,o2.a保持undefined

    但是可以注意到一个奇怪的副作用,实际上a = 2赋值操作创建了一个全局的变量a。这 是怎么回事?

    with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。 尽管with块可以将一个对象处理为词法作用域,但是这个块内部正常的var声明并不会被限制在这个块的作用域中,而是被添加到with所处的函数作用域中。

    eval(..)函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

    可以这样理解,当我们传递o1with时,with所声明的作用域是o1,而这个作用域中含有一个同o1.a属性相符的标识符。但当我们将o2作为作用域时,其中并没有a标识符, 因此进行了正常的 LHS 标识符查找。

    o2的作用域、foo(..)的作用域和全局作用域中都没有找到标识符a,因此当a=2 执行时,自动创建了一个全局变量(因为是非严格模式)。