V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
BeautifulSoap
V2EX  ›  Node.js

2025 年 node 项目,乱成一锅粥的 typescript ESM import 写法该怎么选?

  •  
  •   BeautifulSoap · 11 天前 · 4019 次点击

    假设在 ./utils/calcute.ts 中有一个工具函数 add()

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

    然后我们在 main.ts 中需要使用这个 add 函数

    写法 1, import 不带扩展名:

    tsconfig 配置 module=esnext ,然后假设有如下 main.ts 文件

    import { add } from "./utils/calcute";
    
    add(1,2)
    

    使用 tsc 编译后使用 node 运行编译后的 js 文件会报错

    
    node ./dist/main.js
    
    ... 省略
    
      code: 'ERR_UNSUPPORTED_DIR_IMPORT',
      url: 'file:///home/xxxxxx/dist/utils/calcute'
     
    

    原因是现在的 node 处理 esm 的 import 需要指定具体文件名(即类似 import ./utils/calcute.js )。不写扩展名的 import 会报错

    而 typescript 编译代码对 import 内 from "xxxx" 的部分是不会做任何处理直接保留的。按照 ts 官方的意思就是这部分是模块解析,不应该是 typescript 的工作而应交给 js 运行时(如 node 、浏览器)自己处理,所以 tsc 编译 ts 文件是会完整保留这部分不做任何变动的

    基于这种方针,于是就有了两种解法

    1. 放弃 tsc 编译使用 bundle
    2. 下面的写法 2

    写法 2:import .js

    tsconfig 配置 module=nodenext 和 moduleResolution=nodenext ,然后 main.ts 内容如下

    import { add } from "./utils/calcute.js"; // 需要添加 .js 扩展名
    
    add(1,2)
    

    说真的,当年我接触到这种写法的时候是大受震撼的。 在 ts 文件中写 import .js 实在过于丑陋了。我不解、我不适应、我无法接受

    但这样的代码经过 tsc 编译后就能正常被 node 执行了,我也只能捏着鼻子用了

    本来以为 esm 的问题也就这样了,但没想到到了 2025 年就乱套了

    写法 3: import .ts

    因为 bun, deno 的竞争,不思进取的 node 终于开始迭代起功能了。甚至还破天荒地添加了直接执行 typescript 代码的功能(运行的时候直接丢弃类型信息把 ts 当 js 跑)

    这个功能现在在在新 node 中已经默认开启可用了,并且 typescript 也为了这个功能添加多个更新。所以可以预见今后用 node 直接执行 ts 会多起来

    然后,这个功能在 esm 上就不出意外得出意外了。还是上面的代码 main.ts 内容如下:

    import { add } from "./utils/calcute.js"; // 需要添加 .js 扩展名
    
    add(1,2)
    

    使用 node main.ts 执行后直接报错

    
    node main.ts
    
    ... 省略
    
      code: 'ERR_MODULE_NOT_FOUND',
      url: 'file:///home/xxxxxxxx/utils/calcute.js'
    
    

    嗯,因为模块的代码位于文件 utils/calcute.ts 中,而 import 语句中写的是 ./utils/calcute.js,所以 node 理所当然的找不到对应的模块文件报错了

    所以为了解决这个问题,tsconfig 后来添加了一个选项 allowImportingTsExtensions ,开启后在 main.ts 中需要将 import 改写成 import .ts 的形式

    import { add } from "./utils/calcute.ts"; // 需要 import .ts ,而不是.js
    
    add(1,2)
    

    嗯,当年 typescript 的回旋镖就这么砸了回来,现在我们又必须在 ts 文件中写 import .ts 了。并且为了兼容这种写法 typesript 现在还不得不添加新的编译选项 allowImportingTsExtensions 来允许在 ts 文件中 import .ts

    但是,这有个问题,启用这个选项必须也启用 noEmit ,也就是说在 typescript 官方那的说法是:我们没有被打脸啊,我们依旧不处理 import 的内容,你想 import .ts 可以,但是你这样写了的话就别用我们的 tsc 来把这种代码编译成 js 了

    但问题是实际上开发中,使用 node 直接执行 ts 文件测试,然后在生产环境中使用 tsc 或其他工具编译成 js 运行会很常见

    于是如果你想直接 node 执行 ts 代码,那就得放弃将使用 tsc 将代码编译为 js

    所以大家怎么选

    目前这 esm import 写法已经乱成这样了,大家平时会怎么选?

    63 条回复    2025-10-27 00:04:52 +08:00
    irrigate2554
        1
    irrigate2554  
       11 天前   ❤️ 2
    我选择少用 nodejs 生态
    shakaraka
        2
    shakaraka  
    PRO
       11 天前
    现在经受过的好几个新旧项目全部切换成 bun 了,再次也是选 deno 。业务上基本依赖的第三方包基本全是 esm ,如果没有的话我们会把项目拉下来,用 AI 修改为 esm 的方式,作为一个本地依赖
    craftsmanship
        3
    craftsmanship  
       11 天前 via Android
    ESM 确实是痛点问题 很乱很麻烦
    craftsmanship
        4
    craftsmanship  
       11 天前 via Android   ❤️ 1
    我的建议是
    - import 的扩展名为 .ts
    - tsconfig 里 module 和 moduleResolution 都设为 NodeNext
    无需 allowImportingTsExtensions 和 noEmit 且不存在你说的问题
    yooomu
        5
    yooomu  
       11 天前
    遇到了同样的问题,所以换了 deno
    SDYY
        6
    SDYY  
       11 天前
    我在 utils/index.ts 中 export
    使用 import x from "./utils"
    Ketteiron
        7
    Ketteiron  
       11 天前
    tsc 虽然能编译成 js ,但这不是它该干的活,毕竟它只是老老实实地把 ts 翻译成 js 没有任何优化,tsc 用来检查类型就行了。
    我的做法是 "moduleResolution": "bundler",后端使用 tsup/tsdown ,前端使用 vite 。
    虽然官方推荐显示指定扩展名,但说实话完全没必要,未来真有必要也可以写个脚本全加上。
    learnshare
        8
    learnshare  
       11 天前
    看来大家经验都差不多,生态很乱,还频繁遇到这些状况。
    统一成 ESM 挺好的,但执行起来不太顺利
    stinkytofux
        9
    stinkytofux  
       11 天前
    前端真的是乱成了一锅粥了.
    Ketteiron
        10
    Ketteiron  
       11 天前   ❤️ 1
    另外现阶段还是建议用 tsx(不是 react 的那个 tsx) 运行 ts 文件,直到 nodejs 没有这些问题了再说。
    Cbdy
        11
    Cbdy  
       11 天前
    import { add } from "./utils/calcute.ts";

    add(1,2)

    我是使用这种写法的,返璞归真,简单明了
    july1995
        12
    july1995  
       11 天前
    写了几天 Python ,我觉得 js 的生态还挺好的。Python 给我的感觉更混乱。
    Donahue
        13
    Donahue  
       11 天前
    @july1995 明明是 js 更乱
    SingeeKing
        14
    SingeeKing  
    PRO
       11 天前
    我选择不带扩展名 + 不用 tsc 做编译(只用它做类型检查)
    root71370
        15
    root71370  
       11 天前 via Android
    别吵了 明明是 java 最乱
    XCFOX
        16
    XCFOX  
       11 天前
    我选 tsx: https://tsx.is/
    facebook47
        17
    facebook47  
       11 天前 via Android
    @yooomu 这个现在可用了嘛?出来有些时间了,但是好像没什么浪花🤣🤣🤣
    rick13
        18
    rick13  
       10 天前
    @shakaraka 现在 bun 生产可用了吗,我看这几天才发 1.3
    mercury233
        19
    mercury233  
       10 天前
    import { add } from "./utils/calcute";
    这种写法就不应该支持 calcute.* 是文件的情况,只支持 calcute/package.json 就会清晰很多
    subframe75361
        20
    subframe75361  
       10 天前
    tsup 停止维护了,nodejs 只跑 tsdown 构建的代码,其他情况用 bun
    lqm
        21
    lqm  
       10 天前
    用 tsx 执行
    fds
        22
    fds  
       10 天前
    @rick13 #18 我拿来跑脚本还挺稳定的。
    nomagick
        23
    nomagick  
       10 天前
    恕我直言 node.js 的 esm loader 写了十年还是半成品,基本算是做死了
    就当 node.js 就只能运行纯 commonjs, tsc 的时候永远翻译成 cjs 。
    如果想运行 esm 那就用其他运行时。
    JamesMackerel
        24
    JamesMackerel  
       10 天前
    所以那个 go 写的 tsc 还有没有消息……
    shakaraka
        25
    shakaraka  
    PRO
       10 天前
    @rick13 #18 我都在 5 、6 个商业项目上用了 1 、2 年
    opengg
        26
    opengg  
       10 天前 via Android
    node 很多方面都是狗屎
    craftsmanship
        27
    craftsmanship  
       10 天前 via Android
    @opengg 愿闻其详
    uni
        28
    uni  
       10 天前
    5202 年了正确的方法是放弃 node 换 bun
    Ketteiron
        29
    Ketteiron  
       10 天前   ❤️ 1
    @nomagick #23 很多项目已经逐渐完全放弃 cjs ,也不提供 cjs 产物,全面转向 esm 是必然的事。
    这跟 esm loader 没多大关系,主要是几万个 package 一开始不愿意支持 esm ,毕竟它还能跑对吧。
    有些库作者激进地 esm-only ,用户又要问为什么不支持 cjs ,这十年是用户与作者们在拉扯,nodejs 对此是没什么办法的。
    esbuild 之类的工具尽量解决历史遗留问题,nodejs 没必要重新实现一遍,因为未来某个时间点会放弃 cjs 。
    Terry05
        30
    Terry05  
       10 天前
    这东西都喷到前端身上,这跟前端有一点关系吗
    molvqingtai
        31
    molvqingtai  
       10 天前
    我的建议 tsc 检查类型,打包不要用 tsc
    xu33
        32
    xu33  
       10 天前
    直接用 nextjs 有啥问题没,全 ts
    musi
        33
    musi  
       10 天前
    我选择用专业的工具进行打包,比如 esbuild/vite
    zogwosh
        34
    zogwosh  
       10 天前
    nodejs 这种垃圾只配当一个纯粹的 js 运行时使用,
    Zhousiru
        35
    Zhousiru  
       10 天前
    可以尝试下 extensionless: https://www.npmjs.com/package/extensionless
    pursuer
        36
    pursuer  
       10 天前
    用 AMD ,想怎么解析你可以自己写,不适合工具链打包场景

    https://github.com/partic2/partic2-iamdee

    https://ex.noerr.eu.org/t/1104713
    mengshouer
        37
    mengshouer  
       10 天前
    现有项目有什么就用什么
    nomagick
        38
    nomagick  
       10 天前   ❤️ 1
    @Ketteiron 不你不懂,node.js 的 esm loader 指的是从硬盘网络或文本 buff 加载 js 代码数据并最终转化成 js 对象的过程,其中涉及静态和动态加载,esm 文件中使用 require, 被 require 的文件中使用 import ,被加载的可能是硬盘文件,url 或者代码文本 buff 。 在简单加载之外又涉及到多个切面的插件,专门的加载线程,以及 node.js native binding 的特殊处理。 整个过程比你想像得复杂得多,具体流程一锅粥,代码写得一团乱麻,内部推不动外部看不懂,功能没写完就发布,标记成 Beta/RC ,根本用不了。
    FlashEcho
        39
    FlashEcho  
       10 天前
    我感觉解法一是不是其实是最常见的,为啥你不用解法一?放弃 tsc 编译使用 bundle 不行吗
    rocmax
        40
    rocmax  
       10 天前 via Android
    前一阵整理了一下正在开发的 monorepo ,把/packages 下面所有内部模块都改为只用 tsc 输出 d.ts 不编译 js ,/apps 下全部用 bundler 打包。
    streamrx
        41
    streamrx  
       10 天前 via iPhone
    @rick13 可以
    Ketteiron
        42
    Ketteiron  
       10 天前
    @nomagick #38 稍微研究了一下,确实很蛋疼
    https://github.com/nodejs/node/issues/52697
    https://github.com/nodejs/node/issues/55782
    更蛋疼的是目前依旧需要为 cjs 浪费大量人力,而 cjs loader 看起来越来越好
    https://github.com/nodejs/node/issues/52219
    esm loader 确实需要重构(顺便重构成以 C++ 为主),但阅读了相关 issue 几乎可以下结论——除非不再支持生态中的 cjs ,不然重构无法开头,有点理解你说的永远翻译成 cjs
    https://github.com/nodejs/node/issues/50356
    如果不准备放弃对 cjs 的支持,esm loader 理论上无法进行有效重构,且 nodejs 团队其中的一部分人员在 v24 依旧认为 cjs 不应该被放弃
    https://github.com/nodejs/node/pull/57460
    顺便还发现了 typescript 也放弃了 esm-only
    https://github.com/microsoft/TypeScript/pull/58419
    v2AKS
        43
    v2AKS  
       10 天前
    esnext 是给 web 项目用的,环境是浏览器,你用 node 执行环境是 Node.js 就要换成 nodenext
    zhennann
        44
    zhennann  
       9 天前
    vonajs 的模块化体系全量采用 typescript+esm ,支持 node24+,对 dev/build/prod 都做了适配,可以算是目前最佳的实践了: https://github.com/vonajs/vona
    Curtion
        45
    Curtion  
       9 天前
    我一般不使用 tsc 当做编译工具
    WarlockMan
        46
    WarlockMan  
       9 天前
    入乡随俗,在 nodejs 的环境内就明确给出后缀类型。js 生态中后缀有 .js 和 .mjs 之分,如果提供默认会让情况复杂化。不要过度依赖任何默认行为
    wu67
        47
    wu67  
       9 天前
    我的选择是, api 项目老实用 js, 前端项目由于有 vite 等社区生态, 倒是可以全 ts, 并且几乎没什么坑.
    aleviosa
        48
    aleviosa  
       9 天前
    > 但是,这有个问题,启用这个选项必须也启用 noEmit ,也就是说在 typescript 官方那的说法是:我们没有被打脸啊,我们依旧不处理 import 的内容,你想 import .ts 可以,但是你这样写了的话就别用我们的 tsc 来把这种代码编译成 js 了

    你看文档看错选项了,应该用 rewriteRelativeImportExtensions ,这样 tsc 编译出来的文件也会把.ts 改成.js ,allowImportingTsExtensions 那是给类型检查用的,不是给编译用的
    aleviosa
        49
    aleviosa  
       9 天前
    主楼的问题直接这样就搞定了

    ```ts
    // ./utils/calculate.ts
    export function add(a: number, b: number): number {
    return a + b;
    }
    ```

    ```ts
    // main.ts
    import { add } from "./utils/calculate.ts";

    add(1,2)
    ```


    用 Node.js 22 以上你可以直接 `node main.ts` 来跑,要编译分发就在 tsconfig.json 里加这个


    ```json
    // tsconfig.json
    {
    "compilerOptions": {
    "rewriteRelativeImportExtensions": true
    }
    }
    ```

    哪怕是 tsc ,编译出来的 main.js 就是这样

    ```
    import { add } from "./utils/calculate.js";
    add(1, 2);
    ```

    为啥会有那么多问题...
    Ketteiron
        50
    Ketteiron  
       9 天前
    @aleviosa #49 OP 的问题在于 TS 是先支持 '.js' 然后才支持 '.ts' 的
    更准确地说,nodenext 要求 import ts 文件使用 '.js',此时是 2022 年 5 月,nodejs 团队觉得这样的做法是错误的;然后推出新的 bundler 选项和 allowImportingTsExtensions 标志,可以选用无扩展名或者 '.ts',此时是 2022 年 11 月;然后推出 rewriteRelativeImportExtensions 来支持多平台的使用,此时已经 2024 年 11 月了。
    yanyiming
        51
    yanyiming  
       8 天前
    再加上 package.json 的搅合会更恶心.
    wangtian2020
        52
    wangtian2020  
       8 天前
    后端
    我后端都直接写在 node-red 里了,playground 的时候就写个 .mjs 单文件 node 运行 ( mjs 在 vscode 中会有完全类型提示)

    前端
    写了五年前端 AMD UMD CMD 我还分不清,一般导入的时候 vite 不报错就不管了

    electron
    electron 也用 vite 打包了,用超现代化的 ECMAScript Modules ,遇到不兼容的包只能自求多福了
    wangtian2020
        53
    wangtian2020  
       8 天前
    @Ketteiron nodejs 不愿意像 bun 一样破坏性的同时支持 .cjs 和 .mjs ,唯唯诺诺不够激进,人家 bun 连 sqlite 和 redis 都内置了,nodejs 还搁这编译 node_sqlite3 呢
    Ketteiron
        54
    Ketteiron  
       8 天前
    @wangtian2020 #53 这确实不容易实现,特别是有 cjs 这个历史包袱,直到最近这两个差不多可以真正互操作,除了一些比较罕见的用例。
    https://joyeecheung.github.io/blog/2024/03/18/require-esm-in-node-js/
    理论上委员会宣称 esm 为标准时,就应该逐渐放弃 cjs ,但直到 2025 年依然还是 cjs first 。
    不过我对 deno 和 bun 的未来不太看好,当 cjs 名义上死亡后,deno 和 bun 用户大概又会迁移回来,虽然这可能会花费十年到无数年。
    kuanat
        55
    kuanat  
       8 天前
    说点跑题的事情,C++20 才引入 Modules ,等生态成熟还早。ESM 应该会成为主流,就是时间可能会很长……

    另外我个人认为,任何现代语言都应该有 opinionated 官方统一 fmt/lint ,当然老语言没办法。
    humbass
        56
    humbass  
       8 天前
    TS 本身就是 vscode 的阳谋,离开 vscode 编辑器 ts 啥都不是。 中小项目直接上 ES 就可以了,等体操搞出来,小公司基本要倒闭了。
    aleviosa
        57
    aleviosa  
       8 天前
    @Ketteiron 都 2025 年了 npm 上最流行的分发格式还是 cjs ,哪怕写的是 esm 实际跑的也还是编译成 cjs ,现实中恐怕 80%以上的代码都是以 cjs 在跑 https://github.com/wooorm/npm-esm-vs-cjs/tree/main

    放弃 cjs 的后果大概是 Node.js 自己被社区抛弃或者 fork ,你看 bun 和 deno 也支持 cjs ,bun 甚至还专门写了一篇 https://bun.com/blog/commonjs-is-not-going-away
    AV1
        58
    AV1  
       8 天前
    @humbass
    正常业务开发都是把 TS 当注释用的,哪有空玩类型体操。
    现在连 python 、lua 、php 都有自己的 type hints 了,与其 TS 说是 vscode 的阳谋,不如说动态语言拥抱类型检查是历史的必然。
    humbass
        59
    humbass  
       8 天前 via Android
    @AV1 类型并不是必须,只是原来静态开发人士带过来的习惯。js 的动态才是特色,也是能活下来这么多年的原因,否则写 java go 不就行了。如果不是这一波 AI 潮,Python 这种都不一定能活下来。
    burnsby
        60
    burnsby  
       7 天前
    确实很乱 我现在的做法是将 bun 用作包管理器,使用 Vite 开发前端, 使用 bun 开发后端然后直接编译成可执行文件,我相当激进,几乎每隔一个月我都会把项目的依赖升级一遍,涉及到大版本更新的我会看一下更新日志然后做一些更改。这样的好处是不用担心过个一两年开发的时候报一堆警告说什么过期不过期
    cleveryun
        61
    cleveryun  
       7 天前
    我感觉生产环境上使用时不用提前先把.ts 转译成.js 了,我自己的项目是直接 node 去跑的(用的最新的 LTS 版本,已经支持直接执行 typescript 了)。

    至于你说的引用文件的后缀名的问题,你可以在项目根目录下的 `package.json` 里定义 `imports` 字段来解决:

    ```json
    {
    "imports": {
    "#middlewares/*": "./src/middlewares/*.mts",
    "#routes/*": "./src/routes/*.mts",
    "#scripts/*": "./src/scripts/*.mts",
    "#models/*": "./src/models/*.mts",
    "#controllers/*": "./src/controllers/*.mts",
    "#services/*": "./src/services/*.mts",
    "#src/*": "./src/*.mts",
    "#build/*": "./build/*.mts",
    "#i18n/*": "./i18n/*.mts",
    "#hosts/*": "./hosts/*.mts",
    "#schedules/*": "./schedules/*.mts",
    "#root/*": "./*.mts"
    },
    }
    ```

    然后你的代码里就可以这样引用文件了(后缀名在 imports 里定义过了):

    ```typescript
    import routerRoot from "#routes/root";
    import { registerRoutesExceptForRoot } from "#routes/index";
    ```
    AV1
        62
    AV1  
       7 天前
    @humbass
    现代新出现的编程语言绝大多数都带类型检查了,总不能说是因为“动态开发人士”能力差,没本事像“静态开发人士”那样开发出有影响力的编程语言吧。
    动不动态也不是 js 活下来的原因。跟 python 同理,如果不是这一波 web 潮,js 这种都不一定能活下来。
    DINGONE
        63
    DINGONE  
       3 天前 via iPhone
    换 Python 🙃
    关于   ·   帮助文档   ·   自助推广系统   ·   博客   ·   API   ·   FAQ   ·   Solana   ·   1244 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 30ms · UTC 23:46 · PVG 07:46 · LAX 16:46 · JFK 19:46
    ♥ Do have faith in what you're doing.