前端模块化

模块化的理解

引入

在早期的 JavaScript 开发中存在全局变量污染依赖管理等问题,在多人开发前端应用时这是一个棘手的问题。

全局变量污染:比如:A 开发的 a.js 文件中创建了一个名字为 name 的全局变量,B开发的 b.js 文件中也有一个 name 全局变量,这样就会导致全局变量命名冲突的问题。

依赖管理:在一个 html 文件中可能会用到多个 <script> 标签引入多个 js 文件,多个文件之间可能会有相互依赖的关系,那么多个 <script> 标签的排列顺序也是一个难以处理的问题。

为了解决这些问题,就需要使用到模块化编程的思想。

什么是模块
  • 将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起
  • 块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信
模块化的好处
  1. 避免命名冲突
  2. 更好地分离,按需加载
  3. 高复用性
  4. 高可维护性

模块化规范

模块化的规范:CommonJS、AMD、CMD、ES6 Modules

CommonJS
概述

Node 应用由模块组成,采用 CommonJS 模块规范。在 CommonJS 模块规范中,每个文件就是一个模块,有自己的作用域,文件中的属性和方法都是私有的,在模块内部可以通过 exportsmodule.exports 把这些变量和方法暴露出来。外部在使用时,需要通过 require 引入这个模块,就可以使用这个模块中的属性和方法了。

特点
  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。
基本语法
  • 暴露模块:module.exports = value(推荐)exports.xxx = value
  • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
实例演示
// lib.js 使用 module.exports 导出模块
var count = 3;
function countIncrement() {
  count++;
}
module.exports = {
  count: count,
  countIncrement: countIncrement
};
// main.js 使用 require 导入模块
const mod = require('./lib');
console.log(mod.count);		// 3
mod.countIncrement();
console.log(mod.count);		// 3

从上面的例子可以看到:CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

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

// lib.js
var count = 3;
function countIncrement() {
  count++;
}
module.exports = {
  get count() {
    return count;
  },
  countIncrement: countIncrement
};

上面代码中,输出的 counter 属性实际上是一个取值器函数。现在再执行 main.js ,就可以正确读取内部变量 counter 的变动了。

// main.js
const mod = require('./lib');
console.log(mod.count);		// 3
mod.countIncrement();
console.log(mod.count);		// 4
ES6 Module
概述

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

基本语法

模块功能主要由两个命令构成:export importexport 命令用于规定模块的对外接口,export 可以输出变量、函数或类,import 命令用于输入其他模块提供的功能。

实例演示
// profile.js
// 导出方式一
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;

// 导出方式二(推荐)
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export { firstName, lastName, year }	// 注意:有大括号

// 导出函数
export function multiply(x, y) {
    return x * y;
};

// 导出时还可以重命名
function f1(x, y) {
    return x + y;
}
export { f1 as add }	// 注意:有大括号
// main.js
// 导入,大括号中的变量名必须和导出的接口的名字相同
import { firstName, lastName, year, multiply, add } from './profile.js'	// 注意:有大括号

// 导入时也可以重命名
import { lastName as myLastName} from './profile.js'

// 执行所加载的模块,但是不输入任何值
import 'lodash'

使用 export default 命令,为模块提供默认输出。

其他模块加载该模块时,import 命令可以为该函数指定任意名字。需要注意的是,这时import命令后面,不使用大括号。

// export-default.js
export default function() {
    console.log('hello world!');
}
// import-default.js 在导入时可以使用任意名字
import sayHello from './import-default.js'
sayHello();

在加载模块的脚本中,不允许修改接口。下面代码中,脚本加载了变量 a,对其重新赋值就会报错。

import { a } from './xxx.js'
a = {}; // Syntax Error : 'a' is read-only;

如果 a 是一个对象,改写 a 的属性是允许的。但是,建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。

AMD
概述

AMD 规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。浏览器端一般采用 AMD 规范,RequireJS 是一个实现了 AMD 规范的工具库。

基本语法

require.config 指定引用路径、define() 定义模块、require() 引用模块

定义暴露模块:

// 定义没有依赖的模块
define(function() {
	return 模块
})

// 定义有依赖的模块
define(['module1', 'module2'], function(m1, m2) {
	return 模块
})

引入使用模块:

require(['module1', 'module2'], function(m1, m2) {
	使用 m1/m2
})
CMD
概述

CMD 是另一种 JS 模块化方案,它与 AMD 很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD 推崇依赖就近、延迟执行。此规范其实是在 Sea.js 推广过程中产生的。

AMD 在定义模块的时候要先声明其依赖的模块,CMD 只要依赖的模块在附近就可以了。AMD 推崇依赖前置,因此,JS 可以及其轻巧地知道某个模块依赖的模块是哪一个,因此可以立即加载那个模块;而 CMD 是就近依赖,它要等到所有的模块变为字符串,解析一遍之后才知道他们之间的依赖关系。

AMD 在依赖模块加载完成后就直接执行依赖模块,依赖模块的执行顺序和我们书写的顺序不一定一致。CMD 在依赖模块加载完成后并不执行,只是下载而已,等到所有的依赖模块都加载好后,进入回调函数逻辑,遇到 require 语句 的时候才执行对应的模块,这样模块的执行顺序就和我们书写的顺序保持一致了。

基本语法

定义暴露模块:

//定义没有依赖的模块
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()
})
ES6 Module 与 CommonJS 的差异
  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值
  • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
  1. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
  • 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
  • 编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。模块内部引用的变化,会反应在外部。

CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

  1. CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段。

参考资料:

Module 的语法

Module 的加载实现

前端模块化详解(完整版)

「万字进阶」深入浅出 Commonjs 和 Es Module

前端模块化:CommonJS,AMD,CMD,ES6

前端模块化——彻底搞懂AMD、CMD、ESM和CommonJS