组件库开发的第一个环节往往不是写组件本身,而是搭好一套让 Rollup 正确处理内部组件引用关系的构建配置。不少团队在本地开发时一切正常,一旦执行打包命令,产物里就出现了组件被重复打入、路径报错或者按需加载失效的情况。这些问题的根源大多出在内部组件导入方式与 Rollup 配置没有对齐。本文围绕这一主题,从目录结构、别名解析、external 配置到产物模式选择,逐层拆解解决方案。

一、内部组件导入的常见问题从哪来
先看一个典型的组件库目录结构:src/components/button/index.ts、src/components/button/src/button.vue、src/components/button/src/button.scss,每个组件文件夹自带入口文件。组件之间存在依赖,比如 Select 组件内部引用了 Input 和 Icon。如果这些引用全部写成相对路径,形如 import Button from '../../button/index.vue',一旦目录结构调整,几十处引用都要跟着改,维护成本非常高。
更隐蔽的问题是打包结果。Rollup 默认会把所有被引用的模块合并进产物。如果不做任何声明,Select 打包时会把 Input 和 Icon 一并打入,Form 打包时又打一次,最终每个组件的产物都包含重复代码,按需加载形同虚设,体积还成倍增长。这就是所谓的内部组件被重复打包问题。
此外,TypeScript 项目里通常会用 tsconfig.json 的 paths 做路径映射,但 Rollup 本身不读取这份配置,构建时就会报找不到模块的错误。开发者需要理解,路径解析发生在打包器层面,必须显式告诉 Rollup 如何将别名还原成真实路径。
二、用别名配置统一内部导入路径
解决导入混乱的第一步是统一引用方式。约定所有内部组件都通过别名引用,例如 import Button from '@/components/button'。为了让 Rollup 识别这个别名,需要安装并配置 @rollup/plugin-alias。该插件会在解析阶段把别名替换为真实的文件系统路径。
import alias from '@rollup/plugin-alias';
import path from 'path';
export default {
input: 'src/index.ts',
plugins: [
alias({
entries: [
{ find: '@', replacement: path.resolve(__dirname, 'src') }
]
})
]
};如果项目同时使用 Vite 做开发环境,注意保持两边的别名规则一致,否则开发时能跑、打包时报错。一个稳妥的做法是把别名定义抽到单独的 alias.config.js,Rollup 与 Vite 共同引用,避免两份配置各自漂移。
TypeScript 项目还需要在 tsconfig.json 中配置对应的 paths,保证类型检查与代码提示正常工作:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}三、避免重复打包:external 与 preserveModules 的配合
要实现真正的按需加载,关键在于让每个组件产物保留独立的模块结构,而不是全部挤进一个 bundle。Rollup 提供了 output.preserveModules 选项,开启后产物会按照源码的目录结构逐个输出文件,配合 preserveModulesRoot: 'src' 可以把模块根目录定位到 src,使产物路径更干净。
同时需要正确配置 external。外部依赖(如 vue、lodash-es)必须声明为 external,交给业务方项目的打包器处理;而组件库内部的引用则不要加入 external,让 preserveModules 把它们保留为模块间的相对引用,这样业务方按需引入某个组件时,它依赖的内部组件会由业务方的打包器合并,不会重复。
export default {
input: 'src/index.ts',
external: (id) => /^(vue|lodash-es|@my-lib\/utils)/.test(id),
output: [
{
dir: 'dist/es',
format: 'esm',
preserveModules: true,
preserveModulesRoot: 'src',
entryFileNames: '[name].mjs'
}
]
};这里有一个容易踩的坑:external 的函数写法里,判断条件一定要精确。如果误把内部组件路径也匹配进去,产物中的 import 语句会指向一个不存在的路径,业务方引入时直接报错。建议对内部包统一使用 @my-lib/ 前缀命名空间,external 函数只匹配这个前缀加具体的工具包名,而不是前缀本身。
对于 npm 包形式的内部子包,还可以利用 package.json 的 dependencies 自动判定外部依赖。社区提供的 rollup-plugin-peer-deps-external 或自行遍历依赖字段生成 external 数组,都能减少手动维护的成本。
四、样式文件与类型声明的处理
组件库除了 JS 逻辑还有样式。常见做法有两种:一是每个组件单独打包出 css,配合 preserveModules 输出同名样式文件;二是提供一份全量的样式入口。前者依赖 rollup-plugin-postcss,并配置 extract: true:
import postcss from 'rollup-plugin-postcss';
plugins: [
postcss({
extract: true,
minimize: true,
extensions: ['.css', '.scss']
})
]类型声明方面,建议用单独的 tsc 任务生成 .d.ts 文件,不要混进 Rollup 流程。执行 tsc --emitDeclarationOnly 输出到 dist/types,再在 package.json 中通过 types 字段指向入口声明文件。组件内部的类型引用同样要走别名映射,保证生成的声明文件中 import 路径可以被消费方解析。
最后,在 package.json 中正确声明 module、main 与 exports 字段,把 ESM 产物指向 dist/es 目录。业务方按需引入 @my-lib/button 时,通过 exports 的子路径映射就能精确命中单个组件产物,配合 tree-shaking 达到理想的体积控制效果。
总结一下,解决内部组件导入问题的核心思路是三件事:别名统一引用入口、external 划清内外边界、preserveModules 保留模块粒度。三者配合到位,组件库的产物才能做到结构清晰、按需可用、互不重复。