为什么总是被 build 拖慢?

在传统的库开发模式中,构建产物 也就是 dist 目录,是库的唯一入口

"exports": {
    "./package.json": "./package.json",
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./helper": {
      "types": "./dist/helper/index.d.ts",
      "import": "./dist/helper/index.mjs",
      "require": "./dist/helper/index.cjs"
    },
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "files": [
    "dist",
    "package.json"
  ],

不论是通过 mainmodule,还是早期的路径设计,业务项目永远只能依赖「构建结果」,而不是「源码」本身

这个模式,在早期「JavaScript、单包、低频修改」的时代还能接受,但是在以下场景中会完全成为开发效率的瓶颈:

  • TypeScript 项目:任何改动都要进行构建

  • 你的库被业务依赖:你正在开发业务项目,它依赖了这个库,而你希望库的改动立即生效。

  • 高频调试与联调:你在改这个库的 bug,然而无法立即看到效果,只能一次次的 build。

典型开发场景

假设存在这样个项目:

some-repo/
├─ packages/
│  ├─ core/        # 一个工具库
│  │  ├─ src/
│  │  │  └─ index.ts
│  │  ├─ dist/
│  │  │  └─ index.js
│  │  └─ package.json
│  └─ app/         # 业务项目
│     └─ src/

「core」是一个被「app」依赖的工具库,它的 package.json 是:

{
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"   // <-- 任何库都需要被构建出 ts 类型,这是我们现代工程化的基本要求
}

当你在「core」中修改一行代码时,真实发生的事情是:

  1. 修改 packages/core/src/index.ts

  2. 手动或自动触发「core」的 build

  3. TypeScript 编译输出到 dist

  4. 「app」重新加载 dist/index.js

  5. 你才能在「app」中看到效果

任何一次源码改动,都会被强制插入一个 build 阶段**。**

build 成为瓶颈的具体原因

build 是串行的

  • 即使只改了一行逻辑

  • 也要走完整个编译流程

  • 构建速度直接决定更新速度(你看到效果、得到反馈的速度)

watch 并不能真正解决问题

即使开启 watch:

  • 仍然存在延迟

  • 仍然需要维护额外进程(它还会占用大量内存

  • 构建失败会直接阻断联调

dist 与 src 的割裂增加了认知成本

  • 报错堆栈指向「dist」

  • sourcemap 偶尔失效

  • IDE 跳转的是 dist 的 d.ts

在 Monorepo 中,这个问题会被放的更大

在 Monorepo 场景下:

  • 一个包的改动,可能影响多个下游

  • 多个包互相 watch

  • 构建互相依赖

最终导致:

我们在**等待构建,而不是在写代码。**

这个的本质问题是,它的入口被强制绑定在了「发布状态」

这在逻辑上是不合理的:

  • 我们在发布阶段关注的是 稳定性、兼容性

  • 我们在开发阶段关注的是 更新反馈速度、可调试性

两者的目标完全不同,却被同一个入口强行绑定了。

为什么问题的解法不是解决 build

可能很多人都会想说:为什么不能把 build 在快一点?watch 再智能一点?

因为这些手段,本质上都在优化构建流程本身,而不是在解决根本问题

这个 build 阶段的问题,并不只是「慢」,而是「不必要」。

经过 build 后,我们实际上在调试构建产物,而不是业务逻辑本身

绕路方案的局限

回到问题可以发现:

开发效率被拖慢,并不是因为 build 太慢,而是因为开发阶段根本不该经过 build。

只要模块入口仍然绑定在 dist:

  • 构建就是不可避免的

  • watch 只是延迟而不是消除

  • 调试对象仍然是产物而不是源码

在一些工程实践中,我们尝试过以下方案:

  • pnpm link 了包,然后将 tsconfig 的 path/alias 映射到该包上

    • img

  • 修改 vite 的 alias,直接指向到 src 里

    • img

这些方案只在局部场景下有效,但缺都有明显缺陷:

  • 只对特定工具生效(TS / bundler / IDE 不一致)

  • 与 node、vite 的真实解析不一样

所以说是「绕路」,他们只是「尝试绕过问题」,并没有「解决问题」。

正确的切入点

真正决定开发体验的关键问题是:

当一个包被 import 时,解析到的文件到底是哪一个?

这个问题并不属于:

  • build 工具

  • bundler 配置

  • IDE 插件

而是属于 包本身的「模块暴露」定义

也正是在这一层,Node.js 引入了 exports 机制。

让我们再复习下 exports:

exports 是包的公开 API 映射表:

  • 指明哪些路径可以被 import

  • 可针对不同环境设置条件导出

  • 替代早期 main/module 的单一入口概念

DX 友好型 exports 策略

原则

  • 开发状态直接指向源码

  • 发布则指向构建

  • 运行时不暴露 ts

开发态配置

{
  "exports": {
    ".": "./src/index.ts",
    "./package.json": "./package.json"
  }
}

会发生什么?

回到上面 core、app 的例子,当我们本地直接安装这个 core 包,node 解析的都是 src/index 这个入口,IDE 跳转、ts 类型 都可以直接跳转到源码

img

这就可以达到很好的开发效果:

  • TS / bundler 直接关联源码

  • 零构建联调

  • IDE 跳转直达源代码

  • 类型定义与实现一致

发布态配置示例

通过 publishConfig 覆盖(pnpm/npm/yarn 都支持):

{
  "publishConfig": {
    "exports": {
      ".": {
        "require": "./dist/index.cjs",
        "import": "./dist/index.js"
      },
      "./package.json": "./package.json"
    }
  }
}

当执行 npm publish / pnpm publish 时:

  1. npm 会读取 publishConfig 中的字段

  2. 用 publishConfig 中的配置覆盖 package.json 对应字段

    1. .exports 会被替换成 publishConfig.exports

    2. 其他字段(如 main / module)也会被覆盖(如果在 publishConfig 中声明过)

  3. 发布到 registry 的包,最终的 package.json 中 exports 指向 dist 构建产物。

这就直接可以:

  • 发布到 npm 的时候指向构建产物

  • node 能直接运行

  • 完全符合了 npm 包规范

同样的,如果你习惯 exports 配置多个入口:

"exports": {
  ".": "./src/index.ts",
  "./utils": "./src/utils/index.ts"
}

然后同步修改发布态 utils 即可~

实践总结

这套 DX 友好的 exports 配置,本质是:

  • 开发阶段直接消费源码

  • 发布阶段严格指向构建产物

  • 开发体验与运行时安全解耦