关于前端:深入理解JavaScript闭包

2次阅读

共计 7073 个字符,预计需要花费 18 分钟才能阅读完成。

前言

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

系列文章

  • 深刻了解 JavaScript——开篇
  • 深刻了解 JavaScript——JavaScript 是什么
  • 深刻了解 JavaScript——JavaScript 由什么组成
  • 深刻了解 JavaScript——所有皆对象
  • 深刻了解 JavaScript——Object(对象)
  • 深刻了解 JavaScript——new 做了什么
  • 深刻了解 JavaScript——Object.create
  • 深刻了解 JavaScript——拷贝的机密
  • 深刻了解 JavaScript——原型
  • 深刻了解 JavaScript——继承
  • 深刻了解 JavaScript——JavaScript 中的始皇
  • 深刻了解 JavaScript——instanceof——找祖籍
  • 深刻了解 JavaScript——Function
  • 深刻了解 JavaScript——作用域
  • 深刻了解 JavaScript——this 关键字
  • 深刻了解 JavaScript——call、apply、bind 三大将
  • 深刻了解 JavaScript——立刻执行函数(IIFE)
  • 深刻了解 JavaScript——词法环境
  • 深刻了解 JavaScript——执行上下文与调用栈
  • 深刻了解 JavaScript——作用域 VS 执行上下文
正文完
 0