微信小程序cli脚本预览上传,我把手动点两分钟的事压成了十秒
做了几年小程序开发,最烦的就是每次改完代码都要打开微信开发者工具,等编译,手动点预览,再掏手机扫码,最后还要填版本号和备注去上传。这一套流程走下来,快的时候两分钟,慢的时候能磨蹭五分钟。要是赶上同时维护四五个小程序项目,光是“上传代码”这件事就得占用不少时间。
后来我把这套流程做成了cli脚本,一条命令完成预览,一条命令完成上传,还能丢进CI/CD里自动跑。现在团队里无论谁想预览当前分支的代码,只要在终端里敲一条命令,二维码直接生成在命令行里,手机上扫一下就能看到效果。上传也一样,版本号、备注、编译配置全部由脚本自动处理。这篇就聊聊整个方案的思路、踩过的坑,以及可以直接复制去用的完整代码。
这套方案用的是微信官方提供的miniprogram-ci工具,加上Node.js脚本封装。适合已经有一定小程序开发经验、想提升日常发版效率的开发者,也适合需要对小程序做自动化测试、持续集成的团队。入门门槛不高,会用终端、能装npm包就够了。
1. 项目背景与整体设计思路
1.1 为什么要脚本化,手动流程到底差在哪
先说说我原来的手动流程长什么样。打开微信开发者工具,等编辑器把代码编译好,点击工具栏里的“预览”按钮,工具会生成一个二维码,我用手机微信扫码,打开小程序,开始肉眼检查。如果觉得没问题,再回到工具里点“上传”,填一个版本号,比如1.0.3,再写一句上线说明,点确定,等它上传完成。
这套流程最难受的有几个点。一是慢,开发者工具启动本身就要好几秒,打开大项目时编译还要更久。二是容易出错,我经常在填版本号的时候手滑少写一位,或者忘了改备注,结果体验版上一堆1.0.2的重复版本,运营同学看到都懵了。三是没法自动化,如果想让测试机每天凌晨自动拉最新代码、自动上传体验版,手动操作就完全做不到了。
把预览和上传脚本化,本质上是把它从“图形界面点按钮”这件事,变成“命令行调接口”这件事。开发者工具本身就是一个GUI壳子,内部真正干活的是一些底层能力,而miniprogram-ci把这部分能力通过Node.js SDK暴露了出来。我们在脚本里拿到编译产物(即小程序代码包),调用SDK的preview和upload接口,把二维码和上传包交给微信服务端处理。
1.2 技术方案选型:为什么是miniprogram-ci
市面上做小程序上传的工具有不少,第三方平台也有上传接口,但最正统的还是官方提供的miniprogram-ci。这个包从微信开发者工具的设计思路里独立出来,专门给开发者在命令行、CI/CD环境中使用,支持预览、上传、构建npm、上传源代码等能力。
我实际对比过几类方案。一是用开发者工具的HTTP端口调试能力,通过命令行调本地开发者工具的接口去上传。这种方案的问题在于,你必须额外安装微信开发者工具且保持它在后台运行,万一界面崩了,整个流程就断了。二是直接用miniprogram-ci,它不依赖GUI工具,纯Node.js环境跑通就行。三是找第三方平台的API,比如一些云开发平台提供的上传接口,但这类方案或多或少会侵入项目结构,还会有平台绑定的风险。
选miniprogram-ci的几个理由:
- 官方维护,底层能力与微信开发者工具一致,不用担心某个构建参数对不上。
- 纯Node.js包,在Linux、macOS、Windows上都能跑,方便接CI/CD。
- 支持预览二维码生成、上传版本、设置编译条件、自定义机器人编号等常用能力。
- 可以配置忽略文件,跳过
node_modules这类不需要打进代码包的目录,提交体积更小、上传更快。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与基础配置
2.1 安装miniprogram-ci与前置依赖
先把Node.js环境确认好。建议使用Node.js 12.0以上版本,我目前用的是16.x和18.x,跑起来都正常。如果版本太老,miniprogram-ci的一些语法糖会报错,后面排查起来也很麻烦。
安装很简单,进入项目根目录,执行:
bash复制npm install miniprogram-ci --save-dev
由于miniprogram-ci只在构建和上传阶段使用,建议装到devDependencies里,避免打进小程序包。如果你的项目里还没初始化过npm,先执行npm init -y生成package.json。
安装完以后,可以先写个最简单的脚本验证一下能不能正常加载包:
javascript复制const ci = require('miniprogram-ci')
console.log('miniprogram-ci loaded')
如果打印出miniprogram-ci loaded,说明环境没问题。如果报错找不到模块,大概率是Node.js版本问题,换一个LTS版本再试。
2.2 获取Appid与上传密钥
这部分是重点,也是新手最容易卡住的地方。miniprogram-ci不再使用开发者工具里的登录态,而是要求你提供一个小程序的Appid和一对“上传密钥”。
Appid就是小程序唯一标识,形如wx1234567890abcdef,在微信公众平台的后台可以查到。进入“开发” -> “开发管理” -> “开发设置”,页面上会直接展示Appid。
上传密钥的获取步骤如下:
- 在微信公众平台“开发设置”页面,滚动到“小程序代码上传密钥”区域。
- 点击“生成密钥”,会要求你用管理员微信扫码验证,验证通过后生成一个
.key结尾的私钥文件。 - 下载密钥文件,保存到一个安全的地方。
这里有几个关键注意点,都是血泪教训:
- 上传密钥文件只能完整下载一次。如果你下载后把文件弄丢了,后台是没法重新查看的,只能作废重建密钥。重建后旧的密钥文件立即失效,所有还在用旧密钥的CI任务会报错。
- 密钥文件就是你的“账号密码”,千万别提交到Git仓库里。我一般会在项目根目录放一个
keys/目录,然后在.gitignore里加上:
text复制keys/
*.key
- 生成密钥时,页面会让你填写一个“IP白名单”。只有在这个IP列表里的机器,才能使用这把密钥进行预览和上传。这个设计其实是为了安全,但很多人第一次用的时候填错IP,导致本地一直报错。本地开发的话,填你当前的公网IP;CI/CD服务器的话,填服务器出口IP。如果暂时不想限制,可以先留空,但上传时可能会遇到安全提示,建议按需配置。
2.3 编译配置与项目路径的约定
miniprogram-ci的Project对象需要projectPath参数,这个路径指的是小程序项目的根目录。如果你用原生小程序开发,根目录就是app.json所在的那一层。如果你用Taro、uni-app这类跨端框架,那么上传前需要先执行构建,把产物输出到一个目录(比如dist),projectPath指向这个dist目录。
这里很多人会踩坑:构建工具生成的dist目录里通常没有project.config.json,只有小程序代码文件。miniprogram-ci在读取projectPath的时候,会尝试找project.config.json中的配置(比如appid、setting之类)。如果没有这个文件,上传会报错。
解决办法是,在构建工具的配置里把project.config.json一并拷贝到dist目录。或者手动在dist里放一份简单的project.config.json:
json复制{
"appid": "wx1234567890abcdef",
"compileType": "miniprogram",
"projectname": "my-miniprogram",
"setting": {
"es6": true,
"minified": true
}
}
不过需要注意的是,Project构造函数里传的appid优先级更高,所以project.config.json里的appid其实不太影响,但最好保持一致,避免某些校验逻辑误判。
另外,ignores参数可以配置忽略文件。小程序上传有代码包大小限制,主包不能超过1.5M(不同时期政策不同,最新以官方文档为准)。把node_modules、测试文件、docs这类不需要打进代码包的目录全部忽略掉,能有效控制包体积:
javascript复制ignores: ['node_modules/**/*', 'docs/**/*', 'test/**/*']
3. 核心实现:预览脚本
3.1 预览脚本的核心逻辑
预览的本质是“把当前代码打包后提交给微信服务端,生成一个临时二维码,扫码后可在手机上打开体验版”。这个二维码有有效期,一般几分钟到几十分钟不等,过期后需要重新生成。
我写了一个preview.js,放在项目的scripts/目录下:
javascript复制const ci = require('miniprogram-ci')
const path = require('path')
const fs = require('fs')
// 从命令行参数读取环境变量,默认 dev
const env = process.argv[2] || 'dev'
// 根据环境选择不同的描述信息
const descMap = {
dev: `开发环境预览 ${new Date().toLocaleString()}`,
test: `测试环境预览 ${new Date().toLocaleString()}`,
prod: `生产环境预览 ${new Date().toLocaleString()}`
}
const project = new ci.Project({
appid: 'wx1234567890abcdef',
type: 'miniProgram',
projectPath: path.join(__dirname, '../dist'),
privateKeyPath: path.join(__dirname, '../keys/private.key'),
ignores: ['node_modules/**/*']
})
ci.preview({
project,
desc: descMap[env] || descMap.dev,
setting: {
es6: true,
es7: true,
minify: true,
minifyWXSS: true,
minifyWXML: true
},
qrcodeFormat: 'image',
qrcodeOutputDest: path.join(__dirname, '../preview-qrcode.png')
}).then(res => {
console.log('预览成功,二维码已生成: preview-qrcode.png')
console.log(res)
}).catch(err => {
console.error('预览失败', err)
process.exit(1)
})
这里需要说明几个参数的含义:
type:项目类型。普通小程序填miniProgram,小游戏填miniGame。如果你开发的是小游戏,这个字段要记得换。desc:预览描述,会显示在体验版二维码的扫码页上。我习惯把时间拼进去,方便区分是哪一次生成的。setting:编译配置。es6表示把ES6转ES5,minify表示压缩代码。这些配置和开发者工具里的“本地设置”是对应的。如果你在开发者工具里开了“ES6转ES5”,这里也要开,不然某些老机型上可能出现兼容问题。qrcodeFormat和qrcodeOutputDest:指定二维码输出格式和路径。我这里输出成PNG图片文件,保存到项目根目录。
运行方式:
bash复制node scripts/preview.js dev
运行完成后,会在项目根目录生成preview-qrcode.png,用手机微信扫一扫就能打开小程序。如果你在命令行环境里不方便扫图片,也可以把qrcodeFormat设为terminal,代码块会直接以字符画的形式打印在终端里,亲测在macOS的iTerm2里显示很清楚。
3.2 二维码输出与自定义处理
很多人会问,预览二维码能不能直接返回base64给其他系统用?答案是可以的。miniprogram-ci在preview的返回值里其实包含了qrcode相关数据,不过我在上面这个脚本里让它直接输出文件。如果你想拿到base64,可以改一下:
javascript复制ci.preview({
project,
desc: 'preview',
setting: { es6: true },
qrcodeFormat: 'base64',
// 注意:使用 base64 时不能同时传 qrcodeOutputDest
}).then(res => {
// res 里面包含二维码base64数据
console.log(res)
}).catch(err => {
console.error(err)
})
这种方式的用途在于,你可以把base64数据推送到企业微信机器人、钉钉群或者自定义的Webhook里,让同事们在群里直接看到二维码。比如我们团队的CI机器人,每次测试包构建完,就会把二维码图推到企业微信群里,测试同学直接点开扫码安装,非常方便。
如果你想把二维码输出成base64并用于其他服务,记得qrcodeFormat和qrcodeOutputDest不要同时传。传了qrcodeOutputDest表示写入文件,传qrcodeFormat: 'base64'表示在返回值里带数据,二者是有冲突的,我之前同时用的时候发现文件没生成,返回值里也没有二维码数据,卡了一阵子才反应过来。
4. 核心实现:上传脚本
4.1 上传脚本的实现与参数校验
上传和预览的差别在于,上传需要一个合法的版本号,并且代码会进到微信后台成为“体验版”。我在scripts/upload.js里实现了上传逻辑:
javascript复制const ci = require('miniprogram-ci')
const path = require('path')
// 版本号和备注可以从命令行参数传入,也支持环境变量
const version = process.argv[2] || process.env.WX_APP_VERSION
const desc = process.argv[3] || process.env.WX_APP_DESC || `自动上传 ${new Date().toLocaleString()}`
if (!version) {
console.error('请传入版本号,例如: node scripts/upload.js 1.0.0 "上线说明"')
process.exit(1)
}
// 简单校验版本号格式
const versionReg = /^\d+\.\d+\.\d+$/
if (!versionReg.test(version)) {
console.error('版本号格式不正确,应为 x.y.z,例如 1.0.0')
process.exit(1)
}
const project = new ci.Project({
appid: 'wx1234567890abcdef',
type: 'miniProgram',
projectPath: path.join(__dirname, '../dist'),
privateKeyPath: path.join(__dirname, '../keys/private.key'),
ignores: ['node_modules/**/*']
})
ci.upload({
project,
version,
desc,
setting: {
es6: true,
minify: true,
minifyWXSS: true,
minifyWXML: true
},
robot: 1
}).then(res => {
console.log(`上传成功,版本号: ${version}`)
console.log(res)
}).catch(err => {
console.error('上传失败', err)
process.exit(1)
})
运行方式:
bash复制node scripts/upload.js 1.0.3 "修复了首页闪退的问题"
版本号的格式要求是x.y.z,这是微信后台的硬性校验。有些团队会在版本号里加日期,比如1.0.3-20250120,这种格式我是没试通过,不建议用。
4.2 版本号管理策略:别让同事之间互相覆盖
当我第一次把上传脚本交给团队使用时,很快遇到了一个问题:两个人同时跑脚本,都传了1.0.3,后传的人直接把前一个人的体验版给覆盖了。这在手动流程里也存在,但脚本化以后,操作成本变低,误操作的概率反而更高。
解决办法有很多种。最简单的,是在脚本里根据当前时间生成版本号。比如用年.月.日-HHmm这种格式作为版本号的一部分。但这种方式生成的版本号不好看,跟需求版本对不上。
我们团队后来采用的方案是:版本号从Git tag里取。每次要发版的时候,先打一个tag,比如v1.0.3,然后脚本自动把v去掉,作为小程序上传版本号。同时desc里带上Git commit哈希的前7位,这样体验版对应的是哪次代码提交,一查就知道。
脚本里可以这样解析:
javascript复制const { execSync } = require('child_process')
function getVersionFromGit() {
const tag = execSync('git describe --tags --abbrev=0').toString().trim()
return tag.replace(/^v/, '')
}
function getCommitHash() {
return execSync('git rev-parse --short HEAD').toString().trim()
}
当然,这要求你的Git管理流程规范,每次发版必须打tag。如果团队还没这个习惯,也可以用环境变量传入版本号,在CI/CD平台上自动生成递增的数字版本。例如在Jenkins里,构建号用BUILD_NUMBER,版本号可以设为1.0.${BUILD_NUMBER},这样每次构建都一定不同,不会互相覆盖。
另外,robot参数指的是上传机器人编号,取值范围是1到30。相当于微信后台允许你配置30个“上传入口”,各自独立,互不干扰。如果你在CI里跑测试、测试同学手动上体验版、生产发版都用了同一个机器人,那仍然会互相覆盖。我建议不同用途的流水线分配不同的robot编号,比如CI自动化测试用robot: 1,生产发布用robot: 2,这样即使版本号相同,后台也能区分出来,二维码的形态都会不同。
5. 进阶:把脚本接入CI/CD与团队协作
5.1 用Git hooks做自动预览
脚本化以后,最直接的应用就是接进Git hooks。我们团队是这么做的:在package.json里加了一个pre-push钩子,每次执行git push之前,自动跑一遍代码检查和预览脚本。这样每个人push代码到远程分支之前,自己手机上已经能看到最新版了。
用husky可以很方便地管理hooks:
bash复制npm install husky --save-dev
npx husky install
npx husky add .husky/pre-push "npm run preview:dev"
然后在package.json里加:
json复制{
"scripts": {
"preview:dev": "node scripts/preview.js dev"
}
}
不过要注意,这个钩子会拖慢push速度,因为预览要上传代码、等微信服务端返回。我建议只在关键分支上开启,或者改成手动触发。团队里不是每个人都喜欢这种“强制感”,所以后来我们改成了可选方案,在Git提交信息里带一个[preview]标识才会触发预览:
bash复制git commit -m "feat: 修复首页样式 [preview]"
然后在pre-push钩子里检查提交信息,包含[preview]才执行预览脚本。
5.2 在Jenkins/GitHub Actions里自动化上传体验版
脚本化的最大价值在于,可以放到CI服务器上去跑。我们有一个小型测试环境,每天晚上10点自动拉取最新develop分支代码,构建后执行上传脚本,上传到体验版,并生成一个固定的版本号。测试同学第二天早上到公司,直接用微信扫体验版二维码,就能测到最新代码,不用等开发手动发。
用GitHub Actions举例,流程文件大概是这样的:
yaml复制name: nightly-build
on:
schedule:
- cron: '0 14 * * *' # 每天晚上10点(UTC+8)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- run: npm install
- run: npm run build:weapp # 这里是构建小程序代码到dist目录
- run: node scripts/upload.js 1.0.${GITHUB_RUN_NUMBER} "夜间自动构建"
env:
WX_APP_VERSION: 1.0.${GITHUB_RUN_NUMBER}
这里的GITHUB_RUN_NUMBER是GitHub Actions里每次运行的唯一序号,可以确保版本号不重复。其他CI平台也有类似的变量,比如Jenkins的BUILD_NUMBER,GitLab CI的CI_PIPELINE_ID。
需要注意一个坑:CI服务器上传前,一定先把构建产物的project.config.json和密钥文件准备好。有些团队会把密钥文件加密后放在代码仓库的secret里,流水线里解密到临时目录,用完再删掉。我之前见过有人图方便,直接把密钥文件明文放在Git仓库里,被安全扫描工具扫出来,被迫把所有密钥全部作废重配,教训很深刻。
5.3 把预览二维码推到企业微信/钉钉群
这个玩法是我觉得最有价值的一个。前面提到,预脚本可以生成base64二维码。把这个base64发给群机器人,团队里的每个人就都能在群里扫码了。
以企业微信为例,群机器人支持发图片消息,类型是image,图片内容需要base64编码。Node.js里用axios发一个POST请求就行:
javascript复制const axios = require('axios')
async function sendQrcodeToWecom(base64Data, webhookUrl) {
const res = await axios.post(webhookUrl, {
msgtype: 'image',
image: {
base64: base64Data,
md5: '' // 计算图片内容的md5
}
})
console.log(res.data)
}
注意,企业微信要求base64后面还要附带md5值,需要先解码base64再计算md5。如果你不想自己算,也可以用官方sdk或者直接上传临时素材,换取图片mediaId后再发送。
类似的思路也适用于钉钉、飞书,它们的群机器人API都支持发图片。每次CI构建完,群里自动冒出一张带二维码的卡片,点开就能扫,体验非常好。
6. 常见问题与排查技巧实录
6.1 高频报错速查表
我在使用过程中遇到过不少报错,每次都是搜半天才知道原因。这里整理一个速查表,按概率排序。
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
Error: invalid appid |
Appid填错了或者项目类型不对 | 检查Project构造函数里的appid,看是不是复制错了,还要确认type是miniProgram还是miniGame |
Error: private key not exist |
私钥文件路径不对或文件不存在 | 检查privateKeyPath指向的文件是否真实存在,密钥文件后缀一般为.key,注意别把路径写成了目录 |
Error: invalid private key |
私钥和Appid不匹配,或者密钥已作废 | 去微信公众平台检查一下这把密钥是否有效,如果之前重建过密钥,旧密钥立即失效,需要下载新密钥并替换路径 |
Error: ip not in whitelist |
当前机器的公网IP不在密钥的IP白名单里 | 登录微信公众平台,在“开发设置 -> 小程序代码上传密钥”里把当前机器的公网IP加入白名单。查公网IP可以直接用curl ifconfig.me |
Error: network error 或 timeout |
网络不通,或被代理拦截 | 先curl一下微信API的域名看能否通,检查本地代理工具是否拦截了https://api.weixin.qq.com的请求 |
Error: invalid version |
版本号不是x.y.z格式 |
修改版本号格式,满足数字.数字.数字 |
Error: qrcode data not found |
预览时同时传了qrcodeFormat和qrcodeOutputDest,导致输出冲突 |
二选一,要么输出文件,要么取base64数据 |
报错Cannot find module 'miniprogram-ci' |
依赖没装好,或者在错误的目录下运行 | 在项目根目录执行npm install,直接用npx运行也可以,确认你在包含node_modules的目录里执行 |
| 微信里打开体验版,提示“获取登录后的微信用户失败” | Appid和密钥不匹配,或者小程序未开通对应的类目权限 | 先确认这套Appid对应的主体信息,看看微信公众平台里是否为体验版用户配置了测试权限。如果只是开发预览阶段,建议用测试号或当前Appid对应的项目配置 |
其中“获取登录后的微信用户失败”这个问题,困扰了我很久。一次在帮一个外地团队排查时,发现他们的Appid是wx1cb4398e1413dce7(随便举例),但私钥是从另一个小程序后台下载的,两个不一致,就报了这个错。后来换回正确的密钥,问题就消失了,希望能帮到同样卡在这条报错信息的同学。
6.2 几个容易踩的隐藏坑
第一个坑是构建目录的问题。如果你用Taro、uni-app这类跨端框架,projectPath一定得指向编译后的dist目录,而不是源码目录。我第一次用Taro的时候,把projectPath指向了源码根目录,上传倒是成功了,但产物里全是一堆src文件,压根没有编译后的app.json,体验版打开直接白屏。
第二个坑是上传密钥的安全管理。密钥文件一旦泄漏,任何拿到它的人都能往你的小程序上传代码,这比拿到Git仓库权限还要严重。建议不要把密钥放在项目代码库里,用CI平台的secret功能去管理,本地开发时则放在项目外的目录,比如~/.wechat-keys/。
第三个坑是description字段别写太长。上传接口对desc的长度有限制,具体以官方文档为准,但我试过超过100个字符就直接报错。所以备注里挑重点写,别把一堆环境信息都堆进去。
第四个坑是ignores不是只影响上传包体积,还会影响预览。如果你把某些页面文件误加进ignores的规则里,比如用了**/*.js这种过于宽泛的匹配,那么预览和上传的代码里就会缺文件,表现是页面正常打开,但某些功能点不了。我就是这样把utils/**/*给忽略掉,结果上线后一堆人反馈工具函数调不到,排查了很久才发现是ignores写得太狠。
6.3 我的经验总结与改进方向
这套cli脚本方案,从最初手动点按钮,到后来变成纯命令行,再到接入CI,前后迭代了几轮。现在我的工作流基本是这样的:
- 日常开发时,执行
npm run preview:dev,终端直接输出二维码,手机扫码就能看。 - 要发体验版给测试时,执行
npm run upload:test,脚本自动取当前分支的最新commit记录拼到备注里。 - 每天凌晨,CI服务器自动拉代码、构建、上传夜间版,测试同学第二天直接扫体验版。
- 生产上架前,执行发布脚本,固定版本号,提交给管理员审核。
在这个过程中,我还尝试过把二维码输出和群机器人打通,也尝试过在miniprogram-ci的返回值里拿sourceMap做线上错误监控,虽然还没完全落地,但思路是能走的。
我的体会是,脚本化的核心不是“省那两分钟”,而是“让流程不会因为人的操作失误而出错”。只要你把版本号、编译配置、上传说明这些都固化到脚本里,反复出现的“低级错误”基本就绝迹了。
如果你刚开始接触这块,建议从小项目试起,先把预览脚本跑通,再加上传脚本,最后再考虑CI/CD。不要一上来就搞一套非常复杂的自动化流程,那样一旦出错,排查成本反而比手动操作还高。
最后再分享一个小技巧:在package.json里加几个语义化别名,让团队成员不需要知道脚本细节也能调用:
json复制{
"scripts": {
"preview:dev": "node scripts/preview.js dev",
"preview:test": "node scripts/preview.js test",
"upload:test": "node scripts/upload.js",
"upload:prod": "node scripts/upload.js"
}
}
这样队友只需要记住npm run preview:dev,不需要关心脚本传参的细节。命令越简单,大家才越愿意用。这大概是所有工程化工具设计里最容易被忽略、却最重要的一件事。
