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

856 阅读9分钟

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

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

执行上下文和作用域

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

场景一:

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

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

场景二:

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

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

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

全局上下文和函数上下文

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()时,会把它的函数上下文给压入栈中:

image.png

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

image.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()函数,那么上下文栈就会:

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

作用域和作用域链

上下文用来确定一个变量的值或者函数的行为(可以访问哪些数据),而作用域是用来确定一个变量/函数的可见性(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,这得益于作用域链

image.png

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

VO和AO

首先我们回顾这张图

image.png

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

image.png

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

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

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

image.png

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

image.png

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

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

printMyName(5, 10)

则其对应的AO为:

image.png

小结

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

image.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 })

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

image.png

可以看到,在闭包中执行上下文的作用域链中,引用了全局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

image.png

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

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

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

    return bread
}

const mybread = breadProduct()

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

image.png

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

image.png

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

image.png

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

例外:当使用eval

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

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

            function bread() {
                eval()
            }

            return bread
        }

        const mybread = breadProduct()

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

image.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被保留了下来:

image.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