4.1 模块化的需求推演
本节将为你介绍JavaScript最初面对模块化诉求时的解决方案及其存在的问题。
4.1.1 script标签
在ES Module模块化标准出现以前,JavaScript本身并没有提供任何模块化规范,当我们需要在项目中添加多个依赖时,往往是通过大量的由上到下并列排布的<script>标签来实现的,很容易在旧代码中看到类似下面这样的代码片段:
<link rel="stylesheet" href="./lib/bootstrap.min.css"> <link rel="stylesheet" href="./lib/bootstrap-theme.min.css"> <link rel="stylesheet" href="./lib/jQuery-table.min.css"> <link rel="stylesheet" href="./lib/flat-ui.min.css"> ... <script src="./lib/js/jQuery.min.js"> <script src="./lib/js/underscore.min.js"> <script src="./lib/js/bootstrap.min.js"> <script src="./lib/js/jQuery-table.min.js"> <script src="./lib/js/echarts.min.js"> <script src="./lib/js/angularjs.min.js">
十年前非常流行的jQuery和Bootstrap都拥有极好的插件生态,许多现成的第三方库都可以直接拿来使用,开发者普遍使用上面的方式来引用多个脚本文件。即使是在现代开发中,许多非专业的前端开发者也仍然非常喜欢使用这种方式来引入外部文件,因为这样即使不学习前端构建工具和各种脚手架工具,也很快就可以让自己编写的脚本在浏览器中运行,而且效果还不错。随着项目中的代码日渐增多,这种原始的依赖管理方案的弊端就会逐渐显现出来。如果<script>标签上没有设置任何延迟执行的属性(defer或async属性),那么<script>标签的执行就会阻塞文档对象模型(Document Object Model,即DOM对象)的解析,加载的脚本文件越多,页面完成初始化的时间就会越长,所以我们经常会看到<script>标签被放在<body>标签之后,这可让网站首屏的内容信息先完成解析渲染,再为页面增加交互,因为交互和逻辑能力的增加对用户而言在视觉上几乎是无感知的。
尽管多个<script>标签看似将不同的代码块隔离到了不同的文件中,但这层代码就像窗户纸一样一捅就破,每一个由<script>标签引入的脚本文件实际上都是直接暴露在同一个全局作用域之下的,这就意味着如果参与合作的开发者在自己的脚本代码中使用了其他某个文件使用过的标识符,那么只有最后一个被引入的脚本中的定义会生效,而先引入的脚本中的定义全都会被覆盖掉,由此引发的混乱可想而知。在现代化基于构建的前端工程体系中,应用程序的入口已经转移到了JavaScript中,多模块加载顺序和并发请求限制数的问题也将通过JavaScript基础工具来实现。
4.1.2 代码隔离
为了满足代码隔离的基本需求,业内出现了以立即执行函数(Immediately Invoked Function Expression,IIFE)为模块包装的第一代模块化解决方案,它的基本代码结构如下:
;(function(window, undefined){ //...具体的业务逻辑代码 })(window);
在ES6标准之前,JavaScript只能使用函数来划分作用域,也就是说JavaScript需要借助函数来解决多人协作时的代码隔离问题。上面的代码结构看似简单,却包含了非常多的基础知识点,下面就来详细说明。
1. 开头的分号
在代码段的开头添加分号,是早期的代码合并工具引发的。浏览器在加载网站资源时,同一个域名下的并发连接数是有上限的(一般为6个),例如,你的网站引用了7个外部资源,那么前6个资源会先行下载,等到其中一个完成下载后,第7个文件才会开始下载。为了提升加载性能,早期的合并工具会将多个脚本文件合并压缩并生成一个文件,但此时定义当前模块的function语句就会与前一个模块结尾的语句连在一起被解析,这就会引发错误。合并后的脚本文件往往都是经过变量替换的,开发者也很难在生成的文件中手动解决这些错误。而在自执行函数的开头添加一个分号,就能有效避免这种问题。
2. 立即执行函数
上述代码的主体是一段立即执行函数,也就是我们常说的IIFE,小括号将function(){}定义语句括起来,这个括号的作用是将函数定义变成一个表达式(当然这并不是唯一的方法),紧接其后的括号里的是函数调用语句,这个匿名函数会在定义后直接运行。这样,函数体中使用的标识符就都只在当前函数作用域有效了,立即执行函数就是通过这种方式来达到代码隔离效果的。
3. 函数的形参和实参
许多开发者最初会被这个写法中的两个“window”搞得晕头转向,实际上只要分清楚形参和实参,就比较容易区分它们了。在代码中创建一个函数时,写在参数列表里的参数称为“形式参数(简称形参)”,它代表你调用这个函数时所传入的实际参数,无论传入的那个实际参数的真实名称是什么,在当前定义的函数体范围内都可以用形参的名称代表它。稍微改动一下上面的例子,就更容易看清楚了:
;(function(global, undefined){ //...具体的业务逻辑代码 })(window);
改动之后,在函数体范围内,“global”这个标识符就代表了传入的“window”参数,即真实的全局对象,如果你在实参处传入Math对象,那么函数体范围内的“global”就代表了Math,它只在自执行函数封闭作用域中有意义。
4. undefined
我们知道“undefined”在JavaScript语言中是一个关键字,不仅如此,它还是全局对象的一个属性,它的值被定义为“undefined”。在低版本的浏览器中,它是可以被赋值修改的,一旦有人恶意修改了“undefined”这个属性的值,那么你写在代码里的所有针对“undefined”的判断逻辑就会混乱。由于立即执行函数中的最后一个形参没有对应于任何值,因此其会被自动赋值为真正的“undefined”,以避免上述风险。另一方面,“undefined”作为形参时,一些代码压缩工具也会对其进行有效的压缩和变量替换,从而减小文件体积,所以在第三方工具库的脚本文件中,我们经常会看到这种书写风格。在JavaScript中可以使用“void 0”来得到真实的“undefined”。
5. 与外部作用域的通信
如果我们将所有的模块代码都编写在自执行函数中,那么函数执行结束后,这些模块代码就会被销毁,其中的某些执行结果或定义的方法又该如何传达给外界呢?常见的方法有以下两种。第一,函数实参为对象类型时,函数体内只保留对原对象的引用,对实参执行的所有操作都会直接影响到原对象。这就好比是在上面的模型中,我们在函数体内定义了一些方法,然后把它挂载在“window”对象的某个命名空间下,这时我们所挂载的目标对象实际上是外层“window”对象的引用,所以在函数执行完毕后,它对“window”对象的影响也会保留下来,因为销毁的只是对它的一个引用,就好像你在系统中删除了一个快捷方式的图标一样。第二,在形式上更贴近模块化规范,自执行函数也是一个函数,它是可以有返回值的,我们可以把自执行函数内部定义的方法通过“return”语句返回,然后将其赋值给另一个变量,这样函数内部的值或方法就可以传递到函数外部了。需要注意的是,在IIFE函数体中书写的对于global变量的赋值并不会影响外部的全局对象,它只会让global这个本地变量指向堆内存中的另一个地址,只有当你对global变量的某个属性进行赋值操作时,相应的值才会出现在全局对象上,这也是初学者非常容易忽略的知识。
4.1.3 依赖管理
借助于前文中介绍的模块化方案,我们能够在一定程度上解决代码隔离的问题,然而,当完整的代码被划分为模块以后,我们又需要对模块的加载顺序和相互之间的依赖关系进行管理。这件事情乍看起来似乎并没有那么重要,在项目依赖较少时,我们可以通过手动排序来避免冲突,随后每一次增加外部依赖,几乎都是按次序继续写在已有的<script>标签之下,那么为什么要对依赖关系图进行解析管理呢?
首先,需要明确的是,尽管HTML标准为<script>标签的async和defer这两个异步加载的属性使得加载脚本时可以不阻塞主线程,但浏览器在实现上并不是完全遵循标准的,每个浏览器在实现层面都会以自己的方式对加载和执行的过程进行优化。在真实的使用场景中,基于浏览器的不同和网络条件的差异,<script>标签的异步属性对脚本加载顺序的影响是不稳定的,这就让开发者陷入了一个两难的境地,同步加载的话会导致页面的等待时间越来越长,异步加载的话依赖关系又会无法保障。常规的脚本在加载完成后就会自动执行,如果访问的模块还没有解析就会引发错误。如果不同模块的依赖关系非常明确,我们就可以在代码层面对这种依赖关系进行强制加载,并对执行顺序进行限制,这样做能够尽量避免环境差异带来的影响,提高代码的健壮性和稳定性,同时清晰的依赖关系也是代码优化所需要的重要信息。
当然,成熟的模块化工具还会添加许多工程化的特性,例如,在测试模式下自动为请求增加时间戳,为请求打上自定义LogID等。模块化最基本的诉求是解决代码隔离和依赖管理两大问题,4.2节将具体介绍各种JavaScript模块化规范。