把公司内部 UI 库搬进小程序:npm 构建与自定义组件发布的踩坑清单

2026-09-29 01:28:27 0 次浏览
微信小程序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 后,做的事可以拆成四步:

  1. 定位:读取指定 package.json 的 dependencies,去 node_modules 里找对应包;
  2. 校验:检查包的 package.json 里 miniprogram 字段(旧版用 main),这个字段指向的目录才是真正要被复制的内容——不是整个包;
  3. 转换:把目录内容拷贝(新版本会走本地编译)到 miniprogram_npm,期间做压缩、babel 转换、按需处理;
  4. 改写:运行时 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 对接入方是灾难,除非升级节奏可控。现在用的是「双版本并行 + 逐项目迁移」:

  1. 组件库 dist-tag 区分 latest 与 next,3.x 发到 next;
  2. 业务项目的 miniprogram/package.json 里锁 2.x,观望的团队不受影响;
  3. 愿意尝鲜的项目改锁 next,跑一周回归再切 latest;
  4. 全量切换后,旧 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 化改造完,真正的收益不是少复制几次文件,而是版本、回滚、灰度这些工程能力第一次落到了小程序端。还有一个常见误区要澄清:构建 npm 不是「打包工具」,它不做 tree-shaking,依赖声明了多少包基本就进多少内容,所以组件库按组件目录拆分入口、业务侧按需 require,仍然是你自己的责任。后续如果你们有多个小程序主体,可以再研究分包异步化和 useExtendedLib 的取舍。踩过别的坑欢迎评论区聊,这份清单我打算持续更新下去。

关键词:微信小程序、npm 构建、自定义组件、组件库发布、styleIsolation、miniprogramRoot、真机调试

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。