Skip to content

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

第六章:限制作用域的过度暴露

到目前为止,我们的重点是解释作用域和变量的工作原理。有了坚实的基础,我们的注意力将转移到更高层次的思考上:我们应用于整个程序的决策和模式。

首先,我们要看看如何以及为什么要使用不同级别的作用域(函数和块)来组织程序的变量,特别是要减少作用域的过度暴露。

最小暴露

函数定义自己的作用域是合情合理的。但为什么我们还需要块来创建作用域呢?

软件工程阐明了一门基本学科,通常应用于软件安全,称为「最小权限原则」(POLP)。[1]而适用于我们当前讨论的这一原则的变体通常被称为「最小暴露」(POLE)。

POLP 表达了对软件架构的一种防御姿态:系统组件应设计为以最小权限、最小访问、最小暴露的方式运行。如果每个组件都以最低限度的必要功能连接起来,那么从安全的角度来看,整个系统就会更强大,因为一个组件的受损或失效对系统其他组件的影响就会降到最低。

如果说 POLP 侧重于系统级的组件设计,那么 POLE 暴露变体则侧重于更低层次;我们将把它应用于作用域之间的交互。

在遵循 POLE 的过程中,我们要尽量减少哪些变量的暴露?很简单:在每个作用域中注册的变量。

你可以这样想:为什么不把程序中的所有变量都放在全局作用域中呢?这可能会让人立刻觉得这是个坏主意,但值得考虑一下为什么会这样。当程序的一部分使用的变量通过作用域暴露给程序的另一部分时,通常会产生三大危害:

  • 命名碰撞:如果在程序的两个不同部分中使用了一个共同的、有用的变量/函数名,但标识符来自一个共享作用域(如全局作用域),那么就会发生命名碰撞,很可能会出现错误,因为一部分以另一部分意想不到的方式使用了该变量/函数。

    例如,试想一下,如果您的所有循环都使用了一个全局 i 索引变量,然后一个函数中的一个循环在另一个函数的循环迭代期间运行,现在共享的 i 变量得到了一个意想不到的值。

  • 意想不到的行为:如果你将变量/函数的用途公开,而这些变量/函数的用途在程序中是私有的,那么其他开发人员就可以用你没有想到的方式使用它们,这可能会违反预期行为并导致错误。

    例如,如果你的程序假定数组包含所有数字,但其他人的代码访问并修改了数组,使其包含布尔和字符串,那么你的代码可能会出现意想不到的错误行为。

    更糟糕的是,暴露隐私细节会招致那些居心不良的人试图绕过你施加的限制,用你的那部分软件做一些不该做的事情。

  • 意外的依赖:如果你不必要地暴露变量/函数,就会招致其他开发人员使用和依赖这些原本私人的部分。虽然这不会破坏你现在的程序,但会给将来的重构带来隐患,因为现在你不可能轻易地重构该变量或函数,而不会破坏软件中你无法控制的其他部分。[2]

    例如,如果您的代码依赖于数字数组,而您后来决定最好使用其他数据结构而不是数组,那么您现在就必须承担调整软件其他受影响部分的责任。

POLE 应用于变量/函数的作用域时,主要是说,默认情况下只暴露必要的最低限度,其他一切尽可能保持私有。在尽可能小的深度嵌套作用域中声明变量,而不是把所有东西都放在全局(甚至外层函数)作用域中。

如果在设计软件时考虑到这一点,就有更大的机会避免(或至少减少)这三种危害。

思考一下:

js
function diff(x, y) {
    if (x > y) {
        let tmp = x;
        x = y;
        y = tmp;
    }

    return y - x;
}

diff(3, 7); // 4
diff(7, 5); // 2
function diff(x, y) {
    if (x > y) {
        let tmp = x;
        x = y;
        y = tmp;
    }

    return y - x;
}

diff(3, 7); // 4
diff(7, 5); // 2

在这个 diff(..)函数中,我们要确保 y 大于或等于 x,这样当我们减去 (y-x),结果就是 0 或更大。如果 x 最初较大(结果将为负数!),我们将使用 tmp 变量交换 xy,以保持结果为正数。

在这个简单的例子中,tmp 是否在 if 代码块中或是否属于函数级似乎并不重要。它当然不应该是一个全局变量!然而,根据 POLE 原则,tmp 应尽可能隐藏在作用域中。因此,我们将 tmp 的作用域屏蔽(使用 let )到 if 代码块中。

隐藏在普通(函数)作用域内

现在我们应该清楚为什么要尽可能将变量和函数声明隐藏在最低(嵌套最深)的作用域中了。但我们该如何做呢?

我们已经看到了 letconst 关键字,它们是块作用域声明符;稍后我们将更详细地讨论它们。但首先,如何在作用域中隐藏 varfunction 声明呢?这可以通过在声明周围包装一个 function 作用域来轻松实现。

让我们举一个例子来说明 function 作用域的作用。

数学运算「阶乘」[3](写为 "6!")是一个给定整数与所有依次低至 1 的整数相乘—实际上,你可以停在 2 ,因为与 1 相乘没有任何作用。换句话说,"6!" 等同于 "6 5!" ,同样等同于 "6 5 * 4!",以此类推。由于所涉及数学的性质,一旦计算出任何给定整数的阶乘(如 "4!"),我们就不需要再做这项工作了,因为答案总是一样的。

因此,如果你天真地计算了 6 的阶乘,然后又想计算 7 的阶乘,你可能会不必要地重新计算从 2 到 6 的所有整数的阶乘。如果你愿意用内存来换取速度,你可以在计算时缓存每个整数的阶乘来解决这种浪费:

js
var cache = {};

function factorial(x) {
    if (x < 2) return 1;
    if (!(x in cache)) {
        cache[x] = x * factorial(x - 1);
    }
    return cache[x];
}

factorial(6);
// 720

cache;
// {
//     "2": 2,
//     "3": 6,
//     "4": 24,
//     "5": 120,
//     "6": 720
// }

factorial(7);
// 5040
var cache = {};

function factorial(x) {
    if (x < 2) return 1;
    if (!(x in cache)) {
        cache[x] = x * factorial(x - 1);
    }
    return cache[x];
}

factorial(6);
// 720

cache;
// {
//     "2": 2,
//     "3": 6,
//     "4": 24,
//     "5": 120,
//     "6": 720
// }

factorial(7);
// 5040

我们将所有计算出的阶乘存储在 cache 中,这样在多次调用 factorial(..) 时,之前的计算结果就会保留下来。但 cache 变量显然是 factorial(..) 工作原理中的一个私有细节,不应该暴露在外部作用域中,尤其是全局作用域。

注意:
这里的 factorial(..)是递归的—从内部调用自身,但这只是为了代码的简洁;非递归的实现会产生与 cache 相同的作用域。

然而,要解决这个过度暴露的问题,并不像看起来那样简单,只需将 cache 变量隐藏在 factorial(..) 中即可。因为我们需要 cache 在多次调用后仍能存活,所以它必须位于该函数之外的作用域中。那么我们该怎么办呢?

cache 定义另一个中间作用域(介于外部/全局作用域和 factorial(..) 内部之间):

js
// 外层/全局作用域

function hideTheCache() {
    // "中间作用域",在这里我们隐藏了 `cache`
    var cache = {};

    return factorial;

    // **********************

    function factorial(x) {
        // 内部作用域
        if (x < 2) return 1;
        if (!(x in cache)) {
            cache[x] = x * factorial(x - 1);
        }
        return cache[x];
    }
}

var factorial = hideTheCache();

factorial(6);
// 720

factorial(7);
// 5040
// 外层/全局作用域

function hideTheCache() {
    // "中间作用域",在这里我们隐藏了 `cache`
    var cache = {};

    return factorial;

    // **********************

    function factorial(x) {
        // 内部作用域
        if (x < 2) return 1;
        if (!(x in cache)) {
            cache[x] = x * factorial(x - 1);
        }
        return cache[x];
    }
}

var factorial = hideTheCache();

factorial(6);
// 720

factorial(7);
// 5040

hideTheCache() 函数除了为 cache 创建一个作用域,以便在多次调用 factorial(..) 时持续存在之外,没有其他作用。但为了让 factorial(..)能够访问 cache,我们必须在同一个作用域中定义 factorial(..)。然后,我们从 hideTheCache() 返回函数引用作为值,并将其存储在外作用域变量中,该变量也命名为 factorial。现在,当我们调用 factorial(..)(多次!)时,其持久的 cache 将保持隐藏,但只有 factorial(..)可以访问!

好吧,但是......每次需要隐藏变量/函数时,都要定义(并命名!)hideTheCache(..) 函数作用域,这将会非常繁琐,尤其是我们可能希望通过为每次出现的函数命名一个唯一的名称来避免名称冲突。唉。

注意:
图解技术:缓存函数的计算输出[4]中提到,重复调用相同输入时优化性能在函数式编程(FP)领域非常常见,通常称为「记忆函数 (memoization)」;这种缓存依赖于闭包(参见第 7 章)。此外,还有内存使用方面的问题(在附录 B 的「内存问题」中讨论)。FP 库通常会为函数的内存化提供一个经过优化和审查的实用程序,在这里它将取代 hideTheCache(..)。记忆函数超出了我们的讨论范围(双关语!scope 译作范围的同时也可理解为作用域),更多信息请参阅我(原作者)的 Functional-Light JavaScript[5] 一书。

与每次都定义一个新的、唯一命名的函数相比,也许更好的解决方案是使用函数表达式:

js
var factorial = (function hideTheCache() {
    var cache = {};

    function factorial(x) {
        if (x < 2) return 1;
        if (!(x in cache)) {
            cache[x] = x * factorial(x - 1);
        }
        return cache[x];
    }

    return factorial;
})();

factorial(6);
// 720

factorial(7);
// 5040
var factorial = (function hideTheCache() {
    var cache = {};

    function factorial(x) {
        if (x < 2) return 1;
        if (!(x in cache)) {
            cache[x] = x * factorial(x - 1);
        }
        return cache[x];
    }

    return factorial;
})();

factorial(6);
// 720

factorial(7);
// 5040

等等!这仍然是在使用一个函数来创建隐藏 cache 的作用域,而且在这种情况下,该函数仍然被命名为 hideTheCache,这又能解决什么问题呢?

回顾一下「函数名的作用域」(第 3 章),函数表达式的名称标识符会发生什么变化。由于 hideTheCache(..) 被定义为一个 function 表达式而不是一个 function 声明,所以它的名称在它自己的作用域中,基本上与 cache 相同而不是在外层/全局作用域中。

这意味着,我们可以将此类函数表达式的每一次出现都命名为完全相同的名称,而绝不会出现任何冲突。更恰当地说,我们可以根据我们试图隐藏的内容,对每个出现的表达式进行语义命名,而不必担心我们选择的任何名称会与程序中的任何其他函数表达式作用域相冲突。

事实上,我们完全可以不使用名称,而是定义一个「匿名函数表达式」。但附录 A 将讨论名称的重要性,即使是对于这种 scope-only 的函数。

立即调用函数表达式 (IIFE)

在前面的阶乘递归程序中,还有一个很容易被忽略的重要部分:函数表达式末尾包含 })(); 的那一行。

请注意,我们用一组 ( .. ) 包围了整个函数表达式,然后在最后添加了第二组 () 括号;这实际上是在调用我们刚刚定义的函数表达式。此外,在这种情况下,函数表达式周围的第一组 ( .. ) 严格来说并不是必需的(稍后会详细说明),但为了便于阅读,我们还是使用了它们。

换句话说,我们正在定义一个函数表达式,然后立即调用它。这种常见的模式有一个非常有创意的名字:立即调用函数表达式 (IIFE)。

当我们想创建一个隐藏变量/函数的作用域时,IIFE 就会派上用场。因为它是一个表达式,所以可以用在 JS 程序中任何允许使用表达式的地方。IIFE 可以命名,如 hideTheCache(),也可以(更常见!)不命名/匿名。它可以是独立的,也可以是另一个语句的一部分:hideTheCache() 返回 factorial() 函数引用,然后 = 赋值给变量 factorial

下面是一个独立 IIFE 的示例,以资比较:

js
// 外层作用域

(function () {
    // 内部隐藏作用域
})();

// 更多外层作用域
// 外层作用域

(function () {
    // 内部隐藏作用域
})();

// 更多外层作用域

与之前的 hideTheCache() 不同,外围的 (..) 被认为是一种可选的样式选择,而对于独立的 IIFE,它们是 必须的;它们将 function 区分为一个表达式,而不是一个语句。不过,为了保持一致,在 IIFE function 的周围一定要加上 ( .. )

注意:
从技术上讲,周围的 ( .. ) 并不是确保 JS 解析器将 IIFE 中的 function 视为函数表达式的唯一语法方式。我们将在附录 A 中查看其他一些选择。

函数边界

请注意,使用 IIFE 定义作用域可能会产生一些意想不到的后果,这取决于它周围的代码。因为 IIFE 是一个完整的函数,函数边界会改变某些语句/结构体的行为。

例如,如果代码中的 return 语句被 IIFE 包住,它的含义就会改变,因为现在 return 指的是 IIFE 的函数。非箭头函数 IIFE 也会改变 this 关键字的绑定,更多内容请参阅对象与类一书。像 breakcontinue 这样的语句不会跨越 IIFE 函数边界来控制外循环或代码块。

因此,如果您需要封装作用域的代码中包含 returnthisbreakcontinue 字符,那么 IIFE 可能不是最好的方法。在这种情况下,您可以使用代码块而不是函数来创建作用域。

块的作用域

至此,您应该对创建作用域以限制标识符暴露的优点感到相当满意了。

到目前为止,我们已经了解了如何通过函数(即 IIFE)作用域来实现这一点。现在我们来考虑使用嵌套代码块中的 let 声明。一般来说,任何{ .. } 作为语句的大括号对将作为一个块,但不一定作为作用域。

块只有在必要时才成为作用域,以包含其块作用域声明(即 letconst)。思考一下:

js
{
    // 不一定(尚未)是作用域

    // ..

    // 现在我们知道该区块需要是一个作用域
    let thisIsNowAScope = true;

    for (let i = 0; i < 5; i++) {
        // 每次循环这也是一个作用域
        // 迭代
        if (i % 2 == 0) {
            // 这只是一个块,不是作用域
            console.log(i);
        }
    }
}
// 0 2 4
{
    // 不一定(尚未)是作用域

    // ..

    // 现在我们知道该区块需要是一个作用域
    let thisIsNowAScope = true;

    for (let i = 0; i < 5; i++) {
        // 每次循环这也是一个作用域
        // 迭代
        if (i % 2 == 0) {
            // 这只是一个块,不是作用域
            console.log(i);
        }
    }
}
// 0 2 4

并非所有的 { .. } 大括号都会创建块(因此有资格成为作用域):

  • 对象字面使用 { .. } 大括号对来分隔键值对,但这种对象值不是作用域。
  • class 在其主体定义周围使用了 { .. } 大括号,但这不是一个块或作用域。
  • function 在其主体周围使用 { .. } 围绕着它的主体,但从技术上讲,这并不是一个块:它是函数主体的单个语句。但它一个(函数)作用域。
  • switch 语句上的一对 { .. } 大括号(围绕 case 子句集)并不定义块/作用域。

除了这些非代码块示例之外, { .. } 大括号对可以定义一个附在语句(如 iffor)之后的块,也可以单独定义,见上一段中最外层的 { .. } 括号对。对于这种显式块如果它没有声明,实际上就不是一个作用域,没有任何操作上的用途,尽管它作为语义信号仍然有用。

明确的独立 { .. } 块一直都是有效的 JS 语法,但由于在 ES6 的 let/const 之前它们不能作为作用域,所以它们非常罕见。不过,在 ES6 之后,它们开始逐渐流行起来。

在大多数支持块作用域的语言中,显式块作用域是一种极为常见的模式,用于为一个或几个变量创建狭小的作用域。因此,根据 POLE 原则,我们也应该在 JS 中更广泛地采用这种模式;使用(显式)块作用域将标识符的暴露范围缩小到最小。

即使在另一个块内部,显式块作用域也是有用的(无论外部块是否是一个作用域)。

例如:

js
if (somethingHappened) {
    // 这是一个块但不是作用域

    {
        // 这既是一个块
        // 也是一个显式作用域
        let msg = somethingHappened.message();
        notifyOthers(msg);
    }

    // ..

    recoverFromSomething();
}
if (somethingHappened) {
    // 这是一个块但不是作用域

    {
        // 这既是一个块
        // 也是一个显式作用域
        let msg = somethingHappened.message();
        notifyOthers(msg);
    }

    // ..

    recoverFromSomething();
}

在这里,{ .. } 大括号对在 if 语句的内部,是 msg 的一个更小的内部显式代码块作用域,因为整个 if 代码块都不需要该变量。大多数开发人员都会直接将 msg 代码块作用到 if 代码块中,然后继续开发。公平地说,当只需要考虑几行代码时,这是一个折腾人的判断。但随着代码的增加,这些过度暴露的问题就会变得更加明显。

那么,添加额外的 { .. } 和缩进级别是否足够重要?我认为您应该遵循 POLE,始终(在合理作用域内)为每个变量定义最小的块。因此,我建议使用如图所示的额外显式块作用域。

回顾「未初始化变量警告 (TDZ)」(第 5 章)中对 TDZ 误差的讨论。我在那里提出的建议是:为了尽量减少使用 let/const 声明时出现 TDZ 错误的风险,应始终将这些声明放在其作用域的顶端。

如果你发现自己在一个作用域中间放置了一个 let 声明,首先要想的是「哦,不!TDZ 警报!」如果该代码块的前半部分不需要这个 let 声明,您就应该使用内部显式代码块作用域来进一步缩小它的暴露范围!

另一个明确块作用域的例子:

js
function getNextMonthStart(dateStr) {
    var nextMonth, year;

    {
        let curMonth;
        [, year, curMonth] = dateStr.match(/(\d{4})-(\d{2})-\d{2}/) || [];
        nextMonth = (Number(curMonth) % 12) + 1;
    }

    if (nextMonth == 1) {
        year++;
    }

    return `${year}-${String(nextMonth).padStart(2, "0")}-01`;
}
getNextMonthStart("2019-12-25"); // 2020-01-01
function getNextMonthStart(dateStr) {
    var nextMonth, year;

    {
        let curMonth;
        [, year, curMonth] = dateStr.match(/(\d{4})-(\d{2})-\d{2}/) || [];
        nextMonth = (Number(curMonth) % 12) + 1;
    }

    if (nextMonth == 1) {
        year++;
    }

    return `${year}-${String(nextMonth).padStart(2, "0")}-01`;
}
getNextMonthStart("2019-12-25"); // 2020-01-01

首先让我们来确定作用域及其标识符:

  1. 外部/全局作用域有一个标识符,即函数 getNextMonthStart(..)
  2. getNextMonthStart(..) 的函数作用域有三个:dateStr(参数)、nextMonthyear
  3. { .. } 大括号对定义了一个包含一个变量的内部块作用域: curMonth

那么,为什么要将 curMonth 放在显式的块作用域中,而不是在顶级函数作用域中与 nextMonthyear 放在一起呢?因为 curMonth 只需要用于前两个语句;在函数作用域级别,它已经过度暴露了。

这个示例很小,因此过度暴露 curMonth 的危害非常有限。但是,当您把默认情况下最小化作用域暴露作为一种习惯时,POLE 原则的好处就能发挥得淋漓尽致。如果您能始终如一地遵循该原则,即使是在很小的情况下,随着程序的发展,它也会为您提供更多的作用。

现在我们来看一个更有说服力的例子:

js
function sortNamesByLength(names) {
    var buckets = [];

    for (let firstName of names) {
        if (buckets[firstName.length] == null) {
            buckets[firstName.length] = [];
        }
        buckets[firstName.length].push(firstName);
    }

    // 一个块,以缩小作用域
    {
        let sortedNames = [];

        for (let bucket of buckets) {
            if (bucket) {
                // 按字母数字对每个桶进行排序
                bucket.sort();

                // 将排序后的名称添加到运行列表中
                sortedNames = [...sortedNames, ...bucket];
            }
        }

        return sortedNames;
    }
}

sortNamesByLength(["Sally", "Suzy", "Frank", "John", "Jennifer", "Scott"]);
// [ "John", "Suzy", "Frank", "Sally",
//   "Scott", "Jennifer" ]
function sortNamesByLength(names) {
    var buckets = [];

    for (let firstName of names) {
        if (buckets[firstName.length] == null) {
            buckets[firstName.length] = [];
        }
        buckets[firstName.length].push(firstName);
    }

    // 一个块,以缩小作用域
    {
        let sortedNames = [];

        for (let bucket of buckets) {
            if (bucket) {
                // 按字母数字对每个桶进行排序
                bucket.sort();

                // 将排序后的名称添加到运行列表中
                sortedNames = [...sortedNames, ...bucket];
            }
        }

        return sortedNames;
    }
}

sortNamesByLength(["Sally", "Suzy", "Frank", "John", "Jennifer", "Scott"]);
// [ "John", "Suzy", "Frank", "Sally",
//   "Scott", "Jennifer" ]

在五个不同的作用域中声明了六个标识符。这些变量是否都可以存在于一个外部/全局作用域中?从技术上讲,是可以的,因为它们的名称都是唯一的,因此不会发生名称冲突。但这样的代码组织实在是太糟糕了,很可能会导致混乱和将来的错误。

我们根据需要将它们分割到每个内部嵌套作用域中。每个变量都定义在程序运行所需的最内层作用域。

sortedNames 本来可以在顶层函数作用域中定义,但这个函数的后半部分才需要它。为了避免在更高级的作用域中过度暴露该变量,我们再次遵循 POLE 的做法,在内部显式块作用域中对其进行块作用域定义。

var let

接下来,让我们来谈谈 var buckets 声明。该变量用于整个函数(除了最后的 return 语句)。任何需要在函数的全部(甚至大部分)部分使用的变量,都应该声明得一目了然。

注意:
参数 names 并未在整个函数中使用,但没有办法限制参数的作用域,因此无论如何,它都是函数作用域内的声明。

那么,为什么我们使用 var 而不是 let 来声明 buckets 变量呢?选择 var 既有语义上的原因,也有技术上的原因。

在风格上,var 从最早的 JS 开始就表示「属于整个函数的变量」。正如我们在「词法作用域」(第 1 章)中所断言的,var 不管出现在哪里,都会依附于最近的外层函数作用域。即使 var 出现在一个块中也是如此:

js
function diff(x, y) {
    if (x > y) {
        var tmp = x; // `tmp` 是函数作用域
        x = y;
        y = tmp;
    }

    return y - x;
}
function diff(x, y) {
    if (x > y) {
        var tmp = x; // `tmp` 是函数作用域
        x = y;
        y = tmp;
    }

    return y - x;
}

即使 var 位于代码块内部,它的声明也是函数作用域的(对 diff(..)),而不是代码块作用域的。

虽然您可以在代码块中声明 var(并且它仍然是函数作用域),但我建议您不要采用这种方法,除非在少数特殊情况下(在附录 A 中讨论)。否则,var 应保留给函数的顶级作用域使用。

为什么不在同一位置使用 let 呢?因为 varlet 在视觉上截然不同,因此可以清楚地表明「此变量属于函数作用域」。在顶层作用域中使用 let,尤其是如果不是在函数的前几行中使用,并且在代码块中的所有其他声明都使用 let 时,并不会在视觉上引起人们注意与函数作用域声明的区别。

换句话说,我觉得 varlet 更好地传达了函数作用域,而 let 既传达了(也实现了!)块作用域,而 var 则不够。只要你的程序既需要函数作用域变量又需要块作用域变量,那么最明智、最易读的方法就是同时使用 var let 这两种变量,它们各自都有自己的最佳用途。

在不同的情况下,选择 varlet 还有其他语义和操作上的原因。我们将在附录 A 中更详细地探讨 var let 的情况。

警告:
我建议同时使用 var let 显然是有争议的,而且与大多数人的观点相悖。更常见的说法是:「var 是坏的,let 能修复它」和「永远不要使用 var,let 是替代品」。这些观点都有道理,但它们只是观点,就像我的观点一样。事实上, var 并没有坏掉或被弃用;它从早期的 JS 开始就一直有效,只要 JS 还在,它就会一直有效。

在哪里使用 let

我建议将 var 保留给(大部分)仅用于顶级函数作用域,这意味着其他大多数声明都应使用 let。但您可能仍想知道如何决定程序中每个声明的归属?

POLE 已经为您的决策提供了指导,但我们还是要明确指出这一点。决定的方式不是基于你想使用哪个关键字。决定的方法是问:「对这个变量来说,最起码的作用域暴露是什么?」

一旦回答了这个问题,你就会知道变量属于块作用域还是函数作用域。如果一开始决定某个变量应属于块作用域,但后来意识到需要将其提升为函数作用域,那么不仅需要改变变量的声明位置,还需要改变所使用的声明器关键字。决策过程确实应该这样进行。

如果声明属于块作用域,则使用 let。如果声明属于函数作用域,则使用 var(再次重申,这只是我的观点)。

但另一种可视化决策的方法是考虑程序的 ES6 前版本。例如,让我们回顾一下之前的 diff(..)

js
function diff(x, y) {
    var tmp;

    if (x > y) {
        tmp = x;
        x = y;
        y = tmp;
    }

    return y - x;
}
function diff(x, y) {
    var tmp;

    if (x > y) {
        tmp = x;
        x = y;
        y = tmp;
    }

    return y - x;
}

在这个版本的 diff(..) 中,tmp 被清楚地声明在函数作用域中。这对 tmp 来说合适吗?我认为不合适。tmp 只需要在这几个语句中使用。在 return 语句中不需要它。因此,它应该是块作用域。

在 ES6 之前,我们没有let,因此无法对其进行实际的块作用域化。不过,我们可以做一个次好的事情来表明我们的意图:

js
function diff(x, y) {
    if (x > y) {
        // `tmp` 仍然在函数作用域
        // 但放置在这里
        // 在语义上表示块作用域
        var tmp = x;
        x = y;
        y = tmp;
    }

    return y - x;
}
function diff(x, y) {
    if (x > y) {
        // `tmp` 仍然在函数作用域
        // 但放置在这里
        // 在语义上表示块作用域
        var tmp = x;
        x = y;
        y = tmp;
    }

    return y - x;
}

tmpvar 声明放在 if 语句中,向代码读者发出了 tmp 属于该代码块的信号。尽管 JS 并不强制执行该作用域定义,但语义信号仍会给代码读者带来好处。

从这个角度看,你可以找到这种代码块中的任何 var 并将其切换为 let 以执行已经发送的语义信号。在我看来,这才是 let 的正确用法。

另一个历史上使用 var 但现在几乎总是使用 let 的例子是 for 循环:

js
for (var i = 0; i < 5; i++) {
    // 做点什么
}
for (var i = 0; i < 5; i++) {
    // 做点什么
}

无论在哪里定义了这样的循环,i 基本上都只能在循环内部使用,在这种情况下,POLE 规定应该用 let 而不是 var 来声明它:

js
for (let i = 0; i < 5; i++) {
    // 做点什么
}
for (let i = 0; i < 5; i++) {
    // 做点什么
}

var 替换为 let 几乎是唯一会「破坏」代码的情况,那就是在循环之外/之后访问循环的迭代器 (i),例如:

js
for (var i = 0; i < 5; i++) {
    if (checkValue(i)) {
        break;
    }
}

if (i < 5) {
    console.log("The loop stopped early!");
}
for (var i = 0; i < 5; i++) {
    if (checkValue(i)) {
        break;
    }
}

if (i < 5) {
    console.log("The loop stopped early!");
}

这种使用模式并不罕见,但大多数人都觉得它的代码结构很糟糕。更可取的方法是使用另一个外层作用域变量:

js
var lastI;

for (let i = 0; i < 5; i++) {
    lastI = i;
    if (checkValue(i)) {
        break;
    }
}

if (lastI < 5) {
    console.log("The loop stopped early!");
}
var lastI;

for (let i = 0; i < 5; i++) {
    lastI = i;
    if (checkValue(i)) {
        break;
    }
}

if (lastI < 5) {
    console.log("The loop stopped early!");
}

整个作用域都需要 lastI,所以用 var 声明。只有在(每次)循环迭代中才需要 i,所以用 let 声明。

Catch 是什么?

到目前为止,我们已经断言 var 和参数是函数作用域,而 let/const 则是块作用域声明。有一个小例外需要指出:catch 子句。

自从在 ES3(1999 年)中引入 try..catch 以来,catch 子句一直使用附加的(鲜为人知的)块作用域声明功能:

js
try {
    doesntExist();
} catch (err) {
    console.log(err);
    // ReferenceError: 'doesntExist' is not defined
    // ^^^^ 从捕获的异常中打印的消息

    let onlyHere = true;
    var outerVariable = true;
}

console.log(outerVariable); // true

console.log(err);
// ReferenceError: 'err' is not defined
// ^^^^ 这是另一个抛出(未捕获)的异常
try {
    doesntExist();
} catch (err) {
    console.log(err);
    // ReferenceError: 'doesntExist' is not defined
    // ^^^^ 从捕获的异常中打印的消息

    let onlyHere = true;
    var outerVariable = true;
}

console.log(outerVariable); // true

console.log(err);
// ReferenceError: 'err' is not defined
// ^^^^ 这是另一个抛出(未捕获)的异常

catch 子句声明的 err 变量是该代码块的块作用域。这个 catch 子句块可以通过 let 保存其他块作用域声明。但该代码块中的 var 声明仍会附加到外层函数/全局作用域。

ES2019 (最近,在撰写本文时)更改了 catch 子句,使其声明成为可选项;如果省略声明,则 catch 代码块不再是(默认情况下)作用域;但它仍然是一个代码块!

因此,如果您需要对异常发生的条件做出反应(以便优雅地恢复),但不关心错误值本身,则可以省略 catch 声明:

js
try {
    doOptionOne();
} catch {
    // 省略 catch 的声明
    doOptionTwoInstead();
}
try {
    doOptionOne();
} catch {
    // 省略 catch 的声明
    doOptionTwoInstead();
}

对于一个相当常见的用例来说,这是对语法的一个微小但却令人高兴的简化,而且在删除不必要的作用域方面也可能略胜一筹!

块中的声明函数 (FiB)

我们已经看到,使用 letconst 的声明是块作用域,而 var 声明是函数作用域。那么,直接出现在代码块中的 function 声明又是怎么回事呢?作为一种特性,这被称为 "FiB"。

我们通常认为 function 声明等同于 var 声明。那么,它们的函数作用域是否与 var 相同?

是又不是。我知道......这很令人困惑。让我们深入了解一下:

js
if (false) {
    function ask() {
        console.log("Does this run?");
    }
}
ask();
if (false) {
    function ask() {
        console.log("Does this run?");
    }
}
ask();

您期望该程序做些什么?三个合理的结果:

  1. 调用 ask() 时可能会出现 ReferenceError 异常,因为 ask 标识符是块作用域中的 if 块作用域,因此在外部/全局作用域中不可用。
  2. ask() 调用可能会因 TypeError 异常而失败,因为 ask 标识符虽然存在,但它是 undefined 的(因为 if 语句没有运行),因此不是一个可调用函数。
  3. ask() 调用可能会正确运行,并打印出 "Does this run?"

令人困惑的是:根据您在哪个 JS 环境中尝试该代码片段,可能会得到不同的结果!这是现有遗留行为暴露出可预测结果的少数几个疯狂领域之一。

根据 JS 规范,块内的 function 声明具有块作用域,因此答案应该是 (1)。然而,大多数基于浏览器的 JS 引擎(包括 v8,它来自 Chrome 浏览器,但也用于 Node)会表现为 (2),这意味着标识符的作用域在 if 块之外,但函数值不会自动初始化,因此它仍然是 undefined

为什么允许浏览器 JS 引擎的行为违背规范?因为在 ES6 引入块作用域之前,这些引擎就已经有了与 FiB 相关的某些行为,而且有人担心,如果修改为遵守规范,可能会破坏某些现有的网站 JS 代码。因此,在 JS 规范的附录 B 中规定了例外情况,允许浏览器 JS 引擎(仅限!)做出某些偏离。

注意:
你通常不会把 Node 归类为浏览器 JS 环境,因为它通常在服务器上运行。但 Node 的 v8 引擎与 Chrome(和 Edge)浏览器共享。由于 v8 首先是一个浏览器 JS 引擎,因此它采用了附录 B 中的例外,这就意味着浏览器例外也扩展到了 Node。

在代码块中放置 function 声明的最常见用例之一,是根据某些环境状态,以某种方式有条件地定义函数(如使用 if..else 语句)。例如:

js
if (typeof Array.isArray != "undefined") {
    function isArray(a) {
        return Array.isArray(a);
    }
} else {
    function isArray(a) {
        return Object.prototype.toString.call(a) == "[object Array]";
    }
}
if (typeof Array.isArray != "undefined") {
    function isArray(a) {
        return Array.isArray(a);
    }
} else {
    function isArray(a) {
        return Object.prototype.toString.call(a) == "[object Array]";
    }
}

出于性能考虑,这样构建代码很有诱惑力,因为 typeof Array.isArray 检查只执行一次,而不是只定义一个 isArray(..) 并将 if 语句放在其中,这样每次调用时都会执行不必要的检查。

警告:
除了 FiB 偏差的风险之外,函数的条件定义的另一个问题是调试这样的程序更加困难。如果最终在isArray(..)函数中发现了一个错误,那么首先必须弄清楚isArray(..)的实际运行情况!有时,错误是由于条件检查不正确而应用了错误的实现!如果定义了一个函数的多个版本,程序的推理和维护就会变得更加困难。

除了前面的片段,还潜伏着其他一些 FiB 情况;在各种浏览器和非浏览器 JS 环境(非基于浏览器的 JS 引擎)中,此类行为可能会有所不同。例如:

js
if (true) {
    function ask() {
        console.log("Am I called?");
    }
}

if (true) {
    function ask() {
        console.log("Or what about me?");
    }
}

for (let i = 0; i < 5; i++) {
    function ask() {
        console.log("Or is it one of these?");
    }
}

ask();

function ask() {
    console.log("Wait, maybe, it's this one?");
}
if (true) {
    function ask() {
        console.log("Am I called?");
    }
}

if (true) {
    function ask() {
        console.log("Or what about me?");
    }
}

for (let i = 0; i < 5; i++) {
    function ask() {
        console.log("Or is it one of these?");
    }
}

ask();

function ask() {
    console.log("Wait, maybe, it's this one?");
}

回想一下「何时使用变量?」(第 5 章)中所述,本代码段中最后一个以 "Wait, maybe..." 为信息的ask() 函数应该提升在 ask() 的调用之上。因为它是该名称的最后一个函数声明,所以它应该「赢」,对吗?很遗憾,不是。

我无意记录所有这些奇怪的角落情况,也不想解释它们为什么会以某种方式出现。在我看来,这些信息都是神秘的遗产[6]

对于 FiB,我真正关心的是,我能给出什么建议来确保你的代码在任何情况下都能可预测地运行?

在我看来,要避免 FiB 的变幻莫测,唯一切实可行的办法就是完全避免 FiB。换句话说,永远不要将 function 声明直接放在任何代码块中。始终将 function 声明放在函数顶层作用域(或全局作用域)的任何地方。

因此,对于前面的 if..else 示例,我的建议是尽可能避免有条件地定义函数。是的,这可能会稍微降低性能,但这是顾全大局的方法:

js
function isArray(a) {
    if (typeof Array.isArray != "undefined") {
        return Array.isArray(a);
    } else {
        return Object.prototype.toString.call(a) == "[object Array]";
    }
}
function isArray(a) {
    if (typeof Array.isArray != "undefined") {
        return Array.isArray(a);
    } else {
        return Object.prototype.toString.call(a) == "[object Array]";
    }
}

如果性能下降成为应用程序的关键路径问题,我建议您考虑这种方法:

js
var isArray = function isArray(a) {
    return Array.isArray(a);
};

// 如果必要的话,覆盖该定义
if (typeof Array.isArray == "undefined") {
    isArray = function isArray(a) {
        return Object.prototype.toString.call(a) == "[object Array]";
    };
}
var isArray = function isArray(a) {
    return Array.isArray(a);
};

// 如果必要的话,覆盖该定义
if (typeof Array.isArray == "undefined") {
    isArray = function isArray(a) {
        return Object.prototype.toString.call(a) == "[object Array]";
    };
}

需要注意的是,这里我在 if 语句中放置了一个 function 表达式,而不是一个声明。在代码块中出现 function 表达式是完全正确的。我们关于 FiB 的讨论是关于避免在代码块中使用 function 声明

即使您测试了您的程序并能正确运行,但在代码中使用 FiB 样式所带来的微小好处,远不能抵消将来被其他开发人员混淆的潜在风险,或您的代码在其他 JS 环境中运行的差异。

FiB 不值得一试,应该避免它。

刀过竹解

在编程语言中使用词法作用域规则的意义在于,我们可以适当地组织程序中的变量,以达到操作和语义代码交流的目的。

最重要的组织技巧之一就是确保没有变量过度暴露在不必要的作用域 (POLE) 中。希望你现在对块作用域的理解比以前更深刻了。

希望到此为止,你对词法作用域的理解已经有了更坚实的基础。在此基础上,下一章将进入闭包这一重要话题。


  1. 最小权限原则https://zh.wikipedia.org/wiki/最小权限原则, 2021年12月26日。 ↩︎

  2. 苹果底层开源代码被发现包含兼容微信的代码https://www.oschina.net/news/127177/apple-libmalloc-compliance-with-wechat, 2021年1月20日。 ↩︎

  3. 阶乘https://zh.wikipedia.org/zh-cn/階乘, 2023年8月23日。 ↩︎

  4. Incremental Computation via Function Cachinghttps://dl.acm.org/doi/pdf/10.1145/75277.75305, 1989年。 ↩︎

  5. getify/Functional-Light-JS: Pragmatic, balanced FP in JavaScripthttps://github.com/getify/Functional-Light-JS, 2016年8月17日。 ↩︎

  6. 代码异味https://zh.wikipedia.org/zh-cn/代码异味, 2021年12月16日。 ↩︎