闭包

闭包的原理

  说闭包之前,需要先说一下变量作用域。在JS中作用域分为全局作用域和局部作用域,决定了变量和函数的可见范围。定义在函数内部的变量一般情况下只对当前所在函数可见,在函数外部是无法读取到函数内部的变量的。不过 JavaScript 是门神奇的语言,若是在函数内没有声明一个变量便对其赋值的话,实际上会默认将该变量声明为全局变量。而作用域链,是指在函数内部查找某一个变量的时候,先会在当前函数内寻找查找,若是找不到则会循着它的父级外部函数一直向上,直到找到该值或是到全局环境为止,也就是说外部父级函数和全局变量对某一函数而言都是可见的,这就形成了一条作用域链。而且在 Javascript 中,函数是一等公民,即函数可以被赋值,可以作为参数传递,也可以作为函数结果被 return 回去。根据这几点,我们就可以设置出一种方法,使得在函数外部依旧可以使用到函数内部的变量(这种需求也还是很常见的),而这种方法,就是闭包。

  我对闭包的理解是,在一个函数中使用了一个内部函数,而且在这个内部函数中使用到了外部函数的变量。在一般情况下,函数一旦执行完毕其内部的变量就会被销毁无法再被访问到,但通过返回一个函数(或者让一个全局变量接受这个函数,所以并不是只有返回函数才算是闭包)就可以使得该函数作用域链上可见的变量在函数外依旧可见,而我们只要在函数外使用一个变量来接受该返回结果就可以延长它们的生命周期了。这也就是闭包的两个作用:

  1. 可以在函数外部读取到函数内部的变量

  2. 让这些变量的值始终保持在内存中。因为闭包被赋予一个全局变量后始终在内存中(对于全局变量,垃圾回收机制不知道什么时候应该回收它们),而闭包的存在依赖于其外部函数,所以该外部函数即使在调用结束后也不会被垃圾回收机制回收,始终都保留在内存中。

  因此使用闭包很消耗内存,不利用于性能优化,糟糕时还可能会造成内存泄漏。所以要慎用!先举个栗子吧。

function add(x) {
  return function(y) {
    return x + y;
  };
}
var add5 = add(5);
var add10 = add(10);
console.log(add5(2));  // 7
console.log(add10(2)); // 12
// 释放对闭包的引用
add5 = null;
add10 = null;

  在 add 的内部函数里使用到了外部变量 x,x 的状态被保留了会一直存在内存中直至闭包被销毁。
  再看看下面这个栗子:

var name = "global";
var obj = {
  name: "local",
  getName: function() {
    return function() {
      return this.name;
    }
  }
}
console.log(obj.getName()());   // global

  当我们调用obj.getName()()的时候,这时候函数其实是在全局作用域中进行的,this 自然就指向了全局对象。我们可以在 getName 中绑定 this 指向来避免这个问题, var _this = this 即可。或者直接使用箭头函数绑定 this,箭头函数中的 this 固定指向了函数被定义时的函数上下文环境。

闭包的应用

封装对象的私有属性和私有方法。

function fn(initial) {
  var num = initial || 0;
  function getNum() {
    return num;
  }
  function setNum() {
    num++;
  }
  return {
    getNum: getNum,
    setNum: setNum
  }
} 
var counter = fn(10);
console.log(counter.getNum());    // 10
counter.setNum();
console.log(counter.getNum());    // 11

  通过闭包我们可以模仿 C++ 里面类的私有成员,使其在函数外部只能通过函数返回的对象来操纵,保持数据的私有性。而且每一次调用都是在前面一次调用的基础上进行的,变量的内容会被保留。

绑定循环变量

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

  上面的代码中我们本来想着会依次打印 1、2、3、4、5 的,但实际上它打印出来的是 5 个 6。这是因为当开始执行定时器中的代码时,此时 for 循环已经运行完毕了,循环变量 i 也变成了 6。所以定时器的回调函数再去取 i 的值就只能取到当前的值 6 了。对此解决办法大致有以下几种:

  • 使用 let 代替 var 声明 循环变量 i。原理是利用 let 在每一趟循环中都会生成一个块级作用域,这样执行定时器的回调函数时取到的 i 值就是这个块级作用域中相应的 i 值了。

  • 给定时器传入第三个参数, 作为 timer 函数的函数参数。

for(var i=1; i<=5; i++) {
  setTimeout(function timer(j) {
    console.log(j)
  }, 0, i)
}
  • 使用闭包,原理是利用闭包可以读取到外部函数的变量并将其保留在内存中,使 i 的值可以被记忆住。
for(var i=1; i<=5; i++){
  (function(j) {
    setTimeout(function timer() {
      console.log(j)
    }, 0)
  })(i)
}

我还看到一种方法,是使用 forEach 来循环的,不过我也不知道原理是啥,暂且贴一下代码。

[1, 2, 3, 4, 5].forEach(val => {
  setTimeout(function timer() {
    console.log(val);
  }, 0)
})

一道有关闭包的题目:

function fun(n,o){
  console.log(o);
  return {
    fun: function(m){
      return fun(m,n);
    }
  };
}

var a = fun(0);  // undefined
a.fun(1);        // 0      
a.fun(2);        // 0
a.fun(3);        // 0

var b = fun(0).fun(1).fun(2).fun(3);  // undefined, 0, 1, 2

var c = fun(0).fun(1);  // undefined, 0
c.fun(2);        // 1
c.fun(3);        // 1

  我第一次做这道题的时候做错了,逻辑没理清过来,现在再说说思路。

  对于变量 a 部分。首先使调用了 fun 函数,打印 形参 o 的时候因为只传进去了一个实参,所以 o 为 undefined,接着返回了一个对象并赋值给变量 a。a.fun(1) 是调用 a 对象里的 fun 方法,返回执行外层 fun 函数的结果。因为运用了闭包,所以可以读取到之前调用外层 fun 函数时的形参 n(即 0),此时 1 和 0 分别作为实参 m、n 一起传递给了外层 fun 函数后,先是打印出形参 o 也就是 0,接着再返回一个对象。但由于没有用一个变量接受这个返回的对象也不是链式调用,所以返回的结果是没用了的,所以后面的a.fun(2)a.fun(3)的运行过程是和a.fun(1)一样的,只是参数 m 变了而已,要注意的是参数 n 还是用的原来变量 a 保存好的 0。

  对于变量 b 部分。首先是调用了 fun 函数,依旧打印出了 undefined 后返回一个对象。接着在返回的对象基础上调用fun(1),也还是打印出形参 0 返回一个对象,到此处执行的操作和之前的 a 一样的。不同的是,因为这里是链式调用,fun(2)的执行是在fun(1)返回的对象的基础上进行的,也就是参数 n 变成了之前fun(1)保留下来的 1 了,所以这时候再打印 0 就打印出了 1 。fun(3)也还是在fun(2)的基础上操作,所以打印出的是 2。

  对于变量 c 部分。fun(1)是在fun(0)的基础上调用后才把返回的对象赋值给变量 c 的,所以变量 c 保存的参数 n 是 1 而不是 0,因此fun(2)打印的也自然是 1 了,而后的fun(3)依旧是在变量 c 的基础上调用,所以打印出来的也是 1。

  其实只要好好分析它们前后的调用关系,这道题也不会像看上去那么复杂的。

函数柯里化

  什么是函数柯里化?其实可以简单地理解为:只传递给函数一部分参数来调用它,再返回一个函数去处理剩下的参数。我们先看个简单的栗子就大概知道了。

function add(a) {
  return function(b) {
    return a + b;
  }
}
console.log(add(1)(2));   // 3

  在上面这个栗子中我们是先传递一个参数给函数 add,并利用闭包会保存作用域链上的变量的特性来保存这个参数,再返回一个函数来接受第二个参数,最后再一起进行计算并返回结果。

  我们通过一道题目来加深对函数柯里化的理解。

// 编写一个 add 函数使下面这几个式子都能输出正确结果。
add(1)(2,3)(4)
add(1)(2)(3)(4)
add(1,2,3,4)

  我们可以像上面那个栗子一样,通过嵌套地返回一个函数来实现,不过麻烦地是需要根据每次调用形参数目的不同来判断是否需要链式调用。这种方法比较繁琐而且通用性不高,所以我们就不使用这种方法了。我试了其他三种方法,不过都有些缺陷(没办法啊,搞了大半天还是没有得出一个完美的方法Orz)。

  • 方法一

  把每一次链式调用的参数拼成一个数组,再在最后一次链式调用时通过改写好了的 valueOf 方法 或 toString 方法来返回我们想要的结果,这里利用的是对象打印或类型转化时会根据不同的情况调用 valueOf 和 toString 方法,具体请看我另一篇文章(如果不改写 valueOf 或 toString 则默认返回函数本身)。

  缺陷在于,这种方法在 Chrome 中会在每一个结果之前打印一个 f 字符(表示函数),而且在 Firefox 中不会生效直接就打印成了函数。

function add(...args) {
  let fn = function(...arg) {
    // 收集参数,返回 fn 链式调用
    args = [...args, ...arg];
    return fn;
  }
  fn.valueOf = function() {
    return args.reduce((total, curVal) => {
      return total + curVal;
    }, 0)
  }
  return fn;
}

console.log(Number(add(1,2,3,4)));
console.log(Number(add(1)(2,3)(4)));
console.log(Number(add(1)(2)(3)(4)));

  补充:可以使用Number()强制类型转换来解决这个问题,如Number(add(1,2,3,4))

  • 方法二

  通过判断形参数目来决定是递归调用自身还是直接调用 fn 函数,而且每一次递归调用都把参数拼凑到一个数组中去,在最后调用 fn 的时候再一起进行相加求值。

function curry(fn, ...args) {
  let recur = function(...arg) {
    if(arg.length === 0) {
      return fn.apply(null, args);
    }
    else {
      args = [...args, ...arg];
      return recur;
    }
  }
  return recur;
}

let add = curry(function(...args) {
  return args.reduce((total, cur) => {
    return total + cur;
  }, 0)
});

console.log(add(1, 2, 3, 4)(10, 20)());  // 40

  缺陷在于,每一次使用都需要额外再调用一次告诉函数该调用 fn 了,否则返回的结果是函数本身。并且因为 args 是存在于 curry 函数中的,返回 recur 函数后 args 被保存了下来,而之后每次使用 add 都是在上一次的 args 的基础上进行的,所以会有一个 args 的累加问题。想要让每一次调用 add 函数都是独立的,只能把累加操作直接合并到柯里化函数中去了(目前我只想到这种办法而已)。

function add(...args) {
  let recur = function(...arg) {
    if(arg.length === 0) {
      return args.reduce((total, cur) => {
        return total + cur;
      }, 0)
    }
    else {
      args = [...args, ...arg];
      return recur;
    }
  }
  return recur;
}
  • 方法三

  这个方法是对方法二的优化,通过给 curry 函数传递一个 length 参数,根据已收集到的参数数目和 length 进行比较,从而决定是递归调用自身还是直接调用 fn 函数。

function curry(fn, length) {
  return function currying(...arg) {
    if(arg.length < length) {
      return function(...arg1) {
        return currying.apply(this, arg.concat(arg1));      
      }
    }
    else {
      return fn.apply(this, arg);
    }
  }
}
var add = curry(function(...arg) {
  return arg.reduce(function(a, b) {
    return a + b;
  })
}, 4)    // 要进行相加的个数

console.log(add(1)(2,3)(4));   // 10
console.log(add(1)(2)(3)(4));  // 10
console.log(add(1,2,3,4));     // 10
console.log(add(1,2,3));       // length 大于实际进行相加的个数,所以会打印函数本身
console.log(add(1,2,3,4)(5));  // length 小于实际进行相加的个数,而且调用fn后还有链式调用,所以报错:Uncaught TypeError: add(...) is not a function
console.log(add(1,2,3,4,5));   // length 小于实际进行相加的个数,但调用fn后没有链式调用,所以可以成功输出结果15

  缺陷在于,需要事先知道要进行相加的个数并设置为参数传递给 curry。若是length 大于实际进行相加的个数,则打印函数本身。若是小于或等于则视链式调用的次数而定(若调用 fn 后还存在链式调用则会报错,否则就能成功输出结果)。

  需要说明的是,返回的 currying 函数里如果arg.length < length的话,需要再返回一个匿名函数来调用 currying。如果直接连接 arg 和 arg1 并返回 currying 的话,第一次调用 add 函数后的 arg.length 会保留到下一次调用 add 函数上,导致第二次调用 add 函数出错。读者可以查看这里的代码进行查看,对比两种写法。

  这三种方法刚看可能有些难理解,但其实只要理解了就可以发现它们的主要原理都是:利用闭包的特性将所有的参数都集中到最后返回的函数里进行计算并返回结果!读者可以代入一个栗子去理清它的执行顺序,应该就能够明白这三种方法了。


  转载请注明: DangoSky 闭包

  目录