谈谈闭包

147 阅读6分钟

能够访问其他函数内部变量的函数,被称为 闭包。 上面这个定义比较难理解,简单来说,闭包就是函数内部定义的函数,被返回了出去并在外部调用。我们可以用代码来表述一下:

function foo() {
  var a = 2;

  function bar() {
    console.log( a );
  }

  return bar;
}

var baz = foo();

baz(); // 这就形成了一个闭包

我们可以简单剖析一下上面代码的运行流程:

  1. 编译阶段,变量和函数被声明,作用域即被确定。
  2. 运行函数 foo(),此时会创建一个 foo 函数的执行上下文,执行上下文内部存储了 foo 中声明的所有变量函数信息。
  3. 函数 foo 运行完毕,将内部函数 bar 的引用赋值给外部的变量 baz ,此时 baz 指针指向的还是 bar ,因此哪怕它位于 foo 作用域之外,它还是能够获取到 foo 的内部变量。
  4. baz 在外部被执行,baz 的内部可执行代码 console.log 向作用域请求获取 a 变量,本地作用域没有找到,继续请求父级作用域,找到了 foo 中的 a 变量,返回给 console.log,打印出 2

闭包的执行看起来像是开发者使用的一个小小的 “作弊手段” ——绕过了作用域的监管机制,从外部也能获取到内部作用域的信息。闭包的这一特性极大地丰富了开发人员的编码方式,也提供了很多有效的运用场景。

闭包的应用场景

闭包的应用,大多数是在需要维护内部变量的场景下。

单例模式

单例模式是一种常见的涉及模式,它保证了一个类只有一个实例。实现方法一般是先判断实例是否存在,如果存在就直接返回,否则就创建了再返回。单例模式的好处就是避免了重复实例化带来的内存开销:

// 单例模式
function Singleton(){
  this.data = 'singleton';
}

Singleton.getInstance = (function () {
  var instance;
    
  return function(){
    if (instance) {
      return instance;
    } else {
      instance = new Singleton();
      return instance;
    }
  }
})();

var sa = Singleton.getInstance();
var sb = Singleton.getInstance();
console.log(sa === sb); // true
console.log(sa.data); // 'singleton'

模拟私有属性

javascript 没有 java 中那种 public private 的访问权限控制,对象中的所用方法和属性均可以访问,这就造成了安全隐患,内部的属性任何开发者都可以随意修改。虽然语言层面不支持私有属性的创建,但是我们可以用闭包的手段来模拟出私有属性:

// 模拟私有属性
function getGeneratorFunc () {
  var _name = 'John';
  var _age = 22;
    
  return function () {
    return {
      getName: function () {return _name;},
      getAge: function() {return _age;}
    };
  };
}

var obj = getGeneratorFunc()();
obj.getName(); // John
obj.getAge(); // 22
obj._age; // undefined
复制代码

柯里化

柯里化(currying),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

这个概念有点抽象,实际上柯里化是高阶函数的一个用法,javascript 中常见的 bind 方法就可以用柯里化的方法来实现:

Function.prototype.myBind = function (context = window) {
    if (typeof this !== 'function') throw new Error('Error');
    let selfFunc = this;
    let args = [...arguments].slice(1);
    
    return function F () {
        // 因为返回了一个函数,可以 new F(),所以需要判断
        if (this instanceof F) {
            return new selfFunc(...args, arguments);
        } else  {
            // bind 可以实现类似这样的代码 f.bind(obj, 1)(2),所以需要将两边的参数拼接起来
            return selfFunc.apply(context, args.concat(arguments));
        }
    }
}
复制代码

柯里化的优势之一就是 参数的复用,它可以在传入参数的基础上生成另一个全新的函数,来看下面这个类型判断函数:

function typeOf (value) {
    return function (obj) {
        const toString = Object.prototype.toString;
        const map = {
            '[object Boolean]'	 : 'boolean',
            '[object Number]' 	 : 'number',
            '[object String]' 	 : 'string',
            '[object Function]'  : 'function',
            '[object Array]'     : 'array',
            '[object Date]'      : 'date',
            '[object RegExp]'    : 'regExp',
            '[object Undefined]' : 'undefined',
            '[object Null]'      : 'null',
            '[object Object]' 	 : 'object'
        };
        return map[toString.call(obj)] === value;
    }
}

var isNumber = typeOf('number');
var isFunction = typeOf('function');
var isRegExp = typeOf('regExp');

isNumber(0); // => true
isFunction(function () {}); // true
isRegExp({}); // => false
复制代码

通过向 typeOf 里传入不同的类型字符串参数,就可以生成对应的类型判断函数,作为语法糖在业务代码里重复使用。

闭包的问题

从上面的介绍中我们可以得知,闭包的使用场景非常广泛,那我们是不是可以大量使用闭包呢?不可以,因为闭包过度使用会导致性能问题,还是看之前演示的一段代码:

function foo() {
  var a = 2;

  function bar() {
    console.log( a );
  }

  return bar;
}

var baz = foo();

baz(); // 这就形成了一个闭包
复制代码

乍一看,好像没什么问题,然而,它却有可能导致 内存泄露

我们知道,javascript 内部的垃圾回收机制用的是引用计数收集:即当内存中的一个变量被引用一次,计数就加一。垃圾回收机制会以固定的时间轮询这些变量,将计数为 0 的变量标记为失效变量并将之清除从而释放内存。

上述代码中,理论上来说, foo 函数作用域隔绝了外部环境,所有变量引用都在函数内部完成,foo 运行完成以后,内部的变量就应该被销毁,内存被回收。然而闭包导致了全局作用域始终存在一个 baz 的变量在引用着 foo 内部的 bar 函数,这就意味着 foo 内部定义的 bar 函数引用数始终为 1,垃圾运行机制就无法把它销毁。更糟糕的是,bar 有可能还要使用到父作用域 foo 中的变量信息,那它们自然也不能被销毁... JS 引擎无法判断你什么时候还会调用闭包函数,只能一直让这些数据占用着内存。

这种由于闭包使用过度而导致的内存占用无法释放的情况,我们称之为:内存泄露。

内存泄露

内存泄露 是指当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者内存池的现象。内存泄漏可能会导致应用程序卡顿或者崩溃。

造成内存泄露的原因有很多,除了闭包以外,还有 全局变量的无意创建。开发者的本意是想将变量作为局部变量使用,然而忘记写 var 导致变量被泄露到全局中:

function foo() {
    b = 2;
    console.log(b);
}

foo(); // 2

console.log(b); // 2
复制代码

还有 DOM 的事件绑定,移除 DOM 元素前如果忘记了注销掉其中绑定的事件方法,也会造成内存泄露:

const wrapDOM = document.getElementById('wrap');
wrapDOM.onclick = function (e) {console.log(e);};

// some codes ...

// remove wrapDOM
wrapDOM.parentNode.removeChild(wrapDOM);

内存泄露的解决方案

  1. 使用严格模式,避免不经意间的全局变量泄露:

    "use strict";
    
    function foo () {
    	b = 2;
    }
    
    foo(); // ReferenceError: b is not defined
    复制代码
    
  2. 关注 DOM 生命周期,在销毁阶段记得解绑相关事件:

    const wrapDOM = document.getElementById('wrap');
    wrapDOM.onclick = function (e) {console.log(e);};
    
    // some codes ...
    
    // remove wrapDOM
    wrapDOM.onclick = null;
    wrapDOM.parentNode.removeChild(wrapDOM);
    复制代码
    

    或者可以使用事件委托的手段统一处理事件,减少由于事件绑定带来的额外内存开销:

    document.body.onclick = function (e) {
        if (isWrapDOM) {
            // ...
        } else {
            // ...
        }
    }
    复制代码
    
  3. 避免过度使用闭包。

大部分的内存泄漏还是由于代码不规范导致的。代码千万条,规范第一条,代码不规范,开发两行泪。