【JavaScript】这次把闭包给你讲的明明白白

前言

有关闭包的文章,网上的讲解可以说是非常多,但并不是很容易读懂,这里,我接上一篇文章《深入理解JS中的词法作用域与作用域链》,讲解一下我对闭包的理解和总结,希望看到这篇文章的小伙伴能够对闭包产生新的认识,如果有不对的地方欢迎指正。

1 理解闭包

闭包是指具有一个封闭对外不公开包裹结构,或空间

在JS中,函数可以构成闭包,因为外部是不能访问到函数内部数据,对外封闭不公开,且函数通常是包裹一段代码。

我们可以理解为闭包是函数在特定情况下执行产生的一种现象。

2 解决的问题

我们都知道,根据作用域查找规则,外部是不能访问到函数内部数据,如果我们想访问函数内部数据,可以借助函数中的return

  function f1(){
    var num = 10;
    return num;
  }
  var res = f1();
  console.log(res); // 10
复制代码

借助return可以访问到函数内部数据,但存在一个问题:数据不能被二次访问。因为第二次访问时候是再次调用该函数,函数中的代码才会再次返回,这个我们通过生成随机数可以很好的证明:

function f1() {
  var num = Math.random();
  return num;
}
var res = f1();
var res2 = f1();
console.log(res + '\n' + res2);
复制代码

输出结果如下,我们发现两次的输出结果并不一样:

image.png

无论我们怎样执行,两次的随机数结果都不同,这种输出结果显然不好。如果我们想让函数只执行一次,我们该怎么做呢?我们可以f1函数中嵌套一个函数,嵌套的内部函数是可以访问f1函数变量的。

function f1(){
  var num = Math.random();
  
  function f2(){
    return num 
  }
  
  return f2
}

var f = f1();
var res1 = f();
var res2 = f();
console.log(res + '\n' + res2);
复制代码

此时的输出结果如下:

image.png

这就产生了闭包,我们试着分析一下这段代码:

  • 全局f1函数在0级作用域链上,f1函数是一个一级链,f1函数中有一个变量num,还有一个函数体f2

  • f2是二级链,通过return将f2当做一个值返回给f1函数。

  • f1函数执行后,将f2的引用赋值给f,执行f函数,输出num变量。

正常来说,当f1函数调用完毕,其作用域是被销毁的,而通过闭包我们将f2给了ff2函数内仍然持对num的引用,num仍然存活内存中,延长了内部函数局部变量生命周期。在当f调用,num是可以访问到的。

image.png

其实,闭包也就是使用了链式访问技巧,0级链无法访问一级链数据,我们通过间接0级链操作二级链的函数,来访问一级链数据。

闭包解决的问题是:让函数外部访问到函数内部的数据。

3 产生条件

再看上面这段代码,我们来分析闭包是如何产生的:

  function f1() {
    var num = Math.random();
    function f2() {
        return num;
    }
    
    return f2;
}

f1()
复制代码

我们通过chrome调试工具查看这段代码,发现当代码运行到外部函数f1定义时,就会产生了闭包:

image.png

即产生闭包(Closure)需要满足三个条件:

  • 函数嵌套
  • 内部函数引用外部函数数据(变量或对象)
  • 外部函数调用

闭包(Closure)到底是个啥?闭包本质:

内部函数里的一个对象,对象里边包含着被引用的变量。 所谓闭包就是一种引用关系,该引用关系存在内部函数中,内部函数引用外部函数变量的的对象

4 基本结构

上面我们说了,闭包就是间接获得函数内部数据使用权利,我们可以总结出常见的闭包结构,一般来说,常见的闭包结构有三种。

4.1 return另一函数

写一个函数,函数内部定义一个新函数,返回新函数,用新函数获得函数内部数据。

function f1(){
  var a = 0
  function f2(){
    a++
    console.log(a)
  }
  return f2
}
var f = f1();
f(); // 1
f(); // 2
复制代码

image.png

4.2 return绑定多个函数的对象

写一个函数,函数内定义一个对象,对象中绑定多个方法,返回对象,利用对象的方法访问函数内部数据。

eg : 如何获得超过一个数据?

function f1(){
  var num1 = Math.random();
  var num2 = Math.random();
  
  return{
    num1:function(){
      return num1;
    },
    num2:function(){
      return num2
    }
  }
}

f1()
// {num1: ƒ, num2: ƒ}

f = f1()
f.num1()
f.num2()
复制代码

image.png

eg: 如何读取一个数据和修改一个数据?

function f1(){
  var num =Math.random();
  return {
    get_num:function(){
      return num;
    },
    set_num:function(value){
      // 此时num访问的是f1函数中的num
      num = value;
    }
  }
}

var f = f1();

// 读取函数中的值
var num = f.get_num();
console.log(num);
// 0.3919299622715364

// 设置函数中的值
f.get_num(123);
num = f.get_num();
console.log(num);
//123
复制代码

image.png

4.3 将函数实参传递给另一函数

函数的实参,也就是函数中局部变量。

function delay(msg){
  setTimeout(function(){
    console.log(msg)
  },2000)
}
delay('开启计时器')
复制代码

image.png

5 应用

5.1 模拟私有变量

我们都知道JS是基于对象的语言,JS强调的是对象,而非类的概念,在ES6中,可以通过class关键字模拟类,生成对象实例。

通过class模拟出来的类,仍然无法实现传统面向对象语言中的一些能力 —— 比如私有变量的定义和使用

我们通过看这样一个User类来了解私有变量(伪代码,不能直接运行)

class User{
  constructor(username,password){
  // 用户名
  this.username = username
  // 密码
  this.password = password
  }
  
  login(){
    // 使用axious进行登录请求
    axios({
      method: 'GET',
      url: 'http://127.0.0.1/server', 
      params: {
        username,
        password
      },
    }).then(response => {
      console.log(response);
    });
  }
}
复制代码

在这个User类里,我们定义了一些属性,和一个login方法,我们尝试输出password这个属性。

  let user = new User('小明',123456)
  user.password  // 123465
复制代码

我们发现,登录密码这么关键敏感的信息,竟然可以通过一个简单的属性就可以拿到,这就意味着,后面人只有拿到user这个对象,就可以非常轻松的获取,甚至改写他的密码。 在实际的业务开发中,这是一个非常危险的操作,我们需要从代码的层面保护password

password这样变量,我们希望它只在函数内部,或者对象内部方法访问到,外部无法触及。 这样的变量,就是私有变量,私有变量一般使用 _ 或双 _ 定义。

在类里声明变量的私有性,我们可以借助闭包实现,我们的思路就是把我们把私有变量放在最外层立即执行函数中,并通过立即执行User这个函数,创造了一个闭包作用域的环境

// 利用IIFE生成闭包,返回user类
const User = (function () {
    // 定义私有变量_password
    let _password

    class User {
        constructor(username, password) {
            // 初始化私有变量_password
            _password = password
            this.username = username
        }

        login() {
            console.log(this.username, _password)

        }
    }

    return User
})()

let user = new User('小明',123465)
console.log(user.username); // 小明
console.log(user.password); // undefined
console.log(user._password); //undefined
user.login(); // 小明 undefined
复制代码

在这段代码中,私有变量_password被好好的保护在User这个立即执行函数内部,此时实例暴露的属性已经没有_password,通过闭包,我们成功利用了自由变量模拟私有变量的效果。

5.2 柯里化

定义一个函数,该函数返回一个函数。 柯里化是把接收 n个参数的1个函数改造为只接收1个参数的n个互相嵌套的函数的过程。也就是从fn(a,b,c)变成fn(a)(b)(c)

我们通过以下案例进行深入理解:以慕课网为例,我们使用site(站点)、type(课程类型)、name(课程名称)三个字符串拼接的方式为课程生成一个完整版名称。对应方法如下:

function generateName(site,type,name){
  return site + type + name
}
复制代码

我们看到这个函数需要传递三个参数,此时如果我是课程运营负责人,如我只负责“体系课”的业务,那么我每次生成课程时,都会固定传参site,像这样传参:

generateName('体系课',type,name)
复制代码

如果我是细分工种的前端助教,我仅仅负责“体系课”站点下的“前端”课程,那么我进行传参就是这样:

generateName('体系课','前端',name)
复制代码

我们不难发现,调用generateName时,真正的变量只有一个,但是我每次不得不把前两个参数手动传一遍。此时,我们的柯里化就出现了,柯里化可以帮助我们在必要情况下,记住一部分参数。

function generateName(site){
  // var site = '体系课'
  return function(type){
    // var type = '前端'
    return function(name){
      // var name = '零基础就业班'
      return prefix + type + name
    }
  }
}

// 生成体系课专属函数
var salesName = generateName('体系课');

// “记住”site,生成体系课前端课程专属函数
var salesBabyName = salesName('前端')

// 输出 '体系课前端零基础就业班'
res = salesBabyName('零基础就业班')
console.log(res)
复制代码

我们可以看到,在生成体系课专属函数中,我们将site作为实参传递给generateName函数中,将site的值保留在generateName内部作用域中。

在生成体系课前端课程函数中,将type的值保留在salesBabyName函数中,最终调用salesBabyName函数,输出。

这样一来,原有的generateName (site, type, name)函数经过柯里化变成了generateName(site)(type)(name)。通过后者这种形式,我们可以记住一部分形参,选择性的传递参数,从而编写出更符合预期,复用性更高的函数。

function generateName(site){
   // var site = '实战课'
   return function(type){
     // var type = 'Java'
     return function(name){
       // var name = '零基础'
       return site + type + name
      }
    }
}
  
// "记住“site和type,生成实战课java专属函数
var shiZhanName = generateName('实战课')('Java')
console.log(shiZhanName);

// 输出 '实战课java零基础'
var res = shiZhanName('零基础')
console.log(res)

 // 啥也不记,直接生成一个完整课程
var itemFullName = generateName('实战课')('大数据')('零基础')
console.log(itemFullName);
  
复制代码

5.3 偏函数

偏函数和柯里化类似,如果理解了柯里化,那么偏函数就小菜一碟了。

柯里化是将一个n个参数的函数转化成n个单参数函数,也就是我们前面说过的将fn(a,b,c)转化成fn(a)(b)(c)的过程。这里假如你有三个入参,你得嵌套三层函数,且每层函数只能有一个入参。柯里化的目标是把函数拆解为精准的n部分

偏函数相比之下就比较随意了,偏函数是固定函数中的某一个或几个参数,然后返回一个新的函数。假如你有三个入参,你可以只固定一个入参,然后返回另一个入参函数。也就是说,偏函数应用是不强调 “单参数” 这个概念的。它的目标仅仅是把函数的入参拆解为两部分

仍然是上面的例子,原函数形式调用:

function generateName(site,type,name){
  return site + type + name;
}

// 调用时传入三个参数
var itemFullName = generateName('体系课', '前端', '2022')
复制代码

偏函数改造:

function generateName(site){
    return function(type,name){
      return site + type + name
    }
}
// 把3个参数分两部分传入
var itemFullName = generateName('体系课')('前端', '2022')
复制代码

5.4 防抖

在浏览器的各种事件中,有一些容易频繁触发的事件,比如scrollresize、鼠标事件(比如 mousemovemouseover)、键盘事件(keyupkeydown )等。频繁触发回调导致大量的计算会引发页面抖动甚至卡顿,影响浏览器性能。防抖和节流就是控制事件触发的频率的两种手段。

防抖的中心思想是:在某段时间内,不管你触发了多少次回调,我都只执行最后一次。

// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(fn, delay) {
  // 定时器
  let timer = null
  
  // 将debounce处理结果当作函数返回
  return function () {
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 每次事件被触发时,都去清除之前的旧定时器
    if(timer) {
        clearTimeout(timer)
    }
    // 设立新定时器
    timer = setTimeout(function () {
      fn.apply(context, args)
    }, delay)
  }
}
// 用debounce来包装scroll的回调
const better_scroll = debounce(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
复制代码

5.5 节流

节流的中心思想是:在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应,也就是隔一段时间执行一次。

// fn是我们需要包装的事件回调, interval是时间间隔的阈值
function throttle(fn, interval) {
  // last为上一次触发回调的时间
  let last = 0
  
  // 将throttle处理结果当作函数返回
  return function () {
      // 保留调用时的this上下文
      let context = this
      // 保留调用时传入的参数
      let args = arguments
      // 记录本次触发回调的时间
      let now = +new Date()
      
      // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
      if (now - last >= interval) {
      // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
          last = now;
          fn.apply(context, args);
      }
    }
}
// 用throttle来包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
复制代码

6 性能问题

6.1 内存泄漏

该释放的变量没有被释放,导致内存占用不断攀高,带来性能恶化,系统崩溃等一系列问题,这种显现叫做内存泄漏。

我们都知道函数执行需要内存,那么函数中定义的变量,会在函数执行结束后自动回收。凡是因为闭包结构,被引用的数据,如果还有变量引用这些数据,那么这些数据就不会被回收。

我们来看以下例子:

function f1(){
    var num  = Math.randon();
    function f2(){
      return num
    } 
    return f2
}

var f = f1();
f();

复制代码

上面这段代码,f2函数中存在对变量num的引用,所以num变量并不会回收,也就会造成内存泄漏。 因此,在函数调用后,最好把外部引用关系置空,如下:

function f1(){
    var num  = Math.randon();
    function f2(){
      return num
    } 
    return f2
}

var f = f1();
f();
f = null;
复制代码

以上,不规范的使用闭包(不置空),可能会造成内存泄漏。 事实上,单纯由闭包导致的内存泄漏,极少极少。内存泄漏大多原因是由于代码不规范导致。

6.2 常见的内存泄漏

6.21 不必要的全局变量

function f1() {
  name = '小明'
}
复制代码

在非严格模式下引用未声明的变量,会在全局对象中创建一个新变量,在浏览器中,全局对象是window,这就意味着name这个变量将泄漏到全局。全局变量是在网页关闭时才会释放,这样的变量一多,内存压力也会随之增高。

6.22 遗忘清理的计时器

程序中我们经常会用到计时器,也就是setIntervalsetTimeout

var timeId = setInterval(function(){
  // 函数体
},1000)
复制代码

在计时器中,定时器内部逻辑是是无穷无尽的,当定时器囊括的函数逻辑不再被需要、而我们又忘记手动清除定时器时,它们就会永远保持对内存的占用。因此当我们使用定时器时,一定要明确计时器在何时会被清除,并使用 clearInterval(timeId)手动清除定时器。

6.23 遗忘的dom元素引用

var divObj = document.getElementById('mydiv')

// dom删除myDiv
document.body.removeChild(divObj);
console.log(divObj);
// 能console出整个div 说明没有被回收,引用存在

// 移出引用
divObj = null;
console.log(divObj) 
// null
复制代码

7 闭包与循环体

闭包和循环体的结合,是闭包最为经典的一种考察方式。

7.1 这段代码输出啥

我们来看一个大家非常熟悉的题目,以上6行代码输出什么?

for(var i=0; i<5; i++){
  setTimeout(function(){
    console.log(i)
  },1000)
}
console.log(i)
复制代码

如果你是刚入门的新手,你可能会给出这样的答案:

0 1 2 3 4 5  
复制代码

给出这样答案的同学,内心一般都是这样想:for循环输出了0-4个i的值,最后一行console打印5,setTimeout这个好像在哪见过,但具体咋回事印象不深了,干脆直接忽略好了。

对于基础还不错的同学,对于setTimeout函数用法特性还有印象,很快就给出了“进化版”答案:

5 0 1 2 3 4  
复制代码

这一部分的同学是这样想的:for循环逐个输出0-4的值,但是setTimeout把输入延迟了1s,所以最后一行先执行,先输出5,然后过了1000ms,0-4会逐个输出。

如果你对JS中的for循环、同步与异步区别、变量作用域、闭包有正确理解,就知道正确答案应该是:

5 5 5 5 5 5 
复制代码

我们试着分析一下正确答案,seTimeout内函数延迟1000ms后执行,最后一行console先输出,最后一行输出5,所以第一个值是5。

for(var i =0;i<5;i++){
  // 5<5? 不满足 
}
console.log(i) // 5
复制代码

for循环里setTimeout执行了5次,函数延迟1000ms执行,大家看这个函数,它自身作用域压根就没有i这个变量,根据作用域链查找规则,要想输出i,需要去上层查找。

setTimeout(function() {
   console.log(i);
}, 1000);
复制代码

但是,这个函数第一次被执行也是1000ms以后的事情了,此时它试图向上一层作用域(这里也就是全局作用域)去找一个叫i的变量,此时for循环已执行完毕,i也进入了最终状态5。所以当1000ms后,这个函数真正被执行的时候,引用到的i值已经是5了。 此时,这段代码的作用域状态示意如下:

image.png

对应的作用域关系如下:

image.png

接下来的连续四次,都会有一个一模一样的setTimeout回调被执行,它输出的也是同一个全局的i,所以说每一次输出都是5。

7.2 改造方法

循环了五次,每次却输出一个值,这种输出效果显然不好。如果我们希望让i从0-4依次被输出,我们改如何改造呢?

方案一:利用setTimeout中第三个参数

开头我们先复习一下setTimeout参数用法:

setTimeout(function(arg1,arg2){
  console.log(arg1);
  console.log(arg2);
},delay,arg1,arg2)
复制代码
  • function(必须):调用函数执行的代码块
  • delay(可选):函数调用延迟的毫秒值,默认是0,意味着马上执行
  • arg1,...arg2(可选):附加参数,当计时器启动时,会作为参数传递给function

我们来看例子:

setTimeout(function(a,b){
  console.log(a);  // 1
  console.log(b);  // 2
},1000,1,2)
复制代码

需要注意的一点是,附加参数只支持在ie9及以上浏览器,如要兼容,需要引入一段MDN提供的兼容旧IE代码

利用setTimeout的第三个参数,i作为形参传递给setTimeout的j,由于每次传入的参数是从for循环里面取到的值,所以会依次输出0~4:

for(var i=0; i<5; i++){
  setTimeout(function(j){
    console.log(j) // 0 1 2 3 4 
  },1000,i)
}
复制代码

方案二:使用闭包

使用闭包,我们往往会用到匿名函数。我们先来复习一下匿名函数。匿名函数也叫一次性函数,她在函数定义时执行,且只执行一次。我们将匿将函数作为实参传递给另一个函数调用

我们在setTimeout外面套一个匿名函数,利用匿名函数的实参来缓存每一个循环的i值。

for(var i= 0; i<5; i++){
  (function(j){
    setTimeout(function(){
      console.log(j)
    },1000)
  })(i)
}
复制代码

当输出j时,引用的是外部函数传递的变量i这个i是根据循环来的,执行setTimeout时已经确定了里面i的值,进而确定了j的值。

方案三:使用let

for(let i= 0; i<5; i++){
  setTimeout(function(){
    console.log(i)
  },1000)
 }
复制代码

for循环每次循环产生一个新的块级作用域,每个块级作用域的变量是不同的。函数输出的是自己的上一级(循环产生的块级作用域)下i的值

8 总结

  • 闭包:具有对外封闭不公开的包裹结构或空间,函数可以构成闭包。
  • 解决的问题:间接访问函数中的数据、延长内部函数局部变量的生命周期。
  • 闭包本质:是一种引用关系,该引用关系存在于函数内部中,内部函数引用变量的对象
  • 产生三要素:函数嵌套、内部函数引用外部函数数据、外部函数调用。
  • 基本结构:rerurn另一函数、return绑定多个函数的对象、将函数实参传递给另一函数。
  • 作用:模拟私有变量、柯里化、偏函数、防抖、节流。
  • 模拟私有变量:将私有变量放在外在的立即执行函数中,并通过立即执行U这个函数,创造一个闭包环境(私有变量:只允许函数内部,或对象方法访问的变量)。
  • 柯里化:把接受n个参数的一个函数转化成只接受一个参数n个函数互相嵌套的函数过程,目标是把函数拆解为精准的n部分,也就是将fn(a,b,c)转化成fn(a)(b)(c)的过程。
  • 偏函数:固定函数中的某一个或几个参数,然后返回一个新的函数,目标是把函数的入参拆解为两部分。
  • 防抖:只执行最后一次
  • 节流:隔一段时间执行一次
  • 不规范的使用闭包会造成内存溢出,解决方案:将外部引用关系=null

结语

本篇文章就到此为止啦,由于本人经验水平有限,难免会有纰漏,对此欢迎指正。如觉得本文对你有帮助的话,欢迎点赞收藏❤❤❤,写作不易,持续输出的背后是无数个日夜的积累,您的点赞是持续写作的动力,感谢支持。

猜你喜欢

转载自juejin.im/post/7085165134993162253