文件模块
JS 中一个文件即一个模块, 由于历史原因存在 CommonJS 和 ESM 两套规范
CommonJS 中的模块加载
CommonJS 中使用 require 引入模块, 使用 exports 或 module.exports 导出模块, 这里使用 node 探讨一下模块加载的特性
导出
- 使用
exports导出模块内容,exports本身作为一个对象, 导出的内容实际在为其添加属性 - 使用
module.exports导出模块内容, 即将导出内容赋值给了exports module.exports与exports同时使用时,module.exports会覆盖exports中的内容
引入
使用 require 加载模块时具有以下性质
- 动态引入, 即可以传递变量而非字面量
- 非顶级使用, 即可以在任意作用域中引用
- 引用模块时会执行原模块代码
- 一个模块只被引入一次, 多次引入时会直接访问缓存
示例
js
// time.js
console.log("引入了 time");
exports.time = +new Date();js
// vendor.js
console.log("引入了 vendor");
module.exports = require("./time");js
// index.js
// NOTE: 通过 require.cache 属性可以获取缓存的模块, 这里输出缓存模块名及模块 exports 内容
const getCacheInfo = () => {
const cache = require.cache;
return Object.entries(cache).map((data, index) => {
const [key, val] = data;
const path = key.replace(__dirname, "example");
return { [path]: val.exports };
});
};
setTimeout(() => {
const timePath = "./time";
console.log(require(timePath));
console.log(getCacheInfo());
}, 1000);
setTimeout(() => {
console.log(require("./vendor"));
console.log(getCacheInfo());
}, 2000);执行结果
code
"引入了 time"
{ time: 1545112502208 }
[ { 'example\\index.js': {} },
{ 'example\\time.js': { time: 1545112502208 } } ]
"引入了 vendor"
{ time: 1545112502208 }
[ { 'example\\index.js': {} },
{ 'example\\time.js': { time: 1545112502208 } },
{ 'example\\vendor.js': { time: 1545112502208 } } ]- 两次输出的时间相同, 说明第二次
vendor中的require取得是缓存数据 vendor和time中的代码各执行了一次, 说明vendor引入的time未执行
删除缓存(修改 index.js 中引入 vender 的部分)
diff
// ... 其他略
setTimeout(() => {
+ delete require.cache[require.resolve('./time.js')]
console.log(require('./vendor'))
console.log(getCacheInfo())
}, 2000)执行结果
code
"引入了 time"
{ time: 1545112648524 }
[ { 'example\\index.js': {} },
{ 'example\\time.js': { time: 1545112648524 } } ]
"引入了 vendor"
"引入了 time"
{ time: 1545112649516 }
[ { 'example\\index.js': {} },
{ 'example\\vendor.js': { time: 1545112649516 } },
{ 'example\\time.js': { time: 1545112649516 } } ]- 两次输出时间不同, 且 time 引入了两次, 说明
require确实是优先使用缓存
其他
- node 基于 commonjs 规范的实现, 实际将每个模块作为一个函数执行, 并提供了
exports作为模块的出口 require的本质是一个全局函数, 其将引入后的模块进行缓存, 每次引入时会优先使用缓存模块- 如果模块中执行了一些异步代码, 这些代码会在原模块中执行, 但对于引入模块来说是获取不到
ESM 中模块的加载
ESM 是 ES6 提出的标准化模块系统, 使用 import 和 export 实现模块的加载和导出, 由于 node 暂不支持该特性, 下面使用 webpack 探讨一下模块加载的特性
webpack 前置配置(仅为了方便查看构建后代码)
- 配置
optimization.minimize: false以方便查看构建后的代码 - 配置
optimization.runtimeChunk: "single"将运行时代码单独打包
注意
- webpack 中引入的模块, 如果未调用, 是不会出现在构建结果中的
导出
export <inter>
- 任何声明(变量、函数、类)都可以通过
export关键字导出 - 可以导出一个对象, 将导出内容作为对象的属性
- 使用对象格式导出时, 可以为导出内容重命名
- 不能直接导出静态数据或变量
- 必须在模块顶层执行
- 导出的接口与其对应的值是动态绑定关系
export 示例
js
export const foo = "foo"; // 导出变量
export function bar() {} // 导出函数
const a = "alias";
export { a as alias }; // 对象格式导出并充重命名export default <inter>
- 为模块指定默认导出
- 一个模块只能调用一次
- 可以导出静态数据或变量
- 不能导出变量声明
- 其表现相当于
export { default: <inter> }
export default 示例
js
const Foo = 'Foo'
export default Foo
export default 'default'
export default {}
export default class {}
export default function () {}
// export default const a = 'a' // 错误导入
静态导入
导入 export <inter> 接口
- 在引入时使用花括号
- 接口名需与导出接口名对应
- 可以使用
as给导入接口起别名 - 多次导入只会进行一次加载
- 可以使用
import * as mouleName from 'modulePath'的格式进行整体导入
示例
js
import { foo as Foo, bar as Bar } from "./example/export/module";导入 export default <inter> 接口
- 在引入时不需要使用花括号
- 直接为导入模块指定名称即可
- 可以使用
import { default as CustomName } from 'modulePath'的格式导入
示例
js
import CustomName from "./example/export-default/module";动态导入
使用 import() 进行动态导入, 返回一个 Promise; 理论上来说, 此时的 import 作为函数使用是支持动态参数的, 但 webpack 中并不支持动态参数的传递, 这会使其在构建时无法正确找到打包模块
示例
js
async function lazyLoad() {
const _ = await import("lodash");
console.log(_.add(1, 2));
}webpack 中的处理
js
// vender.js
console.log("执行了");
let time = 0;
setTimeout(() => {
time += 1000;
}, 1000);
export { time };js
// a.js
import { time as timeA } from "./vendor";
console.log("a: ", timeA);
setTimeout(() => {
console.log("a: ", timeA);
}, 1234);
export default timeA;js
// b.js
import { time as timeB } from "./vendor";
console.log("b: ", timeB);
setTimeout(() => {
console.log("b: ", timeB);
}, 1234);
export { timeB };js
// index.js
import timeA from "./a";
import { timeB } from "./b";
console.log(timeA, timeB);
setTimeout(() => {
console.log(timeA, timeB);
}, 2000);输出结果
code
执行了
a: 0
0 0
a: 1000
0 1000构建代码
js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
[0],
{
"./src/index.js": function (
module,
__webpack_exports__,
__webpack_require__
) {
__webpack_require__.r(__webpack_exports__);
// CONCATENATED MODULE: ./src/vendor.js
console.log("执行了");
let time = 0;
setTimeout(() => {
time += 1000;
}, 1000);
// CONCATENATED MODULE: ./src/a.js
console.log("a: ", time);
setTimeout(() => {
console.log("a: ", time);
}, 1234);
var a = time;
// CONCATENATED MODULE: ./src/index.js
console.log(a, time);
setTimeout(() => {
console.log(a, time);
}, 2000);
},
},
[["./src/index.js", 1]],
]);分析
- 'vendor' 模块
- 使用了
export输出的是一个引用接口 - 被重复引入多次但只执行一次
- 使用了
- 'a' 模块
- 引入 'vendor' 模块暴露的接口, 会随引用值的变化而改变
- 使用
export defaul输出一个变量, 故不会随引用值而改变
- 'b' 模块
- 观察 webpack 构建后的代码发现并没有 'b' 模块的内容
- 该模块实际为转发接口模块, 相当于
export { time as timeB } from './vendor' - 转发接口模块中的内容会被忽略, webpack 打包时直接忽略
- 'index' 模块
- 引入了 'a' 模块的默认输出, 是一个静态变量, 其值不会改变
- 引入了 'b' 模块的输出接口, 实际等同于
import { time as timeB } from './vendor', 即与 'a' 模块中的处理相同
小结
import和export为 ES6 中的关键字export可以输出引用接口export default可以指定默认输出, 相当于export { default: <inter> }- ES6 模块输出的是引用接口, 不会缓存运行结果
import引入模块时会执行模块内容- 同一模块被重复引入只会执行一次
import命令引入的变量是只读的, 不可修改(如果该变量是对象, 可以修改其属性, 但应当禁止)- 一个模块中输入和输出同一模块, 该模块会被当作转发接口模块
- 模块内部不能使用引入的模块
- 可以将
import和export合并为export { xx } from './vendor'格式 - webpack 构建时会直接忽略模块内容
差异
ES6 模块与 CommonJS 模块的主要差异
- ES6 模块作为关键字使用, 在编译时输出
- CommonJS 模块作为函数和对象使用, 在运行时加载
- ES6 模块输出的内容为值引用
- CommonJS 模块输出的内容为值拷贝
补充说明
文章写于 2018 年, 当时 node 尚不支持 ESM, 随着 node 版本的迭代, 可以通过 package.json 切换 node 项目模块规范
json
// package.json
{
// "type": "commonjs", // 默认 CJS
"type": "module" // 切换至 ESM
}