模块化的理解
什么是模块化
- 将一个复杂的程序,按一定的规则(规范)封装成几个块(文件),并进行组合在一起
- 块的内部数据与实现是私有的,只是向外暴露一些接口(方法)与外部其它模块通信
模块化有什么好处?(为什么要使用模块化)
-
避免命名冲突(减少命名空间污染)
-
更好的分离,按需加载
-
更高复用性,高可维护性
-
解决引入多个
<script>
后容易出现的问题 -
请求过多:依赖多个模块,那样就会发送多个请求,导致请求过多
- 依赖模糊:不知道依赖之间的具体依赖关系是什么,容易导致加载先后顺序出错。
- 难以维护:以上两种原因必然导致难以维护
模块化发展史
早期 JavaScript 开发很容易存在全局污染和依赖管理混乱问题,这些问题在多人开发前端应用的情况下变得更加棘手。举个?:
<body>
<script src="./index.js"></script>
<script src="./home.js"></script>
<script src="./list.js"></script>
</body>
没有模块化,那么 script 内部的变量是可以相互污染的。比如上述代码中 ./index.js 文件和 ./list.js 文件为小 A 开发的,./home.js 为小 B 开发的。index.js中定义了变量name是一个string,home.js中定义的name变量是一个function,name变量这时候就被互相污染了
在规范出现前的解决方案
1. 全局function模式 : 将不同的功能封装成不同的全局函数
function m1(){
//...
}
function m2(){
//...
}
问题: 污染全局命名空间, 容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系
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
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 }
})(window)
// index.html文件
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
myModule.foo()
myModule.bar()
console.log(myModule.data) //undefined 不能访问模块内部数据
myModule.data = 'xxxx' //无法修改的模块内部的data
myModule.foo() //没有改变
</script>
4. **IIFE模式增强 : 引入依赖 —–**这也是现代模块实现的基石
// module.js文件
(function(window, $) {
let data = 'www.baidu.com'
//操作数据的函数
function foo() {
console.log(`foo() ${data}`)
$('body').css('background', 'red')
}
function bar() {
console.log(`bar() ${data}`)
otherFun() //内部调用
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.myModule = { foo, bar }
})(window, jQuery)
// index.html文件
<!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
myModule.foo()
</script>
上面的例子需要在myModule.js中使用jQuery,所以必须先引入jQuery库,就把这个库当作参数传入,。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。
模块化规范
在ES6出来之前:
- CommonJs–就是require这种,node里面使用的,是node的模块规范。
- AMD–是Require.js在推广的过程中对模块定义的规范化产出。
- CMD–是淘宝Sea.js在推广的过程中对模块定义的规范化产出
ES6:
- ES6Module–就是import、export这类的,我们对这个应该很熟。
CommonJS
每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。( Node 是 CommonJS在服务器端一个具有代表性的实现。 )
特点
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。(也就是说,输入的是被输出的值的拷贝。 )
- 模块加载的顺序,按照其在代码中出现的顺序。
具体使用
暴露模块:module.exports = value 或 exports.xxx = value
引入模块:require(module)
如果是第三方模块,module为模块名;如果是自定义模块,module为模块文件路径
// 举个?:example.js
const x = 5;
const add = function (a) {
return a + x;
};
// 暴露变量
module.exports.x = x;
module.exports.add = add;
// 引入变量
var example = require('./example.js');
console.log(example.x); // 5
console.log(example.add(1)); // 6
CommonJS暴露与引入的是什么?
暴露: CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
引入(加载): require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。
CommonJS加载原理
CommonJS 的一个模块,就是一个脚本文件。require
命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。
{
id: 'xxx',
exports: { ... },
loaded: true,
...
}
上面代码就是 Node 内部加载模块后生成的一个对象。该对象的id
属性是模块名,exports
属性是模块输出的各个接口,loaded
属性是一个布尔值,表示该模块的脚本是否执行完毕。其他还有很多属性,这里都省略了。
以后需要用到这个模块的时候,就会到exports
属性上面取值。即使再次执行require
命令,也不会再次执行该模块,而是到缓存之中取值。
也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
AMD
概念与特点
CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。
AMD规范则是非同步加载模块,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。
但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。此外AMD规范比CommonJS规范在浏览器端实现要来的早。
优点:AMD模块定义的方法非常清晰,不会污染全局环境,能够清楚地显示依赖关系。
基本用法
AMD是”Asynchronous Module Definition”的缩写,即“异步模块定义”。
AMD规范很简单,只有一个API,即define函数:
define([module-name?], [array-of-dependencies?], [module-factory-or-object])
其中:
- module-name: 模块标识,可以省略。
- array-of-dependencies: 所依赖的模块,可以省略。
- module-factory-or-object: 模块的实现,或者一个JavaScript对象。
AMD也采用require()语句加载模块,但是不同于CommonJS,它要求两个参数:
require([module], callback);
- 第一个参数[module],是一个数组,里面的成员就是要加载的模块;
- 第二个参数callback,则是加载成功之后的回调函数。
//定义没有依赖的模块
define(function(){
return 模块
})
//定义有依赖的模块
define(['module1', 'module2'], function(m1, m2){
return 模块
})
// 引入使用模块
require(['module1', 'module2'], function(m1, m2){
使用m1/m2
})
CMD
CMD(Common Module Definition)是国内大牛玉伯在开发SeaJS的时候提出来的,属于CommonJS的一种规范规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。
CMD规范整合了CommonJS和AMD规范的特点。在 Sea.js 中,所有 JavaScript 模块都遵循 CMD模块定义规范。
基本用法
//定义没有依赖的模块
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()
})
UMD (Universal Module Definition)
UMD 是 JavaScript 模块的通用模块定义模式。这些模块能够在任何地方工作,无论是在客户端、服务器还是其他地方。
// 通常用来加载不同的规范
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['jquery', 'underscore'], factory);
} else if (typeof exports === 'object') {
// Node, CommonJS之类的
module.exports = factory(require('jquery'), require('underscore'));
} else {
// 浏览器全局变量(root 即 window)
root.returnExports = factory(root.jQuery, root._);
}
}(this, function ($, _) {
// 方法
function a(){}; // 私有方法,因为它没被返回 (见下面)
function b(){}; // 公共方法,因为被返回了
function c(){}; // 公共方法,因为被返回了
// 暴露公共方法
return {
b: b,
c: c
}
}));
ES Module(ES6 模块化)
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
特点
1、静态语法: ES6 module 的引入和导出是静态的,import 会自动提升到代码的顶层 ,import , export 不能放在块级作用域或条件语句中。
2、执行特性: ES6 模块提前加载并执行模块文件,ES6 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历,执行顺序是子 -> 父。
3、导出绑定: 不能修改import导入的属性,直接修改会报错
注:如果导出的是对象,是可以成功改写他的属性的,并且其他模块也可以读到改写后的值。不过,这种写法很难查错,建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。
基本用法
import、export
import会自动提升到代码的顶层,并且import、export 只能出现在代码的顶层
export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。
为什么?
引擎处理import
语句是在编译时,这时不会去分析或执行if
语句,所以import
语句放在if
代码块之中毫无意义,因此会报句法错误,而不是执行时错误。
所以,import
和export
命令只能在模块的顶层,不能在代码块之中(比如,在if
代码块之中,或在函数之中)。
这样设计的不足?
这样的设计,固然有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载就不可能实现。所以import
命令无法取代 Node 的require
方法,因为require
是运行时加载模块,import
命令无法取代require
的动态加载功能。
const path = './' + fileName;
const myModual = require(path);
上面的语句就是动态加载,require
到底加载哪一个模块,只有运行时才知道。import
命令做不到这一点。
所以 ===> ES2020提案 引入import()
函数,支持动态加载模块。
导入、导出 示例
// 导出info.js export && export default
const name = "张三";
export const age = "18";
export default name;
// 导入
import name, { age } from "./info";
console.log(name, "name");
console.log(age, "age");
// 执行 module 不导出值 多次调用 module 只运行一次。
import 'module'
// 用星号( * )指定一个对象,所有输出值都加载在这个对象上面
import * as circle from './circle';
// 导出,这样导出的模块 import时可以指定任意的名字
export default {xxx}
export 与 import 的复合写法
注:写成一行以后,foo
和bar
实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用foo
和bar
。
// 先输入后输出同一个模块
export { foo, bar } from 'my_module';
// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };
模块的接口改名和整体输出
// 接口改名
export { foo as myFoo } from 'my_module';
// 整体输出
export * from 'my_module';
默认接口
export { default } from 'foo';
// 具名接口改为默认接口
export { es6 as default } from './someModule';
// 等同于
import { es6 } from './someModule';
export default es6;
// 默认接口改具名接口
export { default as es6 } from './someModule';
import * as someIdentifier from "someModule";
对应的符合写法:
export * as ns from "mod";
// 等同于
import * as ns from "mod";
export {ns};
import() :
import()与import的参数一致,并且可以动态使用,加载模块。返回一个 Promise 对象(可以用在async函数中), 返回的 Promise 的 then 成功回调中,可以获取模块的加载成功信息。
import()应用
- 按需加载
button.addEventListener('click', event => {
import('./dialogBox.js')
.then(dialogBox => {
dialogBox.open();
})
.catch(error => {
/* Error handling *
})
});
- 条件加载
if (condition) {
import('moduleA').then(...);
} else {
import('moduleB').then(...);
}
- 动态的模块路径
import(f())
.then(...);
注:import()
加载模块成功以后,这个模块会作为一个对象,当作then
方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口。
作用
- webpack借助 ES Module 的静态导入导出的优势,实现了 tree shaking。
- ES Module 还可以 import() 懒加载方式实现代码分割。
- 引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
除了静态加载带来的各种好处,ES6 模块还有以下好处。
- 不再需要
UMD
模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。 - 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者
navigator
对象的属性。 - 不再需要对象作为命名空间(比如
Math
对象),未来这些功能可以通过模块提供。
浏览器 & Node.js 加载ES6模块
浏览器
默认
浏览器对于带有 type="module"
的 <script>
,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>
标签的defer
属性。并且如果网页有多个<script type="module">
,它们会按照在页面出现的顺序依次执行。
<script type="module" src="./foo.js"></script>
async
<script>
标签的async
属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。
<script type="module" src="./foo.js" async></script>
一旦使用了async
属性,<script type="module">
就不会按照在页面出现的顺序执行,而是只要该模块加载完成,就执行该模块。
一些注意点
- 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
- 模块脚本自动采用严格模式,不管有没有声明
use strict
。 - 模块之中,可以使用
import
命令加载其他模块(.js
后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export
命令输出对外接口。 - 模块之中,顶层的
this
关键字返回undefined
,而不是指向window
。也就是说,在模块顶层使用this
关键字,是无意义的。 - 同一个模块如果加载多次,将只执行一次。
利用顶层的this === undefined
这个语法点,可以侦测当前代码是否在 ES6 模块之中。示例:
import utils from 'https://example.com/js/utils.js';
const x = 1;
console.log(x === window.x); //false
console.log(this === undefined); // true
Nodejs
Node.js 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块和 CommonJS 采用各自的加载方案。从 v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。
在node中
.mjs
文件总是以 ES6 模块加载,.cjs
文件总是以 CommonJS 模块加载,.js
文件的加载取决于package.json
里面type
字段的设置。type为module
时可以加载ES6模块
注意,ES6 模块与 CommonJS 模块尽量不要混用。require
命令不能加载.mjs
文件,会报错,只有import
命令才可以加载.mjs
文件。反过来,.mjs
文件里面也不能使用require
命令,必须使用import
。
举个?,package.json文件中:
以下代码会被解析为ES6模块,但如果去掉type,默认解释为CommonJS模块
{
"type": "module",
"main": "./src/index.js"
}
循环加载
CommonJS 中的循环加载
CommonJS 模块的重要特性是加载时执行,即脚本代码在require
的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。
// 举个?,a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
如果先执行a.js,先输出一个done
变量,然后加载另一个脚本文件b.js
。注意,此时a.js
代码就停在这里,等待b.js
执行完毕,再往下执行。b.js 执行到第二行,就会去加载a.js
,这时,就发生了“循环加载”。系统会去a.js
模块对应对象的exports
属性取值,可是因为a.js
还没有执行完,从exports
属性只能取回已经执行的部分,也就是exports.done = false
,然后,b.js
接着往下执行,等到全部执行完毕,再把执行权交还给a.js
。于是,a.js
接着往下执行,直到执行完毕。
// 执行脚本
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
// 运行结果
在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
上面的代码证明了两件事。一是,在b.js
之中,a.js
没有执行完毕,只执行了第一行。二是,main.js
执行到第二行时,不会再次执行b.js
,而是输出缓存的b.js
的执行结果,即它的第四行。
ES6 module 中的循环加载
ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import
从一个模块加载变量(即import foo from 'foo'
),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
上面代码中,a.mjs
加载b.mjs
,b.mjs
又加载a.mjs
,构成循环加载。执行a.mjs
,结果如下。
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined
执行过程
首先,执行a.mjs
以后,引擎发现它加载了b.mjs
,因此会优先执行b.mjs
,然后再执行a.mjs
。接着,执行b.mjs
的时候,已知它从a.mjs
输入了foo
接口,这时不会去执行a.mjs
,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)
的时候,才发现这个接口根本没定义,因此报错。
如何解决
让b.mjs
运行的时候,foo
已经有定义了。这可以通过将foo
写成函数来解决。
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};
// b.mjs`
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};
这时再执行a.mjs,就可以得到预期结果,这是因为函数具有提升作用,在执行import {bar} from './b'
时,函数foo
就已经有定义了,所以b.mjs
加载的时候不会报错。这也意味着,如果把函数foo
改写成函数表达式,也会报错。
CommonJS与ESModule
因为现代的构建工具(如Webpack)和运行时环境(如Node.js)通常会支持跨模块化系统的互操作性。当使用 import countObj from './countObj.js';
语法时,构建工具会自动将其转换为适合目标环境的模块化规范(例如babel-loader)。
否则模块化方案不同是不能直接互相引的!!!!!
使用CommonJS导出ESModule
// 导出 name.js
export const age = 18;
export default "这是一个默认导出";
// 引入
let info = require("./name");
console.log(info);
使用ESModule导出CommonJS
// 导出 countObj.js
const x = 5;
const add = function (a) {
return a + x;
};
module.exports.x = x;
module.exports.add = add;
// 引入
import countObj from './countObj.js';
console.log(countObj);
注:通过 Babel 转码,CommonJS 模块的require
命令和 ES6 模块的import
命令,可以写在同一个模块里面,但是最好不要这样做。因为import
在静态解析阶段执行,所以它是一个模块之中最早执行的。混用的话容易导致问题
require('core-js/modules/es6.symbol');
require('core-js/modules/es6.promise');
import React from 'React';
实际应用场景与问题
使用CommonJS规范打包一个SDK(第三方库),常用与node服务端或者小程序场景
// 使用webpack打包 webpack.config.js配置
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'lodashshsh.js',
libraryTarget: 'lib'
},
devtool: 'source-map'
};
// 引入该sdk
const lodashshsh = require('./dist/lodashshsh.js');
console.log(lodashshsh);
// rollup打包 rollup.config.js配置
export default {
input: './src/main.js',
output: {
file: './dist/lodashshsh.js',
format: 'cjs'
}
};
项目分别打包ES Module文件、CommonJS文件、UMD文件(经典例如ant-design)
简单写下配置代码,在使用babel的情况下,仅供参考,实际问题实际解决
// CommonJS
{
"presets": [
["@babel/env", {
"loose": true,
"modules": "cjs"
}], "@babel/preset-react"
],
"plugins": [
["@babel/plugin-transform-runtime", {
"useESModules": false
}],
]
}
// ES Modules
{
"presets": [
["@babel/env", {
"loose": true,
"modules": false
}], "@babel/preset-react"
],
"plugins": [
["@babel/plugin-transform-runtime", {
"useESModules": true
}]
]
}
// UMD
{
"presets": [
["@babel/preset-env", {
"modules": false,
}], "@babel/preset-react"
],
"plugins": [
["@babel/plugin-transform-runtime", {
"useESModules": false
}]
]
}
ant的除了 es modules,还打包生成了 commonjs 也就是它的 lib 文件夹,还有最落后的 umd 也就是 dist 文件夹,也就是浏览器可以直接引用使用的
注:这里面有个容易纠结的点,例如日常开发中的Vue、React单页面应用(网页),它的打包后产物,是能够在浏览器上直接打开(打不开一般都是路径的问题)或者服务器上直接部署的静态资源。对于这个资源来说,因为不作为模块被引用,虽然它是UMD规范,也可以不用纠结它的规范(因为没意义)。
而ant-design中除了 es modules,还打包生成了 commonjs 也就是它的 lib 文件夹,还有 umd 也就是 dist 文件夹,也就是浏览器可以直接引用使用的。
常见问题
ES Module和CommonJS的差异
CommonJS:
CommonJS 模块是运行时加载
CommonJS 模块输出的是一个值的拷贝
CommonJs 是单个值导出,本质上导出的就是 exports 属性。
CommonJS 是可以动态加载的,对每一个加载都存在缓存,可以有效的解决循环引用问题。
CommonJS 模块同步加载并执行模块文件。
ESModule:
ES6 Module 静态的,不能放在块级作用域内,代码发生在编译时。
ES6 Module 输出的是值的动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。可以通过导出方法修改,可以直接访问修改结果。
ES6 Module 可以导出多个属性和方法,可以单个导入导出,混合导入导出。
ES6 模块提前加载并执行模块文件,
ES6 Module 导入模块在严格模式下。
ES6 Module 的特性可以很容易实现 Tree Shaking 和 Code Splitting。