让前端飞Web Developer前端之路

请不要再问我闭包了!

2018-08-19  本文已影响7人  黎贝卡beka

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

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

一、块级作用域

// 块声明 由一对花括号界定
{ 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 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
闭包

上一篇下一篇

猜你喜欢

热点阅读