原文链接:https://davidwalsh.name/for-and-against-let

在这篇文章中,我将要梳理一下 JavaScript ES6 中获得人们鼓吹(或者还有反对?)的新特性: let 关键字。let 引入了一个先前并不广为 JS 开发者所接受的作用域实现形式——块作用域。

函数作用域

让我们简单回顾一下函数作用域的知识——如果你想要了解更多,请移步我写的 “You Don't Know JS” 系列中的 《You Don't Know JS: Scope & Closures》。

看一下下面这段代码:

foo();    // 42

function foo() {
    var bar = 2;
    if (bar > 1 || bam) {
        var baz = bar * 10;
    }

    var bam = (baz * 2) + 2;

    console.log( bam );
}

你可能听说过“提升(hoisting)”这个词,它描述了 JS 代码中使用 var 关键字声明的变量在作用域内是如何被处理的。这并非严格意义上的技术表述,而更多是一种比喻。但是就我们这里讨论的问题来说,已经够用了。上述代码片段的处理方式与下面的代码类似:

function foo() {
    var bar, baz, bam;

    bar = 2;

    if (bar > 1 || bam) {
        baz = bar * 10;
    }

    bam = (baz * 2) + 2;

    console.log( bam );
}

foo();  // 42

如上所示,函数 foo() 的声明被移动(或者称为“提升(hoisted)”)到了作用域的顶部,同样 barbazbam 等变量也被提升到了它们的作用域顶部。

因为 JS 中的变量总是表现出提升行为,许多开发者倾向于将变量声明在(函数)作用域的顶部,来让代码风格与其实际行为一致。这样做没有任何毛病。

但是你是否见过前后风格不一致的代码吗:

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

类似于上面的代码非常常见。另外一个常见的例子是:

var a, b;

// other code

// later, swap `a` and `b`
if (a && b) {
    var tmp = a;
    a = b;
    b = tmp;
}

if 语句块里的 var tmp 违背了所谓的“将所有声明语句移动到顶部”的编码风格。前面代码片段里 for 循环里的 var i 也是这样。

两个例子中,变量都会被“提升”,那么为什么开发者依然把这些变量声明语句写到作用域很里面而不是顶部,尤其是在其它变量已经被有意移到顶部的情况下?

块作用域

最主要的原因是开发者想将部分变量限制在作用域的某个更小的范围内。换句话说,开发者将 var i 声明在 for 循环里面是为了在形式上告知他人——包括未来的自己!——变量 i 只应该在 for 循环中使用。 if 语句中的 var tmp 也是如此。tmp 是一个临时变量,只在 if 语句执行期间存在。

通过书写形式,我们宣称:“在且仅在这里使用这个变量”。

最小权限原则

在软件开发领域,有一个“最小权限(暴露)原则”,该原则指出,恰当的软件设计会隐藏细节,除非有暴露的必要。在模块设计中,我们恰恰是这么做的——将私有变量与函数隐藏在闭包中,仅仅将一小部分函数或属性暴露出来作为公共 API 。

块作用域就是这种思想的延伸。我们的建议是,合适的软件设计应该是,变量的使用位置与其声明位置越近越好,并且尽可能地声明在作用域的内部。

凭直觉你已经很明了这个原则。你知道我们不会把所有的变量都声明为全局变量,尽管在某些情况下这样做会更简单一些。为什么呢?因为这是一种糟糕的设计。这种设计会导致意外冲突,冲突会引发 bug

因此你会把变量控制在使用它们的函数中。当需要在函数中嵌入其他函数时,在必要且合适的情况下,你会把变量嵌入这些内层函数中。如此种种。

块作用域仅仅是说,我想把一个 { .. } 块当作一个作用域,而不需要使用一个新的函数来封装出一个来。

当你想要表达“如果我只需要在 for 循环中使用变量 i,那么我就将其声明在 for 循环的定义中”的时候,你其实就是遵循了这样一个原则。

JS 中块作用域的缺失

不幸的是,在历史上,JS 从未提出一种切实可行的方式来强化这种作用域模式,需要人们自觉遵守来维护这种风格。当然了,缺乏强制性意味着这些东西已然被打破了,有时候会有效,而另外一些时候则会导致 bug 。

其他编程语言(如 Java 、C++)确实拥有块作用域,你可以声明一个变量,使其属于一个特定的块而不是外围的作用域或函数。从其他语言转过来的开发者深切地知道在进行某些变量的声明的时候使用块作用域的好处。

他们常常觉察到,由于缺少一种使用 { .. } 而不是重量级的内联函数定义(又称为IIFE,立即执行函数表达式)创建内联作用域的方法,JS 缺乏一定的表现力。

他们说的一点没错。 一直以来,JavaScript 就缺少块作用域能力。更确切的说,我们缺少一种语法层面的支持来强化我们已经习惯的表达风格。

这还不是全部

即便是在支持块作用域的语言中,并非所有的变量都会最终处于块作用域中。

从该语言中随便拿出一个书写良好的代码库,你肯定会发现一些变量声明处于函数层面,而另外一些处于更小范围的块作用域内。为什么呢?

因为这是编写软件的一个相当自然的要求。有时候我们需要一个在函数各处都可以使用的变量,有时候却需要一个仅在有限范围内使用的变量。这当然不是一个二选一的命题。

证据何在?函数参数。它们是在整个函数作用域内都可以访问的变量。据我所知,没有人会真的去鼓吹函数不应该拥有明确命名的参数,而原因是它们“不存在于某个块作用域中”。因为绝大多数开发者都会同意我在这里的论断:

块作用域与函数作用域都是合法的,且都有用途,并非不能共存。下面的代码就显得很傻:

function foo() {    // <-- Look ma, no named parameters!
    // ..

    {
        var x = arguments[0];
        var y = arguments[1];

        // do something with `x` and `y`
    }

    // ..
}

几乎可以肯定的是,你不会仅仅因为坚信在代码结构中“只能使用块作用域”而写出这样的代码,就像是虽然你坚信“只能使用全局作用域”而不会将 xy 声明为全局变量一样。

你不会那么做的,你只会将参数命名为 xy,然后在函数中任何需要的地方使用。

其他你想要在整个函数作用域使用的变量也是如此。你要做的很可能是将一个使用 var 修饰的变量放置在函数顶部,然后该干嘛干嘛。

let 出场

既然你已经知道块作用域的重要性了,更重要的是认识到它是对函数或全局作用域的补充而非替代,我们很高兴地告诉大家 ES6 规范终于引入了一个直接支持块作用域的机制,那就是使用 let 关键字。

从它的最基本的形式来看,letvar 的兄弟。但是使用 let 声明的变量会被限定在它们被声明的块中,而不是像 var 一样被“提升”到将其包裹的函数的作用域:

function foo() {
    a = 1;                  // careful, `a` has been hoisted!

    if (a) {
        var a;              // hoisted to function scope!
        let b = a + 2;      // `b` block-scoped to `if` block!

        console.log( b );   // 3
    }

    console.log( a );       // 1
    console.log( b );       // ReferenceError: `b` is not defined
}

耶!let 不仅仅表达了而且实现了块作用域。

大体来说,在任何块出现的地方(像是 {..}),let关键字都可以创建一个作用域范围为其所在块的变量声明。因此,在任何地方,当你需要创建有限范围作用域的时候,使用 let 即可。

注意:嗯,在 ES6 之前,let 并不存在。但是存在相当数量的从 ES6 到 ES5 的转化器——例如 traceur6to5 以及 Continuum —— 这些转换器会将 ES6 中使用了 let 的代码(以及其他大多数 ES6 新特性!)转换为可以在所有相关浏览器中运行的 ES5(有时是 ES3 代码)。鉴于以后 JS 会以特性为单位进行快速迭代演进,将转换器纳入标准构建流程会成为 JS 开发的“新常态”。这意味着你应在从现在就该开始使用 JS 中的最新、最好的特性,让工具来处理与(旧)浏览器兼容的问题。等待数年后直到先前的浏览器退出历史舞台才去使用新特性的日子一去不复返了。

隐式还是显式

let 是一种隐式作用域创建机制,很容易让人迷失在这种兴奋中。它劫持了一个现存的块,在其原本语义基础上添加了作为一个作用域的语义。

if (a) {
    let b = a + 2;
}

在上面的代码片段中,块是一个 if 块,但是仅仅因为 let 关键字出现于其中就使得它成为一个作用域。否则,如果里面不包含 let 的话,{..} 块就不是一个作用域。

这一点为什么这么重要呢?

一般来说,开发者更喜欢显式机制(explicit mechanisms)而不是隐式机制(implicit mechanisms),因为通常这样会使代码更加易读、易懂和易维护。

例如在 JS 类型转化领域,很多开发者更喜欢显式转化(explicit coercion)而不是隐式转化(implicit coercion)

var a = "21";

var b = a * 2;          // <-- implicit coercion -- yuck :(
b;                      // 42

var c = Number(a) * 2;  // <-- explicit coercion -- much better :)
c;                      // 42

注意:更多关于隐式/显式类型转化的话题请参考我写的《You Don't Know JS: Types & Grammar》,尤其是第四章 Coercion

当在示例代码中,块中没有几行代码的时候,很容易就可以看出这个块是否成为了一个作用域:

if (a) {    // block is obviously scoped
    let b;
}

但是真实场景是,一个单一的块可能会有几十行乃至上百行代码。先不管这样的代码块是否应该存在——实际上确实存在——假如 let 深藏在这些代码之中,判断一个块是否同时是一个作用域会变得异常困难

反过来说,当你发现在代码中存在一个 let 声明的时候,你需要知道它属于哪个块,与使用目光向上扫描直到找到最近的 function 关键字不同,现在你需要仔细查找最近的左大括号 { 。相比之下难度会更大。不一定困难很多,但肯定会更困难。

这更像是一种精神税。

潜在的坑

但是这还不仅仅是一种精神税。尽管使用 var 声明的变量会被“提升”到将其包裹的函数的顶部,使用 let 声明的变量却没有被“提升”到块顶部的待遇。如果你不小心在其声明之前使用了拥有块作用域的变量,那么会报错:

if (a) {
    b = a + 2;      // ReferenceError: `b` is not defined

    // more code

    let b = ..

    // more code
}

在技术上, {let b 之间的这段“时间”被称作“暂时性死区” (TDZ)——这个词可不是我杜撰出来的——变量在其暂时性死区中是不能使用的。从技术的角度看,每一个变量都有自己的暂时性死区,有些会重叠,再次重申,TDZ 始于块的开端,结束于正式的变量声明及初始化的位置。

由于我们先前把声明语句 let b = .. 安排在了块的腹地,后期又想在更早的地方使用这个变量,那么就会遇到坑——一种自废武功的设计——我们忘记查找 let 关键字,把它移动到变量 b 首次使用之前的地方。

开发者十有八九会被 TDZ "bug" 咬到,最后痛定思动,每次都把 let 声明放置到块的最顶端。

let 关键字会隐式生成一个作用域的行为会导致另外一个坑:重构坑。

看下面这段代码:

if (a) {
    // more code

    let b = 10;

    // more code

    let c = 1000;

    // more code

    if (b > 3) {
        // more code

        console.log( b + c );

        // more code
    }

    // more code
}

假如说不久之后,出于某种原因,你认为代码中的 if (b > 3) 部分需要移到 if (a) { .. 这个块的外面。与此同时,你注意到 let b = .. 这个声明语句需要与之一起移动。

但是当时你并没有意识到这个代码块也依赖变量 c——因此它隐藏的比较深——这样的话,c 的作用域是代码块 if (a) { ..。一旦你将 if (b > 3) { .. 移出来,代码就不能运行了,你不得不找到 let c = .. 语句,验证它是否可以移出来,诸如此类。

我还可以举出很多其他情景——当然是假设的,但是也深受我或他人写的大量实战代码的影响——不过我认为你应该已经理解了我要表达的意思。我们太容易让自己的陷入这些危险坑了。

如果变量 bc 有着更加明确的作用域,确认需要进行什么样的重构或许会更容易一些,而不是焦头烂额地将其找出来。

显式为王?

实际上,使用显式方式写代码如此重要以至于我能想到的唯一例外是乐于使用 for (let i=0; .. ) ..。这种写法到底属于显式还是隐式写法尚且存在争议。我认为其中显式成分多一点。不过比起 { let i; for (i=0; ..) .. } 这样的写法还是要差一些。

尽管如此,还是有充分的理由说明 for (let i=0; ..) .. 这样的写法更好一些。它利用了作用域闭包的特性,非常好用,也非常强大。

{ let i;
    for (i=1; i<=5; i++) {
        setTimeout(function(){
            console.log("i:",i);
        },i*1000);
    }
}

上述代码与它的近亲 var 一样,是无法输出正确结果的——它会输出 5 次 i: 6 。但是下面的代码是可以输出正确结果的:

for (let i=1; i<=5; i++) {
    setTimeout(function(){
        console.log("i:",i);
    },i*1000);
}

它会输出 i: 1, i: 2, i: 3,等等。原因何在?

因为在 ES6 规范中明确说明了在 for 循环头部,let i 中的 i 的作用域不仅仅位于 for 循环,而且位于** for 循环的每一次迭代**中。换句话说,它的行为与下面的代码类似:

{ let k;
    for (k=1; k<=5; k++) {
        let i = k; // <-- new `i` for each iteration!
        setTimeout(function(){
            console.log("i:",i);
        },i*1000);
    }
}

太棒了,它解决了开发者们经常遇到的关于闭包与循环的难题!

let 的作用域更明确一些

或许到现在我已经让你确信显式作用域会更好一些。但是还有一个缺陷,那就是它没有强制要求开发者遵循 { let b, c; .. } 这样的代码风格(或惯例),也就意味着你自己或者是团队中的其他人可能会选择不遵守规范而把代码搞乱。

其实还有另外一种方式。即使用“ let 块形式” 而不是使用 “let 声明形式”:

if (a) {
    // make an explicit scope block!
    let (b, c) {
        // ..
    }
}

尽管只做了些许改动,但是瞧仔细了:let (b, c) { .. } 语句为变量 bc 创建了一个显式的作用域块。这种写法从语法上要求 bc 写在块的顶部,而这个块除了创建作用域外并不承担其他职责。

我认为这是使用基于 let 的块作用域的最好的方式。

不过这里有个问题。TC39 委员会在投票中并未将 let 的这种特殊形式纳入 ES6 。它或许会在不久的将来被纳入进来,或许永远不会,唯一确定的是,它肯定不在 ES6 的规范中。

所以,我们只能使用前一种形式了吗?

或许还有希望。我构建了一个叫作 let-er 的小工具,相当于“let 块形式”的编译器。默认情况下它处于 ES6 模式,输入代码如下:

let (b, c) {
    ..
}

输出代码如下:

{ let b, c;
    ..
}

看起来还不算坏吧?它仅仅进行了非常简单的转化,把非标准化的 “let 块形式(let block form)” 转化为“let 声明形式(let declaration form)”。在使用 let-er 进行转化后,就可以使用常规的 ES6 转换器再将其转化为前 ES6 环境了(如浏览器等)。

假如你打算只使用 let-er 来进行 let 相关的转化,还可以设置 ES3 选项,这样就可以产生如下代码了(一堆充满了奇技淫巧的代码):

try{throw void 0}catch( b ){try{throw void 0}catch( c ){
    ..
}}

没错,上面的代码应用了这样一个冷知识,即 try..catch 语句中的 catch 部分具有块作用域。

没有人会想写这样的代码,也没有人喜欢由此带来的性能下降。但是要记住,这是编译后的代码,它只用于非常古老的浏览器,比如说 IE6 。性能低是令人遗憾的(在我的测试中大约会下降 10%),但是既然你的代码要运行在性能这么低的 IE6 中,所以就......

不管怎样,let-er 默认的编译目标是标准 ES6,因此与其他 ES6 工具——如标准编译器——搭配很好。

唯一的要选择的就是你更愿意写 let (b, c) { .. } 风格的代码,还是觉得 { let b, c; .. } 已经够用了?

现在我会在自己的项目中使用 let-er 。我认为这种写法更好。我希望或许在 ES7 中,TC39 的委员们会认识到“let 块形式(let block form)”的重要性,从而把它纳入 JS 的标准中,这样的话,let-er 也就可以退役啦!

总之呢,显式块作用域会比隐式的要好。请认真对待块作用域。

letvar 的替代吗?

JS 社区中的一些重量级任务,以及 TC39 委员们喜欢说这样一句话:“let 是新的 var”。实际上,有些人还真的建议人们(希望是开玩笑的)进行全局查找-替换操作,将 var 替换为 let

对于这种愚蠢的建议我就无语了。

首先,我们上面提到的坑很可能会在你的代码中大面积出现,尤其是当你的代码没有严格按照 var 的最佳实践去写的时候。例如,类似下面的代码是很常见的:

if ( .. ) {
    var foo = 42;
}
else {
    var foo = "Hello World";
}

而人们一致同意的写法应该是这样的:

var foo;

if ( .. ) {
    foo = 42;
}
else {
    foo = "Hello World";
}

但是有的代码确实没有那样写。再或者,你不小心这么写了:

b = 1;

// ..

var b;

又或者,你不小心在循环中利用了不存在块作用域的闭包特性:

for (var i=0; i<10; i++) {
    if (i == 2) {
        setTimeout(function(){
            if (i == 10) {
                console.log("Loop finished");
            }
        },100);
    }
}

因此如果你盲目地把现有代码中的 var 替换为 let,有极大的可能性某些代码会突然失效。仅仅把 var 替换为 let 而不作其他改动,上面列举的几种情形都会失效。

如果你打算对现有的代码进行改进,使其支持块作用域,你需要一步步进行,而且要非常小心,需要评估和论证块作用域是否适合用在那个地方。

当然会有一些地方,曾经使用了 var,现在使用 let 会更好一些。很好。而我依然不喜欢隐式地使用,但是假如那是你的菜的话,就用吧。

但是还是会有一些地方,经过你的分析之后发现,代码存在结构性问题,使用 let 会显得怪怪的,或者会使得代码晦涩难懂。在这种地方,你可能会选择重构代码,但是可能也会经过审慎评估决定依然使用 var

let 是新的 var”这句话,它假设了——不管说这话的人是否承认——一种优越感,即所有的 JS 代码都应该是完美的,遵循了合适的规则的。当你拿上述这些先前已有的代码举例的时候,它的鼓吹者会反击:“这些代码本身就是错的”。

好吧。不过这只是一个非重点。这句话基本上等于在说:“只有使用了 let 你的代码才是完美的,否则你需要准备将其重写使其完美,并保持之”。

另外一些鼓吹者会温和一些:“在新写代码的时候使用 let 就可以了。”

这基本上等同于精英主义,因为它再一次假设你一旦学会 let 并决定在代码中使用,那么人们就会期待你新写出的代码不会遭遇前面说的那些坑。

我敢说 TC39 委员会的人可以做到。他们都是一些聪明人,并且深谙 JS 的奥妙。但是像我辈凡夫俗子就没有那么幸运了。

letvar 的新搭档

最理性也最务实的观点是,拥抱代码重构,循序渐进而不是一下子提升代码质量。

你当然可以在学到关于作用域的最佳实践后,每次在写代码的时候使其变得更好一些,新写下的代码当然理应比旧有的代码质量更高一些。但是你不会在阅读完一本书或者一篇文章之后,突然之间打通了任督二脉,写出的代码都是完美无缺的代码了。

相反,我希望你应该同时拥抱新生代的 let 和老生代的 var,在代码中将它们作为有意义的标记。

在需要使用块作用域的地方使用 let,同时确保你知道它们的出现意味着什么。而在不是那么容易或者不应该使用块作用域的地方依然使用 var 。在现实世界的代码中,有些变量确实需要将其作用域设定为整个函数范围,对于它们来说,var 是很好的标记。

function foo() {
    var a = 10;

    if (a > 2) {
        let b = a * 3;
        console.log(b);
    }

    if (a > 5) {
        let c = a / 2;
        console.log(c);
    }

    console.log(a);
}

在上面的代码中,let 冲我大喊大叫:“嘿,我的作用域范围是一个块”。它引起了我的注意,我会更加小心的留意它。而 var 仅仅在说:“嘿,我是原来的纵跨整个函数作用域的变量,你的老朋友,我可以跨越多个作用域。”

如果在函数的顶部使用 let a = 10 会怎么样?你可以这样做,没问题。

但是我并不认为这是一个好主意。为什么呢?

首先,你让 varlet 之间的显著差异不那么明显了,失去了信号的功用。这样的话,只有位置可以标记差异,但是而不是语法。

其次,依然存在潜在的坑。有没有遇到过当程序中出现了诡异的 bug,需要使用 try..catch 语句将其包裹起来调试的场景?我当然遇到过。

艾玛:

function foo() {
    try {
        let a = 10;

        if (a > 2) {
            let b = a * 3;
            console.log(b);
        }
    }
    catch (err) {
        // ..
    }

    if (a > 5) {
        let c = a / 2;
        console.log(c);
    }

    console.log(a);
}

块作用域是个好东西,但是不是银弹,并非任何场合都适用。在某些场景下, var 声明的变量纵跨整个函数作用域的特性,以及变量“提升”行为,是非常有用的。它们并非语言设计中的败笔而理应被移除,而是应该被合理地使用,一如 let

下面是更好的表述方式:“let 是新的拥有块作用域var”。这句话强调了只有在 var 被用作标记拥有块作用域的信号的时候应该用 let 将其代替。其他情形下,就不要动 var 了。它在自己适用的领域表现还是相当不错的!!

总结

拥有块作用域相当棒,let 将其带给了我们。但是一定要显式地构造块作用域。避免将 let 声明写的到处都是。

let + var,而不是 s/var/let/(大概是二选一的意思?)。当你遇到再有人对你说“let 是新生的 var ”这句话的时候,皱皱眉,表示微笑就好了。

let 对 JS 的作用域特性进行了扩充,而不是替代。var 依然可以很好的用来标记纵贯整个函数作用域的变量。拥有了这哼哈二将,并且妥善使用,可以让作用域设置意图清晰易懂,容易维护,以及进行规范化。这是一件大好事!