闭包你真的了解吗?

开始之前

这是回北京后被疫情困在家的第四周了,在弹尽粮绝和刚刚看完《十二道锋味》芬兰之旅,还沉浸在奇幻的极光和诱人的西餐中开始写这篇网络日记。关于闭包在之前面试的时候就想写了,但是因为事儿多,忙了给耽搁下了。而且写一篇技术类的网络日记真的还是挺费时间的。写这种技术类的日记不像小时候写的日记,记录一些流水,这个真的还是要备课。这片日记还是以阮一峰老师讲闭包为主。但是阮老师写的关于闭包单刀直入,似乎少了一些前情知识,所以在内容上面做了一些调整,然后加入了几个实战的案例来分析JavaScript这个重要的概念——闭包。

环境与作用域

闭包之前先了解一些 JavaScript 的环境和作用域有助于对闭包的理解,可以说是这部分内容是闭包的先行知识。

任何的编程语言其实都有这个概念,环境就是函数运行的环境,作用域就是函数作用的范围。举个类似的例子,一座城市。城市里有学校,医院,商场等基础设施是市民的生活环境,城市的存在需要市民的依赖,只有市民依赖城市生活这座城市才会存在,当某一天市民不再依赖这座城市生活了,城市基本上也就破败了,也就不复存在了。城市里面的一个个基础设施单位学校,超市等也是一样,没人用了就倒闭了回收了。城市里面一些基础设施会有自己的辐射范围,尤其像是学校,都会划学区,就规定了这个学校就服务自己所在片区的孩子。现在我们做对比的话我们可以把城市想象成JavaScript中的全局环境。城市中一个个的基础设置就相当于函数,函数中也会有自己的小环境。每个环境都有自己的作用范围,全局环景就对应全局作用域,函数环境就对应函数作用域。
javaScript全局环境有一个特点就是全局环境永远不会被回收。举个例子

<html>
    .....
  <body>
    <script>
      let title = 'javascript closure'
      console.log(title);
    </script>
  </body>
</html>

上面的代码不完整啊,就是简单的写一个html页面,里面只有上面的一个script定义的内容,其他什么都没有。我们会发现,当我们用浏览器打开,看控制台打印出title了,当我们再次在控制台打印title,title还会会打印出来,可见这个脚本执行完,这个环境并没有被回收。


上面是全局环境,我们再看一下全局环境中的局部环境,先看一下下面这段代码:

<script type="text/javascript">
    let title = 'JavaScript Closure'
        function foo(){
            let n = 0;
            function show(){
        console.log(title);
                console.log(++n);
            }
            show();
        }
        foo();
    </script>

上面的代码中我们再全局环境中创建了一个 foo 函数,它会形成自己的一个局部环境,里面有有数据 n 和 函数 show,调用show时,他会在父级foo的环境中创建自己的环境。环境是只有在函数调用的才会创建,对应计算机语言就是创建一块内存区域。

image

我们运行这个文件会看到控制台打印出总是会打印出 1 来,即使我们多次调用 foo(), 每次的结果都还是一样的,这说明 foo 这个换环境每次都是新的,也就是我们每调用一次就会开辟一块新的内存空间。
image

关于作用域,全局作用域只有一个,每个函数又都有作用域(环境)。

  • 编译器运行时会将变量定义在所在作用域
  • 使用变量时会从当前作用域开始向上查找变量
  • 作用域就像攀亲戚一样,晚辈总是可以向上辈要些东西


    image

    作用域链只向上查找,找到全局window即终止,所以我们会看到 show 函数是可以访问到 title 这个变量。但是我们尽量不要在全局作用域上定义变量

但是有的时候我们想保留一个函数环境或者说成是作用域。比如我们说上面函数中的 n, 我们想实现的效果就是我们每次调用一次 show 函数 n 就要累加一次。也就说但我们执行完 foo函数的时候他的那块内存区域不会被清空回收。

<script type="text/javascript">
        let title = 'JavaScript Closure'
        function foo(){
            let n = 0;
            return function show(){
                console.log(title);
                console.log(++n);
            }
        }
        let a = foo();
        a(); // JavaScript Closure   1
        a(); // JavaScript Closure   2
        a(); // JavaScript Closure   3
    </script>

我们可以看到当我们把 show 函数的引用返回出去,foo的环境就会被保留。那么也就是说子函数被外部使用时,它的父级环境就是会保留的。
其实我们经常使用的构造函数就是一个很好的环境例子,子函数被外部使用时,父级环境将会保留。

function User() {
  let a = 1;
  this.show = function() {
    console.log(a++);
  };
}
let a = new User();
a.show(); //1
a.show(); //2
let b = new User();
b.show(); //1

其实构造函数我们可以看作是下面代码的一种变形(构造函数实际上会复杂一些)

function User(){
  let a = 1;
  function show(){
    console.log(a++)
  }
  return {
    show:show
  }
}

ES6中 letconst 可以变量的声明放在块级作用域中(放在新环境,而不是全局中)。

{
    let a = 9;
}
console.log(a); //ReferenceError: a is not defined
if (true) {
    var i = 1;
}
console.log(i);//1

对于这一点我们之前写 for 循环肯定最有感触,比如下面的例子:

let arr = [];
for (var i = 0; i < 10; i++) {
    arr.push((() => i));
}
console.log(arr[3]()); //10 
=================================
let arr = [];
for (let i = 0; i < 10; i++) {
    arr.push((() => i));
}
console.log(arr[3]()); //3 

下面这个图可以表示一下着两个区别:

for 循环的时候会开辟很多会块级作用于,当使用 var 的时候,i 是定义在了全局环境中,而使用 let 就会在新开辟的块级作用域声明一个 i 变量,这样在函数执行的时候在本块级作用域就找到了,就不用去全局找了。全局中如果没有其他地方定义 i,其实全局中 i 也是不存在的。
当然了如果我们既要保留现在的效果,又要保留全局中的 i ,我们使用我们的老方法

//自行构建闭包
var arr = [];
for (var i = 0; i < 10; i++) {
  (function (a) {
      arr.push(()=>a);
  })(i);
}
console.log(arr[3]()); //3
console.log(i); //10

闭包

在 JavaScript 中,函数内部可以直接读取全局变量,在函数外部无法读取函数内的局部变量。但是出于种种原因,我们有时候需要得到函数内的局部变量,那就是在函数内部再定义一个函数,他可以读取函数内部的变量,然后我们把这个函数返回,就到了这个效果。上面 show 那个例子就可以看出,我们利用它成功读取了 foo 函数内部变量 n。这个就是闭包。

那么到底什么是闭包,用阮老师的话是:闭包就是能够读取其他函数内部变量的函数。

由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成"定义在一个函数内部的函数"。

所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。他是函数作用域的一种延伸。

闭包的用途

闭包可以用在许多地方。它的最大用处有两个,一个是前面提到的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中。

怎么来理解这句话呢?请看下面的代码。

function f1(){
  var n=999;
  nAdd=function(){n+=1}
  function f2(){
    alert(n);
  }
  return f2;
}
var result=f1();
result(); // 999
nAdd();
result(); // 1000

在这段代码中,result实际上就是闭包f2函数。它一共运行了两次,第一次的值是999,第二次的值是1000。这证明了,函数f1中的局部变量n一直保存在内存中,并没有在f1调用后被自动清除。

为什么会这样呢?原因就在于f1是f2的父函数,而f2被赋给了一个全局变量,这导致f2始终在内存中,而f2的存在依赖于f1,因此f1也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。

这段代码中另一个值得注意的地方,就是"nAdd=function(){n+=1}"这一行,首先在nAdd前面没有使用var关键字,因此nAdd是一个全局变量,而不是局部变量。其次,nAdd的值是一个匿名函数(anonymous function),而这个匿名函数本身也是一个闭包,所以nAdd相当于是一个setter,可以在函数外部对函数内部的局部变量进行操作。

实战应用

假设我们现在有一个按钮,我们想要实现的效果是当我们点击按钮的时候,按钮会向右边移动。下面来实现一下这个效果:
Version 1

<!DOCTYPE html>
<html>
<head>
    <title>JavaScript闭包</title>
</head>
<body>
    <button id='button' style="position: absolute;">JavaScript</button>
    <script type="text/javascript">
        window.onload = function(){
            let button = document.getElementById('button');
            button.addEventListener('click',function(){
                let left = 1;
                setInterval(function(){
                    button.style.left  = left++ + 'px';
                },100)
            })
        }
    </script>
</body>
</html>
image

我们会看到这个按钮是一直抖动的,因为我们每次点击的时候都会开辟一块作用域,如下图:

如果我们要改进它,需要我们将 left 这个值保留下来,也就是放到父级中去:
Version 2

<script type="text/javascript">
        window.onload = function(){
            let button = document.getElementById('button');
            let left = 1;
            button.addEventListener('click',function(){
                setInterval(function(){
                    button.style.left  = left++ + 'px';
                },100)
            })
        }
    </script>

我们将 left 放到父级作用域来,我们会看到这个按钮就不会再抖动了。当然了目前还是不完善的,因为当我们多点击按钮几次,这个按钮会越来越快,原因跟V1是相同的,left是成了父级了,但是setinterval还在,我们点击 n次,相当于interval形成了n个事件队列,执行的事件间隔就相当于 100 / n。所以我们要判定,如果事件绑定了我们就不要再次绑定了。

Version 3

<script type="text/javascript">
        window.onload = function(){
            let button = document.getElementById('button');
            let left = 1;
            let interval = false;
            button.addEventListener('click',function(){
                if(!interval){
                    interval = true;
                    setInterval(function(){
                        button.style.left  = left++ + 'px';
                    },100)
                }
            })
        }
    </script>

使用闭包注意的点

1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。

2)闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

对于第一个举个例子解释一下:

对于下面点击事件我们要输出元素的desc信息,因为一个个点击事件一直驻留在内存当中的,所以其父级中的 item 就会一直存在,有几个存在几份。

<body>
  <div desc="JavaScript">JavaScript</div>
  <div desc="Closure">Closure</div>
</body>
<script>
  let divs = document.querySelectorAll("div");
  divs.forEach(function(item) {
    item.addEventListener("click", function() {
      console.log(item.getAttribute("desc"));
    });
  });
</script>

如果有很多按钮,就有很多个 item,而我们只需要一个描述信息的文本而已,所以我们只要一个他的属性就可以了,不需要它一直在,免得太多了造成了内存泄漏,所以可以这样改进:

<script>
  let divs = document.querySelectorAll("div");
  divs.forEach(function(item) {
    let desc = item.getAttribute("desc")
    item.addEventListener("click", function() {
      console.log(desc);
    });
    item = null;
  });
</script>

我们将其属性赋个一个变量,让他来代替 item 驻留内存总,在最后我们把item设置为 null,释放空间。一个对象的属性总是比这个完整的对象消耗的空间小,所以我们就到达了内存优化的目的。

阮老师的思考题

如果你能理解下面两段代码的运行结果,应该就算理解闭包的运行机制了。

代码片段一:

var name = "The Window";

var object = {
  name : "My Object",

  getNameFunc : function(){
    return function(){
      return this.name;
    };

  }

};

alert(object.getNameFunc()());

代码片段二:

var name = "The Window";

var object = {
  name : "My Object",
  getNameFunc : function(){
    var that = this;
    return function(){
      return that.name;
    };
    }
};
alert(object.getNameFunc()());

写在最后

这篇日记总共写了三天,这种基础性的知识说起来感觉比应用型的难好多。应用性的直接介绍一下属性,放几个实例,说一下就可以,放一下效果图就可以,这种语言知识说的时候还真的不好说起,当然了也是我水平不行,自己对这一块并没有理解透彻。所以如果大家看到了这篇日记,发现上面讲的不好不对,还请在下面留言。希望能和大家在前端学习的道路上一起进步。另外今天是女神节,祝愿所有女生都能成为女神,所有男生都能成为自己女神心中的“闭包”。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,099评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,473评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,229评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,570评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,427评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,335评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,737评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,392评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,693评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,730评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,512评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,349评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,750评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,017评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,290评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,706评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,904评论 2 335

推荐阅读更多精彩内容