Node.js模块化演进:从__dirname报错看现代JavaScript开发实战
最近在技术社区里,经常看到刚接触Node.js的朋友们被一个看似简单的问题困扰——运行代码时突然抛出“__dirname is not defined”的错误。这个错误信息直白得让人有点懵,特别是当你从网上找到的示例代码明明运行得好好的,复制到自己的项目里却突然罢工。实际上,这个看似简单的报错背后,反映的是JavaScript生态正在经历的一场深刻变革:从传统的CommonJS模块系统向现代ESM(ECMAScript Modules)的全面迁移。
如果你是Node.js的新手,遇到这个问题不必慌张。这恰恰是一个很好的切入点,让你理解Node.js模块系统的工作原理。今天我们就从这个问题出发,深入探讨三种不同场景下的解决方案,并借此机会梳理一下Node.js模块化的发展脉络。无论你是在构建一个全新的项目,还是维护一个遗留代码库,这些知识都会让你在开发中更加得心应手。
1. 理解问题的根源:CommonJS与ESM的模块化之争
要彻底解决__dirname报错问题,我们首先得弄清楚Node.js中两种模块系统的根本区别。这不仅仅是技术细节的差异,更代表了JavaScript语言发展的不同阶段和设计哲学。
1.1 CommonJS:Node.js的“原生”模块系统
CommonJS是Node.js诞生之初采用的模块系统,它深深植根于服务器端JavaScript的开发需求。在这个系统中,每个文件都被视为一个独立的模块,拥有自己的作用域。__dirname和__filename就是CommonJS环境下的两个特殊变量:
__dirname:当前执行脚本所在的目录的绝对路径__filename:当前执行脚本的绝对路径
这两个变量在CommonJS中是由Node.js运行时自动注入的,开发者可以直接使用,无需任何导入操作。这种设计简单直观,特别适合服务器端文件操作的需求。
// CommonJS示例 - 直接使用__dirname
const fs = require('fs');
const path = require('path');
// 读取当前目录下的config.json文件
const configPath = path.join(__dirname, 'config.json');
const configData = fs.readFileSync(configPath, 'utf8');
console.log('配置文件内容:', configData);
CommonJS采用同步加载机制,模块在首次被require()时加载并缓存,后续的require()调用直接返回缓存结果。这种机制在服务器端环境中表现良好,但在浏览器端会导致性能问题。
1.2 ESM:JavaScript的“标准”模块系统
ESM(ECMAScript Modules)是ECMAScript标准的一部分,从ES6(ES2015)开始引入。与CommonJS不同,ESM是语言层面的标准,旨在为JavaScript提供统一的模块系统,无论是在浏览器端还是服务器端。
ESM的几个关键特性:
- 静态分析:模块的导入导出关系在代码执行前就可以确定
- 异步加载:支持按需加载,更适合浏览器环境
- 严格模式:ESM模块默认处于严格模式
- 没有全局变量:不自动注入
__dirname、__filename等变量
// ESM示例 - 需要手动实现__dirname功能
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
// 获取当前文件的目录路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 读取配置文件
const configPath = join(__dirname, 'config.json');
const configData = readFileSync(configPath, 'utf8');
console.log('ESM模式下的配置文件内容:', configData);
注意:ESM的设计哲学是“显式优于隐式”。所有依赖必须明确声明,这虽然增加了少许代码量,但大大提高了代码的可维护性和可预测性。
1.3 两种模块系统的对比
为了更清晰地理解两者的差异,我们通过一个表格来对比它们的主要特性:
| 特性维度 | CommonJS | ESM(ECMAScript Modules) |
|---|---|---|
| 引入方式 | require() | import |
| 导出方式 | module.exports 或 exports | export |
| 加载时机 | 运行时动态加载 | 编译时静态分析 |
| 加载机制 | 同步加载 | 异步加载(支持动态导入) |
| 作用域 | 每个文件独立作用域 | 每个模块独立作用域 |
| 特殊变量 | 提供__dirname、__filename、require等 | 不提供这些变量,需通过import.meta.url获取 |
| 严格模式 | 默认非严格模式 | 默认严格模式 |
| 循环依赖 | 支持但可能有问题 | 静态分析,处理更清晰 |
| 浏览器支持 | 需要打包工具转换 | 现代浏览器原生支持 |
这个对比表格清晰地展示了为什么在ESM模式下直接使用__dirname会报错——ESM根本没有这个变量!Node.js在运行ESM模块时,不会像CommonJS那样自动注入这些全局变量。
2. 解决方案一:回归CommonJS的兼容性策略
对于许多现有项目,特别是那些依赖大量第三方库的遗留系统,最简单直接的解决方案可能就是暂时回到CommonJS模式。这听起来像是技术上的倒退,但在实际开发中,稳定性往往比追求最新技术更重要。
2.1 项目级配置:package.json的type字段
Node.js从v13.2.0开始,允许通过package.json中的type字段来指定项目的默认模块系统。这个字段有两个可选值:
"commonjs":默认值,文件使用CommonJS模块系统"module":使用ESM模块系统
如果你的项目意外地设置了type: "module",那么所有.js文件都会被当作ESM模块处理,这时使用__dirname就会报错。解决方法很简单:
{
"name": "your-project",
"version": "1.0.0",
"description": "一个Node.js项目",
"type": "commonjs", // 明确指定使用CommonJS
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.18.0"
}
}
或者,如果你不确定是否需要这个字段,直接删除type字段也可以,因为Node.js默认就是CommonJS模式。
提示:在团队协作项目中,建议明确设置
type字段,避免因不同开发者的Node.js版本或配置差异导致的不一致问题。
2.2 文件扩展名的微妙差异
Node.js判断一个文件使用哪种模块系统,有一套完整的优先级规则。了解这些规则可以帮助你避免很多莫名其妙的错误:
.mjs文件:总是作为ESM模块处理.cjs文件:总是作为CommonJS模块处理.js文件:取决于最近的package.json中的type字段- 如果
type: "module",则作为ESM处理 - 如果
type: "commonjs"或未设置,则作为CommonJS处理
- 如果
这里有一个实际开发中容易踩的坑:即使你的package.json设置了type: "commonjs",如果你不小心把文件保存为.mjs扩展名,Node.js还是会把它当作ESM模块处理。
# 错误示例:文件扩展名导致的问题
# 假设有一个文件叫 utils.mjs
node utils.mjs # 会被当作ESM,使用__dirname会报错
# 解决方案:重命名文件
mv utils.mjs utils.js
node utils.js # 现在会被当作CommonJS,__dirname可用
2.3 混合模块项目的处理策略
现实中的项目往往不是纯粹的一种模块系统。你可能需要同时使用CommonJS和ESM模块,特别是在渐进式迁移或使用第三方库时。Node.js提供了几种处理混合模块的策略:
策略一:使用完整的文件扩展名
- CommonJS模块使用
.cjs扩展名 - ESM模块使用
.mjs扩展名 - 这样无论
package.json如何配置,Node.js都能正确识别
策略二:动态导入(适用于ESM中调用CommonJS)
// 在ESM模块中导入CommonJS模块
async function loadCommonJSModule() {
const commonJSModule = await import('./legacy-module.cjs');
console.log(commonJSModule.default); // CommonJS的module.exports会变成default导出
}
策略三:创建适配层 对于大型项目,可以创建一个薄薄的适配层,统一处理模块系统的差异:
// adapter.js - 模块系统适配器
import { createRequire } from 'module';
// 在ESM中创建require函数
const require = createRequire(import.meta.url);
// 现在可以像在CommonJS中一样使用require
const path = require('path');
const fs = require('fs');
export { require, path, fs };
这种方法的优点是可以在ESM环境中继续使用那些尚未支持ESM的第三方库,为迁移争取时间。
3. 解决方案二:在ESM中实现__dirname功能
如果你决定拥抱ESM,或者项目要求必须使用ESM模块系统,那么就需要学会在ESM中获取当前文件目录的方法。这不仅仅是解决__dirname报错的问题,更是理解ESM设计哲学的好机会。
3.1 理解import.meta.url
ESM引入了一个新的元属性:import.meta.url。这个属性返回当前模块文件的URL字符串,格式通常是file://协议。这是ESM中获取模块自身信息的标准方式。
// 查看import.meta.url的内容
console.log(import.meta.url);
// 输出类似:file:///Users/username/project/src/index.js
import.meta.url有几个重要特点:
- 它是只读的,不能在运行时修改
- 它总是返回当前模块的绝对URL
- 在浏览器和Node.js中行为一致(浏览器中可能是http/https协议)
3.2 完整的__dirname实现方案
要从import.meta.url获取类似CommonJS中__dirname的功能,需要经过几个步骤:
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// 步骤1:将URL转换为文件路径
const __filename = fileURLToPath(import.meta.url);
// 步骤2:获取目录路径
const __dirname = dirname(__filename);
// 步骤3:使用路径(示例)
const configPath = join(__dirname, 'config', 'app.json');
console.log('配置文件路径:', configPath);
让我们分解一下这个过程:
-
fileURLToPath():这个函数来自Node.js的
url模块,专门用于将file://协议的URL转换为本地文件系统路径。它会自动处理不同操作系统的路径分隔符差异。 -
dirname():来自
path模块,用于获取路径的目录部分。如果路径是文件,返回其所在目录;如果已经是目录,返回父目录。 -
join():同样是
path模块的方法,用于安全地拼接路径片段。相比字符串拼接,join()能正确处理路径分隔符,避免出现//或缺少分隔符的问题。
3.3 创建可复用的工具函数
在实际项目中,你可能会在很多地方需要获取__dirname。为了避免重复代码,可以创建一个工具函数或工具模块:
// utils/path-utils.js
import { fileURLToPath } from 'url';
import { dirname } from 'path';
/**
* 获取当前文件的目录路径(ESM版本的__dirname)
* @param {string} metaUrl - import.meta.url
* @returns {string} 目录路径
*/
export function getDirname(metaUrl) {
return dirname(fileURLToPath(metaUrl));
}
/**
* 获取当前文件的路径(ESM版本的__filename)
* @param {string} metaUrl - import.meta.url
* @returns {string} 文件路径
*/
export function getFilename(metaUrl) {
return fileURLToPath(metaUrl);
}
/**
* 一次性获取__dirname和__filename
* @param {string} metaUrl - import.meta.url
* @returns {Object} 包含dirname和filename的对象
*/
export function getPathInfo(metaUrl) {
const filename = fileURLToPath(metaUrl);
return {
__filename: filename,
__dirname: dirname(filename)
};
}
使用这个工具模块:
// 在其他文件中使用
import { getPathInfo } from './utils/path-utils.js';
const { __dirname, __filename } = getPathInfo(import.meta.url);
console.log('当前文件:', __filename);
console.log('当前目录:', __dirname);
这种封装不仅减少了代码重复,还提高了代码的可测试性。你可以为这些工具函数编写单元测试,确保它们在不同环境下都能正常工作。
3.4 处理嵌套调用的特殊情况
有时候你可能会遇到这样的情况:在一个工具函数中需要获取调用者的文件路径,而不是工具函数自身的路径。这在日志记录、错误跟踪等场景中很常见。
// logger.js - 日志工具
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createRequire } from 'module';
import { captureStackTrace } from 'console';
const require = createRequire(import.meta.url);
/**
* 增强版路径获取,可以获取调用者信息
* @param {number} depth - 调用栈深度(0表示当前函数,1表示调用者,以此类推)
* @returns {Object} 路径信息
*/
export function getCallerPathInfo(depth = 1) {
// 使用Error栈追踪获取调用信息
const stack = new Error().stack.split('\n');
// 解析调用栈获取文件信息(简化示例,实际实现会更复杂)
let callerInfo = stack[depth + 2] || '';
// 这里只是示意,实际项目中可能需要使用专门的栈解析库
console.log('调用栈信息:', stack.slice(0, 5));
// 回退方案:返回当前模块信息
const currentFilename = fileURLToPath(import.meta.url);
return {
filename: currentFilename,
dirname: dirname(currentFilename),
callerInfo: callerInfo.trim()
};
}
// 使用示例
export function logWithContext(message) {
const pathInfo = getCallerPathInfo(1);
console.log(`[${new Date().toISOString()}] ${pathInfo.filename}: ${message}`);
}
这种高级用法虽然复杂,但在构建框架或库时非常有用。它允许你的工具代码感知调用上下文,提供更精准的调试信息。
4. 解决方案三:构建时预处理与现代化工具链
对于大型项目或团队协作项目,手动处理模块系统差异可能会变得繁琐且容易出错。这时,现代化的构建工具和开发工具链可以大大简化工作流程。
4.1 使用TypeScript的路径处理
如果你使用TypeScript开发Node.js应用,TypeScript编译器可以帮你处理很多路径相关的问题。TypeScript对ESM和CommonJS都有很好的支持,并且可以在编译时进行转换。
// tsconfig.json配置示例
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext", // 或"ESNext"、"CommonJS"
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@utils/*": ["src/utils/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
TypeScript的module配置项决定了输出的模块系统:
"CommonJS":输出为CommonJS模块,可以使用__dirname"ESNext"或"NodeNext":输出为ESM模块,需要使用import.meta.url
对于需要兼容两种模块系统的库项目,可以配置多目标输出:
{
"compilerOptions": {
// 主配置...
},
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/cjs",
"module": "CommonJS"
}
},
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/esm",
"module": "ESNext"
}
}
4.2 Babel转译方案
Babel是一个广泛使用的JavaScript编译器,可以将新版本的JavaScript代码转译成向后兼容的版本。对于模块系统的处理,Babel提供了灵活的配置选项。
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
targets: {
node: 'current' // 根据当前Node.js版本决定转译特性
}
}]
],
plugins: [
// 处理ESM模块语法
'@babel/plugin-transform-modules-commonjs',
// 或者保持ESM语法
// '@babel/plugin-syntax-import-meta'
]
};
Babel的一个强大之处在于可以根据需要选择性地转译某些语法特性。例如,你可以保持ESM的import/export语法,但通过插件处理import.meta.url:
// 自定义Babel插件示例(简化版)
// 这个插件会将import.meta.url转换为Node.js兼容的形式
const importMetaPlugin = () => {
return {
visitor: {
MetaProperty(path) {
if (path.node.meta.name === 'import' &&
path.node.property.name === 'meta') {
// 替换import.meta为兼容的实现
// 实际实现会更复杂,这里只是示意
}
}
}
};
};
4.3 构建工具集成:Webpack、Rollup、Vite
现代前端构建工具都对模块系统有很好的支持,并且提供了处理路径问题的各种配置选项。
Webpack配置示例:
// webpack.config.js
const path = require('path');
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
module.exports = {
target: 'node', // 重要:指定构建目标为Node.js
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
libraryTarget: 'commonjs2'
},
resolve: {
extensions: ['.js', '.mjs', '.cjs', '.json'],
alias: {
'@': path.resolve(__dirname, 'src')
}
},
plugins: [
new NodePolyfillPlugin() // 提供Node.js核心模块的polyfill
],
// 处理__dirname和__filename
node: {
__dirname: true, // 保留__dirname的真实值
__filename: true // 保留__filename的真实值
}
};
Vite配置示例(针对Node.js):
// vite.config.js
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
formats: ['es', 'cjs'] // 同时输出ESM和CommonJS版本
},
rollupOptions: {
// 确保Node.js内置模块不被打包
external: ['fs', 'path', 'url', 'util']
}
},
// 开发服务器配置
server: {
port: 3000
}
});
这些构建工具不仅可以处理模块系统差异,还能提供代码分割、tree shaking、热重载等高级功能,大大提升开发体验和代码质量。
4.4 开发环境工具:nodemon、ts-node-dev
在开发过程中,实时重新加载和TypeScript支持非常重要。以下是一些常用开发工具的最佳实践配置:
nodemon配置(支持ESM):
{
"watch": ["src"],
"ext": "js,mjs,json",
"ignore": ["src/**/*.test.js"],
"exec": "node --loader ts-node/esm src/index.js", // 支持TypeScript和ESM
"env": {
"NODE_ENV": "development"
}
}
ts-node-dev配置:
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only --ignore-watch node_modules src/index.ts",
"dev:esm": "ts-node-dev --esm --respawn --transpile-only src/index.ts"
}
}
这些工具的正确配置可以让你在开发过程中几乎感受不到CommonJS和ESM的差异,专注于业务逻辑的实现。
5. 实战案例:从零构建一个双模块兼容的Node.js库
理论讲了很多,现在让我们通过一个完整的实战案例,看看如何在实际项目中优雅地处理模块系统问题。我们将构建一个简单的配置文件加载库,这个库需要同时支持CommonJS和ESM。
5.1 项目结构与初始化
首先创建项目的基本结构:
# 创建项目目录
mkdir config-loader && cd config-loader
# 初始化package.json
npm init -y
# 创建目录结构
mkdir -p src/{cjs,esm} tests dist
编辑package.json,设置双入口点:
{
"name": "config-loader",
"version": "1.0.0",
"description": "一个支持CommonJS和ESM的配置文件加载器",
"type": "commonjs", // 默认使用CommonJS
"main": "./dist/cjs/index.js", // CommonJS入口
"module": "./dist/esm/index.js", // ESM入口
"exports": {
".": {
"import": "./dist/esm/index.js", // ESM导入时使用
"require": "./dist/cjs/index.js" // CommonJS导入时使用
},
"./package.json": "./package.json"
},
"scripts": {
"build:cjs": "tsc --project tsconfig.cjs.json",
"build:esm": "tsc --project tsconfig.esm.json",
"build": "npm run build:cjs && npm run build:esm",
"test": "jest",
"test:cjs": "jest --config jest.cjs.config.js",
"test:esm": "jest --config jest.esm.config.js"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0"
},
"keywords": ["config", "loader", "commonjs", "esm"],
"author": "Your Name",
"license": "MIT"
}
5.2 核心代码实现
创建TypeScript源代码:
// src/types/config.ts
export interface ConfigOptions {
path?: string;
watch?: boolean;
defaults?: Record<string, any>;
}
// src/utils/path-utils.ts
/**
* 路径工具函数 - 处理不同模块系统的路径差异
*/
// 这些函数会在编译时被替换为适当的实现
export function getDirname(metaUrl?: string): string {
// 这个实现会在编译时根据目标模块系统被替换
throw new Error('This function should be replaced during build');
}
export function getFilename(metaUrl?: string): string {
throw new Error('This function should be replaced during build');
}
// src/core/loader.ts
import { readFileSync, watch } from 'fs';
import { join } from 'path';
import { ConfigOptions } from '../types/config';
import { getDirname } from '../utils/path-utils';
export class ConfigLoader {
private configPath: string;
private config: Record<string, any> = {};
private watcher: any = null;
constructor(options: ConfigOptions = {}) {
// 使用getDirname获取当前目录
const dirname = getDirname();
this.configPath = options.path || join(dirname, 'config.json');
if (options.defaults) {
this.config = { ...options.defaults };
}
this.loadConfig();
if (options.watch) {
this.startWatching();
}
}
private loadConfig(): void {
try {
const content = readFileSync(this.configPath, 'utf8');
const fileConfig = JSON.parse(content);
this.config = { ...this.config, ...fileConfig };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`Failed to load config from ${this.configPath}:`, error);
}
}
}
private startWatching(): void {
if (this.watcher) {
this.watcher.close();
}
this.watcher = watch(this.configPath, (eventType) => {
if (eventType === 'change') {
console.log(`Config file changed, reloading...`);
this.loadConfig();
}
});
}
get<T = any>(key: string, defaultValue?: T): T {
const keys = key.split('.');
let value: any = this.config;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return defaultValue as T;
}
}
return value as T;
}
set(key: string, value: any): void {
const keys = key.split('.');
let config = this.config;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!(k in config) || typeof config[k] !== 'object') {
config[k] = {};
}
config = config[k];
}
config[keys[keys.length - 1]] = value;
}
stopWatching(): void {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
}
}
// src/index.ts
export { ConfigLoader } from './core/loader';
export type { ConfigOptions } from './types/config';
5.3 构建配置
创建不同的TypeScript配置文件:
// tsconfig.base.json - 基础配置
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// tsconfig.cjs.json - CommonJS配置
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./dist/cjs",
"rootDir": "./src"
}
}
// tsconfig.esm.json - ESM配置
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "./dist/esm",
"rootDir": "./src"
}
}
创建构建脚本,处理路径工具函数的不同实现:
// scripts/build-utils.js
const fs = require('fs');
const path = require('path');
// CommonJS版本的路径工具实现
const cjsPathUtils = `
const path = require('path');
function getDirname() {
return __dirname;
}
function getFilename() {
return __filename;
}
module.exports = { getDirname, getFilename };
`;
// ESM版本的路径工具实现
const esmPathUtils = `
import { fileURLToPath } from 'url';
import { dirname } from 'path';
export function getDirname(metaUrl = import.meta.url) {
return dirname(fileURLToPath(metaUrl));
}
export function getFilename(metaUrl = import.meta.url) {
return fileURLToPath(metaUrl);
}
`;
// 写入文件
fs.writeFileSync(
path.join(__dirname, '../dist/cjs/utils/path-utils.js'),
cjsPathUtils
);
fs.writeFileSync(
path.join(__dirname, '../dist/esm/utils/path-utils.js'),
esmPathUtils
);
console.log('Path utilities built successfully!');
5.4 测试配置
创建针对两种模块系统的测试配置:
// jest.cjs.config.js - CommonJS测试配置
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
transform: {
'^.+\\.ts$': ['ts-jest', {
tsconfig: 'tsconfig.cjs.json'
}]
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
}
};
// jest.esm.config.js - ESM测试配置
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@/(.*)$': '<rootDir>/src/$1'
},
transform: {
'^.+\\.ts$': ['ts-jest', {
tsconfig: 'tsconfig.esm.json',
useESM: true
}]
}
};
创建测试文件:
// tests/config-loader.test.ts
import { ConfigLoader } from '../src/core/loader';
import { writeFileSync, unlinkSync } from 'fs';
import { join } from 'path';
import { getDirname } from '../src/utils/path-utils';
describe('ConfigLoader', () => {
const testDir = getDirname();
const testConfigPath = join(testDir, 'test-config.json');
beforeEach(() => {
// 创建测试配置文件
writeFileSync(testConfigPath, JSON.stringify({
database: {
host: 'localhost',
port: 5432
},
server: {
port: 3000
}
}));
});
afterEach(() => {
// 清理测试文件
try {
unlinkSync(testConfigPath);
} catch (error) {
// 文件可能已被删除
}
});
test('should load config from file', () => {
const loader = new ConfigLoader({ path: testConfigPath });
expect(loader.get('database.host')).toBe('localhost');
expect(loader.get('server.port')).toBe(3000);
});
test('should use defaults when file not found', () => {
const loader = new ConfigLoader({
path: '/non-existent/path.json',
defaults: { app: { name: 'TestApp' } }
});
expect(loader.get('app.name')).toBe('TestApp');
});
test('should allow setting values', () => {
const loader = new ConfigLoader({ path: testConfigPath });
loader.set('new.key', 'value');
expect(loader.get('new.key')).toBe('value');
});
});
5.5 使用示例
最后,提供使用示例:
// examples/cjs-usage.js - CommonJS使用示例
const { ConfigLoader } = require('config-loader');
const loader = new ConfigLoader({
path: './config.json',
watch: true,
defaults: {
app: {
name: 'MyApp',
version: '1.0.0'
}
}
});
console.log('Database host:', loader.get('database.host', 'localhost'));
console.log('App name:', loader.get('app.name'));
// 修改配置
loader.set('app.debug', true);
// 示例结束,停止监听
setTimeout(() => {
loader.stopWatching();
console.log('Stopped watching config file');
}, 5000);
// examples/esm-usage.mjs - ESM使用示例
import { ConfigLoader } from 'config-loader';
const loader = new ConfigLoader({
path: './config.json',
watch: true,
defaults: {
app: {
name: 'MyApp',
version: '1.0.0'
}
}
});
console.log('Database host:', loader.get('database.host', 'localhost'));
console.log('App name:', loader.get('app.name'));
// 修改配置
loader.set('app.debug', true);
// 示例结束,停止监听
setTimeout(() => {
loader.stopWatching();
console.log('Stopped watching config file');
}, 5000);
通过这个完整的实战案例,我们可以看到,构建一个同时支持CommonJS和ESM的库需要一些额外的工作,但这些工作带来的好处是明显的:你的库可以同时服务于使用不同模块系统的项目,大大提高了兼容性和可用性。
在实际开发中,我遇到过不少因为模块系统不兼容而导致的问题。有一次,一个团队在迁移到ESM时,发现他们依赖的一个关键库只支持CommonJS,导致整个迁移计划受阻。如果他们提前考虑了双模块支持,这个问题完全可以避免。这也是为什么我现在在新项目开始时,就会考虑模块系统的兼容性问题——这看起来是额外的工作,但实际上能节省大量的后续维护成本。
1238



被折叠的 条评论
为什么被折叠?



