前端工程化的开篇:模块化的诞生


theme: smartblue
highlight: dark

本文正在参加「金石计划 . 瓜分6万现金大奖」

前言

如果我问你 webpack 是干什么用的?大多数人都会说 webpack 是打包工具。

更准确地说:它所解决的问题是如何在前端项目中更高效地管理和维护项目中的每一个资源。

要明⽩我们的打包⼯具究竟做了什么,⾸先我们必须了解一下 JS 中的模块化以及它的发展过程。

我们来说一下前端模块化的演进过程:

  1. Stage 1 – 文件划分方式

最早我们会基于文件划分的方式实现模块化,也就是 Web 最原始的模块系统。具体做法是将每个功能及其相关状态数据各自单独放到不同的 JS 文件中,约定每个文件是一个独立的模块。使用某个模块将这个模块引入到页面中,一个 script 标签对应一个模块,然后直接调用模块中的成员(变量 / 函数)。

这种方式的缺点:

1.模块直接在全局工作,大量模块成员污染全局作用域;
2.没有私有空间,所有模块内的成员都可以在模块外部被访问或者修改;
3.一旦模块增多,容易产生命名冲突;
4.无法管理模块与模块之间的依赖关系;
5.在维护的过程中也很难分辨每个成员所属的模块。

总之,这种原始“模块化”的实现方式完全依靠约定实现,一旦项目规模变大,这种约定就会暴露出种种问题,非常不可靠,所以我们需要尽可能解决这个过程中暴露出来的问题。

  1. Stage 2 – 命名空间方式

后来,我们约定每个模块只暴露一个全局对象,所有模块成员都挂载到这个全局对象中,具体做法是在第一阶段的基础上,通过将每个模块“包裹”为一个全局对象的形式实现,这种方式就好像是为模块内的成员添加了“命名空间”,所以我们又称之为命名空间方式。

这种命名空间的方式只是解决了命名冲突的问题,但是其它问题依旧存在。

  1. Stage 3 – IIFE

使用立即执行函数表达式(IIFE,Immediately-Invoked Function Expression)为模块提供私有空间。具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现。

这种方式带来了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问,这就解决了前面所提到的全局作用域污染和命名冲突的问题。

  1. Stage 4 – IIFE 依赖参数

在 IIFE 的基础之上,我们还可以利用 IIFE 参数作为依赖声明使用,这使得每一个模块之间的依赖关系变得更加明显。

以上 4 个阶段是早期的开发者在没有工具和规范的情况下对模块化的落地方式,这些方式确实解决了很多在前端领域实现模块化的问题,但是仍然存在一些没有解决的问题。

最明显的问题就是:模块的加载。在这几种方式中虽然都解决了模块代码的组织问题,但模块加载的问题却被忽略了,我们都是通过 script 标签的方式直接在页面中引入的这些模块,这意味着模块的加载并不受代码的控制,时间久了维护起来会十分麻烦。试想一下,如果你的代码需要用到某个模块,如果 HTML 中忘记引入这个模块,又或是代码中移除了某个模块的使用,而 HTML 还忘记删除该模块的引用,都会引起很多问题和不必要的麻烦。

更为理想的方式应该是在页面中引入一个 JS 入口文件,其余用到的模块可以通过代码控制,按需加载进来。

除了模块加载的问题以外,目前这几种通过约定实现模块化的方式,不同的开发者在实施的过程中会出现一些细微的差别,因此,为了统一不同开发者、不同项目之间的差异,我们就需要制定一个行业标准去规范模块化的实现方式。

模块化规范的出现

1.CommonJS

CommonJS 是 Node.js 中所遵循的模块规范,该规范约定,一个文件就是一个模块,每个模块都有单独的作用域,通过 module.exports 导出成员,再通过 require 函数载入模块。

我们是不可以直接在浏览器端直接使用这个规范的,CommonJS 约定的是以同步的方式加载模块,因为 Node.js 执行机制是在启动时加载模块,执行过程中只是使用模块,所以这种方式不会有问题。但是如果要在浏览器端使用同步的加载模式,就会引起大量的同步模式请求,导致应用运行效率低下。

2.AMD

所以在早期制定前端模块化标准时,并没有直接选择 CommonJS 规范,而是专门为浏览器端重新设计了一个规范,叫做 AMD ( Asynchronous Module Definition) 规范,即异步模块定义规范。同期还推出了一个非常出名的库,叫做 Require.js,它除了实现了 AMD 模块化规范,本身也是一个非常强大的模块加载器。

在 AMD 规范中约定每个模块通过 define() 函数定义,这个函数默认可以接收两个参数,第一个参数是一个数组,用于声明此模块的依赖项;第二个参数是一个函数,参数与前面的依赖项一一对应,每一项分别对应依赖项模块的导出成员,这个函数的作用就是为当前模块提供一个私有空间。如果在当前模块中需要向外部导出成员,可以通过 return 的方式实现。

除此之外,Require.js 还提供了一个 require() 函数用于自动加载模块,用法与 define() 函数类似,区别在于 require() 只能用来载入模块,而 define() 还可以定义模块。当 Require.js 需要加载一个模块时,内部就会自动创建 script 标签去请求并执行相应模块的代码。

目前绝大多数第三方库都支持 AMD 规范,但是它使用起来相对复杂,而且当项目中模块划分过于细致时,就会出现同一个页面对 js 文件的请求次数过多的情况,从而导致效率降低。在当时的环境背景下,AMD 规范为前端模块化提供了一个标准,但这只是一种妥协的实现方式,并不能成为最终的解决方案。

同期出现的规范还有淘宝的 Sea.js,只不过它实现的是另外一个标准,叫作 CMD,这个标准类似于 CommonJS,在使用上基本和 Require.js 相同,可以算上是重复的轮子。但随着前端技术的发展,Sea.js 后来也被 Require.js 兼容了。如果你感兴趣可以课后了解一下 Seajs官网。

3.ESModule

前⾯我们说到的 CommonJS 规范和 AMD 规范有这么⼏个特点:

  1. 语⾔上层的运⾏环境中实现的模块化规范,模块化规范由环境⾃⼰定义。
  2. 相互之间不能共⽤模块。例如不能在 Node.js 运⾏AMD 模块,不能直接在浏览器运⾏ CommonJS 模块。

在 EcmaScript 2015 也就是我们常说的 ES6 之后,JS 有了语⾔层⾯的模块化导⼊导出关键词与语法以及与之匹配的 ESModule 规范。使⽤ ESModule 规范,我们可以通过 import 和 export 两个关键词来对模块进⾏导⼊与导出。

每个 JS 的运⾏环境都有⼀个解析器,否则这个环境也不会认识 JS 语法。它的作⽤就是⽤ ECMAScript 的规范去解释 JS 语法,也就是处理和执⾏语⾔本身的内容,例如
按照逻辑正确执⾏ var a = “123”;,function func() {console.log(“hahaha”);} 之类的内容。
在解析器的上层,每个运⾏环境都会在解释器的基础上封装⼀些环境相关的 API。例如 Node.js 中的 global对象、process 对象,浏览器中的 window 对象,document 对象等等。这些运⾏环境的 API 受到各⾃规范的影响,例如浏览器端的 W3C 规范,它们规定了 window 对象和 document 对象上的 API 内容,以使得我们能让 document.getElementById 这样的 API 在所有浏览器上运⾏正常。

ESModule 就属于 JS Core 层⾯的规范,⽽ AMD,CommonJS 是运⾏环境的规范。所以,想要使运⾏环境⽀持 ESModule 其实是⽐较简单的,只需要升级⾃⼰环境中的 JS Core 解释引擎到⾜够的版本,引擎层⾯就能认识这种语法,从⽽不认为这是个 语法错误(syntaxerror) ,运⾏环境中只需要做⼀些兼容⼯作即可。

Node.js 在 V12 版本之后才可以使⽤ ESModule 规范的模块,在 V12 没进⼊ LTS 之前,我们需要加上 –experimental-modules 的 flag 才能使⽤这样的特性,也就是通过 node –experimental-modules index.js 来执⾏。浏览器端 Chrome 61 之后的版本可以开启⽀持 ESModule 的选项。

这也就是说,如果想在 Node.js 环境中使⽤ESModule,就需要升级 Node.js 到⾼版本,这相对来说⽐较容易,毕竟服务端 Node.js 版本控制在开发⼈员⾃⼰⼿中。

但浏览器端具有分布式的特点,是否能使⽤这种⾼版本特性取决于⽤户访问时的版本,⽽且这种解释器语法层⾯的内容⽆法像 AMD 那样在运⾏时进⾏兼容,所以想要直接使⽤就会⽐较麻烦。

综上所述,如何在不同的环境中去更好的使用 ES Modules 将是你重点考虑的问题。

小节

今天我们了解了 JS 中的模块化以及它的发展过程,后续还会进行 Webpack 的深入分析,持续写作中…

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容