前言
在前端项目开发过程中,每次发布新版本通常需要执行一系列重复操作:
执行前端构建命令。
将构建产物打包成 ZIP。
连接 Linux 服务器。
将 ZIP 上传到服务器。
执行服务器上的发布脚本。
如果每次都手动操作,不仅效率低,还容易因为操作遗漏导致部署异常。
本文介绍一种基于 npm Scripts + bestzip + ssh2 的轻量级自动化部署方案,将这些步骤整合到构建流程中。
最终只需执行:
npm run build:portal
即可自动完成前端构建、ZIP 压缩、SSH 连接、文件上传以及服务器发布脚本的执行。
该方案不依赖 Jenkins、Docker 或额外的 CI/CD 平台,适合已有发布脚本、希望快速实现自动化部署的前端项目。
一、技术方案
1.1 使用的技术
技术 用途 npm Scripts 管理构建和部署流程 Yarn 执行子项目构建 bestzip 压缩前端构建产物 Node.js 编写自动化部署脚本 ssh2 SSH 连接、SFTP 上传和远程命令执行 dotenv 管理部署环境配置
1.2 部署流程
npm run build:portal
│
▼
前端项目构建
│
▼
postbuild:portal 自动执行
│
▼
删除旧 ZIP(如果存在)
│
▼
压缩 dist 内的构建产物
│
▼
生成 dist/portal.zip
│
▼
执行 deploy-portal.cjs
│
▼
读取服务器部署配置
│
▼
建立 SSH 连接
│
▼
通过 SFTP 上传 ZIP
│
▼
执行 Linux 发布脚本
│
▼
输出部署结果
1.3 项目目录结构
以下为本文示例使用的项目结构:
frontend/
├── .gitignore
├── .env.deploy
├── .env.deploy.example
├── .deploy-known-hosts.json
├── package.json
├── scripts/
│ └── deploy-portal.cjs
└── packages/
└── portal/
├── package.json
└── dist/
├── index.html
├── assets/
└── portal.zip
其中:
.env.deploy:存放服务器连接信息。
.deploy-known-hosts.json:记录 SSH 服务器主机密钥哈希。
deploy-portal.cjs:执行文件上传与远程发布。
portal.zip:构建完成后的部署包。
二、安装项目依赖
在项目根目录执行:
npm install -D bestzip ssh2 dotenv
三个依赖分别负责:
bestzip
用于将构建目录压缩为 ZIP 文件,支持 Windows、Linux 和 macOS。
ssh2
Node.js SSH 客户端库,主要用于:
使用账号密码建立 SSH 连接。
通过 SFTP 上传文件。
执行服务器上的 Shell 命令。
接收远程命令的标准输出和错误输出。
dotenv
读取本地 .env 配置文件,将服务器地址、账号、密码等信息加载到 process.env 中。
三、配置 npm 构建与自动打包
3.1 原有构建命令
项目使用 Monorepo 目录结构,前端代码位于 packages/portal。
根目录 package.json 中已有以下构建命令:
{
"scripts": {
"build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build"
}
}
该命令完成以下操作:
设置 Node.js 环境变量。
进入 packages/portal。
使用 Yarn 执行项目构建。
这里的 set NODE_OPTIONS=... 属于 Windows CMD 语法,本文后续打包命令同样以 Windows 环境为例。
3.2 使用 postbuild 自动执行打包
npm 支持 pre 和 post 生命周期脚本。
例如,执行:
npm run build:portal
如果存在对应的 postbuild:portal,npm 会在 build:portal 成功完成之后自动执行它。
因此,无需修改原有构建命令,只需要添加:
{
"scripts": {
"build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build",
"postbuild:portal": "cd packages/portal/dist && (if exist portal.zip del /Q portal.zip) && bestzip ../portal.zip * && move /Y ..\\portal.zip portal.zip && cd ../../.. && node scripts/deploy-portal.cjs"
}
}
npm 的 post 脚本机制可参考 npm Scripts 官方文档 。
3.3 分析打包命令
第一步:进入构建目录
cd packages/portal/dist
这样可以确保 ZIP 内部直接包含构建产物,而不是完整的 packages/portal/dist 目录层级。
第二步:删除旧 ZIP
(if exist portal.zip del /Q portal.zip)
如果存在旧的 portal.zip,则先删除,避免旧压缩包进入新压缩包。
第三步:压缩构建产物
bestzip ../portal.zip *
这里有个关键细节:
不要直接将 ZIP 输出到正在压缩的目录中。
如果在 dist 目录执行:
bestzip portal.zip *
就可能导致正在生成的 ZIP 文件被包含在压缩内容中。
因此,先将 ZIP 输出到上一级目录:
bestzip ../portal.zip *
第四步:将 ZIP 移动回 dist
move /Y ..\portal.zip portal.zip
此时压缩已经完成,可以安全地将 ZIP 移回 dist 目录。
第五步:返回项目根目录,执行部署脚本
cd ../../.. && node scripts/deploy-portal.cjs
由于部署脚本中使用了基于当前工作目录的路径,必须先返回项目根目录。
最终 ZIP 内部结构:
portal.zip
├── index.html
├── assets/
│ ├── index.js
│ └── index.css
└── ...
而不是:
portal.zip
└── packages/
└── portal/
└── dist/
└── index.html
注意:这里只处理了构建产物目录中已有 ZIP 的情况。对于正式项目,建议确保构建流程会清理旧资源,或者另外添加构建产物清理逻辑。
四、配置服务器连接信息
在项目根目录创建 .env.deploy:
DEPLOY_HOST=192.168.1.100
DEPLOY_PORT=22
DEPLOY_USER=root
DEPLOY_PASSWORD=your_password
DEPLOY_REMOTE_DIR=/root/releases
DEPLOY_SCRIPT=/root/deploy_cp.sh
DEPLOY_ARGS=portal portal.zip
配置说明:
配置项 含义 DEPLOY_HOST Linux 服务器地址 DEPLOY_PORT SSH 端口,默认 22 DEPLOY_USER SSH 登录账号 DEPLOY_PASSWORD SSH 登录密码 DEPLOY_REMOTE_DIR ZIP 上传目录 DEPLOY_SCRIPT 服务器发布脚本路径 DEPLOY_ARGS 发布脚本执行参数
例如:
DEPLOY_SCRIPT=/root/deploy_cp.sh
DEPLOY_ARGS=portal portal.zip
最终执行的 Linux 命令等价于:
bash /root/deploy_cp.sh portal portal.zip
其中:
$1 = portal
$2 = portal.zip
这种方式可以让同一个发布脚本通过参数支持不同项目。
需要注意,DEPLOY_REMOTE_DIR 必须已经存在,并且 SSH 登录账号拥有写入权限。
另外,普通账号通常无法直接访问 /root 下的脚本。实际使用时应按照服务器权限配置选择合适的部署目录和账号。
五、编写 Node.js 部署脚本
在项目根目录创建:
scripts/deploy-portal.cjs
下面是完整实现。相较于最初版本,这里增加了远程参数校验、SSH 指纹记录、临时文件上传和异常处理。
const fs = require('node:fs');
const path = require('node:path');
const { Client } = require('ssh2');
// 加载部署配置
require('dotenv').config({
path: path.resolve('.env.deploy')
});
// 本地构建产物
const zipPath = path.resolve(
'packages/portal/dist/portal.zip'
);
// SSH 主机密钥记录文件
const knownHostsFile = path.resolve(
'.deploy-known-hosts.json'
);
// 远程配置
const remoteDir = process.env.DEPLOY_REMOTE_DIR;
const remoteScript = process.env.DEPLOY_SCRIPT;
const remoteArgs = process.env.DEPLOY_ARGS || '';
const ssh = new Client();
/**
* 校验部署配置
*/
function validateConfig() {
const required = [
'DEPLOY_HOST',
'DEPLOY_USER',
'DEPLOY_PASSWORD',
'DEPLOY_REMOTE_DIR',
'DEPLOY_SCRIPT'
];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`缺少配置:${key}`);
}
}
if (!fs.existsSync(zipPath)) {
throw new Error(`ZIP 不存在:${zipPath}`);
}
// 限定远程路径为普通 Linux 绝对路径
for (const value of [remoteDir, remoteScript]) {
if (!/^\/[a-zA-Z0-9_./-]+$/.test(value)) {
throw new Error(`远程路径不合法:${value}`);
}
}
// 发布参数仅允许普通文件名和标识符字符
if (!/^[a-zA-Z0-9_.\s/-]*$/.test(remoteArgs)) {
throw new Error('DEPLOY_ARGS 包含非法字符');
}
const port = Number(process.env.DEPLOY_PORT || 22);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('DEPLOY_PORT 不合法');
}
}
/**
* 验证 SSH 服务器主机密钥
*
* 首次连接时自动记录。
* 后续连接时检查密钥是否发生变化。
*/
function verifyHostKey(hash) {
const host = process.env.DEPLOY_HOST;
const port = Number(process.env.DEPLOY_PORT || 22);
const hostId = `${host}:${port}`;
let knownHosts = {};
if (fs.existsSync(knownHostsFile)) {
knownHosts = JSON.parse(
fs.readFileSync(knownHostsFile, 'utf8')
);
}
if (knownHosts[hostId]) {
if (knownHosts[hostId] !== hash) {
console.error(
`SSH 主机密钥发生变化:${hostId}`
);
return false;
}
return true;
}
// 首次连接,自动信任并记录密钥
console.log(
`首次连接服务器 ${hostId},记录 SSH 主机密钥`
);
knownHosts[hostId] = hash;
fs.writeFileSync(
knownHostsFile,
JSON.stringify(knownHosts, null, 2),
{ mode: 0o600 }
);
return true;
}
/**
* 建立 SSH 连接
*/
function connectSSH() {
return new Promise((resolve, reject) => {
ssh.once('ready', resolve);
ssh.once('error', reject);
ssh.connect({
host: process.env.DEPLOY_HOST,
port: Number(process.env.DEPLOY_PORT || 22),
username: process.env.DEPLOY_USER,
password: process.env.DEPLOY_PASSWORD,
readyTimeout: 30000,
// 验证服务器主机密钥
hostHash: 'sha256',
hostVerifier: verifyHostKey,
// 排查连接问题时可以开启
// debug: (message) => console.log('[SSH]', message)
});
});
}
/**
* 使用 SFTP 上传 ZIP
*
* 先上传为临时文件,完成后重命名。
*/
function uploadZip() {
return new Promise((resolve, reject) => {
ssh.sftp((err, sftp) => {
if (err) return reject(err);
const remoteZip = `${remoteDir}/portal.zip`;
const tempZip = `${remoteZip}.uploading`;
sftp.fastPut(zipPath, tempZip, (err) => {
if (err) return reject(err);
// 同名目标文件已存在时,
// 某些 SFTP 服务器可能拒绝覆盖式重命名
sftp.unlink(remoteZip, (unlinkErr) => {
if (
unlinkErr &&
unlinkErr.code !== 2
) {
return reject(unlinkErr);
}
sftp.rename(tempZip, remoteZip, (err) => {
if (err) reject(err);
else resolve();
});
});
});
});
});
}
/**
* 执行远程发布脚本
*/
function executeDeployScript() {
return new Promise((resolve, reject) => {
const command =
`bash '${remoteScript}' ${remoteArgs}`;
console.log(`执行远程命令:${command}`);
ssh.exec(command, (err, stream) => {
if (err) return reject(err);
stream.on('data', (data) => {
process.stdout.write(data);
});
stream.stderr.on('data', (data) => {
process.stderr.write(data);
});
stream.on('error', reject);
stream.on('close', (code, signal) => {
if (code === 0) {
resolve();
} else {
reject(new Error(
`发布失败,退出码:${code},信号:${signal}`
));
}
});
});
});
}
/**
* 主部署流程
*/
async function main() {
try {
validateConfig();
console.log('开始连接 SSH 服务器...');
await connectSSH();
console.log('SSH 连接成功,开始上传 ZIP...');
await uploadZip();
console.log('ZIP 上传完成,执行发布脚本...');
await executeDeployScript();
console.log('部署成功!');
} finally {
ssh.end();
}
}
// 程序入口
main().catch((err) => {
console.error('部署失败:', err.message);
process.exitCode = 1;
});
说明:示例保留了固定的远程文件名 portal.zip。其中 SFTP 上传采用临时文件方式,降低上传过程中出现不完整正式文件的风险,但删除旧文件再重命名仍然存在短暂空档,并不是严格的原子替换。正式环境如需并发部署与无间断切换,应使用版本化部署目录和原子切换策略。
远程参数校验也是为了匹配本文的简单部署场景,不支持包含空格等特殊字符的路径和参数。
六、实现 SSH 首次连接自动信任
在自动化部署中,另一个容易遇到的问题是 SSH 首次连接。
使用 Windows 自带的 SSH 命令连接新服务器时,通常会要求确认服务器主机密钥:
Are you sure you want to continue connecting
(yes/no/[fingerprint])?
手动输入 yes 后,OpenSSH 会将服务器主机密钥保存到本地 known_hosts。
但 Node.js 的 ssh2 与系统 OpenSSH 是两个独立的 SSH 客户端。
ssh2 不会默认读取 OpenSSH 的 known_hosts,并且在未指定 hostVerifier 时,默认接受服务器主机密钥。
为了避免每次连接都无条件信任服务器,并且不要求其他开发人员手动执行 SSH,本方案额外实现了主机密钥记录机制。
6.1 首次连接
首次连接服务器时:
首次连接服务器 192.168.1.100:22,
记录 SSH 主机密钥
随后自动创建:
.deploy-known-hosts.json
内容示意:
{
"192.168.1.100:22": "服务器主机密钥的SHA256十六进制哈希"
}
6.2 后续连接
之后每次连接时,脚本都会检查当前服务器的主机密钥哈希是否与已记录值一致。
如果一致,继续连接。
如果不一致,拒绝连接:
SSH 主机密钥发生变化:192.168.1.100:22
部署失败
这样可以避免服务器身份发生变化时仍然继续部署。
6.3 安全说明
该机制属于 TOFU(Trust On First Use,首次使用时信任)。
需要特别注意:
首次连接时仍存在信任错误服务器的风险。
如果服务器重装系统并更换 SSH 主机密钥,需要重新核实服务器身份。
不建议在生产环境中直接删除信任记录并盲目重新接受密钥。
正式生产部署可以改为管理员预先提供经过核验的服务器公钥指纹。
同时,需要区分 SSH 主机密钥验证和 SSH 协议握手。
如果出现:
Connection lost before handshake
并不意味着一定是未接受主机密钥导致的,还需要通过 ssh2 的调试日志检查网络、SSH 服务或协议协商问题。
七、配置 Git 忽略规则
部署配置中包含服务器密码,不能直接提交到 Git 仓库。
在根目录 .gitignore 中添加:
# 本地部署配置
/.env.deploy
# SSH 主机密钥记录
/.deploy-known-hosts.json
# 构建产物
/packages/portal/dist/
为了方便其他开发人员使用,可以额外创建:
.env.deploy.example
示例内容:
DEPLOY_HOST=
DEPLOY_PORT=22
DEPLOY_USER=
DEPLOY_PASSWORD=
DEPLOY_REMOTE_DIR=
DEPLOY_SCRIPT=
DEPLOY_ARGS=portal portal.zip
其他开发人员只需要复制:
copy .env.deploy.example .env.deploy
然后填写自己的服务器连接信息即可。
如果真实密码此前已经提交到 Git 历史中,仅添加 .gitignore 并不能消除泄露风险,还需要更换密码并视情况清理历史。
八、Linux 服务器发布脚本
前面已经配置:
DEPLOY_SCRIPT=/root/deploy_cp.sh
DEPLOY_ARGS=portal portal.zip
因此,部署脚本最终在服务器执行:
bash /root/deploy_cp.sh portal portal.zip
下面提供一个简化的服务器发布脚本示例:
#!/bin/bash
set -e
PROJECT="$1"
ZIP_NAME="$2"
UPLOAD_DIR="/root/releases"
DEPLOY_DIR="/var/www/$PROJECT"
ZIP_PATH="$UPLOAD_DIR/$ZIP_NAME"
if [ ! -f "$ZIP_PATH" ]; then
echo "部署包不存在:$ZIP_PATH"
exit 1
fi
mkdir -p "$DEPLOY_DIR"
unzip -oq "$ZIP_PATH" -d "$DEPLOY_DIR"
echo "$PROJECT 发布成功"
例如:
bash /root/deploy_cp.sh portal portal.zip
会将:
/root/releases/portal.zip
解压至:
/var/www/portal/
需要注意,这只是用于说明参数传递的简化脚本。
实际生产环境建议保留历史版本、处理旧文件清理,并采用临时发布目录加软链接切换,避免发布过程中影响正在访问的用户。
九、执行自动化部署
完成以上配置后,在项目根目录执行:
npm run build:portal
预期执行过程如下:
> npm run build:portal
开始构建前端项目...
Build completed successfully.
开始压缩构建产物...
ZIP 打包完成。
开始连接 SSH 服务器...
首次连接服务器 192.168.1.100:22,
记录 SSH 主机密钥
SSH 连接成功,开始上传 ZIP...
ZIP 上传完成,执行发布脚本...
执行远程命令:
bash '/root/deploy_cp.sh' portal portal.zip
portal 发布成功
部署成功!
至此,一条命令就完成了整个前端部署流程。
可选:增加独立部署命令
当前方案将部署直接放在 postbuild:portal 中,意味着每次正常执行 npm run build:portal 都会尝试连接服务器。
如果希望将普通构建和正式发布区分开,可以调整为:
{
"scripts": {
"build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build",
"postbuild:portal": "cd packages/portal/dist && (if exist portal.zip del /Q portal.zip) && bestzip ../portal.zip * && move /Y ..\\portal.zip portal.zip",
"deploy:portal": "node scripts/deploy-portal.cjs",
"release:portal": "npm run build:portal && npm run deploy:portal"
}
}
这样可以分别执行:
# 仅构建并生成 ZIP
npm run build:portal
# 将已有 ZIP 上传并发布
npm run deploy:portal
# 构建、打包、上传、发布
npm run release:portal
这种设计可以避免开发人员只想构建项目时意外触发服务器发布。
十、常见问题
10.1 ZIP 内包含 packages/portal/dist 目录
原因是直接使用了包含完整相对路径的压缩源:
bestzip portal.zip packages/portal/dist/*
解决方式是先进入构建目录:
cd packages/portal/dist
bestzip ../portal.zip *
这样 ZIP 内部只包含构建产物。
10.2 ZIP 内包含另一个 portal.zip
原因可能是正在压缩的目录中存在旧 ZIP,或者输出 ZIP 的位置位于压缩源目录中。
解决思路:
删除旧 ZIP。
先将新 ZIP 输出到 dist 外部。
压缩完成后再移动回 dist。
10.3 Node.js 提示 MODULE_NOT_FOUND
例如:
Cannot find module
packages\portal\dist\scripts\deploy-portal.cjs
原因是执行 Node.js 时仍位于 dist 目录。
解决:
cd ../../.. && node scripts/deploy-portal.cjs
确保返回项目根目录。
10.4 SSH 提示 Connection lost before handshake
先排查以下问题:
服务器 SSH 端口是否正确。
服务器 SSH 服务是否正常运行。
服务器是否存在防火墙或连接限制。
SSH 握手阶段是否存在协议兼容问题。
可以开启日志:
debug: (message) => console.log('[SSH]', message)
也可以使用系统 SSH 客户端辅助排查:
ssh -vvv -p 22 root@192.168.1.100
需要注意,系统 SSH 能正常连接,不代表 Node.js ssh2 使用完全相同的认证配置和加密算法。
10.5 上传成功但发布脚本执行失败
重点检查:
DEPLOY_SCRIPT 是否正确。
当前账号是否有权限读取并执行脚本。
脚本的参数是否正确。
服务器是否安装了脚本依赖的命令。
发布脚本是否以非零退出码表示错误。
本方案会将服务器标准输出和标准错误实时输出至本地终端,方便排查异常。
十一、总结
本方案通过 npm Scripts、bestzip 和 ssh2,将原本需要多个手动步骤的前端部署流程整合到 Node.js 脚本中。
主要优势:
部署流程简单 :通过 npm 命令完成自动化构建和发布。
依赖少 :无需部署额外的 CI/CD 平台。
复用现有脚本 :无需重写服务器上的发布逻辑。
配置独立 :服务器账号、密码、路径与项目代码分离。
支持首次主机密钥记录 :开发人员无需额外手动接受 SSH 主机密钥。
错误可追踪 :上传失败、SSH 连接失败和远程脚本执行失败均可输出错误信息。
当然,这套方案并不能完全替代成熟的 CI/CD 系统。
如果后续需要支持多环境发布、部署审批、自动回滚、发布记录和权限管理,可以在现有脚本的基础上进一步扩展,或者逐步迁移到 GitHub Actions、GitLab CI、Jenkins 等平台。
对于日常的小型或中型前端项目,这种基于现有工具组合的自动化部署方式,已经能够显著减少重复操作,提高发布效率。
参考资料