请不要再问我闭包了!

作用域永远都是任何一门编程语言中的重中之重,因为它控制着变量与参数的可见性与生命周期。

我们首先从块级作用域和函数作用域入手来看闭包。

一、块级作用域

// 块声明 由一对花括号界定
{ StatementList }

任何一对花括号中的语句集都属于一个块,在这之中定义的所有变量在代码块外都是不可见的,我们称之为块级作用域。

  1. 使用var

通过var声明的变量没有块级作用域。其表现是,在语句块里声明的变量只能是全局或者整个函数块的,你可以在语句块外面(花括号外)访问到它。换句话说,语句块不会生成一个新的作用域。

var x = 1;
{
  var x = 2;
}
console.log(x); // 输出 2

这是因为花括号{} 不会新创建一个自己的块级作用域。块中的 var x语句与块前面的var x语句作用域相同(一个作用域),相当于var x = 1; var x = 2;

另外,通过 var 定义的变量,不论其在函数中什么位置定义的,都将被视作在函数顶部定义,这一特定被称为提升(Hoisting)。

function foo() {
    console.log(a) // undefined 在变量声明之前调用不会报错
    var a = 'hello'
    console.log(a) // 'hello'
}
foo();
console.log('xx', xx); // undefined
var xx = 18;

这是因为,JavaScript引擎在解析代码的时候把变量的定义和赋值分开了,首先对变量进行提升,将变量提升到函数的顶部;但是,不对变量的赋值进行提升。过程如下:

function foo() {
  var a;
  console.log(a);
  a = 'hello';
  console.log(a);
}
var xx;
console.log('xx', xx); // undefined
xx = 18;
  1. 使用let和 const

但是在很多情境下,我们迫切的需要块级作用域的存在,也就是说在 {} 内部声明的变量只能够在 {} 内部访问到,在 {} 外部无法访问到其内部声明的变量。

相比之下,使用 let 和 const 声明的变量是有块级作用域的。

  • let允许你声明一个作用域被限制在块级中的变量、语句或者表达式。与var关键字不同的是,它声明的变量只能是全局或者整个函数块的。
  • const常量是块级作用域,很像使用 let 语句定义的变量。常量的值不能通过重新赋值来改变,并且不能重新声明。
let x = 1;
{
  let x = 2;
}
console.log(x); // 输出 1

x = 2被限制在块级作用域中, 也就是它被声明时所在的块级作用域。

const 也一样:hh

const c = 1;
{
  const c = 2;
}
console.log(c); // 输出1, 而且不会报错 因为const c = 2 所在的块级作用域是一个新的作用域
  1. 模拟块级作用域

注:for循环是一个块语句

function test(){ 
for(var i=0;i<3;i++){ 
} 
console.log(i);  // 3 这里改写成 let 可以看到会报错 i is not defined
} 
test();

可以看出,使用var声明的变量,不支持块级作用域,它只支持函数作用域,即在这个函数中的任何位置定义的变量在该函数中的任何地方都是可见的。

不用let,我们来手动模拟块级作用域。

function test() {
    (function () {
        for(var i=0;i<3;i++){ 
        } 
    })();
    console.log(i); // 输出 i is not defined,成功!
}
test();

这里,我们把for语句块放到了一个立即执行函数之中,当这个函数执行完毕,变量i自动销毁,因此,我们在块外便无法访问了(用到了函数作用域)。

在JS中,为了防止命名冲突,我们应该尽量避免使用全局变量和全局函数。

怎么避免呢?可以把要定义的所有内容放入到下面这个立即执行函数体中,相当于给它们的外层添加了一个函数作用域,该作用域之外的程序是无法访问它们的。

(function (){ 
//内容 
})();

关于块级作用域有非常经典的案例:使用let声明的变量在块级作用域内能强制执行更新变量。且看下文。

二、函数作用域

js 有函数作用域,外部是无法访问函数内部的变量的,闭包除外。

function f1(){
  var n=999;
}
console.log(n); // 报错 n is not defined

另外,变量的作用域无非就是两种:全局变量和局部变量。对于局部变量的查找,是按照链式作用域进行查找的。最简单的,函数内部可以直接读取全局变量。

   var n=999;
  function f1(){
    console.log(n);
  }
  f1(); // 999

需要注意一点,函数内部声明变量的时候,一定要使用var 或者 let 命令,实际上声明了一个全局变量。

function f1(){
 n=999;
}
f1();
console.log(n); // 999

三、理解闭包

了解函数作用域,我们知道函数外部是无法访问到函数内部的变量的;但是,出于种种原因,我们有时候需要得到函数内的局部变量,那又该如何去访问函数内部的变量呢?

function f1(){
    var n=999;
    return function() {
      console.log(n);
    } 
}
var result=f1();
result();// 输出999

上面函数中的内层函数就是闭包,就是通过建立函数来访问函数内部的局部变量。(返回的函数在其定义内部引用了局部变量 n ,根据变量的查找遵循‘链式作用域’,所以,当一个函数返回了一个函数后,其内部的局部变量还被新函数引用)。

JavaScript中的函数会形成闭包。 闭包是由函数以及创建该函数的词法环境组合而成。这个环境包含了这个闭包创建时所能访问的所有局部变量。即闭包 = 函数 + 函数能够访问的自由变量

正常来说函数被调用完之后,其内部的局部变量就会被立即销毁(垃圾回收机制);但是,当其中的局部变量被引用着,那么它会一直被保存在内存中,即使定义该变量的函数已经执行完毕。这便是闭包得以存在的原因。

四、闭包的用途

主要是用到闭包的这两个特性:

  • 可以读取函数内部的变量;
  • 让这些变量的值始终保持在内存中;
  1. 实现私有方法、私有变量(模块模式)

私有方法不仅仅有利于限制对代码的访问:还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。

创建一个计数器:

var Counter = (function() {
    var pivateCounter = 0;
    function changeBy(val) {
        pivateCounter += val;
    }
    return {
        increment: function() {
            changeBy(1);
        },
        decrement: function() {
            changeBy(-1);
        },
        getValue: function() {
            return pivateCounter;
        }
    }
})();

Counter.getValue(); // 0
Counter.increment();
Counter.getValue(); // 1

让一个匿名函数立即执行,来创建一个计数器。
这三个公共函数是共享同一个环境的闭包。多亏 JavaScript 的词法作用域,它们都可以访问 privateCounter 变量和 changeBy 函数。

创建多个计数器:不使用匿名函数;

var makeCounter = function() {
    var pivateCounter = 0;
    function changeBy(val) {
        pivateCounter += val;
    }
    return {
        increment: function(val) {
            changeBy(val);
        },
        decrement: function(val) {
            changeBy(val);
        },
        getValue: function() {
            return pivateCounter;
        }
    }
}
var Counter1 = makeCounter();
var Counter2 = makeCounter();
Counter1.getValue();
Counter1.increment(5);
Counter1.getValue();
Counter2.getValue();

请注意两个计数器 counter1 和 counter2 是如何维护它们各自的独立性的。每个闭包都是引用自己词法作用域内的变量 privateCounter 。

每次调用其中一个计数器时,通过改变这个变量的值,会改变这个闭包的词法环境。然而在一个闭包内对变量的修改,不会影响到另外一个闭包中的变量。

  1. 在循环中创建闭包:一个常见的错误
for(var i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log(i); 
    }, 1000)
}
// 定时器setTimeout中的回调函数不使用箭头函数时,内部的 this 指向 window, 这是因为setTimeout()是window中的方法,所有其回调函数执行时的作用域是全局作用域。
// 当然这里没有这个问题

上面的代码不会输出数字 0 到 9,而是会输出数字 10 十次。
当 console.log 被调用的时候,匿名函数保持对外部变量 i 的引用,此时 for循环已经结束, i 的值被修改成了 10(异步代码中的回调函数会被放进任务队列,等到调用栈空闲时,才去执行任务队列);再者,由于使用 var 声明的变量没有块级作用域,所以调用 10 次 console.log(i);其中的 i 引用的是同一个 词法作用域中的 i。

  • 再看一段代码:
let arr = [];
for(var i = 0; i < 10; i++) {
    console.log('arr[i]', i);
    arr[i] = function() { // 这里定义阶段同样是 arr[0] ~ arr[9], 只是函数体中的 i 值随着这第一段 for 循环的每次遍历一直在改变,直到循环结束
        console.log(i);
    }
}
for(var j = 0; j < 10; j++) {
      arr[j](); // 这里 arr[0]~arr[9]
}

第二段 for 循环就相当于:

for(var j = 0; j < 10; j++) {
      console.log(i);
}

在第一段for 循环中,由于使用 var 定义的变量没有块级作用域,再加上console.log(i);所在的函数与外部的变量 i 形成一个闭包(广义上说,函数都是一个闭包;闭包=函数+该函数能够访问的外部变量),for 循环形成的 10个闭包,这10个闭包引用的都是同一个作用域中的变量 i ,所以等到循环结束,这10个闭包中 i 值都为 10;即第一段循环结束时,for 循环中的 10个函数处于声明定义阶段,这时 10个函数体中 的 i 的值便为10了。(变量的查找:其定义时所处的作用域)。

  • 如何修改代码来避免引用错误

为了得到想要的结果,需要在每次循环中创建变量 i 的拷贝。还记得在块级作用域中提到的使用匿名函数立即执行来给内部代码包一层新的作用域(函数作用域)。相当于for(var j = 0; j < 10; j++) { console.log(i); }。即不会出现回调函数都引用着外部的同一个变量,因为函数作用域的包裹,使得每次循环都有自己的作用域。

外部的匿名函数会立即执行,并把 i 作为它的参数,此时函数内 e 变量就拥有了 i 的一个拷贝。当传递给 setTimeout 的回调函数执行时,它就拥有了对 e 的引用,而这个值是不会被循环改变的。

for(var i = 0; i < 10; i++) {
    (function(e) {
        setTimeout(function() {
            console.log(e);  
        }, 1000);
    })(i);
}
var arr = [];
for(var i = 0; i < 10; i++) {
    arr[i] = (function(e) { 
        console.log(e);
    })(i)
}
for(var j = 0; j < 10; j++) {
      arr[j]();
}

另外,看一下这段代码:

var arr = [];
for(var i = 0; i < 10; i++) {
    arr[i] = function() { 
        console.log(i);
    }
}
for(var i = 0; i < 10; i++) {
      arr[i](); 
}

这里第二段 for 循环对 i 重新赋值了,相当于:(容易混淆吧?!hh,仔细思考下吧)

for(var i = 0; i < 10; i++) {
      (function() { 
        console.log(i);
      })();
}

最简单的解决办法,当然是使用 let 了,有了块级作用域,一切就安全了。

在循环中创建闭包(函数),一不小心就会出现引用错误,导致拿不到自己想要的结果。

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }

运行这段代码后,您会发现它没有达到想要的效果。无论焦点在哪个input上,显示的都是关于年龄的信息。即上面我们 demo 代码中for 循环结束时 i的值。

解决这个问题的一种方案便用到了闭包的另一个用途:

  1. 函数式编程

一些案例:https://github.com/xuexueq/widgets/blob/master/toggle/index.js

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function makeHelpCallback(help) {
  return function() {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

setupHelp();

就是保留对函数的活动对象(arguments[]),通过传入一个函数,返回一个函数,来让告诉代码做什么,而不是怎么做,专注于控制状态。

最后注意一下,变量的查找与 this 指向的确定不要弄混淆了。(一个是定义时所在的作用域(链式作用域);一个是执行的所在的作用域)

function makeFunc() {
    var name = "xql";
    function displayName() {
        console.log(this); // window
        console.log(name); // xql
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc();
function makeFunc() {
    var name = "xql";
    function displayName() {
        console.log(this); // makeFunc(){}
        console.log(name); // xql
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc.bind(makeFunc)();

五、闭包的注意点

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

由于IE的js对象和DOM对象使用不同的垃圾收集方法,因此闭包在IE中会导致内存泄露问题,也就是无法销毁驻留在内存中的元素。

function closure(){
    var oDiv = document.getElementById('oDiv');//oDiv用完之后一直驻留在内存中
    oDiv.onclick = function () {
        alert('oDiv.innerHTML');//这里用oDiv导致内存泄露
    };
}
closure();
//最后应将oDiv解除引用来避免内存泄露
function closure(){
    var oDiv = document.getElementById('oDiv');
    var test = oDiv.innerHTML;
    oDiv.onclick = function () {
        alert(test);
    };
    oDiv = null;
}

References

block
闭包

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

推荐阅读更多精彩内容

  • 第2章 基本语法 2.1 概述 基本句法和变量 语句 JavaScript程序的执行单位为行(line),也就是一...
    悟名先生阅读 4,114评论 0 13
  • Lua 5.1 参考手册 by Roberto Ierusalimschy, Luiz Henrique de F...
    苏黎九歌阅读 13,727评论 0 38
  • 这是16年5月份编辑的一份比较杂乱适合自己观看的学习记录文档,今天18年5月份再次想写文章,发现简书还为我保存起的...
    Jenaral阅读 2,727评论 2 9
  • 来北京已经过八年了,中间回去过一次,走过一次那条山路。 从毕业开始,在一个不算偏但比较远的一个小山沟沟上班。连接住...
    一起生长阅读 416评论 1 0
  • 现在是吃柿子的季节,无论是在超市,还是在门口的水果店,亦或是街边的水果摊上,都能看到柿子的影子。只是,作为...
    浪雨晴阅读 999评论 3 3