JS 模块化深入分析
全局模块
global.js
"use strict";
var globalObj = {
a: 1,
output: function () {
console.log('console from global');
return 'console from global';
}
};
其实不能叫模块,直接定义在全局的变量,各个文件间不通过语句引入,在HTML中通过<script>引入,全局直接使用。
问题: 带来了命名污染的问题
解决: 通过模块化方案。
AMD
定义: 异步模块加载机制,模块定义简单,完整描述了模块的定义、依赖关系、引用关系以及异步加载机制。
使用方法
requirejs并不是浏览器环境原生的模块系统,在使用前需要加载requirejs库
-
define
使用define表明当前的js文件也是一个模块,需要有导出的结果
在使用ts进行编程的时候,需要引入@types/requirejs模块进行类型的预定义
// 工厂函数,返回暴露的方法 define(dep, fun):暴露的方法 // dep: array 描述模块依赖关系,名字决定模块调用名称 // func: ...array 工厂函数,参数为第一个参数的返回结果,返回暴露接口 -
require
用于导入模块,在回调函数里使用
// 此处...为ES6语法,用于理解参数状态 require([deps], func(...deps) {}) // dep 需要的依赖 // func: ...deps callback 执行函数 -
require.config
用于配置参数路径,格式如下:
require.config({ // 必须为path对象 path: { name: "path" } })
浏览器端demo
以下创建了一个demo说明AMD的使用方法
-
HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>module test</title> </head> <body> <div id="test"></div> <script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js"></script> <script src="./global.js"></script> <script src="./main.js"></script> </body> </html> -
模块1:
amd.jsdefine(function() { console.log('加载模块1'); return { type: 'amd', output() { console.log('console from amd'); return 'console from amd'; } } }) -
模块2:
amd2.jsdefine(['amd1'], function(amd1) { console.log('加载模块2'); const name = 'amd2'; return { name: name, output() {return 'console from amd2, ' + amd1.type + '; ' + name;} } }) -
总入口模块
main.jslet testDIV = document.getElementById('test'); if (testDIV) { let str = globalObj.output(); console.log('全局模块加载完毕'); // 注意此处的顺序 require(['amd2', 'amd1'], function (amd1, amd2) { console.log('执行模块加载回调'); console.log('dep: ' + amd1.type + ', ' + amd2.name); str += amd1.output(); testDIV.innerHTML = str; }) console.log('执行全局内容'); testDIV.innerHTML = str; } -
输出结果
console from global 全局模块加载完毕 // 加载模块部分异步执行 执行全局内容 // 模块全部加载,加载过程中会执行模块内部的语句 加载模块1 加载模块2 执行模块加载回调 dep: amd, amd2 console from amd
node端demo
需要引入第三方包:npm install requirejs --save
const requirejs = require('requirejs');
requirejs(['./amd1'], function(amd1) {
amd1.output();
})
// console.log(requirejs);
console.log('node 外部运行');
查看结果
node 外部运行
加载模块1
console from amd
总结
从这个浏览器端和node端可以看出,两端运行的还是JS,加载第三方库给环境增加全局的方法:在浏览器端通过<script>全局引入require方法;在node端使用原生的require方法引入包,重命名为requirejs,即可在node端使用对应的模块方法。
所以在不同端使用不同的第三方库,仅需注意提供使用库的模块化方法对应的方法可以了。
问题:在通过<script>标签引入时,具有define和require两个方法,使用node引入时,如何使用define?
源码上看:requirejs包含供浏览器端使用的
requirejs.js文件,全局注册define等方法;还提供r.js文件,node package默认加载的文件,用于node环境下AMD模块的载入。
在浏览器中通过
<script>标签引入的时候,define和require全部注册在全局变量;在node环境下使用的时候,应该使用下面的方法:
// npm install requirejs --save const requirejs = require('requirejs'); // 通过打印可以看到requirejs目前的方法,其是带有require和define的方法的 requirejs.define // define requirejs.require // require requirejs.require.config // require.config // 切忌使用下面的引入方法 require('requirejs') // 会覆盖node的commonjs的载入方法通过node commonjs的引入,将对应的AMD的引入方法注册到对象返回。
CMD
定义:通用模块定义,推崇依赖就近,一个文件就是一个模块。
使用方法
define(id?, d?, factory)
Id:指模块的名称,一般不设置,和文件同名;
d: deps(上面两个参数不设置,由构建工具自动生成)
factory:工程函数: function(require, exports, module) {}
-
require
是一个方法,接收 模块标识符id作为唯一参数,用来获取其他模块提供的接口。
define(function(require, exports) { a = require('./a'); a.doSomething(); }) -
require.async
require.async(id, callback?)异步加载回调,相当于AMD的require
define(function(require, exports, module) { // 异步加载一个模块,在加载完成时,执行回调 require.async('./b', function(b) { b.doSomething(); }); // 异步加载多个模块,在加载完成时,执行回调 require.async(['./c', './d'], function(c, d) { c.doSomething(); d.doSomething(); }); }); -
Require.resolve
require/resolve解析路径,但不加载模块
-
Exports
Object对象,向外提供模块接口。
// 向外提供接口由两种写法 // exports对象增加属性 // 返回值 define(function(require, exports) { // 对外提供 foo 属性 exports.foo = 'bar'; // 对外提供 doSomething 方法 exports.doSomething = function() {}; return { foo: '', doSomething: function() {} } }); -
module
Object- module.id: 当前模块的id
- Module.url:当前模块的绝对路径
- module.dependencies: 当前模块的依赖
- Module.exports: 上面exports的引用
浏览器端demo
-
HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>module test</title> </head> <body> <div id="test"></div> <script src="https://cdn.bootcdn.net/ajax/libs/seajs/3.0.3/sea.js"></script> <script src="./global.js"></script> <!-- <script src="./main cmd.js"></script>s --> <script> seajs.use(['./main-cmd.js'], function(main) {}); </script> </body> </html> -
模块1:
cmd1.jsdefine(function(require, exports, module) { console.log('加载cmd1'); exports.name = 'cmd1'; }) -
模块2:
cmd2.jsdefine(function(require, exports, module) { console.log('加载cmd2'); cmd1 = require('./cmd1'); console.log(`cmd2 加载${cmd1.name}`); exports.name = 'cmd2'; }) -
总入口模块:
main-cmd.jslet testDIV = document.getElementById('test'); if (testDIV) { let str = globalObj.output(); console.log('全局模块加载完毕'); define(function(require, exports, module) { console.log('顶层模块执行'); cmd1 = require('./cmd1'); console.log(`cmd3加载${cmd1.name}`); require.async('./cmd2', function(cmd2) { console.log(`cmd3异步加载${cmd2.name}`); }) console.log('模块内部同步处理结束'); }) console.log('执行全局内容'); testDIV.innerHTML = str; } -
输出
console from global 全局模块加载完毕 执行全局内容 顶层模块执行 加载cmd1 cmd3加载cmd1 // 内部异步加载 模块内部同步处理结束 加载cmd2 cmd2 加载cmd1 cmd3异步加载cmd2
node端demo
CMD中的C取通用(common)之意,且其加载模块的时候,使用reuqire加载,这与node自建的require方法很相似,CMD在node中的用法见下面的demo
// 安装对应的模块 npm install seajs --save
// 全局引入seajs的require,其实是为全局引入define
require('seajs');
const cmd2 = require('./cmd2');
define('node',function(require, exports, module) {
require.aysnc(['./cmd1'], function(cmd1) {
})
})
console.log('node模块运行结束')
输出如下
// 开始加载模块2
加载cmd2
加载cmd1
cmd2 加载cmd1
// 可以看出,模块加载可以理解为同步的
node模块运行结束
// define定义的部分还是异步的
加载模块1
总结
CMD模块方法关键字define,内部工厂函数require具有自身的功能,所以引入seajs的define方法就就可以了。
AMD与CMD的区别
这个问题网上最常见的解答:CMD推崇依赖就近的原则。
下面从模块加载原理解释一下这句话:
首先,模块化分为模块加载(下载模块文件)和模块执行两个阶段;AMD和CMD都是异步模块加载,在语法和具体执行上有差异,表现为:
- AMD依赖前置,在读到这一行的时候就知道需要哪些模块,然后进行加载,加载结束执行模块以后调用回调函数(异步模式)
- CMD依赖就近,读取define方法的时候,首先会调用toString方法,然后查找里面的require语句,获取当前模块的所有的依赖,进行加载,在正式运行前,模块文件已经加载完毕,执行时,看似好像require了,但是其实文件已经存在,此时执行模块文件,此时执行看似好像是同步加载,但是不是,还是异步加载模式;
- CMD内出现async的时候,继续嵌套异步模式
从上面的分析解释中可以看出,加载全为异步;AMD用户体验更好(进入回调时,所有模块都已执行,即:所有模块都已经准备好了),CMD按需执行,性能上更好。
CommonJS
定义:每一个文件都是一个模块,模块是同步加载的,同步加载的特性不适用于浏览器端。
基本用法
两个关键字module.exports和require
这两个关键字对应的方法由node环境提供,可以理解为文件在运行前,node给文件添加了以下内容:
var module = {
exports: {},
}
var exports = module.exports;
两个关键字的用法如下
-
module.exportsObject导出对象的内容
-
require导入其他的包,注意此处的包的寻址方式,具有单例特性
- require的模块是第一次加载,首先会加载该模块,导出内容
- require模块被加载过,模块代码不会执行,直接导出上次执行的结果
demo
模块1
var obj = {
name: 'commonjs1',
output: function() {
console.log('console from commonjs1');
}
}
console.log('调用commonjs1');
module.exports = obj;
模块2
const commonjs1 = require('./commonjs1');
console.log('调用js2');
console.log(`commonjs2 调用 ${commonjs1.name}`);
var obj = {
name: 'commonjs2'
}
module.exports = obj;
顶层模块
console.log('顶层模块运行');
const commonjs2 = require('./commonjs2');
console.log(`main 调用 ${commonjs2.name}`);
const commonjs1 = require('./commonjs1');
console.log(`main 调用 ${commonjs1.name}`);
console.log('调用结束');
调用结果
顶层模块运行
调用commonjs1
调用js2
commonjs2 调用 commonjs1
main 调用 commonjs2
// 注意此处commonjs1并没有重复执行
main 调用 commonjs1
调用结束
commonjs的调用要注意两点:
- 文件同步加载,demo中看不出来,考虑可能node认为文件都在本地,同步加载没有问题(此处也是commonjs和ES6module的重要区别)
- 模块文件不会重复执行,首次执行以后保存模型的实例,在其他模块中调用的时候,返回模块的实例,有点像单例模式;
commonjs的特性
在上述的demo中,向模块1添加内部的变量。
模块1
var counter = 0;
var obj = {
name: 'commonjs1',
cnt : counter,
output: function() {
console.log('console from commonjs1');
},
getCounter() {
return counter++;
},
addCnt() {
this.cnt++;
}
}
console.log('调用commonjs1');
module.exports = obj;
模块2
const commonjs1 = require('./commonjs1');
console.log('调用js2');
console.log(`commonjs2 调用 ${commonjs1.name}`);
var obj = {
name: 'commonjs2'
}
console.log(`commonjs2调用commonjs的counter: ${commonjs1.getCounter()}`);
// 单独获取属性或者方法 的时候是
var cnt = require('./commonjs1').cnt;
var add = require('./commonjs1').addCnt;
console.log(`commonjs2调用commonjs的cut: ${cnt}`);
add();
console.log(`commonjs2调用commonjs的cnt: ${cnt}`);
console.log(`commonjs2调用commonjs的cnt: ${commonjs1.cnt}`);
commonjs1.addCnt();
console.log(`commonjs2调用commonjs的cnt: ${commonjs1.cnt}`);
module.exports = obj;
顶层模块
console.log('顶层模块运行');
const commonjs2 = require('./commonjs2');
console.log(`main 调用 ${commonjs2.name}`);
const commonjs1 = require('./commonjs1');
console.log(`main 调用 ${commonjs1.name}`);
console.log(`main调用commonjs的counter: ${commonjs1.getCounter()}`);
console.log(`main调用commonjs的cnt: ${commonjs1.cnt}`);
commonjs1.addCnt();
console.log(`main调用commonjs的cnt: ${commonjs1.cnt}`);
const commonjs1_2 = require('./commonjs1');
console.log(`main调用commonjs的counter: ${commonjs1_2.getCounter()}`);
console.log(`main调用commonjs的cnt: ${commonjs1_2.cnt}`);
commonjs1_2.addCnt();
console.log(`main调用commonjs的cnt: ${commonjs1_2.cnt}`);
console.log('调用结束');
输出结果
顶层模块运行
调用commonjs1
调用js2
commonjs2 调用 commonjs1
commonjs2调用commonjs的counter: 0
commonjs2调用commonjs的cut: 0
commonjs2调用commonjs的cnt: 0
commonjs2调用commonjs的cnt: 0
commonjs2调用commonjs的cnt: 1
main 调用 commonjs2
main 调用 commonjs1
main调用commonjs的counter: 1
main调用commonjs的cnt: 1
main调用commonjs的cnt: 2
main调用commonjs的counter: 2
main调用commonjs的cnt: 2
main调用commonjs的cnt: 3
调用结束
上面这个例子比较复杂,但需要注意两点:
- commonjs调用是和js的赋值一样,对象传递引用,基本变量传递值拷贝;
- commonjs在加载后会在内存里保存当前的加载内容,并根据require的类型进行返回。
ES6module
定义:一个js代表一个模块,每一个模块只加载一次。
包含import和export两个关键字。
-
export
可以在程序中多次使用,表示导出多个变量;
仅能使用一次:
export default -
Import
必须使用结构语法
-
import {func1, func2} from 'mudule':针对使用export {}导出的对象 -
import [name] from 'module':针对使用export default {}导出的对象
-
demo
node为了兼容es6模块,需要使用.mjs作为文件后缀
模块1: esm1.mjs
var counter = 0;
var obj = {
name: 'esm1'
}
function getName() {
console.log('from esm1');
}
function add() {
counter++;
}
console.log('运行模块1');
export {obj};
export {counter, add};
export default getName;
模块2: esm2.mjs
var obj = {
name: 'esm2'
}
function getName() {
console.log('from esm2');
}
console.log('运行模块2');
// 调用模块1
import {obj as obj1, counter, add} from './esm1.mjs'
console.log(`esm2调用${obj1.name}`);
console.log(counter);
add();
console.log(counter);
export {obj};
export default getName;
顶层模块: main.mjs
import {obj as obj1} from './esm2.mjs'
import esm2Default from './esm1.mjs'
import {obj as obj2} from './esm1.mjs'
import esm1Default from './esm1.mjs'
console.log(`main调用${obj1.name}`);
esm1Default();
console.log(`main调用${obj2.name}`);
esm2Default();
import {counter, add} from './esm1.mjs'
// counter++; TypeError: Assignment to constant variable. 只读
console.log(counter);
add();
console.log(counter);
// export ;
执行结果
// 模块运行一次
运行模块1
运行模块2
esm2调用esm1
// 注意此处的导出值
0
1
main调用esm2
from esm1
main调用esm1
from esm2
// 注意此处的导出值
1
2
上面结果有两个需要注意的点:
- 模块自身代码只运行一次
- esm是一种映射关系,第一次加载模块到内存,后续加载模块的时候,将内存中全有的部分进行映射,有点类似于『单例模式』