[JavaScript] Esta vez, les hablaré sobre los cierres claramente

prefacio

Hay artículos sobre el cierre de paquetes. Hay muchas explicaciones en Internet, pero no son muy fáciles de entender. Aquí, seguiré un artículo "Comprensión profunda del alcance léxico y la cadena de alcance en JS" para explicar mi comprensión de La comprensión y resumen de los cierres, espero que los amigos que lean este artículo puedan tener una nueva comprensión de los cierres. Si hay algo mal, corríjame.

1 Comprender los cierres

Un cierre se refiere a una estructura o espacio envolvente que está cerrado y no abierto al mundo exterior .

En JS, las funciones pueden formar cierres, porque no se puede acceder a los datos dentro de la función desde el exterior, no está abierta al mundo exterior y la función generalmente envuelve una pieza de código.

Podemos entender que un cierre es un fenómeno generado por la ejecución de una función en una situación específica.

2 Problemas resueltos

Todos sabemos que de acuerdo con las reglas de búsqueda de alcance, los datos dentro de la función no se pueden acceder desde el exterior.Si queremos acceder a los datos dentro de la función, podemos usar lo siguiente en la función return:

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

Con la ayuda de returnlos datos dentro de la función, hay un problema: no se puede acceder a los datos dos veces. Debido a que la función se vuelve a llamar en la segunda visita , el código de la función regresará nuevamente, lo que podemos probar muy bien generando números aleatorios:

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

Los resultados de salida son los siguientes, encontramos que los resultados de salida no son los mismos dos veces:

imagen.png

No importa cómo lo ejecutemos, el resultado del número aleatorio es dos veces diferente, lo que obviamente no es bueno. Si queremos que la función se ejecute una sola vez, ¿cómo podemos hacerlo? Podemos anidar una función dentro de f1una función , y la función interna anidada puede acceder a la variable de función 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);
复制代码

La salida en este momento es la siguiente:

imagen.png

Esto crea el cierre, intentemos analizar este código:

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

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

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

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

imagen.png

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

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

3 产生条件

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

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

f1()
复制代码

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

imagen.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
复制代码

imagen.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()
复制代码

imagen.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
复制代码

imagen.png

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

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

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

imagen.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了。 此时,这段代码的作用域状态示意如下:

imagen.png

对应的作用域关系如下:

imagen.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

结语

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

Supongo que te gusta

Origin juejin.im/post/7085165134993162253
Recomendado
Clasificación