JS闭包
基本知识
作用域
作用域可以理解为某种规则下的限定范围,该规则用于指导开发者如何在特定场景下查找变量。
函数作用域和全局作用域
function foo () {
var a = 'bar'
console.log(a);
}
foo();
对以上代码改动:
var b = 'bar';
function foo(){
console.log(b);
}
foo();
执行时,foo函数在自身作用域内未找到b变量,会继续向外扩大查找范围,在全局作用域中找到了变量b。
function bar(){
var b = 'bar';
}
function foo(){
console.log(b); // b is not defined
}
foo();
foo和bar分属两个彼此独立的函数作用域,foo无法访问bar中定义的变量b,foo作用域链内(直到上层全局作用域中)也不存在相应的变量,报错。
在Js执行函数时,遇见变量且需要读取其值,就会“就近”先在函数内部查找该变量的声明和赋值。如果在函数内部无法找到,就跳出函数作用域,到更上层作用域中查找。
function bar(){
var b= 'bar';
function foo(){
console.log(b);
}
foo();
}
bar();
foo执行时,变量b的声明和赋值是在foo的上层函数bar的作用域中获取的。
var b= 'bar';
function bar(){
function foo(){
console.log(b);
}
foo();
}
bar();
更上层作用域也可以顺着作用域范围向外扩散,直到全局作用域。
变量作用域的查找是一个扩散的过程,像是链条:环环相扣、逐次递进。
块级作用域和暂时性死区
ES6新增let
和const
声明变量块级作用域。块级作用域是指作用域范围局限在代码块中。
新特性的出现带来了新的概念:暂时性死区,暂时性死区得从“变量提升”说起。
function foo(){
console.log(b); // undefined
var b = 'bar';
}
foo()
输出undefined
,因为变量bar
在函数内进行了提升,以上代码可理解为:
function foo(){
var b;
console.log(b); // undefined
b = 'bar'
}
foo();
在使用let
对b进行声明是,会报错:
function foo() {
console.log(b); // Cannot access 'b' before initialization
let b = 'bar';
}
foo();
let
和cons
声明变量时,会针对这个变量形成一个封闭的块级作用域。在块级作用域内,声明变量之前访问变量,就会报错
function foo() {
let b = 'bar';
console.log(b);
}
foo();
在{}
的作用域中存在一个“死区”,函数开头开始,终止与相关变量声明语句的所在行。在这个范围内无妨访问使用let
和const
声明的变量,这块区域的专业名词是:TDZ(Temporal Dead Zone)。
在理解上述内容后,来看一下代码:除了自身作用域内的foo3
,bar2
还可以访问foo2
、foo1
;但是bar1
无妨访问bar2
函数内部定义的foo3
var foo1 = 'fool';
function bar1(){
var foo2 = 'foo2';
function bar2() {
var foo3 = 'foo3'
}
}
死区问题理解看一下代码:在bar1
中,let b = 'bar'
这一行前面的区域是“死区”,在“死区”中访问b
会报错。
function bar(){
console.log(b);
let b = 'bar';
console.log(b)
}
bar();
对于上述暂时性死区,极端情况是:函数的参数默认值设置也会收到影响
function foo(arg1=arg2,arg2){
console.log(arg1,arg2);
}
foo('bar1','bar2');
在foo
中,如果没有传第一个参数,则会使用第二个参数作为第一个实参;但当第一个参数为默认值时,执行arg1=arg2
会被当做暂时性死区处理。
function foo(arg1=arg2,arg2){
console.log(arg1,arg2);
}
foo(undefined,'bar2'); // Cannot access 'arg2' before initialization
foo(null,'bar2'); // null bar2
报错是因为除了块级作用域,函数参数默认值也会受到暂时性死区的影响。输出null bar2
是因为在执行foo(null,'bar2');
时,不会认为“函数第一个参数为默认值”,而会直接接受null
作为第一个参数的值。
思考一下代码的输出结果时什么:
function foo(arg1){
let arg1;
}
foo('bar1');
代码会报错:Identifier 'arg1' has already been declared
,这是因为函数参数名出现在其“执行上下文/作用域”中导致的。
以上的例子是为了引出新的知识点:“执行上下文”,下面一起来看看吧。
执行上下文和调用栈
执行上下文就是当前代码的执行环境/作用域,和之前介绍的作用域链有很大的联系,但有完全不同的两个概念。执行上下文包含了作用域链,同时又是递进的:有了作用域链才会执行上下文部分。
代码执行的两个阶段
- 代码预编译阶段
- 代码执行阶段
预编译阶段注意的问题:
- 在预编译阶段声明变量
- 对变量声明提升,值为
undefined
- 对所有非表达式的函数声明进行提升
注意以上3点,在正确理解和判断代码逻辑时有很大的帮助:
function bar(){
console.log('first');
}
var bar = function (){
console.log('second')
}
bar(); // second
var bar = function (){
console.log('second')
}
function bar(){
console.log('first');
}
bar(); // second
两个代码片的输出结果都是second
,原因是在预编译阶段虽然对变量bar
进行了声明,但是不会对其进行赋值;函数bar
则被创建并提升。在代码执行阶段,变量bar才会被赋值(通过表达式赋值)。
继续看代码思考问题:
foo(10);
function foo(num){
console.log(foo);
foo = num;
console.log(foo);
var foo;
}
console.log(foo);
foo = 1;
console.log(foo);
执行结果:
undefined
10
[Function: foo]
1
在foo(10)
执行时,会在函数体内进行变量提升,此时执行函数体内的第一行会输出undefined
,执行函数体内的第三行会输出foo
,接着运行代码,运行到函数体外的console.log(foo)
语句时,会输出foo
函数的内容(因为foo
函数内的foo = num
,num
被赋值给函数作用域内的foo
变量)。
结论:作用域在调用栈预编译阶段确定,但是作用域链实在执行上下文的创建阶段完成生成的,因为函数在调用时才会开始创建对应的执行上下文,执行上下文包括变量对象、作用域链、this的指向。
代码执行过程:
- 预编译阶段创建变量对象(VO,Variable Object),此时只是创建,而未进行赋值。
- 代码执行阶段,变量对象转为激活对象(AO,Active Object),完成VO到AO的转换,此时,作用域链也将被确定,它由当前执行环境的变量对象和所有外层已经完成的激活对象组成。这道工序保证了变量和函数的有序访问,如果未在当前作用域中找到变量,则会继续向上查找直到全局作用域。
调用栈
在理解执行上下文的基础上,函数调用栈就很容易理解了。在执行函数时,如果这个函数又调用了另外一个函数,而这“另外一个函数”又调用了另外一个函数 (开启套娃模式),这样便形成了一系列的调用栈
function foo1(){
foo2();
}
function foo2(){
foo3();
}
function foo3(){
foo4();
}
function foo4(){
console.log('foo4')
}
foo1();
foo1
先入栈,紧接着foo1
调用foo2
,foo2
再入栈,以此类推,直到foo4
执行完;然后foo4
先出栈,接着foo3
出栈,以此类推。这个过程满足先进后出(后进先出)的规则,因此形成调用栈。
正常来讲,在函数执行完毕并处栈时,函数内的局部变量再下一个垃圾回收(GC)节点会被回收,该函数对应的执行上下文将会被销毁,这也是我们在外界无妨访问函数内定义变量的原因。也就是说,只有在函数执行时,相关函数才可以访问该变量,该变量会在预编译阶段被创建,再执行阶段被激活,在函数执行完毕后,其相关上下文会被销毁。
闭包
前面解释了:作用域、执行上下文、调用栈,目的还是为闭包知识做准备,终于到了闭包环节了。
匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。(红宝书第四版)
通俗来讲:函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可以访问,进而形成闭包。
function numGenerator() {
let num = 1;
num++;
return () => {
console.log(num);
};
}
var getNum = numGenerator();
getNum();
numGenerator
创建一个变量num,接着返回打印num值的匿名函数,宰割函数引用了变量num,使得在外部通过调用getNum
方法访问变量num,因此在numGenerator
执行完毕之后,即相关调用栈出栈之后,变量num不会消失,任然有机会被外界访问。
在正常情况下,外界是无妨访问函数内部的变量的,函数执行之后,上下文被销毁。但是在函数(外层)中,如果我们返回了另一个函数,且这个返回的函数使用了函数(外层)内的变量,那么外界便能够通过这个返回的函数获取原函数(外层)内部的变量值。这就是闭包的基本原理。
内存管理
内存管理都是指对内存生命周期的管理,而内存的生命周期无外乎分配内存、读写内存、释放内存。
let foo = 'bar' // 分配内存
alert(foo) //读写内存
foo = null //释放内存
内存管理基本概念
内存空间:
- 栈空间:由计算机自动分配释放,存放函数的参数值、局部变量的值等,其操作方式类似于数据结构中的栈
- 堆空间:开发者分配释放,关于这部分空间要考虑垃圾回收的问题。
数据类型:
- 基本数据类型:
undefined
、null
、number
、boolean
、string
等 - 引用数据类型:
object
、array
、function
等
一般情况下,基本数据类型按照值大小保存在栈空间中,占有固定大小的内存空间;引用类型保存在堆空间中,内存空间大小并不固定,需按引用情况来进行访问。
Js依赖宿主浏览器的垃圾回收机制,一般情况下不用开发者操行。但这并不代表在释放内存方面就万事大吉了,某些情况下依然会出现内存泄漏现象。
内存泄漏是指内存空间已经不再使用,但由于某种原因并没有被释放的现象(这是一个“玄学”的概念)。因为内存空间是否还在使用在某种程度上是不可判定的,或者判定成本很高。内存泄漏会导致程序运行缓慢、崩溃。
内存泄漏场景举例
let element:any = document.getElementById("element");
element.mark = "marked";
// 移除element节点
element.parentNode.removeChild(element);
// 优化加
element=null
删除了id为element的节点,到那时变量element存在,该节点占有的内存无法释放,为了解决这个问题,需要在remove
方法中添加element=null
,这样更稳妥。
const element:any = document.getElementById("element");
element.innerHTML = `<Button id='button'>点击</Button>`;
const button = document.getElementById('button');
button?.addEventListener('click',()=>{
console.log('first')
})
element.innerHTML = '';
// 优化
button?.removeEventListener('click',()=>{
console.log('first')
})
因为element.innerHTML = '';
,Button元素已经从DOM删除了,但是由于事件处理句柄还在,所以该节点变量依然无法被回收。因此还需要移除事件,防止内存泄漏。
function foo(){
const name = 'mark';
setInterval(()=>{
console.log(name)
},1000)
}
foo();
由于存在setInterval
,所以name内存空间始终无法被释放,如果不是业务要求的话,一定记得在合适的时机使用clearInterval
清除。
浏览器垃圾回收
内存泄漏和垃圾回收注意事项
function foo(){
let value = 123;
function bar(){
alert(value);
}
return bar;
}
const bar = foo();
以上代码中,变量value将会被保存在内存中,如果加上bar = null
,则随着bar
不再被引用,value
也会被清除。
结合浏览器引擎优化,修改代码:
function foo() {
let value = Math.random();
function bar() {
debugger
}
return bar;
}
const bar = foo();
bar();
在浏览器中执行代码,并在函数bar
中设置断点,会发现value
没有被引用
在bar
函数中加入对value的引用
function foo() {
let value = Math.random();
function bar() {
console.log(value);
debugger
}
return bar;
}
const bar = foo();
bar();
此时在引擎中存在闭包变量value值
通过实例借助Chrome devtool
排查内存泄漏的具体位置,代码如下:
var array = [];
function createNodes() {
let div;
let i = 100;
let frag = document.createDocumentFragment();
for (; i > 0; i--) {
div = document.createElement('div');
div.appendChild(document.createTextNode(i));
frag.appendChild(div);
}
document.body.appendChild(frag);
}
function badCode() {
array = [[...Array(100000).keys()]];
createNodes();
setTimeout(badCode, 1000);
}
badCode();
以上代码递归调用了badCode
,这个函数每次向array
数组中写入新的由100000项0~1数字组成的新数组,badCode
函数使用全局变量array
后并没有手动释放内存,垃圾回收机制不会处理array
,因此会导致内存泄漏;同时,badCode
函数调用了createNodes
函数,每秒会创建100个div节点。
打开Chrome devtool
,选中Perfonnance
拍下快照,可以看到JS Heap
和Nodes
线随着时间线一直在上升,并没有被垃圾回收机制回收。
例题分析
实战例题1
const foo = (() => {
var v = 0;
return () => {
return v++;
};
})();
for (let i = 0; i < 10; i++) {
foo();
}
console.log(foo());
foo是一个立即执行函数,尝试打印foo
时,要执行代码:
const foo = (() => {
var v = 0;
return () => {
return v++;
};
})();
console.log(foo);
输出结果:
()=>{
return v++;
}
在循环执行foo
时,引用自由变量10次,最后执行foo时,得到10.这里的自由变量是指在没有相关函数作用域中声明,但却被使用了的变量。
实战例题2
const foo = () => {
var arr = [];
for (var i = 0; i < 10; i++) {
arr[i] = () => {
console.log(i);
};
}
return arr[0];
};
foo()();
自由变量为i,执行foo返回的是arr[0]
,arr[0]
此时是函数,其中变量i的值是10
实战例题3
var fn = null;
const foo = () => {
var a = 2;
function innerFoo() {
console.log(a);
}
fn = innerFoo;
};
const bar = () => {
fn();
};
foo();
bar();
正常来讲,根据调用栈的知识,foo函数执行完毕后,其执行环境生命周期会结束,所占用的内存会被垃圾收集器释放,上下文消失。但是通过讲innerFoo
函数赋值给全局变量fn
,foo
的变量对像a
也会被保留下来。所以,函数fn
在函数bar
内部执行时,依然可以访问这个被保留下来的变量对象,输出结果为2。
实战例题4
var fn = null;
const foo=()=>{
var a = 2;
function innerFoo(){
console.log(c)
console.log(a);
}
fn = innerFoo;
}
const bar = ()=>{
var c = 100
fn();
}
foo();
bar();
报错:c is not defined
,在bar中执行fn时,fn已经被复制为innerFoo
,变量c并不在其作用域链上,c只是bar函数的内部变量。
总结
做为合格的前端工程师并不是要背诵晦涩难懂的“闭包和垃圾回收机制”,而是根据面临的场景,凭借扎实的基础,能够通过查阅文档和资料提升应用性能,分析内存事故原因从而突破瓶颈。