【js基础巩固计划】深入理解闭包

235 阅读11分钟

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

闭包一直让人头疼的一个知识点,不太容易理解。而且使用不当还有副作用(内存泄露)。但是js代码中往往充斥着大量的闭包。因此理解闭包并合理正确的使用是很重要的。

这是js基础巩固系列文章的第三篇,旨在帮助自己巩固js相关知识,同时也希望能给大家带来些新的认识,如有疑问出入,欢迎评论区一起讨论交流

什么是闭包

深入理解作用域与作用域链一文中,我们知道了函数的词法作用域在预解析阶段就已经确认好了。在其内部属性[[Scopes]]可以体现,

如下代码:

function func() {
  let age = 18
  function inner() {
    return age
  }
  console.dir(inner)
  return inner
}
func()

image.png

其中[[Scopes]]中的 Closure(func)就是一个闭包

我们来看看官方对闭包的定义

JavaScript权威指南: 闭包是指有权访问另一个函数作用域中的变量的函数

mdn: 闭包是函数本身与其词法环境的组合

按照定义,从严格意义来讲下面的代码就是形成一个闭包(当前函数作用域内使用了外层作用域的变量)

let a = 1
function func() {
  return a
}

JavaScript权威指南中说道:所有js函数都是闭包

但是仔细一想,这好像和我们实践中的闭包不一样呀

我们来看看实践中比较常见的一段代码:

let a = 1
function outer() {
    let outer = 'outer'
    function inner() {
        let b = 'b'
        console.log(a)
        console.log(outer)
        console.log(b)
    }
    console.dir(inner)
    return inner
}
outer()

image.png

通过上面的[[Closure]]属性我们可以发现几个点:

  1. 全局作用域中声明的变量a没有出现在[[Closure]]闭包中
  2. inner函数自身作用域内的声明的变量b没有出现[[Closure]]闭包中

其实通过上面的控制台打印,我们可以发现与 mdn红宝书的定义有出入(闭包内部只保存了外层词法环境的变量outer,全局变量a与函数本身作用域的变量b没有保存)

我们可以给闭包一个实践上的定义:(其实没必要死抠概念,重在理解)

函数可以记住并访问所在的词法作用域。当函数在当前词法作用域之外调用便产生的闭包。换句话说:闭包是内部函数引用外部函数变量的集合

闭包的使用

定时器

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i); // 6
  }, i * 1000);
}

上面这段代码如果要使输出符合我们正常的预期应该怎么解决?

第一种:直接使用支持块级作用域的关键字let,这里不是本文的主题,不多描述。具体可以看我的这篇文章:你真的理解变量提升吗

第二种:使用闭包(严格来说上面这段代码已经形成了一个闭包,但是不符合我们的预期,需要改造)

for (var i = 1; i <= 5; i++) {
  (function(i) {
    setTimeout(function timer() {
      console.log(i); // 1,2,3,4,5
    }, i * 1000);
  })(i)
}

我们来简单分析下上面这段代码

每次循环,立即执行函数都会产生一个新的作用域,定义器中的回调函数引用了外层词法作用域的变量。立即执行函数执行完成,其执行上下文被销毁,但是由于闭包,其参数变量i没有被销毁而是保存到了内存中。并形成了独自的作用域。每次输出的值便是当初外层函数传入的值

模拟私有变量

function Fruits() {
  let fruits = []
  this.getFruits = function() {
    return fruits
  }
  this.addFruits = function (fruit) {
    fruits.push(fruit)
  }
}
const f = new Fruits()
console.log(f.getFruits()) // []
f.addFruits('apple')
console.log(f.getFruits()) // ['apple']
f.addFruits('banala')
console.log(f.getFruits()) // ['apple','banala']

上述代码中我们无法直接访问/操作其内部的变量fruits,只能通过暴露出来的getFruitsaddFruits间接操作fruits

当构造函数执行完成,执行上下文被销毁,但其内部的变量fruits由于闭包的作用被保存在了内存中

注意到闭包的一个特点:它保存在内存的数据并不是一份快照,而是对同一个闭包对象(同一个执行上下文下)Closure (foo)的引用,因此会有累加的效果(如果是多次调用函数,则会产生多个执行上下文,此时也会有多个闭包对象,互不干涉)

这里我们可以思考一个问题:

如果js没有闭包这个概念呢,我们对代码略微调整

let fruits = []
function Fruits() {
  this.getFruits = function() {
    return fruits
  }
  this.addFruits = function (fruit) {
    fruits.push(fruit)
  }
}
const f = new Fruits()
console.log(f.getFruits()) // []
f.addFruits('apple')
console.log(f.getFruits()) // ['apple']
f.addFruits('banala')
console.log(f.getFruits()) // ['apple','banala']

我们将fruits放到全局作用域了。功能也是没有问题的,那为什么不采用这种污染全局作用域的方式呢。答案显而易见:如果有多个实例呢,功能瞬间会出现问题,这时候你可能已经体会到了闭包的设计是多么的精妙

缓存

运用闭包可以有效的帮我们缓存数据

function createCache() {
  const obj = {}

  return {
    setProperty: function (key, value) {
      if(obj[key]) {
        console.error('you have already set');
        return
      } else {
        obj[key] = value
      }
    },
    getProperty: function (key, value) {
      return obj[key]
    }
  }
}

上面的代码可以帮助我们缓存并获取曾经设置过的属性,并避免重复设置

来看一个稍微复杂点的例子

function mermorize(fn) {
  const cache = {}
  return function (...args) {
    const key  = JSON.stringify(args)
    return cache[key] || (cache[key] = fn.apply(null, args))
  }
}

function add(number1, number2) {
  console.log('add :>> ');
  return number1 + number2
}

const mermorizeAdd = mermorize(add)
mermorizeAdd(1,2) // add:>> 
mermorizeAdd(1,2)

上面的代码利用闭包,将计算结果给缓存了起来。一旦发现结果被缓存过,直接返回。不需要再次计算

我们再来看一个累加器的例子

function accumulate() {
  let n = 0
  return function (number) {
    n += number
    return n
  }
}

const sum = accumulate()
sum(1) // 1
sum(2) // 3

上面的代码利用闭包,将每次累加的最新结果给缓存下来,下次计算时基于最新的值添加

另一个经典的缓存应用案例便是 vuexvue-router的注册过程,相信看过相关源码的小伙伴应该很熟悉,这里直接上代码

let Vue 

export function install (_Vue) {
  // 判断是否注册过
  if (Vue && _Vue === Vue) {
    return
  }
  Vue = _Vue
}

上面的代码在使用 Vue.use(xxx)注册的时候,如果是第一次注册,则将 Vue给缓存下面,避免重复注册

模块化

另一个闭包的实践场景是模块化

在介绍模块化前我们先介绍下单例模式

单例模式:一个类有且只有一个实例,并且有一个全局获取该实例的接口

class Single {
  static instance
  constructor() {
    if(Single.instance) {
      return Single.instance
    }
    Single.instance = this
    return this
  }
}

const s1 = new Single()
const s2 = new Single()
console.log('s1 === s2 :>> ', s1 === s2); // true

上面的代码中生成的实例永远是同一个

我们也可以通过闭包的方式去实现

class Single {
  constructor() {}
}

const createSingle = (function () {
  let instance 
  return function () {
    if (!instance) {
      instance = new Single()
    }
    return instance
  }
})()

const s1 = createSingle()
const s2 = createSingle()

console.log('s1 === s2 :>> ', s1 === s2); // true

我们基于上面的代码用ESM的方式改下

// single.js
class Single {
  constructor() {}
}

const createSingle = (function () {
  let instance 
  return function () {
    if (!instance) {
      instance = new Single()
    }
    return instance
  }
})()

export default createSingle()

// a.js
export {default} from './single.js'

// b.js
export {default} from './single.js'

// c.js
import s1 from './a.js'
import s2 from './b.js'

console.log(s1 === s2) //true

我们发现:ESM是天然的单例模式,每一个模块都是一个单例,换言之:模块化建立在单例模式上

模块化有以下特点:

  1. 拥有自己的私有作用域、不会污染全局作用域
  2. 有统一对外暴露的属性,便于其他模块使用

用一个简单的模块化的例子:

// a.js
 const date = new Date()
 export default date

// main.js
import date from './a.js';

console.log('date :>> ', date);

拿上面的ESM模式的例子,我们可以分析下 export和 import的作用:

export: 导出一个exports对象,便于其他模块使用

import: 读取某个模块的 exports对象,从中取出来某个属性

我们基于 webpack打包工具看下打包后的结果

 (() => { // webpackBootstrap
 	"use strict";
 	var __webpack_modules__ = ([
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
var date = new Date();
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (date);

/***/ })
 	]);
 	// The module cache
 	var __webpack_module_cache__ = {};
 	
 	function __webpack_require__(moduleId) {
 		var cachedModule = __webpack_module_cache__[moduleId];
 		if (cachedModule !== undefined) {
 			return cachedModule.exports;
 		}
 		var module = __webpack_module_cache__[moduleId] = {
 			exports: {}
 		};
 	
 		// Execute the module function
 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
 	
 		// Return the exports of the module
 		return module.exports;
 	}
 	
 	(() => {
 		// define getter functions for harmony exports
 		__webpack_require__.d = (exports, definition) => {
 			for(var key in definition) {
 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
 				}
 			}
 		};
 	})();
 	
 	/* webpack/runtime/hasOwnProperty shorthand */
 	(() => {
 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
 	})();
 	
 	/* webpack/runtime/make namespace object */
 	(() => {
 		// define __esModule on exports
 		__webpack_require__.r = (exports) => {
 			if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
 				Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
 			}
 			Object.defineProperty(exports, '__esModule', { value: true });
 		};
 	})();
 	
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);

console.log('date :>> ', _a_js__WEBPACK_IMPORTED_MODULE_0__["default"]);
})();

 })()
;

对于上面的打包结果,我们可以发现(这里不对打包结果展开详情分析)

  1. 每一个模块都是一个函数,返回一个对象 module.exports用于对外暴露的数据。便于其他模块读取
  2. 整体打包结果是一个 IFFE,不会污染全局作用域
  3. required函数其实就是读取模块的 module.exports属性

可见,模块化运用到了我们前面提的很多知识点:利用闭包实现缓存利用闭包实现私有变量单例模式,到这里。大家应该可以体会到闭包给我们带来的诸多用处,js几乎处处是闭包

内存泄露

大多数人"谈闭色变"的原因之一就是可能会造成内存泄露

内存泄露指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果

我们需要明白一个事情:闭包本身与内存泄露并没有直接关系,但是闭包容易造成内存泄露

function createIncrease() {
    const doms = new Array(1000).fill(0).map(item => {
      const dom = document.createElement('dom')
      dom.innerHTML = item
      return dom
    })

    function increase() {
      doms.forEach(dom => {
        dom.innerHTML = Number(dom.innerHTML) + 1
      })
    }

     return increase
}

const increase = createIncrease()
const btn = document.querySelector('button')
btn.addEventListener('click', increase)

上面这段代码中increase只调用一次就不需要了,但是doms却一直被保存在内存中,从而造成内存泄露

我们可以对上面代码进行优化

  function createIncrease() {
    const doms = new Array(1000).fill(0).map(item => {
      const dom = document.createElement('dom')
      dom.innerHTML = item
      return dom
    })

    function increase() {
      doms.forEach(dom => {
        dom.innerHTML = Number(dom.innerHTML) + 1
      })
    }

    return increase
  }

  const btn = document.querySelector('button')

  function handleClick() {
    const increase = createIncrease()
    increase = null
    btn.removeEventListener('click', handleClick)
  }

  btn.addEventListener('click', handleClick, {
       once: true
  })

上述代码,按钮点击执行一次后,便会把相关函数的引用给置空。浏览器判断increase函数不可再被访问,进而进行垃圾回收,避免内存泄漏

我们再次把上面代码进行改造

function createIncrease() {
    const doms = new Array(1000).fill(0).map(item => {
      const dom = document.createElement('dom')
      dom.innerHTML = item
      return dom
    })

    function increase() {
       // increase内部没有引用 doms,没有形成闭包
    }

    function bar() {
      doms
    }

    return increase
}

const btn = document.querySelector('button')
const increase = createIncrease()
btn.addEventListener('click', increase)

代码中increase内部没有引用 doms,没有形成闭包。但其中另外一个函数引用了。此时依旧会带来内存泄露

另外有个点需要注意,闭包产生了,并不一定会永远放在内存中

function foo () {
    var name = 'liuxy'
    var age = 18

    function bar () {
        console.log(name)
        console.log(age)
    }
    return bar
}

foo()()

上述代码中,产生闭包的内部函数bar被调用后,其执行上下文被销毁。那么保存在bar[[Scopes]]上的Clourse(foo)对象自然也会被销毁

到这里我们给闭包内存泄漏的关系做一个总结:

  1. 闭包产生内存泄漏的原因与其他场景并没有什么不同:引用了一个不再需要使用的函数,会导致函数关联的词法环境无法被销毁,从而导致内存泄漏
  2. 当多个函数共享词法环境,会导致词法环境膨胀,会出现无法触达也无法回收的内存空间,从而导致内存泄漏

有关垃圾回收的细节后续会单独文章描述

结语

闭包无处不在,日常业务中的定时器ajax网络请求交互事件的逻辑函数缓存等,高阶的偏应用函数柯里化等都有闭包的功劳在里面。不知不觉,在我们的开发过程中,大量使用了闭包,闭包从一定角度极大的方便了我们的开发,提高了开发效率

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论

参考文章

JavaScript深入之闭包

JavaScript之闭包相关