深入理解JavaScript中作用域问题

JavaScript中什么是作用域?有多少种作用域?为什么我要学习作用域?

相信很多小伙伴们都能明白“作用域”这三个字,按照字面上理解,它不就是“发挥作用的区域”吗?然而在我的前端学习中,我发现它有一个非常专业的解释称为 执行环境

执行环境是什么

执行环境是JavaScript中最为重要的一个概念,因为它定义了变量或函数有权访问其他数据,并且决定了它们的行为,因此执行环境拥有一套独有的 规则。接下来,我们入个门来了解下JavaScript是如何工作的?

编译原理

JavaScript 引擎的编译步骤,可以说是为我们后面理解执行环境埋下了深厚的伏笔。在这里我将给大家介绍它的三个具体步骤:

  1. 词法分析

    这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块。这些代码块被称为词法单元。
    例如: var a = 2;,这个字符串你会怎么理解?通常这段字符串会被分解成为下面这些词法单元:vara=2;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。

  2. 语法分析

    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。

  3. 生成代码

    最后这个过程是将“抽象语法树”(AST) 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。 抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

小结: var a = 2;,意思就是先在我的编译器中声明这个变量 a,并为它分配内存,紧接着我再赋个值给 a,这个值存在a里面。

三位好伙伴

要参与到对程序 var a = 2; 进行处理的过程,我们需要了解三位好家伙,分别是引擎、编译器、作用域。可以说 JavaScript 工作都是靠它们三位大功臣来完成了,首先 引擎 从头到尾负责整个 JavaScript 程序的编译及执行过程,紧接着 编译器 负责语法分析及代码生成等脏活累活,最后 作用域 负责收集并维护由所有声明的标识符(变量)组成的一系列查 询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

下面我们将 var a = 2; 分解,看看引擎和它的朋友们是如何协同工作的。

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

LHS & RHS

LHS(Left-Hand-Search)称为左侧查找,那么顾名思义,RHS(Right-Hand-Search)自然叫做右侧查找了。在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头 (RHS)”。

考虑以下代码你会更明白:

function foo(a) {
            console.log(a); // 2 
        }
        foo(2); 

最后一行foo( 2 );我们调用了 foo 函数,并且传给它了一个实参 2,对于 foo 函数,它接受到了一个参数并把这个参数赋值给 a,因此a = 2,这一步是左侧查找了,可以理解为 a 是 2 要赋值的目标。最后console.log( a );这段话中,执行的就是右侧查找,因为我们要输出 a 就必须去查找这个值。

小结: LHS 指的是等式左边部分,RHS 指的是等式右边部分

有了以上的基础概念,我们不难理解原来作用域就是我代码书写、生成、和执行的地方啊,也就是代码的执行环境啊。

作用域嵌套

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

考虑以下代码:

function foo(a) {
            console.log(a + b);
        }
        var b = 2;
        foo(2); // 4

你会发现在我的 foo 函数中,我并没有声明 a、b 变量,怎么办?输出会报错吗?把代码运行一下,结果出乎意料居然是 4。这个结果到底是怎么来的?

  1. 前面我们刚讲过 LHS 和 RHS,所以我们能理解当我们调用 foo 函数时其实是进行了一次 LHS,把 2 赋值给了 a。
  2. 对于 b 的值是如何取到的,我们就要明白如果引擎在当前作用域中无法找到某个变量时,它就会在外层嵌套的作用域中继续查找,在这里 foo 函数中并没有 b 这个变量,因此引擎会向外查找,下一个作用域就是全局作用域了,很庆幸,在全局作用域中有 b 这个变量,这是 foo 函数就可以获得 b 变量的值 2,最后输出 4 了。

作用域类别

作用域有三种,分为词法作用域,函数作用域和块作用域。当然,你也可以说作用域有两种,静态作用域和动态作用域,那么这又是另一个话题了。

词法作用域

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。

考虑以下代码:

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

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

            bar(b * 3);
        }

        foo(2); // 2, 4, 12

在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含的气泡。

image

查找 a、b、c 依次从内向外,a、b 都在 foo 函数作用域中找到,c 在 bar 作用域中找到。作用域查是从最内层的作用域开始查找,例如:bar -> foo -> global,它会在找到第一个匹配的标识符时停止。我们把 bar -> foo -> global 称为作用域链,引擎在查找时就是依照作用域链不断向上查找的。

函数作用域

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

在学习词法作用域的时候,我们就已经接触到了函数作用域,但是我仍需要强调一点,函数作用域中的变量只能给自己和子函数使用,父级函数是获取不到子函数中的变量的。

举个例子:

function foo(a) {
            function bar(c) {
                c = 3;
            }
            console.log(a, c)
        }

        foo(2); // Uncaught ReferenceError: c is not defined

块作用域

1. 零污染

尽管函数作用域是最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可以实现维护起来更加优秀、简洁的代码。

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

我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使 用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。

使用块作用域可以帮助我们避免污染全局作用域。

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

bar 变量仅在 if 声明的上下文中使用,以为我们用 {} 把它包裹在了一个块当中,这样就避免了不污染全局作用域。

如果你还是不能理解块级作用域的好处,希望下面这个例子可以帮助你理解:

for (let i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log(i);
    }, 1000);
} 

正常情况下,我们对这段代码行为的预期是分别输出数字 0~9,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出10次 10

这是为什么?

要理解 10 是怎么来的,我们需要理清以下两点:

  1. 由于一次又一次的循环,循环结束时,i 此时已经是 10 了。换句话说,当 i 是 10 的时候,循环结束。
  2. var 关键字声明其实是一个全局声明

这样我们就很好理解这个结果了。首先定时器会在 1s 中之后输出,此时 i 已经变成了 10,而 i 又是个全局变量,所以此时输出的会是 10 次 一样的值。

那我们如何才能不污染全局作用域呢?这里我推荐使用 let 关键字,let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let 为其声明的变量隐式地了所在的块作用域。(不了解 es6 中新增的 let 和 const 关键字可以先跳过这里往后看再回到这里)

如果我们用 let 替换 var 关键字,你会发现我们非常成功的获取到了我们认为的结果,原因是因为 let 关键字把变量 i 绑定在了我当前这个作用域(循环)中,因此全局作用域中并不会出现 i 变量,每次循化都会绑定一个独立的值。

2. 垃圾收集

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。在这里我们只看一个小小的例子。

function process(data) {
        // 在这里做点有趣的事情 
    }

    // 在这个块中定义的内容可以销毁了!   
    {
        let someReallyBigData = {..
        }

        process(someReallyBigData);
    }

    var btn = document.getElementById("my_button");

    btn.addEventListener("click", function click(evt) {
        console.log("button clicked");
    });

es6 新增的 let & const 关键字

let 关键字可以把变量绑定在当前作用域中,外面作用域无法获得这个变量。

for (let i = 0; i < 10; i++) {
    console.log(i); // 0 ~ 9
}
console.log(i); // Uncaught ReferenceError: i is not defined

const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

var foo = true;

    if (foo) {
        var a = 2;
        const b = 3; // 包含在 if 中的块作用域常量 

        a = 3; // 正常 !     

        b = 4; // 错误 ! 
    }

    console.log(a); // 3 console.log( b ); // ReferenceError!

虽然说我们在声明变量的时候用 var 没有什么问题,但是一旦我们作用域中变量多起来,我们的大脑可能就会处于一种混乱的状态,出现 bug 时会不知所措(找 bug 是真的头痛哦)!在这里我还是推荐小伙伴们可以尽量使用 let 或 const 这种新增的关键字。

结语

看到这里我们就已经学习完了 JavaScript 中作用域,之所以我们要了解并掌握作用域,最大的原因是它能帮助我们快速的分析代码出错之处,它对我们的影响也会潜移默化的体现在我们书写的代码当中,相信小伙伴们未来都能摆脱 bug 烦恼!:)

你可能感兴趣的:(深入理解JavaScript中作用域问题)