为什么总是被 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"
],不论是通过 main、module,还是早期的路径设计,业务项目永远只能依赖「构建结果」,而不是「源码」本身。
这个模式,在早期「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」中修改一行代码时,真实发生的事情是:
修改 packages/core/src/index.ts
手动或自动触发「core」的 build
TypeScript 编译输出到 dist
「app」重新加载 dist/index.js
你才能在「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 映射到该包上
修改 vite 的 alias,直接指向到 src 里
这些方案只在局部场景下有效,但缺都有明显缺陷:
只对特定工具生效(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 类型 都可以直接跳转到源码。
这就可以达到很好的开发效果:
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 时:
npm 会读取 publishConfig 中的字段
用 publishConfig 中的配置覆盖 package.json 对应字段
.exports 会被替换成 publishConfig.exports
其他字段(如 main / module)也会被覆盖(如果在 publishConfig 中声明过)
发布到 registry 的包,最终的 package.json 中 exports 指向 dist 构建产物。
这就直接可以:
发布到 npm 的时候指向构建产物
node 能直接运行
完全符合了 npm 包规范
同样的,如果你习惯 exports 配置多个入口:
"exports": {
".": "./src/index.ts",
"./utils": "./src/utils/index.ts"
}然后同步修改发布态 utils 即可~
实践总结
这套 DX 友好的 exports 配置,本质是:
开发阶段直接消费源码
发布阶段严格指向构建产物
开发体验与运行时安全解耦
.jpg)