JavaScript设计模式与开发实践 | 03 - 闭包和高阶函数

闭包

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

创建闭包的常见方式,就是在一个函数内部创建另一个函数。闭包的形成与变量的作用域以及变量的生存周期有关。

变量的作用域

变量的作用域就是指变量的有效范围。

当在函数中声明一个变量时,如果变量前面没有带上关键字var,这个变量就会成为全局变量;如果用var关键字在函数中声明变量,这个变量就是局部变量,只有在该函数内部才能访问到这个变量,在函数外部是访问不到的。

在JavaScript中,函数可以用来创造函数作用域。在函数里面可以看到外面的变量,而在函数外面则无法看到函数里面的变量。这是因为当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码的执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象。变量的搜索是从内到外的。

var a = 1;

var func1 = function(){
  var b = 2;
  var func2 = function(){
      var c = 3;
      console.log(b);  // 输出:2
      console.log(c);  // 输出:1
  }
  func2();
  console.log(c);  // 变量c在函数内部,是局部变量,此时在外部访问不到。 输出:Uncaught ReferenceError: c is not defined
};

func1();

变量的生存周期

全局变量的生存周期是永久的,除非我们主动销毁这个全局变量。而在函数内用var关键字声明的局部变量,当退出函数时,这些局部变量即失去了它们的价值,会随着函数调用的结束而被销毁:

var func = function(){
  var a = 1;  // 退出函数后局部变量a将被销毁
  console.log(a);  // 输出:1
};

func();

但是,有一种情况却跟我们的推论相反。

var func = function(){
  var a = 1;  //函数外部访问不到局部变量a,退出函数后,局部变量a被销毁
  console.log(a);  // 输出:1
};

func();  
console.log(a);  // 输出:Uncaught ReferenceError: a is not defined


var func = function(){
  var a = 1;
  return function(){
      a++;
      console.log(a);
  }
};

var f = func();

f();  // 输出:2
f();  // 输出:3
f();  // 输出:4
f();  // 输出:5

当退出函数后,局部变量a并没有消失,而是似乎一直在某个地方存活着。这是因为当执行 var f = func(); 时,f返回了一个匿名函数的引用,它可以访问到func()被调用时产生的环境,而布局变量a一直处在这个环境里。既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。在这里产生了一个闭包结构,局部变量的生命周期被延续了。

闭包的作用

  • 封装变量

  • 延续局部变量的寿命

1. 封装变量

闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”。

假设有一个计算乘积的函数:

var cache = {};
var mult = function(){
  var args = Array.prototype.join.call(arguments, ',');
  if(cache[args]){
      return cache[args];
  }
  var a = 1;
  for(var i=0, l=arguments.length; i< l; i++){
      a = a * arguments[i];
  }
  return cache[args] = a;
};

console.log(mult(1,2,3));  // 输出:6
console.log(mult(1,2,3));  // 输出:6

我们看到cache这个变量仅仅在mult函数中被使用,与其让cache变量跟mult函数一起平行地暴露在全局作用域下,不如把它封闭在mult函数内部,这样可以减少页面中的全局变量,以避免这个变量在其他地方被不小心修改而引发错误。

var mult = (function(){
  var cache = {};
  return function(){
      var args = Array.prototype.join.call(arguments, ',');
      if(args in cache){
        return cache[args];
      }
      var a = 1;
      for(var i=0, l=arguments.length; i<l; i++){
        a = a * arguments[i];
      }
      return cache[args] = a;
  }
})();

console.log(mult(1,2,3));  // 输出:6
console.log(mult(1,2,3));  // 输出:6

提炼函数是代码重构中的一种常见技巧。如果在一个大函数中有一些代码能够独立出来,就把这些代码封装在独立的小函数里。独立出来的小函数有助于代码服用。

var mult = (function(){
  var cache = {};
  var calculate = function(){
      var a = 1;
      for(var i=0, l=arguments.length; i<l; i++){
        a = a * arguments[i];
      }
      return a;
  };

  return function(){
      var args = Array.prototype.join.call(arguments, ',');
      if(args in cache){
        return cache[args];
      }
      return cache[args] = calculate.apply(null, arguments);
  }
})();

console.log(mult(1,2,3));  // 输出:6
console.log(mult(1,2,3));  // 输出:6

2.延续局部变量的寿命
img对象常用于进行数据上报,如下:

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

report('http://xxx.com/getUserInfo');

一些低版本浏览器的实现存在bug,在这些浏览器中使用report函数进行数据上报会丢失30%左右的数据,也就是说,report函数并不是每一次都成功发起了HTTP请求。丢失数据的原因是img是report函数中的局部变量,当report函数的调用结束后,img局部变量随即被销毁,而此时或许还没来得及发出HTTP请求,所以此次请求就会丢失掉。

把img变量用闭包封闭起来:

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

闭包和面向对象设计

过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。通常用面对对象思想能实现的功能,用闭包也能实现,反之亦然。

看看这段面向对象写法的代码:

var extent = {
  value: 0;
  call: function(){
    this.value++;    
    console.log(this.value);
  }
};

// 作为对象的方法调用,this指向该对象
extent.call();  // 输出:1
extent.call();  // 输出:2
extent.call();  // 输出:3

换成闭包的写法如下:

var extent = function(){
  var value = 0;
  return {
      call: function(){
          value++;
          console.log(value);
      }
  }
};

var extent = extent();
extent.call();  // 输出:1
extent.call();  // 输出:2
extent.call();  // 输出:3

闭包与内存管理

局部变量本来应该在函数退出的时候就被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。从这个意义上看,闭包确实会使一些数据无法被及时销毁。使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些对象放在闭包中和放在全局作用域中,对内存方面的影响是一致的。如果在将来需要回收这些变量,可以手动把变量设为null。

使用闭包的同时比较容易造成循环引用,如果闭包的作用域链中保存着一些DOM节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非JavaScript的问题。在IE浏览器中,由于BOM和DOM中的对象是使用C++以COM对象的方式实现的,而COM对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。

如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为null。将变量设为null,意味着切断变量与它之前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

高阶函数

定义

高阶函数是指至少满足下列条件之一的函数:

  • 函数可以作为参数被传递;

  • 函数可以作为返回值输出。

函数作为参数传递

1. 回调函数

在ajax异步请求的应用中,回调函数的使用非常频繁。当我们想在ajax请求返回之后做一些事情,但又不知道请求返回的确切时间时,最常见的方案就是把callback函数当作参数传入发起的ajax请求的方法中,待请求完成之后执行callback函数:

var getUserInfo = function(){
  $.ajax('http://xxx.com/getUserInfo?' + userId, function(data){
      if(typeof callback === 'function'){
          callback(data);
      }
  });
}

getUserInfo(13157, function(data){
  console.log(data.userName);
});

回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些请求时,我们也可以把这些请求封装成一个函数,并把它作为参数传递给另一个函数,“委托”给另一个函数来执行。

2. Array.prototype.sort

Array.prototype.sort接受一个函数当作参数,这个函数里面封装了数组元素的排序规则。从Array.prototype.sort的使用可以看到,我们的目的是对数组进行排序,这是不变的部分;而使用什么规则去排序,则是可变的部分。把可变的部分封装在函数参数里,动态传入Array.prototype.sort,使Array.prototype.sort方法成为了一个非常灵活的方法。

// 从小到大排序
console.log(    // 输出:[1, 3, 4]
    [1, 4, 3].sort(function(a, b){
        return a - b;
    })
);

// 从大到小排序
console.log(  // 输出:[4, 3, 1]
    [1, 4, 3].sort(function(a, b){
        return b - a;
    })
);

函数作为返回值输出

1. 判断数据的类型

判断一个数据是否是数组,可以基于鸭子类型的理念来判断,比如判断这个数据有没有length熟悉,有没有sort方法或者slice方法。但更好的方式是用Object.prototype.toString来计算。

Object.prototype.toString.call(obj)返回一个字符串,比如Object.prototype.toString.call([1,2,3])总是返回[Object Array],而Object.prototype.toString.call("str")总是返回[Object String]。所以我们可以编写一系列isType函数:

var isType = function(type){
  return function(obj){
      return Object.prototype.toString.call(obj) === '[object ' + type + ']';
  }
};

var isString = isType('String');
var isArray = isType('Array');
var isNumber = isType('Number');

console.log(isArray([1,2,3]));   // 输出:true

2. getSingle

有一种设计模式叫单例模式,下面是它的例子:

var getSingle = function(fn){
  var ret;
  return function(){
    return ret || (ret = fn.apply(this, arguments));
  };
};

var getScript = getSingle(function(){
  return document.createElement('script');
});

var script1 = getScript();
var script2 = getScript();

console.log(script1 === script2);  // 输出:true

这个高阶函数的例子,既把函数当作参数传递,又让函数执行后返回了另一个函数。

高阶函数实现AOP

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离处理之后,再通过“动态织入”的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。

在JavaScript中实现AOP,都是指把一个函数“动态织入”到另一个函数之中,具体的实现技术有很多,这里我们通过扩展Function.prototype来实现。

function.prototype.before = function(beforefn){
  var __self = this;  // 保存原函数的引用
  return function(){  // 返回包含了原函数和新函数的“代理”函数
      beforefn.apply(this, arguments);  // 执行新函数,修正this
      return __self.apply(this, arguments);  // 执行原函数
  }
};

Function.prototype.after = function(afterfn){
  var __self = this;
  return function(){
      var ret = __self.apply(this, arguments);
      afterfn.apply(this, arguments);
      return ret;
  }
};

var func = function(){
  console.log(2);
};

func = func.beforefn(function(){
  console.log(1);
}).after(function(){
  console.log(3);
});

func();

这种使用AOP的方式来给函数添加职责,也是JavaScript语言中一种非常特别和巧妙的装饰者模式实现。

PS:本节内容为《JavaScript设计模式与开发实践》第三章 笔记。

    原文作者:设计模式
    原文地址: https://segmentfault.com/a/1190000003985658
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞