闭包
前言
网络中有各种各样说闭包的文章,有些浅尝辄止,有些 CV 党,又有些只讲一部分。对我而言,要想了解闭包,需要掌握 词法环境(或者是ES中的变量对象) 、执行上下文与调用栈、 (词法)作用域 等等
好在我们在之前的文章中已经梳理了这几块内容:词法作用域、词法环境、执行上下文 。接下来让我们解开闭包的面纱,看看这位克利奥帕特拉七世
一句话解释
闭包就是一个绑定了执行环境的函数,它利用了词法作用域的特性,在函数嵌套时,内层函数引用外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包
闭包的定义
各路大神对闭包的定义
winter:
闭包其实只是一个绑定了执行环境的函数
闭包与普通函数的区别是,它携带了执行环境,就像人在外星中需要自带吸氧的装备一样,这个函数也带有在程序中生存的环境
实际上 JavaScript 中跟闭包对应的概念就是“函数”
候策
函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包
黑客与画家
闭包(lexical closure) 一个函数,通过它可以引用由包含这个函数的代码所定义的变量
闭包是指那些能够访问自由变量的函数
那什么是自由变量呢?
自由变量是指在函数中使用的,但既不是函数参数也不是函数局部变量的变量
由此,我们可以看出闭包共又两部分组成
闭包 = 函数 + 函数能够访问的自由变量
闭包是指一个函数可以记住其外部变量并可以访问这些变量
《你不知道的 JavaScript》
闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境
李兵《浏览器工作原理与实践》
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包
闭包形成的原理
就像开篇讲的,要想理解闭包,就先要了解三个知识点:词法作用域、词法环境、执行上下文与调用栈 。我们快速梳理下:
词法作用域:这里我们说的都是函数作用域,它由函数被声明时所处的位置决定。函数作用域有个特点,函数内的变量函数外不能访问,函数外的变量函数内能访问
词法环境:在代码编译阶段记录变量声明、函数声明。函数声明形参的合集
执行上下文:调用函数时所带的所有信息。包括词法环境、变量环境、this
我们讲三者一结合,就有了:
- 一段代码在执行时分为两个阶段,编译阶段和执行阶段(JavaScript 是解释型语言,会逐行执行程序,但还是会有两个阶段,两者相差几微秒)
- 编译阶段会发生「变量提升」,确定作用域,生成词法环境,词法环境包括环境记录器和 outer,环境记录器记录自由变量,outer 指向父作用域
- 词法环境的环境记录器收集 var、function 等变量
- 变量环境的环境记录器收集 let、const、class 等变量
- 执行阶段会创建执行上下文,它包括词法环境、变量环境和 this,以及确定作用域链
也就是说执行上下文除了 this,像词法环境、变量环境在编译阶段就已经确定了,其中词法环境的变量 var、function 会进行变量提升、函数提升,而变量环境中的变量不会提升;而两者的 outer 则相同,它们都指向父作用域
当代码要访问一个变量时,首先会搜索自身的作用域(即内部词法环境)是否有此变量,再沿着 outer,去父作用域(外部环境),然后搜索更外部的环境,以此类推,直到全局词法环境,这被关系被称为作用域链,是在函数调用时被确认的
我们用一例子来解释闭包:
function foo() {
var a = 1;
var b = 2;
return function bar() {
console.log(a++);
};
}
var baz = foo();
baz();
- 在任何代码执行之前,先创建全局执行上下文,并往调用栈中压栈
- 接着创建词法环境,登记函数声明 foo 和变量声明 baz(此时处于编译阶段)
- 由于全局词法环境没有外部引用,所以箭头指向了 null
- 代码开始执行,执行
foo()
,创建foo()
的函数执行上下文,并往调用栈中压栈 - 在开始执行 foo 函数内代码前,即函数内的编译阶段,创建 foo 的词法环境,登记函数声明 bar 和变量声明 a、b。它的 outer 指向父作用域——全局作用域
- 代码执行至
function bar
时,创建 bar 的词法环境,它没有变量,outer 指向父作用域 foo- 所有函数在“诞生”时都会记住创建它们的词法环境
- 所有函数都有个
[[Environment]]
的隐藏属性,该属性保存了对创建该函数的词法环境的引用 - 我们说过作用域与它创建于哪里相关,与在哪儿调用无关
- 调用完函数
foo()
,弹出调用栈,foo 中的函数 bar、变量 b 随着 foo 出栈而被释放 - 由于函数 bar 的结果赋值给了全局变量 baz,baz 相当于多个了隐藏属性
[[Environment]]
,它指向父作用域 foo,而 bar 又引用了 foo 作用域下的变量 a,所以变量 a 无法被释放- 因此,
baz.[[Environment]]
有对{a: 0}
词法环境的引用 [[Environment]]
引用在函数创建时被设置并永久保存
- 因此,
- 调用函数
baz()
,创建baz()
执行上下文,并将其压入调用栈中 - 并在执行代码前(编译阶段),创建一个新的词法环境,并且它的 outer 指向
baz.[[Environment]]
,即父作用域 foo - 当它查找变量 a 时,先在自己的词法环境中找,找不到,沿着 outer 往它的父作用域找,在 foo 词法环境中找到了变量 a,并在变量所在的词法环境中更新变量
如此,调用完 baz,因为 baz 一直存在全局词法环境中,它的隐藏属性[[Environment]]
一直引用着 foo 函数中的 a 变量(即使 foo 函数已经被销毁了)
当再次调用 baz 时,就会再往调用栈中压入baz()
,并生成一个新的 bar 的词法环境,它的 outer 还是引用 baz.[[Environment]]
,即上图中的 foo 词法环境
不知道解释清楚闭包了没有?
简单来说:闭包是自带变量的函数。首先先嵌套函数,内层函数引用外层函数的变量。因为词法作用域的性质,在函数定义时就确定了作用域、词法环境,所以即使在调用完外层函数,外层函数中的变量也不会被垃圾回收,因为内层函数已经赋值给了全局变量,因为变量存在,所以外层函数的变量不会被释放
其内在逻辑是:内层函数赋值给全局变量,内层函数又引用外层函数变量,所以外层函数变量就不会被释放
简易理解:闭包 = 自由变量 + 函数
闭包的本质是函数在执行时,会被放到执行栈上去执行,执行结束后被移除,但是堆上作用域成员由于外部的引用而不能被释放。因此内部函数依然可以访问外部函数的成员。
function checkAge(min){
return function(age){ // 函数作为返回
return age > min; // 闭包,引用外部参数
}
}
// 若果用es6的语法会更简洁
const checkAge = min => age => age > min
const checkAge18 = checkAge(18)
checkAge18(lucy.age)
闭包的作用
试想一下,闭包解决了什么问题?
如果没有闭包,要你实现一个应用,写很多页面,同时引入一些库和框架,自己又写了一些工具函数,当它们在一个(全局)作用域下,就会发生变量冲突问题(虽然可以通过命名规范等解决,但实际开发中难免会遇到命名相同的问题)
假设你定义了一个变量 a,一个函数 b,直接写在全局环境下,这个模块实现了功能 A。现在我们的程序需要开发功能 B,你也想用变量 a,函数 b 标识符来表示,那就尴尬了,因为已经在功能 A 上使用过,不能再使用,而如果不用变量 a 和函数 b 标识符来表示语义化上又不恰当
你也许想到了 c、d 两个名字,但是当你调试时,发现原来这两个标识符也已经被其他的工具函数使用过
命名冲突的原因是因为同作用域下已存在相同的变量名,要解决以上问题,就要从作用域上着手
即——一个模块应该有自己的作用域,来保证模块的正常运行
全局作用域肯定不行,我们只用函数作用域可以实现这一功能。所以闭包其实是利用了函数作用域实现的一种变量保护机制
它的作用是对模块中变量的保护。即在函数作用域中写好代码后,将要使用的变量 return 语句暴露到外界
function outer() {
var a = '私有变量,只能在 outer 中使用';
function inner() {
console.log('我是 outer 中的私有函数,只能在 outer 中使用');
}
return function closureOuter() {
inner();
console.log(a);
}
}
var bar = outer();
bar();
// 我是 outer 中的私有函数,只能在 outer 中使用
// 私有变量,只能在 outer 中使用
我们以这个 demo 为例,来说一说整个过程。因为作用域特性,外层函数不能访问内层函数的变量。所以函数 outer
外不能使用 outer
内的变量 a
和函数 inner
。但是如果我们调用函数 outer
时赋值给 bar
,返回的是函数 outer
内的函数 closureOuter
,此时的 bar
指向函数 closureOuter
,函数 closureOuter
因为词法环境的原因,能访问变量 a
和函数 inner
。当调用 bar
时,执行函数 clousureOuter
,执行 inner
和打印变量 a
我们也可以这样理解,outer
就是一个模块,它暴露 closureOuter
给外界,外界调用 outer
模块,能使用 outer
的变量,但是不能对内部的变量做修改(保护变量)
所以说闭包的作用是:闭包能创建函数的私有变量,且这个变量不会随着函数执行完就被垃圾回收机制回收。而这能对某些场景下的变量起到保护作用
闭包的优缺点及误区
- 优点
- 保护私有变量
- 避免全局变量污染
- 让这些变量始终存在内存中(是优点)
- 缺点
- 一直存在内存中(也是缺点)
网上说闭包会造成内存泄露?
这话不对,内存泄露是指你用不到的变量,任然占用内存空间。但是闭包里的变量就是我们需要的变量,怎么能说是内存泄露呢
闭包的应用
闭包的应用场景有两处,一是作为返回值,二是作为参数传递。有没有很熟悉,这不是就我们在 Function 中讲函数是第一公民的理由吗?
函数的特性让其能作为返回值,以及作为参数传递。所以说所有的函数天生就是闭包(只有一个例外,即 new Function 语法,它的 [[Environment]]
并不指向当前的词法环境,而是指向全局环境)
作为返回值
和例题很相似,也是我们平时见过最多的闭包形式,外层函数返回内层函数
function foo() {
var a = 1;
return function bar() {
console.log(a)
}
}
var baz = foo();
baz(); // 1
作为参数传递
function foo() {
var a = 1;
function bar() {
console.log(a)
}
baz(bar)
}
function baz(fn) {
fn()
}
baz(foo) // 1
像我们平时开发中,无意中会用到各种闭包
私有实例变量
function Person(name, age, like) {
return {
toString() {
return `${name} ${age} ${like}`
}
}
}
const johnny = new Person('johnny', 28, 'sayhi')
console.log(johnny.toString())
toString
形成了闭包
函数式编程
function add(a) {
return function (b) {
return a + b
}
}
add(2)(3) // 5
面向事件编程
定时器、事件监听、Ajax 请求、跨窗口通信、Web Workers 或者任何异步,只要使用了回调函数,实际上就是在使用闭包
// 定时器
function wait(message) {
setTimeout( function timer(){
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );
// message 是 wait 函数的变量,但是被 timer 函数引用,就形成了闭包
// 事件监听
let a = 1;
$("#btn").on("click", function callback(){
console.log(a);
});
// 变量 a 被 callback 函数引用,形成闭包
// AJAX
let a = 1;
fetch("/api").then(function callback() {
console.log(a)
})
// 同事件监听
只要是回调函数,它引用了自由变量,那就形成了闭包
模块化
用闭包模拟私有方法
var Counter = (function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
})();
console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */
例子来源:MDN
React 中的使用
我们使用的 hook 就是用到了闭包,而....
https://blog.azhubaby.com/2022/03/01/2022-03-01-函数式组件与类组件有何不同/
总结
本文介绍了闭包的形成过程,闭包的作用,以及闭包的使用场景
闭包和对象
闭包能实现对私有属性的保护
对象也能实现对私有变量的保护,后来Class类更有其 private 属性
https://github.com/ikcamp/Functional-Light-JS/blob/zh-cn/ch7.md