精读《你不知道的JavaScript(上)》二

119 阅读11分钟

第二章 词法作用域

2.1 理解词法作用域

词法作用域就是你写代码时候,变量和块作用域的位置

看下例子:

var a = 3;//这行语句是属于全局作用域的
function foo(a) {
    //这里面是foo函数形成的块作用域,所以下面的b,bar(),以及自己的a,都是这个作用域的
    var b = a * 2;//这里的a是foo函数里面的,不是外面的var a = 3;里面的a
    function bar(c) {
        //这里面是bar函数形成的块作用域,所以c是属于这里   
        console.log(a,b,c);
    }
    bar(b * 3);
}
foo(2);//2,4,12

在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们分成3个逐级包含的"气泡作用域"。

image.png

  • 作用域查找会在找到第一个匹配的标识符时停止:作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。这个案例中就是bar使用的a,b,c来自上一级的foo。
  • 当子级的作用域有着与父级作用域同样的标识符(变量名、函数名)时候,子级的会覆盖父级,这种叫做遮蔽效应。例如在foo作用域、以及子作用域中使用a时候,都是foo函数中的a,而不是foo作用域的父作用域中的var a = 3;中的a。当然,当bar中有a时候,bar中使用a时候,就会使用自己a,而覆盖foo(a)的a,与全局的var a = 3的a。

2.2 欺骗词法

欺骗词法: 引擎在运行时来“修改”(也可以说欺骗)词法作用域,或者说就是在引擎运行时动态地修改词法作用域(本来在编译词法时就已经确定的).

欺骗词法的两种机制:(了解即可,不推荐实际开发使用)

2.2.1 eval

eval() 函数会将传入的字符串当做 JavaScript 代码进行执行。利用该语法我们可以把指定代码,指定在局部作用域下执行:

function foo(str, a) { 
  eval( str ); // 欺骗! 
  console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

2.2.2 with

它的作用是 扩展一个语句的作用域链。比如在如下代码中 with 内的代码,会自动指向 obj 对象:

function foo(obj) { 
  with (obj) {
    a = 2; 
  }
}

var o1 = {
  a: 3
};

var o2 = { 
  b: 3
};
foo( o1 );
console.log( o1.a ); // 2

foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!

2.2.3 性能

  •  witheval ,它们两个都不应该是我们在日常开发时的首选项,因为它们改变作用域的特性,会导致 引擎无法在编译时对作用域查找进行优化 ,所以我们应该尽量避免使用

2.3 小结

  • 作用域是由书写代码时函数声明的位置来决定的。编译时词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

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

3.1 函数中的作用域

  • 函数作用域:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用,)这种设计方案能充分利用JavaScript变量可以根据需要改变值类型的“动态”特性。

3.2 隐藏内部实现

  • 最小暴露原则:在软件设计中,应该最小限度的暴露最少内容,而将其他内容隐藏起来。
  • 在JavaScript中的函数作用域就可以实现隐藏代码的效果

image.png 上面左边的代码中我们能访问到b以及doSomethingElse()函数,但就目前而言,b以及doSomethingElse()只是为了给doSomething()使用,因此我们不应该让外部也能够访问到这两个标识符,所以应该进行隐藏,修改为上面右边的代码

3.3 函数作用域

函数声明与函数表达式:

function foo() {
    ...
}

我们知道上面代码中函数foo内的变量和函数被隐藏起来了,是不会对全局作用域造成污染。但是变量名foo仍然存在于全局作用域中,会造成污染。那有什么方法能避免函数名的污染呢?

例如以下,作为函数表达式,而不是一个标准的函数声明来解决。这样函数名只存在于它自己的函数作用域内,而不会存在于其父作用域,这样就没有了污染。

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

,这样foo就会被当做一个函数表达式,而不是一个函数声明(即foo不会存在于父级作用域中),回到上面的例子中,全局作用域是访问不到foo的,foo只存在于它自己的函数作用域中

补充: 什么是函数声明和函数表达式
首先我们得了解JS声明函数的三种方式:

  • 函数表达式(Function Expression):当我们用()包裹一个函数,此时第一个单词是从(function开始的
  • 函数声明(Function Declaration):如果function是声明中的第一个词,那么就是一个函数声明

// 函数表达式
var f = function() {
      console.log(1);  
}

// 函数声明
function f (){
     console.log(2);
}

console.log(f())
//思考一下,这里为啥会打印出1

3.3.1 匿名和具名

函数表达式可以是匿名的,而函数声明则不可以省略函数名,有函数名的就是具名函数,没有函数名的就是匿名函数,例如以下就是匿名函数

setTimeout(function(){
    console.log("I waited 1s");
},1000);

3.3.2 立即执行函数表达式

比如 (function foo(){ .. })()。第一个 ( ) 将函数变成表达式,第二个 ( ) 执行了这个函数。这就是立即执行函数表达式,也被称为IIFE,即立即执行函数表达式 (Immediately Invoked Function Expression),它可以具名也可以匿名

3.4 块作用域

有时我们仅会将var赋值变量在if或for的{...}内使用,但它仍然会对外层的函数作用域造成污染,这个时候就会希望能有一个作用域能将其外部的函数作用域隔开,声明的变量仅在此作用域有效,块作用域就可以帮我们做到这点,例如

for(var i = 0; i < 10; i++){
   
    var a = 5;
}
console.log("for外面调用for里面的a",a); //for外面调用for里面的a 5  
console.log("for外面for里面的i",i);//for外面for里面的i 10

3.4.1 with

用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

3.4.2 try/catch

try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

try {
  undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
  console.log( err ); // 能够正常执行! 
}
console.log( err ); // ReferenceError: err not found

3.4.3 let

ES6 引入了新的 let 关键字,可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。

注意: 使用 let 进行的声明不会在块作用域中进行提升.
块作用域的好处:

for(let i = 0; i < 10; i++){
    var a = 5;
}
console.log("for外面for里面的i",i);//Uncaught ReferenceError: i is not defined

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。这样就避免了i对外部函数作用域的污染.

3.4.4 const

除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,其值是固定的,之后任何试图修改值的操作都会引起错误。

var foo = true;
if (foo) {
  var a = 2;
  const b = 3; // 包含在 if 中的块作用域常量
  a = 3; // 正常!
  b = 4; // 错误! 
}
console.log( a ); // 3
console.log( b ); // ReferenceError!

第四章 提升

4.1 先有鸡(赋值)还是先有蛋(声明)?

考虑第一段代码

a = 2;
var a; 
console.log( a );

输出结果是2,而不是undefined

考虑第二段代码

console.log( a ); 
var a = 2;

输出结果是undefined,而不是ReferenceError

4.2 编译器再度来袭

在编译阶段找到所有的声明后,编译器又做了什么?答案就是提升
以4.1的第一段代码为例,编译器会对var a;声明进行提升(即把var a;置于所在作用域的最上面),而a = 2;则会保持所在位置不动,此时代码会变成以下:

var a; 
a = 2;
console.log( a );

由此可知,在编译阶段,编译器会对声明进行提升,即先有蛋(声明)后有鸡(赋值)。
哪些声明会被进行提升?

  • 变量声明:例如上例中的var a;
  • 函数声明:注意是函数声明,而不是函数表达式!函数声明提升,是将整个函数进行提升,而不是仅仅函数名的提升

4.3 函数优先

函数声明和变量声明都会被提升。但是一个值得注意的细节是函数会首先被提升,然后才是变量。
考虑以下代码:

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

会输出 1 而不是 2 !这个代码片段会被引擎理解为如下形式:

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

注意: js会忽略前面已经声明的声明,不管是变量声明还是函数声明,只要其名称相同,则后续不会再进行重复声明,但是对该变量新的赋值,会覆盖之前的值.
一句话概括:函数声明的优先级高于变量声明,会排在它前面.

5.1 启示

  • 闭包产生的2种情况
  • 当函数作为另一个函数的参数

  • 函数作为返回值返回

什么是闭包?(广义版)
书中解释: 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
MDN的解释: 闭包是函数和声明该函数的词法环境的组合。

闭包函数需要在它本身的词法作用域以外执行,并且使用任何回调函数其实也是闭包。

闭包暴露函数作用域的三种方式:

  • 1,通过外部函数的参数进行暴露.
function foo() { 
  var a = 2;
  function bar() { 
    
   baz(a) //通过外部函数的参数进行暴露
  }
  bar(); 
};
function baz(val) { 
   console.log( val ); // 2 
}
foo();
  • 2,通过外部作用域的变量进行暴露
var val;
function foo() { 
  var a = 2;
  function bar() { 
    
   val=a //通过外部作用域的变量进行暴露
  }
  bar(); 
};
foo();
console.log(val)  //2
  • 3,通过return直接将整个函数进行暴露
function foo() { 
   var a = 2;
   function bar() { 
    console.log(a)
   }
   return bar //通过return直接将整个函数进行暴露
};
var val=foo();
val()  //2

5.2 循环和闭包

看如下例子:

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

我们期望的结果是分别输出数字 1~5,每秒一次,每次一个。
但实际结果是,这段代码在运行时会以每秒一次的频率输出五次 6。

为什么我们会以为分别输出1~5?

  • 首先, 我们知道, var 的声明下,for 循环没有自己的块作用域, 也就是说 i 位于全局作用域中,整段程序只有唯一一个 i;
  • setTimeout 作为异步函数, 在程序执行过程中, 会被推到任务队列中, 等待所有同步函数执行完毕后再执行, 所以, 当 for 完成循环后, 全局变量 i 已经变成了 6, 自然在执行异步函数时输出的都是6了

如何使用闭包解决这个问题?

IIFE 函数会通过声明立即执行一个函数来创建函数作用域, 通过这个特性, 我们可以在每次循环时, 将当前状态的 i 传递到 IIFE 函数中, 在内部为 setTimeout 创建一个新的作用域;

那我们这样改写.

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

这样改写后,匿名函数每次都通过传递的形参j保存了每次i值,这样当时的i值就保存在了独立的作用域

我们还可以利用块作用域进行解决:

for (let i=1; i<=5; i++) { 
  setTimeout( function timer() {
    console.log( i );
  }, i*1000 );
}
//结果是分别输出数字 1~5,每秒一次,每次一个。	

for 循环头部的 let 不仅将 i 绑定到了 for 循环中, 事实上它将 i 重新绑定到了循环的每一个迭代中

总结一下一般什么时候考虑使用闭包:

  • 1,需要创建一个独立的作用域并隐藏一些变量或函数,不被外部使用,或者想保存一些外部作用域的变量或函数到这个独立作用域.
  • 2,只想暴露一部分自身作用域的变量或函数给外部使用.

5.3 模块

在js中的模块也是和闭包息息相关。

模块:

  • 必须要有外部的封闭函数,该函数必须要被调用一次
  • 封闭的函数至少要返回一个内部函数
  • 使用立即执行函数配合有奇效
var foo = (function(){
    var something = "cool";
    var another = [1,2,3];
    function doSomething() {
        console.log(something);
    }
    function doAnother() {
        console.log(another.join("!"));
    }
    return {
        doSomething,
        doAnother
    }
})();
foo.doSomething();//cool
foo.doAnother();//1!2!3