1. 为什么你的 Node.js 项目总在 require 和 import 之间报错
刚接触 Node.js 模块化开发时,最容易卡住的地方不是写业务逻辑,而是文件之间的导入导出。你写了一个工具函数,想在其他文件里用,结果终端甩出一句Cannot use import statement outside a module,或者require is not defined in ES module scope,然后你就开始怀疑人生。
这个问题的根源在于 Node.js 同时存在两套模块系统:CommonJS 和 ES6(ECMAScript Modules,简称 ESM)。CommonJS 是 Node.js 早期的默认方案,用require()导入、module.exports导出;ES6 模块是 ECMAScript 标准方案,用import导入、export导出。两套系统语法不同、加载机制不同,甚至同一个.js文件在不同配置下会被当成不同的模块类型来解析。
适合阅读这篇文章的人:刚学 Node.js 后端开发、对模块化概念还比较模糊、遇到ERR_REQUIRE_ESM或Cannot use import statement outside a module不知道怎么排查的新手。我会用可复制的package.json配置和.mjs/.cjs文件骨架,把两种模块规范的导入导出方式讲清楚,再给出混用报错的逐步验证动作,让你一次跑通两种写法。
在开始之前,如果你需要调用大模型 API 来做一些模块化的 AI 功能测试,可以先用 TaoToken 的模型对话 快速验证接口返回格式,确认请求参数和响应结构没问题之后,再把它封装成 Node.js 模块集成到项目里。这样调试成本会低很多。
2. 先把环境配好:TaoToken 前置准备
在写模块化代码之前,如果你打算在项目里接入大模型能力(比如做一个翻译模块、摘要模块),需要先拿到 API Key。这一步很快,但后面所有请求都依赖它。
打开 TaoToken 控制台,注册或登录后进入 API Keys 管理页,创建一个新的 Key。建议按项目命名,比如node-module-demo,方便后续区分。
拿到 Key 之后,你的 Node.js 项目里可以通过环境变量读取,不要硬编码在源码里。下面是一个.env文件的示例:
TAOTOKEN_API_KEY=sk-你的实际Key TAOTOKEN_BASE_URL=https://taotoken.net/api然后在代码里用process.env.TAOTOKEN_API_KEY读取。这样无论你用 CommonJS 还是 ESM,都能统一管理配置。
如果你后续要做长期编码或者 Agent 类项目,可以考虑 Coding Plan,它更适合需要持续调用模型的场景。接入细节可以参考 接入文档。
3. CommonJS 与 ES6 模块的可复制配置骨架
3.1 package.json 里的 type 字段决定一切
Node.js 判断一个.js文件用哪种模块系统,核心看package.json里的type字段。默认不写type,或者写"type": "commonjs",.js文件就按 CommonJS 解析;写"type": "module",.js文件就按 ES6 模块解析。
下面是一个完整的package.json骨架,你可以直接复制:
{ "name": "node-module-demo", "version": "1.0.0", "type": "commonjs", "scripts": { "start:cjs": "node src/cjs/app.js", "start:mjs": "node src/mjs/app.mjs" }, "dependencies": { "dotenv": "^16.4.5" } }这里"type": "commonjs"表示默认走 CommonJS。如果你想整个项目走 ES6 模块,把值改成"module"即可。但更推荐的做法是:用文件扩展名来区分,.cjs强制 CommonJS,.mjs强制 ES6 模块,.js则跟随package.json的type字段。这样同一个项目里两种模块可以共存,不会互相干扰。
3.2 CommonJS 导出与导入写法
CommonJS 的导出核心是module.exports。它可以导出字符串、对象、函数,也可以挂载多个属性。
先看一个导出对象的例子:
// src/cjs/message.cjs module.exports = 'Hello World!';导入时用require():
// src/cjs/app.cjs const msg = require('./message.cjs'); console.log(msg); // 输出: Hello World!如果导出多个属性,可以这样写:
// src/cjs/person.cjs module.exports.name = 'Sachin'; module.exports.age = 20; module.exports.greet = function (name) { console.log('Welcome', name); };导入后直接访问属性或调用方法:
// src/cjs/use-person.cjs const person = require('./person.cjs'); console.log(person.name, ',', person.age); // Sachin , 20 person.greet('Sachin'); // Welcome Sachin注意一个容易踩的坑:exports和module.exports初始指向同一个对象,但如果你直接给exports赋值一个新对象,它就不再和module.exports关联了。所以导出单个函数或对象时,统一用module.exports = ...最稳妥。
// src/cjs/area.cjs function area(x) { return x * x; } module.exports = { area };// src/cjs/use-area.cjs const square = require('./area.cjs'); console.log(square.area(5)); // 253.3 ES6 模块导出与导入写法
ES6 模块用export导出,用import导入。它支持命名导出和默认导出两种方式。
命名导出:
// src/mjs/details.mjs export const FirstName = 'sachin'; export const LastName = 'sahara';导入时用花括号解构:
// src/mjs/app.mjs import { FirstName, LastName } from './details.mjs'; console.log(FirstName); // sachin默认导出:
// src/mjs/greeting.mjs export default function greet(name) { console.log('Welcome', name); }导入默认导出不需要花括号:
// src/mjs/use-greeting.mjs import greet from './greeting.mjs'; greet('Sachin'); // Welcome SachinES6 模块还支持export { msg1, msg2 }这种集中导出写法,适合一个文件里导出多个变量:
// src/mjs/messages.mjs const msg1 = 'Hello'; const msg2 = 'World'; export { msg1, msg2 };导入时同样用解构:
// src/mjs/use-messages.mjs import { msg1, msg2 } from './messages.mjs'; console.log(msg1, msg2); // Hello World3.4 两种模块系统的核心差异对照
| 对比项 | CommonJS | ES6 模块 |
|---|---|---|
| 导入语法 | require() | import |
| 导出语法 | module.exports/exports | export/export default |
| 文件扩展名 | .js(默认)/.cjs | .mjs/.js(type=module) |
| 加载时机 | 运行时同步加载 | 编译时静态分析 |
| 顶层 this | module.exports | undefined |
| 是否支持动态导入 | 原生支持 | 用import()动态导入 |
这张表建议收藏,遇到报错时先对照检查自己用的是哪套语法、文件扩展名和package.json配置是否匹配。
4. 验证请求:跑通两种模块并调用 API
4.1 先验证 CommonJS 模块能正常导入导出
在项目根目录执行:
node src/cjs/app.cjs如果输出Hello World!,说明 CommonJS 模块配置正确。再执行:
node src/cjs/use-person.cjs输出Sachin , 20和Welcome Sachin,说明属性导出和方法导出都没问题。
4.2 再验证 ES6 模块能正常导入导出
执行:
node src/mjs/app.mjs输出sachin,说明命名导出和导入正常。再执行:
node src/mjs/use-greeting.mjs输出Welcome Sachin,说明默认导出正常。
4.3 在模块中调用 TaoToken API 验证
下面写一个 CommonJS 版本的 API 调用模块,用来验证 Key 和接口是否可用:
// src/cjs/ai-client.cjs const https = require('https'); function chatCompletion(prompt) { return new Promise((resolve, reject) => { const data = JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }] }); const options = { hostname: 'taotoken.net', path: '/api/v1/chat/completions', method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TAOTOKEN_API_KEY}` } }; const req = https.request(options, (res) => { let body = ''; res.on('data', (chunk) => body += chunk); res.on('end', () => resolve(JSON.parse(body))); }); req.on('error', reject); req.write(data); req.end(); }); } module.exports = { chatCompletion };调用方式:
// src/cjs/test-api.cjs require('dotenv').config(); const { chatCompletion } = require('./ai-client.cjs'); chatCompletion('用一句话解释什么是模块化').then((res) => { console.log(res.choices[0].message.content); });执行node src/cjs/test-api.cjs,如果返回一段关于模块化的解释,说明 API Key 和网络请求都正常。
ES6 版本写法类似,只是导入导出语法不同:
// src/mjs/ai-client.mjs export async function chatCompletion(prompt) { const res = await fetch('https://taotoken.net/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TAOTOKEN_API_KEY}` }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }] }) }); return res.json(); }调用:
// src/mjs/test-api.mjs import 'dotenv/config'; import { chatCompletion } from './ai-client.mjs'; const res = await chatCompletion('用一句话解释什么是模块化'); console.log(res.choices[0].message.content);注意 ES6 模块里可以直接用顶层await,不需要包在 async 函数里。执行node src/mjs/test-api.mjs验证结果。
5. 本篇常见报错排查
5.1 Cannot use import statement outside a module
这个报错的意思是:你在一个被 Node.js 当成 CommonJS 的文件里写了import语句。排查步骤:
第一步,检查文件扩展名。如果是.js,去看package.json里type字段是不是commonjs或者没写。如果是,要么把文件改成.mjs,要么把type改成module。
第二步,检查package.json的位置。Node.js 会从当前文件向上逐级查找最近的package.json,如果子目录里有一个package.json覆盖了配置,也会导致解析行为不一致。
第三步,确认没有在 CommonJS 文件里混用import。CommonJS 只能用require()。
5.2 require is not defined in ES module scope
这个报错反过来:你在 ES6 模块里用了require()。ES6 模块没有require、__dirname、__filename这些 CommonJS 特有的变量。
解决办法有两种。第一种,改用import语法。第二种,如果必须用require,可以通过createRequire创建:
// src/mjs/use-require.mjs import { createRequire } from 'module'; const require = createRequire(import.meta.url); const pkg = require('./package.json'); console.log(pkg.name);5.3 ERR_REQUIRE_ESM
当你用require()去加载一个 ES6 模块文件时,会报这个错。比如require('./details.mjs')就会触发。
解决方式:要么把被加载的文件改成 CommonJS(.cjs),要么在 CommonJS 里用动态import():
// src/cjs/load-esm.cjs import('./details.mjs').then((mod) => { console.log(mod.FirstName); });注意import()返回的是 Promise,所以要用.then()或await。
5.4 找不到模块路径
require('./message')不写扩展名时,Node.js 会按.js、.json、.node的顺序查找。但在 ES6 模块里,import必须写完整扩展名,import './message'会直接报错,必须写成import './message.mjs'或import './message.js'。
另外,导入文件夹时,CommonJS 会找index.js,ES6 模块不会自动找index.js,需要显式写import './folder/index.mjs'。
5.5 混用导致的循环依赖问题
CommonJS 和 ES6 模块对循环依赖的处理方式不同。CommonJS 在循环依赖时可能拿到不完整的module.exports,而 ES6 模块通过静态分析能更好地处理。如果你的项目里两个模块互相导入,建议统一用一种模块系统,不要混用。
6. 继续深入:从模块化到实际项目接入
模块化配置跑通之后,下一步就是把它用到真实项目里。如果你在做 AI 相关的 Node.js 后端,建议把 API 调用封装成独立模块,CommonJS 和 ES6 各维护一份适配层,业务代码只依赖统一的接口。
需要长期编码或做 Agent 项目的,可以看看 Coding Plan,它更适合持续调用模型的场景。接入过程中遇到参数问题,可以对照 接入文档 排查。如果你只是想先验证模型返回格式,用 模型对话 快速试一下就行。
最后提醒一个实际开发中的小技巧:在package.json里加一个"engines"字段锁定 Node.js 版本,比如"node": ">=18.0.0",因为 ES6 模块的顶层await和fetch在低版本 Node.js 里不可用。这样团队协作时不会因为版本差异导致模块解析行为不一致。