美文网首页让前端飞
读《你不知道的JavaScript》笔记(一)

读《你不知道的JavaScript》笔记(一)

作者: 前端辉羽 | 来源:发表于2020-07-17 10:53 被阅读0次

    常听别人提到 《你不知道的JavaScript》这本书,抽时间读了一下,感觉这的确是一本很值得推荐的书,用通俗易懂的语言讲解了Javascript的基础知识,最难得的是,本书的讲解重点恰恰是Javascript最难懂的部分。很多在以前难以理解的知识点在读了这本书之后都变得豁然开朗,特把读书过程中的个人认为比较重要的部分整理如下,以供分享和自己以后参考。
    因篇幅较长,Javascript又分上中下三卷,所以相关笔记会整理成一个系列,本文是系列(一)
    本文目录:

    • 第一部分 作用域和闭包
      • 第1章 作用域是什么
      • 第2章 词法作用域
      • 第3章 函数作用域和块作用域
      • 第4章 提升
      • 第5章 作用域闭包

    第一部分 作用域和闭包

    第1章 作用域是什么

    在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编 译”。

    • 分词/词法分析(Tokenizing/Lexing)
      这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元(token)。例如,考虑程序 var a = 2;。这段程序通常会被分解成 为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在 这门语言中是否具有意义。
    • 解析/语法分析(Parsing)
      这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
    • 代码生成
      将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息 息相关。 抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指 令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

    比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在 语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化 等。

    LHS 和 RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“= 赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最 好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头 (RHS)”。
    考虑下面的程序,其中既有 LHS 也有 RHS 引用:

    function foo(a) { 
      console.log( a ); // 2 
    }
    foo( 2 );
    

    最后一行 foo(..) 函数的调用需要对 foo 进行 RHS 引用,意味着“去找到 foo 的值,并把 它给我”。并且 (..) 意味着 foo 的值需要被执行,因此它最好真的是一个函数类型的值。
    这里还有一个容易被忽略却非常重要的细节。
    代码中隐式的 a=2 操作可能很容易被你忽略掉。这个操作发生在 2 被当作参数传递给 foo(..) 函数时,2 会被分配给参数 a。为了给参数 a(隐式地)分配值,需要进行一次 LHS 查询。

    第2章 词法作用域

    词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写 代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)。

    第3章 函数作用域和块作用域

    看下面的代码输出:

    var before = 1;
    before += 1;
    (function () {
        console.log(before) //2
        before += 1
        console.log(before) //3
        var inner = 'inner1'
    }())
    before += 1
    console.log(before) //4
    console.log(inner) //ReferenceError
    

    1.因为在js中语句的结束并不一定需要有分号,所以立即执行函数的前面一定要加上分号,防止出现不必要的错误
    2.立即执行函数里面可以获得外面的变量,并且可以对变量进行操作
    3.因为IIFE会直接运行代码,所以在IIFE后面对变量进行的修改并不会对IIFE里面获取到的变量有任何影响
    注意:如果给立即执行
    如果把代码修改成下面这样,那三处console会分别打印出什么呢?

    var before = 1;
    before += 1;
    (function (before) {
        console.log(before)
        before += 1
        console.log(before)
    }())
    before += 1
    console.log(before)
    

    答案是
    undefined
    NaN
    3
    因为此时IIFE的形参before相当于重新定义了一个内部变量before,但是并没有赋值,所以第一处打印出来的自然是undefined,用undefined+1,结果是NaN。
    而第三处打印的before依然是全局变量before
    如果把代码再进行修改,三处打印值又会是什么呢?

    var before = 1;
    before += 1;
    (function (before) {
        console.log(before)
        before += 1
        console.log(before)
    }(before))
    before += 1
    console.log(before)
    

    答案是
    2
    3
    3
    此时IIFE的形参before不但进行了定义,而且在自调用的时候通过实参获取到了全局变量before,并把全局变量before的值赋值给了形参before,同时IIFE的内部变量before和全局变量before虽然名字一样,但是他俩是互不相关的。
    在实际开发中运用到的IIFE,如果需要使用到全局变量,通常也都是用这种方式实现IIFE和外部环境的完全解耦。

    第4章 提升

    变量声明的提升是提升到所在的作用域的最上方,比如

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

    就相当于

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

    另外要注意,函数声明会被提升,但是函数表达式却不会被提升。

    foo(); 
    var foo = function bar() { // ... };
    

    此时控制台会报TypeError而不是 ReferenceError
    同时也要记住,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:

    foo(); // TypeError 
    bar(); // ReferenceError
    var foo = function bar() { // ... };
    

    这个代码片段经过提升后,实际上会被理解为以下形式:

    var foo; 
    foo(); // TypeError 
    bar(); // ReferenceError 
    foo = function() {// ... }
    

    函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个 “重复”声明的代码中)是函数会首先被提升,然后才是变量。

    foo(); // 1
    var foo;
    function foo() {
        console.log(121233232);
    }
    foo = function () {
        console.log(2);
    };
    

    函数声明会直接上面的变量声明,同时函数因为不存在赋值一说,所以会上面代码会输出 1 而不是 2 !这个代码片段会被引擎理解为如下形式:

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

    尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。比如下面这段代码,输出值是3

    foo(); // 3
    function foo() {
        console.log(1);
    }
    var foo = function () {
        console.log(2);
    };
    function foo() {
        console.log(3);
    }
    

    虽然这些听起来都是些无用的学院理论,但是它说明了在同一个作用域中进行重复定义是 非常糟糕的,而且经常会导致各种奇怪的问题。

    声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。比如var a = function foo(){ ... },变量a会被提升,但是function foo(){ ... }依旧会待在原地。
    要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候(名字相同的情况下,普通变量的声明会被函数声明无情覆盖),否则会引 起很多危险的问题!

    第5章 作用域闭包

    在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使 用了回调函数,实际上就是在使用闭包!

    下面是前面讲过的IIFE模式的代码(立即执行函数)

    var a = 2; 
    (function IIFE() { console.log( a ); })();
    

    上面这段IIFE的代码看上去像是闭包,但严格来说并不是,因为函数(示例代码中 的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而 外部作用域,也就是全局作用域也持有 a)。a 是通过普通的词法作用域查找而非闭包被发 现的
    尽管 IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建 可以被封闭起来的闭包的工具。因此 IIFE 的确同闭包息息相关,即使本身并不会真的使用闭包。

    面试经典问题

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

    正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。 但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。接下来只能对代码进行改造

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

    IIFE每次执行都会创建独立的作用域,但是上面的代码并不能解决问题,因为如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一 个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。它需要有自己的变量,用来在每个迭代中储存 i 的值:

    for (var i = 1; i <= 5; i++) {
        (function (j) {
            setTimeout(function timer() {
                console.log(j);
            }, j * 1000);
        })(i);
    }
    

    第 3 章介绍了 let 声明,可以用来劫 持块作用域,并且在这个块作用域中声明一个变量。 本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码就可以正常运行了:

    for (var i = 1; i <= 5; i++) {
        let j = i; // 是的,闭包的块作用域!
        setTimeout(function timer() {
            console.log(j);
        }, j * 1000);
    }
    

    但是,这还不是全部!for 循环头部的 let 声明还会有一 个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随 后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

    下面展示的代码在Javascript被称为模块,最常见的实现模块模式的方法通常被称为模块暴露。

    function CoolModule() {
        var something = "cool";
        var another = [1, 2, 3];
        function doSomething() {
            console.log(something);
        }
        function doAnother() {
            console.log(another.join(" ! "));
        }
        return {
            doSomething: doSomething,
            doAnother: doAnother
        };
    }
    var foo = CoolModule();
    foo.doSomething(); // cool 
    foo.doAnother(); // 1 ! 2 ! 3
    

    模块模式需要具备两个必要条件

    1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块 实例)。
    2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并 且可以访问或者修改私有的状态。

    模块模式的一个简单但强大的变化用法是,命名将要作为公共 API 返回的对象:

    var foo = (function CoolModule(id) {
        function change() { // 修改公共 API 
            publicAPI.identify = identify2;
        }
        function identify1() {
            console.log(id);
        }
        function identify2() {
            console.log(id.toUpperCase());
        }
        var publicAPI = {
            change: change,
            identify: identify1
        };
        return publicAPI;
    })("foo module");
    foo.identify(); // foo module 
    foo.change();
    foo.identify(); // FOO MODULE
    

    通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修 改,包括添加或删除方法和属性,以及修改它们的值。

    Javascript的词法作用域和动态作用域区别很大,典型例子如下:

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

    词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调 用。因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出 3,而事实是输出2。所以函数对数据的调用是在声明的时候就已经决定了。
    主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定 的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

    下面的代码很让人疑惑

    var obj = {
        id: "酷",
        cool: function () {
            console.log(this.id);
        }
    };
    var id = "不酷"
    obj.cool(); // 酷 
    setTimeout(obj.cool, 1000); // 不酷
    

    本来还很酷的,为什么1秒后就不酷了呢,因为在setTimeout的回调中,丢失了cool() 函数丢失了同 this 之间的绑定。我们尝试下在定义obj的cool方法时使用箭头函数

    var obj = {
        id: "酷",
        cool: () => {
            console.log(this.id);
        }
    };
    var id = "不酷"
    obj.cool(); // 不酷 
    setTimeout(obj.cool, 1000); // 不酷
    

    这次的结果更糟糕,两次的输出都是“不酷”,因为箭头函数没有自己的this,在cool方法中的this指定的就是全局对象window,正确的方法应该是在setTimeout的回调中,通过bind将cool中的this强制锁定为obj,下面的结果是我们想要的

    var obj = {
        id: "酷",
        cool: function () {
            console.log(this.id);
        }
    };
    var id = "不酷"
    obj.cool(); // 酷 
    setTimeout(obj.cool.bind(obj), 1000); // 酷
    

    相关文章

      网友评论

        本文标题:读《你不知道的JavaScript》笔记(一)

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