闭包是 js 中一个极为 NB 的武器,但也不折不扣的成了初学者的难点。因为学好闭包就要学好作用域,正确理解作用域链,然而想做到这一点就要深入的理解函数,所以我们从函数说起。

函数的声明和调用

首先说明一下,本文基于原生 js 环境,不涉及 DOM 部分
最基本的就是函数的定义和调用,注意区分以下形式:

  1. function func(){
  2. }
  3. var func = function(){
  4. };
  5. func();
  6. var returnValue = func();
  7. (function(){
  8. })();
  9. (function(){
  10. }());

立即执行函数直接声明一个匿名函数,立即使用,省得定义一个用一次就不用的函数,而且免了命名冲突的问题。如果写为如下形式可获得立即执行函的返回值。

  1. var returnValue = (function(){return 1;}());
  2. var returnValue = (function(){return 1;})();

除此之外,函数还有一种非常常见的调用方式——回调函数。将一个函数作为参数传入另一个函数,并在这个函数内执行。比如下面这个形式

  1. document.addEventListener("click", console.log, false);

理解了上面的部分,我们看一个典型的例子,好好理解一下函数的定义和调用的关系,这个一定要分清。下面这段代码很具有代表性:

  1. var arr = [];
  2. for(var i = 0; i < 10; i++){
  3. arr[i] = function(){
  4. return i;
  5. };
  6. }
  7. for(var j = 0; j < arr.length; j++){
  8. console.log(arr[j]() + " ");
  9. }

我们需要理解这里面第一个 for 循环其实相当于如下形式,它只是定义了 10 个函数,并把函数放在数组中, 并没有执行函数。由于 js 遵循词法作用 (lexical scoping), i 是一个全局变量,所以第二个 for 循环调用函数的时候,i 等于 10

  1. var i = 0;
  2. arr[0] = function(){ return i; }; i++;
  3. arr[1] = function(){ return i; }; i++;
  4. arr[2] = function(){ return i; }; i++;
  5. arr[9] = function(){ return i; }; i++;

再讲完了闭包我们再回来解决这个问题。

关于函数的参数传递这里就不多说了,值得强调的是,上述 2 种定义函数的方式是有区别的,想理解这个区别,先要理解声明提前。

变量声明提前

这个地方简单理解一下 js 的预处理过程。js 代码会在执行前进行预处理,预处理的时候会进行变量声明提前,每个作用域的变量(用 var 声明的变量,没有用 var 声明的变量不会提前)和函数定义会提前到这个作用域内的开头。
函数中的变量声明会提前到函数的开始,但初始化不会。比如下面这个代码。因此我们应该避免在函数中间声明变量,以增加代吗的可读性。

  1. function(){
  2. console.log(a);
  3. f();
  4. function f(){
  5. console.log("f called");
  6. }
  7. var a = 3;
  8. console.log(a);
  9. }

这段代码等于 (并且浏览器也是这么做的):

  1. function(){
  2. function f(){
  3. console.log("f called");
  4. }
  5. var a;
  6. console.log(a);
  7. f();
  8. a = 3;
  9. console.log(a);
  10. }

不同函数定义方式的区别

第一个区别:

  1. function big(){
  2. func();
  3. func1();
  4. function func(){
  5. console.log("func is called");
  6. }
  7. var func1 = function(){
  8. console.log("func1 is called");
  9. };
  10. }
  11. big();

第二个区别,比较下面 2 段代码

  1. function f() {
  2. var b=function(){return 1;};
  3. function b(){return 0;};
  4. console.log(b());
  5. console.log(a());
  6. function a(){return 0;};
  7. var a=function(){return 1;};
  8. }
  9. f();

不难发现,用表达式定义的函数可以覆盖函数声明直接定义的函数;但是函数声明定义的函数却不能覆盖表达式定义的函数。
实际中我们发现,定义在调用之前var f = function(){};会覆盖function f(){}, 而定义在调用之后function f(){}会覆盖var f= function(){};(你可以以不同顺序组合交换上面代码中的行,验证这个结论)

第三个区别,其实这个算不上区别

  1. var fun = function fun1(){
  2. console.log(fun1 === fun);
  3. };
  4. fun();
  5. fun1();

此外还有一个定义方法如下:

  1. var func = new Function("alert('hello')");

这个方式不常用,也不建议使用。因为它定义的函数都是在 window 中的,更严重的是,这里的代码实在eval()中解析的,这使得这个方式很糟糕,带来性能下降和安全风险。具体就不赘述了。

词法作用域

C++ 和 Java 等语言使用的都是块级作用域,js 与它们不同,遵循词法作用域 (lexical scoping)。讲的通俗一些,就是函数定义决定变量的作用域函数内是一部分,函数外是另一部分,内部可以访问外部的变量,但外部无法直接访问内部的变量。首先我们看下面这个代码

  1. var a = 3;
  2. var b = 2;
  3. var c = 20;
  4. function f(){
  5. var a = 12;
  6. b = 10;
  7. var d = e = 15;
  8. f = 13;
  9. console.log(a + " " + b + " " + d);
  10. }
  11. f();
  12. console.log(a);
  13. console.log(b);
  14. console.log(c);
  15. console.log(d);
  16. console.log(e);
  17. console.log(f);

注:原生 js 在没有定使用义的变量时会得到 undefined,并在使用过程中遵循隐式类型转换,但现在的浏览器不会这样,它们会直接报错。不过在函数中使用滞后定义的变量依然是 undefined,不会报错,这里遵循声明提前的原则。

这是一个最基本的作用域模型。我们上文提到过,函数里面可以访问外面的变量,函数外部不能直接访问内部的变量.
我们再看一个复杂一点的:

  1. var g = "g";
  2. function f1(a){
  3. var b = "f1";
  4. function f2(){
  5. var c = "f2";
  6. console.log(a + b + c + g);
  7. }
  8. f2();
  9. }
  10. f1("g");

在 js 中,函数里面定义函数十分普遍,这就需要我们十分了解作用域链。
如下这个代码定义了下图中的作用域链:

  1. var g = 10;
  2. function f1(){
  3. var f_1 = "f1";
  4. function f2(){
  5. var f_2 = "f2";
  6. function f3(){
  7. var f_3 = "f3";
  8. }
  9. }
  10. }

函数、作用域链与闭包详解 - 图1

这里内层的函数可以由内向外查找外层的变量 (或函数),当找到相应的变量(或函数) 立即停止向外查找,并使用改变量(或函数)。而外层的函数不能访问内层的变量(或函数),这样的层层嵌套就形成了作用域链。

值得一提的是,函数的参数在作用于上相当于在函数内部第一行就声明了的变量,注意这里指的仅仅是声明,但不一定完成初始化,也就说明参数在没有传入值的时候值为 undefined。

回调函数

那么问题来了,在一个函数外部永远不能访问函数内部的变量吗?答案是否定的,我们可以用回调函数实现这个过程:

  1. function A(arg){
  2. console.log(arg);
  3. }
  4. function B(fun){
  5. var a = "i am in function B";
  6. var i = 10;
  7. fun(a);
  8. }
  9. B(A);

上面这个过程对于 B 而言,只把自己内部的变量 a 给了 fun,而外部的 A 无论如何也访问不到 B 中的 i 变量,也就是说传入的 fun 函数只能访问 B 想让它访问的变量,因此回调函数这样的设计可以在代码的隔离和开放中间取得一个极好的平衡。
说句题外话:javascript 特别适用于事件驱动编程,因为回调模式支持程序以异步方式运行。

好了,如果上面的你都看懂了,那么可以开始看闭包了。

闭包

闭包是指有权访问另一个函数作用域中的变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,通过另一个函数访问这个函数的局部变量。闭包主要是为了区分私有和公有的方法和变量,类似于 c++ 和 java 中对象的 public 成员和 protected 成员。

一言以蔽之:作用域的嵌套构成闭包!

构成闭包以下几个必要条件

  1. 函数 (作用域) 嵌套函数
  2. 函数 (作用域) 内部可以引用外部的参数和变量
  3. 参数和变量不会被垃圾回收机制回收。可以查看: 内存管理与垃圾回收

闭包的优缺点

  • 优点
  1. 希望一个变量长期驻扎在内存中(如同 c++ 中 static 局部变量)
  2. 避免全局变量的污染
  3. 私有成员的存在
  • 缺点
  1. 闭包常驻内存,会增大内存使用量,大量使用影响程序性能。
  2. 使用不当很容易造成内存泄露 (关于内存管理和垃圾回收的细节以后会专门讲一篇的)。

一般函数执行完毕后,局部活动对象就被销毁,内存中仅仅保存全局作用域。但闭包不会

为什么有闭包

我们考虑实现一个局部变量调用并自加的过程:

  1. var a = 0;
  2. function fun(){
  3. return a++;
  4. }
  5. fun();
  6. fun();
  7. fun();
  8. function func(){
  9. var a = 0;
  10. return a++;
  11. }
  12. func();
  13. func();
  14. func();

看了上面代码你会发现,当 a 是全局变量的时候可以实现,但 a 成为了局部变量就不行了,当然,必须是闭包才可以实现这个功能:

  1. var f = (function(){
  2. var a = 0;
  3. return function(){
  4. return a++;
  5. }
  6. })();
  7. f();
  8. f();
  9. f();

这样不仅实现了功能,还防止了可能的全局污染。

上文举了在循环内定义函数访问循环变量的例子,可结果并不如意,得到了十个 10,下面我们用闭包修改这个代码,使它可以产生 0~9:

  1. var arr = [];
  2. for(var i = 0; i < 10; i++){
  3. arr[i] = (function(i){
  4. return function(){
  5. return i;
  6. };
  7. })(i);
  8. }
  9. for(var j = 0; j < arr.length; j++){
  10. console.log(arr[j]());
  11. }

当然还以其他的解决方法:

  1. var arr = [];
  2. for(var i = 0; i < 10; i++){
  3. arr[i] = console.log.bind(null, i);
  4. }
  5. for(var j = 0; j < arr.length; j++){
  6. console.log(arr[j]());
  7. var arr = [];
  8. for(let i = 0; i < 10; i++){
  9. arr[i] = function(){
  10. console.log(i);
  11. };
  12. }
  13. for(var j = 0; j < arr.length; j++){
  14. console.log(arr[j]());
  15. }

迭代器

好了,是时候放松一下了,看看下面这个代码,这个会简单一些

  1. var inc = function(){
  2. var x = 0;
  3. return function(){
  4. console.log(x++);
  5. };
  6. };
  7. inc1 = inc();
  8. inc1();
  9. inc1();
  10. inc2 = inc();
  11. inc2();
  12. inc2();
  13. inc2 = null;
  14. inc2 = inc();
  15. inc2();

你会发现,inc 返回了一个函数,这个函数是个累加器,它们可以独立工作互补影响。这个就是 js 中迭代器 next() 的实现原理。下面是一个简单的迭代器:

  1. function iterator(arr){
  2. var num = 0;
  3. return {
  4. next: function(){
  5. if(num < arr.length)
  6. return arr[num++];
  7. else return null;
  8. }
  9. };
  10. }
  11. var a = [1,3,5,7,9];
  12. var it = iterator(a);
  13. var num = it.next()
  14. while(num !== null){
  15. console.log(num)
  16. num = it.next();
  17. }

如果你学了 ES6,那么你可以用现成的迭代器,就不用自定义迭代器了。

箭头函数

箭头函数本身也是一个函数,具有自己的作用域。不过在箭头函数里面的 this 上下文同函数定义所在的上下文,具体可以看我的另一篇文章:javascript 中 this 详解

典型实例

这个实例会涉及到对象的相关知识,如果不能完全理解,可以参考:javascript 中 this 详解javascript 对象、类与原型链

  1. function Foo() {
  2. getName = function () { console.log (1); };
  3. return this;
  4. }
  5. Foo.getName = function () { console.log (2);};
  6. Foo.prototype.getName = function () { console.log (3);};
  7. var getName = function () { console.log (4);};
  8. function getName() { console.log (5);}
  9. Foo.getName();
  10. getName();
  11. Foo().getName();
  12. getName();
  13. new Foo.getName();
  14. new Foo().getName();
  15. new new Foo().getName();

Curry 化

Curry 化技术是一种通过把多个参数填充到函数体中,实现将函数转换为一个新的经过简化的(使之接受的参数更少)函数的技术。当发现正在调用同一个函数时,并且传递的参数绝大多数都是相同的,那么用一个 Curry 化的函数是一个很好的选择.

下面利用闭包实现一个 curry 化的加法函数

  1. function add(x,y){
  2. if(x && y) return x + y;
  3. if(!x && !y) throw Error("Cannot calculate");
  4. return function(newx){
  5. return x + newx;
  6. };
  7. }
  8. add(3)(4);
  9. add(3, 4);
  10. var newAdd = add(5);
  11. newAdd(8);
  12. var add2000 = add(2000);
  13. add2000(100);

https://segmentfault.com/a/1190000016286645