闭包及其应用

112 阅读5分钟

闭包定义

函数执行时使用的是定义函数时生效的变量作用域,而不是调用函数时生效的变量作用域。 严格来讲,所有Javascript函数都是闭包。但由于多数函数调用与函数定义都在同一作用域内当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

闭包的作用

  1. 让函数外部可以操作、读取函数内部的变量
  2. 延长局部变量的生命周期
// 典型闭包
function test() {
 var num = 18
 return function () {
   console.log(num)
 }
}

var func = test() // 赋值给一个全局变量
func() // 18

// 也可以这样写
function test(){
  var num = 18;
  function test2(){
    console.log(num);
  }
  return test2;  // 返回一个函数
}

var func = test();   // 指针指向test
func() // 18

循环和闭包

正常情况下,下面代码期望分别输出1-5,每秒一次,每次一个。但实际上这段代码会每秒一次输出五次6。

for (var i=1; i<=5; i++) {
  setTimeout( function timer() {
    console.log( i )
  }, i*1000)
}

首先解释 6 是哪里来的。这个循环的终止条件是 i 不再 <=5。条件首次成立时 i 的值是6。因此,输出显示的是循环结束时 i 的最终值。
定时器的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(..,0) ,所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个6。

以上代码的缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i 。 有一个 i 。

改进这段代码:在循环过程中使用IIFE声明并立即执行一个函数来创建作用域。并且在每个独立的作用域中有自己的变量,用来在每个迭代中储存 i 的值。

for (var i = 1; i <=5; i++) {
  (function () {
    var j = i
    setTimeout(function timer() {
      console.log(j)
    },j*1000)
  })()
}

// 进一步改进
for (var i = 1; i <= 5; i++) {
  (function (j){
    setTimeout(function timer() {
      console.log(j)
    },j*1000)
  })(i)
}

以上内容来自 《你不知道的JavaScript 上卷 第五章-作用域闭包》

内存图

普通嵌套函数内存图

function foo() {
  function bar() {
    console.log(''bar)
  }
  return bar
}
var fn = foo()
fn()

普通嵌套函数.png

闭包函数内存图

function foo() {
  var name = 'foo' 
  function bar() {
    console.log('bar')
  }
  return bar
}

var fn = foo()
fn()

闭包函数内存图.png

闭包的使用场景

匿名函数自调用

两个函数自成一个空间,互不影响;不污染全局空间;内部所有的临时变量执行完毕都会释放,不占内存。
这种技术限制了向全局作用域中添加过多变量和函数,避免了命名冲突的后果。在多人开发的情况下,每个人在各自私有作用域中写自己的代码,即使变量名一样,也不会出现命名冲突。

(function test() {
  var age = 18
  console.log(age)
})()
//18

(function test() {
  var age = 20
  console.log(age)
})()
//20

(function (num) {
  console.log(num)
})(12)
// 12

链式作用域

function f1() {
 var n = 999
 function f2() {
  alert(n) // 999
  }
}

上面代码中,函数 f2 就被包括在函数 f1 内部,这时 f1 内部的所有局部变量对 f2 都是可见的。但是反过来不行, f2 内部的局部变量,对 f1 就是不可见的。这就是 JavaScript 特有的链式作用域结构,子对象会一级一级地向上寻找父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。

// 写法1:
var num = 18
(function () {
  (function () {
    (function () {
      (function () {
        console.log(num)
      })()
    })()
  })()
})()

// 写法2:
(function () {
  (function () {
    (function () {
      (function () {
        var num = 18
        console.log(num)
      })()
    })()
  })()
})()

写法1的性能不如写法2,因为会一层层在作用域中遍历查找,不如在当前作用域定义一个变量或者传进一个参数。对变量 num 的访问实际上都是对整条作用域链的遍历查找
先查找最近的作用域,没有找到就接着一层一层往外找,如果在某个作用域找到了变量就会结束本次查找,找不到就报错 not defined

注意:每次访问都是对作用域链的一次遍历查找,其中全局作用域是最耗费时间的。局部作用域查找速度要远远大于全局作用域查找速度,所以高级程序设计一般是尽量避免全局查找。

提高变量查找效率

下面代码需要在 window 众多属性中遍历查找 document,我们可以把 document 赋值给一个变量。把 document 全局变量,变成局部变量,缩短作用域链查找的长度,以提高性能。

var btn = document.getElementById('btn')

// 写法1
(function () {
  var d = document
  var btn1 = d.getElementById('btn')
  var btn2 = d.getElementById('btn')
  var btn3 = d.getElementById('btn')
  var btn4 = d.getElementById('btn')
})();

// 写法2
(function (d) {
  var btn1 = d.getElementById('btn')
  var btn2 = d.getElementById('btn')
  var btn3 = d.getElementById('btn')
  var btn4 = d.getElementById('btn')
})(document)

for 循环的排他思想

根据变量作用域访问原则:就近原则,从内部往外部查找变量。因此创造一个 function 这个封闭的作用域,于是 i 就只会在当前作用域查找赋值,就不会去外部访问提升到全局的全局变量了。

var aLis = document.getElementsByTagName('li')

// 传统方法
for(var i = 0; i < aLis.length; i++) {
  aLis[i].onmouseover = function () {
    // 宁可错杀三千,不能放过一个 先把所有的li的className清除
    for(var j = 0; j < aLis.length; j++){
      aLis[j].className = ''
    }
    this.className = 'current'
  }
}

// 闭包方法
var lastone = 0 // 记录点击前选中li对应的索引
for(var i = 0; i < aLis.length; i++) {    // 此处 i 是全局的
  (function (i) {   // 接收全局变量i
    aLis[i].onclick = function () {  // i优先查找局部的变量,能找到就不再往外查找
      // 清除
      aLis[lastone].className = ''
      // 设置
      this.className = 'current'
      // 赋值
      lastone = i
    }
  })(i) // 此处 i 是全局的
}

实现模块化

将所有的数据和功能都封装在一个函数内部(私有的)只向外界暴露一个包含多个方法的对象或函数。模块使用者只需要通过模块暴露的对象调用方法来实现对应的功能。

第一种写法

function myTool() {
  // 私有数据 外界不能擅自改变这个变量
  var money = 100
  // 操作数据的函数
  function get() {
    money = money * 10
    console.log('挣了一笔钱,总金额是:' + money)
  }
  function spend() {
    money -= 10
    console.log('挣了一笔钱,总金额是:' + money)
  }
  // 向外部暴露方法
  return {
    'get' : get,
    'spend' : spend
  }
}

var fn = myTool()
fn.get() // 1000
fn.spend() // 990

👍 第二种写法

直接向外界暴露一个对象,对象里面是方法。直接挂载到 window 上,外部直接使用 myTool. 去执行相应方法。

(function myTool(w) {
  var money = 100

  function get() {
    money = money * 10
    console.log('挣了一笔钱,总金额是:' + money)
  }
  function spend() {
    money -= 10
    console.log('挣了一笔钱,总金额是:' + money)
  }

  w.myTool = {
    'get':get,
    'speed':spend
  }
})(window)

// 使用
myTool.get()
myTool.speed()

节流

throttle 函数执行会返回一个包含定时器的函数,onresize 事件执行该函数从而调用定时器,而定时器限制了它内部回调函数的执行。

window.onresize = throttle(sayHi,200)

function sayHi() {
  console.log('hi!')
}

function throttle(fn,delay) {
  var timer = null
  return function() {
    clearTimeout(timer)
    timer = setTimeout(fn,delay)
  }
}

私有属性和私有方法

// 构造函数模式
(function() {
  var age = 100
  function run() {
    return 'runing...'
  }
  // 构造方法 Box 是全局变量
  Box = function (){}
  Box.prototype.go = function (){
    return age + run()
  }
})()
var box = new Box()
box.go() // 100runing...

上面的对象声明,采用表达式声明方式 Box = function (){} 而不是函数声明方式 function Box (){} 是因为采用后一种方式就变成私有函数,无法在全局访问到(后面需要 new 访问 Box

// 原型对象模式
(function () {
  var user = '';
  Person = function (value) {
    user = value;
  };
  Person.prototype.getUser = function () {
    return user;
  };
  Person.prototype.setUser = function (value) {
    user = value;
  }
})();

使用了 prototype 导致方法共享了,而 user 也就变成静态属性了。(所谓静态属性,即共享于不同对象中的属性)。

// 单例模式
var box = function () {
  var age = 100; // 私有变量
  function run() { // 私有函数
    return '运行中...'
  }
  return { // 直接返回对象
    go:function (){ // 对外公共接口的特权方法
      return age + run()
    }
  };
}();

alert(box.go()) // 100运行中...

闭包的注意事项

释放内存

function fn1() {
 var num = [999999999]
 function fn2() {
   console.log(num)
 }
 return fn2
}

var f = fn1()
f()

f = null

使用闭包的同时容易形成循环引用;如果闭包的作用域中保存的是 DOM 节点,这时就可能造成内存泄漏;但这不是闭包的问题,也不是 JavaScript 的问题,而是浏览器垃圾回收机制采用的是引用计数策略。
解决方法:把造成循环引用的变量设为 null ,而不用的变量及时释放 f = null

闭包中的 this 指向

this 对象是在运行时基于函数的执行环境绑定的。如果 this 在全局范围内,this 指向 window,如果在对象内部就指向这个对象。闭包在运行时指向 window ,因为闭包并不属于这个对象的属性或方法。

var user = 'hello window'
var box = {
  user:'hello box',
  getUser:function (){
    console.log(this.user) // hello box
    return function() {
      console.log(this.user) // hello window
    }
  }
}

box.getUser() 谁调用 this 就指向谁。此时 this 指向 box 所以 this.user'hello box'box.getUser()() 包含两个过程,首先 box.getUser() 执行得到函数 function() { console.log(this.user)} ,接着返回的这个函数再执行得到 'hello window',因为返回的函数中的 this 指向 window。 让闭包中的 this 指向对象 box 可以这样做:

// 1、利用对象冒充
box.getUser().call(box)

// 2、备份this
var user = 'hello window'
var box = {
  user:'hello box',
  getUser:function() {
    // 此处this指向box
    var that = this
    return function() {
      console.log(that.user)
    }
  }
}

box.getUser()(); //hello box