彻底理解Javascript闭包
Javascript是面向函数编程语言,非常灵活。定义函数,赋值给变量,或作为参数传递给其他函数,最后在完全不同的地方调用。
我们知道函数可以访问外部变量,该特性很常用,但当外部变量变化时,函数获得的是当前最新值还是函数创建时存在的值?另外,当函数被传至其他地方并执行会怎么,在新的地方访问外部会怎么?
几个问题
让我们提出两个问题作为引子,然后一步一步研讨内部机制,这样你能回答这些问题,甚至未来更复杂的问题。
函数
sayHi
使用外部变量name
,当函数运行时,这两个值会使用那个?let name = “John”;
function sayHi() {
alert(“Hi, ” + name);
}name = “Pete”;
sayHi(); // what will it show: “John” or “Pete”?
这种场景在浏览器和服务器都很常见。函数可能计划执行晚于创建时间,举例用户请求之后或网络请求。
所以,问题是:获得最新的变化吗?
函数
makeWorker
创建新的函数并返回。新函数在其他地方调用。它将在创建的地方访问外部变量或执行地方或两种都是?function makeWorker() {
let name = “Pete”;return function() {
alert(name);
};
}let name = “John”;
// create a function
let work = makeWorker();// call it
work(); // what will it show? “Pete” (name where created) or “John” (name where called)?
词法环境
为了理解发生了什么,让我们先讨论技术上“变量”是什么?
在Javascript中,每个运行函数、代码块以及脚本作为一个整体有一个与之关联的对象,被命名为词法环境。
词法环境对象有两部分组成:
- 环境记录——对象拥有的作为属性的局部变量(一些其他信息如this的值)。
- 引用外部词法环境。
所以,变量是特定内部变量的属性,环境记录。获取或改变变量即获取或改变对象属性。
举例,下面简单代码,仅有一个词法环境:
这被称为全局词法环境,与整个脚本关联。对浏览器所有<script>
标签共享相同的全局环境。
在上图中,矩形即环境记录(变量存储),箭头为外部引用。全局词法环境没有外部词法环境,所以为null
。
下面一个较大图展示let
变量工作机制:
右边矩形展示全局词法环境执行期间变化:
- 当脚本开始,词法环境为空。
let phrase
定义开始,初始时没有值,所以undefined
被存储。phrase
被赋值。phrase
引用新的值。
所有看起来很简单,对吧?
总结:
- 变量是特定内部对象属性,与当前执行块/函数/脚本关联。
- 对变量操作即对对象的属性操作。
函数声明
函数声明是特别的,与let
声明变量不同,它们处理不是执行时,而是词法环境创建时。对全局词法环境,即script开始时。
这就是为什么我们可以调用函数在期定义之前。
下面代码展示词法环境开始时不空,有say
,因为有函数声明,之后获得phrase
,使用let
声明:
调用函数期间,有两个词法环境:内部的(为函数调用)和外部的(全局的):
- 内部词法环境对应于当前say函数执行,有一个变量:name,函数参数。当调用say(“Jhon”),所以name的值为Jhon.
- 外部词法环境是全局词法环境。
内部的词法环境有外部词法环境的引用,指向外部词法环境。
当执行代码访问变量时——首先搜索内部词法环境,然后在搜索外部词法环境,再往外部搜索直到链的结尾。
如果变量都没有发现,严格模式会产生错误。没有use strict
,创建一个新的全局变量并赋值为undefined
,为了向后兼容。
让我们看看示例中搜索过程:
- 当say函数内部的alert访问name变量时,在词法环境中立刻搜索到。
- 当say想访问phrase变量时,本地没有phrase,所以接着搜索外部引用,即全局词法环境。
现在我们能给出文字开头第一个问题的答案。
函数获得变量当前值,即最新值。
因为描述的机制。就变量值没有存储,当函数访问时,从当前的词法环境或外部词法环境中国获得当前值,所以答案是Pete
:
let name = "John";
function sayHi() {
alert("Hi, " + name);
}
name = "Pete"; // (*)
sayHi(); // Pete
上面代码执行流程为:
- 全局词法环境有
name:"John"
. - 星号行全局变量被修改,现在名称为
name:"Pete"
. - 当函数
say
执行时,从外部访问name,这时全局词法环境中的name已经是“Pete”。
一个调用——一个词法环境
每次函数运行时,会创建新的还是词法环境。如果还是被调用多次,那么每次执行有其独立的词法环境,保存局部变量和为每次运行的特定参数。
词法环境是一个规范对象
词法对象是一个规范对象,我们不能在我们代码中直接获得或操作该对象,Javascript引擎对其进行优化,不使用的对象会被删除,为了节约内存空间执行其他任务。
嵌套函数
当函数在另一个函数体里被创建,称为嵌套函数。
技术上,这很容易成为可能,我们可以使用这种方式组织代码:
function sayHiBye(firstName, lastName) {
// helper nested function to use below
function getFullName() {
return firstName + " " + lastName;
}
alert( "Hello, " + getFullName() );
alert( "Bye, " + getFullName() );
}
这里创建嵌套函数getFullName
是为了方便,能够访问外部变量,所以返回完整名称。
更有趣的是,嵌套函数能够被返回:作为新对象的属性(如果外部函数创建它并返回)或将其作为结果,然后在其他地方使用。无论在哪,都仍能访问相同的外部变量。
使用构造函数示例:
// constructor function returns a new object
function User(name) {
// the method is created as a nested function
this.sayHi = function() {
alert(name);
};
}
let user = new User("John");
user.sayHi();
返回函数示例:
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
我们看看makeCounter
示例。其创建counter函数,每次执行返回下一个number。尽管很简单,简单修改代码能有很多实际用途,举例,作为伪随机数生成器等,所以该示例不完全是人为制作版本。
counter函数如何工作?
当内部函数运行时,count++
语句中的变量,是从里往外搜索。示例中的顺序应该是:
- 背地嵌套函数
- 外部函数的变量
- 继续查找直到全局环境
本例中的count在第二步被发现,当外部变量被修改时,改变被发现,所以count++
发现外部变量并且在其词法环境中增加值,如果我们让其let count = 1
.
这里有几个问题:
- 我们能以某种方式不通过
makeCounter
中的代码重置count
变量?如:上面示例中的alter
调用之后。 - 如果我们调用
makeCounter
多次,返回多个counter
函数,它们相互独立还是共享相同的count?
继续阅读之前尝试回答?
怎么样?
好了,这里是我们的答案。
- 没有办法重置,
count
是局部函数变量,我们不能从外部访问。 - 对每次调用
makeCounter()
,会产生新的词法环境,拥有自己的counter
,所以每个counter
函数结果相关独立。
示例代码如下:
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter1 = makeCounter();
let counter2 = makeCounter();
alert( counter1() ); // 0
alert( counter1() ); // 1
alert( counter2() ); // 0 (independant)
到目前为止,外部变量的场景你很清楚了,但更复杂场景需更深入的理解,所以我们继续。
词法环境详细阐述
为了更深入的理解,需循序渐进讲解makeCounter
及详细信息:
- 当脚本开始时,仅有全局词法环境:
在开始时仅有makeCounter
函数,为函数声明,但没有执行。所有函数出生时接受一个隐藏属性[[Environment]]
,引用其创建的词法环境。我们不讨论它,但从技术上讲其是函数知道词法环境创建在哪的方式。
这里,makeCounter
在全局词法环境中创建,所以[[Environment]]
引用它。
- 然后继续运行代码, 执行
makeCounter
,下图显示当执行makeCounter
函数第一行时的情景:
此刻,创建词法环境,并保存变量和参数。所有词法环境存储两件东西:
- 局部变量,我们的示例中
count
是局部变量(当let count代码执行时)。 - 引用外部词法环境,及设置函数的
[[Environment]]
,这里引用全局词法环境。所以,现在我们有两个词法环境:第一个是全局的,第二个是当前makeCounter函数调用,引用全局外部引用。 - 在执行
makeCounter
函数时,创建一个内嵌函数,无论使用函数声明或函数表达式创建,所有函数获得[[Environment]]
属性,引用创建他们的词法环境。对我们新建的嵌套函数,是当前makeCounter
函数。
注意,这一步创建内部函数,但没有调用。代码function(){ return count++;}
没有执行,将要被返回。 - 继续执行,调用
makeCounter
函数完成,结果被赋值给全局变量counter.
函数只有一行:return count++
,运行时被执行。 - 当调用
counter()
时,创建一个空的词法环境,它没有局部变量,但其[[Environment]]
引用外部词法环境,所以能访问之前makeCounter()
函数创建的变量:
现在访问变量,首先搜索它自己的词法环境(空的),然后是makeCounter()
函数创建的词法环境,最后是全局环境。当其查询到count,在makeCounter()
函数创建的词法环境中,即最近的外部词法环境。注意这里的内存管理方式,当之前makeCounter()
函数调用完成,它的词法环境被驻留在内存中,因为有嵌套的[[Environment]]
引用它。通常,只有有其他函数引用它,词法环境对象即被驻留,反之被清除。
- 当调用
counter()
函数,不仅返回count的值,也增加其值。注意修补所在地,count的值正好在其发现的词法环境中被修改。
所以,返回值是在前一步的基础上进行修改的结果,后面调用一样。
- 下一次counter()执行做同样的事情。
现在回答本文开头的第二个问题就显而易见了。下面代码中的work()函数在其外部词法环境中访问name。
所以结果为“Pete”。
但如果在makeWorker()
没有let name
,那么继续搜索,到达全局词法环境,发现其值为”John”.
闭包
闭包是一个通用的编程术语,开发者一般都了解。闭包是一个函数,能够记住其外部变量并能够访问它们。在一些语言中是不可能的,或在通过特定的方式实现函数才能实现。根据上面的解释,Javascript中所有函数天生就是闭包(除了new Function 语法)。
也就是:它们使用一个隐藏属性[[Environmeng]]
自动记住在哪儿被创建,并能够访问外部变量。
如果要问一个前端工程师什么是闭包,有效的回答将是闭包的定义和解释Javascript中所有函数都是闭包,并且可能很少关于技术细节:[[Environmeng]]
属性以及词法环境如何工作的。
代码块、循环、立即调用的函数表达式
上面示例集中在函数上,但词法环境也存在于代码块上{...}
。当代码块运行时被创建,并包含块级局部变量,这里有几个示例:
if
在下面示例中,当执行到if块时,为其创建新的词法环境:
新的词法环境得到闭包的一个外部引用,所以phrase变量可以被访问到。但所有变量和函数表达式在if代码块里面声明的,其词法环境不能被外部访问。
举例,if执行之后,下面的alert代码不能访问user,因此报错。
For,while
对循环,每个运行有独立的词法环境,如果变量声明在for里面,那么作为局部词法环境:
for(let i = 0; i < 10; i++) {
// Each loop has its own Lexical Environment
// {i: value}
}
alert(i); // Error, no such variable
这确实是个例外,因为let i 看上去是在代码块{...}
的外面,但实际上每次循环运行有其自己的词法环境,保留当前i,所以循环结束后,i不能访问。
代码块
我们也可以仅使用代码块{…}去隔离变量,使其称为局部变量。
举例,在web浏览器中,所有脚本共享相同的全局环境,所以如果在脚本中创建全局变量,对其他脚本也是有效的,但是如果两段脚本有相同的名称的全局变量会冲突,相互覆盖对方。
如果变量名称是一个普通的单词则很可能发生,并各个脚本的作者相互不知道。
要消除这种情况,可以使用代码块隔离整个脚本:
{
// do some job with local variables that should not be seen outside
let message = "Hello";
alert(message); // Hello
}
alert(message); // Error: message is not defined
块外部的代码或其他脚本里的代码不能访问,因为代码块有自己的词法环境。
IIFE
以前的代码,有称为“立即执行函数表达式”,简写为IIFE,用于达到该目的。代码如下:
(function() {
let message = "Hello";
alert(message); // Hello
})();
这里创建函数表达式并立即执行。所以代码立即执行,并有自己的私有变量。
函数表达式使用(function(){...})
包裹,因为当Javascript遇到function
在主代码流程中,则解释为函数声明的开始,但函数声明必须有名称,所以下面代码会产生错误:
// Error: Unexpected token (
function() { // <-- JavaScript cannot find function name, meets ( and gives error
let message = "Hello";
alert(message); // Hello
}();
为了其正常运行,可以把代码编程函数声明,增加名称,但是仍不工作。Javascript不允许函数声明被立即调用:
// syntax error because of brackets below
function go() {
}(); // <-- can't call Function Declaration immediately
所以需要括号告诉Javascript该函数是另一个表达式的上下文,即函数表达式。无需名称并可以理解执行。
Javascript有多种方式声明函数表达式:
// Ways to create IIFE
(function() {
alert("Brackets around the function");
})();
(function() {
alert("Brackets around the whole thing");
}());
!function() {
alert("Bitwise NOT operator starts the expression");
}();
+function() {
alert("Unary plus starts the expression");
}();
上面所有情况都是声明函数表达式并立即执行。
垃圾回收
我们讨论的词法环境对象和正常对象有相同的内存管理机制。
通常,词法环境对象在函数运行后被清除,举例:
function f() {
let value1 = 123;
let value2 = 456;
}f();
这里词法环境有两个属性值,但f()
执行完成后,词法环境变成不可达,所以从内存中删除。
但如果有嵌套函数,在f执行之后仍然可达,那么
[[Environment]]
引用执行外部词法环境,则仍在内存中保留:function f() {
let value = 123;function g() { alert(value); }
return g;
}let g = f(); // g is reachable, and keeps the outer lexical environment in memory
注意,如果f()调用多次,结果函数被保存,那么相应的词法环境对象也被保留在内存中,所有三个函数代码如下:
function f() {
let value = Math.random();
return function() { alert(value); };
}
// 3 functions in array, every of them links to Lexical Environment
// from the corresponding f() run
// LE LE LE
let arr = [f(), f(), f()];
当词法环境变成不可达,则会从内存清除。也就是:当没有嵌套函数保留其引用,在下面代码中,
g
执行之后变成不可达,value
从内存中删除。function f() {
let value = 123;function g() { alert(value); }
return g;
}let g = f(); // while g is alive
// there corresponding Lexical Environment livesg = null; // …and now the memory is cleaned up
实际中的优化
我们已经看到,在理论上一个函数激活状态,所有外部变量也被保留。
但是实际中,Javascript引擎视图进行优化。他们分析变量使用,如果很容易看到外部变量不被使用,则删除。
在V8(chrom,opera)在调试模式下变量变量变得无需,这是重要的影响
试着运行下面的代码,在chrome的开发工具中,当暂停时,控制台输入alert(value)
.
function f() {
let value = Math.random();
function g() {
debugger; // in console: type alert( value ); No such variable!
}
return g;
}
let g = f();
g();
你能看到,没有这个变量,理论上它应该是可达,但引擎进行了优化。
这导致很有趣调试问题,同名变量,我们能看到外部变量代替了期望的变量:
let value = "Surprise!";
function f() {
let value = "the closest value";
function g() {
debugger; // in console: type alert( value ); Surprise!
}
return g;
}
let g = f();
g();
V8这个特性是好的,如果你调试时,可能会遇到。这不是调试器的bug,是V8的特性。也许未来会改变,让我们运行该示例进行检查。
本文参考链接:https://blog.csdn.net/neweastsun/article/details/73478393