闭包认识
只用三句话,通俗解释什么是闭包:
- JS变量的查找规则是由内到外,JS函数是可以嵌套声明的,所以子函数可以访问父函数的作用域;
- 当外层函数把子函数return出去,又赋值给外部变量时,那么这个变量就相当于拥有访问封闭数据包的能力;
- 最终的效果就是:外层函数执行完毕后,局部变量数据由于被引用了而未被销毁;
javascript
function useFoo(){
var a = 2;
function bar(){
console.log(a); //2
}
return bar;
}
// useFoo函数执行后,其作用域不会立即销毁,称之为闭包函数
var baz = useFoo();
闭包应用
闭包怎么用
- 利用闭包函数能留存数据的特点,可以更加便捷的创建数据模块化
- 而所谓模块化,就是指封装隔离的代码环境,在模块内部的数据和方法不会影响外部代码,就比如像传统面向对象的Class封装、ES6的Module封装、指令和组件封装等,都是不同粒度的代码模块化的封装方式。
- 闭包会造成内存泄露吗?闭包只是延迟变量生命周期的一种方式,正常情况不会造成内存泄露,而只有在DOM事件引用、定时器未清除,或者闭包变量长期持有大数据未释放,才会造成内存泄漏。解决的方式是手动把引用设置为null,切断引用;
闭包的实践案例
- React的hooks 逻辑封装
- Vue computed 计算属性和 watch 监听回调
- 实现防抖截流函数
- 实现函数柯里化
- 实现链式调用
- 实现高阶函数
闭包(Closure)与Object、ESModule的区别与选择
模块化是对逻辑代码封装和避免JS变量冲突的主要方式
Object对象
- 先说object对象,虽然经常使用字面量的声明方式,把对象当做map集合来存数据,但对象的本质其实是继承自Object基类的
- 而类,或者说Class,是源自传统的面向对象的设计范式,是指先定义Class,然后通过实例化得到实例对象
- 相同点就是,Class类也是模块化的一种方式。用来组织和管理对象相同的属性和方法,类的成员可以是private私有的、protect派生类共享的以及public公有的
- 通常用来抽象封装设计时使用,比如抽象一个Axios请求类,通过参数化间隔时间和响应结构来实例化不同的请求对象;
- 而闭包相较于对象,定义灵活,不需要提前定义类,并且像hooks封装一样,可以随着组件的生命周期很方便的被回收管理。对象就像是结构化的闭包,但如果闭包结构变得复杂时,就应该使用对象封装更合适;
Module的模块
- ES6 Module模块则通常是独立的js文件,是基于文件作用域的
- 相同点是模块文件内部定义的变量/常量/方法默认都是私有的,需要通过export导出,而导出的内容是单例模式的,也就是说,哪怕其他不同的文件通过import引入多次,都指向同一个实例;
- 区别是Module是ES6新特性,老旧浏览器可能不支持,而闭包是JS的基础特性。且ESM的加载和解析是代码运行前完成的,通常用来定义一些全局的工具方法或者全局变量;
闭包原理
三个问题
所有的JS函数都是闭包函数吗?
《JavaScript权威指南》犀牛书以及《现代JavaScript教程》都提到过一句话:
"从技术的角度来讲,所有的JavaScript函数都是闭包函数
",如何理解这句话?
因为:
- ECMAScript标准中,所有JavaScript函数(哪怕是空函数),函数的隐藏属性
[[enviroment]]
词法环境都包含outer
外部环境的引用 - 也就是说,任何函数总能访问外部环境的变量,哪怕这个函数在当前自己所处的词法环境之外被执行
JS执行时怎么就能区分普通函数和闭包函数,实现数据留存的?
JS代码分为"预编译"和"执行"两个阶段:
- 预编译阶段:创建VO(变量对象),并为变量分配内存空间,进行变量提升,生成AST;
- 执行阶段:把VO转成AO(活动对象),创建执行上下文(包含作用域链),执行代码逻辑;
所以在预编译阶段时,普通函数的局部变量只在当前函数的作用域,执行后就被销毁;而闭包函数中的局部变量由于跨作用域引用,所以在预编译时就被分配在了堆空间,函数执行后实现了生命周期延长;
V8引擎的内存管理机制怎么理解闭包的实现?
上面闭包的特性,只是ECMAScript定义的标准,和JS引擎的具体实现又有些许不同,主要包含以下内容:
惰性解析(lazy parsing)
- 虽然JS分为预编译和执行两个阶段,但V8引擎反而采用的是编译执行+解释执行混合策略(JIT-Just in Time),它不会一次性把所有的JavaScript代码进行编译,是这考虑到一次性编译会影响JS代码启动速度,以及内存的大量占用。
- 这就是V8引擎的"惰性解析/延迟解析"特性:就是说在解析代码时,仅仅编译顶层代码,遇到函数声明,会跳过函数内部的代码,等函数执行时再编译函数内部的代码并执行
预编译器(preparser)
- 但惰性解析不了解函数内部的逻辑,使得在预编译阶段,无法区分普通函数还是闭包函数了,所以v8引擎引入了预编译器模块来解决
- 当解析代码遇到函数时,并不是真正的跳过函数,而是通过预编译器对函数内部进行快速的预解析,检查语法有没有错误,以及有没有引用外部作用域的变量,从而进行判断该函数是否为闭包函数
逃逸分析(escape analysis)
- 首先回顾内存管理分为"栈内存"和"堆内存":
-
- 栈内存由引擎的GC来管理分配和释放,内存大小固定且空间连续,后进先出策略,像JS基础数据类型都会被分配在栈内存中;
- 堆内存由开发者管理,内存大小不固定,树形存储;
- 通过预编译器模块确定了如果是普通函数的变量,则说明未逃逸,在当前作用域使用,则保留在栈空间,快速访问,自动释放;如果是闭包引用变量,则分配到堆内存中,长生命周期,需手动GC管理;
优化词法环境(不重要)
- 当我们在嵌套的子函数中debugger,watch查看外层函数的局部变量时,发现本应该可以访问的,却显示
not available
- 这是v8引擎对函数的词法环境进行了优化,
outer
(外部作用域)实际上是一个删减版本的对象。只是对调试有影响,但不影响最终结果
最后总结
- 闭包并不是JavaScript语言独有的,是Go、PHP、swift、Java、Dart等函数式编程语言/动态语言的核心特性,甚至是计算机科学重要的编程概念;
- 闭包的本质就是词法作用域规则的副产品,在预编译时通过外部词法环境引用来获得局部变量的访问权限,在执行时延迟了变量的生命周期。简言之,就是一句话:
定义时有了引用,执行时就能使用
; - JS引擎是JIT策略且惰性解析的,通过预解析器模块进行逃逸分析,将闭包数据存储到堆内存中,从而实现延迟变量的生命周期;