浅谈前端模块化
简介
技术的诞生是为了解决某个问题,模块化也是。 随着前端的发展,web
技术日趋成熟,js
功能越来越多,代码量也越来越大。之前一个项目通常各个页面共用一个js
,但是随着js
逐渐拆分,项目中引入的js
越来越多. 在js
模块化诞生之前,开发者面临很多问题:
- 全局变量污染:各个文件的变量都是挂载到
window
对象上,污染全局变量。 - 变量重名:不同文件中的变量如果重名,后面的会覆盖前面的,造成程序运行错误。
- 文件依赖顺序:多个文件之间存在依赖关系,需要保证一定加载顺序问题严重。
简单来说,模块化是将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起,块的内部数据与实现是私有的, 只是向外部暴露一些接口(属性、方法)与外部其它模块通信
我们再来看看模块化有哪些优点:
- 避免命名冲突(减少命名空间污染)
- 更好的分离, 按需加载
- 更高复用性
- 高可维护性
下面我们来看看前端模块化的发展历程
在说这些模块化之前,我们先来看看最早期的模块化是怎么实现的
IIFE
早期的模块化主要是利用立即执行函数和闭包的特性来实现的。
// module1.js
// 这样我们就可以通过 myModule.getName() 来获取 name,并且实现 name 属性的私有化,即外部调用不到
const myModule = (function () {
const name = "闭包模块";
const getName = () => {
return name;
};
// 只暴露需要给外面使用的
return { getName };
})();
console.log(myModule.name); // undefined
console.log(myModule.getName()); // 闭包模块
那假如我们这个模块需要依赖其他模块呢?假如我们这里的模块2依赖上面的模块。
我们可以给函数传递参数
// module2.js
const myModule2 = (function (myModule) {
const name = "闭包模块2";
const getName = () => {
return name;
};
const getFullName = () => {
return name + myModule.getName();
};
// 只暴露需要给外面使用的
return { getName, getFullName };
})(myModule);
console.log(myModule2.name); // undefined
console.log(myModule2.getName()); // 闭包模块2
console.log(myModule2.getFullName()); // 闭包模块2闭包模块
通过这种传参的形式,我们就可以在 myModule2
模块中使用其他模块,从而解决了很多问题,这也是现代模块化规范的思想来源。
这样的模块化有什么问题呢?那就是文件越来越多,依赖越来越不明显。
比如我们上面的例子,module2
依赖module1
,所以我们引入js
的时候一定要先引入module1
再引入module2
。
// index.html文件 <!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="./module1.js"></script>
<script type="text/javascript" src="./module2.js"></script>
// ...
所以当我们模块越来越多的时候,文件也会越来越多,并且依赖也会越来越混乱。这种方案在目前基本没人使用。
下面笔者说说真正成文的几种模块化方案
CommonJS规范
说到CommonJS
,大家就会不约而同的想到node.js
。在node.js
中,默认就支持模块系统,并且遵循CommonJS
规范。
CommonJs 就是模块化的社区标准,而 Nodejs 就是 CommonJs 模块化规范的实现
那CommonJS
的语法是怎么样的?都有哪些特点呢?下面我们来看看
CommonJS
规范中一个js
文件就是一个模块,在模块内部定义的变量和函数默认只能在该模块内部访问,如果外部需要访问需要显示导出。
语法
使用module.exports、exports
导出,require
导入。
我们首先定义一个模块,并导出一个变量
// cjs2.js
module.exports = {
name: "我是cjs2.js 我通过module.exports导出",
};
我们在另外一个模块引入
// cjs1.js
const module2 = require("./cjs2");
console.log(module1.name); // 我是cjs2.js 我通过module.exports导出
执行看下效果
除了使用module.exports
导出,我们还可以使用exports
导出
// cjs2.js
exports.name = "我是cjs2.js 我通过exports导出"
我们再次执行看下效果
都正确输出了。
对于只需要导入不需要使用的模块我们该怎么处理呢?
其实我们直接require
就可以了。在引入@babel/polyfill
的时候我们经常这样使用。
require('@babel/polyfill')
module.exports和exports
既然module.exports
和exports
都能导出,那module.exports
和exports
到底有什么区别呢?他们各自又有什么特点呢?
第一 module.exports
和exports
两者不能共存,当同时存在的时候module.exports
会把exports
覆盖。
// cjs2.js
module.exports = {
name: "我是cjs2.js 我通过module.exports导出",
};
exports.name = "我是cjs2.js 我通过exports导出";
我们执行看下效果
第二 exports
导出不能直接赋值
// cjs2.js
exports = {
name: "我是cjs2.js 我通过exports导出",
};
我们执行看下效果
看到,导出来的是undefined
,为什么不能这样导出呢?
exports
实际上是module.exports
的引用,exports = module.exports
,我们直接 exports = {}
修改 exports
,等于将exports
重新赋值了,所以后续的导出肯定不会影响到module.exports
所以我们上面如果想使用exports
导出name
只能通过如下方式
exports.name = "我是cjs2.js 我通过exports导出";
第三 为什么有了module.exports
还要exports
?
因为exports
相较module.exports
语法更简洁。
exports.name = 'randy'
// 相较
module.exports.name = 'randy'
第四 exports
相较module.exports
劣势是什么?
如果我们不想在 commonjs
中导出对象,而是只导出一个类或者一个函数再或者其他属性的情况,那么 就只能使用 module.exports
了。
如上我们知道 exports
会被初始化成一个对象,也就是我们只能在对象上绑定属性,但是我们可以通过 module.exports
自定义导出对象外的其他类型元素。
特点
接下来我们分析下CommonJS
的模块化都有哪些特点
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果(运行时加载)。
我们来看个例子
// cjs2.js
let count = 1;
const incCount = () => {
count++;
};
let user = { age: 24 };
const incAge = () => {
user.age++;
};
module.exports = {
count,
incCount,
user,
incAge,
};
我们导入使用下
// cjs1.js
const module1 = require("./cjs2");
console.log(module1.count); // 1
module1.incCount();
console.log(module1.count); // 1
console.log(module1.user); // { age: 24 }
module1.incAge();
console.log(module1.user); // { age: 25 }
上面的例子,当我们第一次导入的时候,数据就会被拷贝过来。所以当数据是基本数据类型的时候,模块内部数据后续的修改就不会再影响到外部导入的模块了,当数据是引用数据类型的时候还是会影响到外部导入的模块。
注意这里说的拷贝是浅拷贝,通过上面的例子也能看出来。
- 以同步方式加载模块,并且模块加载的顺序,按照其在代码中出现的顺序,不会将
require
提升到顶部。
我们来看个例子
// cjs3.js
console.log("我是cjs3模块");
module.exports = function say() {
console.log("cjs3");
};
cjs2.js
我们引入cjs3.js
// cjs2.js
require("./cjs3.js");
console.log("我是cjs2模块");
module.exports = function say() {
console.log("cjs2");
};
入口js
文件引入cjs2.js
和cjs3.js
// cjs1.js
console.log("cjs1开始执行");
require("./cjs2.js");
require("./cjs3.js");
console.log("cjs1执行完毕");
我们执行cjs1.js
看效果
可以看到require
完全是运行时同步加载的。
require
可以放在块级作用域或条件语句中。
if (true) {
const module1 = require("./cjs2");
console.log(module1.count);
}
CommonJS
规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。这在服务端node.js
中使用是没什么问题的。但是如果是浏览器端,模块都依赖于网络请求,再使用同步的方式肯定是不太适用的,所以就有了后面的AMD、CMD
异步加载模块的方式。
如何实现模块的?
前面说了CommonJS
规范,一个js
文件就是一个模块,模块内部定义的变量和函数在模块外部是访问不到的,这个是怎么实现的呢?
这就得说到模块包装函数了。看了下面的函数你就瞬间明白了
(function(exports, require, module, __filename, __dirname) {
// 实际代码
})
其实我们每个文件的代码都会被包裹在一个立即执行函数里面。并且该函数会有exports, require, module, __filename, __dirname
这五个参数。这也是为什么我们能直接使用这五个变量的原因。
exports
前面说了,就是module.exports
的引用,用来导出模块require
前面说了,用来导入模块module
前面说了,用来导出模块__filename
输出当前文件的绝对路径__dirname
输出当前文件的文件夹绝对路径,也就是不包括当前文件名。
require查找规则
既然require
用来导入模块,那么对于它的查找规则你们都清楚了吗?
require
的查找分两种,一种是带路径,一种是不带
我们先来说说带路径的情况
require("./serve")
对于上面的导入它会依次进行如下判断
- 首先会查找当前目录下的
serve.js
- 没找到再查找当前目录下的
serve.json
- 再没找到再查找当前目录下的
serve
目录,并查看入口文件(package.json -> main字段
),如果有定义则执行main
字段指定的入口文件。 - 再没查到再查找当前目录下的
serve
目录,执行该目录下的index.js
文件。
我们再来说说不带路径
require("serve")
对于不带路径的情况它会当做安装的第三方模块进行查找,也就是查找node_modules
目录下是否有该文件。
它会一层一层往上查找node_modules
文件夹,直到根目录
当然它在node_modules
中查找模块也是遵循带路径的查找规则(即先serve.js
再serve.json
再serve
目录)
AMD
AMD
是异步加载模块的一个比较流行的方案。主要用于浏览器端,其中最具代表性的就是require.js
,我们来测试下。
语法
使用define
定义模块并使用return
导出,使用require
导入模块。
首先我们引入下require.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AMD</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"></script>
</head>
<body>
<script src="./amd.js"></script>
</body>
</html>
然后创建我们的模块文件
// amd1.js
console.log("adm1开始执行啦");
// 异步加载,使用require导入模块
require(["./amd2.js"], function (m) {
// 模块加载完才会进入,也就是依赖前置
console.log("amd1.js");
console.log(m);
});
console.log("adm1执行结束啦");
模块二,导出模块
// amd2.js
// 使用define定义模块
define(function () {
console.log("amd2.js");
return {
name: "randy",
};
});
我们到浏览器运行看下效果
果然是异步加载的,并且子模块会优先执行。
特点
异步加载,依赖前置,也就是依赖的模块全部加载完毕才会运行当前模块。
原理
require.js
的核心原理是通过动态创建 script
脚本来异步引入模块,然后对每个脚本的 load
事件进行监听,如果每个脚本都加载完成了,再调用回调函数。
上面的例子,我们可以看到,他给我们自动创建了两个<script>
CMD
CMD
也是异步加载模块的一个比较流行的方案。主要用于浏览器端,其中最具代表性的就是sea.js
,我们来测试下。
语法
使用define
方法利用require
参数导入模块,使用exports、module.exports
导出模块,使用seajs.use
使用模块。
首先我们引入下sea.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CMD</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/seajs/3.0.3/sea.js"></script>
</head>
<body>
<script src="./cmd.js"></script>
</body>
</html>
然后创建我们的模块文件
// cmd1.js
console.log("cmd1开始执行啦");
// 使用模块
seajs.use(["./cmd2.js"], function (math) {
console.log("seajs");
const sum = math.add();
console.log(sum);
});
console.log("cmd1执行结束啦");
模块二,导出add
方法
// cmd2.js
define(function (require, exports, module) {
console.log("cmd2.js");
// ... 这里可能会有很多其他代码
// 需要用到的时候再去加载 cmd3.js
const num = require("./cmd3.js");
const add = () => {
return num.a + num.b;
};
exports.add = add;
});
模块三,导出a、b
变量
// cmd3.js
define(function (require, exports, module) {
console.log("cmd3.js");
// 还可以使用module.exports导出
module.exports.a = 10;
module.exports.b = 15;
});
我们到浏览器运行看下效果
可以看到,模块是异步加载的,并且子模块在需要用到的时候才会去加载。
特点
异步加载,依赖后置,也就是当需要用到某模块依赖的时候才会使用require
去加载,而不是事先定义好。
ESM
ESM
是在es6
实现的。ESM
是 JavaScript
官方推出的标准化模块系统。
语法
使用export、export default
导出数据,import
导入数据。
我们首先定义一个模块,并导出一个变量
// esm2.js
export const name = '我是esm2.js 我通过export导出'
我们在另外一个模块引入
// esm1.js
import {name} from './esm2.js'
console.log(name); // 我是esm2.js 我通过export导出
因为现在很多浏览器都已经直接支持ESM
了,所以我们直接在index.html
中引入,然后启动服务查看一下效果。
注意script
标签需要定义type="module"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>esm</title>
</head>
<body>
<script type="module" src="./esm1.js"></script>
</body>
</html>
除了使用export
导出,我们还可以使用export default
导出。
// esm2.js
const name = '我是esm2.js 我通过export default导出'
export default name
我们在另外一个模块引入
// esm1.js
import name from './esm2.js'
console.log(name); // 我是esm2.js 我通过export default导出
对于只需要导入不需要使用的模块我们该怎么处理呢?
其实我们直接import
就可以了。
import('@babel/polyfill')
对于导出和导入我们还可以取别名
// esm2.js
const name = 'randy'
export {
// 导出取别名
name as _name
}
我们在另外一个模块引入
// esm1.js
import {_name} from './esm2.js'
console.log(_name); // randy
我们在另外一个模块引入的时候还可以使用导入别名
// esm1.js
// 导入取别名
import {_name as NAME} from './esm2.js'
console.log(NAME); // randy
export default和export
既然export
和export default
都能导出,那它们之间的区别是什么呢?
第一 export default
在一个文件模块中只能有一个。导出的数据能直接被使用。
// esm2.js
const name = 'randy'
export default name
我们在另外一个模块引入
// esm1.js
import name from './esm2.js'
// name就是导出的数据,能直接被使用
console.log(name); // randy
第二 export
可以有多个,并且导出的数据会在对象上。
// esm2.js
export const name = 'randy'
export const age = 24
我们在另外一个模块引入
// esm1.js
// 需要从对象中结构出来
import {name, age} from './esm2.js'
console.log(name); // randy
console.log(age); // 24
第三 不同于上面说的CommonJS
,ESM
中export
和export default
可以混合使用。只需要确保只有一个export default
即可。
// esm2.js
export const name = 'randy'
export const age = 24
const info = 'export和export default可以混用哦'
export default info
我们在另外一个模块引入
// esm1.js
// 需要从对象中结构出来
import info, {name, age} from './esm2.js'
console.log(name); // randy
console.log(age); // 24
console.log(info); // export和export default可以混用哦
特点
接下来我们看看ESM
模块化都有哪些特点
ESM
运行机制与commonjs
运行机制不一样。js
引擎对脚本静态分析的时候,遇到模块加载指令后会生成一个只读引用(编译时加载)。等到脚本真正执行的时候。才会通过引用模块中获取值,在引用到执行的过程中,模块中的值发生变化,导入的这里也会跟着发生变化。也就是说ESM
是动态引入的,并不会缓存值。
我们来看这个例子
// esm2.js
export let count = 1;
export const incCount = () => {
count++;
};
我们导入使用下
// esm1.js
import { count, incCount } from "./esm2.js";
console.log(count); // 1
incCount();
console.log(count); // 2
可以看到,我们直接修改模块内部的变量,在我们的导入模块也是会有影响的。所以可以看出来ESM
导入模块导入的不是值拷贝,而是引用。
ESM标准模块加载器是异步的,读取到脚本后不会立即执行
,而是先进入编译阶段
进行模块解析
,检查模块上调用了import和export的地方
,并以此类推的把依赖模块一个个异步、并行地进行下载
。在此阶段ESM
加载器不会执行任何依赖模块代码,只检查语法错误、确定模块依赖关系、确定模块输出和输入的变量。最后ESM
会进入执行阶段,按照顺序执行各模块脚本。所以import
会自动提升到代码的顶部。
我们来看个例子
// esm3.js
console.log("我是esm3模块");
export default function say() {
console.log("esm3 say");
}
esm2.js
我们引入esm3.js
// esm2.js
import "./esm3.js";
console.log("我是esm2模块");
export default function say() {
console.log("esm2 say");
}
入口js
文件引入esm2.js
和esm3.js
// esm1.js
console.log("esm1开始执行");
import say2 from "./esm2.js";
say2();
import say3 from "./esm3.js";
say3();
console.log("esm1执行完毕");
我们执行esm1.js
看效果
可以看到import
语句被提升了,并且esm3.js
只被加载了一次。
ESM 默认使用严格模式(use strict)
,因此在ESM
模块中的this不再指向全局window对象(global object)
,而是undefined
。
console.log(this) // undefined
如果要访问全局对象,需要使用globalThis
console.log(globalThis);
- 使用
var
定义的变量不会挂载在window
上
我们来看例子
var name1 = "randy";
console.log(name1); // randy
console.log(window.name1); // undefined
上面代码执行结果如下
import
不可以放在块级作用域或条件语句中。如果要动态导入,需要使用到import()
方法
// esm2.js
export default function say() {
console.log("esm2");
}
export const name = "randy";
我们使用动态导入
if (true) {
import("./esm2.js").then((res) => {
console.log(res);
});
}
查看下输出结果
关于<script type="module">
因为前面我们演示是使用的<script type="module">
来运行我们的esm
模块化代码,对于<script type="module">
这里我们再来说明一下。
当我们的script
标签加上了type="module"
就可以在script
里面直接使用ESM
模块了。也就是可以使用 import、export
啦。
需要注意 script
标签加上了 type="module"
默认会被 defer
修饰。也就是会被异步加载执行。
Vite
抛弃了以往以bundle
为主的开发服务器,进而选择了一种nobundle
的资源处理模式。其实它的底层就是利用了type="module"
使浏览器能直接运行ESM
模块化代码,让我们在开发阶段运行速度有一个质的飞升。
如果我们不使用这种方式,直接将ESM
模块化代码在浏览器运行时会报错的。
所以目前使用ESM
模块化的代码都会使用webpack
等构建工具进行编译转化,以让浏览器能直接运行。
接下来我们来看看ESM
模块到底需要怎么编译,以及编译后代码会变成什么样?
编译ESM模块
聪明的同学肯定会想到编译es
肯定是babel
,还真没错,对于ESM
模块的编译我们需要用到babel
,那使用babel
就直接能把ESM
模块编译成浏览器可执行的代码吗?
使用babel
假设有下面的代码
index2.js
用来导出
// index2.js
const name = "randy";
export default name;
index1.js
用来导入
// index1.js
import name from "./index2";
console.log(name);
我们使用babel
编译一下,看输出什么
// 编译后的index2.js
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var name = "randy";
var _default = name;
exports["default"] = _default;
可以看到它定义了一个exports
对象,并将__esModule
属性设置为true
。然后将我们的默认导出设置到default
属性上。
如果还有其它导出属性的话也会放在exports
上。
// 编译后的index1.js
"use strict";
var _index = _interopRequireDefault(require("./index8"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
console.log(_index["default"]); // 这里会输出randy
这边导入的时候它会使用require
来进行导入,并且会使用_interopRequireDefault
方法,这个方法就用到了前面的__esModule
,它会进行判断是否有__esModule
,有就直接返回,没有就包裹在对象的default
属性中返回。
从这里就可以看出,为什么我们在写前端代码的时候既可以使用
CommonJS
规范还可以使用ESM
规范了。因为使用babel
编译后的代码都会变成CommonJS
规范。
如果我们将上面的index1.js
修改成require
导入,编译后会变成什么样呢?
// index1.js
const name = require("./index2");
console.log(name);
我们来编译一下,编译结果如下,可以看到他并没有像上面一样使用辅助函数_interopRequireDefault
,而是直接使用的require
"use strict";
var name = require("./index8");
console.log(name); // 这里会输出{default: "randy"}
这也就可以解释为什么有的地方使用
require
去引用一个模块时需要加上default
了。比如require('xx').default
。因为通过require导入模块不会被处理,会被直接使用。
这个场景在写 webpack
代码分割逻辑时经常会遇到。
require.ensure([], (require) => {
callback(null, [
require('./src/pages/login').default,
]);
});
通过babel
编译后的代码能直接在浏览器运行吗?
当然是不行的,因为我们发现编译后的代码是CommonJS
规范,还是不能直接在浏览器运行的。那怎么办呢?
那就还需要一个支持 CommonJS
模块语句的构建系统,比如webpack
、Browserify
。这里我们选择大家所熟知的webpack
来演示。
使用webpack
我们安装webpack
并配置babel loader
再来编译一下我们上面的代码。
编译结果如下
(function () {
"use strict";
var __webpack_modules__ = {
"./jssrc/es3.js": function (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) {
__webpack_require__.r(__webpack_exports__);
// index2.js
var name = "randy";
__webpack_exports__["default"] = name;
},
};
// The module cache
var __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = (__webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {},
});
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}
/* webpack/runtime/make namespace object */
!(function () {
// define __esModule on exports
__webpack_require__.r = function (exports) {
if (typeof Symbol !== "undefined" && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {
value: "Module",
});
}
Object.defineProperty(exports, "__esModule", { value: true });
};
})();
var __webpack_exports__ = {};
!(function () {
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _es3__WEBPACK_IMPORTED_MODULE_0__ =
__webpack_require__(/*! ./es3 */ "./jssrc/es3.js");
console.log(_es3__WEBPACK_IMPORTED_MODULE_0__["default"]);
})();
})();
我们简化一下,总体结构如下
(function() {
// __webpack_modules__ 是一个模块路径和获取模块值函数的对象。
// __webpack_require__ 是一个通过模块路径在__webpack_modules__ 查找并模块值的方法
// 通过立即执行函数定义__webpack_require__.r方法,这个方法类似前面的babel,给对象定义__esModule属性,表示是ESM模块编译出来的结果
// 通过立即执行函数,调用__webpack_require__方法传递模块路径获取到我们需要的模块值,进行输出
})()
简化后的代码一眼就看出来是我们最开始说的IIFE
模块化方案,也就是立即执行函数,这种代码我们的浏览器是一定能够直接运行的。
所以,ESM
模块化代码如果要通过编译然后在浏览器执行一定会经过babel
和webpack
的处理,将他变成立即执行函数,然后我们的浏览器就能正常执行了。
既然模块化方案有这么多,有没有一种方案能统一呢?诶,还真有,那就是UMD
方案。
UMD
UMD(Universal Module Definition)
,即通用模块定义,从名字就可以看出来,这东西是做大一统的。
它其实就是对AMD、CMD、CommonJS
的兼容,使得各个版本都能正常运行。
我们在使用webpack
构建代码的时候通过配置library
就能输出UMD
格式的代码。
module.exports = {
//...
output: {
library: {
name: 'MyLibrary',
type: 'umd',
},
},
};
核心代码
其实它也很简单,也是一个立即执行函数
((root, factory) => {
if (typeof define === 'function' && define.amd) {
// AMD
define(factory);
} else if (typeof define === 'function' && define.cmd){
// CMD
define(function(require, exports, module) {
module.exports = factory()
})
} else if (typeof exports === 'object') {
// CommonJS
module.exports = factory();
} else {
// 都不是直接挂载在全局对象上
root.umdModule = factory();
}
})(this, () => {
// return 需要定义的模块
return {
name: 'randy'
}
});
可以看到,define
是 AMD/CMD
语法,而 exports
只在 CommonJS
中存在,你会发现它在定义模块的时候会检测当前使用环境和模块的定义方式,如果匹配就使用其规范语法,全部不匹配则挂载再全局对象上,我们看到传入的是一个 this
,它在浏览器中指的就是 window
,在服务端环境中指的就是 global
,使用这样的方式就能兼容各种模块了。
总结
看到这,小伙伴们是否对前端模块化又有了一个新的认知呢?
就目前来说,其实使用比较广泛的只有CommonJS
和ESM
规范。笔者觉得这两个规范我们需要重点掌握。对于AMD
和CMD
可能只能在老项目里面才能看得到,了解即可。
CommonJS
和ESM
模块化区别:
-
语法层面,
CommonJs
是通过module.exports
、exports
导出,require
导入。而ESM
则是export
、export default
导出,import
导入。 -
CommonJS
模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存(浅拷贝)结果(运行时加载)。ESM
模块是js
引擎对脚本静态分析的时候,遇到模块加载指令后会生成一个只读引用(编译时加载)。等到脚本真正执行的时候。才会通过引用模块中获取值,在引用到执行的过程中,模块中的值发生变化,导入的这里也会跟着发生变化。也就是说ESM
是动态引入的,并不会缓存值。 -
CommonJS
以同步方式加载模块,并且模块加载的顺序,按照其在代码中出现的顺序,不会将require
提升到顶部。ESM标准模块加载器是异步的,读取到脚本后不会立即执行
,而是先进入编译阶段
进行模块解析
,检查模块上调用了import和export的地方
,并以此类推的把依赖模块一个个异步、并行地进行下载
。在此阶段ESM
加载器不会执行任何依赖模块代码,只检查语法错误、确定模块依赖关系、确定模块输出和输入的变量。最后ESM
会进入执行阶段,按照顺序执行各模块脚本。所以import
会自动提升到代码的顶部。 -
CommonJS
的导入require
可以放在块级作用域或条件语句中。而ESM
模块的import
不可以放在块级作用域或条件语句中。如果要动态导入,需要使用到import()
方法。 -
ESM 默认使用严格模式(use strict)
,因此在ESM
模块中的this不再指向全局window对象(global object)
,而是undefined
。如果要访问全局对象,需要使用globalThis
。 -
在
ESM
模块中使用var
定义的变量不会挂载在window
上。
后记
感谢小伙伴们的耐心观看,本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!
转载自:https://juejin.cn/post/7210216031536431162