felix-cao / Blog

A little progress a day makes you a big success!
31 stars 4 forks source link

JavaScript 再谈闭包(closure) #146

Open felix-cao opened 5 years ago

felix-cao commented 5 years ago

对于 JavaScript 程序员来说,闭包(closure) 是一个难懂又不得不去征服的技术概念。我在此之前写的一篇文章《JavaScript 闭包(closure)》,是从JavaScript的执行上下文来分析闭包的,现在看来对于初学者还是晦涩难懂的,本篇结合变量的作用域以及变量的生存周期来分析闭包。

一、变量的作用域

变量的作用域,是指变量的有效范围,分为全局作用域和函数作用域。详细资料请移步《JavaScript 作用域 #59》。我们最为常见的是函数作用域,即在函数中声明的变量的作用域。

当在函数中声明一个变量的时候,如果该变量前没有带上关键字 var, 这个变量就会成为全局变量,这是一种容易造成变量冲突的做法,所以 ES5 定义了一个 use strict 的严格模式,要求变量必须先声明再使用,否则会报错。

正常情况下,用 var 关键字在函数中声明变量,这时候的变量即是局部变量,只有在该函数内部才能访问到这个变量,在函数外部是访问不到的。

function fn() {
  var num = 1;
  console.log(num); 
}

fn()
console.log(num) // Uncaught ReferenceError: num is not defined

上面的代码,在函数 fn 内部定义的 num,在 fn 函数外部是无法访问的,只有在 fn 函数的内部才可以正常访问。

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

下面这段包含了嵌套函数的代码,可以帮助我们加深对变量搜索过程的理解。

var num0 = 0;
var fun = function() {
  var num1 = 1;
  var func = function() {
    var num2 = 2;
    console.log(num0); // 0
    console.log(num1);  // 1
  }

  func();
  console.log(num2); // Uncaught ReferenceError: num2 is not defined
}

fun();

上面的代码,当 fun 函数执行时,func 函数才会执行,func 执行时需要访问变量 num0num1func 函数作用域内没有定义 num0num1,向上逐层搜索。

二、变量的生存周期

除了变量的作用域之外,另外一个跟闭包有关的概念是变量的生存周期。 对于全局变量来说,其生存周期是永久的,除非我们主动销毁这个全局变量。 而对于在函数内用 var 关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数调用的结束而被销毁:

function fn() {
  var num = 1; // fn 函数执行完成退出后,变量 num 即被销毁
  console.log(num); 
}

fn()

根据这个知识,我们来看下面的代码:

var fun = function() {
  var count = 0
  return function() {
    count ++;
    console.log(count);
 }
}

var increment = fun();
increment() // 1
increment() // 2
increment() // 3
increment() // 4
increment() // 5

与上面代码的结论相反,当退出 fun 函数后,局部变量 count 并没有消失,似乎一直在某个地方存活着,这是因为当执行 var increment = fun() 时,increment 得到了一个函数的引用,是从 fun 函数执行时返回的,它可以访问到 fun 被调用时所产生的执行上下文,而 fun 的函数变量 count 一直处在这个环境里。既然 count 这个局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。在这里产生了一个闭包结构,这个 count 局部变量我们又叫做自由变量,看上去似乎生命被延续了。

三、闭包的作用

3.1、封装变量

闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”,这里有一个计算乘积的简单函数:

var multiply = function() {
  var result = 1;
  for( var i =0, l = arguments.length; i < 1; i ++) {
    result = result * arguments[i];
  }
  return result;
}

上面的代码 multiply 函数接收一些 number 类型的参数,并返回这些参数的乘积。 在函数内部封装了变量 result 作为“私有变量”。

但是,当我们多次调用相同参数的 multiply 函数时,函数内部经过多次相同的 for 循环,每次都进行 for 循环计算是一种资源浪费,这对于一名优秀的程序员是难以接受的。

multiply(2,3,4,5);
multiply(2,3,4,5);
multiply(2,3,4,5);
....

为了解决这种资源浪费,我们加入了缓存机制:

var multiply = (function(){
  var cache = {};
  var calculate = function(){  // 封闭 calculate 函数
    var result  = 1; 
    for ( var i = 0, l = arguments.length; i < l; i++ ){ 
      result  = result  * arguments[i]; 
    } 
    return result; 
  };

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

上面代码,变量multiply 等号右边是一个带参数的立即执行函数,返回一个函数,立即函数里封装了 一个缓存变量 cache 和一个计算函数 calculate, 供 return 后面的函数消费。

由此可见,cachecalculate 都是 multiply 函数私有的,不需要暴露在全局的。

3.2、延长自由变量的生命

img 对象经常用于进行数据上报,如下所示:

var report = function( src ){ 
  var img = new Image(); 
  img.src = src; 
}; 
report( 'http://xxx.com/getUserInfo' ); 

但是通过查询后台的记录我们得知,因为一些低版本浏览器的实现存在 bug,在这些浏览器下使用 report 函数进行数据上报会丢失 30% 左右的数据,也就是说,report 函数并不是每一次都成功发起了 HTTP 请求。丢失数据的原因是 imgreport 函数中的局部变量,当 report 函数的调用结束后,img 局部变量随即被销毁,而此时或许还没来得及发出 HTTP 请求,所以此次请求就会丢失掉。现在我们把 img 变量用闭包封闭起来,便能解决请求丢失的问题:

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

四、闭包的缺点

闭包可以延长自由变量的生命,这是由于 JavaScript 函数的执行上下文,在执行完毕之后,生命周期结束,那么该函数的执行上下文就会失去引用。其占用的内存空间很快就会被垃圾回收器释放。可是闭包的存在,会阻止这一过程。因此闭包的核心思想是函数调用完成之后,其执行上下文环境不会被销毁,这会带来内存的消耗。解决方法是,在退出函数之前,将不使用的局部变量全部删除。