闭包

150 阅读15分钟

1. 作用域

对于多数编程语言,最基本的功能就是能够存储变量当中的值、并且允许我们对这个变量的值进行访问和修改。那么有了变量之后,应该把它放在哪里、程序如何找到它们?是否需要提前约定好一套存储变量、访问变量的规则?答案是肯定的,这套规则就是作用域

说到作用域就得先说一下js是如何进行编译的

JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在传统的编译语言中,程序中的代码在执行之后会经历三个步骤:词法分析、语法分析、代码生成:

  1. 词法分析:这个阶段会将源代码拆成最小的、不可再分的词法单元(token)。比如代码 var name = 'kobe';通常会被分解成 var 、name、=、kobe、空格; 这五个词法单元。代码中的空格在 JavaScript 中是被直接忽略的。
  2. 语法分析:这个过程是将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。
  3. 代码生成:这一步就是将AST转化为可执行代码,简单来说就是将 var name = 'kobe'; 的AST转化为一组机器指令,用来创建一个 name 变量(需要给name分配内存),并将一个值储存在 name 中。

比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂的多,这里不再细说。总之,任何JavaScript代码片段在执行前都要进行编译,因此在 JS 引擎眼里,var name = 'kobe'; 语句包含了两个声明:

  • var name (编译时处理)

  • name = 'kobe' (运行时处理)

你可能会问,JS 不是不存在编译阶段的“动态语言”吗?事实上,JS 也是有编译阶段的,它和传统语言的区别在于,JS 不会早早地把编译工作做完,而是一边编译一边执行。简单来说,所有的 JS 代码片段在执行之前都会被编译,只是这个编译的过程非常短暂(可能就只有几微妙、或者更短的时间),紧接着这段代码就会被执行。

!!!在编译阶段和执行阶段阶段的过程如下:

  • 编译阶段: 编译器会找遍当前作用域,看看是不是已经有一个叫 name 的变量。如果有,那么就忽略 var name 这个声明,继续编译下去;如果没有,则在当前作用域里新增一个 name。然后,编译器会为引擎生成运行时所需要的代码,程序就进入了执行阶段。

  • 执行阶段: JS 引擎在执行代码的时候,仍然会查找当前作用域,看看是不是有一个叫 name 的变量。如果能找到,就给它赋值。如果找不到,就会从当前作用域里向上层作用域逐级查找。如果最终仍然找不到 name 变量,引擎就会抛出一个异常。

这里,JS引擎的查找过程就是作用域链,作用域指的是变量能够被访问到的范围。 在 JavaScript 中,作用域也分为好几种,ES6 之前只有全局作用域和函数作用域两种。ES6 出现之后,又新增了块级作用域,下面这来看看这几个概念。

(1)全局作用域

在编程语言中,变量一般会分为全局变量和局部变量。在 JavaScript 中,全局变量是挂载在 window 对象下的变量,所以在网页中的任何位置都可以使用并且访问到这个全局变量。下面来看一下全局作用域:

var globalName = 'Kobe Bean Bryant Cox';
function getName() { 
  console.log(globalName) // 'Kobe Bean Bryant Cox'
  var name229 = 'kobe'
  console.log(name229) // kobe
} 
getName();
console.log(name229); // name is not defined
console.log(globalName); 

可以看到,globalName 变量在任何地方都是可以被访问到的,所以它就是全局变量。而在 getName 函数中作为局部变量的 name 变量是不具备这种能力的。

在 JavaScript 中,所有没有经过定义而直接被赋值的变量默认就是一个全局变量,比如下面代码中 setName 函数里面的 curName:

function setName(){ 
  curName = 'kobe';
}
setName();
console.log(curName); // kobe
console.log(window.curName) // kobe

全局变量是拥有全局的作用域,无论在何处都可以使用它,在浏览器控制台输入 window.curName 时,就可以访问到 window 上的全局变量。当然全局作用域有相应的缺点,当定义很多全局变量时,可能会引起变量命名的冲突,所以在定义变量时应该注意作用域的问题。

!!!注意: 没有定义的变量,是没有变量提升的 function foo () { console.log(s) // Uncaught ReferenceError: s is not defined s=24 } foo()

(2)函数作用域

在 JavaScript 中,函数中定义的变量叫作函数变量,这种变量只能在函数内部才能访问到,所以它的作用域也就是函数的内部,称为函数作用域:

function getName () {
  var name = 'kobe';
  console.log(name); // kobe
}
getName();
console.log(name); // name is not defined

可以看到,name 变量是在 getName 函数中进行定义的,所以 name 是一个局部的变量,它的作用域就在 getName 函数里,也称作函数作用域。

除了这个函数内部,其他地方都是不能访问到它的。同时,当这个函数被执行完之后,这个局部变量也相应会被销毁。所以会看到在 getName 函数外面的 name 是访问不到的。

(3)块级作用域

ES6 中新增了块级作用域,最直接的表现就是新增的 let 和 const 关键词,使用这两个关键词定义的变量只能在块级作用域中被访问,有“暂时性死区”的特点,也就是说这个变量在定义之前是不能被使用的。

说到暂时性死区,还要从“变量提升”说起,来看下面代码:

function foo() { 
  console.log(bar) // undefined
  var bar = 3 
} 
foo()

上面代码会输出:undefined,原因是变量bar在函数内进行了提升。相当于:

function foo() { 
  var bar 
  console.log(bar) 
  bar = 3 
} 
foo()

但在使用 let声明时,会报错:

function foo() { 
  console.log(bar) 
  let bar = 3 
} 
foo() // Uncaught ReferenceError: bar is not defined。

使用 let 或 const 声明变量,会针对这个变量形成一个封闭的块级作用域(es5之前用var声明的变量在变量环境中,let,const声明的变量在词法环境中,这是一个新的环境,两者彼此互不干扰),在这个块级作用域当中,如果在声明变量前访问该变量,就会报 referenceError 错误;如果在声明变量后访问,则可以正常获取变量值:

function foo() { 
  let bar = 3 
  console.log(bar) 
} 
foo()

这段代码正常输出 3。因此在相应花括号形成的作用域中,存在一个“死区”,起始于函数开头,终止于相关变量声明的一行。在这个范围内无法访问 let 或 const 声明的变量。

说完暂时性死区,下面来看看块级作用域。在 JavaScript 编码过程中, if 语句及 for 语句后面 {} 这里面所包括的就是块级作用域:

console.log(num) // num is not defined
if(true){
  let num = '24'console.log(num); // 24
}
console.log(num) // num is not defined

可以看到,变量 num 是在 if 语句{} 中由 let 关键词进行定义的变量,所以它的作用域是 if 语句括号中的那部分,而在外面进行访问 num变量是会报错的,因为这里不是它的作用域。所以在 if 代码块的前后输出 num 这个变量的结果,控制台会显示 num 并没有定义。

2. 闭包

(1)闭包基本概念

通俗来讲,闭包其实就是一个可以访问其他函数内部变量的 函数 。内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包。

通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中:

function fun() {
 var num = 8;
   return function(){
     console.log(num);
   };
}
var res = fun();
res();  // 8

这段代码在控制台中输出的结果是 8(即 num 的值)。可以发现,num 变量作为一个 fun 函数的内部变量,正常情况下作为函数内的局部变量,是无法被外部访问到的。但是通过闭包,最后可以拿到 num 变量的值。

从直观上来看,闭包这个概念为 JavaScript 中访问函数内变量提供了途径和便利。这样做的好处很多,比如,可以利用闭包实现缓存等。

(2)闭包产生原因

上面说了作用域的概念,我们还需要知道作用域链的基本概念。当访问一个变量时,代码解释器会首先在当前的作用域查找,如果没找到,就去父级作用域去查找,直到找到该变量或者不存在父级作用域中,这样的链路就是作用域链。

需要注意,每一个子函数都会拷贝上级的作用域,形成一个作用域链:

var a = 1;
function fun1() {
  var a = 2
  function fun2() {
    var a = 3;
      console.log(a);//3
		}
	}
}

可以看到,fun1 函数的作用域指向全局作用域(window)和它自己本身;fun2 函数的作用域指向全局作用域 (window)、fun1 和它本身;而作用域是从最底层向上找,直到找到全局作用域 window 为止,如果全局还没有的话就会报错。这就很形象地说明了什么是作用域链,即当前函数一般都会存在上层函数的作用域的引用,那么他们就形成了一条作用域链。

由此可见,闭包产生的本质就是:当前环境中存在指向父级作用域的引用

function fun1() {
  var num = 24
  function fun2() {
    console.log(num);  // 24
  }
  return fun2;
}
var result = fun1();
result();

可以看到,这里 result 会拿到父级作用域中的变量,输出 2。因为在当前环境中,含有对 fun2 函数的引用,fun2 函数恰恰引用了 window、fun1 和 fun2 的作用域。因此 fun2 函数是可以访问到 fun1 函数的作用域的变量。

那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,只需要让父级作用域的引用存在即可,因此可以这样修改上面的代码:

var fun3;
function fun1() {
  var a = 2
  fun3 = function() { // 此处通过window变量来修改了全局fun3的值为一个函数,这样外面就可以直接访问fun3这个函数了
    console.log(a);
  }
}
fun1();
fun3();

可以看到,其中实现的结果和前一段代码的效果其实是一样的,就是在给 fun3 函数赋值后,fun3 函数就拥有了 window、fun1 和 fun3 本身这几个作用域的访问权限;然后还是从下往上查找,直到找到 fun1 的作用域中存在 a 这个变量;因此输出的结果还是 2,最后产生了闭包,形式变了,本质没有改变。

(3)闭包应用场景

下面来看看闭包的表现形式及应用场景:

  • 在定时器、事件监听、Ajax 请求、Web Workers 或者任何异步中
// 定时器
setTimeout(function handler(){
  console.log('24');
},1000);
// 事件监听
document.getElementById(app).addEventListener('click', () => {
  console.log('kobe is the best');
});
  • 作为函数参数传递的形式:
var num = 8;
function foo(){
  var num = 24;
  function baz(){
    console.log(num);
  }
  bar(baz);
}
function bar(fn){
  // 这是闭包
  fn();
}
foo();  // 输出24,而不是8,因为这取决于定义的时候,而不是执行的时候
  • IIFE(立即执行函数) ,创建了闭包,保存了全局作用域(window)和当前函数的作用域,因此可以输出全局的变量:
var num = 24;
(function IIFE(){
  console.log(num);  // 输出24
})();

IIFE 是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。

  • 结果缓存(备忘模式)

备忘模式就是应用闭包的特点的一个典型应用。比如下面函数:

function add(a) {
    return a + 1;
}

当多次执行 add() 时,每次得到的结果都是重新计算得到的,如果是开销很大的计算操作的话就比较消耗性能了,这里可以对已经计算过的输入做一个缓存。所以这里可以利用闭包的特点来实现一个简单的缓存,在函数内部用一个对象存储输入的参数,如果下次再输入相同的参数,那就比较一下对象的属性,如果有缓存,就直接把值从这个对象里面取出来。实现代码如下:

使用 ES6 的方式实现:

function memorize(fn) {
    const hash = {}
    return function (...args) {
        const key = JSON.stringify(args)
        const info = hash[key] || (hash[key] = fn.call(fn, args))
        console.log('hash-----', hash)
        return info
    }
}

function add(num) {
    return Number(num) + 24
}

const handleAdd = memorize(add)

handleAdd(23)            // 输出: 47    当前: hash: {[23]: 47}
handleAdd(23)            // 输出: 47    当前: hash: {[23]: 47}
handleAdd(8)            // 输出: 32    当前: hash: {[23]: 47, [8]: 32}


备忘函数中用 JSON.stringify 把传给 handleAdd 函数的参数序列化成字符串,把它当做 hash 的索引,将 add 函数运行的结果当做索引的值传递给 hash,这样 handleAdd 运行的时候如果传递的参数之前传递过,那么就返回缓存好的计算结果,不用再计算了,如果传递的参数没计算过,则计算并缓存 fn.call(fn, args),再返回计算的结果。

心得感悟:

谈到缓存,我们像我们最开始都用全局变量来缓存数据,但是容易污染全局变量,后面我们把变量放到函数内,但是每次函数执行的时候都会初始化,所以我们就在这个函数内部返回一个函数,这样内层函数可以访问到外层函数内的变量,外层函数执行一遍声明了变量,后面内层函数多次执行,就可以缓存或者修改这个变量了。实际上相当于是把原来放在全局作用域下的变量,套了一层函数的壳子,其实都是大同小异的

(4)循环输出问题

最后来看一个常见的和闭包相关的循环输出问题,代码如下:

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

这段代码输出的结果是 5 个 6,那为什么都是 6 呢?如何才能输出 1、2、3、4、5 呢?

可以结合以下两点来思考第一个问题:

  • setTimeout 为宏任务,由于 JS 中单线程 eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行。

  • 因为 setTimeout 函数也是一种闭包,往上找它的父级作用域链就是 window,变量 i 为 window 上的全局变量,开始执行 setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。

那如何按顺序依次输出 1、2、3、4、5 呢?


1)利用 IIFE

可以利用 IIFE(立即执行函数),当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行,改造之后的代码如下。

for (var i = 1; i<=5; i++) {
    (function (num) {
        setTimeout(function kobe () {
            console.log(num)
        })
    })(i)
}

可以看到,通过这样改造使用 IIFE(立即执行函数),可以实现序号的依次输出。利用立即执行函数的入参来缓存每一个循环中的 i 值。


2)使用 ES6 中的 let

ES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行。

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

可以看到,通过 let 定义变量的方式,重新定义 i 变量,则可以用最少的改动成本,解决该问题。

3)定时器第三个参数

setTimeout 作为经常使用的定时器,它是存在第三个参数的。我们经常使用前两个,一个是回调函数,另外一个是定时时间,setTimeout 从第三个入参位置开始往后,是可以传入无数个参数的。这些参数会作为回调函数的附加参数存在。那么结合第三个参数,调整完之后的代码如下:

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

可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现想要的结果,这也是一种解决循环输出问题的途径。

总结:

  1. 闭包其实就是一个可以访问其他函数内部变量的函数。内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包。
  2. 作用域链是在代码编译的时候形成的,不是执行的时候形成的。
  3. 闭包产生的本质就是:当前函数存在指向父级作用域的引用
  4. 变量缓存其实就是把在全局声明的变量通过闭包的形式在整体上套了一层函数的壳子,保证了变量不被污染,并实现了缓存
  5. 内层的函数能用外层函数的变量,并且可以起到缓存效果,甚至是变更变量的原因是外层函数只执行了一遍,一直是内层函数在不断的调用,如果外层函数再次调用,就没有办法得到缓存的效果了