深入浅出FE(四)闭包Closure

358 阅读11分钟

目录

1.定义

1.1 为什么要用闭包?

1.2 闭包

2.原理

2.1 函数

2.2 词法环境

2.3 引用

2.4 闭包

3.用法

3.1 封装私有变量

3.2 延长变量生命周期

4.拓展

4.1 闭包的缺陷

4.2 Currying

4.3 经典闭包面试题

5.参考资料

1.定义

1.1 为什么要用闭包?

因为局部变量无法共享和长久的保存,而全局变量可能造成变量污染,所以我们希望有一种机制既可以长久的保存变量又不会造成全局污染。

1.2 闭包

什么是闭包,不同的人会有不同的理解,不同的书中答案都不尽相同...

《JavaScript高级程序设计》这样描述:

闭包是指有权访问另一个函数作用域中的变量的函数;

《JavaScript权威指南》这样描述:

从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。

《你不知道的JavaScript》定义:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

MDN定义:

函数与对其状态即词法环境lexical environment)的引用共同构成闭包closure)。也就是说,闭包可以从内部函数访问外部函数作用域。

winter

在JavaScript中,我们称函数对象为闭包。根据ECMA-262规范,JavaScript的函数包含一个[[scope]]属性。[[scope]]指向scope chain(ECMA-262v3)或者Lexical Environment(ECMA-262v5)。这对应于闭包的环境部分,[[scope]]中可访问的属性列表即是标识符列表,对象本身的引用则对应于环境。控制部分即是函数对象本身了。

我比较赞同MDN上的定义,相比其他都是用法的定义,MDN的定义突出了运行期的概念,比较准确(个人认为)。

2.原理

在JavaScript,函数在每次创建时生成闭包,即闭包是函数的运行期实例

2.1 函数

既然闭包是函数运行期的实例,那我们先来用静态的视角来看函数,它就是一个函数对象(函数的实例)。如果不考虑它作为对象的那些特性,那么函数也无非就是“用三个语义组件构成的实体”。这三个语义组件是指:

  • 参数:函数总是有参数的,即使它的形式参数表为空;
  • 执行体:函数总是有它的执行过程,即使是空的函数体或空语句;
  • 结果:函数总是有它的执行的结果,即使是 undefined。

我们先来看一个最简单的函数的例子:

x => x

在闭包创建时,参数 x 将作为闭包(作用域 / 环境)中的名字被初始化——这个过程中“参数 x”只作为名字或标识符,并且“将会在”闭包中登记一个名为“x”的变量;按照约定,它的值是 undefined。并且,还需要强调的是,这个过程是引擎为闭包初始化的,发生于用户代码得到这个闭包之前。

每个函数可以对应多个闭包(每次都会生成新的参数),一个闭包也可以对应多个函数(递归)。

2.2 词法环境

所谓词法环境,就是一个能够表示标识符在源代码(词法)中的位置的环境。由于源代码分块,所以词法环境就可以用“链式访问”来映射“块之间的层级关系”。但是“var 变量”突破了这个设计限制。

var x = 1;
if (true) {
  var x = 2;

  with (new Object) {
    var x = 3;
  }
}

这个示例中的“1、2、3”所在的“var 变量”x,都突破了它们所在的词法作用域(或对应的词法环境),而指向全局的x。于是,自 ECMAScript 5 开始约定,ECMAScript 的执行上下文将有两个环境,一个称为词法环境,另一个就称为变量环境(Variable Environment);所有传统风格的“var 声明和函数声明”将通过“变量环境”来管理。

在早期的 JavaScript 中,作用域与执行环境是一对一的,所以也就常常混用,而到了 ECMAScript 5 之后,有一些作用域并没有对应用执行环境,所有就分开了。在 ECMAScript 5 之后,ECMAScript 规范中就很少使用“作用域(Scope)”这个名词,转而使用“环境”这个概念来替代它。

2.3 引用

引用这个词很重要,细分可以分为“引用(规范引用,即为表达式的值)”和javascript引用(对象函数类型),但是明显的是概念中的引用指的是规范引用。

表达式的值,在 ECMAScript 的规范中,称为“引用”。在 JavaScript 的内部,所谓“引用”是可以转换为“值”,以便参与值运算的。因为表达式的本质是求值运算,所以引用是不能直接作为最终求值的操作数的。这依赖于一个非常核心的、称为“GetValue()”的内部操作。所谓内部操作,也称为内部抽象操作(internal abstract operations),是 ECMAScript 描述一个符合规范的引擎在具体实现时应当处理的那些行为。

2.4 闭包

如果你清楚了函数和词法环境的概念,那我们再继续看闭包概念。

一个典型的闭包案例:

function A(){
    let a = 1;
    return function(){
        console.log(a)
    }
}
A();

返回1,如果知道函数的机制,我们知道函数A弹出调用栈后,A中的a变量应该也释放了,但是为什么return出的函数还能继续引用到函数A中的变量呢?因为函数A中的变量a是存储在堆中的,现在的JS引擎还能通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。

再来看MDN上的一个例子,这个例子说明了闭包是函数运行期的实例,同一个函数,闭包不一样:

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2));  // 7
console.log(add10(2)); // 12

在这个示例中,我们定义了 makeAdder(x) 函数,它接受一个参数 x ,并返回一个新的函数。返回的函数接受一个参数 y,并返回x+y的值。

从本质上讲,makeAdder 是一个函数工厂 — 他创建了将指定的值和它的参数相加求和的函数。在上面的示例中,我们使用函数工厂创建了两个新函数 — 一个将其参数和 5 求和,另一个和 10 求和。

add5add10 都是闭包。它们共享相同的函数定义,但是保存了不同的词法环境。在 add5 的环境中,x 为 5。而在 add10 中,x 则为 10。

3.用法

3.1 封装私有变量

function closureDemo(){
    let a = 1;

    let getA = () => {
      return a;
    }

    let setA = (b) => {
      a = b;
    }

    return {
        getA: getA,
        setA: setA
    }
}
let c = closureDemo();
c.setA(5);
console.log(c.getA());

上面是一个稍微复杂点的例子,封装了一个私有变量a,我们可以通过c这个闭包获取函数closureDemo的引用及其词法环境,然后调用这个闭包的内部函数,操作私有变量。

3.2 延长变量生命周期

曾探的书《JavaScript设计模式与开发实践》中举了这样一个例子:一般用于错误信息或者用户信息上报的函数假设为report函数:

function report(url){
    var img = new image();
    img.src = url;
}

但是通过查询后段后发现一些低版本浏览器的实现存在bug,在这些浏览器下使用report函数进行数据上报会丢失30%的数据,也就是说,report函数并不是每一次都成功发起http请求,丢失数据的原因是因为img是report函数中的局部变量,当report函数调用结束后,img变量由于是存在栈上,所以调用结束后就销毁,而此时或许还来得及发出HTTP请求,所以请求会丢失。

我们把img变量用闭包封装起来,便能解决请求丢失问题。

var report = (function(url){
  var imgs = [];
  return function(url){
    var img = new image();
    imgs.push(img)
    img.src = url;
  }
});

4.拓展

4.1 闭包的缺陷

  • 闭包的缺点就是常驻内存会增大内存使用量,并且使用不当很容易造成内存泄露。如果将来需要回收这些闭包变量,我们需要将他们手动设为null。还有使用闭包比较容易形成循环引用,如果闭包的作用域链中保存着一些DOM节点,这时候就有可能造成内存泄漏。但这并不是闭包造成的问题,也不是js的问题。这是因为IE<8(详见参考资料4)中BOM和DOM中的对象是使用C++以COM对象的方式实现的,而COM对象的垃圾回收机制采用的是引用计数策略。在基于引用计数的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法回收,但循环引用造成的内存泄漏并不是闭包造成的(在现代浏览器中不会出现)。要解决循环引用带来的内存泄漏问题,只需要将循环引用的变量设置为null,译者着切断变量与它之前引用的值之间的连接,当垃圾回收器下次运行时,会回收他们占用的内存。

  • 如果不是因为某些特殊任务而需要闭包,在没有必要的情况下,在其它函数中创建函数是不明智的,因为闭包对脚本性能具有负面影响,包括处理速度和内存消耗。

4.2 Currying

Currying,中文译为柯里化,在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,尽管它是 Moses Schnfinkel 和 Gottlob Frege 发明的。

按照Stoyan Stefanov(《JavaScript Pattern》作者)的说法,所谓“柯里化”就是

使函数理解并处理部分应用。

Currying又称部分求值。一个currying函数首先接受一些参数,接受这些参数之后,函数不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。简单说就是接受参数直到需要结果的时候才返回结果。

那么柯里化有什么用呢?柯里化有3个常见作用:1. 参数复用2. 提前返回;3. 延迟计算/运行

因为我是做金融相关的,所以我举一个金融例子,假如我需要统计我最近的基金收益,需要将每一天的收益加起来,最后一起计算,可能有人会说,为什么不直接看最后多少钱,减去当时的钱就是收益,没必要计算,但是假设你的钱每天都有进出,所以这也算是一种比较好的理财计算方式。

那么接下来,可以写一下这个代码(参考自《JavaScript设计模式与开发实践》)

var currying = function(fn){
  var args = [];
  return function () {
    if(arguments.length === 0){
      return fn.apply(this, args);
    }else{
      [].push.apply(args, arguments);
      return arguments.callee;
    }
  }
}

var cost = (function(){
  var Income = 0;
  return function(){
    for(var i = 0; i < arguments.length; i++){
      Income += arguments[i];
    }
    return Income;
  }
})();

var cost = currying(cost);
cost(100);
cost(150);
cost(-100);
cost(130);
console.log('cost()=' + cost());

//精简版本

//ES5
function curry(fn, arr = []) {
  return fn.length === arr.length
    ? fn.apply(null, arr)
    : function(...args) {
        return curry(fn, arr.concat(args));
      };
}

//ES6
const curry = (fn, arr = []) =>
  fn.length === arr.length
    ? fn(...arr)
    : (...args) => curry(fn, [...arr, ...args]);

4.3 经典闭包面试题

题目:

for ( var i=1; i<=5; i++) {
       setTimeout( function timer() {
               console.log( i );
       }, i*1000 );
} 

解决办法:
1.var改成let,创建块作用域
2.使用立即执行函数IIF

for (var i = 1; i <= 5; i++) {
       (function(j) {
         setTimeout(function timer() {
           console.log(j);
         }, j * 1000);
       })(i);
}

3.使用 setTimeout 的第三个参数

for ( var i=1; i<=5; i++) {
       setTimeout( function timer(j) {
               console.log( j );
       }, i*1000, i);
}

5.参考资料

1. JavaScript Closures for Beginners

2. winter 闭包概念考证

3. 曾探《JavaScript设计模式与开发实践》

4. IE<8循环引用导致的内存泄露

5. 周爱民 极客时间《JavaScript核心原理解析》课程

6. MDN 闭包

7. JS中的柯里化(currying)

8. 百度百科:柯里化

9. 函数 为什么要Currying化,currying化有什么优点?