前端打工人救命稻草:Node.js模块导入导出避坑指南(血泪经验)

前端打工人救命稻草:Node.js模块导入导出避坑指南(血泪经验)

开篇先唠两句

兄弟,先放下你手里的咖啡,听我说两句。

你是不是也有过这种经历?代码看着明明一模一样,复制粘贴过来,跑起来就报错,报错信息还云山雾罩的,什么"Cannot find module"啊,什么"exports is not defined"啊,看得你直挠头。然后就开始疯狂百度,Stack Overflow翻了个底朝天,最后发现就是个导入导出的问题,当场就想把键盘砸了。

我懂,我真的懂。当年我刚入行的时候,被requireimport这俩货折磨得死去活来。有回项目急着上线,我就因为混用了CommonJS和ESM,本地跑得好好的,一上服务器就崩,凌晨两点还在公司加班改代码,那滋味,啧啧,这辈子不想再来第二次。

所以今天这篇,我把这些年踩过的坑、流过的泪、掉过的头发,全都给你抖落出来。不整那些虚头巴脑的理论,就讲人话,就讲怎么在实际项目里不翻车。看完这篇,不敢说让你成为模块大师,但至少能让你少加班两小时,早点回家陪对象打游戏,不香吗?

好,废话不多说,咱们直接开整。


模块这玩意儿到底是啥

先别急着写代码,咱们先搞明白"模块"到底是个啥东西。

说白了,模块就是把一大坨代码拆成一个个小块块,每个块块管自己的一亩三分地。就像你家里的工具箱,锤子放一格,螺丝刀放一格,扳手再放一格,用的时候打开就能找到,不会乱七八糟堆在一起。

在Node.js的世界里,每个文件默认就是一个模块。这个设计真的挺香的,因为这意味着你在文件A里定义的变量,不会跑到文件B里去捣乱。想想浏览器端那个年代,全局变量满天飞,命名冲突打到头破血流,Node.js这个设计简直就是一股清流。

来,看个最简单的例子,你就明白了:

// 文件名叫 math.js
const PI = 3.14159;

function add(a, b) {
    return a + b;
}

function multiply(a, b) {
    return a * b;
}

// 把想让别人用的东西挂到 exports 上
module.exports = {
    add,
    multiply,
    PI: PI  // 也可以简写成 PI
};

然后你在另一个文件里这么用:

// app.js
const math = require('./math.js');

console.log(math.add(2, 3));      // 输出 5
console.log(math.PI);             // 输出 3.14159
console.log(PI);                  // 报错!PI is not defined

看到没?最后一行直接报错,因为PI被关在math.js这个模块的小黑屋里了,外面根本访问不到。这就是模块的妙处——封装性,变量不会到处污染,各回各家,各找各妈。

而且Node.js的模块系统还有个好处,就是缓存机制。同一个模块被require多次,实际上只执行一次,后面都是拿缓存。这个特性后面我们会讲到,用好了是性能优化,用不好就是诡异bug。


CommonJS和ESM这俩兄弟的恩怨情仇

好,现在进入核心战场。Node.js的模块系统其实有两套,一套是CommonJS,一套是ESM(ECMAScript Modules)。这俩货就像同父异母的兄弟,看着有点像,但脾气秉性完全不同,硬凑在一起准出事。

CommonJS:Node.js的亲儿子

CommonJS是Node.js最早采用的模块规范,可以说是看着Node.js长大的。它的语法简单粗暴,就俩关键字:require用来导入,module.exports用来导出。

// 导出 - utils.js
const fs = require('fs');
const path = require('path');

function readConfig(filePath) {
    const fullPath = path.resolve(filePath);
    const content = fs.readFileSync(fullPath, 'utf-8');
    return JSON.parse(content);
}

function writeLog(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
}

// 导出多个东西
module.exports = {
    readConfig,
    writeLog
};

// 也可以这样导出单个
// module.exports = readConfig;
// 导入 - app.js
const utils = require('./utils.js');
const { readConfig, writeLog } = require('./utils.js');  // 解构赋值也行

const config = readConfig('./config.json');
writeLog('系统启动成功');

CommonJS的特点是同步加载,也就是说,require语句执行的时候,代码会停下来等这个模块加载完。这在服务器端没啥问题,因为文件都在本地硬盘上,读取速度快。但如果在浏览器端这么搞,页面就会卡死,所以浏览器后来才搞出了AMD、CMD那些异步加载的方案。

ESM:JavaScript官方钦定的标准

ESM是ES6(ES2015)引入的官方模块标准,语法用的是importexport。这货才是JavaScript的亲儿子,未来的主流方向。

// 导出 - mathUtils.mjs 或者设置 type: "module"
// 具名导出(Named Export)
export const PI = 3.14159;
export const E = 2.71828;

export function circleArea(radius) {
    return PI * radius * radius;
}

export function circleCircumference(radius) {
    return 2 * PI * radius;
}

// 默认导出(Default Export)- 一个模块只能有一个
export default function greet(name) {
    return `Hello, ${name}! Welcome to the matrix.`;
}
// 导入 - main.mjs
// 导入默认导出,名字可以随便起
import greet from './mathUtils.mjs';

// 导入具名导出,必须用花括号,名字要对上
import { PI, circleArea } from './mathUtils.mjs';

// 一次性导入所有,用 * 号
import * as math from './mathUtils.mjs';

// 同时导入默认和具名
import greet, { PI, E } from './mathUtils.mjs';

console.log(greet('张三'));           // Hello, 张三! Welcome to the matrix.
console.log(circleArea(5));           // 78.53975
console.log(math.circleCircumference(5));  // 31.4159

看到没,ESM的语法更灵活,可以按需导入,不用把整个模块都引进来。而且ESM是静态分析的,编译的时候就能确定依赖关系,这对Tree Shaking(树摇优化)特别友好,后面讲性能的时候会细说。

2026年了,该选哪个?

我的建议是:新项目直接上ESM,老项目慢慢迁移。CommonJS虽然现在还能用,但毕竟是过渡方案,未来肯定是ESM的天下。而且很多新出的库都已经只提供ESM版本了,你再用CommonJS就享受不到最新的轮子。

不过现实很骨感,很多老项目还是CommonJS的天下,npm上大部分包也是CommonJS格式。所以作为前端打工人,两套你都得会,还得知道怎么让它们和平共处,这个后面会讲。


require和module.exports怎么玩

既然CommonJS还没死透,咱们就先把它的玩法摸清楚,免得在老项目里翻车。

require的本质

require其实是个函数,接收一个路径参数,返回那个模块导出的东西。路径可以是:

  1. 相对路径./开头,表示当前目录;../表示上一级目录
  2. 绝对路径/开头,表示系统根目录(很少用)
  3. 模块名:直接写包名,比如expresslodash,Node.js会去node_modules里找
// 各种require姿势
const fs = require('fs');                    // 内置模块,直接写名字
const express = require('express');          // 第三方包,从node_modules找
const myUtil = require('./utils/helper');    // 相对路径,可以省略.js后缀
const config = require('../config/db.json'); // 甚至可以require JSON文件!

看到最后一行没?Node.js的require可以直接加载JSON文件,自动帮你JSON.parse了,这个特性有时候挺方便的:

// config.json
{
    "port": 3000,
    "database": {
        "host": "localhost",
        "user": "root",
        "password": "secret123"
    }
}

// app.js
const config = require('./config.json');
console.log(config.database.host);  // 直接就能用,不用手动parse

module.exports和exports的关系

这里有个大坑,我当年踩过三次,每次都想抽自己耳光。

很多人看到exportsmodule.exports就懵,这俩到底啥关系?简单来说:

  • module.exports是真正的导出对象,最后返回给外界的是它
  • exports只是module.exports的一个引用(快捷方式),一开始它俩指向同一个对象
// 这样写没问题,exports和module.exports指向同一个对象
exports.foo = 'bar';
exports.hello = function() {
    console.log('world');
};

// 上面的代码等价于:
module.exports.foo = 'bar';
module.exports.hello = function() {
    console.log('world');
};

但是!如果你直接给exports赋值,就会切断和module.exports的关系,然后导出的就是空对象,你的代码就白写了:

// 错误示范!这样写会翻车
exports = {
    foo: 'bar',
    hello: function() {
        console.log('world');
    }
};

// 另一个文件里
const myModule = require('./myModule');
console.log(myModule);  // 输出 {},空空如也,气不气?

为什么?因为exports = {...}这行代码,让exports指向了一个新的对象,而module.exports还是指向原来的空对象。最后require返回的是module.exports,所以拿到的是个空对象。

正确姿势是始终操作module.exports,或者给exports添加属性而不是重新赋值:

// 正确姿势1:直接操作module.exports
module.exports = {
    foo: 'bar',
    hello: function() {
        console.log('world');
    }
};

// 正确姿势2:给exports添加属性(不重新赋值)
exports.foo = 'bar';
exports.hello = () => console.log('world');

require的加载机制

Node.js找模块是有套路的,按这个顺序来:

  1. 如果是内置模块(fs、path、http等),直接返回
  2. 如果是以./../开头,按相对路径找文件
  3. 如果是以/开头,按绝对路径找(基本不用)
  4. 如果是其他情况,认为是第三方模块,从node_modules

找文件的时候,后缀名可以省略,Node.js会按这个顺序尝试:.js.json.node(编译后的二进制模块)

// 假设目录结构:
// project/
//   ├── app.js
//   └── utils/
//       ├── helper.js
//       └── config.json

// 在app.js里
const helper = require('./utils/helper');      // 找helper.js
const config = require('./utils/config');      // 找config.json

还有个细节:require是同步的,而且会缓存结果。第一次加载模块时会执行整个文件,后面再require同一个路径,直接返回缓存,不会重新执行。

// counter.js
let count = 0;
module.exports = {
    increment: () => ++count,
    getCount: () => count
};

// app.js
const counter1 = require('./counter');
const counter2 = require('./counter');  // 拿到的是同一个对象

console.log(counter1.increment());  // 1
console.log(counter2.increment());  // 2,因为counter1和counter2是同一个引用
console.log(counter1.getCount());   // 2

这个缓存机制有时候会带来意想不到的bug,特别是模块里有副作用(比如连接数据库、初始化配置)的时候,得留个心眼。


import和export的正确打开方式

好了,说完老一辈的CommonJS,咱们来聊聊新贵ESM。这货语法更现代,功能更强大,但坑也不少。

具名导出 vs 默认导出

ESM有两种导出方式,用法完全不同,别搞混了。

具名导出(Named Export):可以导出多个,导入时必须用花括号,名字要对上号

// api.js
// 导出常量
export const API_BASE_URL = 'https://api.example.com';
export const TIMEOUT = 5000;

// 导出函数
export async function fetchUser(id) {
    const response = await fetch(`${API_BASE_URL}/users/${id}`, {
        timeout: TIMEOUT
    });
    if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
}

export async function fetchPosts(userId) {
    const response = await fetch(`${API_BASE_URL}/users/${userId}/posts`, {
        timeout: TIMEOUT
    });
    return response.json();
}

// 导出类
export class ApiError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.name = 'ApiError';
        this.statusCode = statusCode;
    }
}
// app.js
// 具名导入必须用花括号,名字必须一致
import { fetchUser, fetchPosts, API_BASE_URL } from './api.js';

// 也可以用as起别名,解决命名冲突
import { fetchUser as getUserInfo } from './api.js';

// 导入所有,起一个命名空间
import * as api from './api.js';
console.log(api.API_BASE_URL);

默认导出(Default Export):一个模块只能有一个,导入时可以随便起名字

// logger.js
// 默认导出一个类
export default class Logger {
    constructor(prefix = '[App]') {
        this.prefix = prefix;
    }

    log(message) {
        console.log(`${this.prefix} ${new Date().toLocaleTimeString()}: ${message}`);
    }

    error(message) {
        console.error(`${this.prefix} ERROR ${new Date().toLocaleTimeString()}: ${message}`);
    }

    warn(message) {
        console.warn(`${this.prefix} WARN ${new Date().toLocaleTimeString()}: ${message}`);
    }
}

// 也可以默认导出函数
// export default function() { ... }

// 或者导出对象
// export default { ... }
// main.js
// 默认导入,名字随便起
import Logger from './logger.js';
import MyLogger from './logger.js';  // 也可以叫MyLogger,都行

const logger = new Logger('[Server]');
logger.log('服务器启动成功');  // [Server] 14:30:25: 服务器启动成功

混合导出:一个模块可以同时有默认导出和具名导出,但一般不推荐这么干,容易让人晕:

// utils.js
export const VERSION = '1.0.0';

export function helper() {
    return 'help';
}

// 默认导出
export default function main() {
    return 'main function';
}
// 混合导入
import mainFunc, { VERSION, helper } from './utils.js';

动态导入:按需加载的神器

ESM有个超赞的特性叫动态导入(Dynamic Import),用import()函数,返回一个Promise。这货最大的好处是可以按需加载,不用一开始就全加载进来,对性能优化特别有帮助。

// 假设我们有个很大的图表库,只在特定页面用
// 传统写法:一开始就加载,浪费资源
// import { Chart } from './heavy-chart-library.js';  // 不管用不用,先加载了再说

// 动态导入:用到的时候再加载
async function renderChart(data) {
    // 点击按钮后才加载这个模块
    const { Chart } = await import('./heavy-chart-library.js');
    
    const chart = new Chart('#chart-container');
    chart.render(data);
    
    return chart;
}

// 按钮点击事件
document.getElementById('show-chart-btn').addEventListener('click', async () => {
    try {
        // 这时候才开始下载和解析 heavy-chart-library.js
        await renderChart(someData);
        console.log('图表渲染成功');
    } catch (error) {
        console.error('加载图表库失败:', error);
    }
});

动态导入还能条件加载,根据不同的环境加载不同的模块:

// 根据环境变量加载不同的配置
async function loadConfig() {
    if (process.env.NODE_ENV === 'development') {
        const config = await import('./config.dev.js');
        return config.default;
    } else {
        const config = await import('./config.prod.js');
        return config.default;
    }
}

// 使用
const config = await loadConfig();
console.log('当前配置:', config);

甚至能并行加载多个模块,用Promise.all

// 同时加载多个大模块,比一个个加载快多了
async function initApp() {
    const [{ default: Chart }, { default: Map }, { utils }] = await Promise.all([
        import('./modules/chart.js'),
        import('./modules/map.js'),
        import('./modules/utils.js')
    ]);
    
    // 三个模块都加载完了,初始化应用
    const app = new App(Chart, Map, utils);
    app.start();
}

ESM的严格模式

ESM默认就是严格模式(strict mode),不用你手动写'use strict',而且比CommonJS的严格模式更严格:

  1. 变量必须先声明后使用,不能偷偷搞个全局变量
  2. this在模块顶层是undefined,不是globalwindow
  3. 不能用with语句(这货本来也没人用)
  4. 不能用arguments.calleearguments.caller
// ESM模块里,这些会报错
foo = 'bar';  // ReferenceError: foo is not defined,因为没有声明

console.log(this);  // undefined,不是globalThis

这点要注意,特别是从CommonJS迁移过来的代码,如果里面有用到this的,可能会翻车。


这俩混用会发生什么惨案

好了,前面分别讲了CommonJS和ESM的玩法,现在进入最刺激的部分:如果把它们混用,会发生什么?

答案是:大概率直接报错,小概率诡异bug

默认情况下的冲突

Node.js默认把.js文件当CommonJS处理。如果你在一个CommonJS文件里写import,或者在一个ESM文件里写require,直接给你报错:

// 假设这是普通的.js文件(CommonJS默认)
import { foo } from './bar.js';  // SyntaxError: Cannot use import statement outside a module
// 假设这是ESM模块(.mjs或type: "module")
const foo = require('./bar.js');  // ReferenceError: require is not defined in ES module scope

看到没,两边互相不认识对方的语法,直接罢工。

怎么指定模块类型

Node.js提供了几种方式来指定文件是什么模块类型:

1. 文件扩展名

  • .mjs:强制是ESM模块
  • .cjs:强制是CommonJS模块
  • .js:看package.json里的type字段,或者默认是CommonJS
# 目录结构
project/
  ├── common.cjs      # CommonJS,不管package.json怎么设
  ├── modern.mjs      # ESM,不管package.json怎么设
  └── legacy.js       # 看package.json的type字段

2. package.json里的type字段

在项目的package.json里加一行,就能把所有.js文件变成ESM:

{
    "name": "my-project",
    "version": "1.0.0",
    "type": "module",  // 加上这行,所有.js文件默认是ESM
    "main": "index.js"
}

这时候如果你想用CommonJS,就得把文件改成.cjs后缀:

// 在 type: "module" 的项目里
// 这个文件叫 legacy.cjs,所以是CommonJS
const fs = require('fs');
module.exports = { ... };

3. 在ESM里调用CommonJS

有时候你不得不在ESM里用CommonJS的模块(比如很多npm包还是CommonJS格式),这时候可以用module.createRequire

// 在ESM模块里(.mjs或type: "module")
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname } from 'path';

// ESM里没有__dirname,得自己造
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// 创建一个require函数
const require = createRequire(import.meta.url);

// 现在可以用require了!
const legacyPackage = require('some-commonjs-package');
const myConfig = require('./config.json');

console.log(legacyPackage);

互操作的坑

即使你能把两种模块混起来用,还有很多细节坑:

1. ESM导入CommonJS

ESM可以用import加载CommonJS模块,但CommonJS的module.exports会被当成ESM的默认导出

// commonjs-module.js
module.exports = {
    foo: 'bar',
    baz: 42
};

// 在ESM里导入
import commonModule from './commonjs-module.js';  // 注意,是默认导入

console.log(commonModule);  // { foo: 'bar', baz: 42 }

// 这样写会报错,因为CommonJS没有具名导出
// import { foo } from './commonjs-module.js';  // SyntaxError

如果你非要用具名导入的形式,得用default这个关键字:

import { default as commonModule } from './commonjs-module.js';
// 或者
import * as commonModule from './commonjs-module.js';

2. CommonJS导入ESM

CommonJS不能直接require一个ESM模块,因为ESM是异步加载的,而require是同步的。但你可以用动态import()(返回Promise):

// commonjs-file.js
// 这样写会报错:Error [ERR_REQUIRE_ESM]: require() of ES Module ...
// const esmModule = require('./esm-module.mjs');

// 正确姿势:用动态import,因为是异步的,得放在async函数里
async function loadEsm() {
    const esmModule = await import('./esm-module.mjs');
    console.log(esmModule);
}

loadEsm();

3. 循环依赖的坑

CommonJS和ESM处理循环依赖的方式不同,混用的时候更容易出问题。这个后面专门讲。

我的建议

除非万不得已,别混用!一个项目统一用一种规范,要么全CommonJS,要么全ESM。现在新项目就直接上ESM,老项目慢慢迁移。实在要混用(比如渐进式迁移),就用上面说的那些方法,但做好被坑的心理准备。


实际项目里我是怎么组织的

理论讲了一堆,现在说说实战。模块怎么组织,直接决定你项目的可维护性。我这些年摸索出一套还算顺手的结构,分享给你。

按功能拆分,别按类型拆分

我见过很多新手喜欢这么组织:

# 反模式,别这么干
project/
  ├── controllers/
  │   ├── userController.js
  │   └── orderController.js
  ├── models/
  │   ├── userModel.js
  │   └── orderModel.js
  ├── routes/
  │   ├── userRoutes.js
  │   └── orderRoutes.js
  └── services/
      ├── userService.js
      └── orderService.js

看起来挺整齐,但实际开发的时候,改一个功能得在五个目录之间跳来跳去,头都大了。而且userControllerorderController之间可能根本没关系,硬凑在一起没意义。

推荐按功能模块拆分

project/
  ├── modules/
  │   ├── user/
  │   │   ├── index.js          # 统一导出
  │   │   ├── controller.js
  │   │   ├── service.js
  │   │   ├── model.js
  │   │   └── routes.js
  │   ├── order/
  │   │   ├── index.js
  │   │   ├── controller.js
  │   │   ├── service.js
  │   │   ├── model.js
  │   │   └── routes.js
  │   └── payment/
  │       └── ...
  ├── shared/
  │   ├── utils/                # 通用工具
  │   ├── constants/            # 常量
  │   └── middleware/           # 中间件
  └── app.js

这样每个功能模块内部自己管自己,对外只暴露必要的接口。比如用户模块的index.js

// modules/user/index.js
import { UserController } from './controller.js';
import { UserService } from './service.js';
import { UserModel } from './model.js';
import { userRoutes } from './routes.js';

// 统一导出,外部只关心这个文件
export {
    UserController,
    UserService,
    UserModel,
    userRoutes
};

// 也可以默认导出配置好的路由
export default userRoutes;

外部使用的时候很清爽:

// app.js
import express from 'express';
import { userRoutes } from './modules/user/index.js';
import { orderRoutes } from './modules/order/index.js';

const app = express();

app.use('/api/users', userRoutes);
app.use('/api/orders', orderRoutes);

工具函数和常量的组织

通用的工具函数和常量,单独放shared目录里,但别一股脑塞一个文件:

// shared/utils/date.js
export function formatDate(date, format = 'YYYY-MM-DD') {
    const d = new Date(date);
    const year = d.getFullYear();
    const month = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    
    return format
        .replace('YYYY', year)
        .replace('MM', month)
        .replace('DD', day);
}

export function addDays(date, days) {
    const result = new Date(date);
    result.setDate(result.getDate() + days);
    return result;
}
// shared/utils/validator.js
export const isEmail = (email) => {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};

export const isPhone = (phone) => {
    return /^1[3-9]\d{9}$/.test(phone);
};
// shared/utils/index.js
// 统一入口,方便导入
export * from './date.js';
export * from './validator.js';
export * from './crypto.js';
// ...

使用的时候:

import { formatDate, isEmail } from '../shared/utils/index.js';
// 或者如果配置了路径别名
import { formatDate } from '@/utils';

别过度拆分

模块拆分是为了好维护,但拆得太碎也烦人。我见过一个项目,十个文件九个是空的,或者就导出一行代码,这种就属于过度设计。

判断标准:如果一个模块只有不到20行代码,而且只被一个地方引用,那就没必要单独拆出来。如果一个模块被三个以上地方引用,或者代码超过200行,那就该考虑拆分了。

还有,别搞太深的嵌套../../../../../../utils/helper这种路径,看着就头大。一般最多三层目录,再深就要考虑重构了。


循环依赖这个鬼东西怎么破

循环依赖(Circular Dependency)是模块系统里的经典难题,A依赖B,B又依赖A,直接死循环给你看。而且报错信息往往莫名其妙,让你怀疑人生。

什么是循环依赖

看个简单的例子:

// a.js
const b = require('./b.js');
console.log('a.js 加载中,b.foo =', b.foo);
module.exports = {
    foo: 'from A'
};

// b.js
const a = require('./a.js');
console.log('b.js 加载中,a.foo =', a.foo);
module.exports = {
    foo: 'from B'
};

// main.js
const a = require('./a.js');
console.log('最终结果:', a.foo);

你猜输出什么?不是报错,而是:

b.js 加载中,a.foo = undefined
a.js 加载中,b.foo = from B
最终结果: from A

看到没,a.foob.js里是undefined!因为当b.js执行的时候,a.js还没执行完,module.exports还是空对象。

怎么发现和解决

1. 用工具检测

可以用madge这个工具检测循环依赖:

npm install -g madge
madge --circular ./src

2. 重构代码结构

最常见的解决方案是把公共部分抽出来,让A和B都依赖C,而不是A和B互相依赖:

// 重构前:a.js <-> b.js 互相依赖

// 重构后:都依赖 c.js
// c.js - 放公共的常量或工具
export const SHARED_CONFIG = {
    timeout: 5000,
    retries: 3
};

// a.js
import { SHARED_CONFIG } from './c.js';
import * as b from './b.js';  // 现在可以安全导入b了

// b.js  
import { SHARED_CONFIG } from './c.js';
// 不再直接依赖a.js

3. 延迟加载(Lazy Loading)

如果循环依赖难以避免,可以用延迟加载,在函数内部再require/import:

// a.js
// 不在顶部导入,而是在函数内部导入
let b;

function getB() {
    if (!b) {
        b = require('./b.js');  // CommonJS延迟加载
        // 或者 ESM: b = await import('./b.js');
    }
    return b;
}

function doSomething() {
    const bModule = getB();
    return bModule.helper();
}

module.exports = { doSomething };
// b.js
const a = require('./a.js');  // 正常导入a

function helper() {
    return a.doSomething();  // 调用a的方法
}

module.exports = { helper };

这样当b.js加载的时候,a.js已经加载完了(虽然还没执行完,但module.exports已经存在),不会拿到空对象。

4. 依赖注入(Dependency Injection)

更优雅的方案是用依赖注入,把依赖作为参数传进去,而不是硬编码在文件里:

// serviceA.js
class ServiceA {
    constructor(serviceB) {
        this.serviceB = serviceB;
    }
    
    async process() {
        const data = await this.serviceB.fetchData();
        return this.transform(data);
    }
    
    transform(data) {
        return data.map(item => item.toUpperCase());
    }
}

export { ServiceA };
// serviceB.js
class ServiceB {
    constructor(serviceA) {
        this.serviceA = serviceA;
    }
    
    async fetchData() {
        return ['apple', 'banana', 'cherry'];
    }
    
    async complexProcess() {
        // 需要用到serviceA的方法
        return this.serviceA.transform(['hello', 'world']);
    }
}

export { ServiceB };
// container.js - 统一组装
import { ServiceA } from './serviceA.js';
import { ServiceB } from './serviceB.js';

// 先实例化,再互相注入
const serviceA = new ServiceA();
const serviceB = new ServiceB();

// 手动注入依赖(或者用IoC容器)
serviceA.serviceB = serviceB;
serviceB.serviceA = serviceA;

export { serviceA, serviceB };

这样两个服务类本身没有直接依赖,依赖关系在容器里组装,彻底消灭循环依赖。


模块找不到报错怎么排查

"Cannot find module"估计是Node.js开发者最常见的报错之一了。遇到这种报错别慌,按这个 checklist 排查:

1. 路径问题(占80%)

最常见的就是路径写错了,特别是相对路径的./../容易搞混。

// 假设目录结构:
// project/
//   ├── src/
//   │   ├── app.js
//   │   └── utils/
//   │       └── helper.js
//   └── config/
//       └── database.js

// 在 app.js 里引入 helper.js
const helper = require('./utils/helper');  // 正确,./表示当前目录src

// 在 helper.js 里引入 database.js
const db = require('../../config/database');  // 正确,../到src,再../到project

// 常见错误:
const db = require('../config/database');   // 错误!只回到src,找不到config
const db = require('./config/database');    // 错误!./表示utils目录

小技巧:在VS Code里,按住Ctrl(Mac是Cmd)点击路径,能跳转就是对的,跳不了就是错的。

2. 扩展名问题

Node.js的require可以省略.js,但ESM的import在Node.js里必须写全扩展名(除非你用打包工具):

// CommonJS,可以省略
const utils = require('./utils');

// ESM,必须写.js(Node.js原生支持时)
import utils from './utils.js';  // 正确
import utils from './utils';     // 错误!Cannot find module

这个坑很多人踩过,从CommonJS迁移到ESM的时候特别容易忘。

3. 包没安装或装错了位置

# 检查 node_modules 里有没有这个包
ls node_modules | grep lodash

# 如果没有,安装
npm install lodash

# 或者你可能装成开发依赖了,生产环境没有
npm install --save-prod lodash  # 不是 --save-dev

4. package.json配置问题

检查package.json里的mainexports字段,看看入口文件对不对:

{
    "name": "my-package",
    "main": "index.js",  // 默认入口
    "exports": {         // 更精细的控制(Node.js 12.7+)
        ".": "./index.js",
        "./utils": "./lib/utils.js"
    }
}

5. 缓存问题

有时候你明明装了包,但还是报错,可能是缓存作祟:

# 清除Node.js模块缓存(简单粗暴但有效)
rm -rf node_modules
rm package-lock.json
npm cache clean --force
npm install

6. 环境变量问题

检查NODE_PATH环境变量,如果设置了这个,Node.js会从这里找模块:

echo $NODE_PATH

# 临时设置(不推荐长期用,容易乱)
export NODE_PATH=/path/to/global/modules

7. 拼写错误

别笑,真的有很多人把lodash写成loadsh,把express写成expres,检查三遍拼写。


性能优化有点东西的

模块系统用得不好,也会成为性能瓶颈。特别是大型项目,模块成千上万,加载时间能差出好几倍。

1. 避免在循环里require

// 糟糕!每次循环都require,重复加载
for (let i = 0; i < 1000; i++) {
    const utils = require('./utils');  // 重复1000次,虽然会走缓存,但还是有开销
    utils.doSomething();
}

// 正确:提到循环外面
const utils = require('./utils');
for (let i = 0; i < 1000; i++) {
    utils.doSomething();
}

虽然Node.js有缓存,但require本身还是有解析路径、查找文件的开销,能提外面就提外面。

2. 动态导入按需加载

前面讲过的import(),对于大模块特别有用:

// 假设 pdf-lib 是个很大的PDF处理库,只有导出功能用得到
// 不要一开始就加载
// import { PDFDocument } from 'pdf-lib';  // 启动时就加载,慢

// 用到的时候再加载
async function exportToPDF(data) {
    const { PDFDocument } = await import('pdf-lib');  // 点击导出按钮时才加载
    const pdf = await PDFDocument.create();
    // ... 处理逻辑
    return pdf.save();
}

3. 只导入需要的部分

很多第三方库支持按需导入,别一股脑全引进来:

// 糟糕!把整个lodash都引进来了,几百KB
import _ from 'lodash';
_.debounce(fn, 300);

// 正确!只导入debounce,几KB
import debounce from 'lodash/debounce';
// 或者用 lodash-es
import { debounce } from 'lodash-es';

4. 启用Tree Shaking

如果你用Webpack、Rollup这些打包工具,确保开启Tree Shaking,把没用到的代码摇掉:

// webpack.config.js
module.exports = {
    mode: 'production',  // 生产模式自动开启Tree Shaking
    optimization: {
        usedExports: true,  // 标记未使用的导出
        sideEffects: false  // 告诉webpack你的代码没有副作用,可以放心摇
    }
};

package.json里标记sideEffects

{
    "name": "my-lib",
    "sideEffects": false,  // 或者 ["*.css", "*.scss"] 如果有样式文件
    "exports": {
        ".": "./index.js"
    }
}

5. 预加载关键模块

对于启动时就必须用的核心模块,可以用--require预加载,或者调整代码顺序:

// 把核心依赖放文件最上面,优先加载
import express from 'express';      // 核心框架,先加载
import { connectDB } from './db';   // 数据库连接,次优先

// 延迟加载非核心模块
const setupSwagger = async () => {
    const { serve, setup } = await import('swagger-ui-express');
    // ...
};

6. 使用模块缓存

Node.js的模块缓存是自动的,但你可以利用它做更多事情。比如把昂贵的初始化操作放在模块顶层,利用缓存只执行一次:

// expensive-module.js
console.log('执行昂贵的初始化...');

const heavyData = (() => {
    // 这个函数只会在第一次require时执行
    const data = [];
    for (let i = 0; i < 1000000; i++) {
        data.push(computeExpensiveValue(i));
    }
    return data;
})();

function computeExpensiveValue(i) {
    return Math.pow(i, 2);
}

module.exports = {
    getData: () => heavyData,
    queryData: (index) => heavyData[index]
};
// app.js
const expensive = require('./expensive-module');
// 第一次require,执行初始化,可能耗时几百毫秒

// 其他地方再require,直接拿缓存,瞬间完成
const expensive2 = require('./expensive-module');
console.log(expensive === expensive2);  // true,同一个对象

几个让同事夸你的小技巧

最后分享几个在实际项目中提升幸福感的小技巧,用了之后同事都会夸你"这代码写得真舒服"。

1. 用路径别名,告别"点点点"

../../../utils/helper这种路径,看着就头大,而且一改目录结构就全得改。用路径别名解决:

Webpack配置

// webpack.config.js
const path = require('path');

module.exports = {
    resolve: {
        alias: {
            '@': path.resolve(__dirname, 'src'),
            '@utils': path.resolve(__dirname, 'src/shared/utils'),
            '@components': path.resolve(__dirname, 'src/components')
        }
    }
};

TypeScript配置(如果用TS):

// tsconfig.json
{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"],
            "@utils/*": ["src/shared/utils/*"]
        }
    }
}

使用

// 以前这么写,又丑又难维护
import { formatDate } from '../../../shared/utils/date';

// 现在这么写,清爽!
import { formatDate } from '@utils/date';
import UserProfile from '@components/UserProfile';

2. 统一导出风格,别各玩各的

一个项目里,有人用默认导出,有人用具名导出,有人混着用,导入的时候一会儿花括号一会儿不用,很烦。团队要约定统一风格:

推荐方案:公共API用具名导出,方便Tree Shaking;入口文件可以默认导出配置好的实例。

// utils/index.js - 统一入口
export * from './date.js';
export * from './validator.js';
export * from './crypto.js';

// 默认导出常用工具集合
import * as utils from './index.js';
export default utils;
// 使用方可以按需选择
import { formatDate } from '@utils';           // 具名导入,推荐
import utils from '@utils';                    // 默认导入,拿到全部

3. 写清楚文档,别让人猜

导出的函数是干嘛的?参数是什么类型?返回什么?别让人翻源码猜:

/**
 * 格式化日期为指定字符串格式
 * @param {Date|string|number} date - 要格式化的日期,可以是Date对象、时间戳或日期字符串
 * @param {string} [format='YYYY-MM-DD'] - 格式化模板,支持YYYY/MM/DD HH:mm:ss等
 * @returns {string} 格式化后的日期字符串
 * @throws {TypeError} 当date参数无效时抛出
 * @example
 * formatDate(new Date(), 'YYYY年MM月DD日'); // "2026年02月18日"
 * formatDate(1700000000000, 'HH:mm');       // "12:00"
 */
export function formatDate(date, format = 'YYYY-MM-DD') {
    // 实现代码...
}

配合VS Code的JSDoc插件,写代码的时候会有智能提示,爽歪歪。

4. 定期清理无用依赖

package.json里的依赖会膨胀,有些用了一次就忘了删,有些升级后旧版本没清。定期用工具检查:

# 检查未使用的依赖
npx depcheck

# 检查过期的依赖
npm outdated

# 自动更新(小心使用,先测试)
npx npm-check-updates -u

5. 模块预加载提升启动速度

对于大型应用,可以用Node.js的--experimental-loader或预加载脚本来优化启动:

// preload.js - 预加载常用模块
import express from 'express';
import mongoose from 'mongoose';
// 把核心模块先加载进内存

export { express, mongoose };
# 启动时预加载
node --import=./preload.js app.js

最后说点真心话

写到这儿,差不多把我这些年关于Node.js模块系统的血泪经验都倒出来了。说实话,模块系统看着简单,就importexport几个关键字,真用起来坑是真不少。

我当年刚学Node.js的时候,被exportsmodule.exports的区别折磨得死去活来,被循环依赖搞得凌晨三点还在调试,被CommonJS和ESM混用的问题逼得差点重构整个项目。那时候就想,要是有人能写篇接地气的文章,把这些坑都指出来,我能少掉多少头发啊。

所以现在写这篇,就是不想让你们再踩一遍我踩过的坑。当然,技术这东西更新快,说不定过两年又出新规范了,但至少这些基础原理是不会变的。

代码跑通了记得请我喝奶茶,开玩笑的,你能早点下班陪家人,或者有时间打两把游戏,我就觉得这文章没白写。

好了,就聊到这儿。如果这篇对你有帮助,转发给那个还在被模块问题折磨的同事,救人一命胜造七级浮屠,咱们下回见。

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐