知识图谱
闭包定义
JS红宝书 中有描述:闭包指的是那些引用了另一个函数作用域中变量的函数。简单的一个描述,闭包并非是单一的概念,它涉及到作用域、作用域链、执行上下文、内存管理等概念,需要开发者对闭包有整体的认识。
作用域
作用域决定了代码区块中变量、函数、对象和其他资源的可见性
可分为:全局作用域、函数(局部)作用域 和 块级作用域
函数(局部)作用域:局部变量在函数开始执行时创建,函数执行完后自动销毁
// 表达式函数
let exampleFunction = function () {
var x = "declared inside function"; // x 只能在 exampleFunction 函数中访问
console.log(x);
}
exampleFunction();
console.log(x); // 函数外部无法访问,引发 error:Uncaught ReferenceError: x is not defined
/**
* 运行结果:
*
* declared inside function
* Uncaught ReferenceError: x is not defined
*/
执行上面代码,变量 x 是函数 exampleFunction 作用域内,函数体内可以正常访问,函数外访问报错
全局作用域:在任何地方都能访问
let x = "declared global";
exampleFunction();
// 声明式函数
function exampleFunction() {
console.log(x);
}
console.log(x);
/**
* 运行结果:
*
* declared global
* declared global
*/
执行上面代码,变量 x 是在全局作用域,exampleFunction 函数在自身函数作用域内未查找到 x 变量(当前作用域内不存在的变量称为 自由变量 ),但是它会继续向外扩大查找范围,因此可以在全局作用域内找到变量 x,变量作用域的查找是一个扩散过程,就像各个环节相扣的链条,逐次递进,形成 作用域链
块级作用域:块语句由一对大括号界定,用于组合零个或多个语句
ES6 中增加了通过
let和const声明变量的块级作用域
let x = 1;
{
let x = 2;
console.log(x); // 输出 2
}
console.log(x); // 输出 1
/**
* 运行结果:
*
* 2
* 1
*/
拓展知识
变量提升
使用 var 关键字声明的变量 或者 声明式函数 会被 声明提升
exampleFunction()
// 表达式函数 (声明变量 + 将函数体赋值给变量):变量 exampleFunction 提升,但函数体赋值动作不提升,故最后一行调用函数执行了此函数体
var exampleFunction = function () {
console.log(x) // undefined
var x = 'declared inside expression function' // 变量 x 的声明被提升,故上一行输出为 undefined
}
// 声明式函数: 函数整体提升,故第一行调用函数执行了声明式函数的函数体
function exampleFunction () {
var x = 'declared inside declarative function'
console.log(x)
}
exampleFunction()
/**
* 运行结果
*
* declared inside declarative function
* undefined
* */
暂时性死区:TDZ
由 let 和 const 声明的变量,在相应花括号形成的作用域中存在一个 死区,起始于函数开头,终止于相关变量声明语句的所在行,在这个范围内无法访问使用 let 或 const 声明的变量
exampleFunction()
function exampleFunction () {
console.log(x)
let x = 'declared inside declarative function'
}
/**
* 运行结果:
*
* Uncaught ReferenceError: Cannot access 'x' before initialization
*/
执行上下文和调用栈
执行上下文
执行上下文就是当前代码的执行环境/作用域,和前文介绍的作用域相辅相成,但又是两个完全不同的概念。
代码执行的两个阶段:代码预编译阶段 、 代码执行阶段
-
预编译阶段:预编译阶段是前置阶段,这一阶段由编译器将
JavaScript代码编译成可执行的代码。在此阶段有一些重要的步骤:- 在预编译阶段进行变量声明
- 在预编译阶段对变量声明进行提升,但是值为
undefined - 在预编译阶段对所有非表达式的函数声明进行提升
-
执行阶段的主要任务是执行代码逻辑,执行上下文在这个阶段会全部创建完成
exampleFunction(10)
// 非表达式函数会在预编译阶段进行函数声明提升,声明的变量作用域在函数外(全局)
function exampleFunction(num){
console.log(exampleFunction) // 访问的局部变量,声明未赋值,故 undefined
exampleFunction = num
console.log(exampleFunction)
var exampleFunction // 使用 var 声明局部变量,在预编译阶段 声明并提升
}
console.log(exampleFunction) // 访问的全局变量
exampleFunction = 1
console.log(exampleFunction)
/**
* 运行结果:
*
* undefined
* 10
* function foo(num){ console.log(foo) foo = num console.log(foo) var foo }
* 1
*/
作用域在预编译阶段确定,但是作用域链是在执行上下文的创建阶段生成的,因为函数在调用时才会开始创建对应的执行上下文。
执行上下文包括:变量对象、作用域链及this的指向(见文始知识图谱)
JavaScript 引擎执行机制基本原理:
代码执行的整个过程类似一条生产流水线。第一道工序是在预编译阶段创建变量对象(Variable Object, VO),此时只是创建,而未进行赋值。到了下一道工序代码执行阶段,变量对象会转为激活对象(Active Object, AO),即完成 VO 向 AO 的转换。此时,作用域链也将被确定,它由当前执行环境的变量对象和所有外层已经完成的激活对象组成。这道工序保证了变量和函数的有序访问,即如果未在当前作用域中找到变量,则会继续向上查找直到全局作用域
调用栈
在执行一个函数时,如果这个函数又调用了另外一个函数,而这 "另外一个函数" 又调用了另外一个函数,这样便形成了一系列的调用栈
注意: 正常来讲,在函数执行完毕并出栈时,函数内的局部变量在下一个垃圾回收(GC)节点会被回收,该函数对应的执行上下文将会被销毁,这也正是我们在外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数才可以访问该变量,该变量会在预编译阶段被创建,在执行阶段被激活,在函数执行完毕后,其相关上下文会被销毁
再谈闭包
闭包并不是 JavaScript 中特有的概念,社区中对闭包的定义也并不完全相同。简单可以理解为:函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在外层函数的外面可以被访问,进而形成闭包。
闭包的基本原理
正常情况下外界是无法访问函数内部变量的,函数执行之后,上下文即被销毁。但是在外层函数中,如果我们返回了另一个函数,且这个返回的函数使用了外层函数内的变量,那么外界便能够通过这个返回的函数获取外层函数内部的变量值
内存管理
内存管理指对内存生命周期的管理,而内存生命周期为:分配内存、读写内存、释放内存
let example = "TEST" // 分配内存
console.log(example) // 读写内存
example = null // 释放内存
JavaScript 是在创建变量时自动进行了分配内存,并且在不适用它们时"自动"释放,释放的过程称为垃圾回收。
-
在上文介绍 调用栈 时了解到,函数执行完毕后出栈,函数内的局部变量会在下一个垃圾回收节点被自动回收。
- 当存在闭包时,闭包会存储在堆内存中,内存并不会被主动释放,需要根据实际情况手动释放内存
-
全局变量的声明周期直至浏览器卸载页面才会结束,即全局变量不会被当做垃圾回收,必要时需要手动释放
垃圾回收算法
垃圾回收机制会自动寻找"不再需要"的内存,从而释放内存空间。目前存在两种垃圾回收算法:
-
引用计数垃圾收集:此算法把 "对象是否不再需要" 简化定义为 "对象有没有其他对象引用到它"。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。
- 局限性:循环引用(两个对象相互引用,形成一个循环),引用计数算法考虑它们相互都有至少一次引用,即使函数调用完毕,已经出栈,它们依旧不会被回收
-
标记 - 清除算法(2012年起,现代浏览器都使用了此算法):此算法把 "对象是否不再需要" 简化定义为 "对象是否可以获得"。这个算法假定设置一个叫做根(root)的对象(在JavaScript里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始的引用,然后找这些对象引用的对象...,从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象
- 局限性:那些无法从根对象查询到的对象都将被清除(实践中很少碰到这种情况)
综上,目前最新的垃圾回收算法,也无法智能判断某变量是否"不再需要"。
内存泄漏
内存泄漏是指任何对象"不再需要"时,依旧在内存中占用空间,未能释放,造成内存浪费。
对于持续运行的服务进程,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。
常见内存泄漏:
- 未及时释放的全局变量
- 未删除的定时器(
setInterval等)和回调函数(addEventListener等) - 闭包,确认不再需要使用,需手动置为
null,释放内存,或者用其他方式替代闭包
闭包栗子
// 最简单,最经典的闭包
const exampleFunction = (function () {
let v = 0;
return () => {
// 引用自由变量
return v++
}
}()) // 立即执行函数
for(let i = 0; i < 10; i++){
exampleFunction()
}
console.log(exampleFunction)
console.log(exampleFunction())
/**
* 运行结果:
*
* f () => {
* return v++
* }
* 10
* */
// 循环中闭包,需正确理解变量作用域,否则易引起bug
const exampleFunction = () => {
var arr = []
let i
for(i = 0; i < 10; i++){
arr[i] = function () {
return i
}
}
return arr[0]
}
console.log(exampleFunction())
console.log(exampleFunction()())
/**
* 运行结果(结果显然不是我们想要的):
*
* ƒ () {
* console.log(i)
* }
* 10
*/
// 改进方法一:通过函数创建一个新的作用域,根据作用域链的规则访问到作用域内的变量
const exampleFunction = () => {
var arr = []
let i
for(i = 0; i < 10; i++){
arr[i] = (function (i) {
return function(){
return i
}
})(i)
}
return arr[0]
}
console.log(exampleFunction())
console.log(exampleFunction()())
/**
* 运行结果:
*
* ƒ () {
* console.log(i)
* }
* 0
*/
// 改进方法二:使用let的块级作用域,每次循环都创建一个块级作用域
const exampleFunction = () => {
var arr = []
for(let i = 0; i < 10; i++){
arr[i] = function () {
return i
}
}
return arr[0]
}
console.log(exampleFunction())
console.log(exampleFunction()())
/**
* 运行结果:
*
* ƒ () {
* console.log(i)
* }
* 0
*/
综上,涵盖了闭包的知识点,欢迎指正!