前言
网络中有各种各样说闭包的文章,有些浅尝辄止,有些 CV 党,又有些只讲一部分。于我而言,要想了解闭包,需要掌握词法环境(或者是 ES 中的变量对象) 、执行上下文与调用栈、 (词法)作用域等等
好在我们在之前的文章中已经梳理了这几块内容:词法作用域、词法环境、执行上下文 。接下来让我们解开闭包的面纱,看看这位克利奥帕特拉七世
一句话解释
闭包就是一个绑定了执行环境的函数,它利用了词法作用域的特性,在函数嵌套时,内层函数引用外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包
闭包的定义
各路大神对闭包的定义
winter:
闭包其实只是一个绑定了执行环境的函数
闭包与普通函数的区别是,它携带了执行环境,就像人在外星中需要自带吸氧的装备一样,这个函数也带有在程序中生存的环境
实际上 JavaScript 中跟闭包对应的概念就是“函数”
候策
函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包
黑客与画家
闭包(lexical closure)一个函数,通过它可以引用由包含这个函数的代码所定义的变量
MDN
闭包是指那些能够访问自由变量的函数
那什么是自由变量呢?
自由变量是指在函数中使用的,但既不是函数参数也不是函数局部变量的变量
由此,我们可以看出闭包共又两部分组成
闭包 = 函数 + 函数能够访问的自由变量
现代 JavaScript 教程
闭包是指一个函数可以记住其外部变量并可以访问这些变量
《你不知道的 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
PS:或许这个例子更能说明闭包吧,foo 函数作用参数传递进 baz 函数中,虽然在 baz 函数中执行,但是能访问到 foo 函数中的变量(即a,自由变量)
像我们平时开发中,无意中会用到各种闭包
私有实例变量
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 函数引用,就形成了闭包 // 调用 wait 后,wait 函数压入调用栈,message 被赋值,并调用定时器任务,随后弹出,1000ms之后,回调函数timer 压入调用栈,因为引用 message,所以就能打印出 message // 事件监听 let a = 1; let btn = document.getElementById('btn'); btn.addEventListener('click',function callback(){ console.log(a); }); // 变量 a 被 callback 函数引用,形成闭包 // 事件监听和定时器一样,都属于把函数作为参数传递形成的闭包。addEventListener函数有两个参数,一为事件名,二为回调函数 // 调用事件监听函数,将 addEventListener 压入调用栈,词法环境中有 click 和 callback 等变量,并因为 callback 为函数,并有作用域函数形成,引用 a 变量。之后弹出调用栈,当用户点击时,回调函数触发,callback 函数压入调用栈,a 沿着作用域链往上找,找到全局作用域中的变量 a,并打印出 // AJAX let a = 1; fetch("/api").then(function callback() { console.log(a) }) // 同事件监听
只要是回调函数,函数中引入了变量,那就形成了闭包
可以说,在 JavaScript 中,所有函数都是天生闭包(除了 new Function 这个特例)
模块化
用闭包模拟私有方法
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 hooks
在 React 的函数式组件中,我们会用 hooks 来控制组件状态,但也有因闭包而带来闭包陷阱
如下例子:
function ProfilePage(props) { const showMessage = () => { alert('Followed ' + props.user); }; const handleClick = () => { setTimeout(showMessage, 3000); }; return ( <button onClick={handleClick}>Follow</button> ); }
点击按钮后,在 3 秒中切换 user,打印出来的式原先的 user ,线上例子可看这里
其原因是函数式组件会捕获渲染时的值。而点击时,那时候的 user 就被捕获了,并传入 showMessage 函数中,使得 3 秒后,展示出的 user 时 3 秒前的快照...
总结
本文介绍了闭包。从它是如何形成的到它的优缺点,再到它的应用,笔者想,如此解释,差不多能对它有个交代了
参考资料
- 说说我对 JavaScript 闭包的理解
- 变量作用域,闭包
- how-do-javascript-closures-work
- 发现 JavaScript 中闭包的强大威力
- 函数那些事:JS 闭包难点剖析
- MDN