JavaScript基础之作用域/作用域链


作用域/作用域链

作用域

定义

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。

作用域是在运行时代码中的某些特定部分中的变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。

通过定义我们可以知道,作用域就是告诉我们变量或函数存放在哪里以及怎么去获取(访问)它们。

LHS查询和RHS查询

  • LHS:赋值操作的左侧

  • RHS:赋值操作的右侧

简单来说,如果查找的目的是对变量赋值,那么会使用LHS查询;如果目的是获取变量的值,那么会使用RHS查询。

var a = 1
console.log(a)

以上代码中就存在LHS查询和RHS查询:

  • var a = 1会被分解为两步:

    1. var a在作用域中声明变量a,这一步在代码执行前进行;(原理可以看看JavaScript基础之执行上下文
    2. a = 1会进行LHS查询,查询变量a,并对它进行赋值。
  • console.log(a)有两个RHS查询:

    1. a进行RHS查询,查询a的值并传给console.log(..);
    2. console进行RHS查询,查询console对象上是否有一个叫做log的方法。

补充:

赋值操作都会导致LHS查询,=操作符或调用函数时出入参数的操作都会导致赋值操作。

LHS查询如果失败,会创建一个全局变量,RHS查询如果失败会报错:ReferenceError;如果你对查询到的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用时,会报错:TypeError

ReferenceError同作用域的判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作时非法或不合理的

全局作用域和函数作用域

全局作用域

  • 最外层函数和在最外层函数外面定义的变量拥有全局作用域
  • 所有末定义直接赋值的变量自动声明为拥有全局作用域
  • 所有window对象的属性和方法拥有全局作用域
var a = 1
function b() {
    c = 2
}
// a 为最外层函数外面定义的变量
// b 为最外层函数
// c 为未定义直接赋值的变量
// window 上的属性和方法就不一一列举了

以上代码中的abc都在全局作用域内,在全局作用域下的变量我们称之为全局变量

全局变量在程序的任何地方都能访问

函数作用域

属于这个函数的全部变量都可以在整个函数的范围内使用及复用(在嵌套的作用域中也可以使用)。

函数作用域是指声明在函数内部的变量(包括函数的形参)

function foo(a) {
    var b = 1
    return a + b
}
foo(1) // 2

以上代码中的ab都是在函数作用域内,在函数作用域内的变量我们称之为局部变量(块级作用域的变量也是局部变量,后面谈)

局部变量只能在它本身以及它内部的作用域内才能被访问

块作用域

在ES6之前

JavaScript不支持块作用

for (var i = 0; i < 10; i++) {
    console.log(i)
}
console.log(i) // 10

var foo = true
if (foo) {
    var bar = foo * 2
    console.log(bar) // 2
}
console.log(bar) // 2

上面的代码,for里的i变量和if块里的bar变量都属于外部作用域。

  • with是块作用域的一个例子,具体可以参考下文中的欺骗词法作用域
  • try/catch的分句catch会创建一个块作用域,其中声明的变量仅在catch内部有效
try {
    throw 123 // 抛出一个异常
} catch (error) {
    console.log(error) // 123 能够正常的执行
}
console.log(error) // ReferenceError: error not found

ES6之后

letconst关键字可以将变量绑定到任意作用域中(通常是 {..} 内部)

for (let i = 0; i < 10; i++) {
    console.log(i)
}
console.log(i) // ReferenceError

var foo = true
if (foo) {
    let bar = foo * 2
    console.log(bar) // 2
}
console.log(bar) // ReferenceError

由于作用域的限制,每段独立的执行代码块只能访问自己作用域和外层作用域中的变量,无法访问到内层作用域的变量。

作用域链

定义

当可执行代码内部访问变量时,会先查找本地作用域,如果找到目标变量即返回,否则会去父级作用域继续查找…一直找到全局作用域。我们把这种作用域的嵌套机制,称为作用域链。

作用域嵌套

function foo(a) {
    var b = a * 2
    function bar(c) {
        console.log(a, b, c)
    }
    bar(b * 3)
}
foo(2) // 2, 4, 12

在这个例子中有三个逐级嵌套的作用域,气泡表示:

作用域嵌套

  1. 包含着整个全局作用域,其中只有一个标识符:foo
  2. 包含着foo所创建的作用域,其中有三个标识符:abarb
  3. 包含着bar所创建的作用域,其中只有一个标识符:c

词法作用域

定义

作用域有两种工作模型。一种是词法作用域,另一种是动态作用域,JavaScript采用的便是词法作用域。

词法阶段:大部分标准语言编译器的第一个工作阶段叫做词法化。词法化的过程会对源代码中的字符进行检查,如果时有状态的解析过程,还会赋予单词语义。

词法作用域就是定义在词法阶段的作用域

换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里决定的

查找

作用域查找会从内部向外部逐级查找,在找到第一个匹配的标识符时停止。

var value = 1

function foo() {
    var value = 2
    function bar() {
        console.log(value)
    }
    bar()
}

foo()

上面的代码输出什么?

根据作用域的查找规则,结果为 2

上面的代码很容易理解,就是按照作用域链或者说作用域气泡的方式一层层的往外部查找变量value,找到即停止并执行console.log(..)输出操作。

那么观察下面的代码:

var value = 1

function foo() {
  console.log(value)
}

function bar() {
  var value = 2
  foo()
}

bar()

上面的代码输出什么?

  • 全局作用域有三个标识符:valuefoobar

  • foo函数作用域没有标识符

  • bar函数作用域有一个标识符:value

根据词法作用域的定义可知,结果为 1

  1. bar()函数执行,函数内声明了value变量并赋值为2,接着foo()函数执行
  2. foo()函数执行,执行console.log(..)方法,查找value变量并输出其值,
    1. 按照作用域的查找规则,首先在foo函数内部查找,foo函数内部没有找到,则去其外部作用域查找
    2. 它的外部作用域为全局作用域,存在value变量,其值为1

在这个过程中,并没有bar函数作用域的参与,可见无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定

补充:

词法作用域查找只会查找一级标识符,比如a,b,c。如果代码中引用了foo.bar.baz,词法作用域查找只会试图查找foo标识符,找到这个变量后,对象属性访问规则会分别接管对bar和baz属性的访问。

欺骗词法作用域

欺骗词法作用域会导致性能下降,不要使用它们

eval
var b = 2

function foo(str, a) {
    eval(str)
    console.log(a, b)
}

foo('var b = 3', 1) // 1, 3

eval(..)调用中的'var b = 3'这段代码会被当做本来就在那里一样来处理。上面的代码实际上会变成下面这样:

var b = 2

function foo(a) {
    var b = 3
    console.log(a, b)
}

foo(1) // 1, 3

这种做法欺骗了词法作用域的规则,使得函数声明后的作用域会被修改。

with
  1. with被当作重复引用同一个对象中的多个属性的快捷方式
var obj = {
    a: 1,
    b: 2,
    c: 3
}

// 给obj的属性重新赋值
obj.a = 2
obj.b = 3
obj.c = 4

//使用with
with (obj) {
    a = 3,
    b = 4,
    c = 5
}
  1. with可以将变量暴露到全局作用域
function foo(obj) {
    with (obj) {
        a = 2
    }
}
var obj1 = {
    a: 3
}
var obj2 = {
    b: 3
}

foo(obj1)
console.log(obj1.a) // 2

foo(obj2)
console.log(obj2.a) // undefined
console.log(a) // 2, a 被暴露到全局作用域上了

这个例子中创建了obj1obj2两个对象。其中一个有a属性,另一个没有。foo()函数接受一个obj参数,该参数是一个引用对象,并对这个对象执行了with(obj){..}。在with块内部我们将obj参数引用对象的a属性赋值为2.

当我们将obj1传递进去,a = 2赋值操作找到了obj1.a并将2赋值给它;当我们将obj2传递进去,obj2并没有a属性,因此不会创建这个属性,obj2保持undefined,但是我们发现a = 2赋值操作创建了一个全局变量a

实际上,with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的标识符

因此在执行foo(obj2)时,with里的a = 2实际上相当于未声明直接赋值的变量自动声明未全局变量,如果with块里改为var a = 2,那么这个a相当于声明在foo里的一个变量。

参考

《你不知道的JavaScript上卷》

深入理解JavaScript作用域和作用域链

面试官:说说作用域和闭包吧


评论
 上一篇
JavaScript基础之闭包 JavaScript基础之闭包
闭包定义MDN定义: 闭包是指那些能够访问自由变量的函数。 自由变量: 自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的的变量。 《你不知道的JavaScript》里的定义: 当函数可以记住并访问所在的词法作用域
2020-08-16
下一篇 
JavaScript基础之执行上下文 JavaScript基础之执行上下文
执行上下文概念执行上下文当 JS 引擎解析到可执行代码片段(通常是函数调用阶段)的时候,就会先做一些执行前的准备工作,这个 “准备工作”,就叫做 “执行上下文(execution context 简称 EC)” 或者也可以叫做执行环境。 E
2020-08-13
  目录