阅读 7553

精华提炼「你不知道的 JavaScript」之作用域和闭包

更新:谢谢大家的支持,最近折腾了一个博客官网出来,方便大家系统阅读,后续会有更多内容和更多优化,猛戳这里查看

------ 以下是正文 ------

第1章 作用域是什么

  • 问题1:变量储存在哪里?
  • 问题2:程序需要时如何找到它们?

1.1 编译原理

JavaScript语言是“动态”或“解释执行”语言,但事实上是一门编译语言。但它不是提前编译的,编译结果也不能在分布式系统中移植。

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

  • 分词/词法分析(Tokenizing/Lexing)

    将由字符组成的字符串分解成(对编程语言来说)有意义的代码块。

    var a = 2;
    复制代码

    上面这段程序会被分解成以下词法单元:var、a、=、2、;。

    空格是否会被当做词法单元,取决于空格在这门语言中是否有意义。

  • 解析/语法分析(Parsing)

    将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的数。这个数被称作抽象语法树(Abstract Syntax Tree, AST)。

    var a = 2;
    复制代码

    以上代码的抽象语法树如下所示:

    • VariableDeclaration 顶级节点
      • Identifier 子节点,值为a
      • AssignmentExpression 子节点
        • NumericLiteral 子节点,字为2
  • 代码生成

    AST转换成可执行代码的过程。过程与语言、目标平台等相关。

    简单来说就是可以通过某种方法将var a = 2;的AST转化为一组机器指令。用来创建一个叫做a的变量(包括分配内存等),并将一个值存储在a中。

1.2 理解作用域

1.2.1 演员表
  • 引擎:从头到尾负责整个JavaScript程序的编译和执行。
  • 编译器:负责语法分析和代码生成等
  • 作用域:负责收集并维护由所有声明的标识符(变量、函数)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
1.2.2 对话

var a = 2;存在2个不同的声明。

  • 1、编译器在编译时处理(var a):在当前作用域中声明一个变量(如果之前没有声明过)。

    st=>start: Start
    e=>end: End
    op1=>operation: 分解成词法单元
    op2=>operation: 解析成树结构AST
    cond=>condition: 当前作用域存在变量a?
    op3=>operation: 忽略此声明,继续编译
    op4=>operation: 在当前作用域集合中声明新变量a
    op5=>operation: 生成代码
    st->op1->op2->cond
    cond(yes)->op3->op5->e
    cond(no)->op4->op5->e
    复制代码
  • 2、引擎在运行时处理(a = 2):在作用域中查找该变量,如果找到就对变量赋值。

st=>start: Start
e=>end: End
cond=>condition: 当前作用域存在变量a?
cond2=>condition: 全局作用域?
op1=>operation: 引擎使用这个变量a
op2=>operation: 引擎向上一级作用域查找变量a
op3=>operation: 引擎把2赋值给变量a
op4=>operation: 举手示意,抛出异常
st->cond
cond(yes)->op1->op3->e
cond(no)->cond2(no)->op2(right)->cond
cond2(yes)->op4->e
复制代码
1.2.3 LHS和RHS查询

LR分别代表一个赋值操作的左侧和右侧,当变量出现在赋值操作的左侧时进行LHS查询,出现在赋值操作的**非左侧**时进行RHS查询。

  • LHS查询(左侧):找到变量的容器本身,然后对其赋值
  • RHS查询(非左侧):查找某个变量的值,可以理解为 retrieve his source value,即取到它的源值
function foo(a) {
    console.log( a ); // 2
}

foo(2);
复制代码

上述代码共有1处LHS查询,3处RHS查询。

  • LHS查询有:

    • 隐式的a = 2中,在2被当做参数传递给foo(…)函数时,需要对参数a进行LHS查询
  • RHS查询有:

    • 最后一行foo(...)函数的调用需要对foo进行RHS查询

    • console.log( a );中对a进行RHS查询

    • console.log(...)本身对console对象进行RHS查询

1.3 作用域嵌套

遍历嵌套作用域链的规则:引擎从当前的执行作用域开始查找变量,如果找不到就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没有找到,查找过程都会停止。

1.4 异常

ReferenceError和作用域判别失败相关,TypeError表示作用域判别成功了,但是对结果的操作是非法或不合理的。

  • RHS查询在作用域链中搜索不到所需的变量,引擎会抛出ReferenceError异常。
  • 非严格模式下,LHS查询在作用域链中搜索不到所需的变量,全局作用域中会创建一个具有该名称的变量并返还给引擎。
  • 严格模式下(ES5开始,禁止自动或隐式地创建全局变量),LHS查询失败会抛出ReferenceError异常
  • 在RHS查询成功情况下,对变量进行不合理的操作,引擎会抛出TypeError异常。(比如对非函数类型的值进行函数调用,或者引用null或undefined类型的值中的属性)

1.5 小结

var a = 2被分解成2个独立的步骤。

  • 1、var a在其作用域中声明新变量
  • 2、a = 2会LHS查询a,然后对其进行赋值

第2章 词法作用域

2.1 词法阶段

词法作用域是定义在词法阶段的作用域,是由写代码时将变量和块作用域写在哪里来决定的,所以在词法分析器处理代码时会保持作用域不变。(不考虑欺骗词法作用域情况下)

2.1.1 查找
  • 作用域查找会在找到第一个匹配的标识符时停止。

  • 遮蔽效应:在多层嵌套作用域中可以定义同名的标识符,内部的标识符会“遮蔽”外部的标识符。

  • 全局变量会自动变成全局对象的属性,可以间接的通过对全局对象属性的引用来访问。通过这种技术可以访问那些被同名变量所遮蔽的全局变量,但是非全局的变量如果被遮蔽了,无论如何都无法被访问到。

    window.a
    复制代码
  • 词法作用域只由函数被声明时所处的位置决定。

  • 词法作用域查找只会查找一级标识符,比如a、b、c。对于foo.bar.baz,词法作用域只会查找foo标识符,找到之后,对象属性访问规则会分别接管对barbaz属性的访问。

2.2 欺骗词法

欺骗词法作用域会导致性能下降。以下两种方法不推荐使用

2.2.1 eval

eval(..)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。

function foo (str, a) {
    eval( str ); // 欺骗!
    console.log( a, b );
}

var b = 2;
foo( "var b = 3;", 1 ); // 1, 3
复制代码

eval('var b = 3')会被当做本来就在那里一样来处理。

  • 非严格模式下,如果eval(..)中所执行的代码包含一个或多个声明,会在运行期修改书写期的词法作用域。上述代码中在foo(..)内部创建了一个变量b,并遮蔽了外部作用域中的同名变量。
  • 严格模式下,eval(..)在运行时有自己的词法作用域,其中的声明无法修改作用域。
function foo (str) {
    "use strict"; 
    eval( str ); 
    console.log( a ); // ReferenceError: a is not defined
}

foo( "var a = 2;" ); 
复制代码
  • setTimeout(..)setInterval(..)的第一个参数可以是字符串,会被解释为一段动态生成的函数代码。已过时,不要使用
  • new Function(..)的最后一个参数可以接受代码字符串(前面的参数是新生成的函数的形参)。避免使用
2.2.2 with

with通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身

var obj = {
    a: 1,
    b: 2,
    c: 3
};

// 单调乏味的重复“obj”
obj.a = 2;
obj.b = 3;
obj.c = 4;

// 简单的快捷方式
with (obj) {
	a = 3;
    b = 4;
    c = 5;
}
复制代码

with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,这个对象的属性会被处理为定义在这个作用域中的词法标识符。

这个块内部正常的var声明并不会被限制在这个块的作用域中,而是被添加到with所处的函数作用域中。

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被泄露到全局作用域上了!
复制代码

上面例子中,创建了o1o2两个对象。其中一个有a属性,另一个没有。在with(obj){..}内部是一个LHS引用,并将2赋值给它。

  • o1传递进去后,with声明的作用域是o1,a = 2赋值操作找到o1.a并将2赋值给它。
  • o2传递进去后,作用域o2中并没有a属性,因此进行正常的LHS标识符查找,o2的作用域、foo(..)的作用域和全局作用域都没有找到标识符a,因此当a = 2执行时,自动创建了一个全局变量(非严格模式),所以o2.a保持undefined。
2.2.3 性能
  • JavaScript引擎会在编译阶段进行数项的性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
  • 引擎在代码中发现eval(..)with,它只能简单的假设关于标识符位置的判断都是无效的。因为无法在词法分析阶段明确知道eval(..)会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给with用来创建词法作用域的对象的内容到底是什么。
  • 悲观情况下如果出现了eval(..)或with,所有的优化可能都是无意义的,最简单的做法就是完全不做任何优化。代码运行起来一定会变得非常慢。

2.3 小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。

编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

有以下两个机制可以“欺骗”词法作用域:

  • eval(..):对一段包含一个或多个声明的”代码“字符串进行演算,借此来修改已经存在的词法作用域(运行时)。
  • with:将一个对象的引用当做作用域来处理,将对象的属性当做作用域中的标识符来处理,创建一个新的词法作用域(运行时)。

副作用是引擎无法在编译时对作用域查找进行优化。因为引擎只能谨慎地认为这样的优化是无效的,使用任何一个都将导致代码运行变慢。不要使用它们

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

3.1 函数中的作用域

属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。

function foo(a) {
    var b = 2;
    
    // 一些代码
    
    function bar() {
        // ...
    }
    
    // 更多的代码
    
    var c = 3;
}
复制代码

foo(..)作用域中包含了标识符(变量、函数)a、b、c和bar。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处的作用域。

全局作用域只包含一个标识符:foo

3.2 隐藏内部实现

最小特权原则(最小授权或最小暴露原则):在软件设计中,应该最小限度地暴露必要内容,而将其他内容都”隐藏“起来,比如某个模块或对象的API设计。

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }
    
    var b;
    
    b = a + doSomethingElse( a * 2 );
    
    console.log( b * 3 );
}

doSomething( 2 ); // 15
复制代码

bdoSomethingElse(..)都无法从外部被访问,而只能被doSomething(..)所控制,设计上将具体内容私有化了。

3.2.1 规避冲突

”隐藏“作用域中的变量和函数带来的另一个好处是可以避免同名标识符之间的冲突。

function foo() {
    function bar(a) {
        i = 3; // 修改for循环所属作用域中的i
        console.log( a + i );
    }
    
    for (var i = 0; i < 10; i++) {
        bar( i * 2 ); // 糟糕,无限循环了!
    }
}
foo();
复制代码

bar(..)内部的赋值表达式i = 3意外的覆盖了声明在foo(..)内部for循环中的i。

解决方案:

  • 声明一个本地变量,任何名字都可以,例如var i = 3
  • 采用一个完全不同的标识符名称,例如var j = 3

规避变量冲突的典型例子:

  • 全局命名空间

    第三方库会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中。

  • 模块管理

    任何库无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显示的导入到另外一个特定的作用域中。

3.3 函数作用域

var a = 2;

function foo() { // <-- 添加这一行
    
    var a = 3;
    console.log( a ); // 3
    
} // <-- 以及这一行
foo(); // <-- 以及这一行

console.log( a ); // 2
复制代码

上述函数作用域虽然可以将内部的变量和函数定义”隐藏“起来,但是会导致以下2个额外问题。

  • 必须声明一个具名函数foo(),意味着foo这个名称本身”污染“了所在的作用域。
  • 必须显示地通过函数名foo()调用这个函数才能运行其中的代码。

解决方案:

var a = 2;

(function foo(){ // <-- 添加这一行
    
    var a = 3;
    console.log( a ); // 3
    
})(); // <-- 以及这一行

console.log( a ); // 2
复制代码

上述代码包装函数的声明以(function...开始,函数会被当做函数表达式而不是一个标准的函数声明来处理。

  • 区分函数声明函数表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。
    • 函数声明:function是声明中的第一个词
    • 函数表达式:不是声明中的第一个词
  • 函数声明函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
    • 第一个片段中,foo被绑定在所在作用域中,可以直接通过foo()来调用它。
    • 第二个片段中,foo被绑定在函数表达式自身的函数中,而不是所在的作用域。(function foo(){ .. }foo只能在..所代表的位置中被访问,外部作用域不行。foo变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
3.3.1 匿名和具名
setTimeout( function() {
    console.log("I wait 1 second!");
}, 1000 );
复制代码

上述是匿名函数表达式,因为function()..没有名称标识符。

函数表达式可以匿名,但函数声明不可以省略函数名。

匿名函数表达式有以下缺点:

  • 在栈追踪中不会显示出有意义的函数名,会使得调试困难。
  • 没有函数名,当函数需要引用自身时只能使用已经过期arguments.callee引用
    • 递归
    • 事件触发后事件监听器需要解绑自身
  • 匿名函数省略了对于代码可读性/可理解性很重要的函数名。

解决方案:

行内函数表达式可以解决上述问题,始终给函数表达式命名是一个最佳实践。

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
    console.log( "I waited 1 second!" );
}, 1000 );

复制代码
3.3.2 立即执行函数表达式

立即执行函数表达式(IIFE,Immediately Invoked Function Expression)

  • 匿名/具名函数表达式

    第一个( )将函数变成表达式,第二个( )执行了这个函数

    var a = 2;
    (function IIFE() {
        
        var a = 3;
        console.log( a ); // 3
        
    })();
    
    console.log( a ); // 2
    
    复制代码
  • 改进型(function(){ .. }())

    用来调用的( )被移进了用来包装的( )中。

  • 当做函数调用并传递参数进去

    var a = 2;
    (function IIFE( global ) {
        
        var a = 3;
        console.log( a ); // 3
        console.log( global.a ); // 2
        
    })( window );
    
    console.log( a ); // 2
    
    复制代码
  • 解决undefined标识符的默认值被错误覆盖导致的异常

    将一个参数命名为undefined,但是在对应的位置不传入任何值,这样就可以保证在代码块中undefined标识符的值真的是undefined

    undefined = true;
    
    (function IIFE( undefined ) {
        
        var a;
        if (a === undefined) {
            console.log("Undefined is safe here!");
        }
    })();
    
    复制代码
  • 倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当做参数传递进去

    函数表达式def定义在片段的第二部分,然后当做参数(这个参数也叫做def)被传递进IIFE函数定义的第一部分中。最后,参数def(也就是传递进去的函数)被调用,并将window传入当做global参数的值。

    var a = 2;
    
    (function IIFE( def ) {
        def( window );
    })(function def( global ) {
       
        var a = 3;
        console.log( a ); // 3
        console.log( global.a ); // 2
        
    });
    
    复制代码

3.4 块作用域

表面上看JavaScript并没有块作用域的相关功能,除非更加深入了解(with、try/catch 、let、const)。

for (var i = 0; i < 10; i++) {
    console.log( i );
}

复制代码

上述代码中i会被绑定在外部作用域(函数或全局)中。

var foo = true;

if (foo) {
    var bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

复制代码

上述代码中,当使用var声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。

3.4.1 with

块作用域的一种形式,用with从对象中创建出的作用域仅在**with声明中**而非外部作用域中有效。

3.4.2 try/catch

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

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

console.log( err ); // ReferenceError: err not found

复制代码

当同一个作用域中的两个或多个catch分句用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告,实际上这并不是重复定义,因为所有变量都会安全地限制在块作用域内部。

3.4.3 let

ES6引入了let关键字,可以将变量绑定到所在的任意作用域中(通常是{ .. }内部),即let为其声明的变量隐式地劫持了所在的块作用域。

var foo = true;

if (foo) {
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

console.log( bar ); // ReferenceError

复制代码

存在的问题

let将变量附加在一个已经存在的的块作用域上的行为是隐式的,如果习惯性的移动这些块或者将其包含在其他的块中,可能会导致代码混乱。

解决方案

为块作用域显示地创建块。显式的代码优于隐式或一些精巧但不清晰的代码。

var foo = true;

if (foo) {
    { // <-- 显式的块
        let bar = foo * 2;
        bar = something( bar );
        console.log( bar );
    }
}

console.log( bar ); // ReferenceError

复制代码

在if声明内部显式地创建了一个块,如果需要对其进行重构,整个块都可以被方便地移动而不会对外部if声明的位置和语义产生任何影响。

  • 在let进行的声明不会在块作用域中进行提升

    console.log( bar ); // ReferenceError
    let bar = 2;
    
    复制代码
  • 1、垃圾收集

    function process(data) {
        // 在这里做点有趣的事情
    }
    
    var someReallyBigData = { .. };
    
    process( someReallyBigData );
    
    var btn = document.getElementById( "my_button" );
    
    btn.addEventListener( "click", function click(evt) {
        console.log("button clicked");
    }, /*capturingPhase*/false );
    
    复制代码

    click函数的点击回调并不需要someReallyBigData。理论上当process(..)执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于click函数形成了一个覆盖整个作用域的闭包,JS引擎极有可能依然保存着这个结构(取决于具体实现)。

  • 2、let循环

    for (let i = 0; i < 10; i++) {
        console.log( i );
    }
    
    console.log( i ); // ReferenceError
    
    复制代码

    for循环头部的let不仅将i绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

    {
        let j;
        for (j = 0; j < 10; j++) {
            let i = j; // 每个迭代重新绑定!
            console.log( i ); 
       	} 
    }
    
    复制代码
3.4.4 const

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章 提升

  • 任何声明在某个作用域内的变量,都将附属于这个作用域。
  • 包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
  • var a = 2;会被看成两个声明,var a;a = 2;,第一个声明在编译阶段进行,第二个赋值声明会被留在原地等待执行阶段
  • 所有的声明(变量和函数)都会被**“移动”到各自作用域的最顶端,这个过程叫做提升**
  • 只有声明本身会被提升,而包括函数表达式在内的赋值或其他运行逻辑并不会提升。
a = 2;

var a;

console.log( a ); // 2

---------------------------------------
// 实际按如下形式进行处理
var a; // 编译阶段

a = 2; // 执行阶段

console.log( a ); // 2

复制代码
console.log( a ); // undefinde

var a = 2;

---------------------------------------
// 实际按如下形式进行处理
var a; // 编译

console.log( a ); // undefinde

a = 2; // 执行

复制代码
  • 每个作用域都会进行变量提升
function foo() {
    var a;
    
    console.log( a ); // undefinde
    
    a = 2;
}

foo();

复制代码
  • 函数声明会被提升,但是函数表达式不会被提升
foo(); // 不是ReferenceError,而是TypeError!

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

复制代码

上面这段程序中,变量标识符foo()被提升并分配给所在作用域,因此foo()不会导致ReferenceError。此时foo并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值),foo()由于对undefined值进行函数调用而导致非法操作,因此抛出TypeError异常。

  • 即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用。
foo(); // TypeError
bar(); // ReferenceError

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

---------------------------------------
// 实际按如下形式进行处理
var foo;

foo(); // TypeError
bar(); // ReferenceError

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

复制代码

4.1 函数优先

  • 函数声明和变量声明都会被提升,但是,函数首先被提升,然后才是变量
foo(); // 1

var foo;

function foo() {
    console.log( 1 ); 
};

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

---------------------------------------
// 实际按如下形式进行处理

function foo() { // 函数提升是整体提升,声明 + 赋值
    console.log( 1 ); 
};

foo(); // 1

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

复制代码
  • var foo尽管出现在function foo()...的声明之前,但它是重复的声明,且函数声明会被提升到普通变量之前,因此被忽略
  • 后面出现的函数声明可以覆盖前面的。
foo(); // 3

function foo() {
    console.log( 1 ); 
};

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

function foo() {
    console.log( 3 ); 
};

复制代码
  • 一个普通块内部的函数声明通常会被提升到所在作用域的顶部,不会被条件判断所控制。尽量避免在普通块内部声明函数
foo(); // "b"

var a = true;
if (a) {
    function foo() { console.log( "a" ); };
}
else {
    function foo() { console.log( "b" ); };
}

复制代码

第5章 作用域闭包

5.1 闭包

  • 当函数可以记住并访问所在的词法作用域,即使函数名是在当前词法作用域之外执行,这时就产生了闭包。
function foo() {
    var a = 2;
    
    function bar() {
		console.log( a );
    }
    
    return bar;
}

var baz = foo();

baz(); // 2 ---- 这就是闭包的效果

复制代码

bar()在自己定义的词法作用域以外的地方执行。

bar()拥有覆盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用,不会被垃圾回收器回收

  • bar()持有对foo()内部作用域的引用,这个引用就叫做闭包。
// 对函数类型的值进行传递
function foo() {
    var a = 2;
    
    function baz() {
		console.log( a ); // 2
    }
    
    bar( baz );
}

function bar(fn) {
    fn(); // 这就是闭包
}

foo();

复制代码
  • 把内部函数baz传递给bar,当调用这个内部函数时(现在叫做fn),它覆盖的foo()内部作用域的闭包就形成了,因为它能够访问a。
// 间接的传递函数
var fn;

function foo() {
    var a = 2;
    
    function baz() {
		console.log( a ); 
    }
    
    fn = baz; // 将baz分配给全局变量
}

function bar() {
    fn(); // 这就是闭包
}

foo();
bar(); // 2

复制代码
  • 将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
function wait(message) {
    
    setTimeout( function timer() {
        console.log( message );
    }, 1000 );
}

wait( "Hello, closure!" );

复制代码
  • 在引擎内部,内置的工具函数setTimeout(..)持有对一个参数的引用,这里参数叫做timer,引擎会调用这个函数,而词法作用域在这个过程中保持完整。这就是闭包
  • 定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包
// 典型的闭包例子:IIFE
var a = 2;

(function IIFE() {
    console.log( a );
})();

复制代码

5.2 循环和闭包

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

//输入五次6

复制代码
  • 延迟函数的回调会在循环结束时才执行,输出显示的是循环结束时i的最终值。
  • 尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i

尝试方案1:使用IIFE增加更多的闭包作用域

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

//失败,因为IIFE作用域是空的,需要包含一点实质内容才可以使用

复制代码

尝试方案2:IIFE增加变量

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

// 正常工作

复制代码

尝试方案3:改进型,将i作为参数传递给IIFE函数

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

// 正常工作

复制代码
5.2.1 块作用域和闭包
  • let可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
  • 本质上这是将一个块转换成一个可以被关闭的作用域
for (var i = 1; i <= 5; i++) {
    let j = i; // 闭包的块作用域!
    setTimeout( function timer() {
        console.log( j );
    }, j * 1000 );
}

// 正常工作

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

上面这句话参照3.4.3–---2.let循环,即以下

{
    let j;
    for (j = 0; j < 10; j++) {
        let i = j; // 每个迭代重新绑定!
        console.log( i ); 
   	} 
}

复制代码

循环改进:

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

// 正常工作

复制代码

5.3 模块

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

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例,可以通过IIFE实现单例模式)
  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
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、必须通过调用CoolModule()来创建一个模块实例
// 2、CoolModule()返回一个对象字面量语法{ key: value, ... }表示的对象,对象中含有对内部函数而不是内部数据变量的引用。内部数据变量保持隐藏且私有的状态。

复制代码
  • 使用IIFE实现单例模式

立即调用这个函数并将返回值直接赋予给单例的模块标识符foo。

var foo = (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
    }
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

复制代码

5.5.1 现代的模块机制

大多数模块依赖加载器/管理器本质上是将这种模块定义封装进一个友好的API。

var MyModules = (function Manager() {
    var modules = {};
    
    function define(name, deps, impl) {
        for (var i = 0; i < deps.length; i++ ) {
			deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply( impl, deps ); // 核心,为了模块的定义引用了包装函数(可以传入任何依赖),并且将返回值(模块的API),储存在一个根据名字来管理的模块列表中。
    }
    
    function get(name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    };
    
})();

复制代码

使用上面的函数来定义模块:

MyModules.define( "bar", [], function() {
    function hello(who) {
        return "Let me introduct: " + who;
    }
    
    return {
        hello: hello
    };
} );

MyModules.define( "foo", ["bar"], function(bar) {
    var hungry = "hippo";
    
    function awesome() {
        console.log( bar.hello( hungry ).toUpperCase() );
    }
    
    return {
        awesome: awesome
    };
} );

var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );

console.log(
	bar.hello( "hippo" );
) // Let me introduct: hippo

foo.awesome(); // LET ME INTRODUCT: HIPPO

复制代码

5.5.2 未来的模块机制

在通过模块系统进行加载时,ES6会将文件当做独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样可以导出自己的API成员。

ES6模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)

  • 基于函数的模块不能被静态识别(编译器无法识别),只有在运行时才会考虑API语义,因此可以在运行时修改一个模块的API。
  • ES6模块API是静态的(API模块不会在运行时改变),会在编译期检查对导入模块的API成员的引用是否真实存在。
// bar.js

function hello(who) {
    return "Let me introduct: " + who;
}

export hello;


// foo.js
// 仅从“bar”模块导入hello()
import hello from "bar";

var hungry = "hippo";

function awesome() {
    console.log(
    	hello( hungry ).toUpperCase();
    );
}

export awesome;

// baz.js
// 导入完整的“foo”和”bar“模块
module foo from "foo";
module bar from "bar";

console.log(
	bar.hello( "rhino")
); // Let me introduct: rhino

foo.awesome(); // LET ME INTRODUCT: HIPPO

复制代码
  • import:将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上
  • module:将整个模块的API导入并绑定到一个变量上。
  • export:将当前模块的一个标识符(变量、函数)导出为公共API

附录A 动态作用域

  • 词法作用域是在写代码或者定义时确定的,关注函数在何处声明,作用域链基于代码嵌套。
  • 动态作用域是在运行时确定的(this也是),关注函数从何处调用,作用域链基于调用栈。
  • JavaScript并不具备动态作用域,它只有词法作用域。但是this机制某种程度上很像动态作用域。
// 词法作用域,关注函数在何处声明,a通过RHS引用到了全局作用域中的a
function foo() {
    console.log( a ); // 2
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;
bar();

-----------------------------
// 动态作用域,关注函数从何处调用,当foo()无法找到a的变量引用时,会顺着调用栈在调用foo()的地方查找a
function foo() {
    console.log( a ); // 3(不是2!)
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;
bar();

复制代码

附录B 块作用域的替代方案

ES3开始,JavaScript中就有了块作用域,包括with和catch分句。

// ES6环境
{
    let a = 2;
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

复制代码

上述代码在ES6环境中可以正常工作,但是在ES6之前的环境中如何实现呢?

答案是使用catch分句,这是ES6中大部分功能迁移的首选方式。

try {
    throw 2;
} catch (a) {
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

复制代码

B.1 Traceur

// 代码转换成如下形式
{
    try {
        throw undefined;
    } catch (a) {
        a = 2;
        console.log( a ); // 2
    }
}

console.log( a ); // ReferenceError

复制代码

B.2 隐式和显式作用域

let声明会创建一个显式的作用域并与其进行绑定,而不是隐式地劫持一个已经存在的作用域(对比前面的let定义)。

let (a = 2) {
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

复制代码

存在的问题:

let声明不包含在ES6中,Traceur编译器也不接受这种代码

  • 方案一:使用合法的ES6语法并且在代码规范上做一些妥协
/*let*/ { let a = 2;
    console.log( a );
}

console.log( a ); // ReferenceError

复制代码
  • 方案二:使用let-er工具,生成完全标准的ES6代码,不会生成通过try/catch进行hack的ES3替代方案
{
    let a = 2;
    console.log( a );
}

console.log( a ); // ReferenceError

复制代码

B.3 性能

  • try/catch的性能的确很糟糕,但技术层面上没有合理的理由来说明try/catch必须这么慢,或者会一直慢下去。
  • IIFE和try/catch不是完全等价的,因为如果把一段代码中的任意一部分拿出来用函数进行包裹,会改变这段代码的含义,其中的this、return、break和continue都会发生变化。IIFE并不是一个普适的方案,只适合在某些情况下进行手动操作。

交流

进阶系列文章汇总如下,内有优质前端资料,觉得不错点个star。

github.com/yygmind/blo…

我是木易杨,网易高级前端工程师,跟着我每周重点攻克一个前端面试重难点。接下来让我带你走进高级前端的世界,在进阶的路上,共勉!

关注下面的标签,发现更多相似文章
评论