跳到主要内容

闭包

以下为多种渠道获取的 闭包 的知识,需要融会贯通~

变量作用域,闭包(javascript.info)

info
  • 如果在代码块 {...} 内声明了一个变量,那么这个变量只在该代码块内可见。for()比较特殊,其中()内声明的变量也被视为代码块的一部分。

词法环境(Lexical Environment)

在 JavaScript 中,每个运行的函数、代码块{...} 以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。词法环境对象由两部分组成:

  • 环境记录(Environment Record): 一个存储所有局部变量作为其属性(包括一些其他信息,例如 this 的值)的对象。
  • 对 外部词法环境 的引用,与外部代码相关联。

可以用伪代码理解:

LexicalEnvironment = {
local: empty | {...}, // 没有局部变量 或者 有局部变量
outer: null | {...}, // 没有外部词法环境 或者 有外部词法环境
}
tip

“词法环境”是一个规范对象(specification object):它只存在于 语言规范 的“理论”层面,用于描述事物是如何工作的。我们无法在代码中获取该对象并直接对其进行操作。

全局词法环境

  1. 当脚本开始运行,词法环境预先填充了所有声明的变量。

    最初,它们处于“未初始化(Uninitialized)”状态。这是一种特殊的内部状态,这意味着引擎知道变量,但是在用 let 声明前,不能引用它。几乎就像变量不存在一样。变量暂时无法使用的区域(从代码块的开始到 let)有时被称为“死区”。

    function func() {
    // 引擎从函数开始就知道局部变量 x,
    // 但是变量 x 一直处于“未初始化”(无法使用)的状态,直到遇到 let,此时“死区”结束
    console.log(x); // ReferenceError: Cannot access 'x' before initialization
    let x = 2;
    }
    tip

    函数声明的初始化会被立即完成(不像 let 那样直到声明处才可用)。正常来说,这种行为仅适用于以函数声明(Function Declaration)的方式声明的函数,而不适用于将函数分配给变量的函数表达式,例如 let say = function(name)...添加一个函数时全局词法环境的初始状态

  2. 然后 let phrase 定义出现了。它尚未被赋值,因此它的值为 undefined。从这一刻起,我们就可以使用变量了。

  3. phrase 被赋予了一个值。

  4. phrase 的值被修改。

内部和外部的词法环境

info
  • 在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境,以存储这个调用的局部变量和参数,以及 对 外部词法环境 的引用。

  • 通常,函数都有名为 [[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用,即 函数的 [[Environment]] 指向 该函数的外部词法环境。

  • 当代码要访问一个变量时,首先会搜索内部词法环境,然后搜索外部词法环境,然后搜索更外部的词法环境,以此类推,直到全局词法环境。如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。

let phrase = 'Hello';

function say(name) {
alert(`${phrase}, ${name}`);
}

say('John');

/*
在调用say('John')时,会创建一个词法环境(内部词法环境),以存储这个调用的局部变量和参数,即 name: 'John'
外部词法环境是全局词法环境,它存储了 phrase变量 和 say函数本身
*/

闭包

tip

闭包 是指一类函数,这类函数可以记住其外部变量并可以访问这些变量。在 JavaScript 中,所有函数都是天生闭包的(只有一个例外,即使用 new Function 创建的函数,这类函数的 [[Environment]] 并不指向函数所在的外部词法环境,而是指向全局词法环境,因此,此类函数无法访问外部(outer)变量,只能访问全局变量。)。

在变量所在的词法环境中更新变量。

例1:

  • 创建 makeCounter 函数,该函数的 [[Environment]] 属性,指向 该函数的外部词法环境,即 global LexicalEnvironment

    global LexicalEnvironment = {
    local: {
    makeCounter: function,
    counter: undefined,
    },
    outer: null,
    }
  • 在调用 makeCounter() 时,会创建一个词法环境,存储这个调用的局部变量(此时有),以及对外部词法环境(此时有)的引用(对外部词法环境的引用保存在其 [[Environment]] 属性中)。

    LexicalEnvironment of makeCounter() call = {
    local: {
    count: 0,
    },
    outer: global LexicalEnvironment,
    }
  • 调用 makeCounter() 时,创建了一个匿名函数,该函数的 [[Environment]] 属性,指向 该函数的外部词法环境,即LexicalEnvironment of makeCounter() call

  • 在调用 counter() 时,会创建一个词法环境,存储这个调用的局部变量(此时没有),以及对外部词法环境(此时有)的引用(对外部词法环境的引用保存在其 [[Environment]] 属性中)。

    LexicalEnvironment of counter() call = {
    local: empty,
    outer: LexicalEnvironment of makeCounter() call,
    }
  • 当执行 counter() 查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后搜索其外部词法环境,并且在哪里找到就在哪里修改。所以调用 counter() 多次,count 变量将在同一位置增加到 2,3 等。

  • 函数 counter 和 counter2 具有独立的外部词法环境,每次调用 makeCounter() 会创建一个新的词法环境。

例2:

shooters数组看起来是这样的:

shooters = [
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); },
function () { console.log(i); }
];

army的每个元素都是一个函数,函数的每次调用都会创建一个新的词法环境({ local: empty, outer: while iteration LexicalEnvironment }; while iteration LexicalEnvironment = { local: empty, outer: makeArmy() LexicalEnvironment })。当一个这样的函数被调用时,由于函数内没有局部变量 i,所以i 来自于外部词法环境,即 makeArmy() 的词法环境。因此,所有这样的函数获得的都是外部词法环境中的同一个值,即最后的 i=10。要解决此问题,可以将 i 的值复制到 while {...} 块内的变量中,如下所示:

let j = i 声明了一个“局部迭代”变量 j,并将 i 复制到其中。原始类型是“按值”复制的,因此实际上我们得到的是属于当前循环迭代的独立的 i 的副本。

使用 for 循环,也可以避免这样的问题,这本质上是一样的,因为 for 循环在每次迭代中,都会生成一个带有自己的变量 i 的新词法环境。因此,在每次迭代中生成的 shooter 函数引用的都是自己的 i。

闭包(红宝书4)

tip
  • 闭包是 引用了另一个函数作用域中的变量的 函数,通常是在嵌套函数中实现的。
  • 理解作用域链创建和使用的细节对理解闭包非常重要。

作用域链

  • 函数执行时,每个执行上下文中都会有一个包含该执行上下文中的变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。
  • 在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。然后用 arguments 和其他命名参数来初始化这个函数的活动对象。
  • 外部函数的活动对象是内部函数作用域链上的第二个对象。
  • 这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。
  • 作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。
function compare(value1, value2) { 
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
}

let result = compare(5, 10);
  • 如上定义的compare()函数是在全局上下文中调用的。
  • 第一次调用compare()时,会为它创建一个包含argumentsvalue1value2 的活动对象,这个对象是其作用域链上的第一个对象。
  • 全局上下文的变量对象则是compare()作用域链上的第二个对象,其中包含thisresultcompare作用域链上的对象

匿名函数的作用域链

function createComparisonFunction(propertyName) { 
return function(object1, object2) { // 内部函数(匿名函数)
let value1 = object1[propertyName];
let value2 = object2[propertyName];

if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
};
}
let compare = createComparisonFunction('name');
let result = compare({ name: 'Nicholas' }, { name: 'Matt' });

// 解除对函数的引用,这样就可以释放内存了
compare = null;
  • 如上内部函数(匿名函数)中,引用了外部函数的变量propertyName,这个内部函数被返回并在其他地方被使用后,它仍然引用着那个变量。这是因为内部函数的作用域链包含createComparisonFunction()函数的作用域。

  • 在函数A内部定义的函数B 会把函数A的活动对象 添加到函数B的作用域链中。

内部函数的作用域链

  • createComparisonFunction()的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍然有对它的引用。在 createComparisonFunction()执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留在内存中,直到匿名函数被销毁后才会被销毁(如上面的compare = null;)
tip

因为闭包会保留外层函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的JavaScript 引擎会 努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。

闭包中的this

  • 如果内部函数没有使用箭头函数定义,则this 对象会在运行时绑定到执行函数的上下文。
window.identity = 'The Window';

let object = {
identity: 'My Object',
getIdentityFunc() {
return function() {
return this.identity;
};
}
};

console.log(object.getIdentityFunc()()); // 'The Window'
  • 每个函数在被调用时都会自动创建两个特殊变量:thisarguments。内部函数永远不可能直接访问外部函数的这两个变量。但是,如果把this 保存到闭包可以访问的另一个变量中,则是行得通的。
window.identity = 'The Window'; 

let object = {
identity: 'My Object',
getIdentityFunc() {
let that = this;
return function() {
return that.identity;
};
}
};

console.log(object.getIdentityFunc()()); // 'My Object'
tip

thisarguments 都是不能直接在内部函数中访问的。如果想访问包含作用域中的arguments 对象,则同样需要将其引用先保存到闭包能访问的另一个变量中。

  • 在一些特殊情况下,this 值可能并不是我们所期待的值。
window.identity = 'The Window'; 
let object = {
identity: 'My Object',
getIdentity () {
return this.identity;
}
};
object.getIdentity(); // 'My Object'
(object.getIdentity)(); // 'My Object'
(object.getIdentity = object.getIdentity)(); // 'The Window'
  • 第一行调用 object.getIdentity() 是正常调用,会返回"My Object",因为 this.identity 就是 object.identity
  • 第二行在调用时把 object.getIdentity 放在了括号里。虽然加了括号之后看起来是对一个函数的引用,但 this 值并没有变。这是因为按照规范,object.getIdentity(object.getIdentity) 是相等的。
  • 第三行执行了一次赋值,然后再调用赋值后的结果。因为赋值表达式的值是函数本身,this 值不再与任何对象绑定,所以返回的是"The Window"。

内存泄漏

由于IE 在IE9 之前对JScript 对象和COM 对象使用了不同的垃圾回收机制,所以闭包在这些旧版本IE 中可能会导致问题。在这些版本的IE 中,把HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。

function assignHandler() {  
let element = document.getElementById('someElement');
element.onclick = () => console.log(element.id);
}

以上代码创建了一个闭包,即 element 元素的事件处理程序,而这个处理程序又创建了一个循环引用,即 匿名函数引用着 assignHandler()的活动对象,这样就阻止了对 element 的引用计数归零。只要这个匿名函数存在,element 的引用计数就至少等于 1。也就是说,内存不会被回收。

function assignHandler() {  
let element = document.getElementById('someElement');
let id = element.id;

element.onclick = () => console.log(id);

element = null;
}

在这个修改后的版本中,闭包改为引用一个保存着element.id 的变量id,从而消除了循环引用。不过,光有这一步还不足以解决内存问题。因为闭包还是会引用包含函数的活动对象,而其中包含element。即使闭包没有直接引用element,包含函数的活动对象上还是保存着对它的引用。因此,必须再把element 设置为null。这样就解除了对这个COM 对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。

闭包(JS设计模式与开发实践)

tip

闭包的形成与变量的作用域以及变量的生存周期密切相关。

变量的作用域

  • 变量的作用域,就是指变量的有效范围。
  • 在 JavaScript 中,函数可以用来创造函数作用域。在函数里面可以看到外面的变量,而在函数外面则无法看到函数里面的变量。这是因为当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象为止。变量的搜索是从内到外而非从外到内的。

变量的生存周期

  • 全局变量的生存周期是永久的,除非我们主动销毁这个全局变量。

  • 对于在函数内用var 关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数调用的结束而被销毁。

  • 以下代码中,当退出函数后,局部变量a 并没有消失,而是似乎一直在某个地方存活着。这是因为当执行var f = func();时,返回了一个匿名函数的引用,它可以访问到func()被调用时产生的环境,而局部变量a 一直处在这个环境里。既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。

  • 在闭包的帮助下,把每次循环的i 值都封闭起来。当在事件函数中顺着作用域链中从内到外查找变量i 时,会先找到被封闭在闭包环境中的i

    闭包应用场景

闭包的作用

封装变量

闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”。

如上,cache 这个变量仅仅在mult 函数中被使用,与其让cache 变量跟mult 函数一起平行地暴露在全局作用域下,不如把它封闭在 mult 函数内部,这样可以减少页面中的全局变量,以避免这个变量在其他地方被不小心修改而引发错误。如果在一个大函数中有一些代码块能够独立出来,我们常常把这些代码块封装在独立的小函数里面。独立出来的小函数有助于代码复用,如果这些小函数有一个良好的命名,它们本身也起到了注释的作用。如果这些小函数不需要在程序的其他地方使用,最好是把它们用闭包封闭起来。

延续局部变量的寿命

局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。

闭包写法:

面向对象写法:

闭包与内存管理

  • 使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量放在闭包中和放在全局作用域,对内存方面的影响是一致的,这里并不能说成是内存泄露。如果在将来需要回收这些变量,我们可以手动把这些变量设为null

  • 使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些DOM 节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非 JavaScript 的问题。(在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。同样,如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为null即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。)

JavaScript Closures(javascripttutorial.net)

tip

In JavaScript, a closure is a function that references variables in the outer scope from its inner scope. The closure preserves the outer scope inside its inner scope. 一个闭包是一个函数,它从其内部作用域引用外部作用域中的变量。闭包将外部作用域保留在其内部作用域内。

1. Lexical scoping 词法作用域

tip

Lexical scoping defines the scope of a variable by the position of that variable declared in the source code. 词法作用域 通过变量在代码中声明的位置 来定义变量的作用域。

JavaScript engine uses the scope to manage the variable accessibility.

let name = 'John';

function greeting() {
let message = 'Hi';
console.log(message + ' '+ name);
}
  • The variable name is a global variable. It is accessible from anywhere including within the greeting()function.
  • The variable message is a local variable that is accessible only within the greeting() function.
  • If you try to access the message variable outside the greeting() function, you will get an error.

According to lexical scoping, the scopes can be nested and the inner function can access the variables declared in its outer scope. 根据词法作用域,作用域可以嵌套,内部函数可以访问在其外部作用域中声明的变量。

function greeting() {
let message = 'Hi';

function sayHi() {
console.log(message);
}

sayHi();
}

greeting();
  • The greeting() function creates a local variable named message and a function named sayHi().
  • The sayHi() is the inner function that is available only within the body of the greeting() function.
  • The sayHi() function can access the variables of the outer function such as the message variable of the greeting() function.
  • Inside the greeting() function, we call the sayHi() function to display the message Hi.

2. JavaScript closures 闭包

function greeting() {
let message = 'Hi';

function sayHi() {
console.log(message);
}

return sayHi;
}
// assigned the hi variable the value returned by the greeting() function, which is a reference of the sayHi() function. 为 hi 变量分配了 greeting() 函数返回的值,这是 sayHi() 函数的引用。
let hi = greeting();
hi(); // still can access the message variable

通常情况下,局部变量仅在函数执行期间存在。这意味着当 greeting() 函数执行完毕后,message变量将无法再访问。在这个例子中,我们执行引用 sayHi() 函数的 hi() 函数,message变量仍然存在。sayHi() 函数是一个闭包。

A closure is a function that preserves the outer scope in its inner scope.

function greeting(message) {
return function(name) {
return message + ' ' + name;
}
}
let sayHi = greeting('Hi');
let sayHello = greeting('Hello');

console.log(sayHi('John')); // Hi John
console.log(sayHello('John')); // Hello John
  • The greeting() function behaves like a function factory. It creates sayHi() and sayHello() functions.
  • The sayHi() and sayHello() are closures. They share the same function body but store different scopes.
  • In the sayHi() closure, the message is Hi, while in the sayHello() closure the message is Hello.

3. JavaScript closures in a loop

  • The reason you see the same message after 4 seconds is that the callback passed to the setTimeout() a closure. It remembers the value of i from the last iteration of the loop, which is 4. 您在 4 秒后看到相同消息的原因是回调传递给 setTimeout() 一个闭包。它会记住循环最后一次迭代中 i 的值,即 4。

  • In addition, all three closures created by the for-loop share the same global scope access the same value of i. 此外,for 循环创建的所有三个闭包共享相同的全局作用域访问相同的 i 值。

To fix this issue, you need to create a new closure scope in each iteration of the loop. 要解决此问题,您需要在循环的每次迭代中创建一个新的闭包作用域。There are two popular solutions: IIFE & let keyword.

  1. Using the IIFE solution

In this solution, you use an immediately invoked function expression (a.k.a IIFE) because an IIFE creates a new scope by declaring a function and immediately execute it. 在此解决方案中,您使用一个立即调用的函数表达式(又名 IIFE),因为 IIFE 通过声明一个函数并立即执行它来创建一个新的作用域。

  1. Using let keyword in ES6

If you use the let keyword in the for-loop, it will create a new lexical scope in each iteration. In other words, you will have a new index variable in each iteration.

In addition, the new lexical scope is chained up to the previous scope so that the previous value of the index is copied from the previous scope to the new one. 此外,新的词法作用域链接到先前的作用域,以便将index的先前值从先前的作用域复制到新的作用域。