为了前端的深度-闭包概念与应用

11,665 阅读5分钟

总结

定义:闭包可以让一个函数访问并操作其声明时的作用域中的变量和函数,并且,即使声明时的作用域消失了,也可以调用

应用:

  1. 私有变量
  2. 回调与计时器
  3. 绑定函数上下文
  4. 偏应用函数
  5. 函数重载:缓存记忆、函数包装
  6. 即时函数:独立作用域、简洁代码、循环、类库包装、通过参数限制作用域内的名称

前言

最近忙着公司的项目,没有时间去继续面试受虐,只抽空读了一遍《javascript 忍者秘籍》。

今天晚上有点焦虑失眠,就干脆写一篇自己总结的闭包知识。

内容基本全部来自忍者秘籍,觉得写的好的话,可以仔细再看一遍书;觉得写的不好的,可能是因为我理解不到位,导致文中自己思考的地方出了差错,也可能是我省略了书中的循序渐进,导致漏掉一些知识点。各种原因,都请指正。

正文

看了很多文章,都在说闭包的定义和闭包的优缺点。我呢,再加上闭包的应用吧。

闭包的定义很多文章里都有,我记得有一种角度说只要能访问外部变量的就是闭包,还有一种角度所有函数都是闭包。

我觉得这些回答是正确的,但是不太方便面试官继续问下去,或者说是不好引导面试官。所以,如果是我在面试,我会用忍者秘籍里的定义:闭包是一个函数在创建时允许该自身函数访问并操作该自身函数之外的变量时所创建的作用域。这个还有点绕口,更清晰的版本是:闭包可以让一个函数访问并操作其声明时的作用域中的变量和函数,并且,即使声明时的作用域消失了,也可以调用。要注意的是:闭包不是在创建的那一时刻点的状态的快照,而是一个真实的封装,只要闭包存在,就可以对其进行修改。

最简单的闭包:

// 全局作用于就是一个闭包
var outerVal = 'lionel';
function outerFn(){
  console.log(outerVal)
}
outerFn() // lionel

复杂点的,也是我们印象中的:

var outerVal = 'lionel';
var later;
function outerFn(){
  var innerVal = 'karma';
  function innerFn(){
    console.log(outerVal, innerVal);
  }
  later = innerFn;
}
outerFn();  // 此时outerFn的作用域已经消失了
later();  // lionel karma

难以理解的,这个例子我们可以理解到,闭包不是快照:

var later;
function outerFn(){
  function innerFn(){
    console.log(lateVal)
  }
  later = innerFn;
}
console.log(lateVal); // undefined
var lateVal = 'lionel'; // 变量提升,闭包声明的那一刻存在这个变量
outerFn();
later(); // lionel

缺点大家很熟悉了,闭包里的信息会一直保存在内存里。解决方法是,在你觉得可以的地方,清除引用,像上面的例子中,使用 later = null 即可,这样就可以在下次垃圾回收中,清除闭包。

下面我们重点来看一下闭包的实际应用

一、私有变量

闭包常见的用法,封装私有变量。用户无法直接获取和修改变量的值,必须通过调用方法;并且这个用法可以创建只读的私有变量哦。我们从下面的例子来理解:

function People(num) { // 构造器
  var age = num;
  this.getAge = function() {
    return age;
  };
  this.addAge = function() {
    age++;
  };
}
var lionel = new People(23); // new方法会固化this为lionel哦
lionel.addAge();
console.log(lionel.age);      // undefined
console.log(lionel.getAge()); // 24
var karma = new People(20);
console.log(karma.getAge()); // 20

如下图,lionel中并不存在age属性,age只存在new的那个过程的作用域中,并且,getAge和addAge中,我们可以看到他们的作用域中都包含一个People的闭包。

alt

二、回调和计时器

这部分我没有多聊的,

三、绑定函数上下文

刚看到这个应用可能有点懵,仔细想想其实我们看到很多次了,那就是bind()函数的实现方式,这里再贴一次简单实现的代码:

Function.prototype.myBind = function() {
  var fn = this,
      args = [...arguments],
      object = args.shift();
  return function() {
    return fn.apply(object, args.concat(...arguments))
  }
}

这里要注意的是:bind()并不是apply和call的替代方法。该方法的潜在目的是通过匿名函数和闭包控制后续执行上下文。

四、偏应用函数

偏应用函数返回了一个含有预处理参数的函数,以便后期可以调用。具体还是看代码吧

Function.prototype.partial = function() {
  var fn = this,
      args = [...arguments];
  return function() {
    var arg = 0;
    var argsTmp = [...args]
    for (var i=0; i<argsTmp.length && arg < arguments.length; i++) {
      if (argsTmp[i] === undefined) {
        argsTmp[i] = arguments[arg++]
      }
    }
    return fn.apply(this, argsTmp)
  }
}
function addAB(a ,b) {
  console.log( a + b);
}
var hello = addAB.partial('hello ', undefined);
hello('lionel'); // hello lionel
hello('karma'); // hello karma
var bye = addAB.partial(undefined, ' bye')
bye('lionel'); // lionel bye
bye('karma'); // karma bye

上面的例子可能有点难以理解,下面是一个简化版的例子:

function add(a) {
  return function(b) {
    console.log( a + b);
  };
}
var hello = add('hello ')
hello('lionel'); // hello lionel
hello('karma'); // hello karma

emmm... 写到这里去研究了半天柯里化和偏函数的区别,最终找到一篇文章符合我的想法:偏函数与函数柯里化,不对的地方请指正。

五、函数重载

1 缓存记忆

我们可以通过闭包来包装一个函数,,从而让调用我们函数的人,不知道我们采用了缓存的方法,或者说,不需要调用者额外做什么,就可以缓存计算结果,如下代码

Function.prototype.memoized = function(key) {
  this._values = this._values || {};
  return this._values[key] !== undefined ?
    this._values[key] + ' memoized' :
    this._values[key] = this.apply(this, arguments);
}
Function.prototype.memoize = function() {
  var fn = this;
  return function() {
    // return fn.memoized.apply(fn, arguments);
    console.log(fn.memoized.apply(fn, arguments))
  }
}
var computed = (function(num){
  // 这里有超级超级复杂的计算,耗时特别久
  console.log('----计算了很久-----')
  return 2
}).memoize();
computed(1); // ----计算了很久-----     2
computed(1); // 2 memoized

2 函数包装

下面的这个例子写的没有书里的好。

function wrap(object, method, wrapper){
  var fn = object[method];
  return object[method] = function() {
    return wrapper.apply(this, [fn.bind(this)].concat(...arguments))
  }
}
let util = {
  reciprocal: function(tag){
    console.log(1 / tag)
  }
}

wrap(util, 'reciprocal', function(original, tag){
   return tag == 0 ? 0 : original(tag)
})

util.reciprocal(0);  // 0

六、即时函数

针对为什么即时函数会放在闭包里介绍,下图是一个很好的说明:

alt

1 独立作用域

var button = $('#mybtn');
(function(){
  var numClicks = 0;
  button.click = function(){
    alert(++numClicks)
  }
})

2 简洁代码

// 例如有如下data
data = {
  a: {
    b: {
      c: {
        get: function(){},
        set: function(){},
        add: function(){}
      }
    }
  }
}
// 第一种调用这三个方法的代码如下, 繁琐
data.a.b.c.get();
data.a.b.c.set();
data.a.b.c.add();
// 第二种方法如下, 引入多余变量
var short = data.a.b.c;
short.get();
short.set();
short.add();
// 第三种使用即时函数 优雅
(function(short){
  short.get();
  short.set();
  short.add();
})(data.a.b.c)

3 循环

这部分是经典的for循环中调用setTimeout打印i,之所以打印i为固定值,是因为闭包并不是快照,而是变量的引用,在执行到异步队列时,i已经改变。

解决方法就是再用一个闭包和即时函数。

4 类库包装

// 下方的代码展示了,为什么jquery库中,它可以放心的用jquery而不担心这个变量被替换
(function(){
  var jQuery = window.jQuery = function() {
    // Initialize
  };
  // ...
})()

5 通过参数限制作用域内的名称

// 当我们担心jquery中的$符号,被其他库占用,导致我们代码出问题的时候,
// 用下面的方法,就可以放心大胆的用啦(不过要注意:如果jQuery也被占用的话就...)
(function($){
  $.post(...)
})(jQuery)