深入浅出模块化(含 tree shaking)(上)
Last updated
Last updated
模块化是工程化的基础 :只有能将代码模块化,拆分为合理单元,才具备调度整合的能力,才有架构和工程一说。早期,JavaScript 只是作为浏览器端脚本语言出现,只负责简单的页面交互,并不具备先天的模块化能力。
随着 Node.js 的发展和 ES 的演进,模块化如今在前端领域早已经不新鲜。但是,对于模块化我们不应该只停留在了解、会用的基础上,还要深入其中,认识在这个演进过程中:
模块化经历了怎样的发展历程,从中我们能学习到哪些知识?
跟其他早已发展成熟的语言相比,JavaScript 语言的模块化又有哪些特点?
新的模块化 feature 又有哪些?dynamic import 现在停留在哪个阶段?
让我们通过本节课程达成这一目标。 不同于社区上常见的文章,我们并不会把焦点放在介绍各种模块化方案的使用方法上,而是直接剖析其实现,分析标准的制定。
这个主题的知识点如下:
接下来,我们通过 2 节内容来学习这个主题。
到底什么是模块化?简单来说就是: 对于一个复杂的应用程序,与其将所有代码一股脑地放在一个文件当中,不如按照一定的语法,遵循确定的规则(规范)拆分成几个互相独立的文件。这些文件应该具有原子特性,也就是说,其内部完成共同的或者类似的逻辑,通过对外暴露一些数据或调用方法,与外部完成整合。
这样一来,每个文件彼此独立,开发者更容易开发和维护代码,模块之间又能够互相调用和通信,这是现代化开发的基本模式。
其实,不论在我们的日常生活还是其他科学领域,都离不开模块化的概念,它主要体现了以下原则:
可复用性
可组合型
中心化
独立性
在模块化的基础上,结合工程化,又可以衍生出很多概念和话题。比如基于模块化的 tree shaking 技术,模块循环加载的处理等。不过不要着急,我们先来看看前端模块化的发展历程。
我认为前端模块化发展主要经历了三个阶段:
早期“假”模块化时代
规范标准时代
ES 原生时代
这些阶段逐次递进,每一种新方案的诞生,都离不开老方案的启示。
在早期,JavaScript 属于运行在浏览器端的玩具脚本,它只负责实现一些简单的交互。随着互联网技术的演进,这样的设计逐渐不能满足业务的需求。这时候开发者往往从代码可读性上,借助函数作用域来模拟实现“假”的模块化,我称其为 函数模式 ,即将不同功能封装成不同的函数:
这样的方式其实根本不算模块化,各个函数在同一个文件中,混乱地互相调用,而且存在命名冲突的风险。这没有在根本上解决问题,只是从代码编写的角度,拆分成了更小的函数单元而已。
于是,聪明的开发者很快就想出了第二种方式,姑且称它为 对象模式 ,即利用对象,实现命名空间的概念:
这样我们模拟了简单的 module1、module2 命名空间,在函数主体中可以调用:
可是这样问题也很明显,module1 和 module2 中的数据并不安全,任何开发者都可以修改:
对象内部成员可以随意被改写,极易出现 bug。那么有什么手段能弥补这个不足呢?
想一想之前关于闭包的课程,从某种角度上看,闭包简直就是一个天生解决数据访问性问题的方案。通过立即执行函数(IIFE),我们构造一个私有的作用域,再通过闭包,将需要对外暴露的数据和接口输出,我们称此为 IIFE 模式 。立即执行函数结合闭包实现的代码如下:
我们在调用时:
如果想要访问变量 foo:
是访问不到具体数据的。 了解了这种模式,我们可以在此基础上“玩出另外一个花”来,该方式的变种:结合顶层 window 对象,我们再来看:
这样的实现,数据 data 完全做到了私有,外界无法修改 data 值。那么如何访问 data 呢?这时候需要模块内部设计并暴露相关接口。上述代码中,只需要调用模块 module1 暴露给外界(window)的函数即可:
修改 data 值的途径,也只能由模块 module1 提供:
如此一来,已经初具“模块化”的实质,实现了模块化所应该具备的初级功能。
我们再进一步思考,如果 module1 依赖外部模块 module2,该怎么办?请参考代码:
事实上,这就是现代模块化方案的基石。到此为止,我们经历了模块化的第一阶段:“假”模块化时代。 这种实现极具阿 Q 精神,它并不是语言原生层面上的实现,而是开发者利用语言,借助 JavaScript 特性,模拟了类似的功能,为后续方案打开了大门。请继续阅读。
Node.js 无疑对前端的发展具有极大的促进作用,它带来的 CommonJS 模块化规范像一股“改革春风”:在 Node.js 中,每一个文件就是一个模块,具有单独的作用域,对其他文件是不可见的。关于 CommonJS 的规范,我们这里不做过多介绍,基础内容读者可自行理解,我们只来看看它的 几个容易被忽略的特点。
文件即模块,文件内所有代码都运行在独立的作用域,因此不会污染全局空间。
模块可以被多次引用、加载。在第一次被加载时, 会被缓存 ,之后都从缓存中直接读取结果。
加载某个模块,就是引入该模块的 module.exports 属性。
module.exports 属性 输出的是值的拷贝 ,一旦这个值被输出,模块内再发生变化不会影响到输出的值。
模块加载顺序按照代码引入的顺序。
注意 module.exports 和 exports 的区别
CommonJS 规范用代码如何在浏览器端实现呢?其实就是实现 module.exports 和 require 方法。
实现思路:根据 require 的文件路径,加载文件内容并执行,同时将对外接口进行缓存。因此我们需要定义:
借助立即执行函数,将 module 和 module.exports 对象进行赋值:
社区上对 CommonJS 实现的模拟很多,这里我不在浪费笔墨重复,给大家推荐浅谈前端模块化,以及 browserify。
由于 Node.js 运行于服务器上,所有的文件一般都已经存在了本地硬盘中,不需要额外的网络请求去异步加载,因而 CommonJS 规范加载模块是同步的。只有加载完成,才执行后续操作。但是,如果放在浏览器环境中,我们都需要从服务器端获取模块文件,此时再采用同步的方式,显然就不合适了。这时候,社区上推出了 AMD 规范。
AMD 规范,全称为:Asynchronous Module Definition,看到 “Asynchronous”,我们就能够反映到它的模块化标准不同于 CommonJS,是异步的,完全贴合浏览器的。
它规定了如何定义模块,如何对外输出,如何引入依赖。这一切都需要代码去实现,因此一个著名的库 —— require.js 应运而生,require.js 实现很简单:通过 define 方法,将代码定义为模块;通过 require 方法,实现代码的模块加载。
define 和 require 就是 require.js 在全局注入的函数。
在熟练使用的基础上,建议读者参考 require.js 源码。
我们看到,require.js 在全局定义了 require 和 define 两个方法,也是利用立即执行函数,将全局对象(this)和 setTimeout 传入函数体内。其中:
这里主要是将依赖注入到依赖队列。而 require 的主要作用是完成创建 script 标签去请求相应的模块,对模块进行加载和执行:
细心的读者可能会有疑问:在我们使用 require.js 之后,并没有发现额外多出来的 script 标签,这个秘密就在于 checkLoaded 方法会把已经加载完毕的脚本删除,因为我们需要的是模块内容,一旦加载之后,没有必要保留有 script 标签了:
更多源码内容,感兴趣的读者可以在评论区讨论交流,或者直接向我提问。
CMD 规范整合了 CommonJS 和 AMD 规范的特点。它的全称为:Common Module Definition,类似 require.js,CMD 规范的实现为 sea.js。
AMD 和 CMD 的两个主要区别如下。
AMD 需要异步加载模块,而 CMD 在 require 依赖的时候,可以通过同步的形式(require),也可以通过异步的形式(require.async)。
CMD 遵循依赖就近原则,AMD 遵循依赖前置原则。也就是说,在 AMD 中,我们需要把模块所需要的依赖都提前在依赖数组中声明。而在 CMD 中,我们只需要在具体代码逻辑内,使用依赖前,把依赖的模块 require 进来。
具体到代码实现,sea.js 与 require.js 并没有本质差别,这里不再另做分析。
UMD 全称:Universal Module Definition,看到 “Universal”,我们可以猜到它允许在环境中同时使用 AMD 与 CommonJS 规范,相当于一个整合。该模式的 核心思想 在于利用立即执行函数根据环境来判断需要的参数类别,譬如在 CommonJS 环境下,上述代码会以如下方式执行:
而如果是在 AMD 模块规范下,函数的参数就变成了 define,适用 AMD 规范。
具体代码:
至此,我们介绍完了模块化的 Node.js 和社区解决方案。这些方案充分利用了 JavaScript 语言特性,并结合浏览器端的特点,加以实现。不同的实现方式体现了不同的设计哲学,但是它们的最终方向都指向了模块化的几个原则:可复用性、可组合型、中心化、独立性,下一节我们继续这个主题,介绍模块化的原生解决方案。
阅读文章过程中有任何疑问可随时跟其他小伙伴讨论,或者直接向作者 LucasHC 提问(作者看到后抽空回复)。 你的分享不仅帮助他人,更会提升自己。
你也可以说说自己最想了解的主题,课程内容会根据部分读者的意见和建议迭代和完善。
此外,我们为本课程付费读者创建了《前端开发核心知识进阶》微信交流群,以方便更有针对性地讨论课程相关问题(入群请到第1-2课末尾添加 GitChat 小助手伽利略的微信,并注明「前端核心」,谢谢~)