做过 uni-app 项目的人都懂,一个 H5 版本从开发完成到真正上线,最烦人的往往不是写代码,而是打包、压缩、上传、解压这一串机械动作。早期我每次发版都是手动打开 HBuilderX,点发行、选 H5,等编译结束再去 dist 目录里找文件,压缩成 zip,开 SFTP 工具传服务器,再登录后台解压替换。这套流程看着简单,但一旦代码改得勤、发版发得密,每天重复三五次之后,你一定会想搞个自动化。这篇文章分享的是我用 PowerShell 脚本把 UniApp H5 项目的打包、压缩、部署整条链路串起来的完整方案,含实际可用的脚本源码和踩坑记录,适合被手动发版折磨的前端、需要为小团队搭建发布工具的工程师,也适合想在 Windows 下快速实现构建发布自动化的朋友。
1. 为什么要用 PowerShell 扛下整条发版链路
1.1 手动发版的真实成本
先老老实实盘点一下手动发版的动作序列:改完代码,提交到仓库;打开 HBuilderX,等它把项目加载起来;点“发行”,选“网站-H5手机版”,进入编译等待;编译完成后去dist/build目录确认产物;右键压缩成 zip;打开 WinSCP 或 FileZilla 把压缩包传到服务器;登录服务器,解压、备份旧版本、替换新文件;最后刷新页面验证。
这串动作单看每一步都不难,但累计起来的成本很可怕。一次发版平均十分钟起步,遇到编译报错、压缩包传错目录、服务器上解压权限不对这些问题,二十分钟也打不住。最要命的是整个过程没有日志,哪一步出了问题只能靠记忆复盘,上次部署了哪个版本、服务器上现在是什么状态,全凭脑子记。
我统计过自己一个月的发版频率,迭代高峰期一天至少三次。也就是说,光手动发版这件事,一个月就要吃掉我大半天时间。而且这类重复操作非常磨人,它不产生任何新价值,纯粹是体力活。当时我就下决心,必须把这套流程脚本化。
1.2 PowerShell 相比批处理和 CI/CD 的取舍
可能有人会说:想自动化,为什么不直接上 Jenkins 或者 GitHub Actions?这得结合团队规模来看。单人项目或者两三个人的小组,专门部署一套 Jenkins 要维护服务、配置流水线、管理插件,成本比收益还高;GitHub Actions 虽然方便,但私有仓库要收费,而且国内访问和触发也有各种幺蛾子。至于 GitLab Runner,前提是你得有一台能跑 Runner 的机器。
那为什么不用 Windows 批处理(.bat)?批处理写简单命令确实快,但一旦牵扯到字符串处理、目录遍历、错误控制、日志格式化,写起来就非常痛苦,一句set /p和if errorlevel 1能让你怀疑人生。PowerShell 是 Windows 自带的现代脚本语言,有对象管道、有异常处理、有$LASTEXITCODE来判断外部命令成败,最关键的是它天生就是给“系统管理自动化”这种场景设计的。
另一个容易忽略的点是:PowerShell 脚本并不会和 CI/CD 体系冲突。就算将来团队规模大了、上了 GitLab Runner,也可以用 Windows Runner 直接调用这套脚本作为流水线的一步。写脚本的关键是把它当作一个可以被调度的工具,而不是只能手敲的玩具。
2. 打包前先看清项目出身:HBuilderX 工程和 CLI 工程是两条路
2.1 先判断项目属于哪种类型
uni-app 项目有两种常见创建方式:一种是在 HBuilderX 可视化界面里新建的工程,一种是基于 CLI 模板(vue-cli 或 vite)创建的工程。很多人把自动化卡在第一步,就是因为没搞清这个区别。
判断方法很简单:打开项目根目录,看是否存在package.json。HBuilderX 可视化创建的工程默认是不带package.json的,它的编译能力完全靠 HBuilderX 内置编译器提供,所以你在命令行里执行npm run build:h5会直接提示找不到脚本。而 CLI 创建的工程一定有package.json,而且scripts里通常写着"build:h5": "uni build"。
更完整的判断方式看下面这张表:
| 特征 | HBuilderX 可视化创建 | CLI / Vite 创建 |
|---|---|---|
| package.json | 通常不存在 | 存在,且有 uni 相关依赖 |
| manifest.json | 项目根目录 | src 目录下 |
| 构建命令 | 依赖 HBuilderX 菜单 | npm run build:h5 |
| 构建产物 | HBuilderX 自动输出 | dist/build/h5或dist/build/web |
| 自动化友好度 | 低 | 高 |
还有一种情况比较特殊:项目虽然是 CLI 创建的,但被导入过 HBuilderX 或者加了 HBuilderX 的插件,此时根目录可能会有manifest.json,但pages.json仍在src下。遇到这种混合形态,以package.json和scripts为准。
2.2 HBuilderX 工程怎么接入自动化链路
如果你的项目确实是 HBuilderX 可视化创建的,又想把发版自动化,有两条路。
第一条路是临时方案:用 HBuilderX 安装目录下的cli.exe在命令行里打开项目并触发发行操作。这个工具确实存在,安装 HBuilderX 后能在安装目录下找到,用法一般类似cli open 项目路径。但不同 HBuilderX 版本对命令行发行支持的稳定性差异很大,而且它本质还是调起图形界面程序,一旦界面弹出来,脚本就很难做到完全无人值守。我的建议是:除非只是应急用一两次,否则别指望它。
第二条路是彻底改造:把工程迁移成 CLI 工程。操作思路是先用 CLI 模板新建一个空项目(比如npx degit dcloudio/uni-preset-vue#vite my-uniapp),然后把原来pages、static、components、store、utils这些目录整体复制进新项目,注意manifest.json、pages.json要从根目录挪到src下,接着安装依赖,跑通npm run dev:h5开发调试,最后确认npm run build:h5能产出正常页面。
迁移过程中最麻烦的是项目里用到的原生插件和第三方库。HBuilderX 插件市场里的部分插件不一定能在 CLI 工程里正常安装,需要到 npm 上找等价包。如果项目重度依赖 HBuilderX 生态,迁移前务必在分支上做一次完整验证,不要贸然替换线上构建方式。
2.3 构建输出目录的确认与清理策略
CLI 工程执行npm run build:h5后,产物默认在dist/build/h5,但个别版本或自定义配置下会输出到dist/build/web,还有团队自己改过 Vite 配置的会输出到其他自定义目录。脚本里千万不要把这个路径写死,否则哪天升级脚手架,脚本会突然找不到产物直接罢工。
我在脚本里用的是探测方式:
$candidates = @("dist/build/h5", "dist/build/web", "dist/build/h5-tmp") $buildDir = $null foreach ($cand in $candidates) { $fullPath = Join-Path $ProjectPath $cand if (Test-Path $fullPath) { $buildDir = $fullPath break } } if (-not $buildDir) { throw "未找到构建产物目录,请先手动执行 npm run build:h5 确认输出位置" }这样即使以后 uni-app 升级改了默认目录,也只需要在$candidates数组里加一个值,而不需要改主逻辑。
关于清理,我只会删除dist/build这一层,不会动dist/dev和整个dist。因为开发时可能还依赖dist/dev下的调试产物,误删会影响正在进行的本地联调。构建前做一次清理,可以避免上次编译残留的旧文件被打进新包,尤其是那些单纯删除的页面,不清理的话旧html可能会被原样保留。
3. PowerShell 脚本核心实现:构建、压缩与版本归档三板斧
3.1 脚本参数设计
既然要做自动化,脚本就不能是写死的,至少要把最常用的变量作为参数暴露出来。我设计的参数如下:
| 参数 | 说明 | 示例 |
|---|---|---|
-ProjectPath | uni-app 项目根目录 | D:\code\my-uniapp |
-ArchiveDir | 本地压缩包存放目录 | D:\backup\uniapp-h5 |
-Keep | 本地保留最新几个版本,默认 10 | 10 |
-Server | 目标服务器user@host | deployer@192.168.1.10 |
-RemotePath | 远端应用根目录 | /var/www/app |
-SkipDeploy | 只构建压缩,不上传 | - |
对应的param块:
param( [string]$ProjectPath = "D:\code\my-uniapp", [string]$ArchiveDir = "D:\backup\uniapp-h5", [int]$Keep = 10, [string]$Server = "deployer@192.168.1.10", [string]$RemotePath = "/var/www/app", [switch]$SkipDeploy ) $ErrorActionPreference = "Stop" $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"所有参数都带默认值,是因为这个脚本的典型使用场景是“每天早上手动跑一遍”,有默认值可以少打很多字;但团队协作时,建议把服务器、项目路径这类敏感信息放到外部配置或环境变量里,至少别直接写死在脚本文件里提交到仓库。
3.2 npm 调用的正确姿势
调用npm run build:h5是脚本最核心的动作,写法上要特别注意。我的推荐写法是:
Push-Location $ProjectPath try { Write-Host "[BUILD] 开始执行 npm run build:h5" & npm run build:h5 if ($LASTEXITCODE -ne 0) { throw "构建失败,退出码: $LASTEXITCODE" } } finally { Pop-Location }这里有几个关键点。第一,用Push-Location和Pop-Location切换工作目录,这样脚本执行完不会改变终端当前路径,避免干扰后续手动命令。第二,调用外部命令用&调用操作符,这是 PowerShell 执行原生命令的标准姿势。第三,判断成败必须用$LASTEXITCODE,这是 npm 进程返回给操作系统的退出码,日志打印错、依赖缺失、编译报错,退出码通通不是 0。
我早期也试过写成npm run build:h5 2>&1 | Tee-Object -FilePath build.log,想同时把输出打到控制台和日志文件。但这个写法在 Windows PowerShell 5.1 里有个很恶心的坑:2>&1会把 npm 的 stderr 流重定向成 PowerShell 的 ErrorRecord,一旦脚本开头设置了$ErrorActionPreference = "Stop",npm 哪怕只是打印了几行无害的警告,脚本也可能被直接中断。所以我现在的做法是构建输出原样留在控制台,日志留痕交给Start-Transcript或者外层统一重定向到文件。
3.3 压缩选型:Windows 的 tar 比 Compress-Archive 更合适
不少人的第一反应是用 PowerShell 自带的Compress-Archive,但实际跑过几次就会后悔。这个命令压缩小文件夹还行,项目一旦大一点,速度慢得让人着急;而且它默认只能生成 zip 格式,在 Linux 服务器上解压还得额外装unzip,压缩率也不如 gzip。
Windows 10 以上系统自带tar.exe,底层是 libarchive,完全可以在 PowerShell 里直接调。我的压缩命令是这样的:
$archiveName = "uniapp-h5-$timestamp.tar.gz" $archivePath = Join-Path $ArchiveDir $archiveName # 删除 sourcemap,避免把源码映射文件一起发上服务器 Get-ChildItem $buildDir -Recurse -Filter *.map | Remove-Item -Force tar -czf $archivePath -C $buildDir "." if ($LASTEXITCODE -ne 0) { throw "压缩失败,退出码: $LASTEXITCODE" }-C $buildDir "."的组合是关键,意思是先进入产物目录,再把当前目录下所有内容打进压缩包。这样解压出来的直接就是index.html、static这些根内容,而不是多套一层h5目录。很多人打出来的包解压后还要手动把文件挪一层,就是因为少了-C参数。
删除.map文件这个习惯是我后来才养成的。H5 项目一旦开启 sourcemap,构建产物里会带一批.map文件,它们不会影响运行,但会让整个压缩包变大、把源码暴露在服务器上,纯属有害无益。与其在 Vite 配置里折腾开关,不如在打包前直接物理删除。
3.4 版本归档与保留策略
压缩包不能生成一个扔一个,必须有规律地归档,否则一个月后D:\backup\uniapp-h5里全是一堆早期版本,想找个历史包都不知道哪个是哪个。我建议的目录结构是:
D:\backup\uniapp-h5\ ├── uniapp-h5-20250315_102345.tar.gz ├── uniapp-h5-20250314_093012.tar.gz └── uniapp-h5-20250313_154201.tar.gz时间戳直接体现在文件名里,按修改时间排序就能看到完整的发版历史。保留策略用脚本自动执行,只保留最近 N 个:
$oldArchives = Get-ChildItem $ArchiveDir -Filter "uniapp-h5-*.tar.gz" | Sort-Object LastWriteTime -Descending | Select-Object -Skip $Keep foreach ($file in $oldArchives) { Remove-Item $file.FullName -Force Write-Host "[CLEAN] 删除旧归档: $($file.Name)" }这里用Sort-Object LastWriteTime -Descending拿到最新到最旧的排列,Select-Object -Skip $Keep跳过最近 N 个,剩下的就是要清理的历史包。为什么本地要保留多个版本而不只留最新?因为有时候你发现线上出了问题,想对比一下“上一个版本”和“当前版本”的差异,本地没有历史压缩包就只能重新构建,费时费力。
4. 部署环节:SCP 上传、远端解压与一键回滚
4.1 密钥认证与主机指纹
部署环节要解决的第一件事是免密登录。脚本里绝对不能出现服务器密码,一方面不安全,另一方面密码认证没法无人值守——第一次连上去让你输入密码,脚本就卡死了。正确做法是生成 SSH 密钥,把公钥放到服务器上。
生成密钥:
ssh-keygen -t ed25519 -C "deploy-key"默认会生成到C:\Users\你的用户名\.ssh\id_ed25519和id_ed25519.pub。然后把公钥追加到服务器的authorized_keys文件里:
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | ssh deployer@192.168.1.10 "mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys"这里需要注意,第一次连接任意一台服务器时,SSH 都会弹出确认主机指纹的提示,手动输入yes并回车确认。这个动作要在自动化脚本跑之前手动完成一次,让指纹写进 known_hosts 文件。之后脚本里再连接就不会卡交互。脚本里我会加上-o BatchMode=yes,这个参数表示“连接过程中禁止任何交互式提示”,万一密钥失效或主机指纹变化,脚本会直接失败而不是挂在那里等输入,CI 场景尤其需要这个特性。
4.2 上传与远端解压
上传用系统自带的scp命令:
if (-not $SkipDeploy) { & scp -o BatchMode=yes $archivePath "${Server}:~/deploy/$archiveName" if ($LASTEXITCODE -ne 0) { throw "上传失败,退出码: $LASTEXITCODE" } }上传目标不建议直接扔/tmp。/tmp是系统公共临时目录,任何系统用户都能读,放部署包不太稳妥。我在服务器上习惯建一个~/deploy目录专门放待部署的压缩包。
真正麻烦的是远端解压和目录切换。与其每次从本地拼一大堆远程命令,不如直接把一套部署脚本放进服务器,本地脚本只负责传参调用。我在服务器/var/www/app下维护一个deploy.sh:
#!/bin/bash set -e APP_BASE=/var/www/app VERSION=$1 ARCHIVE=~/deploy/$VERSION.tar.gz if [ ! -f $ARCHIVE ]; then echo "压缩包不存在: $ARCHIVE" exit 1 fi mkdir -p $APP_BASE/releases/$VERSION tar -xzf $ARCHIVE -C $APP_BASE/releases/$VERSION ln -sfn $APP_BASE/releases/$VERSION $APP_BASE/current cd $APP_BASE/releases ls -1 | sort -r | tail -n +4 | while read dir; do rm -rf "$dir" done echo "部署完成: $VERSION"本地调用:
& ssh -o BatchMode=yes $Server "bash $RemotePath/deploy.sh $timestamp"这个方案比在 PowerShell 里拼一长串远程命令靠谱得多。远程脚本自身处理解压、软链、清理,本地只需要传版本号。ln -sfn这行是整个部署的关键:它把current这个软链接原子性地切换到新版本目录。Web 服务器配置里只要让站点根目录指向current,那一次发版本质上就是替换一次软链,不会有“正在上传一半、页面访问 404”的尴尬窗口。
4.3 远端目录结构与权限
部署完成后的服务器目录大致长这样:
/var/www/app/ ├── current -> releases/20250315_102345 └── releases/ ├── 20250315_102345 │ ├── index.html │ ├── static │ └── ... ├── 20250314_093012 └── 20250313_154201Nginx 配置里的root /var/www/app/current;即可,Nginx 默认会解引用软链,所以切换版本对 Web 服务是即时生效的。这里有一个常见问题:部署用户 deployer 创建的文件,默认属主是 deployer,如果 Nginx 的 worker 进程跑在www-data用户下,可能会因为目录权限不足返回 403。我一般会先设置目录属组和读权限,要么把站点目录的属组改成www-data并加g+rx,要么干脆让 Nginx 用 deployer 用户跑,具体按团队权限管理习惯来。至少要在上线前手动检查一次:sudo -u www-data ls current/能列出内容才算过关。
4.4 一键回滚
这个设计最值钱的地方在于回滚只需要一条命令。假设新版本20250315_102345上线后首页白屏,回滚到上一个版本:
$rollbackVersion = "20250314_093012" & ssh -o BatchMode=yes $Server "ln -sfn $RemotePath/releases/$rollbackVersion $RemotePath/current"软链切换是原子的、即时的,回滚过程中不像整包替换那样要重新上传文件。我用这个方案应对过一次线上事故:发布后用户反馈登录异常,我直接在终端里切了一下软链,十几秒内服务恢复,然后才有时间慢慢看新版本的日志。这种安全感是手动发版给不了的。
5. 实际跑起来才会踩到的坑:编码、执行策略与路径细节
5.1 PowerShell 5.1 中文乱码的根源与根治
我自己部署这套脚本时,第一个坑就是中文乱码。现象分两种:一种是 npm 构建时终端提示乱码,全是一堆菱形问号;另一种是脚本里的中文注释和Write-Host在 PowerShell 5.1 里直接显示成乱码。
根源很简单:Windows PowerShell 5.1 控制台默认代码页是 GBK,而 Node/npm 输出的是 UTF-8;脚本文件本身如果存成了无 BOM 的 UTF-8,PowerShell 5.1 会按 ANSI 去读,中文自然全挂。
根治办法分三步。第一步,把系统脚本执行时的控制台编码设为 UTF-8:
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [Console]::OutputEncoding第二步,把脚本文件保存成带 BOM 的 UTF-8。在 VS Code 里可以通过右下角“选择编码”改成“UTF-8 with BOM”,或者直接用 PowerShell 重存:
$content = Get-Content -Raw deploy.ps1 Set-Content -Path deploy.ps1 -Value $content -Encoding UTF8第三步,如果条件允许,干脆装一个 PowerShell 7(pwsh)。它在设计上默认全链路 UTF-8,乱码问题基本消失,而且&&连接符、$PSNativeCommandUseErrorActionPreference这些现代语法都能用。现在 Windows 上winget install Microsoft.PowerShell一条命令就能装好,别再死守 5.1 了。
5.2 执行策略限制与团队分发
新机器上第一次运行脚本,十有八九会碰到这个报错:
无法加载文件 deploy.ps1,因为在此系统上禁止运行脚本。原因是 Windows PowerShell 默认执行策略是Restricted,不允许运行任何脚本。在你自己的开发机上执行一次:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的意思是:本地创建的脚本可以运行,从网络下载的脚本需要可信签名。这个策略对绝大多数开发机足够。也可以不改策略,在命令行启动时临时绕过:
powershell -NoProfile -ExecutionPolicy Bypass -File deploy.ps1如果脚本要发给团队其他人用,最省心的包装方式是在同目录下放一个deploy.bat:
@echo off powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*这样同事双击 bat 就能跑,不用每个人理解什么是执行策略。
5.3 路径带空格与外部命令传参陷阱
开发机上项目路径带空格是常态,比如D:\My Projects\uni-app-demo。PowerShell 自身处理带空格的路径没问题,变量作为一个整体传给外部命令也没问题——它不像老式 CMD 那样遇到空格就截断。真正的坑出现在拼字符串的场景。
我踩过的一个实际例子:scp命令里把路径拼进一个大字符串时忘记用变量,而是手写路径,结果传到远端服务器后目标目录多被拆了一段。另一个常见问题是在tar的-C参数后传一个带空格的路径。虽然变量传参没问题,但如果你写成tar -czf $archivePath -C $buildDir .,而$buildDir恰好是数组类型,PowerShell 会把数组展开成多个参数,瞬间把所有路径打散。
我的建议是:脚本内部统一使用Join-Path拼路径,杜绝手写字符串拼接;对外部命令传参时全部使用变量;项目路径尽量约定不带空格。如果团队里实在避免不了,那就严格要求所有路径变量在赋值时用[string]类型约束,防止被意外设置成数组。
5.4 定时任务和开机自启场景
脚本跑通之后,完全可以挂进 Windows 任务计划程序,实现每天固定时间自动构建。用schtasks注册:
schtasks /Create /TN "UniAppH5DailyBuild" /TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File D:\scripts\deploy.ps1 -SkipDeploy" /SC DAILY /ST 02:00这里有一个易踩的坑:任务计划程序执行时的工作目录默认是C:\Windows\System32,脚本里所有相对路径都会失效。所以脚本内部必须全部用绝对路径,或者执行第一条命令时就Push-Location到明确路径。还有一点,计划任务如果配置成“不管用户是否登录都要运行”,那 SSH 密钥要放在该任务账户的.ssh目录下,不是你的个人账户下,这个细节经常被忽略导致任务一直报错。
你也可以把脚本做成开机自启:放一个deploy.ps1 -SkipDeploy的快捷方式到shell:startup目录,但我的看法是,发布动作最好显式触发,不要开机自动发版。真要定时构建,最多是-SkipDeploy生成压缩包,部署这步留给人工确认后执行。
5.5 敏感信息与安全边界
最后提醒一下安全层面的习惯。脚本里的服务器地址、用户名这些信息属于敏感配置,最好不要直接提交进 Git 仓库。我通常的做法是把这类变量放进用户级环境变量或者一个单独的被.gitignore排除的config.ps1文件里,主脚本通过Import-PowerShellDataFile或简单的dot sourcing加载。SSH 私钥权限也要保持默认,别图省事把私钥拷给项目组每个人,而是每人各自生成自己的密钥,离职时从服务器上删掉对应公钥即可。
写到这里,这套 PowerShell 自动化链路基本完整了。它不炫技,也没有用什么高级框架,核心就是先把手工流程拆成可以重复执行的原子步骤,再用脚本把它们串起来。实际用下来,我最深的感受是:自动化的收益不只是省那几分钟,而是每次发版的路径变得可预测、可留痕、可回滚。如果你也打算给自己写一个,建议从最小闭环开始——先把打包和压缩跑通,再逐步加上部署、清理、回滚,而不是一开始就追求全自动。