阅读 108

读懂闭包 - JavaScript

在理解闭包之前必须清楚作用域,而说起作用域就不得不提起JavaScript的编译

编译


一般情况下语言分为两种类型:编译型语言和解释型语言,这些语言一般指我们编写的代码(也就是高级语言),这些代码机器一般看不懂,所以需要先翻译一下,翻译成机器语言才能执行。

这两种语言的最大的区别在于翻译的时机不同,编译型语言一般会提前进行编译,把代码编译成为机器语言的文件,运行时再直接运行编译后的文件即可,不需要每次运行时都重复编译,编译与运行操作相对独立(如C/C++);而解释型语言在运行前也需要编译,但其编译阶段往往在执行前的那一瞬间,通常在编译后就马上会执行。

回到我们的JavaScript,JavaScript属于解释型语言,在执行前的瞬间进行编译,整个过程都是由JavaScript引擎在控制和操作,下面以一句赋值语句 var a = 1 为例简单看看编译都是干了些啥:

  1. 分词/词法分析 编译器在拿到代码后,二话不说一顿拆,把代码字符串拆成不可分割的词法单元,如上述赋值语句会被拆成 var、a、=、1 。
  2. 解析/语法分析 拆完之后太乱了!需要把这堆词法单元整理一下,整理转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,称为“抽象语法树”(Abstract Syntax Tree,AST)。
  3. 代码生成 整理后我(编译器)看着舒服了,但是执行不了啊,所以要把AST转换成可执行的代码,可以理解为机器代码。

ok简单的了解了下编译操作,实际操作要比这里说的复杂得多,期间还包括了很多性能优化等操作,没事这里我们只要了解这几个主要步骤即可。

简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript 编译器首先会对 var a = 1 这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。

理解作用域


作用域是干什么的

知道了存在编译行为,那么再回头看下 var a = 1; 这句语句具体是如何执行的?

首先是编译阶段,编译器会对这句代码字符串进行分词,再整理成抽象语法树,最后在生成代码的过程中,作用域大哥出现了,编译器看到了声明a这个变量,首先会询问作用域大哥是否存在a这个变量了?如果是,编译器会忽略该声明,继续进行编译;否则编译器会要求作用域大哥在当前作用域的集合中声明一个新的变量,并命名为a。(假如在当前作用域代码中有多处声明的话,那么都会在编译阶段提前进行声明,也就是常说的变量提升)

ok编译器完事了,将生成后的机器代码交给js引擎执行,由于已经声明过了,引擎看到var a = 1;时,知道它应该做的是给a赋值1,所以首先也要问作用域大哥,有没有a这个变量,大哥掏了下自己的裤袋发现有并把a交出去,js引擎拿到后就赋值为1;假如大哥掏了半天没找着a,js引擎又没有其他作用域可以问了(此时就是位于顶层作用域中,也就是全局作用域),那只能抛出一个错误了。

作用域嵌套

上面的例子中提到“没有其他作用域可以问了”,没错,作用域可以有很多个,它们可以互相嵌套,也可以并存。看下面代码

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

var a = 1;
foo();
复制代码

这里存在两个作用域,一个全局作用域,另一个是foo的作用域。 同样,编译后进入执行,执行foo();时,先遇到变量b,询问foo作用域大哥有没有b,有的好的赋值为2; ...(这里跳过查找console) 继续,看到变量a,继续询问foo作用域大哥,大哥掏了半天确实没有,不急,沿着它的上层作用域,也就是全局作用域继续问; 有了,在全局作用域大哥那里找到了,取到值为1,执行打印语句。

看了上面这些小例子,大概就能明白作用域大哥主要是在管理属于自己的东西;而这种由多个作用域嵌套产生的,称为作用域链,最顶层为全局作用域,查找变量的规则就是沿着作用域链一层一层的往上找。

那作用域链是怎么产生的?答案就是编译阶段就产生的,可以理解为写代码时就形成了。 为什么会这样?词法作用域规定的。

词法作用域

首先作用域不是JavaScript特有的,它是一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的作用域中根据标识符名称(变量名)进行查找。

作用域共有两种主要的工作模型:第一种是最为普遍的,被大多数编程语言所采用的词法作用域(js采用的就是这种),另外一种叫做动态作用域,仍有一些编程语言在使用(比如 Bash 脚本、Perl 中的一些模式等)。

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的,除了eval,with等)。这里没有为什么,因为词法作用域就是这么规定的,所以我们才经常说作用域是在写代码的时候就已经定好了,其实就是在编译阶段产生的。

闭包


闭包是什么

巴啦啦说了这么多,闭包终于来了。首先,闭包也可以理解为一种权限,一种可以访问某一个作用域的权限。闭包也是基于词法作用域书写代码时所产生的自然结果。

function foo() { 
    var a = 2; 
    function bar() { 
        console.log( a );
    } 
    return bar 
} 
var fn = foo();
fn(); // 2
复制代码

执行上面结果我们会得到输出2,分析一下,首先,这里存在三个作用域,全局作用域,foo作用域,bar作用域。

当执行fn();时,实际上执行的是bar函数,询问bar作用域大哥有没有a变量,得到否定回答后,接着要问谁???

回头看一下代码,如果你把目光聚集到fn();身上,那你可能会有那么一刹那觉得接下来要问全局作用域了;如果,你把目光聚集到bar函数定义处,那么你可能就会想到接下来应该问foo作用域了。没错,这也是上文提到的“动态作用域”和“词法作用域”的区别。 那么请注意!JavaScript采用的是词法作用域!所以接下来应该问foo作用域大哥,因为这条作用域链已经是在编译的时候就产生了,根据词法作用域的规则产生的,所以,眼看fn();是在全局作用域中调用执行的,但是在它的函数体里面却能访问到foo作用域,这就是它的权限,这就是闭包。

换句话讲,当你严格按照词法作用域的规则去分析上面这段代码的执行过程时,即使你不知道闭包这个名字,你也知道打印出来的是2,你也知道作用域链是怎样的,只不过我们给这种看似很神奇的东西取了个名字,叫闭包。所以才说“闭包也是基于词法作用域书写代码时所产生的自然结果”。

不要太绕,总结来讲,在当前作用域下能够访问其他作用域的东西,这就是闭包的能力。

另外,闭包会增加内存占用的问题。正常情况下,一个函数执行完之后它所占用的内存就会被垃圾回收机制回收并释放内存,但对于闭包来讲有些不同,如上foo函数执行完后,本应该被垃圾回收,但是,由于fn保持着对foo作用域的引用,所以foo并不会在执行完就被回收掉。

怎么产生闭包

当函数没有在 定义自己的词法作用域 中被调用时,就产生了闭包。

比如,上面的例子中在函数里面return一个函数,并在全局作用域中得到调用; 还有,把函数传入另一个函数里面进行调用:

function foo() { 
    console.log( a );
} 
function bar(fn) {
  var a = 2;
  fn(); // 这里产生了闭包,foo没有在定义自己的词法作用域中被调用
}
var a = 1;
bar(foo); // 1, 
复制代码

另外,我们经常用的setTimeout也能产生闭包

function wait(message) { 
    setTimeout( function timer() { 
        console.log( message ); 
    }, 1000 ); 
} 
wait( "Hello, bibao!" );
复制代码

这里,不管setTimeout内部在哪里调用的timer函数,timer函数都保持着对wait作用域的访问权限,所以能正确地打印出message;

最后,闭包可以干什么

最大的一个作用就是利用闭包制作模块化。在没有ES6的块级作用域之前,通常会利用函数作用域去做模块的封装,看下面例子:

function utilsModule(){
    var name = 'util';

    function getName(){
        // ...
        console.log(name); 
    }

    function format(){
        // ...
        console.log('format'); 
    }

    return {
        getName: getName
        format: format
    }
}

var utils = utilsModule(); // 获取模块

// 使用模块暴露的API
utils.getName(); // util
utils.format(); // format

复制代码

上述代码中,我们封装了一个工具模块utilsModule,在utilsModule函数里面我们return了一个对象,对象里的属性就是我们想要暴露给外界的API。外界通过调用utilsModule()获取到模块的引用utils,再通过utils去调用需要的API,调用API执行的时候都会产生闭包,其实闭包在我们的代码中随处可见。

封装可以避免全局污染,而且更有利于维护和扩展。

文章分类
前端
文章标签