前端模块化的前世今生

写在前面

本文主要参考前端模块化详解
模块化就是将一个复杂的系统按照一定的规范分解成多一个个独立的模块的代码组织方式。最早前端是没有模块化的概念的,只是通过一个个 script 标签引入,但随着前端代码的日益膨胀,会出现很多问题,比如多个 script 之间的依赖关系很难掌控,容易不清楚他们之间的依赖关系而导致加载顺序出错,然后也需要引入多个js,代码难以维护,所以 js 就必须使用和其它语言一样的模块化去管理了,因此前端就出现了一系列的模块化解决方案,并不断的发展完善。本文带你从石器时代开始一步步了解一下模块化的前世今生。

模块化的发展

  • 全局 function 模式:
    将不同的功能封装成不同的全局函数。问题在于会污染全局命令空间,容易引起命令冲突

    1
    2
    function f1 () {}
    function f2 () {}
  • 命名空间模式
    简单的用一个对象包裹封装下,这样减少了全局变量,解决了命名冲突,但是数据会不安全,外部可以直接修改模块内部的数据。

    1
    2
    3
    4
    5
    let myModule = {
    data: 'heheda',
    f1() {},
    f2() {}
    }
  • IIFE模式
    匿名函数自调用(闭包)
    将数据和行为封装到一个函数内部,通过给 windows 添加属性来向外暴露接口,数据是私有的,外部只能通过暴露的方法操作。但是一个模块无法依赖另一个模块。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // module.js
    (function(window) {
    let data = 'heheda'
    // 向外暴露的函数
    function f1() {}
    // 向外暴露的函数
    function f2() {
    f3()
    }
    // 私有的函数
    function f3() {}
    window.myModule = {
    f1: f1,
    f2: f2
    }
    })(window)
  • IIFE模式增强
    引入依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // modules.js
    (function(window, $) {
    let data = 'hehda'
    function f1 () {
    $('body').css('background', 'red')
    }
    function f2 () {
    f3()
    }
    function f3 () {}
    window.myModule = {f1, f2}
    })(window, jQuery)

把库当做参数传入,这样保证了模块的独立性,还使得模块之间的依赖关系变得明显

模块化的好处

避免命名冲突(减少命名空间污染)
独立性,独立完成一个功能,不受外部环境的影响
更高的复用性
高可维护性
可以依赖其它模块和被其它模块依赖

模块化规范

CommonJs

Node模块化的方案用的是commonJs规范,每个文件就是一个模块,有自己的作用域。在服务器端,模块的加载时运行时同步加载的,在浏览器端,模块需要提前变异打包处理。

特点

  1. 所有代码运行在模块作用域,不会污染全局作用域
  2. 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想模块再次运行,,必须清楚缓存
  3. 模块的加载顺序,按照其在代码中出现的顺序
    基本语法
    暴露模块: module.exports = value 或者 exports.xxx = value
    引入模块: require(xxx), 如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径

commonJs 每个模块内部, module 变量代表当前模块,这个变量时一个对象,它的 exports 属性表示对外的接口。加载某个模块,就是加载改模块的 module.exports 属性

1
2
3
4
5
6
var x = 5
var addX = function (value) {
return value + x
}
module.exports.x = x
module.exports.addX = addX
1
2
3
var example = require('./example.js')
example.x
example.addX

CommonJS 模块的加载机制是输入的是被输出的值的拷贝。一旦输出一个值,模块内部的变化就影响不到这个值(与ES6不同)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// lib.js
var counter = 3
function incCounter() {
counter++
}
module.exports = {
counter: counter,
incCounter: incCounter
}

// main.js
var counter = require('./lib').counter
var incCounter = require('./lib).inCounter
console.log(counter) // 3
incCounter()
console.log(counter)

上面demo说明,counter输出以后,lib.js模块内部的变化就影响不到counter了。这是因为counter是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

服务端 demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|-modules
|-module1.js
|-module2.js
|-module3.js
|-app.js
|-package.json
// npm install uniq --save // 用户数组去重

// module1.js
module.exports = {
msg: 'module1',
foo () {
console.log(this.msg)
}
}
// module2.js
module.exports = function() {
console.log('module2')
}
// module3.js
exports.foo = function () {
console.log('module3')
}
exports.arr = [1,2,3,4]

// app.js
let uniq = require('uniq')
let module1 = require('./module1')
let module2 = require('./module2')
let module3 = require('./module3')

module1.foo()
module2()
module3.foo()
console.log(uniq(module3.arr))
// node app.js 运行文件
客服端 demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-js
|-dist //打包生成文件的目录
|-src //源码所在的目录
|-module1.js
|-module2.js
|-module3.js
|-app.js //应用主源文件
|-index.html //运行于浏览器上
|-package.json

// npm install browserify --save-dev
// browserify js/src/app.js -o js/dist/bundle.js // 打包处理js

// 在index.html文件中引入<script type="text/javascript" src="js/dist/bundle.js"></script>

AMD

CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD 规范则是非同步加载模块,允许指定回调函数。由于Node.js 主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。此外AMD规范比CommonJS 规范在浏览器端实现要来着早。

基本语法

1
2
3
4
5
6
7
8
9
10
11
12
// 定义没有依赖的模块
define(function() {
return 模块
})
// 定义有依赖的模块
define(['module1', 'module2'], function(m1, m2) {
return 模块
})
// 引入模块
require(['module1','module2'], function(m1, m2) {
使用 m1/m2
})

RequireJS是一个工具库,主要用于客户端的模块管理。它的模块管理遵守AMD规范,RequireJS的基本思想是,通过define方法,将代码定义为模块。

  1. 下载 require.js(https://requirejs.org)
  2. 代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    // 目录结构
    |-js
    |-libs
    |-require.js
    |-modules
    |-alerter.js
    |-dataService.js
    |-main.js
    |-index.html

    // dataService.js文件
    // 定义没有依赖的模块
    define(function() {
    let msg = 'www.baidu.com'
    function getMsg() {
    return msg.toUpperCase()
    }
    return { getMsg } // 暴露模块
    })

    //alerter.js文件
    // 定义有依赖的模块
    define(['dataService'], function(dataService) {
    let name = 'Tom'
    function showMsg() {
    alert(dataService.getMsg() + ', ' + name)
    }
    // 暴露模块
    return { showMsg }
    })

    // main.js文件
    (function() {
    require.config({
    baseUrl: 'js/', //基本路径 出发点在根目录下
    paths: {
    //映射: 模块标识名: 路径
    alerter: './modules/alerter', //此处不能写成alerter.js,会报错
    dataService: './modules/dataService'
    }
    })
    require(['alerter'], function(alerter) {
    alerter.showMsg()
    })
    })()

    // index.html文件
    <!DOCTYPE html>
    <html>
    <head>
    <title>Modular Demo</title>
    </head>
    <body>
    <!-- 引入require.js并指定js主文件的入口 -->
    <script data-main="js/main" src="js/libs/require.js"></script>
    </body>
    </html>

    // 页面中引入 require.js 模块
    在index.html引入 <script data-main="js/main" src="js/libs/require.js"></script>

在项目中引入第三方库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// alerter.js文件
define(['dataService', 'jquery'], function(dataService, $) {
let name = 'Tom'
function showMsg() {
alert(dataService.getMsg() + ', ' + name)
}
$('body').css('background', 'green')
// 暴露模块
return { showMsg }
})

// main.js文件
(function() {
require.config({
baseUrl: 'js/', //基本路径 出发点在根目录下
paths: {
//自定义模块
alerter: './modules/alerter', //此处不能写成alerter.js,会报错
dataService: './modules/dataService',
// 第三方库模块
jquery: './libs/jquery-1.10.1' //注意:写成jQuery会报错
}
})
require(['alerter'], function(alerter) {
alerter.showMsg()
})
})()

CMD

CMD规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。CMD规范整合了CommonJS和AMD规范的特点。在 Sea.js 中,所有 JavaScript 模块都遵循 CMD模块定义规范。

基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//定义没有依赖的模块
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()
})

sea.js demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 目录结构
|-js
|-libs
|-sea.js
|-modules
|-module1.js
|-module2.js
|-module3.js
|-module4.js
|-main.js
|-index.html

// module1.js文件
define(function (require, exports, module) {
//内部变量数据
var data = 'atguigu.com'
//内部函数
function show() {
console.log('module1 show() ' + data)
}
//向外暴露
exports.show = show
})

// module2.js文件
define(function (require, exports, module) {
module.exports = {
msg: 'I Will Back'
}
})

// module3.js文件
define(function(require, exports, module) {
const API_KEY = 'abc123'
exports.API_KEY = API_KEY
})

// module4.js文件
define(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文件
define(function (require) {
var m1 = require('./module1')
var m4 = require('./module4')
m1.show()
m4.show()
})

ES6 模块化

export 用于导出模块,import 用于导入模块

1
2
3
4
5
6
7
8
9
10
// f1.js 定义模块
var basicNum = 0
var add = function(a, b) {
return a + b
}
// f2.js 引用模块
import { basicNum, add } from './math'
function test (ele) {
ele.textContent = add(99 + basicNum)
}

如上例所示,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。

1
2
3
4
5
6
7
8
9
10
// export-default.js
export default function () {
console.log('foo');
}

// import-default.js
import customName from './export-default';
customName(); // 'foo'

// 模块默认输出, 其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。

ES6 模块与 CommonJS 模块的差异

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // lib.js
    export let counter = 3;
    export function incCounter() {
    counter++;
    }
    // main.js
    import { counter, incCounter } from './lib';
    console.log(counter); // 3
    incCounter();
    console.log(counter); // 4
  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
    第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成,这里解释一下静态定义,静态定义就是在js编译时就会生成,浏览器在加载ES6模块时,也是用 script 标签加载的,但是要加入 type=”module”属性,浏览器对于带 type=”module” 的 <script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

总结

CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此适用于 AMD CMD 解决方案。
ADM 和 CMD 的区别在于对于依赖的模块,AMD 是提前执行,推崇依赖就近,CMD 是延迟执行,推崇依赖前置。不过 RequireJS 从 2.0 开始,也改成可以延迟执行(根据写法不同,处理方式不同)。
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。