JavaScript之作用域和闭包

目尽地平线 2019-06-27

一、作用域

  1. 作用域共有两种主要的工作模型:第一种是最为普遍的,被大多数编程语言所采用的词法作用域,另外一种叫作动态作用域;
  2. JavaScript所采用的作用域模式是词法作用域。

1.词法作用域

  1. 词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
  2. JavaScript 中有两个机制可以“欺骗”词法作用域:

    • eval(..):可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时) ;
    • with:通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时) 。
    • 这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。

2.函数作用域和块级作用域

  1. 函数作用域: 函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,即函数内定于的函数和变量为该函数私有;
  2. 块级作用域:

    • 块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)
    • ES6前在JavaScript中并不存在块级作用域( 例外:try/catch 结构在 catch 分句中具有块作用域);
    • 在 ES6 中引入了 let 关键字( var 关键字的表亲) ,用来在任意代码块中声明变量。 if(..) { let a = 2; } 会声明一个劫持了 if 的 { .. } 块的变量,并且将变量添加到这个块中(另外常量定义const也具有块级作用域)。

3.函数和变量的提升

(1)、提升

  1. 函数作用域和块作用域的行为是一样的,即,某个作用域内的变量,都将附属于这个作用域。
  2. 引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来;
  3. 因此包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理;
  4. 当看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个声明: var a; 和 a = 2; 。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。

    • 这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作提升
  5. 每个作用域都会进行提升操作;

(2)、函数优先

  1. 函数声明和变量声明都会被提升。但是函数会首先被提升,然后才是变量。
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 );
};
  • var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明(因此被忽略了) ,因为函数声明会被提升到普通变量之前。
  • 尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

二、作用域闭包

(1)、理解闭包

  • 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
  1. 在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成"定义在一个函数内部的函数"。
  2. 在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁

(2)、闭包的用途

  1. 可以读取函数内部的变量;
  2. 让变量的值始终保持在内存中。

(3)、闭包的产生实例

  1. 可以读取函数内部的变量
function foo() {
var a = 2;
function bar() {
    console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 这就是闭包的效果。
  • 在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间;
  • 闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收,因为 bar() 本身在使用;
  • 拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。
  • bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
  1. 循环和闭包:
for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}
  • 正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次 6:

    • 延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0) ,所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。
    • 实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i,即所有函数共享一个 i 的引用 。
  • 解决方案:使用 IIFE在每次迭代中将本次迭代的i传入创建的作用域并封闭起来;
for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })( i );
}
  • 在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

(4)、使用闭包的注意点

  1. 由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。

    • 解决方案:在退出函数之前,将不使用的局部变量全部删除。
  2. 闭包会在父函数外部,改变父函数内部变量的值。所以,如果把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

相关推荐