由一个闭包引发的一系列老生常谈问题

162 阅读9分钟

之前写了一些常用数据处理方法练手,刚写了个防抖,想到涉及的知识点有点意思,是基础但是不好理解,正巧我也没系统总结过,就顺手来捋一遍。

知识点

闭包、执行上下文、作用域(链)、垃圾回收、原型(链)......

确实都是些老生常常常谈的知识点了,对大多数人来讲也不是很常用,但是能理解它们确实很重要,属于厚积薄发里面的厚积。于是,我们温故而知新叭~

for example

写一个防抖~众所周知,防抖的意思就是(>^ω^<),一般应用在输入框联想搜索。需求是传一个要执行的方法,然后在一段时间之后再执行。顺便提一嘴节流,节流就是,应用一般是元素拖拽的时候,既要保证用户的行为立即有所反馈,又不要事件频繁被触发。二者由异曲同工之妙,拿防抖举个例子:

  1. 搭个闭包架子
function debounce (func, timeout) {
    timeout = timeout || 300;
    return function (...args) {
        var _this = this;
        setTimeout(function () {
            func.apply(_this, args);
        }, timeout);
    }
}

这是执行一次需要做的事情,实现的效果就是推迟timeout毫秒执行func。

  1. 加上一些在闭包函数内部所使用的变量,来清除上一个还未来得及执行的setTimeout达到我们的目的
function debounce (func, timeout) {
    timeout = timeout || 300;
    var timer = null;
    return function (...args) {
        var _this = this;
        clearTimeout(timer);
        timer = setTimeout(function () {
            func.apply(_this, args);
        }, timeout);
    }
}

这样就算简单实现了一个防抖,不难吧~那我们就来看看那这里面涉及到的一系列知识点,看看闭包是个什么玩意儿~

运行过程

执行代码:

function debounce (func, timeout) {
    timeout = timeout || 300;
    var timer = null;
    return function (...args) {
        var _this = this;
        clearTimeout(timer);
        timer = setTimeout(function () {
            func.apply(_this, args);
        }, timeout);
    }
}
function log (...args) {
    console.log("log:", args);
}
var test = debounce(log, 3000);
test(1);

在js引擎运行代码之前,打开了浏览器就会创建一个全局环境......balabala,过于基础不想写.....结果就是在解析代码之前我们的执行上下文栈(运行栈)里面首先放入了一个全局执行环境。然后,解析代码,变量提升(变量对象的创建顺序为:形参、函数声明、变量声明),全局执行环境里面就有了一些初始化变量:

// 执行上下文栈:
[
    {
        owner: "Global",
        AO: {
            debounce: obj,
            log: obj,
            test: undefined
        }
    }
]
执行上下文

执行上下文的生命周期分为三个部分:创建阶段、执行阶段和执行完毕阶段

创建阶段主要有三个内容:

  1. 创建变量对象
  2. 初始化作用域链(scope chain)
  3. 确定this的指向

执行阶段,也有三个内容:

  1. 变量赋值
  2. 函数引用
  3. 执行其他代码

执行完毕:

  1. 主要内容:执行完毕后跳出执行上下文栈,等待被回收。

debounce()函数被调用(创建阶段——还没开始执行函数体相关代码),执行上下文栈就新增了一个debounce的执行环境:

// 执行上下文栈:
[
    {
        owner: "Global",
        AO: {
            debounce: obj,
            log: obj,
            test: undefined
        }
    },
    {
        owner: "debounce",
        VO: {
            arguments: {
                func: undefined,
                timeout: undefined,
                length: 2
            },
            funcA: obj,
            timer: undefined
        },
        [[scope]]: global.AO
    }
]

这里可以看到VO和AO,VO是变量对象,就是变量声明的时候,而AO是这个变量对象激活的时候,叫活动对象。[[scope]]呢,这个是里面包含该函数的作用域(自身执行上下文中的活动对象(AO)可以被访问的区域)链,初始值为引用着上一层作用域链里面所有的作用域,后面执行的时候还会将自己的AO对象添加进去。然后运行debounce(执行阶段,执行函数体相关代码):

// 执行上下文栈:
[
    {
        owner: "Global",
        AO: {
            debounce: obj,
            log: obj,
            test: funcA[point]  // 保存debounce返回出来的函数指针
        }
    },
    {
        owner: "debounce",
        AO: {
            arguments: {
                func: log func,
                timeout: 3000,
                length: 2
            },
            funcA: obj,
            timer: null
        },
        [[scope]]: global.AO + AO
    }
]

在运行debounce函数的时候,它的VO对象被激活变成了AO对象,有了值,并且global里的test也有了值是一个指向匿名函数funcA的指针,funcA创建了执行上下文,同时debounce执行完毕,出栈,等待被回收,为了方便看我就给注释掉了:

// 执行上下文栈:
[
    {
        owner: "Global",
        AO: {
            debounce: obj,
            log: obj,
            test: funcA[point]
        }
    },
    // {
    //     owner: "debounce",
    //     AO: {
    //         arguments: {
    //             func: log func,
    //             timeout: 3000,
    //             length: 2
    //         },
    //         funcA: obj,
    //         timer: null
    //     },
    //     [[scope]]: global.AO + AO
    // },
    {
        owner: "funcA",
        VO: {
            arguments: [],
            _this: undefined
        },
        [[scope]]: global.AO + debounce.AO + AO
    }
]
垃圾回收机制

如何确定哪些内存需要回收,哪些内存不需要回收,这是垃圾回收期需要解决的最基本问题。我们可以这样假定,一个对象为活对象当且仅当它被一个根对象或另一个活对象指向。根对象永远是活对象,它是被浏览器或V8所引用的对象。被局部变量所指向的对象也属于根对象,因为它们所在的作用域对象被视为根对象。全局对象(Node中为global,浏览器中为window)自然是根对象。浏览器中的DOM元素也属于根对象

test赋值这个地方就很关键了,把闭包函数funcA赋值给了一个变量test,这个变量test是一个活对象,这活对象引用了闭包函数,闭包函数又引用了AO对象,所以这个时候AO对象也是一个活对象。此时闭包函数的作用域链得以保存,不会被垃圾回收机制所回收。

现在栈中就剩下global和匿名函数的执行上下文了,这时候接着执行下面的代码,运行test(1),也就是说运行匿名函数funcA:

// 执行上下文栈:
[
    {
        owner: "Global",
        AO: {
            debounce: obj,
            log: obj,
            test: funcA[point]
        }
    },
    // {
    //     owner: "debounce",
    //     AO: {
    //         arguments: {
    //             func: log func,
    //             timeout: 3000,
    //             length: 2
    //         },
    //         funcA: obj,
    //         timer: null
    //     },
    //     [[scope]]: global.AO + AO
    // },
    {
        owner: "funcA",
        AO: {
            arguments: [1],
            _this: this指针
        },
        [[scope]]: global.AO + debounce.AO
    }
]

运行匿名函数funcA的时候,_this赋值,window上的clearTimeout入栈出栈,然后setTimeout入栈出栈,任务队列中增加了一个匿名函数B,timer被赋值了一个id。至此,funcA执行完毕,出栈等待回收,timeout毫秒之后,匿名函数B入栈:

// 执行上下文栈:
[
    {
        owner: "Global",
        AO: {
            debounce: obj,
            log: obj,
            test: funcA[point]
        }
    },
    // {
    //     owner: "debounce",
    //     AO: {
    //         arguments: {
    //             func: log func,
    //             timeout: 3000,
    //             length: 2
    //         },
    //         funcA: obj,
    //         timer: null
    //     },
    //     [[scope]]: global.AO + AO
    // },
    // {
    //     owner: "funcA",
    //     AO: {
    //         arguments: [1],
    //         _this: this指针
    //     },
    //     [[scope]]: global.AO + debounce.AO
    // },
    {
        owner: "funcB",
        AO: {},
        [[scope]]: global.AO + funcA.AO + AO
    }
]

匿名函数B的作用是执行AO里面的log,所以依然同理,log的执行上下文被创建运行,然后window的console.log()函数入栈出栈,最后匿名函数B执行完毕出栈:

// 执行上下文栈:
[
    {
        owner: "Global",
        AO: {
            debounce: obj,
            log: obj,
            test: funcA[point]
        }
    },
    // {
    //     owner: "debounce",
    //     AO: {
    //         arguments: {
    //             func: log func,
    //             timeout: 3000,
    //             length: 2
    //         },
    //         funcA: obj,
    //         timer: null
    //     },
    //     [[scope]]: global.AO + AO
    // },
    // {
    //     owner: "funcA",
    //     AO: {
    //         arguments: [1],
    //         _this: this指针
    //     },
    //     [[scope]]: global.AO + debounce.AO
    // },
    // {
    //     owner: "funcB",
    //     AO: {},
    //     [[scope]]: global.AO + funcA.AO + AO
    // },
    // {
    //     owner: "log",
    //     AO: {},
    //     [[scope]]: global.AO + funcB.AO + AO
    // }
]

随着代码一行一行的被执行,执行上下文不断被创建和销毁,scope里引用的AO不断变化产生的作用域链,由此大家可以对闭包函数的大致执行上下文的变化以及作用域链的关系(scope)心里有数了吧,也不会感到太过于艰涩。

最后有一个作用域和变量提升的经典坑:

var a = 1;
function test() {
    console.log(a);
    var a = 2;
    console.log(a);
}
test();
//
var log1 = function () {
    console.log("log1");
};
log1();
var log1 = function () {
    console.log("another log1");
}
log1();
//
function log2 () {
    console.log("log2");
};
log2();
function log2 () {
    console.log("another log2");
}
log2();

总结

人家问,闭包是什么呀,大家都会答:有权访问另一个函数作用域中的变量的函数。但是具体是什么呢,说白了,闭包的原理就是能独立使用私有变量,将匿名函数里的作用域链通过作用域继承保留下来。

从理论上来讲,所有函数都是闭包。因为都在创建时就将上层执行上下文的数据保存起来了(函数的[[scope]]属性)。所以在函数中可以通过作用域链访问到外层作用域的变量,也就是可以访问自由变量,它就是一个闭包。但是实际上更严格一点,即使创建变量的上下文已经销毁,但它依然存在(比如,内部函数从父函数中返回)。

由于垃圾回收机制的问题,闭包存在一个比较明显的问题,就是闭包会让函数中的变量都被保存在内存中,内存消耗很大,可能会导致内存泄漏的问题,所以不能滥用。

闭包其实有很多讲头:作用域继承、作用域链、执行上下文、垃圾回收、词法环境、堆栈内存啥的,再拓展还有this(bind、call、apply)、原型(链)、继承,还能由此想到深拷贝浅拷贝,学海无涯......

PS

说到原型(链),之前琢磨了一下感觉有一个很好的比喻,就是血缘关系。 我是我爸的女儿,我爸就是我的原型,我有自己的一些特征(属性),我还有些特征可以在我爸那儿找到(__proto__),甚至爷爷那儿,反正就能追根溯源一直往上,直到最后为空,这个就是原型链了,而能达到这个效果的就是通过传递(function)基因(prototype)。举个例子:

// 生一个宝贝的过程
function makeBaby (month) {
    this.month = month;
}
// 由于这家人祖祖辈辈都有一个“漂亮大眼睛”的显性基因
makeBaby.prototype.eyes = "漂亮的大眼睛";

// 生了个宝贝儿
var baby = new makeBaby("樱桃小嘴");

// 这个时候就能看到这个宝贝儿不仅有樱桃小嘴,还有漂亮的大眼睛
console.log(baby.eyes);

客观条件:只要是函数,就会有一个prototype对象;只要是对象,就会有一个__proto__属性

解析:本来这个宝贝儿没有漂亮大眼睛的,但是没办法,基因好,人家祖辈有,所以它也有大眼睛了。这个prototype基因就是baby的原型啦~baby身上可以通过__proto__找到这个基因,所以:

baby.__proto__ === makeBaby.prototype   // true

而且人家的祖辈prototype也是个对象,它也有__proto__,所以祖辈prototype也能通过自己的__proto__找到它祖辈的原型,由于makeBaby是个函数,函数的祖先是个Object,所以就有:

makeBaby.prototype.__proto__ === Object.prototype   // true

再往上,就没有惹,就是空,所以我们就可以得到这么一条血缘关系————原型链:

baby ---> makeBaby.prototype ---> Object.prototype ---> null

还有一条!!上面是从对象开始的,函数是个对象,所以函数本身不仅有prototype还有__proto__,所以:

makeBaby.__proto__ === Function.prototype;  // true
Function.prototype.__proto__ === Object.prototype;  // true
make ---> Function.prototype ---> Object.prototype ---> null;

就得到一条以函数开始的原型链啦~(^▽^) 这样理解起来虽然俗气得很,但是好理解~

唔。。。这是篇闭包的文章!!