在工作中经常会使用到 ES6 Module 模块化,但是却没有深究其中的原理与其他 JavaScript 模块化方案的区别,正好国庆有时间整理了一下前端模块化的相关知识,记录在此方便以后回忆。
背景介绍:
JavaScript 作为嵌入式的脚本语言,自身设计上没有类(class),模块(module)的概念,难以处理大规模的程序设计。相比 JAVA 中有个重要概念包(package),逻辑上相似的代码组织在同一个包中,包内环境相对独立,不用担心命名冲突,外部直接通过import导入即可。
遗憾的是JavaScript在设计时定位原因,没有提供类似的功能,开发者需要模拟出类似的功能,来隔离、组织复杂的JavaScript代码,我们称为模块化。
模块化方法
一个模块就是实现特定功能的文件,目前JavaScript模拟模块化分为三个阶段:函数封装,对象封装和立即执行函数。
函数封装
JavaScript的作用域就是基于函数的,函数的一个功能就是对执行特定逻辑的语句进行封装。
function fn1(){
.....
}
function fn2(){
.....
}
一个函数就是一个小的模块,可以直接在函数所在文件调用。
缺点:污染了全局变量,无法保证不与其他模块发生变量名冲突,并且函数模块间相互独立,没有关联。
命名空间
为了解决函数封装的问题,可以将所有的模块成员封装在一个对象中。
var myModule = {
var1: 1,
var2: 2,
fn1: function(){
},
fn2: function(){
}
}
//调用模块中函数
myModule.fn1()
//存在风险
myModule.var1 = 3
模块名唯一的前提下,避免了变量污染,且模块成员之间具有联系。
缺点:尽管命名空间模式一定程度上解决了全局命名空间上的变量污染问题,但是它没办法解决代码和数据隔离的问题。外部可以随意修改内部成员,存在一定风险。
立即执行函数
立即执行函数 简称 IIFE(Imdiately Invoked Function Expression),即用function声明一个匿名函数之后,会立即调用该函数。通过立即执行函数,创建一个独立的块级作用域来隐藏内部变量,在模块外部无法修改没有暴露出来的变量或函数。在没有块级作用域的 ES3 时代,这是模拟块级作用域相当普遍的做法。
var myModule = (function(){
var name = 'Bob';
function getName(){
console.log(name)
}
return {
//利用函数闭包的特性来实现私有数据和共享方法
getName
};
})();
那假如我们这个模块需要依赖其他模块呢?这时候就用到了引入依赖,即函数传参:
// otherModule.js模块文件
var otherModule = (function(){
return {
a: 1,
b: 2
}
})()
// myModule.js模块文件 - 依赖 otherModule 模块
var myModule = (function(other) {
var name = 'chill'
function getName() {
console.log(name)
console.log(other.a, other.b)
}
return { getName }
})(otherModule)
随着前端发展对模块需求越来越大,社区中逐渐出现了一些优秀且被大多数人认同的模块化解决方案,慢慢演变成了通用的社区模块化规范,例如 AMD, CMD,CommonJS 等。再到后面 ES6 的出现,也意味着官方(语言层面)的模块化规范 ESM 的落地。
CommonJS
09年 美国程序员 Ryan Dahl 以 CommonJs 规范为基础创造了 node.js 项目,将 JS 语言用于服务器端编程,为前端奠基,从此之后 nodejs 就成为了 CommonJs 的代名词。
在CommonJS中,一个文件就是一个模块,模块中的变量,函数,类都是私有的,外部不可以访问。定义一个模块导出通过 exports 或者 module.exports 挂载即可,其中规定module代表当前模块,exports是对外的接口。
通常,我们使用require引用模块,exports对外输出模块。
// num.js
var a = 1
var b = 2
var add = function (){
return a + b
}
// 导出
module.exports.a = a
module.exports.b = b
module.exports.add = add
//引用 num.js
var num = require('./num.js')
console.log(num.a) // 1
console.log(num.b) // 2
console.log(num.add(a,b)) // 3
Module模块里都有些什么?
//Module原生模块上的一些属性
Module {
id: '.', // 如果是 mainModule id 固定为 '.',如果不是则为模块绝对路径
exports: {}, // 模块最终 exports
filename: '/absolute/path/to/entry.js', // 当前模块的绝对路径
loaded: false, // 模块是否已加载完毕
children: [], // 被该模块引用的模块
parent: '', // 第一个引用该模块的模块
paths: [ // 模块的搜索路径
'/absolute/path/to/node_modules',
'/absolute/path/node_modules',
'/absolute/node_modules',
'/node_modules'
]
}
为什么上面的module, exports, __filename, __dirname 不需要引入就能使用?
原因:
Node 在解析 JS 模块时,首先以文本格式读取模块内容,然后模块内容会被包裹在一个立即执行函数中,之后会改变这个立即执行函数中内部this的指向,指向的便是module.exports这个空对象。
(function (exports, require, module, __filename, __dirname) {
let name = "lm";
exports.name = name;
});
MyScript.call(module.exports, args);
我们会给空对象传递exports, module, __filename, __dirname等参数,所以不需要 require,便可以直接访问到这些方法,变量。
参数中的 module 是当前模块的 module 实例。
exports 是 module.exports 的别名,我们使用exports或者module.exports导出模块时,其实也就是给module.exports这个对象添加属性,之后我们使用require引入模块得到的便是module.exports这个对象。
__filename,__dirname 则分别是当前模块在系统中的当前文件夹路径和绝对路径。
-注意:
-
CommonJS中 require 得到的模块中变量,方法的拷贝,并不是直接引用。因此一旦输出一个值,模块内部的变化就影响不到这个值。(与 ES6 module的区别) -
CommonJS规范是在代码运行时同步阻塞性加载模块,在执行代码过程中遇到require(...)时会停下来等待,直到新的模块加载完成之后再继续执行接下去的代码。
AMD和require.js
CommonJS用同步的方式加载模块。在服务端,模块文件都存在本地磁盘,读取非常快。但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。
AMD (Asynchronous Module Definition) 即 异步模块定义。它是一个在浏览器端模块化开发的规范
AMD规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
/** 网页中引入require.js及main.js **/
<script src="js/require.js" data-main="js/main"></script>
/** main.js 入口文件/主模块 **/
// 首先用config()指定各模块路径和引用名
require.config({
baseUrl: "js/lib",
paths: {
"jquery": "jquery.min", //实际路径为js/lib/jquery.min.js
"underscore": "underscore.min",
}
});
// 执行基本操作
require(["jquery","underscore"],function($,_){
// some code here
});
RequireJS主要解决两个问题
1、多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
2、js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长
// 定义math.js模块
define(function () {
var basicNum = 0;
var add = function (x, y) {
return x + y;
};
return {
add: add,
basicNum :basicNum
};
});
// 定义一个依赖underscore.js的模块
define(['underscore'],function(_){
var classify = function(list){
_.countBy(list,function(num){
return num > 30 ? 'old' : 'young';
})
};
return {
classify :classify
};
})
// 引用模块,将模块放在[]内
require(['jquery', 'math'],function($, math){
var sum = math.add(10,20);
$("#sum").html(sum);
});
CMD和sea.js
CMD (Common Module Definition) 即 通用模块定义。
CMD 是另一种 JS 模块化方案,它与 AMD 很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD 推崇依赖就近、延迟执行。此规范其实是在 sea.js 推广过程中产生的。
/** AMD写法 **/
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) {
// 等于在最前面声明并初始化了要用到的所有模块
a.doSomething();
if (false) {
// 即便没用到某个模块 b,但 b 还是提前执行了
b.doSomething()
}
});
/** CMD写法 **/
define(function(require, exports, module) {
var a = require('./a'); //在需要时申明
a.doSomething();
if (false) {
var b = require('./b');
b.doSomething();
}
});
/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
var $ = require('jquery.js');
var add = function(a,b){
return a+b;
}
exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
var sum = math.add(1+2);
});
ES6 Module
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
ES6 模块中不存在 require, module.exports, __filename 等变量,CommonJS 中也不能使用 import。两种规范是不兼容的,一般来说平日里写的 ES6 模块代码最终都会经由 Babel, Typescript 等工具处理成 CommonJS 代码。
/** 定义模块 math.js **/
var 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);
}
/** 默认导出export default时,import不用大括号 **/
//定义输出
export default { basicNum, add };
//引入
import math from './math';
function test(ele) {
ele.textContent = math.add(99 + math.basicNum);
}
总结
AMD 和 CMD 的区别
在模块定义时对依赖的处理不同
1、AMD 推崇依赖前置,在定义模块的时候就要声明其依赖的模块
2、CMD 推崇就近依赖,只有在用到某个模块的时候再去require
注意:AMD 和 CMD 最大的区别是对依赖模块的执行时机处理不同,而不是加载的时机或者方式不同。单纯的认为RequireJS是异步加载,sea.js是同步加载是不准确的,其实加载模块都是异步的,只不过AMD依赖前置,js可以方便知道依赖模块是谁,立即加载;而CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块。AMD用户体验好,全部加载D用户体验好,因为没有延迟,依赖模块提前执行了,CMD性能好,因为只有用户需要的时候才执行。
ES6 Module和 CommonJS的区别
1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import后原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
-
运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
-
编译时加载: ES6 模块不是对象,而是通过
export命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。 -
CommonJS 加载的是一个对象(即
module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。