4

前言

网络中有各种各样说闭包的文章,有些浅尝辄止,有些 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 > 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 秒前的快照...

总结

本文介绍了闭包。从它是如何形成的到它的优缺点,再到它的应用,笔者想,如此解释,差不多能对它有个交代了

参考资料

系列文章


山头人汉波
391 声望554 粉丝