Skip to content

CommonJS 模块规范与ES6 模块规范(Day7 3/3) ​

本页对应 Day07_Node,共 6 段视频,约 1 小时 40 分。

学习目标 ​

  • 能编写 CommonJS 模块,分别通过给 module.exports 赋值、给 module.exports 添加属性、给 exports 添加属性暴露单个或多个数据,并解释 exports 重新赋值后失效的原因。
  • 能使用 require 导入自定义模块与内置模块,说明自定义模块路径必须以 ./ 或 ../ 开头的规则,并判断省略扩展名时对 .js、.json、目录的查找顺序。
  • 能实现将整个目录作为 CommonJS 模块导入,正确配置 package.json 的 main 入口或使用默认 index.js,并说明缺少入口文件时的报错原因。
  • 能使用 ES 模块语法导入 Node 内置模块,如 import { readFile } from 'node:fs' 或 import * as fs from 'node:fs',并说明 .mjs 与 package.json 中 type: "module" 的启用条件。
  • 能编写 ES 自定义模块,使用 export default 暴露单个数据、使用 export 声明或统一暴露多个数据,并按具名导入、别名导入或命名空间导入正确引入。
  • 能区分 CommonJS 与 ES 模块在暴露/导入语法、扩展名解析和目录模块解析上的差异,并依据报错定位常见模块配置问题。

知识点 ​

模块为什么要封闭 ​

为什么需要:模块通常只对外提供功能,内部变量不应污染使用方;使用方只需要知道“能拿到什么数据”。
怎么想:CommonJS 中模块默认封闭,require 得到的是 module.exports 的值;不暴露时默认是空对象。
怎么做:

js
// 01-mod.js
console.log('我是一个模块');
let address = '上海';
js
// index.js
require('./01-mod.js');
const mod01 = require('./01-mod.js');
console.log(mod01); // {}

实际中运行后能看到:01-mod.js 的 console.log 只执行一次;mod01 是空对象。
易错点:模块里的 address 外部不能用;require 多次不会重复执行模块,后面拿的是缓存结果。

用 module.exports 暴露一个数据 ​

为什么需要:让外部拿到模块的计算结果或对象。
怎么想:module.exports 的值就是模块暴露的数据,默认是空对象;给它赋值就是替换暴露数据。
怎么做:

js
// 02-mod.js
module.exports = 100;
// 后写会覆盖前写
// module.exports = 'hello';
// module.exports = [1, 2, 3];
js
// index.js
const mod02 = require('./02-mod.js');
console.log(mod02); // 100

也可以暴露对象:

js
const obj = {
  name: '小段',
  age: 19,
  say() { console.log(this.name); }
};
module.exports = obj;

易错点:多次给 module.exports 赋值,只有最后一次有效;赋值不会“结束模块”,后面的代码仍会执行,通常把暴露语句放最后。

给 module.exports 添加属性暴露多个数据 ​

为什么需要:一个模块常要暴露多个方法或字段。
怎么想:默认的 module.exports 是空对象,不替换它,而是给它加属性,就能暴露多个数据;require 得到整体对象。
怎么做:

js
// 03-mod.js
module.exports.getMsg = () => {
  console.log('MOD03 getMessage');
};
module.exports.setMsg = () => {
  console.log('MOD03 setMessage');
};
js
const mod03 = require('./03-mod.js');
mod03.getMsg();

易错点:require 得到的是对象,暴露的多个数据是对象属性;可以用解构赋值取属性,但解构只是取值,不改变整体对象。

exports 别名与引用断开 ​

为什么需要:少写 module.exports。
怎么想:exports 是 module.exports 的别名,初始指向同一对象;给 exports 加属性等于给 module.exports 加属性;但给 exports 重新赋值会断开引用。
怎么做:

js
// 04-mod.js
exports.username = '小段';
exports.password = '123123';

等价于给 module.exports 加属性。若要整体替换,应写:

js
module.exports = [1, 2, 3];

易错点:exports = [...] 后,module.exports 仍是原来的空对象,require 得到空对象。根因是对象引用:exports 重新赋值后不再指向 module.exports。

require 引入规则与扩展名 ​

为什么需要:区分内置模块和自定义模块,并决定查找哪个文件。
怎么想:不以 ./ 或 ../ 开头,会被当作内置模块;自定义模块要写路径。省略扩展名时,依次查找 .js、.json、.node、目录。
怎么做:

js
const mod02 = require('./02-mod'); // 可省略 .js

JSON 文件也能作为模块,require 会自动解析成对象:

js
const mod05 = require('./05-mod.json');

其他扩展名如 .sb 也能导入,但扩展名不能省略;转写稿中它按 JS 处理,写 module.exports = [1, 2, 3] 后能拿到数组。
易错点:同目录也要写 ./;省略扩展名不等于任意扩展名都能省;同名时 .js 优先于 .json。

目录作为模块 ​

为什么需要:把多个文件组织成一个模块,对外只暴露入口文件。
怎么想:require 目录时,先找 package.json 的 main 字段;没有 main 就找 index.js;都没有就报错。
怎么做:

json
// 07-mod/package.json
{ "main": "01.js" }
js
// 07-mod/01.js
module.exports = { msg: 'MOD 07' };
js
const mod07 = require('./modules/07-mod');
console.log(mod07); // { msg: 'MOD 07' }

没有 package.json 时,目录中写 index.js 作为默认入口。
易错点:空目录会报错;入口不是目录里任意文件;入口文件内部可以再 require 其他文件,但对外只认 main 或 index.js。

小结 ​

CommonJS 暴露数据可归到底层一条:module.exports 的值就是模块暴露的数据,默认空对象;给它赋值就替换,给它加属性就暴露多个数据;exports 只是别名,不能重新赋值。require 多次只执行一次模块,后续使用缓存。

先理解模块要解决什么问题 ​

模块的第一目标是“封装”:模块内部变量默认不对外暴露,使用模块的人只关心它给了什么。CommonJS 用 require 导入、用 module.exports / exports 暴露;ES6 模块则用 import / export 两个关键字。为什么要先对照?因为转写稿里大量调试都围绕“暴露什么、导入得到什么”展开。怎么想:先问模块暴露的是单个数据还是多个数据,再决定导入写法。怎么做:CommonJS 中 module.exports 默认是空对象,exports 是它的别名;给 module.exports 赋值会改变暴露数据,给 exports 重新赋值则会断开引用,无法暴露。易错点:require('./01-mod.js') 必须用 ./ 开头,否则会被当作内置模块;require 多次只执行一次,后续拿缓存。

js
// CommonJS 暴露
module.exports = { name: '小段', say() {} };
// 或
exports.name = '小段'; // 本质是给 module.exports 加属性

ES6 导入内置模块:必须配合解构或整体命名 ​

为什么需要:ES6 的 import 不是函数调用,而是语法关键字,不能像 require('fs') 那样直接得到整个对象。怎么想:内置模块暴露的是多个命名数据,所以要么按名字解构,要么用 * as 整体拿到。怎么做:

js
import { readFile, writeFile } from 'node:fs';
// 或整体导入
import * as FS from 'node:fs';
FS.writeFile('a.txt', 'hello', () => {});

易错点:脚本中出现 import / export 后,Node 要求它是 ES 模块:文件用 .mjs,或在项目 package.json 中写 "type": "module"。一旦指定 type: module,同一范围里就不能再用 require,转写稿原话是“有我没他”。另外,本节演示中 ES6 直接导入 JSON 失败,所以不要照搬 CommonJS 的 JSON 导入习惯。

自定义模块:默认导出只能一个 ​

为什么需要:很多模块只提供一个主功能,用默认导出最直接。怎么想:export default 在语法层面只允许写一次,因此它天然只暴露一个数据。怎么做:

js
// 01-mod.js
export default 100;
// 导入时可任意命名
import MOD01 from './modules/01-mod.js';

易错点:不能写两个 export default;ES6 导入自定义模块时,转写稿实测扩展名不能像 CommonJS 那样随便省略,老老实实写 .js 更稳。

命名导出:多个数据必须“有名字” ​

为什么需要:一个模块可能暴露数组、函数、对象等多个数据。怎么想:export 后面必须跟变量声明语句,让每个数据有名字。怎么做:

js
// 02-mod.js
export const data = [1, 2, 3];
export const getUser = () => ({ name: '小段' });
// 统一暴露也可以
const n1 = 1, n2 = 2;
export { n1, n2 };

导入多个命名数据时,名字必须与导出名对应;不喜欢可用 as 改名:

js
import { data, getUser as getU } from './modules/02-mod.js';
// 或整体导入为一个对象
import * as MOD02 from './modules/02-mod.js';

易错点:import { data: d } 这种“对象解构式改名”不合法,必须写 data as d;export { n1, n2 } 不是暴露一个对象,而是暴露多个命名数据。

目录作为模块:入口要明确 ​

CommonJS 中目录可依靠 package.json 的 main 或默认 index.js 作为入口。ES6 下转写稿做了调试:先写目录导入,报找不到模块;补 package.json 的 main,再补 "type": "module",仍然报错,最后还在排查,未给出成功结论。因此这里不要死记“ES6 目录一定能自动找 index”。更稳的做法是显式导入入口文件:

js
import MOD04 from './modules/04-mod/index.js';

易错点:不要把 CommonJS 的目录查找规则直接套到 ES6;遇到目录导入失败,先检查入口文件、扩展名和 type 配置,再退回显式文件路径。

一句话总结 ​

ES6 模块的核心不是背四种暴露规则,而是记住:export default 只给一个数据;命名导出给多个有名字的数据;import 按名字取,整体取用 * as;运行环境要明确是 ES 模块。调试时先看“模块到底暴露了什么”,再看“导入语法是否匹配”。

作业原文与目标 ​

text
1. 封装容量单位转换函数,字节转为其他单位, 第二个参数指定 目标单位 0:不转换, 1:转为KB; 2:转为MB 3:转为GB 4:转为TB
2. 将定义好的函数封装成模块,分别封装成 CommonJS 模块和 ES6 模块

这份作业表面是“写一个转换函数”,实际练的是两件事:先把纯函数写对,再把它放进 CommonJS 和 ES6 两种模块体系里,理解“模块内数据默认封闭、通过暴露规则对外提供数据、引入方按规则拿到数据”。转写稿里反复强调的 module.exports、exports 别名、require 路径、export default、export 多个数据、.mjs 与 package.json 的 type,正好对应这份作业的封装步骤。

一、容量单位转换函数:先写对再封装 ​

为什么需要:字节数通常很大,直接展示不直观;统一用 1024 进制换算成 KB/MB/GB/TB,便于阅读和复用。

怎么想:第二个参数 unit 取 0~4,0 表示不转换,1~4 分别除以 1024 的 1~4 次方。先做参数校验,再计算,避免非法单位或负数进入业务。

怎么做:

js
function formatBytes(bytes, unit = 0) {
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  if (!Number.isFinite(bytes) || bytes < 0) {
    throw new TypeError('bytes 必须是非负数字');
  }
  if (!Number.isInteger(unit) || unit < 0 || unit > 4) {
    throw new RangeError('unit 必须是 0~4');
  }
  if (unit === 0) return bytes;
  return bytes / Math.pow(1024, unit);
}

console.log(formatBytes(2048, 1)); // 2
console.log(formatBytes(1024 * 1024, 2)); // 1

易错点:不要用 1000 代替 1024;unit 要限制在 0~4;返回值是数值还是带单位字符串要提前约定,作业只要求“转为其他单位”,返回数值最直接;负数、NaN 要拦截。

二、CommonJS 模块:module.exports 暴露,require 引入 ​

为什么需要:函数写好后要被其他文件复用。转写稿说得很清楚:模块是封闭的,模块里的变量默认不能用,必须通过暴露规则对外提供数据。

怎么想:CommonJS 中 module.exports 的值就是模块暴露的数据;exports 是 module.exports 的别名,可以给 exports 加属性,但不能直接 exports = {...},否则引用断开。require('./xxx') 必须以 ./ 或 ../ 开头,否则会被当成内置模块;require 多次不会重复执行模块,后续拿的是缓存结果。

怎么做:

js
// commonjs/formatBytes.js
function formatBytes(bytes, unit = 0) {
  if (unit === 0) return bytes;
  return bytes / Math.pow(1024, unit);
}

module.exports = { formatBytes };
js
// commonjs/index.js
const { formatBytes } = require('./formatBytes');

console.log(formatBytes(2048, 1)); // 2

易错点:写成 exports = { formatBytes } 会拿到空对象;路径漏了 ./ 会报“找不到模块”;解构名字必须和暴露的属性名一致;如果 require 多次,模块代码只执行第一次,这是缓存机制,不是重复导入。

三、ES6 模块:export 暴露,import 引入 ​

为什么需要:ES6 模块是标准语法,适合现代前端和 Node 的 ESM 体系。转写稿中强调:ES6 用 export 关键字暴露数据,用 import 导入;单个数据可用 export default,多个数据用 export 命名导出。

怎么想:export default 只能写一次;export 多个数据时,后面要跟变量声明,如 export const formatBytes = ...。导入单个默认导出可自己起名;导入多个命名导出必须按名字解构,或用 import * as mod 一步拿到对象。ES6 自定义模块导入时扩展名要写全;.js 里直接用 import 需要 package.json 的 "type": "module",或把文件改成 .mjs。

怎么做:

js
// esm/formatBytes.mjs
export function formatBytes(bytes, unit = 0) {
  if (unit === 0) return bytes;
  return bytes / Math.pow(1024, unit);
}
js
// esm/index.mjs
import { formatBytes } from './formatBytes.mjs';

console.log(formatBytes(1024 * 1024, 2)); // 1

易错点:export default 写两次会语法错误;export 多个数据不能写成对象字面量,要写变量声明;ES6 导入自定义模块不要随意省略扩展名;ES6 不能直接导入 JSON;若用目录作为模块,需要 package.json 的 main 指定入口,或默认 index.js,同时注意 type 字段与模块规范匹配。

四、联调与验收 ​

先分别运行 node commonjs/index.js 和 node esm/index.mjs,检查 0~4 五个单位的结果。CommonJS 若拿到空对象,优先检查是否只写了 exports = ...;若报找不到模块,检查路径是否以 ./ 开头。ES6 若报 Cannot use import statement outside a module,检查文件是否为 .mjs 或 package.json 是否设置了 "type": "module"。转写稿中移动文件后路径要同步修改、require 多次走缓存、exports 重新赋值会断开引用,这些都是联调时最值得先排查的点。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

js
// 文件:capacity.js
const BASE = 1024;
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];

function bytesToUnit(bytes, targetUnit) {
  if (!Number.isFinite(bytes)) {
    throw new TypeError('bytes 必须是数字');
  }
  if (!Number.isInteger(targetUnit) || targetUnit < 0 || targetUnit > 4) {
    throw new RangeError('targetUnit 必须是 0~4');
  }
  return bytes / Math.pow(BASE, targetUnit);
}

module.exports = {
  bytesToUnit,
  UNITS
};
js
// 文件:index.js
const { bytesToUnit, UNITS } = require('./capacity');

console.log(bytesToUnit(1024, 1), UNITS[1]);          // 1 'KB'
console.log(bytesToUnit(1024 * 1024, 2), UNITS[2]);   // 1 'MB'
console.log(bytesToUnit(1024, 0), UNITS[0]);          // 1024 'B'

要点:CommonJS 自定义模块用 require('./capacity') 引入,必须以 ./ 或 ../ 开头,否则会被当作内置模块查找;module.exports 默认是空对象,这里暴露成对象后可用解构接收;扩展名 .js 可省略;require 多次不会重复执行模块代码,后面拿的是缓存结果。易错点:不要写 exports = { bytesToUnit },这样会断开 exports 与 module.exports 的引用;应写 module.exports = { ... } 或 exports.bytesToUnit = ...。

js
// 文件:capacity.mjs
const BASE = 1024;
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];

export function bytesToUnit(bytes, targetUnit) {
  if (!Number.isFinite(bytes)) {
    throw new TypeError('bytes 必须是数字');
  }
  if (!Number.isInteger(targetUnit) || targetUnit < 0 || targetUnit > 4) {
    throw new RangeError('targetUnit 必须是 0~4');
  }
  return bytes / Math.pow(BASE, targetUnit);
}

export { UNITS };
js
// 文件:index.mjs
import * as capacity from './capacity.mjs';

console.log(capacity.bytesToUnit(1024, 1), capacity.UNITS[1]);          // 1 'KB'
console.log(capacity.bytesToUnit(1024 * 1024, 2), capacity.UNITS[2]);   // 1 'MB'

要点:ES6 模块用 export 暴露、import 导入;import * as capacity 会把模块暴露的数据整体拿到一个对象里,类似 CommonJS 的整体导出;ES6 导入模块地址也要以 ./ 开头;转写稿中明确 ES6 导入时扩展名不能省略,所以这里写 ./capacity.mjs。易错点:如果文件是 .js 且使用 import/export,需要 .mjs 扩展名或在 package.json 中设置 "type": "module";import { bytesToUnit: fn } 这种“解构式改名”不行,要写 import { bytesToUnit as fn };export default 只能写一次。

js
// 文件:capacity-module/package.json
{
  "name": "capacity-module",
  "main": "./index.js"
}
js
// 文件:capacity-module/index.js
const BASE = 1024;

function bytesToUnit(bytes, targetUnit) {
  if (!Number.isInteger(targetUnit) || targetUnit < 0 || targetUnit > 4) {
    throw new RangeError('targetUnit 必须是 0~4');
  }
  return bytes / Math.pow(BASE, targetUnit);
}

module.exports = { bytesToUnit };
js
// 文件:app.js
const { bytesToUnit } = require('./capacity-module');

console.log(bytesToUnit(1024 * 1024, 2)); // 1

要点:整个目录也可以作为模块,require('./capacity-module') 会加载该目录下 package.json 中 main 指定的入口文件;如果没有 main,默认找目录下的 index.js;如果既没有入口配置也没有 index.js,会报找不到模块。易错点:require('./capacity-module') 仍然必须以 ./ 开头;main 路径要写对;转写稿中的查找顺序是:先找 .js,再找 .json,最后才找目录。

常见错误 ​

  1. 现象:require('01-mod') 报找不到模块。
    原因:自定义模块未以 ./ 或 ../ 开头,被当作内置模块查找。
    修法:改为 require('./01-mod') 或正确相对路径。

  2. 现象:require('./mod') 得到 {},调用不到模块内变量/方法。
    原因:模块内没有暴露数据,CommonJS 默认暴露空对象。
    修法:使用 module.exports = ...,或给 module.exports / exports 添加属性。

  3. 现象:模块里写了 exports = fn,入口仍拿到空对象。
    原因:exports 只是 module.exports 的初始别名,重新赋值会断开引用。
    修法:改为 module.exports = fn;若用 exports,只添加属性,如 exports.fn = fn。

  4. 现象:多次 require('./mod'),以为模块代码会执行多次。
    原因:CommonJS 模块首次加载后走缓存。
    修法:把需要重复执行逻辑放到导出函数中,由调用方显式调用。

  5. 现象:.js 文件中直接写 import / export 运行报错。
    原因:Node 默认按 CommonJS 解析 .js,未启用 ES 模块。
    修法:改用 .mjs,或在 package.json 中设置 "type": "module"。

  6. 现象:ES6 模块中写了多个 export default 报语法错误。
    原因:export default 只能出现一次。
    修法:单个默认导出用 export default;多个数据改用 export const/function 等具名导出。

  7. 现象:ES6 具名导入时随便改变量名,运行报错。
    原因:具名导入必须匹配模块导出的名字。
    修法:按导出名导入;需要改名时用 import { data as d } from './mod.js'。

  8. 现象:ES6 导入自定义模块省略 .js 扩展名,报找不到模块。
    原因:ES 模块自定义路径通常要求完整扩展名。
    修法:写完整路径,如 import { fn } from './modules/01-mod.js'。

  9. 现象:require('./dir') 或导入目录模块报错。
    原因:目录下既无 package.json 的 main 入口,也无默认 index.js。
    修法:补 package.json 的 main,或创建 index.js 作为入口。

练习 ​

  1. 容量单位转换函数:实现 formatBytes(bytes, targetUnit),targetUnit 为 0/1/2/3/4 时分别返回字节、KB、MB、GB、TB;处理非法参数与小数精度,并写 3 组测试用例。

  2. CommonJS 封装:把第 1 题封装为 format-bytes.cjs,分别用“单个函数导出”和“对象属性导出”两种方式实现,并在入口文件中 require 后调用验证。

  3. ES6 封装:把第 1 题封装为 format-bytes.js,使用 export default 导出;另建 units.js,用具名导出单位表与转换函数,在入口中分别用默认导入、具名导入和 import * as 导入验证。

  4. 目录模块练习:创建 utils/ 目录,内含 index.js 和 package.json,配置入口后分别用 CommonJS 和 ES6 方式导入该目录模块,观察并记录差异。

  5. 排错练习:给出三段错误代码:exports = fn、.js 中直接 import 未配置 type、ES6 导入省略 .js。分别说明报错现象、原因和最小修改方案。

原视频对照 ​

#视频时长
1CommonJS模块规范语法-模块暴露数据规则0111:18
2CommonJS模块规范语法-模块暴露数据规则0229:12
3CommonJS模块规范语法-require引入模块相关规则08:26
4CommonJS模块规范语法-整个目录作为模块09:55
5ES模块规范-导入内置模块12:28
6ES模块规范-自定义模块29:01

小结 ​

  1. CommonJS 自定义模块必须写相对路径,./ / ../ 是区分内置模块的关键。
  2. CommonJS 未暴露数据时入口得到空对象;模块代码首次加载后走缓存。
  3. module.exports 才是最终暴露值,exports 只能添加属性,不能整体重新赋值。
  4. ES6 模块使用 import / export 关键字,运行环境需 .mjs 或 "type": "module"。
  5. export default 只能一次;具名导出可多个,导入名必须匹配,必要时用 as 改名。
  6. CommonJS 对扩展名、JSON、目录入口更宽松;ES6 自定义模块路径更严格,目录模块也需明确入口配置。
  7. 封装作业时先确定模块对外 API,再选择 CommonJS 或 ES6 规范,入口文件只消费暴露接口。