前端模块化规范详解
1. 模块化雏形
在JavaScript发展初期就是为了实现简单的页面交互逻辑,而如今CPU、浏览器性能得到了极大的提升,很多页面逻辑迁移到了客户端(表单验证等),随着Web发展,前端代码日益膨胀,此时在JS方面就会考虑使用模块化规范去管理。模块化已经发展了有十余年了,不同的工具和轮子层出不穷,但总结起来,它们解决的问题主要有三个:
-
模块的管理。
-
内部模块的组织。
-
模块源码到目标代码的编译和转换。
1.1. 模块化的目的
-
将一个复杂的程序依据一定的规范封装成几个块,并进行组合在一起。
-
块的内部数据与实现是私有的,只是向外部暴露一些接口与外部其它模块通信。
1.2. 模块化的演进过程
1.2.1. 全局function模式
将不同的功能封装成不同的全局函数。
-
编码:将不同的功能封装成不同的全局函数。
-
问题:污染全局命名空间,容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系。
function m1(){ //...}function m2(){ //...}
1.2.2. namespace模式
简单对象封装。
-
作用:减少了全局变量,解决命名冲突。
-
问题:数据不安全,外部可以直接修改模块内部的数据。
let myModule = { data: \'www.baidu.com\', foo() { console.log(`foo() ${this.data}`) }, bar() { console.log(`bar() ${this.data}`) }}myModule.data = \'other data\' // 能直接修改模块内部的数据myModule.foo() // foo() other data
这样的写法会暴露所有模块成员,内部状态可以被外部改写。
1.2.3. IIFE模式
匿名函数自调用,俗称闭包。
-
作用:数据是私有的,外部只能通过暴露的方法操作。
-
编码:将数据和行为封装到一个函数内部,通过给window添加属性来向外暴露接口。
-
问题:如果当前这个模块依赖另一个模块怎么办?
定义模块:
// module.js文件(function(window) { let data = \'www.baidu.com\' //操作数据的函数 function foo() { //用于暴露有函数 console.log(`foo() ${data}`) } function bar() { //用于暴露有函数 console.log(`bar() ${data}`) otherFun() //内部调用 } function otherFun() { //内部私有的函数 console.log(\'otherFun()\') } //暴露行为 window.myModule = { foo, bar } //ES6写法})(window)
模块引入:
// index.html文件 myModule.foo() // foo() www.baidu.com myModule.bar() // bar() www.baidu.com // otherFun() console.log(myModule.data) // undefined 不能访问模块内部数据 myModule.data = \'xxxx\' //不是修改的模块内部的data myModule.foo() // foo() www.baidu.com
1.2.4. IIFE增强模式
这是现代模块化实现的基石。
定义模块:
// module.js文件(function(window, $) { let data = \'www.baidu.com\' //操作数据的函数 function foo() { //用于暴露有函数 console.log(`foo() ${data}`) $(\'\').css(\'background\', \'red\') } function bar() { //用于暴露有函数 console.log(`bar() ${data}`) otherFun() //内部调用 } function otherFun() { //内部私有的函数 console.log(\'otherFun()\') } //暴露行为 window.myModule = { foo, bar }})(window, jQuery)
模块引入:
// index.html文件 myModule.foo()
上例子通过jquery方法将页面的背景颜色改成红色,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。
1.3. 模块化的好处
-
避免命名冲突,减少命名空间污染。
-
更好的分离,按需加载。
-
更高复用性。
-
高可维护性。
1.4. 引入多个后出现问题
-
请求过多,由于要依赖多个模块,那样就会发送多个请求,导致请求过多。
-
依赖模糊,由于不知道他们的具体依赖关系是什么,也就是说很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。
-
难以维护,上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。 模块化固然有多个好处,然而一个页面需要引入多个js文件,就会出现以上这些问题。而这些问题可以通过模块化规范来解决,因此才有了后续的Commonjs, AMD, ES6, CMD规范。
2. 模块化发展时间线
构建其实是工程化、自动化思想在前端开发中的体现(个人认为狭义的工程化就是构建工具+CI/CD),将一系列流程用代码去实现,让代码自动化地执行这一系列复杂的流程。构建为前端开发注入了更大的活力,解放了我们的生产力。
-
2009 年,Kevin Dangoor 发起了 ServerJS 项目,后更名为 CommonJS,其目标是指定浏览器外的 JS API 规范(例如 FS、Stream、Buffer 等)以及模块规范 Modules/1.0。这一规范也成为同年发布的 NodeJS 中的模块定义的参照规范。
-
2011 年,RequireJS 1.0 版本发布,作为客户端的模块加载器,提供了异步加载模块的能力。作者在之后提交了 CommonJS 的 Module/Transfer/C 提案,这一提案最终发展为了独立的 AMD 规范。
-
2013 年,Grunt、Gulp第一版相继发布,同年,面向浏览器端模块的打包工具Browserify发布。
-
2014 年,跨平台的前后端兼容的模块化定义语法 UMD发布。
-
2014 年,Sebastian McKenzie 发布了将 ES6 语法转换为 ES5 语法的工具 6to5,并在之后更名为Babel。
-
2014 年,Guy Bedford 对外发布了 SystemJS 和 jspm 工具,用于简化模块加载和处理包管理。
-
2014 年,打包工具 Webpack 发布了第一个稳定版本。
-
2015 年,ES6(ES2015)规范正式发布,第一次从语言规范上定义了 JS 中的模块化。
-
2015 年,Rich Harris 发布的 Rollup 项目,基于 ES6 模块化,提供了 Tree Shaking 的功能。
-
2017年,鉴于当时的Webpack使用上过于繁琐,官方文档也不是很清晰明了。所以Parcel一发布就被推上了风口浪尖。
-
2019年,snowpack将node_modules转为ESM的构建工具开始出现。
-
2020年,随着浏览器对ESM和HTTP2的支持,bundleless思路也开始出现,esbuild此时开始出现在视野里,同时snowpack也开始内置使用esbuild。
-
2021年,Vite出现。
由此,随着前端技术的演进,整体的构建工具也随之演进,接下来将主要对目前为止比较流行的构建工具进行分析,主要包括以下几类:
-
初版构建工具。
-
现代打包构建工具基石Webpack。
-
基于Webpack改进的构建工具。
-
突破 JS 语言的构建工具。
-
基于ES Module的 bundleless 构建工具。
3. 模块化规范
3.1. CommonJS
3.1.1. 基本概念
Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。
3.1.2. 基本特点
-
所有代码都运行在模块作用域,不会污染全局作用域。
-
模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
-
模块加载的顺序,按照其在代码中出现的顺序。
3.1.3. 基本语法
-
暴露模块:module.exports = value 或 exports.xxx = value。
-
引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径。
此处有个疑问:CommonJS暴露的模块到底是什么?CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
// example.jsvar x = 5;var addX = function (value) { return value + x;};module.exports.x = x;module.exports.addX = addX;
上面代码通过module.exports输出变量x和函数addX。
var example = require(\'./example.js\'); // 以\"./\"开头,则表示加载的是一个位于相对路径console.log(example.x); // 5console.log(example.addX(1)); // 6
require命令用于加载模块文件。require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。
3.1.4. 模块的加载机制
CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。这点与ES6模块化有重大差异(下文会介绍),请看下面这个例子:
// lib.jsvar counter = 3;function incCounter() { counter++;}module.exports = { counter: counter, incCounter: incCounter,};
上面代码输出内部变量counter和改写这个变量的内部方法incCounter。
// main.jsvar counter = require(\'./lib\').counter;var incCounter = require(\'./lib\').incCounter;console.log(counter); // 3incCounter();console.log(counter); // 3
上面代码说明,counter输出以后,lib.js模块内部的变化就影响不到counter了。这是因为counter是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。
3.1.5. 服务端的实现
1. 安装node
2. 执行npm init 初始化
|-modules |-module1.js |-module2.js |-module3.js|-app.js|-package.json{ \"name\": \"commonJS-node\", \"version\": \"1.0.0\"}
3. 下载第三方模块
npm install uniq --save // 用于数组去重
4. 定义模块代码
//module1.jsmodule.exports = { msg: \'module1\', foo() { console.log(this.msg) }}//module2.jsmodule.exports = function() { console.log(\'module2\')}//module3.jsexports.foo = function() { console.log(\'foo() module3\')}exports.arr = [1, 2, 3, 3, 2]// app.js文件// 引入第三方库,应该放置在最前面let uniq = require(\'uniq\')let module1 = require(\'./modules/module1\')let module2 = require(\'./modules/module2\')let module3 = require(\'./modules/module3\')module1.foo() //module1module2() //module2module3.foo() //foo() module3console.log(uniq(module3.arr)) //[ 1, 2, 3 ]
5. 运行模块 node app.js
3.1.6. 浏览器端的实现
使用Browserify:Browserify 会对代码进行解析,整理出代码中的所有模块依赖关系,然后把相关的模块代码都打包在一起,形成一个完整的JS文件,这个文件中不会存在 require 这类的模块化语法,变成可以在浏览器中运行的普通JS。
1. 创建项目结构
|-js |-dist //打包生成文件的目录 |-src //源码所在的目录 |-module1.js |-module2.js |-module3.js |-app.js //应用主源文件 |-index.html //运行于浏览器上 |-package.json { \"name\": \"browserify-test\", \"version\": \"1.0.0\" }
2. 下载browserify
-
全局:npm install browserify -g
-
局部:npm install browserify --save-dev
3. 定义模块代码
注意:index.html文件要运行在浏览器上,需要借助browserify将app.js文件打包编译,如果直接在index.html引入app.js就会报错!
4. 打包处理JavaScript
根目录下运行:
browserify js/src/app.js -o js/dist/bundle.js
5. 页面使用引入
在index.html文件中引入:
3.2. AMD
3.2.1. 基本概念
CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD规范则是非同步加载模块,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。此外AMD规范比CommonJS规范在浏览器端实现要来看早。
3.2.2. 基本语法
定义暴露模块:
//定义没有依赖的模块define(function(){ return 模块})//定义有依赖的模块define([\'module1\', \'module2\'], function(m1, m2){ return 模块})
引入使用模块:
require([\'module1\', \'module2\'], function(m1, m2){ 使用m1/m2})
3.2.3. AMD的实现
通过比较是否使用AMD,来说明使用AMD实际使用的效果。
1. 未使用AMD规范
// dataService.js文件(function (window) { let msg = \'www.baidu.com\' function getMsg() { return msg.toUpperCase() } window.dataService = {getMsg}})(window)// alerter.js文件(function(window, dataService) { let name = \'baidu\' function showMsg() { alert(dataService.getMsg() + \', \' + name) } window.alerter = {showMsg}})(window, dataService)// main.js文件(function (alerter) { alerter.showMsg()})(alerter)// index.html文件Modular Demo 1:未使用AMD(require.js)
最后得到结果如下:
www.baidu.com,baidu
这种方式缺点很明显:首先会发送多个请求,其次引入的JS文件顺序不能搞错,否则会报错。
2. 使用require.js
RequireJS是一个工具库,主要用于客户端的模块管理。它的模块管理遵守AMD规范,RequireJS的基本思想是,通过define方法,将代码定义为模块;通过require方法,实现代码的模块加载。
接下来介绍AMD规范在浏览器实现的步骤:
1. 下载并引入插件
-
官网:http://www.requirejs.cn/
-
github:https://github.com/requirejs/requirejs
然后将require.js导入项目:js/libs/require.js
2. 创建项目结构
|-js |-libs |-require.js |-modules |-alerter.js |-dataService.js |-main.js |-index.html
以下是alerter.js模块示例代码:
// alerter.js// 依赖模块和第三方库define([\'dataService\', \'jquery\'], function(dataService, $) { let name = \'baidu\' function showMsg() { alert(dataService.getMsg() + \', \' + name) } $(\'body\').css(\'background\', \'green\') // 暴露模块 return { showMsg }})
以下是dataService.js模块示例代码:
// dataService.js// 定义没有依赖的模块define(function() { let msg = \'www.baidu.com\' function getMsg() { return msg.toUpperCase() } // 暴露模块 return { getMsg } // 暴露模块})
以下是main.js模块示例代码:
// main.js(function() { require.config({ baseUrl: \'js/\', //基本路径 出发点在根目录下 paths: { //自定义模块 alerter: \'./modules/alerter\', //此处不能写成alerter.js,会报错 dataSize: \'./modules/dataService\', // 第三方库模块 jquery: \'./libs/jquery-1.10.1\' //注意:写成jquery会报错 } }) require([\'alerter\'], function(alerter) { alerter.showMsg() })})()
3. 在页面中使用
Modular Demo
3. 总结
通过两者的比较,可以得出AMD模块定义的方法非常清晰,不会污染全局环境,能够清楚地显示依赖关系。AMD模式可以用于浏览器环境,并且允许非同步加载模块,也可以根据需要动态加载模块。
3.3. CMD
3.3.1. 基本概念
CMD规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。CMD规范整合了CommonJS和AMD规范的特点。在 Sea.js 中,所有 JavaScript 模块都遵循 CMD 模块定义规范。
3.3.2. 基本语法
// 定义没有依赖的模块define(function(require, exports, module){ exports.xxx = value module.exports = value})// 定义有依赖的模块define(function(require, exports, module){ // 引入依赖模块(同步) var module2 = require(\'./module2\') // 引入依赖模块(异步) require.async(\'./module3\', function(m3) { }) // 暴露模块 exports.xxx = value})// 引入使用的模块define(function (require) { var m1 = require(\'./module1\') var m4 = require(\'./module4\') m1.show() m4.show()})
3.3.3. CMD的实现
1. 下载插件并引入
-
官网: http://seajs.org/
-
github: https://github.com/seajs/seajs
然后将sea.js导入项目:js/libs/sea.js
2. 创建项目结构
|-js |-libs |-sea.js |-modules |-module1.js |-module2.js |-module3.js |-module4.js |-main.js |-index.html
以下是module1.js模块示例代码:
// module1.jsdefine(function (require, exports, module) { // 内部变量数据 var data = \'baidu.com\' // 内部函数 function show() { console.log(\'module1 show() \' + data) } // 向外暴露 exports.show = show})
以下是module2.js模块示例代码:
// module2.jsdefine(function (require, exports, module) { module.exports = { msg: \'I am baidu\' }})
以下是module3.js模块示例代码:
// module3.jsdefine(function(require, exports, module) { const API_KEY = \'abc123\' exports.API_KEY = API_KEY})
以下是module4.js模块示例代码:
// module4.jsdefine(function (require, exports, module) { //引入依赖模块(同步) var module2 = require(\'./module2\') function show() { console.log(\'module4 show() \' + module2.msg) } exports.show = show //引入依赖模块(异步) require.async(\'./module3\', function (m3) { console.log(\'异步引入依赖模块3 \' + m3.API_KEY) })})
以下是main.js模块示例代码:
// main.js文件define(function (require) { var m1 = require(\'./module1\') var m4 = require(\'./module4\') m1.show() m4.show()})
3. 在页面中使用
在index.html中引入:
seajs.use(\'./js/modules/main\')
最后得到结果如下:
module1 show(), baidu.commodule4 show() I am baidu异步引入依赖模块3 abc123
3.3.4. AMD和CMD的区别
// CMDdefine(function (require, exports, module) { //依赖就近书写 var module1 = require(\'Module1\'); var result1 = module1.exec(); module.exports = { result1: result1, }});// AMDdefine([\'Module1\'], function (module1) { var result1 = module1.exec(); return { result1: result1, }});
从上面的代码比较中我们可以得出AMD规范和CMD规范的区别:
1. 对依赖的处理:
-
AMD推崇依赖前置,即通过依赖数组的方式提前声明当前模块的依赖。
-
CMD推崇依赖就近,在编程需要用到的时候通过调用require方法动态引入。
2. 在本模块的对外输出:
-
AMD推崇通过返回值的方式对外输出。
-
CMD推崇通过给module.exports赋值的方式对外输出。
3.4. ESModule
3.4.1. 基本概念
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
3.4.2. 基本使用
export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
// 定义模块 math.jsvar basicNum = 0;var add = function (a, b) { return a + b;};export { basicNum, add };// 引用模块import { basicNum, add } from \'./math\';function test(ele) { ele.textContent = add(99 + basicNum);}
如上例所示,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。
// export-default.jsexport default function () { console.log(\'foo\');}// import-default.jsimport customName from \'./export-default\';customName(); // \'foo\'
模块默认输出,其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。
ES6 模块与 CommonJS 模块的差异:
1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
下面重点解释第一个差异,我们还是举上面那个CommonJS模块的加载机制例子:
// lib.jsexport let counter = 3;export function incCounter() { counter++;}// main.jsimport { counter, incCounter } from \'./lib\';console.log(counter); // 3incCounter();console.log(counter); // 4
ES6 模块的运行机制与 CommonJS 不一样。ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
3.4.3. ES6模块化实现
简单来说就一句话:使用Babel将ES6编译为ES5代码,使用Browserify编译打包JavaScript。
1. 定义package.json文件
{ \"name\": \"es6-babel-browserify\", \"version\": \"1.0.0\"}
2. 安装babel-cli, babel-preset-es2015和browserify
npm install babel-cli browserify -gnpm install babel-preset-es2015 --save-dev
3. 定义.babelrc文件
{ \"presets\": [\"es2015\"]}
4. 定义模块代码
//module1.js文件// 分别暴露export function foo() { console.log(\'foo() module1\')}export function bar() { console.log(\'bar() module1\')}//module2.js文件// 统一暴露function fun1() { console.log(\'fun1() module2\')}function fun2() { console.log(\'fun2() module2\')}export { fun1, fun2 }//module3.js文件// 默认暴露 可以暴露任意数据类型,暴露什么数据,接收到就是什么数据export default () => { console.log(\'默认暴露\')}// app.js文件import { foo, bar } from \'./module1\'import { fun1, fun2 } from \'./module2\'import module3 from \'./module3\'foo()bar()fun1()fun2()module3()
5. 编译并在index.html中引入
-
使用Babel将ES6编译为ES5代码(但包含CommonJS语法):babel js/src -d js/lib
-
使用Browserify编译js:browserify js/lib/app.js -o js/lib/bundle.js
然后在index.html文件中引入:
最后得到如下结果:
foo() module1bar() module1fun1() module2fun2() module2默认暴露
6. 引入第三方库
首先安装依赖 npm install jquery@1,然后在app.js文件中引入:
//app.js文件import { foo, bar } from \'./module1\'import { fun1, fun2 } from \'./module2\'import module3 from \'./module3\'import $ from \'jquery\'foo()bar()fun1()fun2()module3()$(\'body\').css(\'background\', \'green\')
3.5. UMD
是一种JavaScript通用模块定义规范,让你的模块能在javascript所有运行环境中发挥作用。
意味着要同时满足CommonJS, AMD, CMD的标准,以下为实现:
(function(root, factory) { if (typeof module === \'object\' && typeof module.exports === \'object\') { console.log(\'是commonjs模块规范,nodejs环境\') module.exports = factory(); } else if (typeof define === \'function\' && define.amd) { console.log(\'是AMD模块规范,如require.js\') define(factory) } else if (typeof define === \'function\' && define.cmd) { console.log(\'是CMD模块规范,如sea.js\') define(function(require, exports, module) { module.exports = factory() }) } else { console.log(\'没有模块环境,直接挂载在全局对象上\') root.umdModule = factory(); }})(this, function() { return { name: \'我是一个umd模块\' }})
3.6. 构建产物模块化标准
-
CommonJS 规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。
-
AMD 规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
-
CMD 规范与AMD 规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在 Node.js 中运行。
-
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
-
UMD 可同时满足 CommonJS, AMD, CMD 标准的实现。