夯实基础-做用域与闭包

一篇巩固基础的文章,也多是一系列的文章,梳理知识的遗漏点,同时也探究不少理所固然的事情背后的原理。javascript

为何探究基础?由于你不去面试你就不知道基础有多重要,或者是说当你的工做经历没有亮点的时候,基础就是检验你好坏的一项指标。html

JS基础都会有哪些考点:闭包,继承,深拷贝,异步编程等一些常见考点,为何不管是当我仍是个学生的时候被面试仍是到如今当面试官去考别人,都仍是问这些?项目从jQuery都过渡到React全家桶了,js仍是考这些?java

由于这些知识点很典型,一个知识点弄懂须要先把不少前置的其余的知识点弄懂。好比闭包,闭包背后就有做用域,变量提高,函数提高,垃圾收集机制等知识点。因此这些知识点每每能以点概面,考察不少基础的东西。git

先来看看闭包(Closure)。程序员

文章里提到了一些知识点:github

  1. JS编译运行过程
  2. 词法做用域与动态做用域
  3. 做用域链顺序
  4. 变量与函数提高
  5. 闭包的应用

JS编译原理

基本概念

与JAVA,C++,C等静态语言不一样,JavaScript是不须要编译的。在JAVA中,程序员写的JAVA代码要被编译器编译成机器语言,而后执行。面试

编译express

通常程序中的一段源代码在执行以前会经历三个步骤,统称为“编译”:编程

  1. 分词/词法分析(Tokenizing/Lexing)
    这个过程会将由字符组成的字符串分解成(对编程语言来讲)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序 var a = 2;。这段程序一般会被分解成为下面这些词法单元:var、a、=、2 、;。空格是否会被看成词法单元,取决于空格在这门语言中是否具备意义。
  2. 解析/语法分析(Parsing)
    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的表明了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。var a = 2; 的抽象语法树中可能会有一个叫做 VariableDeclaration 的顶级节点,接下来是一个叫做 Identifier(它的值是 a)的子节点,以及一个叫做 AssignmentExpression的子节点。AssignmentExpression 节点有一个叫做 NumericLiteral(它的值是 2)的子节点。
  3. 代码生成

    将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。抛开具体细节,简单来讲就是有某种方法能够将 var a = 2; 的 AST 转化为一组机器指令,用来建立一个叫做 a 的变量(包括分配内存等),并将一个值储存在 a 中。windows

解释器

JavaScript则不一样,JavaScript中对应编译的部分叫作解释器(Interpreter)。这二者的区别用一句话来归纳就是:编译器是将源代码编译为另一种代码(好比机器码,或者字节码),而解释器是直接解析并将代码运行结果输出。

编译运行过程

JavaScript编译运行过程当中有三个重要的角色:引擎,编译器,做用域。三者互相配合这样工做:

  1. 源代码被编译器处理,进行词法和语法分析,将编译出来的变量、方法、数据等存储到做用域,而后将编译出来的机器代码交给引擎处理。
  2. 做用域负责收集并维护由全部声明的标识符(变量)组成的一系列查询,并实施一套很是严格的规则,肯定当前执行的代码对这些标识符的访问权限。
  3. 引擎运行来处理这些机器代码,遇到变量、方法、数据等去做用域中寻找,并执行。

举个例子:

var a = 1;

这段代码交给解释器以后:

  1. 编译器运行源代码,识别出声明变量var a,编译器询问做用域是否已经有一个该名称的变量存在于同一个做用域的集合中。若是是,编译器会忽略该声明,继续进行编译;不然它会要求做用域在当前做用域的集合中分配内存声明一个新的变量,并命名为 a。
  2. 编译器将上述代码编译成机器代码并交给引擎执行。
  3. 引擎运行时从做用域获取a,若是没有a则抛出异常,有的话则将a赋值1。

上述代码在执行过程当中开起来就好像:

var a;
a = 1;
这不就是很熟悉的变量提高吗,可是为何会有变量提高呢,能够理解为代码的声明和赋值是分别在编译和运行时执行,二者之间的数据衔接全靠做用域(事实上并非这样,后面会提到)。

异常

这里咱们很熟悉,有两种异常:编译异常,运行异常。

编译异常

编译器在编译的时候发生错误,编译中止好比:
clipboard.png
很明显编译器没法知道将1赋值给谁,无法写出对应的机器语言,编译中止。

运行异常

引擎在运行时候发生错误,例如:
clipboard.png
引擎向做用域获取a,可是编译器未在做用域中声明a,运行报错。

声明了a,并将a赋值为1,可是a没法运行,运行报错。

LHS查询 RHS查询

RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器自己,从而能够对其赋值。

ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上
有不少不一样。其中一个不一样的行为是严格模式禁止自动或隐式地建立全局变量。所以,在
严格模式中 LHS 查询失败时,并不会建立并返回一个全局变量,引擎会抛出同 RHS 查询
失败时相似的 ReferenceError 异常。

接下来,若是 RHS 查询找到了一个变量,可是你尝试对这个变量的值进行不合理的操做,
好比试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的
属性,那么引擎会抛出另一种类型的异常,叫做 TypeError。

垃圾收集

和C#、Java同样JavaScript有自动垃圾回收机制,也就是说执行环境会负责管理代码执行过程当中使用的内存,在开发过程当中就无需考虑内存分配及无用内存的回收问题了。

JavaScript垃圾回收的机制很简单:找出再也不使用的变量,而后释放掉其占用的内存,可是这个过程不是时时的,由于其开销比较大,因此垃圾回收器会按照固定的时间间隔周期性的执行。

变量生命周期

什么叫再也不使用的变量?再也不使用的变量也就是生命周期结束的变量,固然只多是局部变量,全局变量的生命周期直至浏览器卸载页面才会结束。局部变量只在函数的执行过程当中存在,而在这个过程当中会为局部变量在栈或堆上分配相应的空间,以存储它们的值,而后再函数中使用这些变量,直至函数结束(闭包特殊)。

一旦函数结束,局部变量就没有存在必要了,能够释放它们占用的内存。貌似很简单的工做,为何会有很大开销呢?这仅仅是垃圾回收的冰山一角,就像刚刚提到的闭包,貌似函数结束了,其实尚未,垃圾回收器必须知道哪一个变量有用,哪一个变量没用,对于再也不有用的变量打上标记,以备未来回收。用于标记无用的策略有不少,常见的有两种方式:标记清除和 引用计数,这里介绍一下标记清除:

标记清除(mark and sweep)

这是JavaScript最多见的垃圾回收方式,当变量进入执行环境的时候,好比函数中声明一个变量,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”。至于怎么标记有不少种方式,好比特殊位的反转、维护一个列表等,这些并不重要,重要的是使用什么策略,原则上讲不可以释放进入环境的变量所占的内存,它们随时可能会被调用的到。

垃圾回收器会在运行的时候给存储在内存中的全部变量加上标记,而后去掉环境中的变量以及被环境中变量所引用的变量(闭包),在这些完成以后仍存在标记的就是要删除的变量了,由于环境中的变量已经没法访问到这些变量了,而后垃圾回收器相会这些带有标记的变量机器所占空间。

大部分浏览器都是使用这种方式进行垃圾回收,只是垃圾收集的时间间隔不一样。

做用域(scope)

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

做用域分类

做用域共有两种主要的工做模型。第一种是最为广泛的,被大多数编程语言所采用的词法做用域,咱们会对这种做用域进行深刻讨论。另一种叫做动态做用域,仍有一些编程语言在使用(好比 Bash 脚本、Perl 中的一些模式等)。

词法做用域

词法做用域是由你在写代码时将变量和块做用域写在哪里来决定的,所以当词法分析器处理代码时会保持做用域不变(大部分状况下是这样的)。

动态做用域

动态做用域并不关心函数和做用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,做用域链是基于调用栈的,而不是代码中的做用域嵌套。

JavaScript中大部分场景都是词法做用域,函数中的this则是动态做用域,咱们先仔细讨论词法做用域。

词法做用域

词法做用域中,又可分为全局做用域函数做用域块级做用域

全局做用域

默认进入的就是全局做用域,在浏览器上全局做用域一般都被挂载到windows上。

函数做用域

函数做用域的含义是指,属于这个函数的所有变量均可以在整个函数的范围内使用及复用(事实上在嵌套的做用域中也可使用)。

var a = 1;

function fn () { // 函数做用域起点
    var a = 2;
    
    console.log(a);
} // 函数做用域终点

fn(); // 函数做用域这行业是,由于涉及到参数传值

console.log(a);

块级做用域

很常见,简单来讲用{}来包裹起来的,一般能够复用的代码就是,好比for循环,switch case,while等等。

for(var i=0; i<10; i++){ // 块做用域
    console.log(i);
} // 块做用域

var a = 1;
switch (a) { // 块做用域
    case 1: { // 块做用域
        // ....
    }
    case 2: { // 块做用域
        // ....
    }
    default: { // 块做用域
        // ....
    }
}

while (a) { // 块做用域
  // ....     
}

{ // 硬写了一个块做用域
    let a = 2;
    console.log(a);
}

看一个例子:

function func (a) {
    var b = a * 2;
    
    function foo (c) {
        console.log(a, b, c);   
    }
    
    foo(b*3)
    
    {
        let a = 2;
        console.log(a); // 2
    }
}

func(1); // 1,2,3

经过上面这个例子咱们来分析:

  1. func被定义在了默认的全局做用域,全局做用域只有 func;
  2. func函数创造了一个函数做用域,在函数体内定义的变量被定义在了函数做用域内:a,b,foo;
  3. foo函数又创造了一个函数做用域,里面有:c
  4. {}创造了一个块级做用域,里面使用let定义了一个a,这里的变量有:a

动态做用域

在词法做用域中,函数运行时遇到变量,回去在其词法做用域中寻找对应变量,而在动态做用域中,则是根据当前运行状况来肯定,最多见的就是this关键字。

var b = 1;
var c = 123;

function fn (a) {
    console.log(a);
    console.log(b);
    console.log(this.c);
}

fn('hello');

var obj = {
    b: 2,
    c: 12,
    fn: fn
}

var o = {
    obj: obj
}

obj.fn('world');

o.obj.fn('!');

fn分别在全局做用域中执行,和obj的属性执行。

变量a是fn的函数做用域中定义的,属于词法做用域范畴;

变量b没有在函数做用域中定义,向上寻找,在全局做用域中找到,也是词法做用域范畴;

this.c属于动态做用域,函数执行的时候顺着调用栈动态寻找,this老是指向调用函数者。

做用域链(scope chain)

不一样做用域之间是如何协做的,这就涉及到了做用域链。

做用域查找会在找到第一个匹配的标识符时中止。在多层的嵌套做用域中能够定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,做用域查找始终从运行时所处的最内部做用域开始,逐级向外或者说向上进行,直到碰见第一个匹配的标识符为止。

不一样做用域之间是能够嵌套的,全部的局部做用域都在全局做用域这个大容器之中,做用域之间的嵌套关系就比如堆栈和出栈。

仍是上面的例子:

  1. func函数被定义在了全局做用域上,因此func函数内的变量做用域链为:[func函数做用域,全局做用域];
  2. foo函数被定义在了foo函数内,两个做用域互相嵌套,foo函数的做用域就是:[foo函数做用域,func函数做用域,全局做用域];
  3. {}在func函数内定义了一个块级做用域:[块级做用域,func函数做用域,全局做用域]。

在每一个做用域内查找变量,若是对于的做用域内没法找到变量,则去其做用域链的上一级查找,直到找到第一个结果返回,不然返回undefined。

若是多个做用域内有相同名称的变量,则会找到距离当前做用域最近的变量。

提高(hoisting)

一开始编译运行过程的时候咱们就知道了JS中存在变量提高,实际上分红两种状况:变量声明提高和函数声明提高。

变量声明提高

一般JS引擎会在正式执行以前先进行一次预编译,在这个过程当中,首先将变量声明及函数声明提高至当前做用域的顶端,而后进行接下来的处理。

这个咱们应该很熟悉了,举个例子:

console.log(a); // undefined
var a = 1;
console.log(a); // 1

按照阅读逻辑,在a声明以前调用a,会发生RHS异常,从而触发ReferenceError。

可是实际运行的时候,并无报错,由于上面的代码看起来被编译成了:

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

这样理解看起来是否是就很合理了。

可是值得注意的是,变量提高只会提高至本做用域最顶端,而不会夸做用域:

var foo = 3;

function func () {

    var foo = foo || 5;

    console.log(foo); // 5
}

func();

在func里面的是函数做用域,全局做用域的一个子集,因此在函数做用域中调用变量foo应该就近寻找当前做用域内有无变量,找到一个即中止寻找。上述代码看起来:

var foo = 3;

function func () {
    var foo;
    
    foo = foo || 5;

    console.log(foo); // 5
}

func();

函数声明提高

与变量声明相似的,函数在声明的时候也会发生提高的状况:

func(); // 'hello world'

function func () {
    console.log('hello world');
}

类似的,若是在同一个做用域中存在多个同名函数声明,后面出现的将会覆盖前面的函数声明;

对于函数,除了使用上面的函数声明,更多时候,咱们会使用函数表达式,下面是函数声明和函数表达式的对比:

console.log(foo1);
//函数声明
function foo1() {
    console.log('function declaration');
}

console.log(foo2);
//匿名函数表达式
var foo2 = function() {
    console.log('anonymous function expression');
};

console.log(bar);
console.log(foo3);
//具名函数表达式
var foo3 = function bar() {
    console.log('named function expression');
};
console.log(bar);

JavaScript中的函数是一等公民,函数声明的优先级最高,会被提高至当前做用域最顶端。上述的例子能够发现:只有函数声明的时候,才会发生变量提高,函数不管是匿名函数/具名函数表达式,均不会发生函数声明提高。

二者优先级

二者同时存在提高,那个优先级更高:

console.log(a);
var a = 1;
function a () {
    console.log('hello');
}

console.log(b);
function b () {
    console.log('hello');
}
var b = 1;

上面例子能够看到,当变量和函数同名的时候,不管谁声明在后,都是函数的优先级最高,变量为函数让路。

为何提高

至于变量提高的缘由:Note 4. Two words about “hoisting”

闭包

通过前面知识点铺垫以后,终于来到了闭包。

function closure () {
    var a = 1;
    
    function result () {
        return a;
    }
    
    return result;
}

closure()();

上面这个例子是个很常见的闭包,变量a在函数closure内,不该该在其做用域外被访问,可是经过返回result函数实现了在外部访问到了a,这就是一个简单的闭包。

事实上闭包的定义:(wiki pedia)

闭包,又称 词法闭包(Lexical Closure)或 函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即便已经离开了创造它的环境也不例外。

简单说就是,函数内定义了一个引用了其做用域内变量的函数,而后将该函数当作一个值传递到其余地方,该函数在运行的时候,虽然运行环境已经不是其词法做用域,可是还能够访问到其词法做用域中的变量。

或者说咱们能够这样理解:

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

这里关键点:函数,函数引用了其做用域内的变量,在其词法做用域外被调用。来看一些常见的例子:

function closure () {
    var a = 1;
    
    function result () {
        return a;
    }
    
    window.result = result;
}

closure();
result();

上面例子的变形,closure不在return result,而是挂载到window对象上。很显然result的词法做用域在不是全局做用域,知足闭包的条件,也是一个闭包。

function wait(message) {
    setTimeout( function timer() {
        console.log( message );
    }, 1000 );
}
wait( "Hello, closure!" );

这个有点意思,延迟很常见。timer的词法做用域是[wait函数做用域,全局做用域],wait里面起了一个延迟队列任务,timer被当作参数传递到了延迟里,而timer里面还调用了message。这样的话,wait执行结束以后并不会被内存回收,1s以后,timer执行,其词法做用域都还在,知足闭包条件,是一个闭包。

练习

一道经典题目:输出结果

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

代码运行以后,打印5个5。这里setTimeout定义以后不会被当即执行,而是加入到队列中延迟执行,执行的时候运行匿名函数,匿名函数打印i,i不在匿名函数做用域中,顺着做用域链向上寻找,在全局做用域中找到i,这时候的i已是5了,因此均打印5。

这里变形一下:还保留for循环,以及setTimeout形式,要求结果输出0,1,2,3,4,怎么改?

不少种方法,咱们分红不一样方向去考虑:

1. 使用块级做用域

变量i其实是个全局做用域变量,for循环,每次都重复声明i,可使用块级做用域,声明不一样的块级做用域中的变量:

for(let i = 0; i<5; i++){
    setTimeout(function(){
        console.log(i);
    }, 100);
}

或者,赋值转换:

for(var i = 0; i<5; i++){
    let a = i;
    setTimeout(function(){
        console.log(a);
    }, 100);
}

这样的话,匿名函数执行的时候,函数做用域内没有i,去块级做用域寻找i,找到并返回结果,并不会直接寻找到全局做用域。

2. 闭包

闭包应该是最容易想到的,由于他的场景知足在其词法做用域外被调用,怎么使用闭包:当即执行函数(IIFE)

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

当即执行函数创造了一个新的匿名函数做用域,这个做用域内的i是定义的时候传进来的,settimeout函数执行时候线上寻找到该做用域,并打印变量。

3.bind函数

或者使用bind函数能够直接更改匿名函数的做用域:

for(var i = 0; i<5; i++){
    setTimeout(function(i){
        console.log(i);
    }.bind(this, i), 100);
}

4.奇技淫巧

只针对这个题目,可使用进栈出栈保持顺序:

var arr = [];

for(var i = 0; i<5; i++){
    arr.unshift(i);
    
    setTimeout(function(){
        console.log(arr.pop());
    }, 100);
}

参考

  1. 《你所不知道的JavaScript》
  2. 《JavaScript高级程序设计》
  3. JavaScript系列文章:变量提高和函数提高
  4. JavaScript深刻之闭包
  5. 闭包
相关文章
相关标签/搜索