美文网首页全栈工程师修炼ES 6写法我想懂得JS
你不懂JS:类型与文法 第五章:文法(下)

你不懂JS:类型与文法 第五章:文法(下)

作者: HetfieldJoe | 来源:发表于2016-08-05 09:30 被阅读315次

    官方中文版原文链接

    感谢社区中各位的大力支持,译者再次奉上一点点福利:阿里云产品券,享受所有官网优惠,并抽取幸运大奖:点击这里领取

    自动分号

    当JavaScript认为在你的JS程序中特定的地方有一个;时,就算你没在那里放一个;,它就会进行ASI(Automatic Semicolon Insertion —— 自动分号插入)。

    为什么它这么做?因为就算你只省略了一个必需的;,你的程序就会失败。不是非常宽容。ASI允许JS容忍那些通常被认为是不需要;的特定地方省略;

    必须注意的是,ASI将仅在换行存在时起作用。分号不会被插入一行的中间。

    基本上,如果JS解析器在解析一行时发生了解析错误(缺少一个应有的;),而且它可以合理的插入一个;,它就会这么做。什么样的地方对插入是合理的?仅在一个语句和这一行的换行之间除了空格和/或注释没有别的东西时。

    考虑如下代码:

    var a = 42, b
    c;
    

    JS应当将下一行的c作为var语句的一部分看待吗?如果在bc之间的任意一个地方出现一个,,它当然会的。但是因为没有,所以JS认为在b后面有一个隐含的;(在换行处)。如此c;就剩下来作为一个独立的表达式语句。

    类似地:

    var a = 42, b = "foo";
    
    a
    b   // "foo"
    

    这仍然是一个没有错误的合法程序,因为表达式语句也接受ASI。

    有一些特定的地方ASI很有帮助,例如:

    var a = 42;
    
    do {
        // ..
    } while (a) // <-- 这里需要;!
    a;
    

    文法要求do..while循环后面要有一个;,但是whilefor循环后面则没有。但是大多数开发者都不记得它!所以ASI帮助性地介入并插入一个。

    如我们在本章早先说过的,语句块儿不需要;终结,所以ASI是不必要的:

    var a = 42;
    
    while (a) {
        // ..
    } // <-- 这里不需要;
    a;
    

    另一个ASI介入的主要情况是,与breakcontinuereturn,和(ES6)yield关键字:

    function foo(a) {
        if (!a) return
        a *= 2;
        // ..
    }
    

    这个return语句的作用不会超过换行到a *= 2表达式,因为ASI认为;终结了return语句。当然,return语句 可以 很容易地跨越多行,只要return后面不是除了换行外什么都没有就行。

    function foo(a) {
        return (
            a * 2 + 3 / 12
        );
    }
    

    同样的道理也适用于breakcontinue,和yield

    纠错

    在JS社区中斗得最火热的 宗教战争 之一(除了制表与空格以外),就是是否应当严重/唯一地依赖ASI。

    大多数的,担不是全部,分号是可选的,但是for ( .. ) ..循环的头部的两个;是必须的。

    在这场争论的正方,许多开发者相信ASI是一种有用的机制,允许他们通过省略除了必须(很少几个)以外的所有;写出更简洁(和更“美观”)的代码。他们经常断言因为ASI使许多;成为可选的,所以一个 不带它们 而正确编写的程序,与 带着它们 而正确编写的程序没有区别。

    在这场争论的反方,许多开发者将断言有 太多 的地方可以成为意想不到的坑了,特别是对那些新来的,缺乏经验的开发者来说,无意间被魔法般插入的;改变了程序的含义。类似地,一些开发者将会争论如果他们省略了一个分号,这就是一个直白的错误,而且他们希望他们的工具(linter等等)在JS引擎背地里 纠正 它之前就抓住他。

    让我分享一下我的观点。仔细阅读语言规范,会发现它暗示ASI是一个 纠错 过程。你可能会问,什么样的错误?明确地讲,是一个 解析器错误。换句话说,为了使解析器失败的少一些,ASI让它更宽容。

    但是宽容什么?在我看来,一个 解析器错误 发生的唯一方式是,它被给予了一个不正确/错误的程序去解析。所以虽然ASI在严格地纠正解析器错误,但是它得到这样的错误的唯一方式是,程序首先就写错了 —— 在文法要求使用分号的地方忽略了它们。

    所以,更直率地讲,当我听到有人声称他们想要省略“可选的分号”时,我的大脑就将它翻译为“我想尽量编写最能破坏解析器但依然可以工作的程序。”

    我发现这种立场很荒唐,而且省几下键盘敲击和更“美观的代码”的观点是软弱无力的。

    进一步讲,我不同意这和空格与制表符的争论是同一种东西 —— 那纯粹是表面上的 —— 我宁愿相信这是一个根本问题:是编写遵循文法要求的代码,还是编写依赖于文法异常但仅仅将之忽略不计的代码。

    另一种看待这个问题的方式是,依赖ASI实质上将换行视为有意义的“空格”。像Python那样的其他语言中有真正的有意义的空格。但是就今天的JavaScript来说,认为它拥有有意义的换行真的合适吗?

    我的意见是:在你知道分号是“必需的”地方使用分号,并且把你对ASI的臆测限制到最小。

    不要光听我的一面之词。回到2012年,JavaScript的创造者Brendan Eich说过下面的话(http://brendaneich.com/2012/04/the-infernal-semicolon/):

    这个故事的精神是:ASI是一种(正式地说)语法错误纠正过程。如果你在好像有一种普遍的有意义的换行的规则的前提下开始编码,你将会陷入麻烦。
    ..
    如果回到1995年五月的那十天,我希望我使换行在JS中更有意义。
    ..
    如果ASI好像给了JS有意义的换行,那么要小心不要使用它。

    错误

    JavaScript不仅拥有不同的错误 子类型TypeErrorReferenceErrorSyntaxError等等),而且和其他在运行时期间发生的错误相比,它的文法还定义了在编译时被强制执行的特定错误。

    尤其是,早就有许多明确的情况应当被作为“早期错误”(编译期间)被捕获和报告。任何直接的语法错误都是一个早期错误(例如,a = ,),而且文法还定义了一些语法上合法但是无论怎样都不允许的东西。

    因为你的代码还没有开始执行,这些错误不能使用try..catch捕获;它们只是会在你的程序进行解析/编译时导致失败。

    提示: 在语言规范中没有要求浏览器(和开发者工具)到底应当怎样报告错误。所以在下面的错误例子中,对于哪一种错误的子类型会被报告或它包含什么样的错误消息,你可能会在各种浏览器中看到不同的形式,

    一个简单的例子是正则表达式字面量中的语法。这里的JS语法没有错误,而是不合法的正则表达式将会抛出一个早期错误:

    var a = /+foo/;     // 错误!
    

    一个赋值的目标必须是一个标识符(或者一个产生一个或多个标识符的ES6解构表达式),所以一个像42这样的值在这个位置上是不合法的,因此可以立即被报告:

    var a;
    42 = a;     // 错误!
    

    ES5的strict模式定义了更多的早期错误。例如,在strict模式中,函数参数的名称不能重复:

    function foo(a,b,a) { }                 // 还好
    
    function bar(a,b,a) { "use strict"; }   // 错误!
    

    另一种strict模式的早期错误是,一个对象字面量拥有一个以上的同名属性:

    (function(){
        "use strict";
    
        var a = {
            b: 42,
            b: 43
        };          // 错误!
    })();
    

    注意: 从语义上讲,这样的错误技术上不是 语法 错误,而是 文法 错误 —— 上面的代码段是语法上合法的。但是因为没有GrammarError类型,一些浏览器使用SyntaxError代替。

    过早使用变量

    ES6定义了一个(坦白地说,让人困惑地命名的)新的概念,称为TDZ(“Temporal Dead Zone” —— 时间死区)

    TDZ指的是代码中还不能使用变量引用的地方,因为它还没有到完成它所必须的初始化。

    对此最明白的例子就是ES6的let块儿作用域:

    {
        a = 2;      // ReferenceError!
        let a;
    }
    

    赋值a = 2在变量a(它确实是在{ .. }块儿作用域中)被声明let a初始化之前就访问它,所以a位于TDZ中并抛出一个错误。

    有趣的是,虽然typeof有一个例外,它对于未声明的变量是安全的(见第一章),但是对于TDZ引用却没有这样的安全例外:

    {
        typeof a;   // undefined
        typeof b;   // ReferenceError! (TDZ)
        let b;
    }
    

    函数参数值

    另一个违反TDZ的例子可以在ES6的参数默认值(参见本系列的 ES6与未来)中看到:

    var b = 3;
    
    function foo( a = 42, b = a + b + 5 ) {
        // ..
    }
    

    在赋值中的b引用将在参数b的TDZ中发生(不会被拉到外面的b引用),所以它会抛出一个错误。然而,赋值中的a是没有问题的,因为那时参数a的TDZ已经过去了。

    当使用ES6的参数默认值时,如果你省略一个参数,或者你在它的位置上传递一个undefined值的话,就会应用这个默认值。

    function foo( a = 42, b = a + 1 ) {
        console.log( a, b );
    }
    
    foo();                  // 42 43
    foo( undefined );       // 42 43
    foo( 5 );               // 5 6
    foo( void 0, 7 );       // 42 7
    foo( null );            // null 1
    

    注意: 在表达式a + 1null被强制转换为值0。更多信息参考第四章。

    从ES6参数默认值的角度看,忽略一个参数和传递一个undefined值之间没有区别。然而,有一个办法可以在一些情况下探测到这种区别:

    function foo( a = 42, b = a + 1 ) {
        console.log(
            arguments.length, a, b,
            arguments[0], arguments[1]
        );
    }
    
    foo();                  // 0 42 43 undefined undefined
    foo( 10 );              // 1 10 11 10 undefined
    foo( 10, undefined );   // 2 10 11 10 undefined
    foo( 10, null );        // 2 10 null 10 null
    

    即便参数默认值被应用到了参数ab上,但是如果没有参数传入这些值槽,数组arguments也不会有任何元素。

    反过来,如果你明确地传入一个undefined参数,在数组argument中就会为这个参数存在一个元素,但它将是undefined,并且与同一值槽中的被命名参数将被提供的默认值不同。

    虽然ES6参数默认值会在数组arguments的值槽和相应的命名参数变量之间造成差异,但是这种脱节也会以诡异的方式发生在ES5中:

    function foo(a) {
        a = 42;
        console.log( arguments[0] );
    }
    
    foo( 2 );   // 42 (链接了)
    foo();      // undefined (没链接)
    

    如果你传递一个参数,arguments的值槽和命名的参数总是链接到同一个值上。如果你省略这个参数,就没有这样的链接会发生。

    但是在strict模式下,这种链接无论怎样都不存在了:

    function foo(a) {
        "use strict";
        a = 42;
        console.log( arguments[0] );
    }
    
    foo( 2 );   // 2 (没链接)
    foo();      // undefined (没链接)
    

    依赖于这样的链接几乎可以肯定是一个坏主意,而且事实上这种连接本身是一种抽象泄漏,它暴露了引擎的底层实现细节,而不是一个合适的设计特性。

    arguments数组的使用已经废弃了(特别是被ES6...剩余参数取代以后 —— 参见本系列的 ES6与未来),但这不意味着它都是不好的。

    在ES6以前,要得到向另一个函数传递的所有参数值的数组,arguments是唯一的办法,它被证实十分有用。你也可以安全地混用被命名参数和arguments数组,只要你遵循一个简单的规则:绝不同时引用一个被命名参数 它相应的arguments值槽。如果你能避开那种错误的实践,你就永远也不会暴露这种易泄漏的链接行为。

    function foo(a) {
        console.log( a + arguments[1] ); // 安全!
    }
    
    foo( 10, 32 );  // 42
    

    try..finally

    你可能很熟悉try..catch块儿是如何工作的。但是你有没有停下来考虑过可以与之成对出现的finally子句呢?事实上,你有没有意识到try只要求catchfinally两者之一,虽然如果有需要它们可以同时出现。

    finally子句中的代码 总是 运行的(无论发生什么),而且它总是在try(和catch,如果存在的话)完成后立即运行,在其他任何代码之前。从一种意义上说,你似乎可以认为finally子句中的代码是一个回调函数,无论块儿中的其他代码如何动作,它总是被调用。

    那么如果在try子句内部有一个return语句将会怎样?很明显它将返回一个值,对吧?但是调用端代码是在finally之前还是之后才收到这个值呢?

    function foo() {
        try {
            return 42;
        }
        finally {
            console.log( "Hello" );
        }
    
        console.log( "never runs" );
    }
    
    console.log( foo() );
    // Hello
    // 42
    

    return 42立即运行,它设置好foo()调用的完成值。这个动作完成了try子句而finally子句接下来立即运行。只有这之后foo()函数才算完成,所以被返回的完成值交给console.log(..)语句使用。

    对于try内部的throw来说,行为是完全相同的:

     function foo() {
        try {
            throw 42;
        }
        finally {
            console.log( "Hello" );
        }
    
        console.log( "never runs" );
    }
    
    console.log( foo() );
    // Hello
    // Uncaught Exception: 42
    

    现在,如果一个异常从finally子句中被抛出(偶然地或有意地),它将会作为这个函数的主要完成值进行覆盖。如果try块儿中的前一个return已经设置好了这个函数的完成值,那么这个值就会被抛弃。

    function foo() {
        try {
            return 42;
        }
        finally {
            throw "Oops!";
        }
    
        console.log( "never runs" );
    }
    
    console.log( foo() );
    // Uncaught Exception: Oops!
    

    其他的诸如continuebreak这样的非线性控制语句表现出与returnthrow相似的行为是没什么令人吃惊的:

    for (var i=0; i<10; i++) {
        try {
            continue;
        }
        finally {
            console.log( i );
        }
    }
    // 0 1 2 3 4 5 6 7 8 9
    

    console.log(i)语句在continue语句引起的每次循环迭代的末尾运行。然而,它依然是运行在更新语句i++之前的,这就是为什么打印出的值是0..9而非1..10

    注意: ES6在generator(参见本系列的 异步与性能)中增加了yield语句,generator从某些方面可以看作是中间的return语句。然而,和return不同的是,一个yield在generator被推进前不会完成,这意味着try { .. yield .. }还没有完成。所以附着在其上的finally子句将不会像它和return一起时那样,在yield之后立即运行。

    一个在finally内部的return有着覆盖前一个trycatch子句中的return的特殊能力,但是仅在return被明确调用的情况下:

    function foo() {
        try {
            return 42;
        }
        finally {
            // 这里没有 `return ..`,所以返回值不会被覆盖
        }
    }
    
    function bar() {
        try {
            return 42;
        }
        finally {
            // 覆盖前面的 `return 42`
            return;
        }
    }
    
    function baz() {
        try {
            return 42;
        }
        finally {
            // 覆盖前面的 `return 42`
            return "Hello";
        }
    }
    
    foo();  // 42
    bar();  // undefined
    baz();  // "Hello"
    

    一般来说,在函数中省略returnreturn;或者return undefined;是相同的,但是在一个finally块儿内部,return的省略不是用一个return undefined覆盖;它只是让前一个return继续生效。

    事实上,如果将打了标签的break(在本章早先讨论过)与finally相组合,我们真的可以制造一种疯狂:

    function foo() {
        bar: {
            try {
                return 42;
            }
            finally {
                // 跳出标记为`bar`的块儿
                break bar;
            }
        }
    
        console.log( "Crazy" );
    
        return "Hello";
    }
    
    console.log( foo() );
    // Crazy
    // Hello
    

    但是……别这么做。说真的。使用一个finally + 打了标签的break实质上取消了return,这是你在尽最大的努力制造最令人困惑的代码。我打赌没有任何注释可以拯救这段代码。

    switch

    让我们简单探索一下switch语句,某种if..else if..else..语句链的语法缩写。

    switch (a) {
        case 2:
            // 做一些事
            break;
        case 42:
            // 做另一些事
            break;
        default:
            // 这里是后备操作
    }
    

    如你所见,它对a求值一次,然后将结果值与每个case表达式进行匹配(这里只是一些简单的值表达式)。如果找到一个匹配,就会开始执行那个匹配的case,它将会持续执行直到遇到一个break或者遇到switch块儿的末尾。

    这些可能不会令你吃惊,但是关于switch,有几个你以前可能从没注意过的奇怪的地方。

    首先,在表达式a和每一个case表达式之间的匹配与===算法(见第四章)是相同的。switch经常在case语句中使用绝对值,就像上面展示的,因此严格匹配是恰当的。

    然而,你也许希望允许宽松等价(也就是==,见第四章),而这么做你需要“黑”一下switch语句:

    var a = "42";
    
    switch (true) {
        case a == 10:
            console.log( "10 or '10'" );
            break;
        case a == 42:
            console.log( "42 or '42'" );
            break;
        default:
            // 永远不会运行到这里
    }
    // 42 or '42'
    

    这可以工作是因为case子句可以拥有任何表达式(不仅是简单值),这意味着它将用这个表达式的结果与测试表达式(true)进行严格匹配。因为这里a == 42的结果为true,所以匹配成功。

    尽管==switch的匹配本身依然是严格的,在这里是truetrue之间。如果case表达式得出truthy的结果而不是严格的true,它就不会工作。例如如果在你的表达式中使用||&&这样的“逻辑操作符”,这就可能咬到你:

    var a = "hello world";
    var b = 10;
    
    switch (true) {
        case (a || b == 10):
            // 永远不会运行到这里
            break;
        default:
            console.log( "Oops" );
    }
    // Oops
    

    因为(a || b == 10)的结果是"hello world"而不是true,所以严格匹配失败了。这种情况下,修改的方法是强制表达式明确成为一个truefalse,比如case !!(a || b == 10):(见第四章)。

    最后,default子句是可选的,而且它不一定非要位于末尾(虽然那是一种强烈的惯例)。即使是在default子句中,是否遇到break的规则也是一样的:

    var a = 10;
    
    switch (a) {
        case 1:
        case 2:
            // 永远不会运行到这里
        default:
            console.log( "default" );
        case 3:
            console.log( "3" );
            break;
        case 4:
            console.log( "4" );
    }
    // default
    // 3
    

    注意: 就像我们前面讨论的打标签的breakcase子句内部的break也可以被打标签。

    这段代码的处理方式是,它首先通过所有的case子句,没有找到匹配,然后它回到default子句开始执行。因为这里没有break,它会继续走进已经被跳过的块儿case 3,在遇到那个break后才会停止。

    虽然这种有些迂回的逻辑在JavaScript中是明显可能的,但是它几乎不可能制造出合理或易懂的代码。要对你自己是否想要创建这种环状的逻辑流程保持怀疑,如果你真的想要这么做,确保你留下了大量的代码注释来解释你要做什么!

    复习

    JavaScript文法有相当多的微妙之处,我们作为开发者应当比平常多花一点儿时间来关注它。一点儿努力可以帮助你巩固对这个语言更深层次的知识。

    语句和表达式在英语中有类似的概念 —— 语句就像句子,而表达式就像短语。表达式可以是纯粹的/自包含的,或者他们可以有副作用。

    JavaScript文法层面的语义用法规则(也就是上下文),是在纯粹的语法之上的。例如,用于你程序中不同地方的{ }可以意味着块儿,object字面量,(ES6)解构语句,或者(ES6)被命名的函数参数。

    JavaScript操作符都有严格定义的优先级(哪一个操作符首先结合)和结合性(多个操作符表达式如何隐含地分组)规则。一旦你学会了这些规则,你就可以自己决定优先级/结合性是否是为了它们自己有利而 过于明确,或者它们是否会对编写更短,更干净的代码有所助益。

    ASI(自动分号插入)是一种内建在JS引擎找中的解析器纠错机制,它允许JS引擎在特定的环境下,在需要;但是被省略了的地方,并且插入可以纠正解析错误时,插入一个;。有一场争论是关于这种行为是否暗示着大多数;都是可选的(而且为了更干净的代码可以/应当省略),或者是否它意味着省略它们是在制造JS引擎帮你扫清的错误。

    JavaScript有几种类型的错误,但很少有人知道它有两种类别的错误:“早期”(编译器抛出的不可捕获的)和“运行时”(可以try..catch的)。所有在程序运行之前就使它停止的语法错误都明显是早期错误,但也有一些别的错误。

    函数参数值与它们正式声明的命名参数之间有一种有趣的联系。明确地说,如果你不小心,arguments数组会有一些泄漏抽象行为的坑。尽可能避开arguments,但如果你必须使用它,那就设法避免同时使用arguments中带有位置的值槽,和相同参数的命名参数。

    附着在try(或try..catch)上的finall在执行处理顺序上提供了一些非常有趣的能力。这些能力中的一些可以很有帮助,但是它也可能制造许多困惑,特别是在与打了标签的块儿组合使用时。像往常一样,为了更好更干净的代码而使用finally,不是为了显得更聪明或更糊涂。

    switchif..else if..语句提供了一个不错的缩写形式,但是要小心许多常见的关于它的简化假设。如果你不小心,会有几个奇怪的地方绊倒你,但是switch手上也有一些隐藏的高招!

    相关文章

      网友评论

      • 962d74e2ef9e:ESLint:不要使用分号
        反者道之动001: @hutu 工具只是帮助人们好的工作,而不是阻碍,除非有很好的理由。

        而且eslint是可以自定义编写规则的,默认是好,但是用不着的时候就没用了。

      本文标题:你不懂JS:类型与文法 第五章:文法(下)

      本文链接:https://www.haomeiwen.com/subject/cgshsttx.html