『前端面试100问』之说说 JavaScript 中的闭包以及闭包的应用场景

239 阅读7分钟

闭包是什么

闭包(Closures)是前端面试中最最常见的面试题之一,就我本人的面试经历,在面试中几乎每次面试都会问到,像腾讯、作业帮、网易、阿里等大厂面试都问到了,闭包涉及的知识点非常广泛,JavaScript 内存管理、内存泄露、实现私有变量、高阶函数,都是可以从闭包引申出来的知识点,所以作为一名前端,理解并会使用闭包及其重要。

我们首先来看一下,MDN 是如何定义闭包的:闭包是绑定在一起(封闭)的函数与其词法作用域的引用的组合。换句话说,闭包允许内部函数访问外部函数的作用域。在JavaScript中,每次创建函数时,都会在函数创建时创建闭包。

词法作用域

什么是词法作用域?词法作用域指的是,某个变量、函数、对象等是否可以访问,是根据源代码中的它们声明的物理位置来决定的。例如:

let a = 'global';
  function outer() {
    let b = 'outer';
    function inner() {
      let c = 'inner'
      console.log(c);   // prints 'inner'
      console.log(b);   // prints 'outer'
      console.log(a);   // prints 'global'
    }
    console.log(a);     // prints 'global'
    console.log(b);     // prints 'outer'
    inner();
  }
outer();
console.log(a);         // prints 'global'

上述代码,inner 函数可以访问它自己的作用域、outer 函数作用域,以及全局作用域。outer 函数只能访问它自己的作用域和全局作用域。

闭包如何工作

理解闭包如何工作,需要理解两个概念:

  • 执行上下文
  • 词法环境

执行上下文

执行上下文是一个抽象的环境,JavaScript 代码在这里得到运行。当全局作用域的代码执行时,这部分代码放在全局执行上下文中进行执行。当某个函数执行时,这个函数的代码放在函数自己的执行上下文中进行执行。

任意时刻只能有一个正在运行的执行上下文(因为 JavaScript 是单线程语言),这个上下文由称为执行堆栈或调用堆栈的栈结构进行管理。

执行堆栈是一个具有后进先出(LIFO)结构的栈结构,其中的元素只能从堆栈顶部添加或移除。

正在运行的执行上下文将始终位于堆栈的顶部,当当前运行的函数完成时,其执行上下文将从堆栈中弹出,下一个被执行的是位于它下面的执行上下文。

让我们看一段代码片段,以便更好地理解执行上下文和堆栈:

执行此代码时,JavaScript 引擎创建一个全局执行上下文来执行全局代码,当它遇到对 first() 函数的调用时,它为该函数创建一个新的执行上下文并将其推送到执行堆栈的顶部。

上面代码的执行堆栈如下所示:

first() 函数完成时,它的执行堆栈将从堆栈中移除,指针将指向它下面的执行上下文,即全局执行上下文。所以全局范围内的剩余代码将被执行。

词法环境

每当JavaScript引擎创建一个执行上下文来执行函数或全局代码时,它也会创建一个新的词法环境来存储在该函数执行期间定义的变量。

词法环境是保存标识符变量映射的数据结构。(这里的标识符是指变量/函数的名称,变量是指实际对象[包括函数类型对象]或原语值)。

词汇环境有两个组成部分:

  • 1.环境记录

    存储变量和函数声明的实际位置

  • 2.对外部环境的引用

    对外部环境的引用意味着它可以访问其外部(父)词汇环境。要想理解闭包是如何工作的,就需要理解对外部环境的引用。

词汇环境在概念上如下所示:

lexicalEnvironment = {
  environmentRecord: {
    <identifier> : <value>,
    <identifier> : <value>
  }
  outer: < Reference to the parent lexical environment>
}

所以,让我们来看看这段代码:

let a = 'Hello World!';
function first() {
  let b = 25;  
  console.log('Inside first function');
}
first();
console.log('Inside global execution context');

当 JavaScript 引擎创建一个全局执行上下文来执行全局代码时,它还创建一个新的词法环境来存储在全局范围内定义的变量和函数。因此全局范围的词汇环境如下所示:

globalLexicalEnvironment = {
  environmentRecord: {
      a: 'Hello World!',
      first: < reference to function object >
  }
  outer: null
}

这里外部词法环境设置为空,因为全局范围没有外部词法环境。 当引擎为 first() 函数创建执行上下文时,它还会创建一个词法环境来存储在函数执行期间在该函数中定义的变量。因此函数的词汇环境如下所示:

functionLexicalEnvironment = {
  environmentRecord: {
      b: 25,
  }
  outer: <globalLexicalEnvironment>
}

函数的外部词法环境设置为全局词法环境,因为该函数在源代码中被全局范围包围。

注意:当函数完成时,其执行上下文将从堆栈中移除,但其词法环境可能会从内存中移除,也可能不会从内存中移除,具体取决于该词法环境是否被其外部词法环境属性中的任何其他词法环境引用。

闭包的内存模型

闭包的产生有两个步骤:

  1. 预扫描内部函数
  2. 内部函数引用的外部变量,保存到
function foo() {
    let name = "tom";
    let test1 = 1;
    let test2 = 2;
    
    let innerBar = {
        setName: function(newName) {
            name = newName;
        },
        getName: function() {
            console.log(test1);
            return name;
        }
    };
    
    return innerBar;
}

let bar = foo();
bar.setName("Jerry");
bar.getName();

JavaScript 引擎是如何执行上述代码的呢?

  • 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文
  • 编译时,发现 setName 引用了 fooname 变量,由于内部函数引用了外部函数变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建了一个 closure(foo) 对象,以保存 name 变量。
  • 接着扫描到 getName 方法时,发现该函数内部还引用了变量 test1,所以又将 test1 变量添加到了 closure(foo) 对象中,此时,堆中的 closure(foo) 对象包含了两个变量。
  • 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。

所以,当执行到 foo 函数时,闭包就产生了;当 foo 函数执行结束之后,返回的 getNamesetName 都引用了 closure(foo) 对象,所以即时 foo 退出,closure(foo) 依然被其内部的 getNamesetName 方法引用。所以在下次调用 bar.setNamebar.getName 时,创建的执行上下文就包含了 closure(foo)

闭包应用

1. 定义私有变量

a = (function () {
    var privatefunction = function () {
        alert('hello');
    }

    return {
        publicfunction : function () {
            privatefunction();
        }
    }
})();

2. 高阶函数(柯里化)

function add(){
    let _args = [...arguments];
    function sum(){
        _args = _args.concat([...arguments]);
 
        return sum;
    }
 
    sum.toString = function(){
        return _args.reduce((a, b) => {
            a += b;
            return a;
        }, 0);
    }
 
    return sum;
}
 
console.log(add(1)(2)) // 3
console.log(add(1,2,3)(4,5)(6)) // 21

3. 模拟面向对象编程

function counter() {
    var a = 0;
    return {
        inc: function() { ++a; },
        dec: function() { --a; },
        get: function() { return a; },
        reset: function() { a = 0; }
    }
}

4. 避免全局污染

大量使用全局变量会耗费较高的性能,将变量定义在外层函数中作为内部数据,通过闭包将变量返回给全局变量,降低全局污染概率

function outer() {
	var $ = function() {};
	var attr = "hello, world";
	return {
		$: $,
		getAttr: function() {
			return attr;
		}
	}
}

var params = outer();
params.$();
console.log(params.getAttr);

5.缓存数据

最常见的就是用闭包改进斐波那契数列的递归版实现

闭包应用传送门

6.回调函数传参

比如用闭包改进下列代码,实现依次打印0, 1, 2, 3, 4

改进前:

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

输出:
55555

改进后:

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

输出:
01234

参考资料


❤️❤️❤️

️最后我想说,如果这篇文章对你有帮助,那就请你帮我三个小忙:

1.点个👍🏻「赞」,表示你对这篇文章的认可❤️❤️❤️

2.关注我,定期为你推送精选好文,上下班路上看一看,有用的知识不知不觉就增加了😊

3.点赞 转发 关注是最大的支持❤️

欢迎关注公众号,入群交流