深入理解JavaScript——闭包


前言
网络中有各种各样说闭包的文章,有些浅尝辄止,有些 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 指向父作用域——全局作用域

调用foo
代码执行至 function bar 时,创建 bar 的词法环境,它没有变量,outer 指向父作用域 foo
所有函数在“诞生”时都会记住创建它们的词法环境
所有函数都有个[[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用
我们说过作用域与它创建于哪里相关,与在哪儿调用无关

代码执行至bar函数
调用完函数 foo(),弹出调用栈,foo 中的函数 bar、变量 b 随着 foo 出栈而被释放
由于函数 bar 的结果赋值给了全局变量 baz,baz 相当于多个了隐藏属性 [[Environment]],它指向父作用域 foo,而 bar 又引用了 foo 作用域下的变量 a,所以变量 a 无法被释放
因此,baz.[[Environment]] 有对 {a: 0} 词法环境的引用
[[Environment]] 引用在函数创建时被设置并永久保存

调用完 foo
调用函数 baz(),创建 baz() 执行上下文,并将其压入调用栈中
并在执行代码前(编译阶段),创建一个新的词法环境,并且它的 outer 指向 baz.[[Environment]] ,即父作用域 foo
当它查找变量 a 时,先在自己的词法环境中找,找不到,沿着 outer 往它的父作用域找,在 foo 词法环境中找到了变量 a,并在变量所在的词法环境中更新变量

调用 baz
如此,调用完 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 > minconst 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,并打印出// AJAXlet 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
⚠️「随朱波流」只是记录本人关于编程、生活、个人成长、思考的自留地。如有需要,可前往 blog.azhubaby.com(阅读原文)中查看第一手文章
到顶部