初衷
在实际开发中,通常是直接使用框架进行开发,框架确实提高了开发效率。在学习框架源码的过程中,越发感觉到 JavaScript 基础知识的重要性。所以打算重新梳理 JavaScript 中的知识点,并以笔记的形式发布出来。一方面分享给掘友们,另一方面由于是初次尝试书写技术型文章,若梳理内容不够完善,也欢迎掘友们指正。
目前已完成
当然,如果各位掘友认为书写的比较清晰,欢迎点赞、收藏加关注哦~
正文开始
初学 JavaScript 时,闭包是一个不太好理解的知识点。但在一些框架的源码中也充满了闭包,而且在《你不知道的 JavaScript(上卷)》中,作者将理解闭包,看作是某种意义上的重生。
《你不知道的 JavaScript(上卷)》5.1节
对于那些有一点 JavaScript 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作是某种意义上的重生。但是需要付出非常多的努力和牺牲才能理解这个概念。
真是如此吗?下面我们来剖析下闭包到底有什么神奇之处,争取一篇文章彻底搞懂闭包。
在剖析之前,需要有 3 个前置知识
- 作用域
- 垃圾回收
- 函数是一等公民
作用域
在JavaScript中,有全局作用域、函数作用域以及E6加入的let
、const
块级作用域(以后再做具体介绍)。目前先以ES5来介绍作用域
// 示例
var message = "global";
function foo() {
var message = "foo";
console.log(message); // foo - 访问的是函数作用域中的message
}
foo();
console.log(message); // global - 访问的是全局作用域中的message
上述示例代码中,全局代码中的message
访问的是全局作用域中声明的message
,而在函数中访问到的是函数作用域中声明的message
。原因是在执行全局代码时,JavaScript引擎会创建全局执行上下文(GEC,Global Execution Context);当执行函数调用时,JavaScript引擎会创建函数执行上下文(FEC,Functional Execution Context)。GEC和FEC中的变量对象(VO,Variable Object),Scope Chain
是当前执行上下文保存的变量对象VO以及父级变量对象ParentVO, 在GEC中Scope Chain
就是VO,也就是全局对象(GO,Global Object);在FEC中Scope Chain
就是活动对象(AO,Activation Obejct) 以及父级作用域的VO。全局声明的变量、函数保存在全局对象(GO)中,函数中声明的变量、函数,保存在函数对应的活动对象(AO) 。上述代码中的变量访问过程如下图所示
关于JavaScript引擎创建GEC、FEC的执行过程,可以查看我的上篇文章 JavaScript-执行机制
垃圾回收
不管哪种编程语言,在代码执行过程中都需要分配内存。不同的是有些编程语言都要手动管理内存,有些编程语言可以自动管理内存。
不管以哪些方式来管理内存,内存管理有如下生命周期:
- 申请需要使用的内存
- 使用分配的内存
- 不需要使用时,对其进行释放
不同的编程语言对于第 1 步和第 3 步会有不同方式的实现
- 手动管理内存:比如 C、C++、OC,都需要手动管理内存的申请和释放(malloc 和 free 函数)
- 自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,可以自动地帮我们管理内存
JavaScript 的内存管理
JavaScript 会在定义变量时分配内存,但针对不同的数据类型有所不同
- 基本数据类型,会在执行时直接在栈空间中进行分配
- 复杂数据类型,执行时会在堆内存中开辟一块空间,并将该空间的内存地址(指针)返回,存储在变量中
JavaScript 的垃圾回收
因为内存大小有限,所以当分配的内存不再需要使用时,需要对其进行释放,以便腾出更多内存空间。
在手动管理内存的编程语言中,需要通过一些方式开发者来释放不再需要的内存,比如 free 函数
- 管理方式非常低效,而且影响编写逻辑代码的效率
- 对开发者的要求高,如果忘记释放,可能会产生内存泄露
目前大部分编程语言都有自己的垃圾回收机制,对于那些不再使用的对象,都称之为垃圾,需要被回收,以释放更多的内存空间。比如 Java 的运行环境 JVM、JavaScript 的运行环境 JavaScript 引擎都有垃圾回收器(GC,Garbage Collection)。那么垃圾回收器是如何知道哪些对象不再使用呢?
常见的 GC 算法
引用计数
- 当一个对象有一个引用指向它时,那么这个对象的引用数就+1。当一个对象的引用数为 0 时,就说明这个对象可以被销毁
- 这个算法有一个很大的弊端就是会产生循环引用,如下图
标记清除
- 这个算法是设置一个根对象,垃圾回收器(GC)会定期从这个根对象开始,找到所有从根开始有引用到的对象。对那些没有引用到的对象,就认为是不可用的对象
- 这个算法很好的解决了循环引用的问题,如下图
JavaScript 采用比较广泛的是标记清除算法,当然类似于 V8 引擎为了进行更多的优化,在算法的实现细节上也会结合一些其他的算法。
函数是一等公民
在JavaScript中,函数使用非常灵活,可以作为一个函数的参数,也可以用为一个函数的返回值。
函数作为参数
function foo(fn) {
fn(); // 调用传入的函数参数
}
function bar() {
console.log("bar"); // bar
}
foo(bar); // 将函数bar作为参数传入foo函数中
函数作为返回值
function foo() {
function bar() {
console.log("bar");
}
return bar;
}
var result = foo(); // 执行foo函数,获取到返回值函数bar,并保存在result变量中
result(); // 通过result执行返回的函数
闭包
什么是闭包
MDN上的解释
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
个人理解
-
一个普通的函数,如果可以访问外层作用域的自由变量,那么这个函数就是一个闭包
-
广义的角度,因为函数可以访问到全局作用域的自由变量,所以JavaScript中的函数都是闭包
-
狭义的角度,如果函数访问了外层作用域的变量,那么就是闭包。如果没有访问,就不是闭包。
闭包的访问过程
根据定义,编写代码剖析下闭包的访问过程
function foo() {
var name = "zhangsan";
function bar() {
var age = 28;
console.log(name); // zhangsan
console.log(age); // age
}
return bar;
}
var result = foo();
result();
上述示例代码,代码的执行过程如下:
- 解析:首先创建全局对象(GO),根据
foo
函数声明创建foo的函数对像(Funcion Object),函数对象中除了函数体内编写的代码,还保存着该函数的父级作用域 - 解析完成,会先创建全局执行上下文(GEC)压入到执行上下文栈(ECS),然后开始从上到下依次执行代码。
- 执行到调用
foo()
时,引擎会根据foo的函数对像(Funcion Object)创建对应的foo的活动对象(AO),然后创建foo的函数执行上下文(FEC)压入到执行上下文栈(ECS),然后开始执行函数内的代码。foo的函数执行上下文(FEC)中的变量对象(VO)指向foo的活动对象(AO)。 - 在执行foo函数过程中,在函数内部又声明了bar函数,则会创建对应的bar的函数对像(Funcion Object)
- foo函数执行完成后,返回了内部的bar函数,将最终的返回值保存在变量
result
中。同时foo的函数执行上下文(FEC)会弹出执行上下文栈。 - 执行到
result()
时,引擎根据bar的函数对像(Funcion Object)创建bar的活动对象(AO) , 然后创建**bar的函数执行上下文(FEC)压入到执行上下文栈(ECS)**开始执行代码。bar的函数执行上下文(FEC)中的变量对象(VO)指向bar的活动对象(AO)。 - bar函数中访问了
name
和age
两个变量,会通过**bar的函数执行上下文(FEC)中的变量对象(VO)**查找变量的值。 - 在bar函数内部声明了
age
变量,所以可以直接访问到age
变量的值。但没有声明name
变量,所以此时的变量对象VO(即bar的活动对象AO)中没有name
变量,则会通过bar的函数执行上下文(FEC)中保存的Scoped Chain
,查找父级作用域的变量对象VO(此时是foo的活动对象),在foo的活动对象中保存了foo函数声明的name
变量的值。 - 正常情况下,foo函数在执行结束,foo的活动对象会被释放。但目前bar函数的作用域引用中指向了这个foo的活动对象,所以不会销毁。
图解访问过程
关于**全局对象(GO)和函数对像(Funcion Object)**的创建过程,可以查看上篇文章 JavaScript-执行机制
- 我们先看一下执行到
foo()
时,引擎内部是怎样的呢?如下图所示
- 当调用
result()
时,此时又是怎样的?如下图所示
闭包的内存泄露
在上面的案例中,如果不再使用bar函数对象,那么该函数对象应该被销毁掉,并且其引用着的父级作用域AO也应该被销毁掉。但是目前因为在全局作用域下的result
变量引用着bar函数对象(内存地址 0x00C),而bar函数对象的作用域引用着调用foo函数创建出来的活动对象(内存地址 0x00B)。
那么如何解决这个问题呢?利用JavaScript引擎的垃圾回收机制,将全局变量result
设置为null
,在GC下次检测中,**活动对象(内存地址 0x00B)**从根节点出发,是不可达的,就是会销毁掉。
下一篇,梳理JavaScript中的this
- 如果认为本篇知识点梳理的尚可,欢迎点赞
- 如果有需要补充、指正的地方,也欢迎评论留言哦~