深入学习js之浅谈作用域(作用域闭包)

本文深入探讨JavaScript中的闭包概念及其应用场景,包括定时器、事件监听器等异步任务中的使用。同时,介绍了模块模式的基本原理及其实现方式。

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

初学js总会对闭包产生疑惑,其实闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。


1.闭包的概念

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

下面我们看一段代码,清晰地展示闭包

function foo() {
			var a = 2;

			function bar() {
				console.log(a);	
			}

			return bar;
		}

		var baz = foo();

		baz();//2 ----- 这就是闭包效果

解析:

函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递。在这个例子中,我们将bar所引用的函数对象本身当作返回值。

在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过不同的标识符引用调用了内部的函数bar()。

bar()显然可以被正常地执行。但是在这个例子中,它在定义的语法作用域以外的地方执行。

在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收机制用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的神奇之处在于它可以阻止这件事的发生。事实上内部作用域依然存在,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫做闭包。

因此,在几微妙后baz被实际调用(调用内部函数bar()),它可以访问定义时的词法作用域,因此它可以访问变量a


当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。

function foo() {
			var a = 2;

			function baz() {
				console.log(a);//2
			}

			bar(baz);
		}

		function bar(fn) {
			fn();//闭包
		}
把内部函数baz传递给bar,当调用这个内部函数时(现在称作fn),它涵盖的foo()内部作用域的闭包就可以观察到了,因为它能够访问a。

间接的传递函数。

var fn;
		function foo() {
			var a = 2;
			function baz() {
				console.log(a);
			}

			fn = baz;//将baz分配给全局变量
		}
		function bar() {
			fn();//闭包
		}

		foo();

		bar();//2
其他闭包栗子:

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

		wait("Hello, closure");

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

wait(..)执行1秒后,它的内部作用域不会消失,timer函数依然保有wait(..)作用域的闭包。

在引擎内部,内置的工具函数setTimeout(..)持有对一个参数的引用,这个参数也许叫作fn或者fnc,或其他名字。引起会调用这个函数,在例子中就是内部的timer函数,而词法作用域在这个过程中保持完整。

这就是闭包

在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是使用闭包。

2.循环和闭包

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

预期这段代码分别输出1-5,每秒一次,每次一个

事实上这段代码的输出是每秒一次输出五次6

为什么?

首先解释6是从哪来的。这个循环的终止条件是i不在<=5。条件首次成立时i的值是6。因此,输出显示的是循环结束时,i的最终值。

那么代码中到底有什么缺陷导致它与期不一致?


缺陷是我们试图假设循环中每个迭代在运行时都会给自己捕获一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是他们都被封闭在一个共享的全局作用域中,因此实际上只有一个i,所以所有函数共享一个i引用。

因此我们需要更多的闭包作用域,特别是在循环过程中每个迭代都需要一个闭包作用域。

前面的文章说过IIFE会通过声明并立即执行一个函数来创建作用域

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

以上代码还不行,因为此时IIFE只是个空作用域

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

上述代码可行,但还可以改进

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

在迭代内使用IIFE会为每个迭代生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值得变量供我们访问。

3.块作用域

前面介绍了let声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

这里注意一点for循环头部的let声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

4.模块模式

function CoolModule() {
			var something = "cool";
			var another = [1,2,3];

			function doSomething() {
				console.log(something);
			}
			function doAnother() {
				console.log(another.join(" ! "));
			}

			return {
				doSomething: doSomething,
				doAnother: doAnother
			};
		}

		var foo = CoolModule();
		foo.doSomething();// cool
		foo.doAnother();//1 ! 2 ! 3
这个模式在js中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。

首先CoolModule()只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行外部函数,内部作用域和闭包都无法被创建。

其次,CoolModule()返回一个对象字面量语法{key:value,...}来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共API。

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数,jQuery就是一个例子。jQuery和$标识符就是jQuery模块的公共API,但他们本身都是函数(由于函数也是对象,它们本身也可以拥有属性)

简而言之,模块模式需要具备两个条件

1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。

2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有状态。


当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式

var foo = (function CoolModule() {
			var something = "cool";
			var another = [1,2,3];

			function doSomething() {
				console.log(something);
			}
			function doAnother() {
				console.log(another.join(" ! "));
			}

			return {
				doSomething: doSomething,
				doAnother: doAnother
			};
		})()

		foo.doSomething();// cool
		foo.doAnother();//1 ! 2 ! 3
我们将模块函数转换成了IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符foo。


模块模式另一个简单但强大的用法是命名将要作为公共API返回的对象

var foo = (function CoolModule(id) {
			function change() {
				//修改公共API
				publicAPI.identify = identify2
			}

			function identify1() {
				console.log(id);
			}

			function identify2() {
				console.log( id.toUpperCase() );
			}

			var publicAPI = {
				change: change,
				identify: identify1
			};

			return publicAPI;
		})( "foo module" );

		foo.identify();// foo module
		foo.change();
		foo.identify();// FOO MODULE

现代的模块机制

大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的API。这里并不会研究某个具体的库,为了宏观了解这里介绍一些核心概念


var MyModules = (function Manager() {
			var modules = {};

			function define(name, deps, impl) {
				for (var i = 0; i < deps.length; i++) {
					deps[i] = modules[deps[i]];
				}
				modules[name] = impl.apply(impl,deps);
			}

			function get(name) {
				return modules[name];
			}

			return {
				define: define,
				get: get
			};

		})();
这段代码的核心是modules[name] = impl.apply(impl,deps);为了模块的定义引入了包装函数(可以传入任何依赖),并将返回值,也就是模块的API,储存在一个根据名字来管理的模块列表中。


下面展示了如何用它来定义模块:

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 introduce: hippo

		foo.awesome(); //LET ME INTRODUCE: HIPPO	
"foo"和“bar”模块都是通过返回一个公共API的函数来定义的。“foo”甚至接收“bar”的实例作为依赖函数,并能相应使用它。

模块管理器符合模块模式的两个特点:调用包装了函数定义的包装函数,并且将返回值作为该模块的API

换而言之,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

ES6中的模块机智


基于函数的模块并不是一个能被静态识别的模式(编译器无法识别),它们的API语法只有在运行时才会被考虑进来。因此可以在运行时修改一个模块的API(参考之前的public API)
而ES6的模块API是静态的(API不会再运行时改变)。由于编译器知道这一点,因此可以在编译器检查对导入模块的API成员的引用是否真实存在。如果API引用不存在,编译器会在编译时就抛出“早期”错误,而不会等到运行期再动态解析(并且报错)


ES6的模块没有“行内”格式,必须定义在独立的文件中(一个文件一个模块)。浏览器或引擎有一个默认的“模块加载器”(可以被重载),可以在导入模块时同步加载模块文件


bar.js
			function hello(who) {
				return "hello " + who;
			}
			export hello;
		foo.js 
			//仅从"bar"模块导入hello()
			import hello from "bar";

			function awesome() {
				console.log(
					hello("world").toUpperCase()
				);	
			}
			export awesome;
		baz.js
			module foo from "foo";
			module bar from "bar"

			console.log(
				bar.hello("world")
				// hello world
			);	
			
			foo.awesome();//HELLO WORLD


import可以将一个模块中的一个或多个API导入到当前作用域中,并分别绑在一个变量上(例子中的hello),module会将整个模块的API导入并绑定到一个变量上(例子中的foo和bar)


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


总结:1.当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

2.闭包是个强大的工具,可以用多种形式来实现模块等模式。

3.模块有两个主要特征:3.1为创建内部作用域而调用了一个包装函数;3.2包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。


如若有误欢迎指出!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值