JavaScript中的闭包-终结篇

252 阅读7分钟

闭包,一个似曾相识的家伙,总是在面试中被问到。今天,就让我们来好好的总结一下闭包吧。

一、什么是闭包

在JavaScript中,根据词法作用域的规则,内部函数总是可以访问其外部函数声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,就把这些变量的集合称为闭包

这段话有点绕。我的理解是,闭包就是能够读取其它函数内部变量的函数,它是连接函数内部和函数外部的一座桥梁,它同时含有对函数对象以及作用域对象引用的对象

闭包的实现也很简单,在一个函数中嵌套另一个函数或者将一个匿名函数作为值传入另一个函数中。

// 函数student1中嵌套了say,say作为参数返回,外部调用时仍能打印name,构成闭包
function student1() {
  const name = 10;
  function say() {
    console.log(name);
  }
  return say;
}

function student2() {
  const name = 20;
  // 定时器中的为一个匿名函数,其作为参数传入了,函数student2执行完毕之后,1s钟后才会执行定时器函数,但此时还能打印name,构成闭包
  setTimeout(function () {
    console.log(name);
  }, 1000);
}

二、JavaScript的作用域

在JavaScript中,变量的作用域有两种:全局变量和局部变量

对于[全局变量],它的生存周期是永久的的,除非主动销毁变量;而对于[函数局部变量] ,当函数执行完毕,局部变量也就销毁了。

函数体是可以父作用域的变量的:

var name = "Tom";

function say() {
    console.log(name);
}

say(); // Tom

但是,我们在函数外部是无法直接获取函数内的局部变量:

function say() {
    var name = "Tom";
}

console.log(say()); // undefined

在JavaScript中,作用域链查找标识符的顺序是从当前作用域开始一级一级往上查找。因此,通过作用域链,JavaScript函数内部可以读取函数外部的变,但反过来,函数的外部通常则无法读取函数内部的变量。

三、如何从外部读取局部变量呢?

很多情况,我们是需要获取函数内部的局部变量的,但是直接获取又获取不到。我们试着在函数中再定义一个函数:

function Student() {
  let name = "Tom";

  function Say() {
    console.log(name);
  }
}

上面的代码,在函数Say中可以获取函数Student内部的所有的局部变量,反之却不行,函数Say内部的局部变量对函数Student是不可见的。

这就是JavaScript的链式作用域,当获取某个变量时,它会向上一层一层的找,所有父层级的变量对子层级都是可见的。

既然子层级的函数可以获取父层级的局部变量,那么我们通过返回子层级的函数不就可以获取父层级的局部变量的了么。

function Student() {
  let name = "Tom";

  function Say() {
    console.log(name);
  }

  return Say;
}

let result = Student();
result(); // Tom

3.1 为什么要函数套函数呢?

你可能会说,要获取name值,直接reutrn name不就可以了么?

function Student() {
    let name = "Tom";  
    return name;
  }
  
let result = Student();
console.log(result); // Tom

从单一目的来说,确实是可行的,但是不要忘记了闭包的另一个特性————隐藏变量。我们是需要创造一个局部变量,如果直接返回一个name,等同于创建一个全局变量的name,就达不到效果。

3.2 为什么要return

因为如果不return,你就无法使用这个闭包。把return Say 改成 window.Say = Say 也是一样的,只要让外面可以访问到这个 Say 函数就行了。

所以 return Say 只是为了 Say 能被使用,也跟闭包无关。

四、闭包的优缺点

闭包的特点有以下三点:

  • 1、函数嵌套函数

  • 2、函数内部可以引用外部的参数和变量

  • 3、参数和变量不会被垃圾回收机制回收

闭包的优点:

  • 可以重复使用变量,并且不会造成变量污染

  • 可以用来定义私有属性和私有方法

闭包的缺点:

  • 会产生不销毁的上下文,导致栈/堆内存消耗过大

  • 会造成内存泄露

4.1思考一个问题:

什么情况下闭包会被回收?

  • 1、如果闭包引入的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄露。

  • 2、如果引用闭包的函数是一个局部变量,等函数销毁后,在下次JavaScript引擎执行垃圾回收时,判断闭包内容已经不再被使用,则js引擎的垃圾回收器就会进行回收。

五、闭包的用途

闭包可以用在许多地方。它的最大用处有两个:

  • 1、可以读取函数内部的变量

  • 2、可以变量的值始终保持在内存中,也可以创建私有变量。

读取函数内部的变量,换句话说就是隐藏一个变量。把局部变量隐藏,创建一个私有变量,对外暴露一个处理的方法。这样就能够避免局部变量随意的被修改。

如何理解闭包可以让变量保持在内存中呢?来看下面的代码:

function counter() {
  let initialValue = 0;
  
  // 这是一个全局函数
  add = function () {
    initialValue += 1;
  };

  function show() {
    console.log(initialValue);
  }

  return show;
}

let Counter = counter();

Counter(); // 0

add();

Counter(); // 1
Counter(); // 1

add();

Counter(); // 2

由此可知,initialValue是驻留在内存的,每次执行add函数都会对initialValue加1。counter运行结束后initialValue并没有被回收,那,这是为什么呢?

这是因为counter是show的父函数,而show函数被赋值给了全局变量,即Counter。这就导致了show函数始终在内存中,然而show依赖counter,因此counter也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。

JavaScript通过标记清理引用计数这两种标记策略,JavaScript 最常用的垃圾回收策略是标记清理。

当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。

所以,show及其上下文都会存留在内存中。

五、闭包的注意事项

由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。

闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象使用,把闭包当作它的公用方法,把内部变量当作它的私有属性,这时一定要小心,不要随便改变父函数内部变量的值。

六、经典的闭包问题

下面的代码输出什么?

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

上面的代码预期输出:1、2、3、4。但结果却是四个5。 原因是:多个子函数的[[scope]]都是同时指向父级,是完全共享的。因此当父级的变量对象被修改时,所有子函数都受到影响。

解决方法1

变量可以通过 函数参数的形式 传入,避免使用默认的[[scope]]向上查找。

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

解决方法2

使用setTimeout包裹,通过第三个参数传入。(注:setTimeout后面可以有多个参数,从第三个参数开始其就作为回掉函数的附加参数)

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

解决方法3

使用 块级作用域,让变量成为自己上下文的属性,避免共享。

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