Skip to content
大纲

文件模块

JS 中一个文件即一个模块, 由于历史原因存在 CommonJS 和 ESM 两套规范

CommonJS 中的模块加载

CommonJS 中使用 require 引入模块, 使用 exportsmodule.exports 导出模块, 这里使用 node 探讨一下模块加载的特性

导出

  • 使用 exports 导出模块内容, exports 本身作为一个对象, 导出的内容实际在为其添加属性
  • 使用 module.exports 导出模块内容, 即将导出内容赋值给了 exports
  • module.exportsexports 同时使用时, module.exports 会覆盖 exports 中的内容

引入

使用 require 加载模块时具有以下性质

  1. 动态引入, 即可以传递变量而非字面量
  2. 非顶级使用, 即可以在任意作用域中引用
  3. 引用模块时会执行原模块代码
  4. 一个模块只被引入一次, 多次引入时会直接访问缓存

示例

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 取得是缓存数据
  • vendortime 中的代码各执行了一次, 说明 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 提出的标准化模块系统, 使用 importexport 实现模块的加载和导出, 由于 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' 模块中的处理相同

小结

  • importexport 为 ES6 中的关键字
  • export 可以输出引用接口
  • export default 可以指定默认输出, 相当于 export { default: <inter> }
  • ES6 模块输出的是引用接口, 不会缓存运行结果
  • import 引入模块时会执行模块内容
  • 同一模块被重复引入只会执行一次
  • import 命令引入的变量是只读的, 不可修改(如果该变量是对象, 可以修改其属性, 但应当禁止)
  • 一个模块中输入和输出同一模块, 该模块会被当作转发接口模块
    • 模块内部不能使用引入的模块
    • 可以将 importexport 合并为 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
}