Skip to content

你并不了解 JavaScript:作用域与闭包 - 第二版

第三章:作用域链

第 1 章和第 2 章为词法作用域(及其组成部分)下了一个具体的定义,并为其概念基础提供了有用的隐喻。在继续学习本章之前,请找其他人用你自己的话(书面或大声)解释什么是词法作用域,以及为什么理解它是有用的。

这一步似乎可以省略,但我发现,花时间把这些想法重新表述为对他人的解释,确实很有帮助。这有助于我们的大脑消化所学知识!

现在是深入探讨具体问题的时候了,所以从现在开始,事情会变得更加详细。不过请坚持下去,因为这些讨论确实会让我们明白,我们对作用域还有多少不了解。请务必慢慢阅读文本和提供的所有代码片段。

为了重温我们正在运行的示例的上下文,让我们回顾一下第 2 章图 2 中嵌套作用域气泡的彩色编码图示:

Colored Scope Bubbles
图 2(第 2 章): 彩色作用域气泡


嵌套在其他作用域中的作用域之间的连接称为作用域链,它决定了变量的访问路径。作用域链是定向的,这意味着查找只能向上/向外移动。

「查找」(大部分)是概念性的

在图 2 中,请注意 for 循环中引用的 students 变量的颜色。我们究竟是如何确定它是一个 红色 (1) 弹珠的呢?

在第 2 章中,我们将变量的运行时访问描述为「查找」,在查找过程中,引擎必须先询问当前作用域的作用域管理器是否知道某个标识符/变量,然后通过嵌套作用域链(朝向全局作用域)向上/向外查找,直到找到为止(如果有的话)。一旦在作用域桶中找到第一个匹配的命名声明,查找就会停止。

因此,查找过程确定了 students 是一个 红色 (1) 全局作用域,因为我们在遍历作用域链时还没有找到匹配的变量名,直到我们到达最终的红色 (1) 全局作用域。

同样,if 语句中的 studentID 也被确定为蓝色 (2) 弹珠。

这种运行时查找过程的暗示在概念上很好理解,但实际上在实际操作中并非如此。

弹珠桶的颜色(也就是变量来自哪个作用域的元信息)通常是在初始编译处理过程中确定的。由于词法作用域在此时已基本确定,因此弹珠的颜色不会因运行时可能发生的任何情况而改变。

由于弹珠的颜色在编译时就已知晓,而且不可更改,因此该信息很可能与 AST 中每个变量的条目一起存储(或至少可从条目中访问);然后构成程序运行时的可执行指令会明确使用该信息。

换句话说,引擎(来自第 2 章)不需要在一堆作用域中查找变量来自哪个作用域桶。这些信息都是已知的!避免运行时查找是词法作用域的一个关键优化优势。无需在所有这些查找上花费时间,运行时就能更高效地运行。

但我刚才说「......通常是确定的......」,说的是在编译过程中确定弹珠的颜色。那么,在什么情况下,在编译过程中会知道呢?

当前文件中的任何词法作用域中都没有声明变量的引用 — 参见 入门 第 1 章,该章中提到,从 JS 编译的角度来看,每个文件都是独立的程序。如果找不到任何声明,这并不一定是错误。运行时中的另一个文件(程序)可能确实在共享全局作用域中声明了该变量。

因此,最终确定变量是否曾在某个可访问的桶中进行过适当的声明,可能需要推迟到运行时进行。

在编译文件的过程中,任何对最初未声明的变量的引用都是未着色的弹珠;在编译完其他相关文件并开始应用程序运行之前,无法确定其颜色。这种延迟查找最终会将颜色解析为变量所在的作用域(可能是全局作用域)。

不过,每个变量最多只需要进行一次查询,因为在运行过程中,没有任何其他因素可以改变弹珠的颜色。

第 2 章中的「查找失败」一节介绍了在运行时执行某个弹珠的引用时,如果该弹珠最终仍未着色会发生什么情况。

遮蔽 (Shadowing)[1]

「遮蔽」听起来可能很神秘,而且有点不靠谱。但别担心,它是完全合法的!

我们这几章的运行示例在不同的作用域中使用了不同的变量名。由于它们都有唯一的名称,因此从某种程度上说,如果所有变量都存储在一个桶中(如红色 (1))也没有关系。

当您有两个或更多变量时,拥有不同的词法作用域桶开始变得更重要,每个变量都在不同的作用域中,但具有相同的词法名称。一个作用域中不能有两个或更多同名变量;这种多重引用将被视为一个变量。

因此,如果需要维护两个或多个同名变量,就必须使用不同的(通常是嵌套的)作用域。在这种情况下,如何布局不同的作用域桶就非常重要了。

思考一下:

js
var studentName = "Suzy";

function printStudent(studentName) {
    studentName = studentName.toUpperCase();
    console.log(studentName);
}

printStudent("Frank");
// FRANK

printStudent(studentName);
// SUZY

console.log(studentName);
// Suzy
var studentName = "Suzy";

function printStudent(studentName) {
    studentName = studentName.toUpperCase();
    console.log(studentName);
}

printStudent("Frank");
// FRANK

printStudent(studentName);
// SUZY

console.log(studentName);
// Suzy
贴士:
在继续之前,请花一些时间使用我们在书中介绍的各种技术/隐喻来分析这段代码。特别是,请务必识别这段代码中的弹珠/气泡颜色。这是很好的练习!

第 1 行的 studentName 变量(语句 var studentName = ..)创建了一个红色 (1) 弹珠。在第 3 行,即 printStudent(..) 函数定义的参数中,同样命名的变量被声明为蓝色 (2) 弹珠。

studentName = studentName.toUpperCase() 赋值语句和 console.log(studentName) 语句中,studentName 将是什么颜色的弹珠?所有三个 studentName 引用都将是蓝色 (2)。

根据「查找」的概念,我们断言它会从当前作用域开始向外/向上查找,一旦找到匹配的变量就停止。蓝色 (2) studentName 立即被找到。红色 (1) studentName 甚至从未被考虑过。

这是词法作用域行为的一个关键方面,称为遮蔽。蓝色 (2) studentName 变量(参数)会对红色 (1) studentName 产生遮蔽。因此,参数对全局变量产生了遮蔽。请多重复几遍这句话,以确保您对术语的理解是正确的!

这就是为什么重新分配 studentName 只影响内部(参数)变量:蓝色 (2) studentName,而不是全局的红色 (1) studentName

当您选择从外层作用域对变量进行遮蔽处理时,一个直接的影响是,从该作用域向内/向下(通过任何嵌套的作用域),现在任何弹珠都不可能被着色为被遮蔽处理的变量(本例中为红色 (1))。换句话说,任何 studentName 标识符引用都将对应于该参数变量,而绝不会对应于全局的 studentName 变量。从词法上讲,在 printStudent(..) 函数内部的任何地方(或任何嵌套作用域)都不可能引用全局 studentName 变量。

全局无遮蔽把戏

请注意:使用我将要描述的技术并不是很好的做法,因为它的实用性有限,会让你的代码读者感到困惑,而且很可能会给你的程序带来错误。我之所以介绍它,只是因为你可能会在现有程序中遇到这种行为,而了解发生了什么是避免被绊倒的关键。

在全局变量已被遮蔽化的作用域中访问该变量可能的,但不是通过典型的词法标识符引用。

在全局作用域(红色 (1))中,var 声明和 function 声明也会作为全局 (global ) 对象(本质上是全局作用域的对象表示)上的属性(与标识符同名)暴露自己。如果您为浏览器环境编写过 JS,您可能会将全局对象识别为 window。这并不完全准确,但对于我们的讨论来说已经足够了。在下一章中,我们将进一步探讨全局作用域/对象的话题。

请看这个程序,它是在浏览器环境中作为独立的 .js 文件执行的:

js
var studentName = "Suzy";

function printStudent(studentName) {
    console.log(studentName);
    console.log(window.studentName);
}

printStudent("Frank");
// "Frank"
// "Suzy"
var studentName = "Suzy";

function printStudent(studentName) {
    console.log(studentName);
    console.log(window.studentName);
}

printStudent("Frank");
// "Frank"
// "Suzy"

注意到 window.studentName 引用了吗?这个表达式正在访问作为 window 属性的全局变量 studentName(我们现在假定它与全局对象同义)。这是从存在遮蔽变量的作用域内部访问遮蔽变量的唯一方法。

window.studentName 是全局 studentName 变量的镜像,而不是单独的快照副本。对其中一个变量的更改仍然可以从另一个变量中看到,无论方向如何。您可以将 window.studentName 视为访问实际 studentName 变量的 getter/setter。事实上,您甚至可以通过在全局对象上创建/设置一个属性,在全局作用域中添加一个变量。

警告:
记住:可以并不意味着应该。不要对需要访问的全局变量进行遮蔽处理,反之,也要避免使用此技巧访问已被遮蔽处理的全局变量。绝对不要将全局变量创建为 window 属性而不是正式声明,从而混淆代码的读者!

这个小「把戏」只适用于访问全局作用域变量(而不是来自嵌套作用域的遮蔽变量),而且只适用于使用 varfunction 声明的变量。

其他形式的全局作用域声明不会创建镜像全局对象属性:

js
var one = 1;
let notOne = 2;
const notTwo = 3;
class notThree {}

console.log(window.one); // 1
console.log(window.notOne); // undefined
console.log(window.notTwo); // undefined
console.log(window.notThree); // undefined
var one = 1;
let notOne = 2;
const notTwo = 3;
class notThree {}

console.log(window.one); // 1
console.log(window.notOne); // undefined
console.log(window.notTwo); // undefined
console.log(window.notThree); // undefined

存在于全局作用域以外的任何其他作用域中的变量(无论它们是如何声明的!),在它们已被遮蔽化的作用域中是完全不可访问的:

js
var special = 42;

function lookingFor(special) {
    // The identifier `special` (parameter) in this
    // scope is shadowed inside keepLooking(), and
    // is thus inaccessible from that scope.

    function keepLooking() {
        var special = 3.141592;
        console.log(special);
        console.log(window.special);
    }

    keepLooking();
}

lookingFor(112358132134);
// 3.141592
// 42
var special = 42;

function lookingFor(special) {
    // The identifier `special` (parameter) in this
    // scope is shadowed inside keepLooking(), and
    // is thus inaccessible from that scope.

    function keepLooking() {
        var special = 3.141592;
        console.log(special);
        console.log(window.special);
    }

    keepLooking();
}

lookingFor(112358132134);
// 3.141592
// 42

全局的红色 (1) special 被蓝色 (2) special(参数)遮蔽,而蓝色 (2) special本身又被 keepLooking()内的绿色 (3) special遮蔽。我们仍然可以使用间接引用 window.special 访问红色 (1) special。但 keepLooking() 无法访问保存数字 112358132134 的蓝色 (2) special

复制不是访问

下面这个「但是......呢?」的问题我已经被问过几十次了。思考一下:

js
var special = 42;

function lookingFor(special) {
    var another = {
        special: special,
    };

    function keepLooking() {
        var special = 3.141592;
        console.log(special);
        console.log(another.special); // Ooo, tricky!
        console.log(window.special);
    }

    keepLooking();
}

lookingFor(112358132134);
// 3.141592
// 112358132134
// 42
var special = 42;

function lookingFor(special) {
    var another = {
        special: special,
    };

    function keepLooking() {
        var special = 3.141592;
        console.log(special);
        console.log(another.special); // Ooo, tricky!
        console.log(window.special);
    }

    keepLooking();
}

lookingFor(112358132134);
// 3.141592
// 112358132134
// 42

哦!那么,这种「另一种」对象技术是否推翻了我的说法,即在 keepLooking() 中「完全无法访问」special 参数?不,这个说法仍然正确。

special: special 是将 special 参数变量的值复制到另一个容器(同名属性)中。当然,如果把一个值放到另一个容器中,遮蔽就不再适用了(除非 another 也被遮蔽化了!)。但这并不意味着我们正在访问参数 special;而是意味着我们正在通过另一个容器(对象属性)访问它当时所具有的值的副本。我们不能在 keepLooking() 中将蓝色 (2) special 参数重新赋值。

你可能会提出另一个「但是......?」的问题:如果我用对象或数组代替数字(112358132134 等)作为值呢?我们有了对象的引用而不是原始值的副本,就能「解决」无法访问的问题吗?

通过引用副本更改对象值的内容与通过词法访问变量本身并不是一回事。我们仍然无法重新分配蓝色 (2) special 参数。

非法遮蔽

并非所有的声明遮蔽方式都是允许的。let 可以对 var 产生遮蔽,但 var 不能对 let 产生遮蔽:

js
function something() {
    var special = "JavaScript";

    {
        let special = 42; // 完全 ok 的遮蔽

        // ..
    }
}

function another() {
    // ..

    {
        let special = "JavaScript";

        {
            var special = "JavaScript";
            // ^^^ Syntax Error

            // ..
        }
    }
}
function something() {
    var special = "JavaScript";

    {
        let special = 42; // 完全 ok 的遮蔽

        // ..
    }
}

function another() {
    // ..

    {
        let special = "JavaScript";

        {
            var special = "JavaScript";
            // ^^^ Syntax Error

            // ..
        }
    }
}

注意在 another() 函数中,内部的 var special 声明试图声明一个函数作用域内的 special,这本身是没有问题的(如 something() 函数所示)。

在这种情况下,语法错误描述表明 special 已经被定义,但该错误信息有点误导。同样,在 something() 中不会出现这种错误,因为通常允许遮蔽。

它被当作 SyntaxError 引发的真正原因是 var 基本上是在试图「越界」(或跳过)同名的 let 声明,而这是不允许的。

禁止跨越作用域的规定实际上止于每个函数作用域,因此这个变体也不例外:

js
function another() {
    // ..

    {
        let special = "JavaScript";

        ajax("https://some.url", function callback() {
            // 完全 ok 的遮蔽
            var special = "JavaScript";

            // ..
        });
    }
}
function another() {
    // ..

    {
        let special = "JavaScript";

        ajax("https://some.url", function callback() {
            // 完全 ok 的遮蔽
            var special = "JavaScript";

            // ..
        });
    }
}

摘要: let(在内作用域中)总是可以遮蔽外作用域的 var。内作用域中的 var 只有在两者之间有函数作用域的情况下才会对外作用域中的 let 产生遮蔽。

函数名的作用域

正如你现在已经看到的,function 声明看起来像这样:

js
function askQuestion() {
    // ..
}
function askQuestion() {
    // ..
}

正如第 1 章和第 2 章所讨论的,这样的 function 声明将在外层作用域(这里是全局作用域)中创建一个名为 askQuestion 的标识符。

那以下代码呢?

js
var askQuestion = function () {
    // ..
};
var askQuestion = function () {
    // ..
};

正在创建的变量 askQuestion 也是如此。但由于它是一个 function 表达式吗,一个用作值的函数定义,而不是一个独立的声明。函数本身不会「提升」(参见第 5 章)。

函数声明与函数表达式的一个主要区别是函数名称标识符的处理。请考虑命名的 function 表达式:

js
var askQuestion = function ofTheTeacher() {
    // ..
};
var askQuestion = function ofTheTeacher() {
    // ..
};

我们知道 askQuestion 最后会进入外层作用域。但是 ofTheTeacher 的标识符呢?对于正式的 function 声明,名称标识符会在外层/封闭的作用域中结束,因此可以合理地认为这里的情况也是如此。但 ofTeacher 被声明为函数本身内部的标识符

js
var askQuestion = function ofTheTeacher() {
    console.log(ofTheTeacher);
};

askQuestion();
// function ofTheTeacher()...

console.log(ofTheTeacher);
// ReferenceError: ofTheTeacher is not defined
var askQuestion = function ofTheTeacher() {
    console.log(ofTheTeacher);
};

askQuestion();
// function ofTheTeacher()...

console.log(ofTheTeacher);
// ReferenceError: ofTheTeacher is not defined
注意:
实际上,ofTheTeacher 并不完全在函数的作用域内。附录 A 中「隐式作用域」会有进一步解释。

ofTheTeacher 不仅是在函数内部而不是外部声明的,而且还被定义为只读:

js
var askQuestion = function ofTheTeacher() {
    "use strict";
    ofTheTeacher = 42; // TypeError

    //..
};

askQuestion();
// TypeError
var askQuestion = function ofTheTeacher() {
    "use strict";
    ofTheTeacher = 42; // TypeError

    //..
};

askQuestion();
// TypeError

由于我们使用了严格模式,赋值失败会被报告为「类型错误 (TypeError)」;而在非严格模式下,这种赋值失败是无声的,不会抛出异常。

函数表达式没有名称标识符时怎么办?

js
var askQuestion = function () {
    // ..
};
var askQuestion = function () {
    // ..
};

有名称标识符的函数表达式称为「命名函数表达式」,而没有名称标识符的则称为「匿名函数表达式」。匿名函数表达式显然没有名称标识符来影响任何作用域。

注意:
我们将在附录 A 中更详细地讨论命名表达式和匿名函数表达式,包括影响使用它们的因素。

箭头函数

ES6 在语言中添加了一种额外的函数表达式,称为「箭头函数」:

js
var askQuestion = () => {
    // ..
};
var askQuestion = () => {
    // ..
};

箭头函数 => 不需要使用 function 来定义。此外,在某些简单的情况下,参数列表两侧的 ( .. ) 是可选的。同样,函数体两侧的 { .. } 在某些情况下也是可选的。如果省略了 { .. } 将直接返回值,而无需使用 return 关键字。

注意:
箭头函数 => 常常被说成是「语法糖」,并声称这等同于客观上更可读的代码。这种说法是可疑的,我认为完全是一种误导。我们将在附录 A 中探讨各种函数形式的「可读性」。

箭头函数在词法上是匿名的,这意味着它们没有直接相关的标识符来引用函数。对 askQuestion 的赋值创建了一个推断名称 "askQuestion",但这和匿名可不一样

js
var askQuestion = () => {
    // ..
};

askQuestion.name; // askQuestion
var askQuestion = () => {
    // ..
};

askQuestion.name; // askQuestion

箭头函数的语法简洁,是以不得不在头脑中为不同的形式/条件变来变去为代价的。举几个例子

js
() => 42;

(id) => id.toUpperCase();

(id, name) => ({ id, name });

(...args) => {
    return args[args.length - 1];
};
() => 42;

(id) => id.toUpperCase();

(id, name) => ({ id, name });

(...args) => {
    return args[args.length - 1];
};

我谈论箭头函数的真正原因是,箭头函数在词法作用域方面的行为与标准的 function 函数有所不同,这种说法很常见,但并不正确。

这是不正确的。

除了匿名(没有声明形式)之外,=> 箭头函数与 function 函数具有相同的词法作用域规则。一个箭头函数,带或不带 { .. } ,仍然会创建一个单独的、内部嵌套的作用域桶。在这个嵌套的作用域桶中的变量声明与在 function 作用域中的行为相同。

思维转换

当定义一个函数(声明或表达式)时,就会创建一个新的作用域。作用域相互嵌套,在整个程序中形成了一个自然的作用域层次结构,称为作用域链。作用域链控制变量的访问,向上和向外的。

每一个新的作用域都是一片净土,都有自己的变量空间。当一个变量名在作用域链的不同层级重复出现时,就会产生遮蔽,阻止从该层级向内访问外层变量。

当我们从这些更微小的细节中走出来时,下一章的重点将转移到所有 JS 程序都包含的主要作用域:全局作用域。


  1. 在程序设计中,变量遮蔽(英语:variable shadowing,或称变量隐藏)指的是:当新变量与旧有变量同名,此名称暂时不再可用于访问旧有变量https://zh.wikipedia.org/zh-cn/變數遮蔽 ,2022年12月19日。 ↩︎