--本文采自本人公众号【猴哥别瞎说】

在 JavaScript 中,作用域似乎是最简单的。但对我而言,有时候与它有关的操作结果却会人感到困惑。特别是当提起“闭包”的时候,总会感到不那么踏实。于是想要通过这个文章,彻底搞懂与“JavaScript作用域”相关的一切。

作用域的定义

首先需要明确作用域是什么?让我们从编程语言的基本功能聊起吧。

几乎所有编程语言最基本的功能之一,就是能够储存变量当中的值,并且能够在之后对这个值进行访问和修改。事实上,正是这种储存和访问变量的值的能力,将状态带给了程序。若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但是它会受到高度限制。

将程序引入程序会引起几个有意思的问题:这些变量存储在哪里?更重要的,程序需要时如何找到它们?

这些问题说明需要一套设计良好的规则来储存变量,并且之后依靠这个规则来找到这些变量。这套规则被称为作用域。

值得注意的是,实际存储变量的位置是栈内存或者堆内存,很多人会认为那个是作用域。它们是存储变量的地方,并不能将这些理解为作用域。具体的规则才是作用域。

哦,原来 作用域是查找名称或者变量的一套规则。那么它在什么地方会被使用呢?是在 JavaScript 引擎中,更多的可以看看这篇文章中有关于 JavaScript 引擎的描述。

那么,既然是规则,我们来看看这些规则是怎样的?

作用域是嵌套的

在实际使用中,通常需要同时顾及几个作用域。常见的就是作用域的嵌套。

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

考虑代码:

function foo(a){
    console.log(a + b);
}

var b=2;

foo(18); // 20
复制代码

对变量 b 的引用无法在 foo 内部完成,但可以在上一层作用域中找到。

这样一层层的往外嵌套的过程,可以简单理解为一个作用域链。

作用域链

动态作用域与静态作用域

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

第一种是最为普遍的,被大多数编程语言采用的词法作用域。此法作用域的定义在词法阶段的作用域。简单说,此法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此词法分析器在处理代码时会保持作用域不变。

第二种则是动态作用域,典型代表是bash语言。这种模型让作用域作为一个在运行时被动态确定的形式,即它不关心函数和作用域是怎样声明以及在何处声明的,只关心他们从何处调用的。换句话说,它的作用域链是基于调用栈的,而不是代码中的作用域嵌套。

为了说明区别,我们来看看例子吧:

首先是以 JavaScript 代码为例的词法作用域:

/*
    执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value
    如果没有,就根据书写的位置,查找外面一层的作用域,也就是 value 等于 1
    所以结果会打印 1
*/

var value = 1;

function foo() {
    console.log(value); // 1
}

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

bar();
复制代码

然后是 bash 为代表的动态作用域:

/*
    执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value
    如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量
    所以结果会打印 2
*/
value=1
function foo () {
    echo $value; // 2
}
function bar () {
    local value=2;
    foo;
}
bar
复制代码

划重点:在 JavaScript 中,无论函数在哪里调用,也无论它何时被调用,它的词法作用域都只有函数被声明时所处的位置决定。

作用域的遮蔽效应

当我们理解了词法作用域的用法,那么我们就可以看到它的遮蔽效应:作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。

这个遮蔽效应,也是常见的测试题目。当你理解了之后,就会觉得很简单。

谁会生成新的作用域

我们讲完了作用域的一些细节。更进一步地,我们想要知道,在 JavaScript 中,哪些操作可以生成新的作用域呢?只有函数会生成新的作用域么?还有其他的结构或操作可以产生作用域呢?

函数作用域

正如我们设想的那样,每声明一个函数,都会为其自身创建一个作用域。我们以一个例子来理解:

function foo(a){
    var b = 2;
    
    function bar(){...}
    
    // ... more code here
    
    var c = 3;
}
复制代码

在这个代码片段中,foo()的作用域中包含了标识符abcbarbar()拥有自己的作用域。全局作用域只包含一个标识符:foo

由于标识符abcbar都附属于foo()的作用域,那么无法从foo()的外部对它们进行访问。也就是说,这些标识符无法从全局作用域中被访问到。

函数作用域的这个限制,在我们想要将某些函数代码进行封装的时候格外有用。通过创造函数作用域,就相当于隐藏了内部实现。

为什么要隐藏内部实现?可以从软件设计角度的最小特权原则或者最小暴露原则来考虑:仅最小限度地暴露内容,而将其他内容都“隐藏”起来。

块作用域

如果你了解除了 JS 之外更多的主流编程语言,你会发现块作用域是一个常见的概念。但可惜的是:常见的块作用域写法(没错,就是那个非常简单的花括号表示法),在 JS 中竟然没有。(是的,就是没有。果不其然 JS 是作者 Brendan Eich 只花了10天设计出来的语言。。)

所以你也就应该明白,下面的写法,是非常危险的:

//由于这里并无法形成块作用域,因此变量i实际被挂在了外部作用域中(在此处,即全局作用域)
for(var i = 0; i< 5; i++){
    console.log("the i is : ", i);
}
复制代码

try/catch

但是,虽然常见的块作用域语法没有,但 JS 语法中有一个非常不起眼的规范(从 ES3 开始生效):try/catch 中的 catch 分句会创建一个块作用域,其中的声明只会在 catch 内部生效。

try {
    undefined(); // 强行制造一个Error
}
catch(err){
    console.log( err ); // 能够正常执行
}

console.log( err ); //ReferenceError : err not found
复制代码

看到这个之后,是不是会发现很无奈?你会想:这个有啥卵用呢?谁会写这么丑陋的代码呀?就为了使用块作用域。。。

但它确实有可用之处。现如今(2020年初)的我们知道,ES6 语法中的let关键字可以创造块作用域,实现我们一直想要的功能。但是,我们如何将let关键字的语法切换到让 ES6 以下的环境也能够适用呢?(即降级服务)

一般情况下,都是类似 babel 这样的工具协助我们将 ES6 代码转化为 ES5 代码,我们并不关心它们是怎么实现的,对吧?现在可以告诉你: 将其写成 try/catch 的方式,是 ES6 中的绝大部分功能迁移的首选方式,以生成兼容 ES5 的代码。

ES6 新关键字: let/const

为了解决 JavaScript 一开始设计时的如上缺陷,ES6 语法规范定义了两个新的关键字:let/const。它们提供了除var以外的另外两种声明方式,作用都是可以用来创建块作用域变量。

let/const

重点来看let关键字,它可以将变量绑定到所在的任意作用域中(通常是{..}内部)。换句话说,let为其声明的变量隐式地劫持了所在的作用域。用个栗子来说明吧:

var foo = true;

if(foo){
    let bar = foo *2;
    console.log( bar );
}
console.log( bar ); // RefenceError
复制代码

可以看到,此时因为let的声明,条件语句的{}被拓展为了一个新的作用域,外部作用域就无法访问到该作用域内的变量了。不过要注意,这种方式是隐式创建了新的作用域。

有了let,我们就可以轻轻松松实现我们想要的块作用域的功能啦,而且还是显式声明的方式:

function bar() {

    var b = 2;
    
    {
        let a = 10;
        console.log(a);
    }
    
    console.log( b ); // 2
    console.log( a ); // ReferenceError
}

复制代码

不过需要注意的是:使用let进行的声明,不会在块作用域中进行提升。关于提升,可以看看接下来的章节。

const关键字,其产生的效果与let类似。只不过,经const声明的变量,是一个常量,不允许程序对其进行修改。

提升

作用域中,经常会被问到的一个问题,就是关于提升。提升分为两大类:变量声明的提升、函数声明的提升。我们来分开看:

变量声明的提升

我们来看一个栗子:

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

上面的结果,很多开发者会认为是 undefined,因为var a声明在 a = 2的后面,自然会被认为变量会被重新赋值(默认值 undefined)。但是,真正输出的结果是2。

想想看前一节我们讲到的和编译器与引擎相关的文章,就会知道:当引擎看到var a = 1;的时候,它会将其看成是两个声明:var aa = 1。其中,第一个声明是在编译阶段进行的,第二个赋值声明会被留在原地等待执行阶段。 于是,JavaScript 会这样处理我们的栗子代码:

var a; //编译阶段
a = 2; //执行阶段
console.log(a); //执行阶段
复制代码

这种将定义声明(或函数声明)从他们在代码中的位置被“移动”到了最上面的做法,就是提升。那么,我们是否真的理解了这个概念呢?再来一个例子:

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

这个时候,会输出什么呢?答案是 undefined。JavaScript 的处理顺序是这样的:

var a; //编译阶段
console.log(a);
a = 2;
复制代码

不过值得注意的是,所谓的提升,只是在特定作用域内的提升。特定的作用域,是提升的限制规则所在。

函数声明的提升

函数声明的提升与变量的提升类似。我们来看栗子吧:

foo();

function foo(){
    console.log("foo");
}
复制代码

这个是可以正常输出的。

不过函数声明有两个值得注意的点:

  • 函数声明会被提升,但是函数表达式却不会被提升。
  • 函数声明与变量声明都会被提升,但是函数会被优先提升,其次才是变量。

还是用栗子来说话吧:

foo(); // 不是ReferenceError,而是TypeError

var foo = function(){
    console.log("foo");
}

复制代码

此时的 JavaScript 引擎是这样看待这个代码的:

var foo;
foo(); //foo有定义,于是不会ReferenceError,但foo此时的值是undefined, 对其调用()会报TypeError
foo = function(){
    console.log("foo");
}
复制代码

我想这是较好理解的。

闭包

我们理解了词法作用域、懂得了作用域链之后,此时来看闭包,就不会那么没底了。

关于闭包,先来一个直截了当的定义:

当函数可以记住并访问其所在的词法作用域的时候,就产生了闭包(即使函数是在其所在的词法作用域之外被执行的)。

还是举个栗子来理解这段话吧:

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}

var baz = foo();
baz(); // 2  -- 这就是闭包 
复制代码

我们以作用域的角度来看待代码:变量a是在foo函数的词法作用域内。因为作用域链嵌套的关系,bar函数可以访问到变量a。但最外层作用域是无法访问到它(变量a)。

代码将bar函数返回到最外层作用域,但它依然能够记住它所能够访问的词法作用域,而不受到在何处被执行的影响。

这就是闭包。只要把握住了作用域的核心概念,万变不离其宗。闭包就没有那么难。

easy,easy~

我们来一个复杂一点的闭包形式:

function foo(){
    var a = 3;
    function baz(){
        console.log(a);
    }
    bar(baz);
}

function bar(fn){
    fn();
}

foo();
复制代码

这个栗子包含了函数提升以及闭包。但是稍微捋一捋,你就知道答案啦~