Skip to main content
 首页 » 编程设计

彻底理解Javascript闭包

2022年07月19日135shangdawei

彻底理解Javascript闭包

Javascript是面向函数编程语言,非常灵活。定义函数,赋值给变量,或作为参数传递给其他函数,最后在完全不同的地方调用。
我们知道函数可以访问外部变量,该特性很常用,但当外部变量变化时,函数获得的是当前最新值还是函数创建时存在的值?另外,当函数被传至其他地方并执行会怎么,在新的地方访问外部会怎么?

几个问题

让我们提出两个问题作为引子,然后一步一步研讨内部机制,这样你能回答这些问题,甚至未来更复杂的问题。

  1. 函数sayHi使用外部变量name,当函数运行时,这两个值会使用那个?

    let name = “John”;

    function sayHi() {
    alert(“Hi, ” + name);
    }

    name = “Pete”;

    sayHi(); // what will it show: “John” or “Pete”?

这种场景在浏览器和服务器都很常见。函数可能计划执行晚于创建时间,举例用户请求之后或网络请求。
所以,问题是:获得最新的变化吗?

  1. 函数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中,每个运行函数、代码块以及脚本作为一个整体有一个与之关联的对象,被命名为词法环境。

词法环境对象有两部分组成:

  1. 环境记录——对象拥有的作为属性的局部变量(一些其他信息如this的值)。
  2. 引用外部词法环境。

所以,变量是特定内部变量的属性,环境记录。获取或改变变量即获取或改变对象属性。
举例,下面简单代码,仅有一个词法环境:

这被称为全局词法环境,与整个脚本关联。对浏览器所有<script>标签共享相同的全局环境。

在上图中,矩形即环境记录(变量存储),箭头为外部引用。全局词法环境没有外部词法环境,所以为null
下面一个较大图展示let 变量工作机制:

右边矩形展示全局词法环境执行期间变化:

  1. 当脚本开始,词法环境为空。
  2. let phrase定义开始,初始时没有值,所以undefined被存储。
  3. phrase被赋值。
  4. 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 

上面代码执行流程为:

  1. 全局词法环境有name:"John".
  2. 星号行全局变量被修改,现在名称为name:"Pete".
  3. 当函数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++语句中的变量,是从里往外搜索。示例中的顺序应该是:

  1. 背地嵌套函数
  2. 外部函数的变量
  3. 继续查找直到全局环境

本例中的count在第二步被发现,当外部变量被修改时,改变被发现,所以count++发现外部变量并且在其词法环境中增加值,如果我们让其let count = 1.

这里有几个问题:

  1. 我们能以某种方式不通过makeCounter中的代码重置count变量?如:上面示例中的alter调用之后。
  2. 如果我们调用makeCounter多次,返回多个counter函数,它们相互独立还是共享相同的count?

继续阅读之前尝试回答?

怎么样?

好了,这里是我们的答案。

  1. 没有办法重置,count是局部函数变量,我们不能从外部访问。
  2. 对每次调用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及详细信息:

  1. 当脚本开始时,仅有全局词法环境:

在开始时仅有makeCounter函数,为函数声明,但没有执行。所有函数出生时接受一个隐藏属性[[Environment]],引用其创建的词法环境。我们不讨论它,但从技术上讲其是函数知道词法环境创建在哪的方式。

这里,makeCounter在全局词法环境中创建,所以[[Environment]]引用它。

  1. 然后继续运行代码, 执行makeCounter,下图显示当执行makeCounter函数第一行时的情景:

此刻,创建词法环境,并保存变量和参数。所有词法环境存储两件东西:

  1. 局部变量,我们的示例中count是局部变量(当let count代码执行时)。
  2. 引用外部词法环境,及设置函数的[[Environment]],这里引用全局词法环境。所以,现在我们有两个词法环境:第一个是全局的,第二个是当前makeCounter函数调用,引用全局外部引用。
  3. 在执行makeCounter函数时,创建一个内嵌函数,无论使用函数声明或函数表达式创建,所有函数获得[[Environment]]属性,引用创建他们的词法环境。对我们新建的嵌套函数,是当前makeCounter函数。

    注意,这一步创建内部函数,但没有调用。代码function(){ return count++;}没有执行,将要被返回。
  4. 继续执行,调用makeCounter函数完成,结果被赋值给全局变量counter.

    函数只有一行:return count++,运行时被执行。
  5. 当调用counter()时,创建一个空的词法环境,它没有局部变量,但其[[Environment]]引用外部词法环境,所以能访问之前makeCounter()函数创建的变量:

现在访问变量,首先搜索它自己的词法环境(空的),然后是makeCounter()函数创建的词法环境,最后是全局环境。当其查询到count,在makeCounter()函数创建的词法环境中,即最近的外部词法环境。注意这里的内存管理方式,当之前makeCounter()函数调用完成,它的词法环境被驻留在内存中,因为有嵌套的[[Environment]]引用它。通常,只有有其他函数引用它,词法环境对象即被驻留,反之被清除。

  1. 当调用counter()函数,不仅返回count的值,也增加其值。注意修补所在地,count的值正好在其发现的词法环境中被修改。

所以,返回值是在前一步的基础上进行修改的结果,后面调用一样。

  1. 下一次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的特性。也许未来会改变,让我们运行该示例进行检查。


本文参考链接:https://blog.csdn.net/neweastsun/article/details/73478393