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新增letconst声明变量块级作用域。块级作用域是指作用域范围局限在代码块中。

新特性的出现带来了新的概念:暂时性死区,暂时性死区得从“变量提升”说起。

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();

letcons声明变量时,会针对这个变量形成一个封闭的块级作用域。在块级作用域内,声明变量之前访问变量,就会报错

function foo() {
    let b = 'bar';
    console.log(b); 
}
foo();

{}的作用域中存在一个“死区”,函数开头开始,终止与相关变量声明语句的所在行。在这个范围内无妨访问使用letconst声明的变量,这块区域的专业名词是:TDZ(Temporal Dead Zone)。

在理解上述内容后,来看一下代码:除了自身作用域内的foo3bar2还可以访问foo2foo1;但是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的指向。

代码执行过程:

  1. 预编译阶段创建变量对象(VO,Variable Object),此时只是创建,而未进行赋值。
  2. 代码执行阶段,变量对象转为激活对象(AO,Active Object),完成VO到AO的转换,此时,作用域链也将被确定,它由当前执行环境的变量对象和所有外层已经完成的激活对象组成。这道工序保证了变量和函数的有序访问,如果未在当前作用域中找到变量,则会继续向上查找直到全局作用域。

调用栈

在理解执行上下文的基础上,函数调用栈就很容易理解了。在执行函数时,如果这个函数又调用了另外一个函数,而这“另外一个函数”又调用了另外一个函数 (开启套娃模式),这样便形成了一系列的调用栈

function foo1(){
    foo2();
}
function foo2(){
    foo3();
}
function foo3(){
    foo4();
}
function foo4(){
    console.log('foo4')
}
foo1();

foo1先入栈,紧接着foo1调用foo2foo2再入栈,以此类推,直到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 //释放内存

内存管理基本概念

内存空间:

  • 栈空间:由计算机自动分配释放,存放函数的参数值、局部变量的值等,其操作方式类似于数据结构中的栈
  • 堆空间:开发者分配释放,关于这部分空间要考虑垃圾回收的问题。

数据类型:

  • 基本数据类型:undefinednullnumberbooleanstring
  • 引用数据类型:objectarrayfunction

  一般情况下,基本数据类型按照值大小保存在栈空间中,占有固定大小的内存空间;引用类型保存在堆空间中,内存空间大小并不固定,需按引用情况来进行访问。

  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 HeapNodes线随着时间线一直在上升,并没有被垃圾回收机制回收。

例题分析

实战例题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函数赋值给全局变量fnfoo的变量对像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函数的内部变量。

总结

  做为合格的前端工程师并不是要背诵晦涩难懂的“闭包和垃圾回收机制”,而是根据面临的场景,凭借扎实的基础,能够通过查阅文档和资料提升应用性能,分析内存事故原因从而突破瓶颈。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值