随着前端功能的日趋复杂,模块化开发变得尤为重要,在大型项目中,模块化的开发方式不仅能有效复用代码,还能针对性的修改模块代码而不影响项目的稳定运行,还能借助同样功能的第三方模块,提高开发效率。
模块化开发作为一种主流开发思想,开发项目就像搭积木一样,每一个模块就是一个个独立的积木而已。但是在代码中会相对复杂一些,模块之间的相互引用导致了模块并非完全独立于其他模块的。
本文主要就前端模块化开发的演变过程,重点说明js
的模块化规范:浏览器端使用的ES Modules
和Node环境使用的CommonJS
规范。以及阐述如何通过基于模块化开发构建web应用。
前端模块化开发的演变过程
早期的模块化开发没有一个合理的规范,最开始的模块化就是把整个js
文件拆分成若干个文件,可以理解为“文件划分”阶段。
比如某个项目下的a.js
,b.js
等等,在使用的时候简单的按照执行顺序引入:
1 | <script src="a.js"></script> |
这样的方式除了按照功能划分了代码,并不是真正意义上的模块,代码之间仍然存在命名冲突,模块变量可以随意更改等问题。
总的来说,只是简单的做了文件划分的存在明显的缺陷:
- 模块之间相互污染全局作用域
- 命名冲突
- 模块之间的引用关系无法管理
除了方便根据文件名找到对应功能的代码,貌似没有别的好处。
为了尽量解决作用域污染和重名问题,我们使用命名空间来对变量进行空间限定,比如模块a.js
:
1 | var moduleA = { |
这样,我们在使用模块a
的使用就不用担心内部成员被污染的情况,但是,模块内部的成员任然会被外部直接访问和修改到,没有做到私有化。
下一阶段可以称为立即执行阶段,即使用IIFE
对模块代码进行闭包处理,这样,我们只需要导出我们希望导出的内容,而外部就不能再访问未暴露的变量,比如:
1 | (function($){ |
立即执行函数能解决变量私有化的问题,大部分情况下已经解决了模块化开发的需求,但是这种写法也是多种多样的,对于别人的代码和模块,引用和修改起来还是十分不便,这样就需要一种规范化出现,让开发者们都遵循和使用这些规范进行开发,也就能解决上述问题。
模块化规范最主要的就是解决:模块引用和模块导出的问题。
其中,commonJS规范是出现较早的规范,目前也是被Node
所使用的模块化规范。
CommonJS规范有以下约定:
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过
module.exports
导出成员 - 通过
require
函数载入其他模块导出的成员
CommonJS规范的模块加载是同步执行的,也就是模块加载完成后再执行代码,对于Node来说,主要应用于服务器开发,代码存储在本地,同步加载也比较快,所以执行起来没什么效率问题,但在浏览器端就不行了,如果执行一段代码需要不断地从服务器加载同步模块,会导致浏览器执行效率低下,因此,浏览器采用AMD这种异步加载模块的规范来实现模块化开发。AMD是”Asynchronous Module Definition”的缩写,意思就是”异步模块定义”。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
AMD模块也是用require
函数导入模块,但需要使用回调函数:
1 | // 定义一个模块 |
目前,主要有两个Javascript库实现了AMD规范:require.js,curl.js和seajs。但使用起来还是很复杂,且模块较多时,模块js文件加载请求变得频繁,所以,AMD规范也只是浏览器端模块规范化的一步。
目前最佳的模块化规范就是:浏览器端的ES Modules
和Node端的CommonJS
规范,这两个规范都是内置的规范,不存在执行环境问题,需要注意的是ES Modules
是es6
才推出的模块化规范,所以早期的大部分浏览器都不支持ES Modules
规范,这就需要我们注意在使用ES Moduels
规范开发时的兼容问题。
浏览器端的模块化规范:ES Modules
上文提到的CAD规范只是为了能实现浏览器端的模块化开发而做的“妥协”方法,执行效率上仍然是个问题。最终,在ES6,我们等到了原生模块化的支持 : ES Modules — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。
导出模块成员
使用export
关键字在最外层对变量进行导出即可。
1 | export const name = 'jinx' |
export
只能写在最外层,而不能写到执行语句中。
还有一种简单的方法是在最后统一导出成员:
1 | export {name, add} |
为避免重名问题,我们还可以把
导入模块和成员
你想在模块外面使用一些功能,那你就需要导入他们才能使用。最简单的就像下面这样的:
1 | import { name, draw, reportArea, reportPerimeter } from '/js-examples/modules/basic-modules/modules/square.js'; |
使用 import
语句,然后你被花括号包围的用逗号分隔的你想导入的功能列表,然后是关键字from,然后是模块文件的路径。模块文件的路径是相对于站点根目录的相对路径,对于我们的basic-modules
应该是 /js-examples/modules/basic-modules
。当然,我们写的路径有一点不同—我们使用点语法意味 “当前路径”,跟随着包含我们想要找的文件的路径。这比每次都要写下整个相对路径要好得多,因为它更短,使得URL 可移植。
值得注意的是,模块导出的值并不是复制了一份,而是导出了引用而已,虽然外部引用无法修改内部模块的成员变量,但模块内部导出成员发生变化后,导出的成员也会随之改变。
以下是一些注意事项:
1 | // 不能省略扩展名 |
浏览器环境使用
在已经支持了es modules
规范的浏览器,我们可以直接使用modules
类型的<script>
进行模块文件导入:
1 |
|
在支持的浏览器上打开这个html文件是可以正常执行模块中的代码的,但不支持es modules
的浏览器会忽略这种模块文件。
为了在不支持的浏览器中也能使用模块语法,我们可以借助第三方编译工具,比如polyfill
,模块代码会被转换为低版本的js代码,并返回给浏览器执行。
比如我们使用browser-es-module-loader这个模块来兼容低版本浏览器,安装:
1 | npm install browser-es-module-loader |
在浏览器端,我们可以通过unpkg.com
拼接模块名就可以获取到最新的js文件:比如这个模块就拼接为https://unpkg.com/browser-es-module-loader。我们引入html文件中:
1 |
|
如果存在资源请求限制,最好是把js文件下载到本地再引用。
注意,如果你的浏览器还不支持
Promise
还需要再安装一个编译Promise的模块,因为上面编译模块代码的第三方模块内部使用的是Promise语法进行回调。这里我们使用了promise-polyfii
模块。
另外一个问题,我们发现在支持es modules
规范的浏览器,调用了两次模块代码,这是因为<script type="module">
执行了一次,而es-module-loader
又执行了一次,我们知道,这个模块是为了在不支持模块规范的浏览器中使用的,支持的就不需要使用了,所以我们使用一个nomodule
的属性来标识:
1 | <script nomodule src="https://unpkg.com/promise-polyfill@8.2.0/dist/polyfill.min.js"></script> |
但这样的写法在生成环境是极不推荐的,生产环境的代码应该是编译后的代码,而且我们可以借助babel这样的工具自动完成,不需要那么麻烦。
在Node环境中使用es modules
保证node版本大于8.5。
在node中执行es modules的语法,需要做两个准备:
- 将
.js
扩展名为.mjs
- 使用node启动模块时带上
--experimental-modules
命令,目前来说,这仍是实验中的特性,所以不要在生成环境中使用。
更改文件扩展名之后,我们就可以在node中使用es modules语法了:
1 | import fs from 'fs' |
执行:
1 | node --experimental-modules index.mjs |
值得注意的是:
1 | // 可以使用{}导出内置模块的成员 |
此外,Node的内置成员也不能再访问:
1 | // 模块加载函数 |
但这些信息都可以从import
函数的成员中转换得到:
1 | import { fileURLToPath } from 'url' |
最新版本的支持:
切换node版本到12.10以上,
1 | nvm use 12.10.0 |
每次都加上--experimental-modules
比较麻烦,我们在package.js中增加一个type
字段:
1 | { |
这样,node默认执行环境就是es modules
语法,而且文件扩展名不再需要使用.mjs
,但是,如果你需要使用CommonJS
语法,需要将扩展名修改为.cjs
。
使用babel兼容js版本问题
为了兼容浏览器使用es modules
规范,我们一般使用babel
进行js版本编译,这样,我们就可以放心使用最新特性的js代码,也就包括了这些模块化开发机制。
安装:
1 | npm install @babel/node @balel/core @babel/preset-env --dev |
使用babel-node
命令运行js即可:
1 | yarn babel-node ./index.js --presets=@babel/preset-env |
为了能正常将模板代码编译为正常的js版本,需要使用
--presets=@babel/preset-env
指令,这是因为babel
是插件化开发的,如果我们未设置插件,某些功能,比如模块代码编译,就无法正常工作。而@babel/preset-env
相当于一个插件集模块,其中就包含了模块解析的插件。
为了更方便的调用,我们可以使用babel
的配置文件.babelrc
来设置presets
项:
1 | { |
当然,这里引用@babel/preset-env
插件集只是为了编译模块代码,我们还可以手动指定插件,比如编译模块代码的插件:
1 | npm install @babel/plugin-transform-modules-commonjs --dev |
然后配置文件使用plugins
项导入:
1 | { |