一文彻底搞懂闭包

·  阅读 2993
一文彻底搞懂闭包

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战

理解闭包的一个很重要的前置知识是作用域。
所以如果你对作用域还不是很了解的话,建议看一下这篇文章 《一文彻底搞懂作用域》

什么是闭包

闭包就是每次调用外层函数时,临时创建的函数作用域对象。
因为内层函数作用域链中包含外层函数的作用域对象,且内层函数被引用,导致内层函数不会被释放,同时它又保持着对父级作用域的引用,这个时候就形成了闭包。
所以闭包通常是在函数嵌套中形成的。
例如下面的代码,就形成了闭包:

function foo (){
  var name = 'snail'
  return function(){
    console.log('my name is '+name)
  }
}
var bar = foo();
bar();
复制代码

闭包并不是一个需要学习新的语法或模式才能使用的工具或技巧,它是基于词法作用域编写代码时自然产生的结果。你甚至不需要为了闭包而创建闭包,了解了闭包,你会发现,代码中闭包随处可见。
了解闭包是为了可以根据自己的意愿来识别和影响闭包的使用。接下来我们看一下闭包常见的应用场景。

应用场景

实现块级作用域

首先我们来看这样一段代码:

function foo(){
  var result = [];
  for(var i = 0;i<10;i++){
    result[i] = function(){
      console.log(i)
    }
  }
  return result;
}
var result = foo();
result[0](); // 10
result[1](); // 10
复制代码

可以看到,每个函数并不像我们期待的那样 result[0]() 打印 0result[1]() 打印 1,以此类推。
因为 var 声明的 i 不只是属于当前的每一次循环,甚至不只是属于当前的 for 循环,因为没有块级作用域,变量 i 被提升到了函数 foo 的作用域中。所以每个函数的作用域链中都保存着同一个变量 i,而当我们执行数组中的子函数时,此时 foo 内部的循环已经结束,此时 i = 10,所以每个函数调用都会打印 10

接下来我们对 for 循环内部添加一层即时函数(又叫立即执行函数 IIFE),形成一个新的闭包环境,这样即时函数内部就保存了本次循环的 i,所以再次执行数组中子函数时,结果就像我们期望的那样 result[0]() 打印 0result[1]() 打印 1 ...

function foo(){
  var result = [];
  for(var i = 0;i<10;i++){
    (function(i){
      result[i] = function(){
        console.log(i)
      }
    })(i)
  }
  return result;
}
var result = foo();
result[0](); // 0
result[1](); // 1
复制代码

当然,ES6 中引入 let 声明变量方式,让 JavaScript 拥有了块级作用域,可以更方便的解决这样的一个问题。

保存内部状态

首先我们来看这样一段代码:

function cacheCalc(){
  var cache = new Map()
  return function (i){
    if(!cache.has(i)) cache.set(i,i*10)
    return cache.get(i)
  }
}

var calc = cacheCalc()
console.log(calc(2)) // 20
复制代码

可以看到,函数内部会使用 Map 保存已经计算过的结果(当然也可以是其他的数据结构),只有当输入数字没有被计算过时,才会计算,否则会返回之前的计算结果,这样就会避免重复计算。

而这样的技巧在 Vue3源码 中同样有使用到。代码地址
这里我在阅读源码的过程中加了一些注释,导致截图中代码行号和源文件中的不一致,但是代码并未进行任何修改。

compileToFunction.png

这里的 compileToFunction 函数会将我们编写的模板进行编译生成 render 函数,而为了避免重复编译,这里在内部创建了一个 compileCache 对象保存编译过的数据。

函数柯里化

首先说一下什么是函数柯里化?
柯里化是把接收多个参数的函数变成接收单一参数(最初函数的第一个参数)的函数,并且返回接收余下的参数且返回结果的新函数。
翻译成人话就是可以将一个接受多个参数的函数分解成多个接收单个参数的函数的技术,直到接收的参数满足了原来所需的数量后,才执行原函数的逻辑。
例如一个非常经典的面试题 => 实现 add(x)(y)(z) = x+y+z 中就用到了函数柯里化。代码如下:

function add(x){
  return function(y){
    return function(z){
      return x+y+z
    }
  }
}
console.log(add(1)(2)(3)) // 6
复制代码

再比如我们有一个函数 foo,可以将输入的数字保留两位小数。此时我们需要一个函数,可以把输入数字保留两位小数并每隔三位添加一个逗号,这个时候就可以把函数 foo 引入进来,并在之前结果的基础上添加每隔三位添加逗号的功能。

模块模式

首先我们来看这样一段代码:

function create(){
  var name = 'snail',
  hobby = ['eat','sleep','codeing']
  function say(){
    console.log('my name is '+name+'.')
  }
  function showHobby(){
    console.log(name+' like '+hobby.join(',')+'!')
  }
  return {
    say,
    showHobby
  }
}

var instance = create();
instance.say();       // my name is snail.
instance.showHobby(); // snail like eat,sleep,codeing!
复制代码

这个模式在 JavaScript 被称为模块。这里我们调用了 create 函数创建了一个模块实例,实例中含有对内部函数的引用,这样保证了内部数据变量是隐藏和私有状态,而返回值则可以看做是模块暴露出的 API。当然这里也可以直接返回方法作为模块的公共 API,·就像 JQuery 中返回的 $

缺点

因为闭包会携带包含它的函数的作用域,所以哪怕外部函数执行完成,因为内部函数保存了外部函数中变量的引用,所以外部函数依然不会被释放,所以过度使用闭包会造成内存占用过多。

好了,以上就是本文总结的闭包相关知识,你学废了吗? <( ̄︶ ̄)>

如有任何问题或建议,欢迎留言讨论!👏🏻👏🏻👏🏻

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改