Monorepo 中如何正确共享工具函数而不打包重复代码?

令狐恒菽 阅读 4

我用 pnpm 搭了个 Monorepo,里面有个 shared 包导出了一些工具函数,但在 apps 里引用时发现每个子项目都把 shared 的代码打包进去了,导致体积变大。明明 shared 是 ESM 格式,也配了 exports 字段,咋还是被重复打包?

比如我在 shared 里写了:

export const formatDate = (date) => {
  return new Date(date).toLocaleDateString();
};

export const debounce = (fn, delay) => {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
};

然后在 app1 里 import { formatDate } from ‘@myorg/shared’,结果 vite 打包后发现整个 shared 都被打进来了,哪怕我只用了一个函数。是不是我的 package.json 配置有问题?

我来解答 赞 2 收藏
二维码
手机扫码查看
1 条解答
慕容栾诺
这个问题我太熟了,之前踩过同样的坑。问题的核心在于你的 shared 包虽然写了 ESM 语法,但打包工具并不一定把它当成 ESM 来处理。咱们一步步排查。

先说原理,为什么会被整包打进去。Tree-shaking 能生效需要满足几个条件,必须是 ESM 格式、package.json 要声明 sideEffects、还有 exports 或 main 字段要指向正确的入口文件。缺一个都不行。

咱们先看看 shared 包的 package.json 应该怎么配:

{
"name": "@myorg/shared",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"files": ["dist"]
}


这里需要注意几个关键点。sideEffects 设为 false 是告诉打包器这个包没有副作用,可以放心地 tree-shaking。type 设为 module 确保 Node 把它当 ESM 处理。exports 字段要明确指定 import 条件。

但光配这个还不够,你的构建产物本身也得是 ESM 格式。很多人用 tsup 或者 unbuild 来打包 shared 包,配置不对的话打出来的可能是 CJS 或者伪 ESM。

我推荐用 tsup,配置简单:

// shared/tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'], // 只输出 ESM,别整 CJS
dts: true, // 生成类型声明
splitting: false, // 小工具库不需要 code splitting
sourcemap: true,
clean: true,
treeshake: true, // tsup 内部也会做 tree-shaking
minify: false, // 库文件不建议压缩,方便调试
});


如果你不想打包,想直接用源码,那也可以。Vite 是支持直接引用 TypeScript 源文件的,但需要配 resolve.alias 或者用 workspace 协议。不过我更建议打包一下,这样类型声明和入口都清晰。

还有个容易忽略的点,你的工具函数写法也得注意。比如你那个 debounce,如果写成 default export 就会影响 tree-shaking 效果:

// ❌ 不推荐,default export 有时候 shake 不干净
export default function debounce(fn, delay) { ... }

// ✅ 推荐,named export 更友好
export const debounce = (fn, delay) => { ... };


然后是 Vite 那边的配置,确保它正确解析你的 monorepo 依赖:

// apps/app1/vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
resolve: {
// 确保符号链接的包被正确解析
dedupe: ['@myorg/shared'],
},
build: {
// 确保开启 tree-shaking(默认就是开启的)
rollupOptions: {
output: {
// 看看打包后的模块划分情况
manualChunks: undefined,
},
},
},
});


如果你用的是 pnpm workspace,记得在根目录的 pnpm-workspace.yaml 里正确声明包位置,然后 shared 包的依赖关系要用 workspace 协议:

// apps/app1/package.json
{
"dependencies": {
"@myorg/shared": "workspace:*"
}
}


最后教你个排查方法。打包完成后,用 rollup-plugin-visualizer 或者直接看 dist 目录的文件大小,如果 shared 相关的代码比预期大很多,说明 tree-shaking 没生效。

还可以在 Vite 打包时加上 --debug 参数,或者用 ANALYZE=true vite build 配合 rollup-plugin-visualizer 看看具体哪些代码被打进去了。

总结一下关键点:package.json 要配 sideEffects: false 和正确的 exports、构建产物必须是纯正 ESM、用 named export、确保 pnpm workspace 链接正确。按这个路子走,你的 shared 包就能被正确地按需打包了。
点赞
2026-03-02 23:38