js学习笔记-闭包与共享作用域

前言

最近我在一篇关于内存泄漏的文章中看到有一个比较神奇的例子,一个函数返回一个没有引用自由变量的内部函数竟然会因为另外一个没有运行的过的内部函数造成内存泄漏,文章中的解释也令我很费解= =:“相同作用域内创建的多个内部函数对象是共享同一个变量对象,……,形成了闭包”。这个解释让我之前对闭包的理解又造成了毁灭性的冲击,可是仔细想想好像又有那么一点点道理……自我接触闭包那天起就不敢随意给闭包下定义,而文献中对它的定义也会经常变化,可是当你跳出闭包这个概念的界限,去思考闭包为什么存在,其实就是弄清楚作用域的问题。回归正传,关于那篇博文中所提到的解释我还是持保留意见,因为我还没找到相应的资料去佐证这一点,但是我自己测试过也确实是出现了内存泄漏的问题,所以只能暂时以这个角度去分析这个问题

一段引发内存泄漏的代码

我们先来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function f() {
var str = Array(10000).join('#');
var foo = {
name: 'foo'
}
function unused() {
var message = 'it is only a test message';
// str = 'unused: ' + str; //若不删除该条语句则会出现内存泄漏
}
function getData() {
return 'data';
}
return getData;
}
var list = [];
document.querySelector('#click_button').addEventListener('click', function () {
list.push(f());
}, false);

上述代码中函数体f创建了两个内部函数,返回了其中一个内部函数且其内部并没有对外部的自由变量进行引用,理论上来说,这并不是闭包。但是当运行了这个脚本之后发现,随着click事件不断触发,该脚本所占的内存越来越大,而且在Chrome的调试器中Memory板块可以很清晰看到f函数所返回的函数中包含了不曾引用过的变量str。这表明这段代码确实产生了内存泄漏,而且原因也正正是因为闭包。

该文章中对其解释是这样的:

相同作用域内创建的多个内部函数对象是共享同一个变量对象(variable object)。如果创建的内部函数没有被其他对象引用,不管内部函数是否引用外部函数的变量和函数,在外部函数执行完,对应变量对象便会被销毁。反之,如果内部函数中存在有对外部函数变量或函数的访问(可以不是被引用的内部函数),并且存在某个或多个内部函数被其他对象引用,那么就会形成闭包,外部函数的变量对象就会存在于闭包函数的作用域链中。这样确保了闭包函数有权访问外部函数的所有变量和函数。

上述代码中即存在内部函数(getData)被外部对象引用的情况,而且又满足了任一内部函数存在对外部词法环境中自由变量的引用。所以返回函数getData形成了闭包。

而在闭包 - JavaScript | MDN中,闭包其实共享同一套词法环境/词法作用域。
词法环境包括环境记录和对外部环境的引用,而其中的环境变量其实就是变量对象

  • 环境记录
    • 形参
    • 函数声明
    • 变量
    • 其它…
  • 对外部词法环境的引用(outer)

关于这个特性的更常见的情景,其实也就是我们常讨论到的问题——在for循环中创建闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function makeHelpCallback(help) {
return function() {
showHelp(help);
};
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
// document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
document.getElementById(item.id).onfocus = showHelp(item.help);
}
}
setupHelp();

从上述代码看,如果赋值给 onfocus 的是showHelp(item.help),其实是给其赋值了一个闭包。而这些闭包是由他们的函数定义和在 setupHelp 作用域中捕获的环境所组成的。这三个闭包在循环中被创建,但他们共享了同一个词法作用域,在这个作用域中存在一个变量item。当onfocus的回调执行时,item.help的值被决定。由于循环在事件触发之前早已执行完毕,变量对象item(被三个闭包所共享)已经指向了helpText的最后一项。

MDN给我们的一个解决方法是使用函数工厂的方法,也就是注释掉的那段代码

1
document.getElementById(item.id).onfocus = makeHelpCallback(item.help);

在上述代码和之前不一样的是,makeHelpCallback(item.help)是一个立即执行的函数,执行完之后返回的函数才是真正的回调函数,返回的函数中执行了showHelp函数,但却为每一个回调(showHelp函数)创建一个新的词法环境。在这些环境中,help 指向 helpText 数组中对应的字符串。

而之前的代码中的showHelp(item.help)其实和下面代码是等价的:

1
2
3
document.getElementById(item.id).onfocus = function(){
showHelp(item.help);
}

所以相当于每个回调(匿名函数)都是在for循环中创建的,共享同一个词法环境,也即是同一个循环中的item。

其实这里的makeHelpCallback函数和我们所熟悉的使用匿名立即执行函数是同一个道理,都是隔绝词法环境,不过这里把匿名函数提炼出为一个函数工厂,原理都是给不同的闭包创造的单独的词法环境。

参考链接

常见的JavaScript内存泄露
An interesting kind of JavaScript memory leak