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

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

------ 如下是正文 ------git

第1章 做用域是什么

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

1.1 编译原理

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

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

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

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

    var a = 2;
    复制代码

    上面这段程序会被分解成如下词法单元:var、a、=、二、;。安全

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

  • 解析/语法分析(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个不一样的声明。

  • 一、编译器在编译时处理(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
    复制代码
  • 二、引擎在运行时处理(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个独立的步骤。

  • 一、var a在其做用域中声明新变量
  • 二、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;
    
    复制代码
  • 一、垃圾收集

    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引擎极有可能依然保存着这个结构(取决于具体实现)。

  • 二、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

// 一、必须经过调用CoolModule()来建立一个模块实例
// 二、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…

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

相关文章
相关标签/搜索