作用域与作用域链

51 阅读9分钟

作用域

理解

  • 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)--《你不知道的JavaScript》
  • 它是静态的(相对于上下文对象), 在编写代码时就确定了

分类

  • 全局作用域
  • 函数作用域
  • 块作用域(ES6)

image.png


执行上下文

--(摘自《JavaScript高级程序设计》)
执行上下文(以下简称“上下文”)的概念在 JavaScript 中是颇为重要的。变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object), 而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。

全局上下文是最外层的上下文。根据 ECMAScript 实现的宿主环境,表示全局上下文的对象可能不一 样。在浏览器中,全局上下文就是我们常说的 window 对象(第 12 章会详细介绍),因此所有通过 var 定 义的全局变量和函数都会成为 window 对象的属性和方法。使用 let 和 const 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义 在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。

每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。 在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。

​ 上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象


作用域与执行上下文

  1. 区别1
    • 全局作用域之外,每个函数都会创建自己的作用域,作用域在函数定义时就已经确定了
    • 全局执行上下文环境是在全局作用域确定之后, js代码马上执行之前创建
    • 函数执行上下文是在调用函数时, 函数体代码执行之前创建
  2. 区别2
    • 作用域是静态的, 只要函数定义好了就一直存在, 且不会再变化
    • 执行上下文是动态的, 调用函数时创建, 函数调用结束时就会自动释放
  3. 联系
    • 执行上下文(对象)是从属于所在的作用域
    • 全局上下文环境==>全局作用域
    • 函数上下文环境==>对应的函数使用域 image.png

作用域链

理解

  • 当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
var color = "blue";
function changeColor() {
    console.log(color)//blue
    if (color === "blue") {
        color = "red";
    } else {
        color = "blue";
    }
}
changeColor(); // blue
console.log(color); // red

函数 changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就是定义arguments 对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量 color,就是因为可以在作用域链中找到它


进阶

上面的内容是作用域的表现形式,但如果想要更加深入,还需要一些其它的知识点(无需了解的童鞋可通过目录直接跳转到后面章节)

编译原理

尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。

尽管如此,JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能比预想的要复杂。 在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

词法分析:这个过程浏览器会把我们写好的代码处理成对计算机来说有意义的词法单元,例如 var a = 1 通常会被分解成以下词法单元 var、a、=、1;

语法分析:这个过程浏览器将词法单元集合转换成语法结构树,也就是抽象语法树AST;

代码生成:这个过程浏览器将AST转换成可执行代码;

通过上面三个阶段,浏览器已经可以运行我们得到的可执行代码了,这整个过程就是编译阶段,后面对可执行代码的运行就是运行阶段

LHS与RHS

承接上文作用域是一套规则,用于确定在何处以及如何查找变量(标识符)
那么编译器是如果通过作用域查找到变量的呢?这里引用了两个查找方法LHS、RHS(赋值操作的左侧/右侧查询)

对于一个简单的例子

var a = 2;

编译器会执行两个步骤

遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的 集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作 用域的集合中声明一个新的变量,并命名为 a。  
 
接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值 操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量

总结:

  1. 如果查找目的是对变量进行赋值,会使用LHS查询,如果目的是获取变量,就会进行RHS查询
  2. LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所 需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层 楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止
  3. 不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下)

分析一个例子

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

var c = foo( 2 );

1.  找出所有的 LHS 查询(这里有 3 处!)

c = ..;、a = 2(隐式变量分配)、b = .. 2.

2.  找出所有的 RHS 查询(这里有 4 处!)

foo(2..、= a;、a ..、.. b

词法作用域

作用域有两种主要工作模型

词法作用域:词法作用域根据编码结构确定作用域,作用域在编译的词法分析阶段就确定了,在运行阶段不再改变。

动态作用域:动态作用域在运行阶段确定,也就是说动态作用域会根据不同的代码运行上下文发生变化。

这里主要学习的是词法作用域。词法作用域是最为普遍的,js采用的是词法作用域

var val = 1;
function test() {
    console.log(val);
}
function bar() {
    var val = 2;
    test();
}

bar(); // val ???

最终的输出结果是1,说明test打印的是全局下的val,这也印证了JavaScript使用了静态作用域。

静态作用域执行过程 当函数执行test函数时,先从内部的AO对象查找是否有val对象,如果没有,沿着作用域链往上查找(由于JavaScript是词法作用域),上层为全局GO,所以结果打印1

动态作用域执行过程 但如果JavaScript采用的动态作用域,执行test函数,从函数内部查询val变量,如果没有,就调用函数的作用域,即bar函数的作用域,成功查询到val=2

欺骗词法

JavaScript 中有两种机制来实现这个目的注意的是
欺骗词法作用域会导致性能 下降。

  • eval
  • with

eval

javaScript 中的 eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书 写时就存在于程序中这个位置的代码

function foo(str, a) {

eval( str ); // 欺骗! console.log( a, b );

}

var b = 2;

foo( "var b = 3;", 1 ); // 1, 3

总结:

  1. eval(..) 通常被用来执行动态创建的代码
  2. 无论何种情况,eval(..) 都可以在运行期修改书写期的词法作用域
  3. 严格模式下,无法修改所在的作用域

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 被泄漏到全局作用域上了!

一个奇怪的副作用,实际上 a = 2 赋值操作创建了一个全局的变量 a

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


面试题

面试题1

var x = 10;
function fn() {
  console.log(x);
}
function show(f) {
  var x = 20;
  f();//执行fn
}
show(fn);//10

作用域不会变,首先执行show()函数,show函数再调用fn(),调用fn()时,
先在自身的作用域中找,没找到在到上一级作用域中找,找到x=10,输出10.

面试题2

var fn = function () {
  console.log(fn)
}
fn()
var obj = {
  fn2function () {
   console.log(fn2)
   //console.log(this.fn2)
  }
}
obj.fn2()

找不到而报错,要想找到,添加this