JS / MJS / TS 文件区别与最佳实践
最后更新:2026-03-05 JavaScript 生态中有三种主要文件扩展名,它们的核心区别体现在两个维度:模块系统和类型系统。 Node.js 最初的模块系统(2009年),同步加载,至今仍大量存在于旧项目和 npm 包中。 CJS 特点 ES2015(ES6)引入的官方标准模块系统,现为前端和 Node.js 的推荐方式。 ESM 特点 .js 的模块系统由 package.json 中的 type 字段决定: 同一项目中混用两种格式 .mjs 无论 package.json 如何配置,始终强制使用 ESM。 何时需要 .mjs 1. 在 "ty
官方文档:https://tc39.es/ecma262/#sec-modules
适用版本:Node.js 22 LTS / TypeScript 5.x(2026-05-08 核实)
最后更新:2026-03-05
一、文件扩展名本质
JavaScript 生态中有三种主要文件扩展名,它们的核心区别体现在两个维度:模块系统和类型系统。
| 扩展名 | 模块系统 | 类型系统 | 需要编译 | 适用场景 |
|---|---|---|---|---|
.js |
CJS 或 ESM(由配置决定) | 无 | 否 | 通用 JavaScript |
.mjs |
强制 ESM | 无 | 否 | 明确标记 ESM 的文件 |
.cjs |
强制 CJS | 无 | 否 | 明确标记 CJS 的文件 |
.ts |
CJS 或 ESM(编译后决定) | 有静态类型 | 是 | 需要类型安全的项目 |
.mts |
强制 ESM(编译为 .mjs) | 有静态类型 | 是 | TS + 强制 ESM |
.cts |
强制 CJS(编译为 .cjs) | 有静态类型 | 是 | TS + 强制 CJS |
二、模块系统详解
CommonJS(CJS)
Node.js 最初的模块系统(2009年),同步加载,至今仍大量存在于旧项目和 npm 包中。
// 导入
const fs = require('fs')
const { join } = require('path')
const myModule = require('./myModule')
// 导出
module.exports = { foo, bar } // 导出对象
module.exports = function() {} // 导出函数
exports.foo = foo // 单个导出(等价于 module.exports.foo = foo)
CJS 特点
| 特点 | 说明 |
|---|---|
| 加载时机 | 同步,运行时(require() 执行到哪一行才加载) |
| 缓存 | 首次 require 后结果被缓存,重复 require 返回缓存 |
| 循环依赖 | 可能得到不完整的导出对象 |
| 动态导入 | 支持(require() 可在 if/函数内使用) |
| Tree-shaking | 不支持(打包工具难以静态分析) |
顶层 await |
不支持 |
| 浏览器原生 | 不支持,需打包工具处理 |
ES Modules(ESM)
ES2015(ES6)引入的官方标准模块系统,现为前端和 Node.js 的推荐方式。
// 具名导入
import { readFile, writeFile } from 'fs/promises'
// 默认导入
import express from 'express'
// 命名空间导入
import * as path from 'path'
// 同时导入默认和具名
import React, { useState, useEffect } from 'react'
// 动态导入(异步,返回 Promise)
const module = await import('./heavy-module.js')
// 具名导出
export function hello() {}
export const PI = 3.14
export class MyClass {}
// 默认导出
export default function main() {}
// 重新导出
export { foo } from './other.js'
export * from './other.js'
export { foo as bar } from './other.js'
ESM 特点
| 特点 | 说明 |
|---|---|
| 加载时机 | 异步,解析阶段(import 语句在代码执行前就被处理) |
| 静态结构 | import/export 必须在顶层,不能在条件语句中 |
| 动态导入 | 支持(通过 import() 函数,返回 Promise) |
| Tree-shaking | 支持(打包工具可分析哪些导出被使用) |
顶层 await |
支持(可在模块顶层直接使用 await) |
| 浏览器原生 | 支持(<script type="module">) |
| 严格模式 | 自动启用 |
CJS vs ESM 核心差异对比
// ============ CJS ============
// 运行时加载,可以在函数/条件中使用
function loadModule(name) {
const mod = require(name) // 合法
return mod
}
// 可以修改导入的值
const obj = require('./obj')
obj.count = 100 // 可以(因为是值的拷贝)
// ============ ESM ============
// 静态导入必须在顶层
import { count } from './counter.js' // 只能在顶层
// 绑定是实时的(live binding),不能直接重新赋值
count = 100 // TypeError: Assignment to constant variable
// 动态导入(ESM 版的 require)
async function loadModule(name) {
const mod = await import(name) // 合法,返回 Promise
return mod
}
三、.js 文件
.js 的模块系统由 package.json 中的 type 字段决定:
// package.json
// 未设置 type 或 type 为 "commonjs"(默认):
// .js 文件使用 CommonJS
{
"name": "my-package"
}
// type 为 "module":
// .js 文件使用 ESM
{
"name": "my-package",
"type": "module"
}
// 当 package.json "type": "commonjs"(或不设置)时,这是 CJS
const path = require('path')
module.exports = { path }
// 当 package.json "type": "module" 时,这是 ESM
import path from 'path'
export { path }
同一项目中混用两种格式
project/
├── package.json ("type": "module") → .js 默认 ESM
├── src/
│ ├── app.js → ESM(遵循 package.json)
│ └── legacy.cjs → 强制 CJS(无视 package.json)
└── config/
└── webpack.config.cjs → 强制 CJS(webpack 配置通常需要 CJS)
四、.mjs 文件
.mjs 无论 package.json 如何配置,始终强制使用 ESM。
// config.mjs —— 即使 package.json 没有 "type": "module",这里也是 ESM
import { readFileSync } from 'fs'
export const config = JSON.parse(readFileSync('./config.json', 'utf-8'))
何时需要 .mjs
- 在
"type": "commonjs"的旧项目中,需要某个文件使用 ESM - 发布双格式 npm 包时(同时提供 ESM 和 CJS 版本)
- 明确意图,避免因
package.json配置变更而改变文件行为
// 双格式 npm 包的典型结构
// package.json
{
"main": "./dist/index.cjs", // CJS 入口
"module": "./dist/index.mjs", // ESM 入口(打包工具识别)
"exports": {
".": {
"import": "./dist/index.mjs", // ESM(Node.js import 用)
"require": "./dist/index.cjs" // CJS(Node.js require 用)
}
}
}
五、.ts 文件(TypeScript)
TypeScript 是 JavaScript 的超集,添加了静态类型系统。.ts 文件不能直接运行,需先编译为 .js。
基本类型系统
// 基本类型
let name: string = 'Alice'
let age: number = 25
let active: boolean = true
let data: null = null
let value: undefined = undefined
// 数组
let nums: number[] = [1, 2, 3]
let strs: Array<string> = ['a', 'b']
// 元组(固定长度和类型)
let pair: [string, number] = ['Alice', 25]
// 联合类型
let id: string | number = 'abc'
// 字面量类型
let direction: 'left' | 'right' | 'up' | 'down' = 'left'
// 任意类型(尽量避免)
let anything: any = 'hello'
// 未知类型(比 any 安全,使用前需类型收窄)
let unknown: unknown = getData()
if (typeof unknown === 'string') {
console.log(unknown.toUpperCase()) // 安全
}
接口与类型别名
// interface:定义对象结构(可扩展)
interface User {
id: number
name: string
email?: string // 可选属性
readonly createdAt: Date // 只读属性
}
// 接口继承
interface Admin extends User {
role: 'super' | 'normal'
permissions: string[]
}
// type:类型别名(更灵活,支持联合、交叉等)
type ID = string | number
type Point = {
x: number
y: number
}
// 交叉类型(合并多个类型)
type AdminUser = User & { role: string }
interface vs type 选择
| 场景 | 推荐 |
|---|---|
| 定义对象/类结构 | interface(可继承、可扩展) |
| 联合类型、元组 | type |
| 交叉类型 | type |
| 需要多次被 extends | interface |
| 简单类型别名 | type |
泛型
// 泛型函数
function identity<T>(arg: T): T {
return arg
}
const result = identity<string>('hello') // 显式指定
const result2 = identity(42) // 类型推断为 number
// 泛型接口
interface ApiResponse<T> {
data: T
status: number
message: string
}
// 使用
const userRes: ApiResponse<User> = {
data: { id: 1, name: 'Alice' },
status: 200,
message: 'ok'
}
// 泛型约束
function getLength<T extends { length: number }>(arg: T): number {
return arg.length
}
枚举
// 数字枚举
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
// 字符串枚举(更推荐,可读性好)
enum Status {
Active = 'ACTIVE',
Inactive = 'INACTIVE',
Pending = 'PENDING',
}
// const enum(编译后内联,无运行时对象)
const enum Flags {
Read = 1,
Write = 2,
Execute = 4,
}
类型断言与类型守卫
// 类型断言(告诉编译器"我知道这是什么类型")
const input = document.getElementById('username') as HTMLInputElement
const value = (input as HTMLInputElement).value
// 类型守卫(运行时收窄类型)
function process(value: string | number) {
if (typeof value === 'string') {
return value.toUpperCase() // 这里 value 是 string
}
return value.toFixed(2) // 这里 value 是 number
}
// instanceof 守卫
function handle(err: Error | string) {
if (err instanceof Error) {
console.log(err.message)
} else {
console.log(err)
}
}
// 自定义类型守卫
function isUser(obj: any): obj is User {
return typeof obj.id === 'number' && typeof obj.name === 'string'
}
实用工具类型
interface User {
id: number
name: string
email: string
age: number
}
// Partial<T>:所有属性变可选
type UpdateUser = Partial<User>
// { id?: number; name?: string; email?: string; age?: number }
// Required<T>:所有属性变必填
type StrictUser = Required<Partial<User>>
// Readonly<T>:所有属性只读
type FrozenUser = Readonly<User>
// Pick<T, K>:选取指定属性
type UserPreview = Pick<User, 'id' | 'name'>
// { id: number; name: string }
// Omit<T, K>:排除指定属性
type PublicUser = Omit<User, 'email'>
// { id: number; name: string; age: number }
// Record<K, V>:键值映射类型
type UserMap = Record<string, User>
// Exclude<T, U>:从联合类型中排除
type OnlyString = Exclude<string | number | boolean, number | boolean>
// string
// Extract<T, U>:从联合类型中提取
type Numbers = Extract<string | number | boolean, number | boolean>
// number | boolean
// NonNullable<T>:排除 null 和 undefined
type SafeValue = NonNullable<string | null | undefined>
// string
// ReturnType<T>:获取函数返回值类型
function fetchUser(): Promise<User> { ... }
type FetchResult = ReturnType<typeof fetchUser>
// Promise<User>
// Parameters<T>:获取函数参数类型
type FetchParams = Parameters<typeof fetchUser>
// []
六、TypeScript 编译配置
tsconfig.json 核心参数
{
"compilerOptions": {
// 编译目标
"target": "ES2022", // 编译输出的 JS 版本
"lib": ["ES2022", "DOM"], // 包含的类型声明库
// 模块系统
"module": "ESNext", // 输出的模块格式
"moduleResolution": "bundler", // 模块解析策略(现代项目推荐)
// 路径
"rootDir": "./src", // 源码根目录
"outDir": "./dist", // 编译输出目录
"baseUrl": ".", // 路径别名的基准目录
"paths": { // 路径别名
"@/*": ["src/*"]
},
// 严格模式(强烈推荐全部开启)
"strict": true, // 开启所有严格检查(等价于下面全开)
"strictNullChecks": true, // null/undefined 不能赋给其他类型
"noImplicitAny": true, // 禁止隐式 any
"strictFunctionTypes": true,// 严格函数类型检查
// 辅助检查
"noUnusedLocals": true, // 不允许未使用的局部变量
"noUnusedParameters": true, // 不允许未使用的参数
"noImplicitReturns": true, // 函数所有分支必须有返回值
"noFallthroughCasesInSwitch": true, // switch 不允许 fallthrough
// 输出控制
"declaration": true, // 生成 .d.ts 类型声明文件
"sourceMap": true, // 生成 .js.map 调试映射
"removeComments": false, // 是否移除注释
// 互操作
"esModuleInterop": true, // 允许 import CJS 模块(如 import fs from 'fs')
"allowSyntheticDefaultImports": true, // 允许无默认导出的模块使用默认导入
"resolveJsonModule": true, // 允许导入 .json 文件
"skipLibCheck": true // 跳过 .d.ts 文件的类型检查(加快编译)
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
tsconfig.json 核心参数说明表
| 参数 | 推荐值 | 说明 |
|---|---|---|
target |
"ES2022" |
编译产物的 JS 版本,越新特性越多但兼容性越低 |
module |
"ESNext" |
输出模块格式,Vite/打包工具项目用 ESNext |
moduleResolution |
"bundler" |
模块查找策略,现代打包工具项目用 bundler |
strict |
true |
开启所有严格检查,新项目必开 |
esModuleInterop |
true |
兼容 CJS 默认导入,几乎必开 |
skipLibCheck |
true |
跳过第三方库类型检查,加快速度 |
declaration |
true |
发布 npm 包时需要生成 .d.ts |
sourceMap |
true |
开发和调试时需要 |
module 选项详解
| 值 | 说明 | 适用场景 |
|---|---|---|
"CommonJS" |
输出 CJS(require/module.exports) |
Node.js 旧项目 |
"ESNext" |
输出 ESM(import/export),保留动态 import |
Vite/现代打包工具 |
"NodeNext" |
Node.js 原生 ESM,严格要求扩展名 | Node.js 项目不用打包工具 |
"Preserve" |
保留输入格式不变(TS 5.4+) | 最新项目 |
moduleResolution 选项详解
| 值 | 说明 | 适用场景 |
|---|---|---|
"node" |
Node.js CJS 解析规则 | 旧项目兼容 |
"bundler" |
模拟打包工具(Vite/webpack)的解析行为 | 现代前端项目(推荐) |
"NodeNext" |
Node.js ESM 解析,要求精确扩展名 | Node.js 原生 ESM |
七、运行 TypeScript 的方式
1. 编译后运行(生产)
# 安装 TypeScript
npm install -D typescript
# 编译
npx tsc
# 运行编译产物
node dist/index.js
2. ts-node(开发时直接运行 .ts)
npm install -D ts-node
# 运行
npx ts-node src/index.ts
# 带 ESM 支持
npx ts-node --esm src/index.ts
ts-node 常用参数表
| 参数 | 说明 |
|---|---|
--esm |
启用 ESM 模式 |
--transpile-only |
只转译不类型检查(更快) |
--project tsconfig.json |
指定 tsconfig 路径 |
--skip-project |
忽略 tsconfig |
3. tsx(推荐,更快)
npm install -D tsx
# 运行(支持 ESM,无需额外配置)
npx tsx src/index.ts
# 监听模式
npx tsx watch src/index.ts
4. Vite(前端项目)
Vite 原生支持 .ts 文件,无需额外配置,只做转译(不检查类型):
npm create vite@latest my-app -- --template vue-ts
cd my-app && npm install && npm run dev
八、文件扩展名决策指南
选择流程
你在写什么项目?
│
├── 前端项目(Vue/React)
│ └── 用 Vite/webpack 打包 → 统一用 .ts(或 .tsx/.vue)
│ package.json 的 type 字段无关紧要,打包工具处理一切
│
├── Node.js 项目(不打包)
│ ├── 新项目(Node 18+)
│ │ ├── 需要类型安全 → .ts + tsx 运行,或编译后运行
│ │ └── 不需要类型 → .js + package.json "type":"module"
│ │
│ └── 旧项目(维护中)
│ ├── 已有 "type":"commonjs" 或无 type
│ │ ├── 需要某文件用 ESM → 改名为 .mjs
│ │ └── 整体迁移 ESM → 加 "type":"module",CJS 文件改 .cjs
│ └── 发布 npm 包(双格式)→ 构建产物用 .mjs 和 .cjs
│
└── 发布 npm 包
└── 用 tsup/unbuild 构建,自动生成 .mjs + .cjs + .d.ts
不同项目类型的推荐配置
前端(Vue 3 + Vite)
// package.json(type 字段不影响,Vite 接管所有)
{
"type": "module"
}
// 文件全部用 .ts / .vue
src/
├── main.ts
├── App.vue
├── types/
│ └── index.ts
└── utils/
└── http.ts
Node.js 服务(现代)
// package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
src/
├── index.ts
├── routes/
│ └── user.ts
└── utils/
└── db.ts
npm 包(双格式发布)
// package.json
{
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
# 使用 tsup 一键构建双格式
npm install -D tsup
# tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
clean: true,
})
九、import 扩展名规则
这是从 CJS 迁移到 ESM 最容易踩坑的地方。
Node.js 原生 ESM(不经过打包工具)
// 必须写完整扩展名
import { foo } from './utils.js' // 正确
import { foo } from './utils' // 错误!Node.js 不会自动补全
// TypeScript 写 .js,编译器能找到对应的 .ts 文件
import { foo } from './utils.js' // TS 文件里这样写,指向 utils.ts
通过打包工具(Vite/webpack/esbuild)
// 打包工具会自动处理扩展名,可以省略
import { foo } from './utils' // 正确(打包工具解析)
import { foo } from './utils.ts' // 也正确
import { foo } from './utils.js' // 也正确
tsconfig.json 中的扩展名处理
// 用于打包工具的 tsconfig
{
"compilerOptions": {
"moduleResolution": "bundler", // 允许省略扩展名
"allowImportingTsExtensions": true // 允许 import './foo.ts'(不输出 .js 时)
}
}
// 用于 Node.js 原生 ESM 的 tsconfig
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext" // 强制要求完整扩展名
}
}
十、类型声明文件(.d.ts)
.d.ts 是纯类型声明文件,不包含任何运行时代码,用于为 JavaScript 库提供类型信息。
// mylib.d.ts —— 为 JS 库提供类型(不包含实现)
export declare function add(a: number, b: number): number
export declare interface Config {
host: string
port: number
debug?: boolean
}
export declare class Client {
constructor(config: Config)
connect(): Promise<void>
disconnect(): void
}
常见 .d.ts 来源
| 来源 | 说明 |
|---|---|
TypeScript 编译(declaration: true) |
自动生成,与源码对应 |
@types/xxx 包 |
DefinitelyTyped 社区提供,如 @types/node |
| 库自带 | 现代库通常随包附带 *.d.ts |
| 手动编写 | 给无类型的 JS 库手写声明 |
# 为没有类型的 JS 包安装类型
npm install -D @types/node
npm install -D @types/lodash
十一、最佳实践总结
1. 新项目统一选 TypeScript
# 前端(Vue)
npm create vite@latest my-app -- --template vue-ts
# 前端(React)
npm create vite@latest my-app -- --template react-ts
# Node.js
npm init -y
npm install -D typescript tsx @types/node
npx tsc --init
2. 开启严格模式,不用 any
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}
// 不好
function process(data: any) { ... }
// 好
function process(data: unknown) {
if (typeof data === 'string') { ... }
}
// 或用泛型
function process<T extends Record<string, unknown>>(data: T) { ... }
3. package.json 明确声明 type 字段
// 现代项目,明确表态
{
"type": "module"
}
4. 避免在同一项目中混用 CJS 和 ESM
需要混用时,用扩展名明确区分(.mjs/.cjs),而不是依赖 package.json 的隐式行为。
5. import 路径保持一致风格
// 打包工具项目:省略扩展名
import { foo } from './utils'
// Node.js 原生 ESM:写 .js(指向 .ts 文件)
import { foo } from './utils.js'
6. 发布 npm 包提供完整的 exports 字段
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}
7. 使用 tsup 简化包构建
# 一条命令构建 CJS + ESM + 类型声明
npx tsup src/index.ts --format cjs,esm --dts
十二、常见陷阱与注意事项
1. require 在 ESM 中不可用
// 错误:.mjs 文件或 "type":"module" 的 .js 中
const fs = require('fs') // ReferenceError: require is not defined
// 正确
import fs from 'fs'
2. __dirname 和 __filename 在 ESM 中不可用
// 错误:ESM 中无 __dirname
console.log(__dirname) // ReferenceError
// 正确:用 import.meta.url 替代
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
3. 动态 require 无法直接迁移到 ESM
// CJS 中可以动态加载
const lang = 'zh'
const messages = require(`./locales/${lang}.json`)
// ESM 中需用动态 import(异步)
const lang = 'zh'
const messages = await import(`./locales/${lang}.json`, {
assert: { type: 'json' }
})
4. TypeScript 中导入 JSON 需要配置
// tsconfig.json 需要
{
"compilerOptions": {
"resolveJsonModule": true
}
}
// 然后才能导入
import config from './config.json'
console.log(config.port)
5. ESM 包被 CJS 文件引用
// 纯 ESM 包(如 node-fetch v3、chalk v5)无法被 CJS require
const fetch = require('node-fetch') // ERR_REQUIRE_ESM
// 解决方案一:用旧版本(提供 CJS)
// "node-fetch": "2.x"
// 解决方案二:动态 import(在 async 函数中)
const { default: fetch } = await import('node-fetch')
// 解决方案三:将自己的项目迁移到 ESM
6. tsc 只做类型检查,不优化代码
# 如果需要打包/压缩,tsc 单独不够
# 配合 esbuild/rollup/webpack 使用,或直接用 tsup/Vite
7. ts-node 默认不支持 ESM
# 错误:默认 ts-node 遇到 ESM 会报错
ts-node src/index.ts # SyntaxError: Cannot use import statement
# 正确方案一:用 --esm 标志
ts-node --esm src/index.ts
# 正确方案二:改用 tsx(推荐)
tsx src/index.ts
最佳实践
新项目全面使用 ESM,不混用 CJS:在 package.json 声明 "type": "module" 后,所有 .js 文件均按 ESM 解析;只在必须兼容老依赖时用 .cjs 扩展名隔离 CJS 代码,避免两套模块系统并存导致的运行时错误。
TypeScript 项目的导入路径带 .js 后缀:tsc 输出 ESM 时不会改写扩展名,源码中写 import { foo } from './foo.js' 才能在 Node.js ESM 模式下正常解析(即使源文件是 .ts)。tsx 和 ts-node --esm 会自动处理,但 tsc 原生输出不会。
// 正确:带 .js 后缀(tsc 输出后 Node 能找到)
import { parse } from './parser.js'
// 错误:不带后缀,Node ESM 下找不到模块
import { parse } from './parser'
动态 import() 做代码分割而非条件加载:条件加载(if (flag) require('x'))在 ESM 中无效且破坏静态分析。用 const mod = await import('./heavy') 配合顶层 await 或异步函数,bundler 才能识别为分割点并生成独立 chunk。
package.json 的 exports 字段取代 main/module:exports 支持条件导出(import/require/browser/node),是发布双模式(ESM+CJS)库的现代标准。旧的 main/module 字段继续兼容旧版 bundler,但新项目应优先用 exports。
避免在模块顶层执行副作用:模块只要被 import(即使只用到其中一个导出),顶层代码就全部执行。数据库连接、console.log、全局状态初始化放在导出函数内部,让调用方显式触发,方便 tree-shaking 和测试隔离。
常见陷阱
陷阱:require is not defined — CJS 代码在 ESM 模块中使用
现象: 给项目加了 "type": "module" 后,某些老代码或第三方库报 ReferenceError: require is not defined。
原因: "type": "module" 让所有 .js 文件按 ESM 解析,而 require/module.exports 是 CJS 专属,ESM 中不存在。
解决: 把需要用 require 的文件改为 .cjs 扩展名(Node.js 始终将其解析为 CJS);或用 import() 动态导入替换 require();对于无法修改的第三方库,用 createRequire(import.meta.url) 创建 CJS-compatible require。
陷阱:命名导出与默认导出混用导致 undefined
现象: import Foo from './foo' 后 Foo 是 undefined 或空对象。
原因: foo.ts 只有命名导出(export const Foo = ...)没有默认导出,但调用方用了默认导入语法。
解决: 命名导出用 import { Foo } from './foo';或在 foo.ts 中补 export default Foo。库的 CJS 互操作(esModuleInterop: true)会将 module.exports 整体作为默认导出,但不影响 ESM 原生模块。
陷阱:循环依赖导致导入值为 undefined
现象: 模块 A 导入模块 B 的值,B 也导入 A 的值;运行时其中一个值是 undefined。
原因: ESM 的循环依赖会创建"活绑定"(live binding),但若被引用的绑定在初始化时尚未赋值(函数声明提升但 const/let 不提升),读取到的就是 undefined。
解决: 重构代码消除循环依赖;把共享的常量/类型提取到第三个文件;如果必须循环,确保被引用的值是函数声明(会提升)而非 const/let。