先有蛋仍是先有鸡?JavaScript 做用域与闭包探析

引子

先看一个问题,下面两个代码片断会输出什么?express

// Snippet 1
a = 2;
var a;
console.log(a);

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

若是了解过 JavaScript 变量提高相关语法的话,答案是显而易见的。本文做为《你不知道的 JavaScript》第一部分的阅读笔记,顺便来总结一下对做用域与闭包的理解。闭包

1、先有蛋仍是先有鸡

上面问题的答案是:异步

  1. -> 2函数

  2. -> undefined工具

咱们从编译器的角度思考:this

  • 引擎会在解释 JavaScript 代码以前首先对其进行编译(没错,JavaScript 也是要进行编译的!),而编译阶段中的一部分工做就是找到全部声明,并用合适的做用域将他们关联起来,即 包括变量和函数在内的全部声明都会在任何代码被执行前首先被处理调试

  • 当你看到 var a = 2;时可能会认为这是一个声明,但 JavaScript 实际上会将其当作两个声明:var aa = 2,第一个定义声明是在编译阶段进行的,第二个赋值声明会被留在原地等待执行阶段处理。code

  • 打个比方,这个过程就好像变量和函数声明从它们的代码中出现的位置被“移动”到了最上面,这个过程就叫作 提高对象

  • 因此,编译以后上面两个代码片断是这样的:递归

// Snippet 1 编译后
var a;
a = 2;
console.log(a);    // -> 2

// Snippet 2 编译后
var a;
console.log(a);    // -> undefined
a = 2;

因此结论就是:先有蛋(声明),后有鸡(赋值)

2、编译

实际上,JavaScript 也是一门编译语言。与传统编译语言的过程同样,程序中的一段源代码在执行以前会通过是三个步骤,统称为“编译”:

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

  • 解析/语法分析(Parsing)

  • 代码生成

简单来讲,任何 JavaScript 代码片断在执行前都要进行编译(一般就在执行前)。

3、做用域

为了理解做用域,能够想象出有如下三种角色:

  • 引擎:从头至尾负责整个 JavaScript 程序的编译及执行过程。

  • 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活。

  • 做用域:引擎的另外一位好朋友,负责收集并维护全部声明的标识符(变量)组成的一系列查询,并实施一套很是严格的规则,肯定当前执行的代码对这些标识符的访问权限。

var a = 2; 为例,过程以下:

  • 首先遇到 var a,编译器会询问做用域是否已经有一个名为 a 的变量存在于同一个做用域的集合中。若是是,编译器会忽略该声明,继续进行编译;不然就会要求做用域在当前做用域的集合中声明一个新的变量,并命名为 a.

  • 而后,编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a=2 这个赋值操做。引擎运行时会首选询问做用域,在当前的做用域集合中是否存在一个叫作 a 的变量。若是是,引擎就会使用这个变量;若是否,引擎就会继续查找该变量(一层一层向上查找)。

  • 最后,若是引擎最终找到了a变量,就会将 2 赋值给它,不然引擎就会举手示意并抛出一个异常(ReferenceError)!

当一个块或函数嵌套在另外一个块或函数中时,就发生了做用域的嵌套。遍历嵌套做用域链的规则很简单:引擎从当前的执行做用域开始查找变量,若是找不到,就向上一级查找。当抵达最外层的全局做用域时,不管找到仍是没找到,查找过程都会中止

4、函数声明式 & 函数表达式

JavaScript 中建立函数有两种方式:

// 函数声明式 
function funcDeclaration() { 
    return 'A function declaration'; 
} 

// 函数表达式 
var funcExpression = function () { 
    return 'A function expression'; 
}

声明式与表达式的差别:

  • 相似于 var 声明,函数声明能够 提高 到其它代码以前,但函数表达式不能,不过容许保留在本地变量范围内;

  • 函数表达式能够匿名,而函数声明不能够。

怎么判断是函数声明式仍是函数表达式?

  • 一个最简单的方法是看 function 关键字出如今声明的位置,若是是在第一个词,那么就是函数声明式,不然就是函数表达式。

函数表达式比函数声明式更加有用的地方:

  • 是一个闭包

  • 能够做为其余函数的参数

  • 能够做为当即调用函数表达式(IIFE

  • 能够做为回调函数

5、匿名函数 & 当即调用函数

“在任意代码片断外部添加包装函数,能够将内部的变量和函数定义“隐藏起来”,外部做用域就没法访问包装函数内部的任何内容。那么,可否更完全一些?若是必须声明一个有具体名字的函数,这个名字自己就会“污染”所在做用域;其次,必须显式经过函数名调用这个函数才能运行其中的代码。若是函数不须要函数名(或者至少函数名能够不污染所在做用域),而且可以自动运行,这就完美了!”——论匿名函数和理解调用函数的诞生。

匿名函数表达式最熟悉的场景就是回调函数:

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

匿名函数表达式书写起来简单快捷,不少库和工具也倾向鼓励使用这种风格的代码。可是,它也有几个缺点须要考虑:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。

  2. 若是没有函数名,当函数须要引用自身时只能使用已通过期的 arguments.callee 引用,好比在递归中。另外一个函数须要引用自身的例子,是在事件触发后事件监听器须要解绑自身。

  3. 匿名函数省略了对于代码可读性、可理解性很重要的函数名。一个描述性的名称可让代码不言自明。

因此,始终给函数表达式命名是一个最佳实践:

setTimeout(function timeoutHandler(){
    console.log("I waited 1 second!");
});

因为函数被包含在一对()括号内部,所以成为了一个表达式,经过在末尾加上另一个()括号就能够当即执行这个函数,好比:

(function foo(){
    // ...
})()

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

它有个术语:IIFE,表示:当即执行函数表达式(Immediately Invoked Function Expression)

它有另一个改进形式:

(function foo(){
    // ...
}())

不一样点就是把最后的括号挪进去了,实际上 这两种形式在功能上是一致的,选择哪一个全凭我的喜爱

至于 IIFE 的另外一个很是广泛的进阶用法是 把它们当作函数调用并传递参数进去

var a = 2;
(function foo(global){
    var a = 3;
    console.log(a);    // -> 3
    console.log(global.a);    // -> 2
})(window);    // 传入window对象的引用
console.log(a);    // -> 2

6、再谈提高

如今咱们再来谈一谈提高。

// Snippet 3
foo();    // -> TypeError
bar();    // -> ReferenceError
var foo = function bar(){
    console.log(1);
};

为何会输出上面这两个异常?咱们能够从编译器的角度把代码看出这样子:

var foo;    // 声明提高
foo();      // 声明但未定义为 undefined,而后这里进行了函数调用,因此返回 TypeError
bar();      // 无声明抛出引用异常,因此返回 ReferenceError
foo = function bar(){
    console.log(1);    
};

而后再变化一下,同名的函数声明和变量声明在提高阶段会怎么处理:

foo();    // 到底会输出什么?
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() 的声明以前,但它是重复的声明(所以被忽略了),由于函数声明会被提高到普通变量以前。即:函数声明和变量声明都会被提高,但函数会首先被提高,而后才是变量(这也从侧面说明了在 JavaScript 中“函数是一等公民”)。

再来:

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

解释:尽管重复的 var 声明会被忽略掉,但出现后面的函数声明仍是能够覆盖前面的。

7、闭包

闭包是基于词法做用域书写代码时所产生的天然结果,你甚至不须要为了利用它们而有意识地建立闭包。闭包的建立和使用在你的代码中随处可见。你缺乏的是根据你本身的意愿来识别、拥抱和影响闭包的思惟环境。

当函数能够记住并访问所在的词法做用域时,就产生了 闭包,即便函数是在当前词法做用域以外执行。

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz();    // -> 2,闭包的效果!

如下是解释说明:

  • 函数 bar() 的词法做用域可以访问 foo() 的内部做用域,而后咱们将 bar() 函数自己当作一个值类型紧传递。在这个例子中,咱们将 bar() 所引用的函数对象自己当作返回值。

  • foo()执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 baz 并调用 baz(),实际上只是经过不一样的标识符引用调用了内部的函数 bar()

  • bar() 显示是能够被正常执行,可是在这个例子中,它在本身定义的词法做用域之外的地方执行。

  • foo() 执行后,一般会期待 foo() 的整个内部做用域都被销毁,由于咱们知道引擎有垃圾回收器用来释放再也不使用的内存空间。因为看上去 foo() 的内容不会再被使用,因此很天然地会考虑对其进行回收。

  • 而闭包的神奇之处正是能够阻止事情的发生。事实上,内部做用域依然存在,所以没有被回收。谁在使用这个内部做用域?原来是 bar() 自己在使用。

  • bar() 所声明的位置所赐,它拥有涵盖 foo() 内部做用域的闭包,使得该做用域可以一直存活,以供 bar() 在以后任什么时候间进行引用。

  • bar() 依然持有对该做用域的引用,而 这个引用就叫闭包

本质上,不管什么时候何地,若是将函数(访问它们各自的词法做用域)当作第一级的值类型并处处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通讯、Web Workers 或者任何其余的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包

再补充一个示例:

function foo() {
    function bar() {
        console.log('1');
    }
    function baz() {
        console.log('2');
    }

    var yyy = {
        bar: bar,
        baz: baz
    }
    return yyy;
}

var kkk = foo();    // kkk经过foo得到了yyy的引用,也就能够调用bar和baz
        
kkk.bar();    // -> 1
kkk.baz();    // -> 2

9、动态做用域

事实上,JavaScript 并不具备动态做用域,它只有 词法做用域(虽然 this 机制某种程度上很像动态做用域)。词法做用域和动态做用域的主要区别为:

  • 词法做用域是在写代码或者定义时肯定的,而动态做用域是在运行时肯定的;

  • 词法做用域关注函数在何处声明,而动态做用域关注函数从何处调用。

像下面的代码片断,若是是动态做用域输出的就是3而不是2了:

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

10、参考

相关文章
相关标签/搜索