让前端飞

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

2020-07-17  本文已影响0人  前端辉羽

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

第一部分 作用域和闭包

第1章 作用域是什么

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

比起那些编译过程只有三个步骤的语言的编译器,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); // 酷
上一篇下一篇

猜你喜欢

热点阅读