【李拜天的学习笔记】一篇文章彻底讲通闭包

156 阅读7分钟

定义:

什么是闭包

JS忍者秘籍:闭包允许函数访问并操作函数外部的变量

红宝书:闭包是指有权访问另一个函数作用域中的变量的函数

MDN:闭包指的是那些能够访问自由变量的函数,自由变量指外部函数作用域中的变量

闭包(closure)是一个函数以及其捆绑的周边环境状态(词法环境)的引用的组合。换言之,闭包可以让开发者从内部函数访问外部函数的作用域。在JS里,闭包会随着函数的创建而被创建。闭包就是内层函数对外层函数变量的不释放

翻译成人话:闭包就是一个函数去引用另一个函数内的变量,因为变量被引用着,所以当另一个函数执行结束时,其对应的执行上下文弹出栈的时候,变量不会被回收,因此可以用闭包来封装一个私有变量。此外,不正当地使用闭包可能会造成内存泄漏。

闭包的特征:

  • 函数中包含函数
  • 内部函数可以访问其外层函数的作用域
  • 参数和变量不会被垃圾回收,始终留在内存中
  • 有内存的地方才有闭包

闭包的作用:

  • 保护变量不被垃圾回收机制销毁,保护闭包函数内的私有变量不受外部影响;
  • 把函数内的值保存下来,使得方法和属性的私有化

如何形成闭包?

内部的函数存在外部作用域的引用就会导致闭包的形成

下面几行代码就是闭包最简单的实现方式,在函数outerFunc中声明了一个局部变量innerVar,而在outerFunc中声明的函数innerFunc一直在引用着局部变量innerVar和全局变量outVar,因为innerFunc函数的调用,变量innerVaroutVar无法被销毁而一直保存在内存中,这就是闭包。

let outVar = 1;
function outerFunc() {
  let innerVar = 2;
  function innerFunc() {
    console.log(outVar, innerVar);
  }
  innerFunc();
}
outerFunc();

示例:

一:在函数中 return 一个函数

function funcFactory(){
  var name = "Li Baitian";
  function printName(){
    console.log(name);
  }
  return printName;
};

var myFunc = funcFactory();
myFunc();  // Li Baitian

var myFunc = funcFactory() 中的 funcFactory()创建了工厂函数的执行上下文并在其中声明了一个局部变量name与一个函数praintName,最终将函数printName的引用返回,也就是将function printName(){console.log(name)}赋值给了变量myFunc

所以,当var myFunc = funcFactory()执行完成之后,相应的函数从执行上下文的栈中弹出,通常此时变量也会被销毁,但是因为myFunc的调用,导致引用着funcFactory里面的变量,所以name不会被销毁

二:立即执行函数

var add = (function() {
  var counter = 0;
  return function (){
    return counter += 1;
  }
})();

add();
add();
add(); // 3

很简单可以看出add为立即执行函数,也是一个变量,变量值为 function(){return counter += 1}add()执行完毕之后,相应的执行上下文就会从调用栈中弹出,变量counter本应该被销毁,而最终输出为3反而证明了变量在中间过程中没有被销毁,这也是闭包作用的体现。

三:定时器打印问题

一段经典代码:

for (var i = 0; i < 5; i++) {
  setTimeout(() => {
    console.log(i);
  }, i * 1000)
}

上面的代码,我们期望的输出是:每隔一秒,依次输出0,1,2,3,4,但是得到的实际输出为5,5,5,5,5,因为ES5使用var来声明变量,带来了变量提升,全剧范围内只有一个变量i。JS单线程遇到异步的时候,代码不会先执行(会入栈),等所有同步代码执行完毕i++到5之后,异步代码才会执行,而循环结束时i=5。所以结果是5,5,5,5,5。

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

代码改成上面这样,就可以按照我们期望的方式进行工作了。这样修改之后,使用 IIFE(立即执行函数)会为每一轮循环都生成一个新的函数作用域,使得定时器函数的回调可以将新的作用域封闭在每一轮循环内部,每一轮循环内部都会含有一个具有正确值的变量可以访问。

因为闭包的存在,上面形成了五个互不干扰的私有作用域

for(let i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i)
    }, i * 1000)
}

使用ES6let也能够达到目的,因为ES6引进块级作用域,每一轮循环的变量i都是重新声明的,JS引擎会记住上一轮循环的值,初始化本轮的i时,在上一轮的基础上去进行运算

闭包的使用场景

节流

规定在一个单位时间内,只能触发一次函数,如果这个单位时间内触发多次函数,则只有一次生效

  • 鼠标不断点击触发,mousedown(单位时间内只触发一次)
  • 监听滚动事件,比如是否滑到底部自动加载更多,用throttle来判断
function throttle(fn, timeout) [
  let timer = null;
	return function (...arg) {
    if(timer) return;
    timer = setTimeout(() => {
      fn.apply(this, arg);
      timer = null;
    }, timeout)
  }
}

防抖

在某个事件触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时

  • search搜索联想,用户在不断输入值时,用防抖来节约请求资源。
  • window触发resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次
function debounce(fn, timeout) {
  let timer = null;
  return function(...arg){
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, arg);
    }, timeout)
  }
}

循环赋值

闭包使得下面代码能依次输出0 - 10,因为闭包会保护内部函数的私有变量,相当于这里生成了100个互相不干扰的私有作用域。

for(var i = 0; i < 10; i++) {
  (function(j){
    setTimeout(() => {
      console.log(j);
    }, 500);
  })(i)
}

如果将自执行函数去掉,下面函数会输出10个10,因为JS遇到异步代码(setTimeout)会先将其入栈,等同步代码执行完成后(此时i=10),再执行setTimeout内的代码。具体哪些操作是同步/异步的,可以移步参考《宏任务与微任务》

for(var i = 0; i < 10; i++){
  setTimeout(() => {
    console.log(i)
  }, 500)
}

实现函数柯里化

function curry(fn, len = fn.length) {
    return _curry(fn, len)
}

function _curry(fn, len, ...arg) {
    return function (...params) {
        let _arg = [...arg, ...params]
        if (_arg.length >= len) {
            return fn.apply(this, _arg)
        } else {
            return _curry.call(this, fn, len, ..._arg)
        }
    }
}

let fn = curry(function (a, b, c, d, e) {
    console.log(a + b + c + d + e)
})

fn(1, 2, 3, 4, 5)  // 15
fn(1, 2)(3, 4, 5)
fn(1, 2)(3)(4)(5)
fn(1)(2)(3)(4)(5)

闭包造成的内存泄漏

为什么会造成内存泄漏?

JS 垃圾回收

由于字符串、对象和数组没有固定大小,所有当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

function fn(){
  let test = new Array(100000).fill('BaiTian')
  return function(){
    console.log(test)
    return test
  }
}
let fnChild = fn()
fn2Child()

因为return的函数中存在对fn下变量test的引用,所以test变量不会被垃圾回收,这样就造成了内存泄漏。因为闭包函数会“锁定”外部函数的变量,形成私有作用域,所以闭包函数会比其它函数占用更多的内存,过多的内存占用很危险,所以请谨慎使用闭包。

对于如何监控页面中的内存泄漏以及如何分析,可以参考这篇文章 js 内存泄漏场景、如何监控以及分析