JavaScript闭包

什么是闭包

闭包在表象上来说是指一个函数有权访问另一个函数作用域的现象,在深层上来说,闭包其实是JavaScript词法作用域的自然产物,是执行上下文和调用栈的共同作用的结果。

闭包比较常见的形式是一个函数嵌套到另一个函数中。

function fn(){
	return function(){}
}

而内层函数能访问到外层函数的作用域,这是由词法作用域的特性决定的。

function fn(){
	const a = 123
	return function(){
		console.log(a)
	}
}
fn()() // 123

在正常的情况下,根据词法作用域规则,函数作用域能够访问更外层作用域(例如全局作用域),外层作用域却无法访问函数作用域,但是借助闭包这个特性,可以将外层函数作用域中的变量暴露到外面的作用域中,因为函数可以访问外层作用域,那么嵌套函数也就可以访问外层函数的作用域。

function fn(){
	const a = 0
	return function(){
		return a++
	}
}
const f = fn()
f() // 0
f() // 1
f() // 2

并且外层函数作用域的变量状态是一直保存,例如上面代码中的变量a 的值会一直更改,这说明外层函数作用域在执行完fn()后仍然存在,未被销毁。

闭包的作用

闭包通常有两个作用

  1. 暴露函数作用域中的变量到外层作用域中,扩展作用域链。

  2. 缓存

  3. 封装私有变量

第一种作用前面已经详细介绍过了。

对于第二种作用,我们可以以节流函数为例,代码中缓存了setTimeout的返回值timeId。

function throttle(cb,delay){
	let timer
	return function f(...args){
		if(!timer){
			timer = setTimeout(()=>{
				cb.call(null, ...args)
				timer = null
			},delay)
		}
	}
}

第三种是借助函数作用域无法被外层作用域访问,但是闭包能将函数作用域的变量暴露到外层作用域中的特性,进行封装,让用户无法直接访问内部变量。

function Person(name) {
  let _age;
  function setAge(n) {
    _age = n;
  }
  function getAge() {
    return _age;
  }

  return {
    name: name,
    getAge: getAge,
    setAge: setAge
  };
}

var p1 = Person('张三');
p1.setAge(25);
p1.getAge() // 25

通过闭包封装私有变量和私有方法,将复杂的逻辑封装到内部,用户无需关注内部逻辑,只需关注使用方法。

内存泄漏

闭包虽然好用,但是也不是没有缺点的,最大的缺点就是容易造成内存泄漏问题。原因也很简单,闭包中内层函数能够访问外层函数的作用域,因此只要内层函数的引用一直存在,那么垃圾回收机制就一直不会回收外层函数作用域中被访问到的变量。

function fn(){
	const a = 0
	return function(){
		return a++
	}
}
// 获取内层函数的引用
const f = fn()
// 只要内层函数的引用一直存在,那么外层函数就不会被销毁
f() // 0
f() // 1
f() // 2

解决的方法也很简单,当你不再需要f的时候,手动删除引用。

// 用完f函数了,应当手动删除引用
// 以下方式都可以
f = null
f = undefined
f = 123

要理解闭包的内存泄漏问题,就得先了解下JavaScript的内存机制。

栈内存和堆内存

JavaScript的内存分为栈内存和堆内存,原始值保存在栈内存中,引用值保存在堆内存中,并且会将内存地址保存在栈内存中。

function fn(){
  const a = 123
  const b = 321
  return function f(){
    console.log(a);
  }
}

debugger
fn()()

以上面的代码为例,当执行到fn()时,fn函数会被压入到执行栈中,此时会生成一个函数执行上下文,保存着函数内部的变量、函数、this值、arguments等。

一般来说函数执行完毕后会出栈销毁(generator函数除外),销毁后该函数的作用域内的变量都不再可访问。但是实际中,JavaScript在执行fn函数时会发现fn函数中存在着闭包,f函数可以访问到fn函数的作用域,因此JavaScript会在堆内存中存放closure对象,并将f函数访问到的fn函数作用域中的变量都保存到这个closure对象里。

closure对象是一个内部对象,我们无法直接访问,但是可以通过浏览器调试工具查看.

这是你仍然可以在控制台中访问到变量a,但是无法访问变量b,因为变量b已经随着fn函数的出栈而销毁了。(注意:在火狐浏览器中仍然可以访问变量b,可能是不同浏览器的实现差异)

JavaScript的闭包

什么是闭包?

闭包是一种语言特性,它允许函数访问其定义时的词法环境(lexical environment)。词法环境是一个包含了标识符和它们的绑定(binding)的结构,例如变量、常量、函数声明等。每个执行上下文(execution context)都有一个关联的词法环境,它决定了在该执行上下文中可以访问哪些标识符。

当一个函数被创建时,它会保存一个对其定义时的词法环境的引用,这个引用称为函数的[[Environment]]内部属性。当一个函数被调用时,它会创建一个新的执行上下文,并将其[[Environment]]属性作为新执行上下文的外部词法环境(outer lexical environment)。这样,函数就可以通过作用域链(scope chain)访问其定义时的词法环境中的标识符。

因此,闭包就是一个函数和其定义时的词法环境的组合,它使得函数可以在执行时访问其定义时的变量和函数。

闭包有什么作用?

闭包是一种强大的语言特性,它可以实现模块化、私有变量、高阶函数等功能

模块化:通过闭包,我们可以创建一个模块(module),即一个拥有私有状态和公开接口的对象。模块可以封装一些相关的数据和操作,并对外提供一些方法或属性。模块可以保护其内部的变量不被外部访问或修改,只能通过模块提供的接口进行交互。例如:

// 创建一个计数器模块
var counter = (function() {
  // 私有变量,只能在模块内部访问
  var count = 0;
  // 公开接口,返回一个对象
  return {
    // 增加计数器
    increment: function() {
      count++;
    },
    // 获取当前计数值
    getValue: function() {
      return count;
    },
    // 重置计数器
    reset: function() {
      count = 0;
    }
  };
})();

// 使用模块
counter.increment(); // 增加计数器
console.log(counter.getValue()); // 输出1
counter.reset(); // 重置计数器
console.log(counter.getValue()); // 输出0
console.log(counter.count); // 输出undefined,无法直接访问私有变量

私有变量:通过闭包,我们可以实现私有变量(private variable),即只能在特定范围内访问或修改的变量。私有变量可以保护数据不被外部干扰或污染,增加代码的安全性和可维护性。例如:

// 创建一个构造函数Person
function Person(name) {
  // 私有变量,只能在构造函数内部访问
  var age = 18;
  // 公开属性,可以在外部访问
  this.name = name;
  // 公开方法,可以在外部调用
  this.getAge = function() {
    return age;
  };
}

// 创建一个Person

参考

ECMAScript® 2024 Language Specification (tc39.es)

最后更新于