JavaScript的闭包原来是这样一回事

本文详细讲解了JavaScript中的执行上下文、作用域、作用域链、变量对象(VO/AO)以及闭包的概念,强调了闭包在引用其他作用域变量时如何影响内存。通过实例分析了闭包可能导致的内存泄漏问题,并探讨了`this`在闭包中的引用特点。最后,提醒开发者在利用闭包的同时要注意内存管理,防止潜在的性能问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述

JavaScript的闭包原来是这样一回事

想要明白闭包,可不是一件很简单的事情,接下来,我将会从上下文和作用域开始,一步一步讲清楚闭包的概念,希望对各位读者能够有所启发。

执行上下文和作用域

执行上下文(execution context),简称上下文(context),这是JavaScript中最重要的概念。上下文是什么,假如有以下两个场景:

场景一:

😄 纪晓岚:和大人走的那么着急干嘛?

😖 和珅:肚子痛,我要去方便~

场景二:

😄 纪晓岚:和大人走的那么着急干嘛?

😌 和珅:有个官员想贿赂我两万两,让它给他个方便,我现在要去处理以下。

上述两个场景中,都有相同名字的变量“方便”,但显然两者的内容含义是不一样的,同一个变量,根据这个变量所处的上下文的不同,值也可能不同

全局上下文和函数上下文

JavaScript环境中,最外层的上下文叫做“全局上下文(Global Execution Context”,不同环境对全局上下文的实现不同,在浏览器中,全局上下文是window

每一个函数也都有一个函数上下文(Function Execution Context),当函数被调用时,就会创建一个函数上下文,并压入上下文栈(Context Stack,函数执行结束就把它弹出,我们来看一个具体例子:

function compare(value1, value2){
    if(value1 < value2){
        return -1
    } else if(value1 > value2){
        return 1
    } else{
        return 0
    }
}

let result = compare(5, 10)
console.log(result) //> -1

假设我们在浏览器中执行以上代码,可以看到,函数compare和变量result处于全局上下文中,当调用函数compare()时,会把它的函数上下文给压入栈中:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kgUhEoVU-1635061893402)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20210912155300826.png)]

当执行完毕之后,就把它弹出栈:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9zuzeWQH-1635061893403)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20210912155317193.png)]

我们稍微改造一下代码,调用console.log()函数:

function compare(value1, value2) {

    console.log('Starting compare...') // 增加一行输出
    
    if (value1 < value2) {
        return -1
    } else if (value1 > value2) {
        return 1
    } else {
        return 0
    }
}

let result = compare(5, 10)
console.log(result)

//> Starting compare...
//> -1

compare()函数中调用console.log()函数,那么上下文栈就会:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dw5iCkMV-1635061893405)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20210912160121690.png)]

console.log()函数执行结束,就会把它的上下文从栈中弹出,然后继续执行compare()函数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TviZ2eMS-1635061893407)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20210912160346222.png)]

作用域和作用域链

上下文用来确定一个变量的值或者函数的行为(可以访问哪些数据),而作用域是用来确定一个变量/函数的可见性(visibility),也就是,确定这个变量/函数可以被访问的范围。看个例子:

var myName = 'window'

function printMyName() {
    console.log(myName)
}

printMyName() //> window
console.log(myName) //> window

我们在浏览器中执行上述代码,首先我们用var定义了一个变量myName,这个变量可以在全局上下文中被访问到,也可以在printMyName()函数上下文中访问到。

我们增加一行代码:

var myName = 'window'

function printMyName() {
    var myName = 'function' // 在函数中定义相同名字变量
    console.log(myName)
}

printMyName() //> function
console.log(myName) //> window

这一次我们看到printMyName()函数输出的结果不一样了,它访问的是函数里面的myName,而不会去访问全局上下文中的myName,这得益于作用域链

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-miYrsM4G-1635061893408)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20210912170349539.png)]

printMyName的函数上下文中,它创建了一个作用域链,在printMyName()函数中解析myName时,首先会在当前作用域里去寻找myName是在哪里定义的。如果当前作用域找到存在myName变量定义的话,那么就会使用当前作用域的myName变量。如果找不到的话,则会向上查找(Look-up),即沿着作用域链向上查找。

VO和AO

首先我们回顾这张图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oW6XmKSj-1635061893410)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20210912170349539.png)]

在上一节中,我们介绍到了作用域链,为了行文方便,简单略过了两件东西,见下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XWaMzwY9-1635061893411)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20210915235422834.png)]

这两样东西是作用域链上的,它们其实是两个对象,正式名称叫做变量对象(VO, Variable Object,它存放着当前的所有变量/函数,以及可执行代码,在作用域链最前面的变量对象VO的代码总是会被执行。

在函数中,则把**激活变量(AO, Activation Object)**作为变量对象VO,激活变量不太一样,当进入一个函数执行时,激活变量AO会被创建,并且它首先定义的第一个变量是arguments,接着就是实际参数,再然后就是函数中的其他定义的变量/函数。

我们补充了关于VO和AO的概念:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4C6gr9ZI-1635061893412)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20211003111041208.png)]

刚才我们提到在作用域链最前面的变量对象VO的代码总是会被执行,也就是图中作用域第0对应的VO,其包含的代码会被执行。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QUimBLJO-1635061893413)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20211003111100897.png)]

这里的arguments是为空的,表示函数没有参数,假如有传参的话:

function printMyName(value1, value2) {
    //...
}

printMyName(5, 10)

则其对应的AO为:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QIZU9ZOq-1635061893413)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20211003111002190.png)]

小结

总结以下刚才函数执行的整个过程:先创建函数上下文,压入上下文栈中,再创建函数上下文对应的作用域链,作用域链包含一整个调用过程中的VO(或AO),一个VO(或AO)包含对应作用域的所有变量/函数(以及可执行代码)。函数执行结束后,函数上下文会被弹出栈,AO、作用域链会被销毁。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rcGPpour-1635061893414)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/image-20211003110937587.png)]

全局VO不一定被销毁,可能会被其他函数作用域链引用

闭包的不同

闭包(Closure)的定义很简单,闭包是函数,它引用了其他作用域中的变量。闭包一词还是挺形象的,它将其他作用域的变量“封闭包围”起来。我们来看一个经典的例子:

function createComparisionFunction(propertyName) {
    return function (o1, o2) {
        let v1 = o1[propertyName]
        let v2 = o2[propertyName]
        switch (true) {
            case v1 < v2:
                return -1
            case v1 > v2:
                return 1
            default:
                return 0
        }
    }
}

在这个例子中,createComparisionFunction函数返回了一个闭包,这个闭包引用了createComparisionFunction函数的propertyName变量。当我们执行以下代码时:

let compare = createComparisionFunction('age')
let result = compare({ age: 1 }, { age: 2 })

函数上下文、作用域链表示如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mvs5TUtY-1635061893415)(JavaScript%E7%9A%84%E9%97%AD%E5%8C%85%E5%8E%9F%E6%9D%A5%E6%98%AF%E8%BF%99%E6%A0%B7%E4%B8%80%E5%9B%9E%E4%BA%8B.assets/closure-16332322870551.jpg)]

可以看到,在闭包中执行上下文的作用域链中,引用了全局VO和createComparisionFunction函数的AO,这样闭包可以访问全局和createComparisionFunction函数的所有变量了。

但是,这样带来了一个副作用(side effect)——createComparisionFunction函数执行结束后,上下文会被销毁,但是它的AO却会留在内存中,因为它被闭包函数所引用,必须得等闭包函数销毁后,AO才会被收回释放,比如:

let compare = createComparisionFunction('age')

let result = compare({ age: 1 }, { age: 2 })

compare = null // 解除引用,闭包函数和引用的AO都会被垃圾回收器回收释放

引用其他作用域的闭包

刚才我们提到,闭包是“引用其他作用域变量”的函数,我们比较常看到的是函数作用域,但其实闭包也可以引用块级作用域的变量:

let foo
{
    let name = 'goods'
    foo = function(){
        console.log(name)
    }
}

浏览器优化

接上面的例子,在运行闭包之后,我们发现“包围”起来的变量中有些其实是不需要,比如argument

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RN2m5OTG-1635061893417)(C:\Users\Kyle\AppData\Roaming\Typora\typora-user-images\image-20211024141444331.png)]

现代浏览器会做一个优化,把明显不需要的变量给踢掉。我们来看一个简单的例子:

function breadProduct(){
    let name = 'deliciousBread'
    let productDate = new Date()

    function bread(){
        console.log(name)
    }

    return bread
}

const mybread = breadProduct()

我们来看一下,当执行结束breadProduct结束,mybread中使用[[Scopes]]去保存作用域链:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v55vZEVU-1635061893418)(C:\Users\Kyle\AppData\Roaming\Typora\typora-user-images\image-20211024145252982.png)]

我们主要关注到,一个是breadProduct函数的AO,一个是全局作用域:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KbL6IzxT-1635061893420)(C:\Users\Kyle\AppData\Roaming\Typora\typora-user-images\image-20211024145417895.png)]

当时,我们看一下breadProduct中,name被显式地引用到,所以它留下来了,但是age没有,就清理掉了

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LABslK5D-1635061893421)(C:\Users\Kyle\AppData\Roaming\Typora\typora-user-images\image-20211024145636858.png)]

这样做的目的很简单,就是节省内存空间,当然这是现代浏览器的一种可选的优化手段,而不是JS规范的必要要求。在以前的浏览器中,尤其是老式终端设备的浏览器中,会保留所有的变量。

例外:当使用eval

用于eval会动态执行语句,因此没办法确定哪个变量会不会被应用,因此浏览器会保留所有变量,我们稍微改一下代码:

        function breadProduct() {
            let name = 'deliciousBread'
            let productDate = new Date()

            function bread() {
                eval()
            }

            return bread
        }

        const mybread = breadProduct()

只要有eval(),所有变量都会被保存下来:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zfRV0Iq2-1635061893422)(C:\Users\Kyle\AppData\Roaming\Typora\typora-user-images\image-20211024150302050.png)]

注意:同一个AO

当我们有多个闭包时,我们来看看会发生什么事情:

        function breadProduct() {
            let name = 'deliciousBread'
            let productDate = new Date()

            function bread() {
                console.log(name)
            }

            function otherBread(){
                console.log(productDate)
            }

            return bread
        }

        const mybread = breadProduct()

虽然otherBread()函数没有被返回,并且随着breadProduct()函数执行结束而被清除,但是它显式引用了productDate,所有productDate被保留了下来:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eEzSoENG-1635061893423)(C:\Users\Kyle\AppData\Roaming\Typora\typora-user-images\image-20211024150904636.png)]

this引用

在闭包中,this引用情况有点复杂,闭包一般是匿名函数,匿名函数中不会自动将对象绑定到this中,看一个例子:

window.identify = 'The window'
let object = {
    identify: 'My Object',
    getIdentify(){
        return function(){
            return this.identify
        }
    }
}

console.log(object.getIdentify()()) //> 'The window'

我们可以通过定义一个引用this的变量来解决:

window.identify = 'The window'
let object = {
    identify: 'My Object',
    getIdentify() {
        let that = this
        return function () {
            return that.identify
        }
    }
}
object.getIdentify()()

内存泄漏

闭包的最大好处就是它能够保存其他作用域的变量,当然,使用不当也会造成内存泄漏,试看一例:

function assignHandler(){
	let element = document.getElementById('someElement')
	element.onclick = () => console.log(element.id)
}

element引用了某一个DOM节点,并且这个节点的onclick方法引用了element,这样一来element就没办法被GC清除掉,假设element是一个很大的对象的话,那么很可能造成内存泄漏。我们可以修改一下引用关系:

function assignHandler(){
    let element = document.getElementById('someElement')
    let id = element.id

    element.onclick = () => console.log(id)
    element = null
}

思考题

试问以下题目会不会造成内存泄漏:

let big = null
let count = 0

setInterval(function () {
    let bigReference = big

    function unused() {
        if (bigReference) {

        }
    }

    big = {
        count: count++,
        place: new Array(2e9),
        introduce: function () {
            console.log('I am big')
        }
    }
}, 1000)

总结

All in all, 闭包就是引用其他作用域变量的一个函数,要明白它,需要从执行上下文到作用域链到AO。闭包的好处有很多,最大的好处就是它能够保存其他作用域的变量,以便后续使用,典型的引用场景有Module、柯里化等等。当然,闭包也有内存泄漏的风险,因此,谨慎使用。

参考

[1] What is the Difference Between Scope and Context in JavaScript?

[2] Professional JavaScript For Web Developers : Chapter4/Chapter10

[3] JavaScript 的静态作用域链与“动态”闭包链

[4] You don’t know JavaScript Yet: Scope and Closure Chapter 7

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值