你不知道的JS(三):从闭包到模块机制

本文深入探讨JavaScript中的闭包概念,包括其如何自然产生、在嵌套函数、返回函数、模块机制中的应用,以及闭包如何防止内存回收。通过示例解释了闭包在异步操作、for循环中的行为,并讨论了闭包可能导致的内存泄漏问题。此外,还对比了CommonJS和ES6模块的区别,并展示了模块化设计的不同模式。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

  • 闭包不是一个需要学习新语法或模块才能使用的工具。
  • 闭包是基于词法作用域书写代码时,所产生的自然结果。你甚至不需要为了利用它们而有意识的创建闭包。闭包的创建和使用在你的代码中无处不在。
  • 也就是说:JavaScript中的闭包无处不在,你需要做的就是识别它并拥抱它。

认识闭包

例子1:嵌套函数

function foo() {
	var a = 2;
	function bar() {
		console.log(a)
	}
	bar()
}
foo()
  • 基于词法作用域的查找规则,函数bar()可以访问外部作用域中的变量a。这是闭包吗?
  • 从纯学术的角度上讲,函数bar()具有一个涵盖foo()作用域的闭包。
  • 但是这种方式定义的闭包并不能直接观察到。

例子2:返回一个函数

function foo() {
	var a = 2;
	function bar() {
		console.log(a);
	}
	return bar;
}
var baz = foo();
baz(); // 这就是闭包的效果
  • foo()执行完成后,将bar函数作为一个值,传递给baz
  • baz实际保存了对函数bar的引用。执行baz()实际上是通过baz这个标识对函数进行执行。

闭包阻止回收

  • foo()执行后,通常会期待foo()的整个作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。
  • 而闭包的神奇之处在于正是可以阻止这件事情发生。事实上内部作用域依然存在,因此没有被回收。
  • 因为bar(),它拥有涵盖foo()内部作用域的闭包,使得作用域能够一直存活,以供bar()在之后任何时间进行引用。
  • bar()依然保持对该作用域的引用,这个引用就叫做闭包。

总结

  • 无论通过什么方式将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数,都会使用闭包。

例子3:将函数当作参数传递

function wait(message) {
	setTimeout(function timer() {
		console.log(message);
	}, 1000);
}
wait("Hello world")

代码分析

  • 将一个内部函数(名为timer)传递给setTimeout(...)timer具有涵盖wait(...)作用域的闭包,因此还保留对变量message的引用。

执行过程

  • wait(...)执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait(...)作用域的闭包。
  • 在定时器、事件监听器、Ajax请求等异步(或同步)任务中,只要使用了回到函数,实际上就是使用了闭包。

for循环中的闭包

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

代码分析

  • 正常情况下,我们想要输出1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出5次6。这是为什么?
  • 首先,解释6是怎么来的。这个循环的终止条件是i<=5不成立的时候,而且i每次只增加1,所以条件成立的时候i=1。因此输出的这个6是循环结束时i的最终值。
  • 事实上,当定时器运行时,即使每个迭代中执行的时setTimeout(.., 0),所有回调函数依然是在循环结束后才会被执行。
  • 尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。

那该什么修改呢?

使用立刻执行函数

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

这样并不行,因为没有创建自己的变量。改为

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

再调整一下

for (var i=1; i<=5; i++) {
	(function(j) {
		setTimout(function timer() {
			console.log(j);
		}, j*1000);
	})(i);
}
  • 每次迭代,立刻执行函数都会生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭再每个迭代内部,每个迭代中都会含有一个具有准确值的变量供我们访问。

使用块作用域

  • let可以用来劫持块,本质上是将块转换成一个可以被关闭的作用域。
  • 在这里,每次迭代我们都需要一个块级作用域。
for (var i=1; i<=5; i++) {
	let j = i; // 块级作用域
	setTimeout(function timer() {
		console.log(j);
	}, j*1000);
}

这样写,已经可以实现功能了,但这不是全部。

  • for循环头部的let声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。

所以上面的代码调整为:

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

模块

其实还有很多代码模式利用了闭包的强大威力。这里分析一个最强大的——模块。

function CoolModule() {
	var something = "cool";
	function doSomething() {
		console.log(something)
	}
	return { // 返回一个字面量对象
		doSomething: doSomething
	}
}
var foo =  CoolModule();
foo.doSomethding();

这个模式在JavaScript中被称为模块。

模块模式需要具备两个必要条件

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有状态。

模块机制

大多数模块依赖加载器/管理器,本质上都是将这种模块定义封装进一个友好的API。

下面展示一下

// 定义模块依赖加载器
var MyModules = (function Magager() {
	var modules = {};
	function define(name, deps, impol) {
		for (var i=0; i<deps.length; i++) {
			deps[i] = modules[deps[i]];
		}
		modulse[name] = impl.apply(impl, deps)
	}
	function get(name) {
		return modulse[name]
	}
	return {
		define: define,
		get: get
	};
})();


// 使用模块依赖加载器
MyModules.define("bar", [], function() {
	function hello(who) {
		return "Let me introduce: " + who;
	}
	return {
		hello: hello
	}
});

MyModules.define("foo", ["bar"], function(bar) {
	var hungry =  "hippo";
	function awesome() {
		console.log(bar.hello(hungry).toUpperCase());
	}
	return {
		awesome: awesome
	}
})

var bar MyModules.get("bar");
var foo = Mymodules.get("foo");
console.log(bar.hello("hippo")) // Let me intruduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

"foo"和“bar”模块都是通过一个返回公共API的函数来定义的。

ES6模块机制

考虑以下代码:

// bar.js
function hello(who) {
	return "Let me introduce: " + who
}
esport hello;
// foo.js
imort hello from "bar"
var hungry = "hippo";
function awesome() {
	console.log(hello(hungry).toUpperCase());
}
export awesome;
module foo from "foo";
module bar from "bar";
console.log(bar.hello("rhino")); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: RHINO

import:可以将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上。

module:会将整个模块的API导入并绑定到一个变量上。

export:会将当前模块的一个标识符(变量、函数)导出为公共API。

模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。

与传统模块的区别

  • 基于函数的模块并不是一个能被静态识别的模式,它们的API语义只有在运行时才能考虑进来。因此,可以在运行时修改一个模块的API。
  • ES6模块API是静态的(API运行时不会改变)。

总结

结合之前了解内容,做一下总结:

闭包

  • 闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
  • 闭包可以当成作用域链的副产品。其实就是跨作用域使用变量。

闭包的运用思路

  • 使用局部变量,同时暴露一个访问器(函数),让别人可以「间接访问」。

使用例子

  1. 第一种:构造函数

特点:每次new调用构造函数都会重新创建一套变量和方法。

function MyObject() { 
	 // 私有变量和私有函数 
	 let privateVariable = 10; 
	 function privateFunction() { 
		 return false; 
	 } 
	 // 特权方法
	 this.publicMethod = function() { 
		 privateVariable++; 
		 return privateFunction(); 
	 }; 
}
  1. 第二种:结合原型对象

特点:私有变量在立刻执行函数中,公有方法定义在构造函数的原型上

(function() { 
	 // 私有变量
	 let name = ''; 
	 // 构造函数
	 Person = function(value) { 
		 name = value; 
	 }; 
	 // 特权方法
	 Person.prototype.getName = function() { 
		 return name; 
	 }; 
	 // 特权方法
	 Person.prototype.setName = function(value) { 
		 name = value; 
	 }; 
})(); 
//调用
let person1 = new Person('Nicholas'); 
console.log(person1.getName()); // 'Nicholas' 
person1.setName('Matt'); 
console.log(person1.getName()); // 'Matt' 
let person2 = new Person('Michael'); 
console.log(person1.getName()); // 'Michael' 
console.log(person2.getName()); // 'Michael'
  1. 第三种:模块模式

特点:创建单例对象,但是又需要私有变量(外部不能访问)

(前面有代码说明)

  • 在匿名函数内部,首先定义私有变量和私有函数。
  • 之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。

内存泄漏

  • 闭包在这些旧版本 IE 中可能会导致内存泄漏,因为采用的垃圾回收机制不同。

CommonJS理解

  • CommonJS 就是基于前面的模块模式实现的。
  • CommonJS 模块就是对象,即在运行时加载整个模块生成一个对象,然后从这个对象上查找属性和方法

ES6模块理解

  • 模块文件中的内容被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包一样。

CommonJS 和 ES Module 的区别

CommonJS

  • 动态(运行时加载)

    在运行时加载整个模块才会生成对象,然后从这个对象上查找属性和方法。

  • 拷贝

    在第一次被加载时,会完整运行整个文件并输出一个对象,拷贝在内存中,模块内部的变化就影响不到这个值。重复引入的模块并不会重复执行,直接从内存中取值。

  • 同步

    CommonJS里面,对于一个模块和它底下的依赖来说,下载,实例化,和求值都是一次性完成的,步骤相互之间没有任何停顿。如果用于浏览器,同步导入对渲染有很大影响。

ES6 Module

  • 静态(编译时加载)

    import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。

    import 会自动提升到代码的顶层,不能放在块级作用域内。且不能动态导入。(文件地址不能有变量)

  • 引用

    先获取文件,解析所有文件导出的变量,一开始变量还没有值,但是先占了内存空间。然后解析文件的导出和导出的变量,导出和导入都是指向同一片内存地址,最后把值都填入内存地址中。

  • 异步

    之所以说是异步,是因为模块的加载过程分成了三个步骤(下载、实例化、求值),可以分开单独完成,当然也可以一次性完成。

对模块化的理解

  • 模块化不等于把一个文件拆分成多个,在同一个文件中,也能够实现模块化。比如使用短小的函数。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值