最近在写【重拾前端】系列,下面有几个快速通道,大家自取
前言
回忆我前几年的时光,大量使用 JavaScript 但却完全不理解闭包是什么。总是感觉这门语 言有其隐蔽的一面,如果能够掌握将会功力大涨,但讽刺的是我始终无法掌握其中的门 道。
JavaScript中闭包无处不在,你只需要能够识别并拥抱它。
最后你恍然大悟:原来在我的代码中已经到处都是闭包了,现在我终于能理解它们了。理 解闭包就好像 Neo3 第一次见到矩阵 4 一样。
定义
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用 域之外执行。
1 | function foo() { |
这样可能还不够直观,我们再修改一下。
1 | function foo() { |
其实,在正常情况下,我们调用完foo
之后,JavaScript检测到后续不会再用到他了, 所以他的内部作用域就会被回收♻️。(涉及到垃圾回收机制,后续的文章我们会说到。)
闭包的神奇之处就在于他可以阻止♻️的发生。因为事实上内部作用域已然存在,那么又有谁在使用呢?哦~是我们的bar
在使用。
拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一 直存活,以供 bar() 在之后任何时间进行引用。
bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
所以我说说我自己的理解:
闭包是一个运行时的概念,如果只是声明或者定义。即使他符合闭包的要求那他也不能成为一个真正的闭包。为什么?因为内存地址并没有开始分配,闭包只是分配之后那一块地址没有被回收而已。
再回到我们最开始的函数:
1 | function foo() { |
一个函数foo内有一个函数bar。并且函数bar用到了函数foo的变量。那么我们就可以称这个引用过程为“闭包”。但是我们这个函数foo必须被调用,所以我们的例子地下调用了一下函数foo。
再回到我们那个较为清晰的例子
1 | function foo() { |
这个应该是大众认知上非常经典的一个闭包了吧?但是假如说这个foo函数没有被调用,那么我认为他就不是一个闭包。
实践
常见的闭包
前面的代码片段有点死板,并且为了解释如何使用闭包而人为地在结构上进行了修饰。你已经写过的代码中一定到处都是闭包的身影。 现在让我们来搞懂这个事实。
1 | function wait(message) { |
这个例子非常常见,代码很简单,就是1秒钟之后输出我们传入的参数。
但是我们仔细看就能看见闭包的影子,一秒钟之后,message
还是可以被timer
访问到。并没有被回收♻️掉。
这就是闭包
本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一 级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使 用了回调函数,实际上就是在使用闭包!
闭包与循环
说到这里肯定有人马上就想到了最经典的面试题:请写出以下的输出内容
1 | for (var i=1; i<=5; i++) { |
正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。
但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。
这是为什么?
因为等到执行console.log
的时候,那个时候的i已经在for的调教下变成了6了,所以他们去取只能取到6;
(事实上, 当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循 环结束后才会被执行,因此会每次输出一个 6 出来。这个涉及到时间循环,后面的文章会具体的展开来讲)
通过我们刚刚的学习知道,如果我们创造了一个闭包,这样的话这个i不就不会被回收了吗?
ok,我们试试
1 | for (var i=1; i<=5; i++) { |
这样能行吗?试试吧,在Chrome或者node里面试试吧,我等着你。
我不卖关子了。这样不行。但是为什么呢?我们现在显然拥有更多的词法作用域了。的确 每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。
如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一 个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。
它需要有自己的变量,用来在每个迭代中储存 i 的值
1 |
|
行了!它能正常工作了!。
可以对这段代码进行一些改进:
1 | for (var i=1; i<=5; i++) { |
当然,这些 IIFE 也不过就是函数,因此我们可以将 i 传递进去,如果愿意的话可以将变量
名定为 j,当然也可以还叫作 i。无论如何这段代码现在可以工作了。
在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的
作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。 问题解决啦!
当然了,肯定有用块级作用域let
的,那样其实是更完美的。
1 | for (let i=1; i<=5; i++) { |
闭包与模块
很早之前,我们引用的js文件内部的变量其实都是挂载在window上面的,久而久之变量名很可能会重复=>覆盖=>BUG=>难以追寻的BUG
所以很需要模块的这个概念来解决。
模块的发展我分为三块:上古时代,近现代,现代
上古时代的模块
闭包最强大的功能=>《模块》
看个例子
1 | function a(){ |
我们仔细的剖析一下这段代码, 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 | var f = (function a(){ |
这个模块只属于f一个人,要修改只能找f帮忙了。
既然模块也是一个函数,那是不是也可以传参呀?
是的,稍作修改就好了。
1 | function a(id){ |
那我想修改模块内部的东西咋办捏?
安排!
1 | function a(id){ |
通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修 改,包括添加或删除方法和属性,以及修改它们的值。
近现代的模块
AMD(很少用到了)
AMD 即Asynchronous Module Definition,中文名是异步模块定义的意思。它是一个在浏览器端模块化开发的规范
由于不是JavaScript原生支持,使用AMD规范进行页面开发需要用到对应的库函数,也就是大名鼎鼎RequireJS,实际上AMD 是 RequireJS 在推广过程中对模块定义的规范化的产出
requireJS主要解决两个问题
1、多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
2、js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长
看一个使用requireJS的例子
1 | /* |
语法
requireJS定义了一个函数 define,它是全局变量,用来定义模块
define(id?, dependencies?, factory);
- id:可选参数,用来定义模块的标识,如果没有提供该参数,脚本文件名(去掉拓展名)
- dependencies:是一个当前模块依赖的模块名称数组
- factory:工厂方法,模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值
在页面上使用require函数加载模块
require([dependencies], function(){});
require()函数接受两个参数
- 第一个参数是一个数组,表示所依赖的模块
- 第二个参数是一个回调函数,当前面指定的模块都加载成功后,它将被调用。加载的模块会以参数形式传入该函数,从而在回调函数内部就可以使用这些模块
require()函数在加载依赖的函数的时候是异步加载的,这样浏览器不会失去响应,它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性的问题。
CMD(很少用到了)
CMD 即Common Module Definition通用模块定义,CMD规范是国内发展出来的,就像AMD有个requireJS,CMD有个浏览器的实现SeaJS,SeaJS要解决的问题和requireJS一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同
语法
Sea.js 推崇一个模块一个文件,遵循统一的写法
define(id?, deps?, factory)
因为CMD推崇
- 一个文件一个模块,所以经常就用文件名作为模块id
- CMD推崇依赖就近,所以一般不在define的参数中写依赖,在factory中写
factory是一个函数,有三个参数,function(require, exports, module)
- require 是一个方法,接受 模块标识 作为唯一参数,用来获取其他模块提供的接口:require(id)
- exports 是一个对象,用来向外提供模块接口
- module 是一个对象,上面存储了与当前模块相关联的一些属性和方法
看个例子:
1 | /* |
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 | // a.js |
因为 CommonJS 还是会使用到的,所以这里会对一些疑难点进行解析
先说 require
吧
1 | var module = require('./a.js') |
另外虽然 exports
和 module.exports
用法相似,但是不能对 exports
直接赋值。因为 var exports = module.exports
这句代码表明了 exports
和 module.exports
享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对 exports
赋值就会导致两者不再指向同一个内存地址,修改并不会对 module.exports
起效。
ES Module
ES Module 是原生实现的模块化方案,与 CommonJS 有以下几个区别
- CommonJS 支持动态导入,也就是
require(${path}/xx.js)
,后者目前不支持,但是已有提案 - CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
- CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是 ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
- ES Module 会编译成
require/exports
来执行的
1 | // 引入模块 API |
总结
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时 就产生了闭包。