JS红人闭包,你究竟是个怎样的人儿啊

157 阅读9分钟

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战

闭包,作为编程语言中的基石,面试中的常客,大家对闭包或多或少都有些自己的理解,不止每一种语言中都有着闭包,就连我们的日常生活中,也离不开闭包,小弟不才,今天我来说说我对闭包的一些浅显的感悟。

对于一直徘徊在初中级前端的我,每次遇到一个问题,可能最先想到的就是度娘了,所以当我直接在搜索框打入闭包时,出现在我眼前的是闭包的百度百科。

闭包就是能够读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。

首先第一句话就是,闭包是能够读取其他函数内部变量的函数。

假设我们用两个函数来代表两个人

function L () {
  let l = "李雷"
  console.log(a + "和" + b);
}

function H () {
  let h = "韩梅梅"
}

当李磊相见韩梅梅的时候,也就是说在函数A中李雷想能访问到函数B中的韩梅梅时,那么李雷就必须进入函数B中,也就是李雷要去韩梅梅家去拜访她。

李雷和韩梅梅.gif

这样便实现了能够读取其他函数内部变量的函数。所以才有了后面的那句,在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。

这便得出了在JAVAScript中闭包的最基本的结论。“定义在一个函数内部的函数“。

JS中的闭包

红宝书:闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

MDN:一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

看了这些权威书籍的解释,上述的李雷和韩梅梅在JS中,符合闭包的定义吗?

我们来看下面这段代码

function foo() { var a = 2;
	function bar() { 
		console.log( a ); // 2
	}
	bar(); 
}
foo();

这是闭包吗?

技术上来讲,也许是。但根据前面的定义,确切地说并不是。我认为最准确地用来解释 bar() 对 a 的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。(但却是非常重要的一部分!)

从纯学术的角度说,在上面的代码片段中,函数 bar() 具有一个涵盖 foo() 作用域的闭包 (事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为 bar() 被封闭在了 foo() 的作用域中。为什么呢?原因简单明了,因为 bar() 嵌套在 foo() 内部。

但是通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工作的。我们可以很容易地理解词法作用域,而闭包则隐藏在代码之后的神秘阴影里,并不那么容易理解。

那么怎样才能形成闭包呢?

其实我们只需要将bar()在foo()内部传递出去

function foo() {
	var a = 2;
	function bar() { 
		console.log( a );
	}
	return bar; 
}

var baz = foo();

baz(); // 2 —— 这样,闭包就展示出来了

函数 bar() 的词法作用域能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身当作一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。

在foo()执行之后,将内部函数bar()作为返回值赋值给了baz(),这样就可以通过不同的标识符,调用foo()的内部函数。bar()显然是可以正常执行的,它实现了在自己定义的词法作用域之外的地方执行。从而实现了闭包。

所以我们能得出一个简单的结论,一个闭包可能需要以下几个条件:

  • 其函数本身
  • 词法作用域
  • 执行环境

如此看来闭包并没有那么深奥,它不是什么高深的技能,反而存在于我们的代码各处,只是有时我们并没有发现到而已。

那么闭包究竟有什么作用,我们为什么要用到它,我们又在哪里有用到它呢?

正常来说,当上述代码的foo(),在执行结束之后是希望被全部销毁的,没用了就没必要留下。而闭包的神奇就在于他顽强的抵御了垃圾回收机制,让其内部作用域依旧在内存中保留。正是因为bar()存在于foo()内部,其是包含了foo()的作用域的闭包。所以只要baz()存在,那么bar()就存在,进而foo也一直存在,时刻准备着,随时被调用执行。

这样看来普通函数就是临时工,来之即用,用完就走。而闭包有没有一种公务员的赶脚,在体制内部,可以利用体制内的资源,一直存在,随叫随到,当然现在的公务员们可不如闭包,做不到随叫随到。

所以,bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

闭包经典使用场景

1. IIFE(自执行函数)

var a = 2;
(function IIFE() { 
	console.log( a );
})();

这是一个典型的闭包的例子,但是根据《你不知道的JavaScript》一书中,认为自执行函数严格来说并不是闭包,因为它并不是在其本身的词法作用域之外执行的,它是在其定义时所在的作用域中执行的。IIFE虽然不是观察闭包的恰当的例子,但是其确实创建了闭包。但是其本身并没有真正的使用闭包。

2. 循环函数

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

对于这段函数,我们想看到的是分别输出1-5,但是事与愿违,这段代码在运行时会以每秒一次的频率输出五次 6。

这是因为延迟函数的回调会在循环结束时才执行。当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。这是因为根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的, 但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。

所以我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。这时我们就可以利用起来我们上面的IIFE函数了,它会通过声明并立即执行一个函数来创建作用域。

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

但是当你试过之后你就会发现,这显然是不可以的,可是我们明明创建了更多的词法作用域了啊。

其实我们不难发现,自执行函数确实创建了更多的词法作用域,可是其只是单纯的创建了作用域,其创建的作用域是空的。所以说仅仅将它们进行封闭是不够的的。我们需要在词法作用域中创建属于每一个词法作用域中的独有的变量。

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

但是既然我们可以使用let了,我们为什么还需要IIFE呢,let本身就可以用来劫持块作用域,并且在这个块作用域中声明一个变量。本质上这是将一个块转换成一个可以被关闭的作用域。

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

这样,我们就通过let形成了5个独立的闭包。但是还有一个问题, 虽然这样是正确的,可是我们依旧只是在for循环的开始创建了一次let,为什么会出现5个块级作用域呢。

我从阮一峰ES6入门获取到了一段信息。

JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

这是块级作用域和let相关的知识,我们就先不在这里展开了。

3. 模块

模块看似与回调无关,但其实也利用到了闭包:

function foo() {
	var something = "cool"; 
	var another = [1, 2, 3];

	function doSomething() { 
		console.log( something );
	}

	function doAnother() {
		console.log( another.join( " ! " ) );
	} 
}

上面代码完全看不到闭包的影子,只有两个私有数据变量something 和 another,和两个内部函数doSomething() 和 doAnother()。它们的词法作用域是foo(),而就是foo()的词法作用域也就是其内部作用于就是闭包。

4. 惰性函数

首先我们先了解一下什么是惰性函数

  • 针对优化频繁使用的函数
  • 由于各大浏览器的差异,我们在实现一项功能的时候需要考虑不同浏览器之间的兼容性问题,因此需要进行浏览器嗅探
  • 惰性载入表示函数执行的分支只会在函数第一次掉用的时候执行,在第一次调用过程中,该函数会被覆盖为另一个按照合适方式执行的函数,这样任何对原函数的调用就不用再经过执行的分支了。

惰性函数大多数都会应用到单例模式上,比如说为了解决浏览器之间的行为差异,经常会在代码中包含了大量的 if 语句,以检查浏览器特性,解决不同浏览器的兼容问题。显然这些if语句我们只希望在一个浏览器中只执行一遍。

function addEvent(type, element, fun) {
  if (element.addEventListener) {
    element.addEventListener(type, fun, false);
  }
  else if (element.attachEvent) {
    element.attachEvent('on' + type, fun);
  }
  else {
    element['on' + type] = fun;
  }
}

但是这样我们每次执行都会进行一次判断。最佳的解决办法就是用惰性函数将结果缓存起来。

function addEvent(type, element, fun) {
  if (element.addEventListener) {
    addEvent = function (type, element, fun) {
      element.addEventListener(type, fun, false);
    }
  }
  else if (element.attachEvent) {
    addEvent = function (type, element, fun) {
      element.attachEvent('on' + type, fun);
    }
  }
  else {
    addEvent = function (type, element, fun) {
      element['on' + type] = fun;
    }
  }
  return addEvent(type, element, fun);
}

再经过第一次的执行之后,结果就会被存起来,下次执行直接调用即可。

5. 偏应用函数和科里化函数

偏应用函数:指将一些参数固定到一个函数,产生另外一个较小的函数的过程。

const partial = (f, ...args) =>(...moreArgs) =>f(...args, ...moreArgs)
const add3 = (a, b, c) => a + b + c
const fivePlus = partial(add3, 2, 3) 
fivePlus(4) // 9

柯里化是将一个多参数函数转换成多个单参数函数,也就是将一个 n 元函数转换成 n 个一元函数

const split = (regex, str) => ("" + str).split(regex);
const elements = split("v1,v2,v3", /,\s*/);
const partial =
  (f, ...args) =>
  (...moreArgs) =>
    f(...args, ...moreArgs);
const csv = partial(split, /,\s*/);
const s = csv("v1,v2,v3");
console.log(s);`

上面我们说了这么多闭包的应用,似乎闭包只有好处没有坏处,闭包这么神奇,我们是不是就可以无限制的使用闭包了呢,答案可以肯定是不可以的,没有一套程序是完美的,闭包也一样。

闭包可能出现的问题:

1. this的指向问题

在闭包中使用 this 会让代码变复杂。如果内部函数没有使用箭头函数定义,则 this 对象会在运 行时绑定到执行函数的上下文。如果在全局函数中调用,则 this 在非严格模式下等于 window,在严格模式下等于undefined。如果作为某个对象的方法调用,则this等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着 this 会指向 window,除非在严格模式下 this 是 undefined。不过,由于闭包的写法所致,这个事实有时候没有那么容易看出来。

var name = 'The Window'
var object = {
	name: 'My object',
	getNameFunc: function () {
		return function () {
	    return this.name
		}
	},
}
console.log(object.getNameFunc()()) // "The Window"

2. 内存泄漏

上面我们提到过,闭包的神奇之处在于能阻止其词法作用域的空间释放,我们前面说过闭包就像公务员一样,普通函数就像临时工一样,公务员的好处是随叫随到,同时也因为其无法随意的辞退,而导致有大量的闭包无法及时的销毁,回收,就导致其会一直占用着资源,这也就是老话讲的占着。这就会导致内存的过度占用,有可能导致内存泄漏。所以我们在使用的过程中要注意,如果遇到不再使用的闭包,就要及时清退。