avatar

【重识前端】闭包与模块

最近在写【重拾前端】系列,下面有几个快速通道,大家自取

【重识前端】原型/原型链和继承

【重识前端】闭包与模块

【重识前端】全面攻破this

【重识前端】一次搞定JavaScript的执行机制

【重识前端】什么是BFC、IFC、GFC 和 FFC

【重识前端】深入内存世界

前言

回忆我前几年的时光,大量使用 JavaScript 但却完全不理解闭包是什么。总是感觉这门语 言有其隐蔽的一面,如果能够掌握将会功力大涨,但讽刺的是我始终无法掌握其中的门 道。

JavaScript中闭包无处不在,你只需要能够识别并拥抱它。

最后你恍然大悟:原来在我的代码中已经到处都是闭包了,现在我终于能理解它们了。理 解闭包就好像 Neo3 第一次见到矩阵 4 一样。

定义

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

1
2
3
4
5
6
7
8
function foo() { 
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();

这样可能还不够直观,我们再修改一下。

1
2
3
4
5
6
7
8
9
function foo() { 
var a = 2;
function bar() {
console.log( a ); // 2
}
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

其实,在正常情况下,我们调用完foo之后,JavaScript检测到后续不会再用到他了, 所以他的内部作用域就会被回收♻️。(涉及到垃圾回收机制,后续的文章我们会说到。)

闭包的神奇之处就在于他可以阻止♻️的发生。因为事实上内部作用域已然存在,那么又有谁在使用呢?哦~是我们的bar在使用。

拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一 直存活,以供 bar() 在之后任何时间进行引用。

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

所以我说说我自己的理解:

闭包是一个运行时的概念,如果只是声明或者定义。即使他符合闭包的要求那他也不能成为一个真正的闭包。为什么?因为内存地址并没有开始分配,闭包只是分配之后那一块地址没有被回收而已。

再回到我们最开始的函数:

1
2
3
4
5
6
7
8
function foo() { 
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();

一个函数foo内有一个函数bar。并且函数bar用到了函数foo的变量。那么我们就可以称这个引用过程为“闭包”。但是我们这个函数foo必须被调用,所以我们的例子地下调用了一下函数foo。

再回到我们那个较为清晰的例子

1
2
3
4
5
6
7
8
9
function foo() { 
var a = 2;
function bar() {
console.log( a ); // 2
}
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

这个应该是大众认知上非常经典的一个闭包了吧?但是假如说这个foo函数没有被调用,那么我认为他就不是一个闭包。

实践

常见的闭包

前面的代码片段有点死板,并且为了解释如何使用闭包而人为地在结构上进行了修饰。你已经写过的代码中一定到处都是闭包的身影。 现在让我们来搞懂这个事实。

1
2
3
4
5
6
7
function wait(message) {
setTimeout(
function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );

这个例子非常常见,代码很简单,就是1秒钟之后输出我们传入的参数。

但是我们仔细看就能看见闭包的影子,一秒钟之后,message还是可以被timer访问到。并没有被回收♻️掉。

这就是闭包

本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一 级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使 用了回调函数,实际上就是在使用闭包!

闭包与循环

说到这里肯定有人马上就想到了最经典的面试题:请写出以下的输出内容

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

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。

但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

这是为什么?

因为等到执行console.log的时候,那个时候的i已经在for的调教下变成了6了,所以他们去取只能取到6;

(事实上, 当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循 环结束后才会被执行,因此会每次输出一个 6 出来。这个涉及到时间循环,后面的文章会具体的展开来讲)

通过我们刚刚的学习知道,如果我们创造了一个闭包,这样的话这个i不就不会被回收了吗?

ok,我们试试

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

这样能行吗?试试吧,在Chrome或者node里面试试吧,我等着你。

我不卖关子了。这样不行。但是为什么呢?我们现在显然拥有更多的词法作用域了。的确 每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。

如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一 个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。

它需要有自己的变量,用来在每个迭代中储存 i 的值

1
2
3
4
5
6
7
8
9

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

行了!它能正常工作了!。

可以对这段代码进行一些改进:

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

当然,这些 IIFE 也不过就是函数,因此我们可以将 i 传递进去,如果愿意的话可以将变量

名定为 j,当然也可以还叫作 i。无论如何这段代码现在可以工作了。
在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的

作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。 问题解决啦!

当然了,肯定有用块级作用域let的,那样其实是更完美的。

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

闭包与模块

很早之前,我们引用的js文件内部的变量其实都是挂载在window上面的,久而久之变量名很可能会重复=>覆盖=>BUG=>难以追寻的BUG

所以很需要模块的这个概念来解决。

模块的发展我分为三块:上古时代,近现代,现代

上古时代的模块

闭包最强大的功能=>《模块》

看个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function a(){
var q = 1;
var w = [1, 2];

function getQ(){
console.log(q)
}

function getW(){
console.log(w)
}
return {
getQ: getQ,
getW: getW,
}
}

var f = a();
f.getQ(); // 1
f.getW(); // [1, 2]

我们仔细的剖析一下这段代码, a是一个函数,我们用a创建了一个模块实例。注意一下:如果不执行 getQ 或者 getW 这个闭包就无法创建,因为在函数a内部的getQ和getW都只是对变量q和变了w的引用声明;没有实实在在的调用就没有创建作用域,也没有开辟内存空间。

引用一句《你不知道的JavaScript-上》中的一句话:

如果不执行 外部函数,内部作用域和闭包都无法被创建。

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

这个对象类型的返回值最终被赋值给外部的变量 f,然后就可以通过它来访问 API 中的 属性方法,比如 f.getW()。

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

其实我们这样每次调用一次a函数相当于创建了一个新的模块实例。

假如,此时我们希望只要一个实例,那咋办呢?

天降猛男—-IIFE(立即执行函数)

=》

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var f = (function a(){
var q = 1;
var w = [1, 2];

function getQ(){
console.log(q)
}

function getW(){
console.log(w)
}
return {
getQ: getQ,
getW: getW,
}
})();

f.getQ(); // 1
f.getW(); // [1, 2]

这个模块只属于f一个人,要修改只能找f帮忙了。

既然模块也是一个函数,那是不是也可以传参呀?

是的,稍作修改就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function a(id){
var q = 1;
var w = [1, 2];

function getQ(){
console.log(q)
}

function getW(){
console.log(w)
}

function getId(){
console.log(id)
}
return {
getQ: getQ,
getW: getW,
getId: getId,
}
}

var f = a(99);
f.getQ(); // 1
f.getW(); // [1, 2]
f.getId(); // 99

那我想修改模块内部的东西咋办捏?

安排!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function a(id){
var q = 1;
var w = [1, 2];

function getQ(){
console.log(q)
}

function getW(){
console.log(w)
}

function getId(){
console.log(id)
}

function change(){
q = q + 1
}
return {
getQ: getQ,
getW: getW,
getId: getId,
change: change,
}
}

var f = a(99);
f.getQ(); // 1
f.getW(); // [1, 2]
f.getId(); // 99
f.change(); // 99
f.getQ(); // 2

通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修 改,包括添加或删除方法和属性,以及修改它们的值。

近现代的模块

AMD(很少用到了)

AMD 即Asynchronous Module Definition,中文名是异步模块定义的意思。它是一个在浏览器端模块化开发的规范

由于不是JavaScript原生支持,使用AMD规范进行页面开发需要用到对应的库函数,也就是大名鼎鼎RequireJS,实际上AMD 是 RequireJS 在推广过程中对模块定义的规范化的产出

requireJS主要解决两个问题

1、多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
2、js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长
看一个使用requireJS的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/* 

* a.js

* 创建一个名为“a”的模块

*/



define('a', function(require, exports, module) {

exports.getTime = function() {

return new Date();

}

});



/*

* b.js

* 创建一个名为“b”的模块,同时使用依赖require、exports和名为“a”的模块:

*/

define('b', ['require', 'exports', 'a'], function(require, exports, a) { exports.test = function() {

return {

now: a.getTime()

};

}

});



/* main.js */

require(['b'], function(b) {

console.log(b.test());

});

语法

requireJS定义了一个函数 define,它是全局变量,用来定义模块

define(id?, dependencies?, factory);

  1. id:可选参数,用来定义模块的标识,如果没有提供该参数,脚本文件名(去掉拓展名)
  2. dependencies:是一个当前模块依赖的模块名称数组
  3. factory:工厂方法,模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值
    在页面上使用require函数加载模块

require([dependencies], function(){});
require()函数接受两个参数

  1. 第一个参数是一个数组,表示所依赖的模块
  2. 第二个参数是一个回调函数,当前面指定的模块都加载成功后,它将被调用。加载的模块会以参数形式传入该函数,从而在回调函数内部就可以使用这些模块

require()函数在加载依赖的函数的时候是异步加载的,这样浏览器不会失去响应,它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性的问题。

CMD(很少用到了)

CMD 即Common Module Definition通用模块定义,CMD规范是国内发展出来的,就像AMD有个requireJS,CMD有个浏览器的实现SeaJS,SeaJS要解决的问题和requireJS一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同
语法
Sea.js 推崇一个模块一个文件,遵循统一的写法
define(id?, deps?, factory)
因为CMD推崇

  1. 一个文件一个模块,所以经常就用文件名作为模块id
  2. CMD推崇依赖就近,所以一般不在define的参数中写依赖,在factory中写

factory是一个函数,有三个参数,function(require, exports, module)

  1. require 是一个方法,接受 模块标识 作为唯一参数,用来获取其他模块提供的接口:require(id)
  2. exports 是一个对象,用来向外提供模块接口
  3. module 是一个对象,上面存储了与当前模块相关联的一些属性和方法

看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* 

* a.js

* 一个文件就是一个模块

*/

define(function(require, exports, module) {

exports.getTime = function() {

return new Date();

}

});



/* main.js */

define(function(require, exports, module) {

/* 按需加载a.js */

var a = require('./a');

console.log(a.getTime());

});
AMD和CMD的区别

最明显的区别就是在模块定义时对依赖的处理不同

1、AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块
2、CMD推崇就近依赖,只有在用到某个模块的时候再去require
这种区别各有优劣,只是语法上的差距,而且requireJS和SeaJS都支持对方的写法

AMD和CMD最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同

很多人说requireJS是异步加载模块,SeaJS是同步加载模块,这么理解实际上是不准确的,其实加载模块都是异步的,只不过AMD依赖前置,js可以方便知道依赖模块是谁,立即加载,而CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块,这也是很多人诟病CMD的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略

为什么我们说两个的区别是依赖模块执行时机不同,为什么很多人认为ADM是异步的,CMD是同步的(除了名字的原因。。。)

同样都是异步加载模块,AMD在加载模块完成后就会执行改模块,所有模块都加载执行完后会进入require的回调函数,执行主逻辑,这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行

CMD加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的

这也是很多人说AMD用户体验好,因为没有延迟,依赖模块提前执行了,CMD性能好,因为只有用户需要的时候才执行的原因

现代的模块

CommonJS

CommonJS 最早是 Node 在使用,目前也仍然广泛使用,比如在 Webpack 中你就能见到它,当然目前在 Node 中的模块管理已经和 CommonJS 有一些区别了。

1
2
3
4
5
6
7
8
9
10
// a.js
module.exports = {
a: 1
}
// or
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1

因为 CommonJS 还是会使用到的,所以这里会对一些疑难点进行解析

先说 require

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var module = require('./a.js')
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
a: 1
}
// module 基本实现
var module = {
id: 'xxxx', // 我总得知道怎么去找到他吧
exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
// 导出的东西
var a = 1
module.exports = a
return module.exports
};
// 然后当我 require 的时候去找到独特的
// id,然后将要使用的东西用立即执行函数包装下,over

另外虽然 exportsmodule.exports 用法相似,但是不能对 exports 直接赋值。因为 var exports = module.exports 这句代码表明了 exportsmodule.exports 享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对 exports 赋值就会导致两者不再指向同一个内存地址,修改并不会对 module.exports 起效。

ES Module

ES Module 是原生实现的模块化方案,与 CommonJS 有以下几个区别

  • CommonJS 支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案
  • CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
  • CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是 ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
  • ES Module 会编译成 require/exports 来执行的
1
2
3
4
5
6
// 引入模块 API
import XXX from './a.js'
import { XXX } from './a.js'
// 导出模块 API
export function a() {}
export default function() {}

总结

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

索引:https://juejin.im/post/5a422b036fb9a045211ef789

文章作者: Derrick
文章链接: http://derricktel.github.io/2020/07/15/%E3%80%90%E9%87%8D%E8%AF%86%E5%89%8D%E7%AB%AF%E3%80%91%E9%97%AD%E5%8C%85%E4%B8%8E%E6%A8%A1%E5%9D%97/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Derrick
打赏
  • 微信
    微信
  • 支付寶
    支付寶