作用域与闭包

136 阅读8分钟

闭包可以说是一个面试中非常常见的问题了,想要很好的理解闭包,那么我们就需要学习JS中的作用域的问题,什么是作用域,作用域链呢?接下来我会分两种不同的角度去解释这个问题。

编译原理

要想更深层次的理解作用域,那么就要想理解JavaScript的编译原理。那么在JS中就有三个比较重要的角色要出现了:

  • 引擎 从头到尾负责整个JavaScript程序的编译及执行过程。\color{#999}{从头到尾负责整个 JavaScript 程序的编译及执行过程。}
  • 编译器 引擎的好朋友之一,负责语法分析及代码生成等脏活累活\color{#999}{引擎的好朋友之一,负责语法分析及代码生成等脏活累活}
  • 作用域 引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查\color{#999}{引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查} 询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。\color{#999}{询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限 。}

在JS的编译里,其实一般都是边编译边执行,但是这个编译过程又是那么的快(一般几ms都搞定了),接下来就是执行过程。 我们都知道啊,对于JS来说,编译过程就是对整个代码块的变量函数等校验的一个过程。看下面代码:

var a = 1

function b() {
     console.log(a)
}

这个JS拿到上面代码的时候,首先要做的就是对变量a以及函数b进行校验,校验其实就是作用域这个好朋友在起作用, 在当前作用域下查找是否有相同命名的变量,如果有的话编译器就会忽略,继续进行编译,没有的话就会在当前作用域下声明一个新的 变量并命名(如上面的代码的分别就是变量a以及函数b)。这个过程就是预编译过程,这里常会出现变量提升问题

进行完预编译过程以后就是执行过程,以上述代码为例的话,就是对变量a进行赋值,不同的是函数b其实是一整个提升。其实就是函数可以先调用的解释

add()

function add() {
  console.log('add函数')
}

变量提升

其实对于变量提升的知识点并不是很多,归结起来其实就两点,如下:

  • 在当前作用域下,调用未定义的变量,会出现undefined
  • 变量的提升次于函数的提升

尝试一下代码:

var v='Hello World';
(function(){
    alert(v);
})()

结果很容易就可以猜得到,其实就是 Hello World。

那么在尝试下面的代码:

var v='Hello World';
(function(){
    alert(v);
    var v='I love you';
})()

怎么样,会不会有出乎意料的输出结果,怎么会输出这个undefined讨人厌的家伙,其实这就是变量提升做的怪,其实这里就印证了上面的第一点。至于分析的话,其实就是作用域链的问题了。

为了印证第二点,可以尝试下面的代码:

console.log(add) // 函数add

function add() {
  console.log(2)
}

var add = 1
console.log(add)  // 1

这时候有聪明的朋友就会想到,**函数不是还有函数表达式的方式吗?**其实也很简单可以印证,如下:

add() // 2

function add() {
  console.log(2)
}

var add = function() {
  console.log(1)
}

add() // 1

还有更聪明的朋友就会想到,哎呀,这变量跟函数以及函数表达式都重名会怎么样啊?!,伸手党是吧,你自己不会去证明吗?!,作为一个有素质的博主,肯定为你们考虑啦,如下:

console.log(add) // 函数add

function add() {
  console.log(2)
}

var add = function() {
  console.log(1)
}

console.log(add) // 函数表达式add

var add = 1
console.log(add)  // 1

其实函数表达式的表现跟变量其实是一样的。 什么?你想问我结论?,上面不是早就写了?你是不是瞎啊?!

词法作用域

说到词法作用域,其实就是变量作用域,我们知道,变量都是有它的作用的~~(这是一段废话)~~,先看下面的代码:

var a = 1

funcion b() {
  var c = 2
  console.log(a)
  console.log(c)
}

b()

上面代码的词法作用域,可以用下图来演示:

作用域1.png

其中像变量a这种在别的地方调用的,我们成为是自由变量。其实所谓的作用域链,就是作用域套作用域,像上图,就是函数b的作用域套在全局作用,就构成了作用域链。作用域链的查找变量是向上层作用域查找,就是一层层向上寻找,直到全局作用域
其实不难发现,作用域链的作用就是,找不存在与当前作用域的变量,像变量a这种在全局作用域的变量,我们就可以叫做是全局变量。像变量c这种的,我们就可以叫做局部变量,局部变量的特性就是不能在它作用域外访问,但是可以在它作用域内访问,像变量a这种自由变量就是一个例子。

闭包

什么是闭包

要想知道什么是闭包,我们先举一个闭包例子看看:

function add() {
  var a = 0
  return function() {
    return a++
  }
}

let result  = add()

console.log(resule()) // 反复执行这句

上面的代码应该是最典型的闭包例子。以前面试的时候,面试官问我什么是闭包的时候,我都会说,就是一个函数内定义个变量,通过返回函数的方式,在返回函数中调用定义的变量,这就是闭包。咋一看好像也没有错是吧,按照上面的例子说的通。那么我们再看下面的例子:

let func;

function foo() {
  var a = 0
  func = funcion() {
    return a++
  }
}

foo()

console.log(func())  // 反复执行这句

很明显,上述代码并没有返回什么函数,而是将全局变量func赋值成匿名函数表达式,并在内部对变量a的引用,这样也可以实现前面代码实现的效果。这样就可以推翻了上面的说法,那么到底什么是闭包呢?我在红宝书第四版中找到了说法,它说闭包是那些引用了另一个函数作用域中的变量的函数,通常在嵌套函数中实现。这就麻烦了,什么是函数作用域啊?怎么会有这个概念的呢?别急,看我表演了时候到了,请看下面代码:

function compare(value1,value2) {
    if(value1 < value2) {
      return -1
    } else if( value1 > value2) {
      return 1
    }else {
      return 0
    }
}

let result = compare(10,5)

compare函数是运行在全局作用域下的。那么在compare函数调用的时候会发什么呢?

在JS的函数执行时,每个执行上下文都会有一个包含其中的变量的对象,那么全局上下文中的叫做变量对象,那么函数中的就叫做活动对象,顾名思义,这个活动对象只存在函数的过程中,函数执行完时就会进行垃圾回收,进行销毁。那么在compare函数执行过程中,创建执行上文,首先会预设变量对象(即全局上下文),接着会创建函数的执行上下文并将活动对象(即compare函数的)推到作用域链的首位。那么在这个例子中,compare函数的作用域链就是compare函数作用域 =》全局作用域。如下图:

闭包1.png

那么闭包该怎么表示呢?看下面修改的代码:

function compare(value1,value2) {
  let res;
    if(value1 < value2) {
      res =  -1
    } else if( value1 > value2) {
      res =  1
    }else {
      res =  0
    }

    return function() {
      return res
    }
}

let result = compare(10,5)

console.log(reult())

如下图展示:

闭包2.png 其实正常的函数活动对象会在执行完的时候销毁,但是由于闭包的存在,对compare函数的活动对象还存在引用关系,迫使不能立即销毁,而保留下来了。那么怎么消除闭包呢?其实对于上述代码很简单,只需要加上下面的代码即可:

result = null

取消了对compare活动对象的依赖,那么就可以正常的垃圾回收到内存。

回归到什么是闭包这个问题,其实红宝书的解释我觉得就可以了,但是如何要说到作用域上面,我那么我觉得可以这么说:函数不在其定义时的作用域调用,并且对当前作用域变量保持引用。

闭包的用途

维护私有变量

这个功能应该是比较输出,其实上面的例子中就有用到,看看下面代码:

function hostName() {
  var name = 'ydw'
  return function() {
    return name
  }
 }

 let result = hostName()

 console.log(result())

可以看到,其实我们并不能修改里面name的值,只能获取到name的值,如果想要修改里面的值,其实我们可以对代码稍微的改动即可。如下:

function hostName() {
  var name = 'ydw'
  return {
    getName: function() { 
      console.log(name)
      return name 
    },
    setName: function(changeName) { 
      name = changeName; 
      console.log(name)
    }
  }
 }

 let result = hostName()

 result.getName()

 result.setName('RadiomM')
 result.getName()

通过改造代码,返回时对象形式的两个函数,从而到达可以修改name的目的。

函数柯里化

柯里化其实一种综合多个函数作用的一个表现形式,来想一个场景,我们需要计算我们自己的月销是多少的时候,可能会写到下面的代码:

var monthCost = 0

var cost = function(money) {
  monthCost += money
}

cost(100)  // monthCost: 100
cost(200)  // monthCost: 300
cost(300)  // monthCost: 600

虽然这样可算出一个月的消费是多少,但是,在计算过程中我们其实不需要关心我到底每天用了多少钱,我只关心到一个月到底消费了多少钱。实现这个需求其实就可以用到闭包了,我们可以存一个变量,来记录每次相加的时候得到的值,然后在最后计算到结果是多少,代码调整如下:


var cost = (function(){
  var args = [];

  return function() {
    if (arguments.length === 0) {
      var money = 0;
      for(let i = 0; i < args.length;i++){
        money += args[i]
      }
      return money
    } else {
      [].push.apply(args,arguments)
    }
  }
})()

cost( 100 ); // 未计算到结果   
cost( 200 ); // 未计算到结果  
cost( 300 ); // 未计算到结果  


console.log( cost() )  // 600

实际上还可以对代码进行优化,因为对于cost函数来说,只需要计算最后的结果多少就可以了,其他对于函数参数的判断,可以交给另外一个函数做,如下:

var currying = function(fn) {
  var args = []

  return function() {
    if(arguments.length === 0) {
      return fn.apply(this,args);
    }else {
      [].push.apply(args, arguments);
      return arguments.callee;
    }
  }
}

var cost = (function() {
  var money = 0

  return function() {
      for(let i = 0; i < arguments.length;i++){
        money += arguments[i]
      }
      return money
  }
})()

var cost1 = currying(cost)

cost( 100 ); // 未计算到结果   
cost( 200 ); // 未计算到结果  
cost( 300 ); // 未计算到结果  


console.log( cost() )  // 600

总结

好了,闭包的内容就只只有以上内容了,觉得可以的朋友可以关注一下我的博客喔。

参考书籍:

《JavaScript设计模式与开发实践》
《JavaScript高级程序设计 第四版》
《JavaScript权威指南 第六版》
《解锁前端面试体系攻略》 修言