浅谈闭包

39 阅读10分钟

闭包概念(MDN)

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

为什么会有闭包

juejin.cn/post/725050…

在编译阶段,v8不会对所有代码进行编译,要不然速度会很慢,严重影响用户体验,所以采用一种“惰性编译”或者“惰性解析”,也就是说 v8默认不会对函数内部的代码进行编译,只有当函数被执行前,才会进行编译。

 

而闭包的问题指的是:由于子函数使用到了父函数的变量,导致父函数在执行完成以后,它内部被子函数引用的变量无法及时在内存中被释放。

 

而闭包问题产生的根本原因是 javascript中本身的特性:

  1. 可以在 JavaScript 函数内部定义新的函数;

  2. 内部函数中访问父函数中定义的变量;

  3. 因为 JavaScript 中的函数是一等公民,所以函数可以作为另外一个函数的返回值。

 

既然由于javascript本身的这种特性就会出现闭包的问题,那么我们就要想办法解决闭包问题,那么“预编译“ 或者“预解析” 就出现了,

 

预编译具体方案: 在编译阶段,v8不会完全不解析函数,而是预解析函数,简单理解来说,就是判断一下父函数中是否有被子函数引用的变量,如果有的话,就需要把这个变量copy一份到 堆内存中,同时子函数本身也是一个对象,它会被存在堆内存中,这样即使父函数执行完成,内存被释放以后,子函数在执行的时候,依然可以从堆内存中访问copy过来的变量。

 

此时代码还未开始运行,我们查看窗口右侧,可以看到不少信息。这里我们主要关注ScopeCall StackCall Stack是函数调用栈,实际上也就是执行上下文的执行栈,其中有一个(anonymous),这个其实就是刚刚所说的全局上下文。

我们发现此时Scope中已经出现了我们将要声明的两个常量,这也证实了刚刚所说的JS中存在编译阶段这个事实。但虽然从一开始就知道有这两个变量了,但如果我们尝试在常量声明之前就访问它的话还是会产生报错,这是因为对于letconst声明的变量存在 “暂时性死区” 的限制。

我们点击一下,可以看到Scope中的calcSum已经被赋值了,也就是说第1~3行的常量声明执行完毕。但此时代码直接跳到了第 12 行,也就是说第 5 行的addOne函数声明直接被跳过了,这其实就是因为刚刚提到的函数提升现象。那么这个函数为什么没有出现在Scope中呢?这是因为函数声明会被放在Window对象中,也就是Scope里面的那个Global。我们刷新一下网页,回到代码尚未开始运行的时刻,打开Global标签,可以看到addOne函数已经被添加在其中了。

ScriptGlobal的区别在于Script是单个JS文件的全局作用域,而Global整个html中所有JS文件共享的全局对象(在浏览器中即Window 。这也就意味着同个 html 中其他的 Script 文件也可以访问我们声明的addOne函数,只要它们是在这个文件之后运行的。

言归正传,我们继续运行代码,此时为了给resultOfOnePlusTwo赋值,我们需要运行addOne函数得到其结果。所以点击下一步时,我们会进入addOne函数内部,此时观察执行栈,会看到addOne函数被推入了执行栈,这也就意味着我们进入了一个新的执行上下文。刚刚我们讲到执行上下文中会有本地变量、Scopethis三个要素,这些都可以在黄色框框出来的Scope部分中查看:

Local就是上下文的本地变量,其中包括函数的参数num和函数中声明的常量one

this的值为Window,因为这个函数是在非严格模式下直接调用(严格模式下为 undefined) 的。而不是作为某个对象的方法,通过obj.method()的形式调用,这种情况下this将会指向调用函数的那个对象。

Scope标签下的内容就是这个执行上下文的Scope(作用域),在这个例子中addOne函数的Scope包括本地作用域Local, 全局作用域ScriptGlobal

 

我们继续运行代码,点击下一步之后可以看到one被赋值1

 

再点一下就到了函数的 return 语句,这个函数返回了一个内层函数innerinner引用了外层函数的numone变量,也就是说出现了闭包

我们再点一下退出addOne函数的运行,此时观察Call Stack会发现,addOne的上下文已经被推出了。

同时由于我们已经求出了addOne的运行结果,所以resultOfOnePlusTwo也被赋值了,其值为我们刚刚返回的inner函数。

下一步我们想要打印resultOfOnePlusTwo的结果,那么就需要运行这个函数,所以点击下一步会进入inner函数的内部,往执行栈中推入inner函数的上下文。此时观察 Scope,会发现除了 Local, Script 和 Global 之外,出现了一个名为addOne的 Closure,也就是闭包。

点击展开可以看到Closure中的内容是inner引用的外层函数addOne中的变量numone

我们回想刚刚展示的那张关于JS的作用域的图片,再观察这个 Scope 的结构,会发现它和刚才那张图展示的作用域是类似的结构,即本地作用域 - 外层函数作用域 - 全局作用域。但是刚刚讲执行上下文时,笔者提到了执行上下文运行结束后其中的变量会被回收,那这里numone这两个变量为什么又回来了呢?这是因为在inner函数被返回之后,JS引擎为了确保它后续能够被正常地调用,会让它把自己引用的外层函数变量也携带在身上,具体来讲,inner身上有一个私有变量[[Scopes]],它所引用的外层变量会作为一个闭包被储存其中,就好像在自己身上携带了一个背包。当inner函数被运行时,JS引擎会解开它的[[Scopes]]背包作为执行上下文的 Scope。我们可以展开inner函数查看它的[[Scopes]]。这其实就是闭包背后真正的原理。

这里还有一个值得注意的点,就是有时候我们会认为作用域的结构和执行栈应该是相同的,但其实两者没有任何关系,作用域是编译时就确定的,而执行栈和执行上下文是运行时的概念。从这个例子中我们也可以看到,虽然执行栈是inner - 全局这样的结构,但作用域却是inner - addOne - 全局的结构。这说明作用域和函数运行的位置以及执行栈的情况是无关的。

闭包应用

防抖

  function debounce(func, delay) {
    let timer = null;
    return function () {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, arguments);
      }, delay);
    };
  }

节流

  const throttle = (fn, delayTime) => {
    var _start = Date.now();
    return function () {
      var _now = Date.now(), context = this, args = arguments;
      if (_now - _start >= delayTime) {
        fn.apply(context, args);
        _start = Date.now();
      }
    }
  }

发布订阅

function createPubSub() {
  // 存储事件及其对应的订阅者
  const subscribers = {};


  // 订阅事件
  function subscribe(event, callback) {
    // 如果事件不存在,则创建一个新的空数组
    if (!subscribers[event]) {
      subscribers[event] = [];
    }
    // 将回调函数添加到订阅者数组中
    subscribers[event].push(callback);
  }


  // 发布事件
  function publish(event, data) {
    // 如果事件不存在,则直接返回
    if (!subscribers[event]) {
      return;
    }
    // 遍历订阅者数组,调用每个订阅者的回调函数
    subscribers[event].forEach((callback) => {
      callback(data);
    });
  }


  // 返回订阅和发布函数
  return {
    subscribe,
    publish,
  };
}


// 使用示例
const pubSub = createPubSub();


// 订阅事件
pubSub.subscribe("event1", (data) => {
  console.log("订阅者1收到事件1的数据:", data);
});


pubSub.subscribe("event2", (data) => {
  console.log("订阅者2收到事件2的数据:", data);
});


// 发布事件
pubSub.publish("event1", "Hello");
// 输出: 订阅者1收到事件1的数据: Hello


pubSub.publish("event2", "World");
// 输出: 订阅者2收到事件2的数据: World

函数柯里化

//柯里化前
function add(a, b, c) {
  return a + b + c;
}
console.log(add(1, 2, 3)); //6
//柯里化后
function addCurried1(a) {
  return function (b) {
    return function (c) {
      return a + b + c;
    };
  };
}
//箭头函数简写
const addCurried2 = (a) => (b) => (c) => a + b + c;
console.log(addCurried1(1)(2)(3)); //6
console.log(addCurried2(1)(2)(3)); //6

promise顺序执行的实现

待定

promise的then链实现

待定

bind实现

Function.prototype.bind = function bind(context, ...params) {
    // this->fn 真正要执行的函数
    // context->obj 要给函数改变的this
    // params->[10,20] 要给函数传递的参数
    return (...args) => {
        // args->[ev] 点击行为触发,传递给匿名函数的信息,例如:事件对象    
        this.call(context, ...params.concat(args));
    };
}; 

闭包带来的问题

juejin.cn/post/726450…

juejin.cn/post/699245…

 

juejin.cn/post/717364…

 

juejin.cn/post/720894…

 

juejin.cn/post/698252…

闭包会带来内存泄漏的问题,要理解这一点,首先得了解JS的垃圾回收机制。

JS的垃圾回收机制

① 标记清除法

使用标记清除法的垃圾回收程序在运行时会标记内存中的所有变量,然后将所有存在于执行上下文中,以及被执行上下文中变量所引用的变量的标记去掉。这一步可以通过从window对象开始,递归地遍历其属性,只要某个变量能够被遍历到就说明其存在于上下文中。随后将所有仍带标记的变量销毁并回收它们的内存。

② 引用计数法 (不常用)

引用计数法的核心在于跟踪记录每个对象被引用的次数。每当存在一个变量引用了某个对象,该对象的引用 +1。相反,如果每存在一个引用该对象的变量发生更改,从而不再引用该对象时,该对象的引用 -1。当对象的引用值为 0 时,说明该对象不被任何变量所指向,即没有变量可以访问它。因此,就可以将该对象所在内存空间释放回收。

function Example(){
    let ObjectA = new Object();
    let ObjectB = new Object();


    ObjectA.p = ObjectB;
    ObjectB.p = ObjectA;   
}


Example();

该方法无法解决循环引用的问题。上述代码中的两个对象在函数结束执行后引用数都不为 0,因此不会被清理,但实际上这两个变量都无法被上下文中的其他变量访问,属于无用变量。如果函数该多次运行,产生了多个无用但不会被回收的ObjectAObjectB,则会造成内存浪费。

内存泄漏

juejin.cn/post/699245…

 

juejin.cn/post/684490…

内存泄漏意味着我们声明了大量不会被垃圾回收的变量。

闭包会导致外层函数即使运行完毕,但其上下文中的变量仍作为闭包被内层函数的[[Scopes]]引用,这些被引用的变量无法被垃圾回收。不合理地滥用闭包会导致大量的变量无法被垃圾回收,从而导致内存泄漏,影响性能。这就是闭包可能带来的问题。