把公司内部 UI 库搬进小程序:npm 构建与自定义组件发布的踩坑清单
组件库在 Web 端跑得好好的,一进小程序就变成三堆散落的 wxml/wxss/js 文件靠复制粘贴同步——这个问题我们团队被折磨了半年,直到把内部 UI 库完整迁到 npm 链路上,才真正摆脱「改一个按钮要动四个仓库」的日子。这篇文章把整条链路拆开讲:组件库仓库怎么发包、小程序项目怎么配双 package.json、构建 npm 底层做了什么、样式隔离怎么绕、真机缓存为什么死活不生效,以及版本升级怎么灰度。
先看全景:这条链路一共有几段
很多文章只讲「构建 npm」按钮在哪,不讲上下游,结果就是照做能跑、换环境就崩。完整链路其实分五段,每段都可能出问题:

flowchart LR
A[组件库仓库<br>源码 + 构建产物] --> B[npm 私服发包<br>语义化版本]
B --> C[小程序项目<br>package.json 依赖]
C --> D[构建 npm<br>生成 miniprogram_npm]
D --> E[页面引用组件]
E --> F[灰度升级<br>版本锁定 + 回滚]
我们的做法是组件库仓库单独建 CI:每次打 tag 触发 gulp 构建,产物推到公司私服(verdaccio)。小程序项目里只在 package.json 声明依赖版本,构建动作交给开发者工具或 miniprogram-ci。谁负责产出、谁负责引入,边界必须先划清,否则后面每次升级都是一场扯皮。
双 package.json 到底怎么摆
这是第一个大坑。微信要求「构建 npm」时能找到一个含 miniprogram 字段或者结构合法的 package.json,而根目录的 package.json 又承担着 devDependencies(eslint、typescript 这些)的职责。官方给的解法是 packNpmManually:你告诉工具「别自己猜了,我手动指定」。
先看目录结构:
flowchart TD
R[项目根目录] --> P1["package.json(根)<br>devDependencies:构建工具链"]
R --> PC["project.config.json<br>packNpmManually: true"]
R --> M[miniprogram 目录]
M --> P2["package.json(小程序)<br>dependencies:@company/ui"]
M --> MIN["miniprogram_npm<br>构建产物,需提交或忽略"]
M --> PAGES[pages]
M --> COMP[components]
P2 -.构建 npm 读取.-> MIN
对应的 project.config.json 关键配置(在微信开发者工具稳定版 + miniprogram-ci 2.x 环境下验证):
// project.config.json 核心片段,其余字段省略
// 适用环境:微信开发者工具稳定版 + miniprogram-ci 2.x
// 三段配置缺一不可,先整体看一遍再逐项解释
{
// miniprogramRoot:告诉工具小程序代码根目录在哪
// 不配的话工具会拿项目根目录当代码根,构建全乱
"miniprogramRoot": "miniprogram/",
// setting 节点下的两段是构建 npm 的开关与映射
"setting": {
// packNpmManually:关闭自动探测,改用手动指定
// 自动模式只在根目录找依赖,双 package.json 结构必须手动
"packNpmManually": true,
// packNpmRelationList:描述「依赖声明在哪、产物吐到哪」的映射
// 是数组,所以多个分包可以各自挂一条映射规则
"packNpmRelationList": [
{
// packageJsonPath:依赖声明所在的位置,指向子目录那份
"packageJsonPath": "./miniprogram/package.json",
// miniprogramNpmDistDir:构建产物的输出目录
// 末尾斜杠别漏,漏了产物会写到上级目录导致引用全断
"miniprogramNpmDistDir": "./miniprogram/"
}
]
}
}
三个字段各司其职:miniprogramRoot 决定工具从哪里算小程序根;packNpmManually 关掉自动模式;packNpmRelationList 描述「依赖声明在哪、产物吐到哪」的映射,支持数组所以可以多个子包各自构建。这里有个细节,miniprogramNpmDistDir 末尾的斜杠别漏,漏了会直接把 miniprogram_npm 写到上级目录,页面引用路径全断。我们组里新人第一次配就踩了这个,报错原文是 module "miniprogram_npm/@company/ui/index.js" is not defined,排查了两小时才发现产物躺在了错误的位置。
两个 package.json 的职责划分如下表:
| 文件 | 放什么 | 构建npm 是否读取 | 常见误放内容 |
|---|---|---|---|
| 根 package.json | eslint / ts / 构建脚本 | 否(手动模式下被跳过) | 把 @company/ui 放进这里 |
| miniprogram/package.json | 运行时依赖(组件库) | 是 | devDependencies 混入其中 |
根目录那份永远只放工具链依赖,组件库只出现在 miniprogram 子目录的 dependencies 里。放错位置的典型症状是:工具提示「找不到可以构建的 npm 依赖」,但其实包明明装好了。
原理剖析:构建 npm 到底做了什么
理解这一节,后面一半的坑你能自己推断出来。开发者工具(或 miniprogram-ci 的 packNpm)拿到 packNpmRelationList 后,做的事可以拆成四步:
- 定位:读取指定 package.json 的 dependencies,去 node_modules 里找对应包;
- 校验:检查包的 package.json 里
miniprogram字段(旧版用main),这个字段指向的目录才是真正要被复制的内容——不是整个包; - 转换:把目录内容拷贝(新版本会走本地编译)到 miniprogram_npm,期间做压缩、babel 转换、按需处理;
- 改写:运行时
require走的是相对查找,小程序没有 node_modules 解析机制,所以引用路径@company/ui实际映射到miniprogram_npm/@company/ui。
第一步的「校验」是大多数发包问题的源头。如果组件库发包时没在 package.json 里写 "miniprogram": "dist",工具会退回用 main 字段,一旦 main 指向的是带源码的入口而非构建产物,轻则体积爆炸,重则 Less 变量、TypeScript 类型文件被原样打进包里导致编译报错。发包产物的 package.json 必须带 miniprogram 字段,这句话值得写进组件库的发布 checklist。
转换环节还有个隐蔽行为:工具会对 node_modules 里的包做压缩和 ES6 转 ES5。如果组件库构建产物本身已经是压缩过的(比如 terser 处理过),再被压一轮可能出问题;我们内部统一约定产物只做 babel 到 ES5、不做 minify,把压缩交给构建 npm 这一步,避免双重处理。
组件库发包:版本管理别靠嘴上约定
发包本身没什么神秘的,npm publish 而已,难的是版本纪律。我们踩过的教训是三周内连发 2.1.0 到 2.1.7 七个补丁版本,但没有一个版本号能对上实际改动,最后只能靠回滚到某个 git commit 才定位问题。现在的规则简单粗暴:
- 组件 API 变更、删字段:major,提前一周在群里预告;
- 新组件、新增属性:minor;
- 样式修复、逻辑 bug:patch,且必须在 commit message 里带 issue 编号;
- 发版用
npm version命令打 tag,CI 校验 tag 与 package.json 一致才允许 publish。
发包产物的 package.json 检查项(在 verdaccio 4.x 私服上验证过):
// 组件库构建后产物里的 package.json,发布前 CI 会逐项检查
// 在 verdaccio 4.x 私服上验证过整条链路
{
"name": "@company/miniprogram-ui",
// version:npm version 命令自动维护,禁止手改
"version": "2.3.0",
// miniprogram:关键中的关键,告诉构建 npm 只拷 dist 目录
// 漏掉这个字段工具会退回用 main,产物质量完全失控
"miniprogram": "dist",
// main:兜底入口,建议与 miniprogram 指向保持一致
"main": "dist/index.js",
// files:白名单机制,只发布 dist 和说明文档
// Less 源码、测试文件统统挡在包外
"files": ["dist", "README.md"],
// sideEffects:声明无副作用,便于构建按需处理
"sideEffects": false
}
还有一条容易被忽略:组件库里如果有 .wxss 引用了公共变量文件,发包前要把 Less/Sass 编译掉,产物里只留纯 wxss。我们有一次把 common.less 一起发了出去,结果业务项目构建 npm 时直接编译失败,报错是 unknown file extension .less——工具不会替你跑预处理器。
externalClasses 与 styleIsolation:样式隔离的两把钥匙
自定义组件默认样式隔离,业务方想覆盖组件内部样式会发现在页面 wxss 里写选择器完全无效。解法有两条路,按场景选。
第一条是 externalClasses:组件声明「我接受外部样式类」,调用方把 class 名传进来。适合预埋样式钩子的场景:
// components/button/index.js
Component({
options: {
// addGlobalClass 与 styleIsolation 二选一,这里用更细粒度的声明
styleIsolation: "isolated"
},
externalClasses: ["btn-class", "btn-hover-class"], // 声明可被外部覆盖的样式类
properties: {
type: { type: String, value: "default" }
}
});
<!-- 调用方:传入自定义样式类 -->
<ui-button btn-class="home-page-btn">提交</ui-button>
第二条是 styleIsolation 配置,取值和行为见下表:
| 取值 | 生效范围 | 典型用途 |
|---|---|---|
| isolated | 完全隔离,互不影响 | 通用组件默认值 |
| apply-shared | 页面样式能进组件 | 需要吃主题变量的业务组件 |
| shared | 页面与组件互相影响 | 强耦合的页面级私有组件 |
组件库里有个坑值得单独说:组件 A 引用了组件 B,B 想吃 A 所在页面的样式,光在 B 上配 apply-shared 没用,因为「页面样式」对被嵌套的组件来说隔了一层。我们的做法是主题变量全走 CSS Custom Properties(wxss 支持 var(--x)),组件内部用 var() 取值,隔离照旧但换肤能力不受影响。另外 shared 模式在组件库这种被多个项目引用的包里慎用——它等于把命名冲突的锅甩给所有接入方。
真机缓存不生效:清单在这里
「开发者工具里好好的,真机上样式/逻辑还是旧的」大概是我们内部群里出现频率最高的问题。原因基本都指向缓存链路,逐项排查:
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 真机样式旧 | 微信客户端缓存了旧代码包 | 删除小程序重新打开,或开发版点「清除缓存」 |
| 构建后引用报错 | miniprogram_npm 未更新,构建产物被 git 忽略 | 每次拉代码后重新执行「构建 npm」 |
| 预览正常、体验版旧 | 上传时未先构建 npm,产物缺失 | CI 里把 packNpm 放在 upload 之前 |
| 部分机型不变 | 真机基础库缓存 | 开发版勾选「下次编译时模拟」并重进 |
| npm 包明明升级了 | 锁文件没更新,装的还是旧版 | 提交 package-lock.json 并核对 dist-tag |
第二行的坑最冤枉:miniprogram_npm 是构建产物,我们最初 gitignore 了它,结果每个同事 clone 完项目都要手动构建一次,忘了就是一串报错。后来干脆把产物提交进仓库,配合 CI 校验产物与 lock 文件一致。产物要不要进 git 没有标准答案,但必须和团队流程绑定。
灰度升级:别让一次发包停掉所有业务线
组件库 2.x 升 3.x 那次,我们学到最多的一课:组件库的 breaking change 对接入方是灾难,除非升级节奏可控。现在用的是「双版本并行 + 逐项目迁移」:
- 组件库 dist-tag 区分
latest与next,3.x 发到 next; - 业务项目的 miniprogram/package.json 里锁
2.x,观望的团队不受影响; - 愿意尝鲜的项目改锁
next,跑一周回归再切 latest; - 全量切换后,旧 major 冻结维护,只接安全修复。
升级当天的 CI 脚本核心逻辑(基于 miniprogram-ci):
// scripts/release.js,跑在组件库仓库的 tag 流水线里
// 依赖:miniprogram-ci 2.x、Node 18+、CI 注入的环境变量
const ci = require("miniprogram-ci");
const { execSync } = require("child_process");
// 读取组件库当前版本号
const version = require("../package.json").version;
// 校验 git tag 与 package.json 版本一致,防止手滑发包
// git describe --exact-match 要求当前 commit 必须打了对等 tag
const tag = execSync("git describe --exact-match --tags").toString().trim();
// 不一致直接抛错,流水线标记失败,包不会被推上去
if (tag !== "v" + version) {
throw new Error("tag 与版本号不一致,终止发布: " + tag);
}
// 实例化项目对象,等价于在开发者工具里打开了这个项目
// appid 与私钥都从 CI 环境变量注入,不落仓库
const project = new ci.Project({
appid: process.env.MP_APPID,
type: "miniProgram",
// projectPath 指向子目录,与 miniprogramRoot 保持同源
projectPath: "./miniprogram",
privateKeyPath: "./private.key"
});
// packNpm 等价于开发者工具里的「构建 npm」按钮
// 放在 upload 之前跑,保证产物缺失时流水线先失败
ci.packNpm(project, {
// ignores:排除规则,node_modules 本体永远不进产物
ignores: ["node_modules/**/*"]
}).then(() => console.log("packNpm done:", version));
这套流程跑了一年,升级窗口从原来的「全员周五晚上加班」变成业务方自己挑时间。回滚也简单,lock 文件改回旧版本、重新构建即可,真机上重新上传一遍就恢复。
参考与延伸
- 微信小程序 npm 支持(官方文档)
- 自定义组件样式隔离 styleIsolation(官方文档)
- miniprogram-ci 快速入门(官方文档)
- 自定义组件 externalClasses(官方文档)
写在最后
把组件库 npm 化改造完,真正的收益不是少复制几次文件,而是版本、回滚、灰度这些工程能力第一次落到了小程序端。还有一个常见误区要澄清:构建 npm 不是「打包工具」,它不做 tree-shaking,依赖声明了多少包基本就进多少内容,所以组件库按组件目录拆分入口、业务侧按需 require,仍然是你自己的责任。后续如果你们有多个小程序主体,可以再研究分包异步化和 useExtendedLib 的取舍。踩过别的坑欢迎评论区聊,这份清单我打算持续更新下去。
关键词:微信小程序、npm 构建、自定义组件、组件库发布、styleIsolation、miniprogramRoot、真机调试