【前端面试复习】闭包

151 阅读6分钟

在了解闭包之前,我们首先需要知道的是函数的作用域。

作用域

首先我们需要知道一个概念: 词法作用域是作用域的一种工作模型

所以从上面这个句话我们就可以得出一个结论 —— 没有作用域的概念就没有词法作用域的概念 所以我们步入正题:什么是作用域

什么是作用域

定义:作用域就是一套规则,用于确定在何处以及如何查找变量的规则。

来看两个例子:

//  例子1
function foo() {
    var a = 'EchoA';
    console.log(a);
}
foo();
// 例子2
var b = 'EchoB';

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

上面这两个例子我们可以很容易的知道: 例子1 —— 在函数作用域中找到变量a 例子2 —— 在全局作用域中找到变量b

所以其实作用域就是查找变量的地方,我们在查找b变量的时候,先在函数作用域中查找,没有找到,再去全局作用域中查找,有一个往外层查找的过程。我们好像是顺着一条链条从下往上查找变量,这条链条,我们就称之为 作用域链

作用域中变量的查找规则

我们都知道JavaScrip是有编译过程的,比如说我们定义一个变量: var str = 'hello'; 这一句话其实是有两个编译动作的:

  1. 编译器在当前作用域中声明一个变量str
  2. 运行时引擎在作用域中查找这个变量,找到了str并为其赋值'hello'

我们现在可以来证实一下我们的上面的说法:

console.log(str); // undefined
var str = 'hello';

var str = 'hello'; 的上一行输出str变量,并没有报错,输出undefined,说明输出的时候该变量已经存在了,只是没有赋值而已。

其实编译器是这样工作的:在代码执行之前从上到下的进行编译,当遇到某个用var声明的变量的时候,先检查在当前作用域下是否存在了该变量。如果存在,则忽略这个声明;如果不存在,则在当前作用域中声明该变量。

词法作用域

所谓的词法作用域就是:在你写代码时将变量和块作用域写在哪里来决定,也就是词法作用域是静态的作用域,在你书写代码时就确定了。并且是逐级包含的

  • eval()和with可以通过其特殊性用来“欺骗”词法作用域,不过正常情况下都不建议使用,会产生性能问题。
  • ES6中有了let、const就有了块级作用域,之后详解。

详解闭包

闭包的重要性不言而喻,那么到底什么是闭包呢?这个概念只要是本Javascript基础的书里面都会写到,但是他们的定义描述都有点不尽相同。

  • 闭包是指有权访问另一个函数作用域中的变量的函数;——《JavaScript高级程序设计》

  • 从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。 —— 《JavaScript权威指南》

  • 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 —— 《你不知道的JavaScript》

其实闭包是基于词法作用域书法代码时产生的自然结果,这是一种现象,闭包可能在我们平时写的代码里随处可见,知识可能我们自己都不知道这一段代码就产生了闭包

我们已经知道 —— 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

看个例子:

function f1() {
    var name = 'Echo';

    function f2() {
        console.log(name):
    }
    f2();
}
f1();

如果按照《JavaScript高级程序设计》和《JavaScript权威指南》中的定义,上面的代码已经产生了闭包,因为 f2() 访问到了 f1() 中的变量,满足了 访问另一个函数作用域中的变量的函数 这一条件,而 f2() 是个函数,满足了 所有的JavaScript函数都是闭包 。 其实这也确实是闭包,只是这种闭包不是太好观察 我们再来看一个例子:

function fn1() {
    var name = 'Echo';

    function fn2() {
        console.log(name);
    }
    return fn2;
}
var fn3 = fn1();
fn3();

这样就清晰的展示了闭包:

  1. fn2的词法作用域能访问fn1的作用域
  2. 将fn2当作一个值返回
  3. fn1执行后,将fn2的引用赋值给fn3
  4. 执行fn3,输出变量name

通过引用的关系,fn3就是fn2函数本身。执行fn3能正常输出name,这不就是fn2能记住并访问它所在的词法作用域,而且fn2函数的运行还是在当前词法作用域之外了。

正常来说,当fn1函数执行完毕之后,其作用域是会被销毁的,然后垃圾回收器会释放那段内存空间。而闭包却很神奇的将fn1的作用域存活了下来,fn2依然持有该作用域的引用,这个引用就是闭包。

所以总结一下:某个函数在定义的词法作用域之外被调用,闭包可以使该函数极限访问定义时的词法作用域

虽然上面的例子和说法比较的书面化,但是闭包绝对不是一个无用的概念,我们平时的代码里多少都有出现过闭包的身影。 一个比较经典的例子就是定时器延时打印:

for (let i = 0; i < 10; i++) {
    setTimeout(function() => {
        console.log(i);
    }, 1000);
}

上面这个例子我们期望的是输出 0~9 ,但是实际结果是最终会打印出 10次9 ,这是因为 setTimeout 中的匿名函数执行时, for 循环都已经结束了,所以最终就是输出 10次9 .

至于原因: i 是声明在全局作用域中的,定时器中的匿名函数也是执行在全局作用域中,所以每次都会输出9.

原因知道了,如何解决就知道了,我们可以让 i 在每次迭代时,都产生一个私有作用域,在这个私有的作用域中保存当前值。

for (let i = 0; i < 10; i++) {
    (function() {
        var j = i;
        setTimeout(function() {
            console.log(j);
        }, 1000);
    })();
}

看,像上面这样就达到了我们想要的结果,只是看上去不是很优雅的写法,我们可以改造一些,将每次迭代的i作为实参传递给自执行函数,自执行函数中用变量去接收:

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

闭包的应用

闭包的应用比较典型是定义模块,我们将操作函数暴露给外部,而细节隐藏在模块内部:

function module() {

    var arr = [];

    function add(val) {
        if (typeof val == 'number') {
            arr.push(val);
        }
    }

    function get(index) {
        if (index < arr.length) {
            return arr[index]
        } else {
            return null;
        }
    }
    return {
        add: add,
        get: get
    }

}
var mod1 = module();
mod1.add(1);
mod1.add(2);
mod1.add('xxx');
console.log(mod1.get(2));