拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UniApp H5自动化部署实战:PowerShell脚本一键发布与快速回滚

UniApp H5自动化部署实战:PowerShell脚本一键发布与快速回滚

如果你手上有一个 UniApp 做的 H5 项目,大概率也熟悉这套流程:打开 HBuilderX 点“发行”,等编译跑完,去 unpackage/dist/build/h5 里翻产物,压成一个 zip,再打开终端上传到 Linux 服务器,解压、清缓存。频率低的时候还好,一旦项目进入一周两次以上的迭代节奏,这些手动动作就会成为最大的风险源。我有一次就是把 build 和 dist 两个目录一起打包传了上去,线上页面直接白屏,最后排查了半天才发现是压缩时路径结构不对。

后来我直接写了一个 PowerShell 脚本,把 UniApp H5 项目的打包、压缩、上传、远端解压和版本切换全部串成一条命令,跑一次大概两分钟,中途不用盯着终端。这篇文章就围绕这个脚本展开,把每一步设计的理由和踩过的坑都整理出来,适合手里有 H5 项目、又不想一上来就搭完整 Jenkins 系统的前端或运维同事参考。

1. 整体思路:把手动发布拆成四个可脚本化的环节

1.1 手动流程里的四个固定动作

无论你在 HBuilderX 里点“发行”点得多熟练,H5 项目的发布本质上就是四个环节:构建产物、打包压缩、上传服务器、远端处理。这四个动作互相独立,输入输出都很明确,正好符合脚本化的条件。

构建环节,入口是 HBuilderX 的发行菜单,或者项目里的 npm 脚本;压缩环节是把 unpackage 或 dist 目录变成单个 zip 包;上传环节是把 zip 包送到服务器;远端处理则包含解压、目录切换、清理临时文件。手动模式下,每一环都靠人脑记忆,任何一步走神都会导致结果不一致。不同开发者压缩时用了不同软件,包内路径结构就全乱了;有人记得删 source map,有人不删;有人传到 /var/www,有人传到 /data/www。这些差异其实比脚本本身更危险,自动化之后反而能把“人”的因素屏蔽掉。

我在写脚本前,先画了一下自己平时发布的动作序列,发现最费时间的不是编译本身,而是找产物目录和传文件。一个 H5 构建产物动辄几千个小文件,用图形化工具拖拽上传能拖十几分钟,而且中间偶尔断一下,文件就缺了。但是如果先把整个目录压成单个 zip,再上传,服务器端一次性解压,整个过程会快很多。这也是后面脚本里选择“打包后传输”的根本原因。

1.2 为什么用 PowerShell 而不直接上 Jenkins

一开始我也想过干脆上 Jenkins,毕竟它是标准答案。但后来评估了一下,如果你只是一个人维护一个小项目,或者团队只有两三个人,搭一套 Jenkins 从部署到维护的成本,可能已经超过了手动发布本身。PowerShell 的好处是 Windows 系统自带,不需要安装额外服务,而且能直接调用命令行程序,能用 .NET 库,也能配合任务计划程序实现定时构建。

不过要强调一点,这篇文章里的 PowerShell 脚本不是用来替代 Jenkins 的,而是把发布流程固化成一段可复用的逻辑。如果后续团队变大,想让别人按一个按钮就触发发布,这套脚本可以直接作为 Jenkins 流水线里的一个构建步骤,或者把同样的逻辑平移成 Pipeline 脚本。先用小脚本把事情串通,再考虑要不要上系统,这个顺序对个人和小团队更实在。

2. 工具选型与环境准备:决定脚本稳定性的三个选择

2.1 构建命令:先搞清楚你的项目是哪一种工程形态

UniApp 的 H5 项目其实有两种常见工程形态。一种是 HBuilderX 里直接创建和维护的,根目录可能没有完整的 package.json 脚本体系;另一种是从 uni-app cli 模板创建,或者迁移成 vue3 + vite 的纯命令行项目,根目录有完整的 package.json,scripts 字段里有 build:h5 这样的命令。

这两种项目的构建入口完全不同。cli 工程可以直接在命令行执行npm run build:h5,产物会出现在dist/build/h5;HBuilderX 工程需要调用 HBuilderX 安装目录下的cli.exe,命令大致是cli.exe publish --platform H5 --project 你的工程名。我实测下来的感受是,HBuilderX 的 CLI 在一些版本里还是会唤起 GUI 窗口,在无人值守环境下可能卡住,所以脚本里我优先走 npm 分支,只有当 package.json 里找不到 build:h5 时才去调 cli.exe。

判断逻辑其实就一小段:读取 package.json,检查 scripts 里有没有 build:h5。这样脚本就能同时兼容两类工程,不至于换一台电脑、换一个项目就得改构建命令。如果你所在的项目组同时维护多个 UniApp 工程,有老工程也有新工程,这个兼容分支能省掉很多维护心塞。

2.2 压缩和传输方案对比

压缩工具方面,很多人第一反应是用 PowerShell 自带的 Compress-Archive,毕竟它不用装任何东西。但实际用下来,Compress-Archive 在大目录下速度明显慢,而且没法做排除。比如我想把*.map这些 source map 文件从包里剔掉,它做不到,只能先复制一份再压缩。我最后选了 7-Zip,压缩率可控、速度快、能排除文件,缺点就是 Windows 默认没装,需要额外安装并在脚本里写好路径。

传输这块,我直接用 Windows 10/11 自带的 OpenSSH 客户端里的 scp 和 ssh 命令,没有再装 pscp 或 Posh-SSH。自带的 OpenSSH 在最新 Windows 里基本都有,开启后和 Linux 上的用法完全一致,配合 SSH 密钥可以实现免密上传和远程执行。Posh-SSH 作为 PowerShell 模块本身也不错,但多一层依赖,对脚本的可移植性反而不好。

用途工具优点缺点
压缩Compress-Archive系统自带,零安装慢,不能排除文件,大目录容易卡
压缩7-Zip快,支持排除,压缩率高默认未安装,脚本需写死路径
上传scp系统自带或易开启,Linux 通用无进度细节,需要配密钥
远程命令ssh系统自带,稳定成熟首次连接需确认主机指纹
封装Posh-SSH纯 PowerShell,API 友好额外模块依赖,升级容易带坑
封装pscp/plink老牌工具,可用场景多单独下载,命令行风格和 OpenSSH 略有差异

2.3 环境准备与三步配置

如果你用的是 Windows 10 或 Windows 11,第一步先确认 OpenSSH 客户端是不是已经装好。在设置里的“可选功能”中检查一下,没有就安装,装完重开终端就能用 scp 和 ssh。第二步是安装 7-Zip,装完把C:\Program Files\7-Zip\7z.exe这个路径记下来,脚本里会用到。第三步是在 PowerShell 里执行一次Set-ExecutionPolicy RemoteSigned -Scope CurrentUser,否则自己写的 .ps1 脚本会因为系统默认执行策略限制而直接拒绝运行,弹出一段让人摸不着头脑的红字。

我另外习惯在脚本开头把控制台编码切到 UTF-8:[Console]::OutputEncoding = [System.Text.Encoding]::UTF8。不然远程命令返回的中文和特殊字符经常变成乱码,排查问题时分外痛苦。如果要用 SSH 免密,需要在本地生成密钥,并把公钥追加到服务器的~/.ssh/authorized_keys里。这一步手动做一次,后面所有发布都不再需要密码,也避免把服务器密码硬编码进脚本里。

3. 脚本核心逻辑拆解

3.1 参数设计与退出码检查

脚本一定不能写死所有信息,至少要把环境名、项目路径、服务器地址、远端目录这些作为参数暴露出来。我的做法是定义一组默认值,同时允许用命令行参数覆盖。比如-EnvName可以传 test 或 prod,-SkipUpload用来只构建不部署,方便本地验证。

PowerShell 调用外部命令时有一个经常被忽略的坑:$ErrorActionPreference = "Stop"对原生 exe 的失败不一定生效。npm 打包失败了、scp 上传断了,PowerShell 本身不会抛异常,脚本会继续往下走,最后部署一个坏的包。所以每调用一个外部命令,必须立刻检查$LASTEXITCODE。脚本里我习惯写成这样:

& npm run build:h5 if ($LASTEXITCODE -ne 0) { throw "npm build 失败,退出码 $LASTEXITCODE" }

每次执行完外部命令后立刻把$LASTEXITCODE存下来判断,不能等到后面再统一检查,因为下一条命令会把退出码覆盖掉。这个细节看起来不起眼,却是整个脚本能可靠运行的基石。

3.2 前置检查与版本号自动读取

脚本开头我会先检查工具链是否存在,包括 git、node、npm、7z、ssh 和 scp。缺哪个直接给出中文提示,而不是让用户面对一串不知从哪冒出来的红色报错去猜。这个前置检查函数很小,但能省下大量解释成本。

版本号是发布包命名的重要部分。UniApp 项目的版本信息在 manifest.json 里,不同工程形态下文件位置略有不同:vite cli 工程通常放在src/manifest.json,HBuilderX 直接创建的工程可能在根目录manifest.json。脚本里两个位置都探测一下:

$manifestPath = "$ProjectPath\src\manifest.json" if (-not (Test-Path $manifestPath)) { $manifestPath = "$ProjectPath\manifest.json" } $manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json $version = $manifest.versionName

得到版本号后,配合当时的日期时间和环境名,生成一个可读的唯一包名,比如myapp-1.2.3-20240912_1530-prod.zip。这样每个版本在服务器上都有独立的目录,出了问题也能根据包名立刻找到历史版本。

3.3 构建函数:兼容两种工程形态

构建这一步是整个流程的核心前置条件。脚本里我写了一个函数,先判断 package.json 的 scripts 里是否存在 build:h5,存在就执行 npm 构建;不存在就回退到 HBuilderX CLI。这样做的好处是,团队里老项目和新项目都能用同一个部署脚本,不用维护两套。

function Invoke-Build { param( [string]$ProjectPath, [string]$HBuilderCli ) $pkg = Get-Content "$ProjectPath\package.json" -Raw -Encoding UTF8 | ConvertFrom-Json if ($pkg.scripts.'build:h5') { Push-Location $ProjectPath npm run build:h5 $code = $LASTEXITCODE Pop-Location if ($code -ne 0) { throw "npm run build:h5 失败" } } else { $projName = Split-Path $ProjectPath -Leaf & $HBuilderCli publish --platform H5 --project $projName if ($LASTEXITCODE -ne 0) { throw "HBuilderX CLI publish 失败" } } }

构建完成后,还要自动探测产物目录。vite cli 工程一般在dist/build/h5,HBuilderX 工程则在unpackage/dist/build/h5。我对两个候选路径做一次 Test-Path,找到哪个用哪个,找不到就抛错终止,避免后面压缩一个不存在的目录。

3.4 压缩与上传:先打包传输为什么更稳

只从提升速度的角度看,压缩上传的好处就很明显:几千个小文件变成一个 zip 包,大小可能是原来的一半甚至更低,上传时间大大缩短。更重要的是可靠,scp 传 zip 包时如果中途断了,整个传输失败,服务器端不会多出一堆残缺文件;而直接传目录,中断后往往留下一半的文件,状态就很难判断。

压缩时我用了 7z 的排除参数,把*.map文件从包里丢弃。source map 在生产环境基本用不到,丢了既能减少体积,也避免把源码映射暴露在公网服务器上。压缩前统一进入产物目录再执行压缩,保证 zip 包内部就是 index.html、assets 等顶层内容,而不是包了一层多余的目录名。

Push-Location $distDir & $sevenZip a -tzip $zipPath * -xr!*.map Pop-Location

上传用的是 scp,指定端口和密钥文件,把 zip 包传到服务器的 /tmp 目录。这一步之后,本地构建压缩这两个环节就彻底结束了。我在实际使用中会把 zip 包也保留在本地一份,用日期目录归档,这样即使服务器回滚失败,本地也可以快速重新上传指定版本。

3.5 远程部署:软链接切换实现可回滚

服务器端的部署方式直接决定了回滚的难易程度。我选择的方案是“版本目录 + 软链接切换”,而不是直接把新文件覆盖到 web 根目录。每个发布包解压到一个以版本号和时间戳命名的目录里,然后让 web 根目录指向这个新目录。如果部署后发现页面有问题,只需要把软链接指回上一个目录,用户访问就立即回到旧版本,全程秒级完成。

远程命令的逻辑是:

$releaseDir = "/data/releases/$projectName/$version-$timestamp-$envName" $remoteCmd = @( "mkdir -p $releaseDir", "cd $releaseDir && unzip -oq /tmp/$fileName", "ln -sfn $releaseDir $webRoot", "rm -f /tmp/$fileName" ) -join " && " & ssh -p $RemotePort -i $KeyPath "$RemoteUser@$RemoteHost" $remoteCmd if ($LASTEXITCODE -ne 0) { throw "远程部署失败" }

这种结构下,web 根目录本身就是一个符号链接,比如/data/www/myapp-h5 -> /data/releases/myapp/20240912_1530_prod。发布过程中新版本解压到独立目录,不会影响正在运行的用户;解压成功、链路切换完成,新版本才生效。这个设计把“发布操作”和“流量切换”彻底分开了,比直接在原目录里覆盖文件要安全得多。

4. 完整脚本参考与落地使用

4.1 一个可以直接修改使用的脚本全文

下面是一份可以落地的完整脚本。路径、服务器信息、目录结构都可以按自己的项目情况调整。我保留了详细注释,方便你一条条对照理解。

param( [string]$EnvName = "prod", [string]$ProjectPath = "D:\Projects\MyUniApp", [string]$RemoteUser = "root", [string]$RemoteHost = "192.168.1.100", [int]$RemotePort = 22, [string]$KeyPath = "$env:USERPROFILE\.ssh\id_rsa", [string]$WebRoot = "/data/www/myapp-h5", [string]$SevenZip = "C:\Program Files\7-Zip\7z.exe", [switch]$SkipUpload ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $timestamp = Get-Date -Format "yyyyMMdd_HHmm" $fileName = "myapp-$timestamp-$EnvName.zip" $zipPath = Join-Path $env:TEMP $fileName $distDir = "" function Check-Command($name) { if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { throw "缺少依赖工具:$name" } } function Get-ManifestVersion { param([string]$ProjectPath) $manifestPath = "$ProjectPath\src\manifest.json" if (-not (Test-Path $manifestPath)) { $manifestPath = "$ProjectPath\manifest.json" } if (-not (Test-Path $manifestPath)) { throw "找不到 manifest.json" } $manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json return $manifest.versionName } function Invoke-Build { param([string]$ProjectPath) $pkg = Get-Content "$ProjectPath\package.json" -Raw -Encoding UTF8 | ConvertFrom-Json if ($pkg.scripts.'build:h5') { Push-Location $ProjectPath npm run build:h5 $code = $LASTEXITCODE Pop-Location if ($code -ne 0) { throw "npm run build:h5 失败" } } else { $hbxCli = "D:\Program Files\HBuilderX\cli.exe" if (-not (Test-Path $hbxCli)) { throw "未找到 HBuilderX CLI,且项目没有 build:h5 脚本" } $projName = Split-Path $ProjectPath -Leaf & $hbxCli publish --platform H5 --project $projName if ($LASTEXITCODE -ne 0) { throw "HBuilderX CLI publish 失败" } } } function Get-DistDir { param([string]$ProjectPath) $candidates = @( "$ProjectPath\dist\build\h5", "$ProjectPath\unpackage\dist\build\h5" ) foreach ($item in $candidates) { if (Test-Path $item) { return $item } } throw "找不到 H5 构建产物目录" } Check-Command "git" Check-Command "node" Check-Command "npm" Check-Command "ssh" Check-Command "scp" $version = Get-ManifestVersion -ProjectPath $ProjectPath $fileName = "myapp-$version-$timestamp-$EnvName.zip" $zipPath = Join-Path $env:TEMP $fileName if (-not $SkipUpload) { git -C $ProjectPath pull --ff-only if ($LASTEXITCODE -ne 0) { throw "git pull 失败" } } Invoke-Build -ProjectPath $ProjectPath $distDir = Get-DistDir -ProjectPath $ProjectPath if (-not (Test-Path $SevenZip)) { throw "未找到 7z:$SevenZip" } Push-Location $distDir & $SevenZip a -tzip $zipPath * -xr!*.map Pop-Location if ($LASTEXITCODE -ne 0) { throw "压缩失败" } Write-Host "压缩完成:$zipPath" if ($SkipUpload) { Write-Host "已跳过上传部署。" exit 0 } & scp -P $RemotePort -i $KeyPath $zipPath "$RemoteUser@$RemoteHost:/tmp/$fileName" if ($LASTEXITCODE -ne 0) { throw "scp 上传失败" } $releaseDir = "/data/releases/myapp/$version-$timestamp-$EnvName" $remoteCmd = @( "mkdir -p $releaseDir", "cd $releaseDir && unzip -oq /tmp/$fileName", "ln -sfn $releaseDir $WebRoot", "rm -f /tmp/$fileName", "touch $WebRoot/index.html" ) -join " && " & ssh -p $RemotePort -i $KeyPath "$RemoteUser@$RemoteHost" $remoteCmd if ($LASTEXITCODE -ne 0) { throw "远程部署失败" } Write-Host "部署完成:$RemoteHost:$WebRoot => $releaseDir"

脚本默认会执行 git pull,把远端最新代码拉下来再构建,保证发布的一定是最新代码。如果你不想在构建前拉代码,可以加-SkipUpload这种开关,或者把 git pull 那段注释掉。这个取舍看团队习惯:有的团队是本地确认无误后再跑部署,有的团队希望直接从仓库拉最新发。

4.2 首次运行前要处理的三件事

新环境第一次跑脚本,通常会遇到三件事。第一件是执行策略,如果 PowerShell 拒绝运行脚本,先执行Set-ExecutionPolicy RemoteSigned -Scope CurrentUser,但如果你在公司环境,域策略可能禁止改,那就在命令行里用powershell -ExecutionPolicy Bypass -File .\deploy-h5.ps1临时绕过。第二件是 SSH 密钥,手动执行一次ssh-copy-id或者手工把公钥追加到服务器,确保从本机到服务器可以免密登录。第三件是服务器目录,/data/releases/myapp和 web 根目录的软链接要提前建好,不要等脚本第一次跑才去创建。

这三件事只需要做一次。第一次跑通之后,后面每次发版就是执行一条命令的事。

4.3 如何接入 Jenkins 或保留手动触发

如果以后想在 Jenkins 里触发这套发布流程,其实不需要改动多少。在 Jenkins 的构建步骤里加一个“执行 Windows PowerShell”的步骤,调用同样的部署脚本即可。环境名、服务器地址这些参数可以通过 Jenkins 的参数化构建传进去,让不同环境使用不同服务器。

保留手动触发也有它的价值。特别是在紧急修复线上问题的时候,脚本就在本地,改完代码、提交、跑一遍,两分钟后上线。不需要去打开 Jenkins 网页、点构建、再等流水线排队。等团队规模变大或者发布权限需要管控时,再把入口统一收进 CI 系统也不迟。

5. 常见问题与排查实录

5.1 高频报错的解决方法

脚本在真实环境跑起来之后,最常见的问题其实不是脚本逻辑本身,而是操作系统和工具链层面的细节。我把高频报错整理成了一张表,基本覆盖了从“脚本跑不动”到“上线没生效”的整个过程。

现象原因处理方法
无法加载文件,因为在此系统上禁止运行脚本PowerShell 执行策略受限Set-ExecutionPolicy RemoteSigned
无法将“git”项识别为 cmdlet、函数、脚本文件Git 未安装或未加入 PATH安装 Git,重开终端;检查环境变量
找不到 npm 命令安装了 Node 但当前终端会话没刷新 PATH重开终端,或安装 Node 后重启电脑
无法将“7z”项识别为 cmdlet7-Zip 未安装或路径不对安装 7-Zip,脚本中写全绝对路径
ssh 连接超时服务器端口不通或防火墙拦截检查安全组、firewalld、端口放行
上传成功后远程解压乱码压缩包内中文文件名编码不一致统一在服务器用 unzip,控制台切 UTF-8
构建成功但找不到产物目录工程类型判断失败或构建输出路径不同手工确认 dist 或 unpackage 下真实路径
发布完线上页面还是旧版nginx 缓存或浏览器缓存发布后 touch index.html,清一下 nginx cache
刷新页面出现 404vue-router 使用 history 模式nginx 配置 try_files 到 index.html

5.2 几个容易被忽略的部署细节

第一个容易被忽略的细节是软链接切换前,目标目录必须已经存在。ln -sfn看起来是强制覆盖,但如果你链接到一个不存在的目录,链接状态会非常诡异。所以我远程命令里永远先执行mkdir -p $releaseDir,确保目录存在后再做切换。

第二个是远程解压后的文件权限。如果服务器上的 web 目录由 nginx 用户读取,而解压出来的文件权限是 750、属主是 root,nginx 可能没有权限读取静态资源。我通常会在解压命令后面补一句chown -R www:www $releaseDir或者统一chmod -R 755,具体看你们服务器运行用户的配置。

第三个是 source map 文件。有些人可能觉得传上去没关系,反正用户也看不到源码。但 map 文件相当于把源码的每个模块映射关系都暴露了,对前端项目来说算是信息泄露风险。脚本里通过-xr!*.map排除,别人直接人工打包时也建议遵循这个规则,本地留档没问题,线上不带。

第四个是 nginx 缓存。静态资源发布完,用户看到的可能还是旧的 index.html 里引用的旧 assets 文件名。如果项目构建时带了 hash 文件名,那问题不大;如果没带 hash,发布完最好 touch 一下 index.html,触发缓存失效。脚本里已经加了这个动作,手动发布时别忘了。

整套方案用到现在,我最大的体会是:自动化部署的脚本并不需要写得多花哨,关键是能稳定复现每一次发布,并且出了问题时可以快速回退。PowerShell 在 Windows 生态下做这件事非常顺手,门槛也没有想象中高。后来我又在脚本的基础上扩展了多服务器部署,把服务器清单放到一个 JSON 配置里,用循环逐个上传执行,逻辑主体完全没变。如果你也经常被 H5 发布这种琐碎但容错率低的事情烦到,不妨从这样一个几十行的脚本开始,把最影响心情的环节交给流程去处理。

返回列表