您的位置:首页 > Web前端 > JavaScript

彻底理解Javascript闭包

2017-06-20 00:04 751 查看

彻底理解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 lives

g = 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的特性。也许未来会改变,让我们运行该示例进行检查。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: