[置顶] 详解js中的闭包

时间:2021-07-24 22:45:37

独立作用域

在ES6出现之前,js中并没有块级作用域的存在,这意味者单纯一个大括号并不能隔离出一块作用域

   {
var a = 1;
}

这样的大括号没有隔离出一块作用域,那么变量a声明在括号内或者括号外都是一样的,那么js中什么时候能隔离出一个局部作用域呢,答案是函数

   var b = 1;
function fn(){
var a = 1;
console.log(b); //1
}
console.log(a) // ReferenceError

这时候函数单独隔离出了一个作用域。而函数外面的作用域在函数作用域的外层,因而函数内部能够访问到外部变量b,但函数外作用域无法访问函数内变量a。
那么在同时声明了变量a的时候会怎么样呢

   var a = 1;
function fn(){
var a = 2;
console.log(a)
}
fn(); // 2
console.log(a) // 1

当函数内部作用域再次声明变量a的时候,这时候变量a的新声明被压入函数调用栈中,这时js引擎读取a的值时候,会读取到新的声明,所以a的值是2。而执行完函数,局部作用域的a就被弹出(变量a的生命周期结束)。上下文切换到外部作用域之后,a的值就是原来外部作用域中的a,因此输出1。
同样,把fn函数替换成一个立即执行函数(学名缩写为IIFE)效果相同

   var a = 1;
(function(){
var a = 2;
console.log(a) // 2
})()

闭包

之前说到,函数可以访问外部作用域中的变量,但外部作用域不能访问函数内部变量。

   function fn1(){
var a = 2
function fn2(){
console.log(a);
}
return fn2;
}
var fn3 = fn1();
fn3(); // 2 这就是闭包

上面代码的fn2可以轻松访问到变量a,这个毫无疑问。当fn2的引用被赋值给fn3,那么fn3现在和fn2一样,能访问到变量a,这个也毫无疑问。然而fn3的声明却在外部作用域,这和我们上文说的外部作用域不能访问到函数内部变量相悖,这,就是闭包。
由于内部函数fn2和fn3的特殊关系,原本fn1的内部作用域原本会被销毁并被js引擎的垃圾回收器回收内存,现在fn1却能一直存活。

顽强的闭包

内部函数fn2的引用无论被传递到哪个作用域中,它都会持有对原始作用域的引用,也就是说,一直能读取到变量a

   var fn4;
function fn1(){
var a = 2
function fn2(){
console.log(a);
}
fn4 = fn2;
}
function fn3(){
fn4(); // 还是强行输出了2
}

闭包无处不在

在定时器,事件监听器,Ajax请求或者其他异步任务中,只要使用了回调函数,实际上就是在使用闭包(回调函数被扔在事件队列中,还保存着对msg等变量的作用域引用)

   function fn(msg){
setTimeout(function(){
console.log(msg);
}, 1000);
}
fn('hello');

fn执行1000毫秒之后,它的内部作用域并不会消失,依然拥有对fn作用域的闭包。

   var btn = document.getElementById('button');
var action = 'Click';
function fn(btn, action){
btn.onclick = function(){
console.log(action);
}
}
fn(btn, action); // 每次点击都能得到action

有一个比较常见的场景是,给循环的元素绑定事件监听函数

   var nodes = document.getElementsByTagName('div');
for(var i = 0, len = nodes.length; i < len; i++){
//这里通过一个IIFE封闭一个关于i的内部作用域
(function(i){
nodes[i].onclick = function(){
//click回调函数中通过闭包拿到i变量
alert(i);
}
})(i)
}

内存泄漏

   function Handler(){
var element = document.getElementById('someElement');
var id = element.id;
element.onclick = function(){
alert(id);
}
//只要onclick的回调匿名函数存在,element所占的内存就永远不会被回收,而我们这里只需要变量id,所以我们需要把element的引用设为null,确保正常回收占用的内存
element = null;
}

使用闭包封装变量

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

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

对于那些相同的参数来说,可以使用缓存来提高效率

   var cache = {};
var mult = function(){
//mult(1, 2, 3) => '1, 2, 3'
var args = Array.prototype.join.call(arguments, ',');
if(cache[args]){
//使用cache.args会把args自动转成字符串
return cache[args];
}
var a = 1;
for(var i = 0; i < arguments.length; i++){
a = a * arguments[i];
}
return a;
}

与其让cache暴露在全局,不如将它封装在IIFE中

   var mult = (function(){
var cache = {};
return function(){
var args = Array.prototype.join.call(arguments, ',');
if(cache[args]){
//使用cache.args会把args自动转成字符串
return cache[args];
}
var a = 1;
for(var i = 0; i < arguments.length; i++){
a = a * arguments[i];
}
return a;
}
})();

提炼函数是代码重构中的一种常见技巧,如果在一个大函数中有一些代码块能够提炼出来,我们常常把这些代码块封装在独立的小函数里面,独立出来的小函数有助于代码复用,如果这些小函数有好的命名,它们本身页起到了注释的作用

   var mult = (function(){
var cache = {};
var calculate = function(){
var a = 1;
for(var i = 0; i < arguments.length; i++){
a = a * arguments[i];
}
return a;
}
return function(){
var args = Array.prototype.join.call(arguments, ',');
if(cache[args]){
return cache[args];
}
//将参数传入
return cache[args] = caculate.apply(null, arguments);
}
})()

延续局部变量的寿命

img对象经常用于数据上报

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

而在一些低版本浏览器中,report函数并不是每一次都成功发起了HTTP请求,原因是img是局部变量,函数结束调用后就被销毁,可能还没来得及发出HTTP请求

   var report = (function(){
var imgs = [];
return function(){
var img = new Image();
//将img放进闭包变量中
imgs.push(img);
img.src = src;
}
})()

用闭包实现命令模式

在完成闭包实现的命令模式之前,我们先用面向对象的方式来编写一段命令模式的代码

   <html>
<body>
<button id="execute">点击我执行命令</button>
<button id="undo">点击我执行命令</button>
</body>
</html>
<script>
var Tv = {
open: function(){
console.log('打开电视机');
},
close: function(){
console.log('关上电视机');
}
};
var OpenTvCommand = function(receiver){
this.receiver = receiver;
};
OpenTvCommand.prototype.execute = function(){
this.receiver.open(); //执行命令,打开电视机
}
OpenTvCommand.prototype.undo = function(){
this.receiver.close(); //撤销命令,关闭电视机
}
var setCommand = function(command){
document.getElementById('exucute').onclick = function(){
command.execute();
}
document.getElementById('undo').onclick = function(){
command.undo();
}
}
setCommand(new OpenTvCommand(Tv));
</script>

命令模式的意图是把请求封装成对象,从而分离请求的发起者和请求的接收者之间的耦合关系。在命令执行之前,可以预先往命令对象中植入命令的接收者。在闭包的模式中,命令接收者会被封闭在闭包形成的环境中

   var Tv = {
open: function(){
console.log('打开电视机');
},
close: function(){
console.log('关上电视机');
}
};
var createCommand = function(receiver){
var execute = function(){
return receiver.open(); //执行命令,打开电视机
}
var undo = function(){
return receiver.close(); //执行命令,关闭电视机
}
return {
execute: execute,
undo: undo
}
}
var setCommand = function(command){
document.getElementById('exucute').onclick = function(){
command.execute();
}
document.getElementById('undo').onclick = function(){
command.undo();
}
}
setCommand(createCommand(Tv));

执行上下文

讲完实际应用
之后,下面来看一下高能的理论原理。
执行上下文是ECMAScript标准中定义的一个抽象概念,用来记录代码的运行环境。它可以是代码最开始执行的全局上下文,也可以是执行某个函数体内的上下文。
需要注意的是,程序至始至终只能进入一个执行上下文,这就是为什么js是单线程的原因,即每次只能有一个命令在执行。浏览器用栈来维护执行上下文,当前起作用的执行上下文位于栈顶,当它内部的代码执行完毕之后出栈,然后将下一个元素作为当前的上下文。
然而,程序并不需要执行完上下文中的所有代码,才能进入另一个执行上下文(在一个函数中调用另一个函数)。经常有当前的执行上下文A执行到一半暂停,又进入另一个执行上下文的情况。每次一个上下文被另一个上下文替代的时,这个新的上下文就入栈称为栈顶。
当有一堆上下文,有些执行到一半暂停的时候又继续,当继续执行的时候我们需要一种方式去记住当前的状态,事实上ECMAScript中已经做出了规定,每个执行上下文都有用来追踪执行状态的记录器

  • 代码执行状态(Code evaluation state)在当前执行上下文中用来记录代码执行,暂停,重新执行的状态
  • 函数(Function):当前上下文正在执行的函数体
  • 范畴(Realm):内部对象集合,全局运行环境极其作用域下的所有代码,其他相关的状态、资源
  • 词法环境(Lexical Environment):用来解决当前上下文中的标识符引用问题
  • 变量环境(Variable Environment):包含环境记录(EnvironmentRecord)的词法环境,而环境变量是由变量声明(VariableStatements)所产生的

词法环境

  • 用来定义标识符的值:词法环境的目的就是管理代码中的数据。也就是说,它给标识符赋值,让标识符变得有意义。比如,代码段console.log(x/10),如果变量x没有具体值,它是没有意义的,这段代码也没有意义。词法环境通过环境记录将标识符和具体的值联系在一起(见下一点)。
  • 词法环境包含环境记录:环境记录完美地记录了词法环境中所有标识符和具体值之间的联系,并且每个词法环境都有自己的环境记录。
  • 词法嵌套结构:内部环境引用包含它的外部环境,外部环境还可以有自己的外部环境。因此,一个环境可以作为多个内部环境的外部环境。全局环境是唯一一个没有外部环境的环境。

回到闭包

  • 每个函数都有一个包含词法环境的执行上下文,它的词法环境确定了函数内的变量赋值以及对外部环境的引用。看上去函数“记住”了外部环境,但其实上是这个函数有个指向外部环境的引用。这就是“闭包”的概念。

  • 每当外部封闭函数执行的时候就产生了闭包,也就是说闭包的创建并不一定需要内部函数返回。

  • JavaScript中闭包作用域是词法作用域,即它在代码写好之后就被静态决定了它的作用域。