前言
最近我参与了几个前端手动部署机器的项目, 部署流程长期依赖人工操作:从本地打包构建,到登录服务器、定位目录,再到上传压缩包、解压缩完成部署 ------ 整套流程步骤繁琐且重复,不仅效率低下,还容易因手动操作出现疏漏。 闲暇时,我基于 Node.js 生态的 scp2 和 ssh2 工具,搭建了一套前端项目自动化部署方案。优化后,部署流程被极致简化:开发者只需在本地执行一条npm run deploy
命令,即可完成从代码构建到服务器部署的全流程自动化。 本文将详细分享这套方案的实现思路,带你一步步构建从本地到服务器的前端自动化部署链路,彻底告别重复的手动部署工作。
项目背景
本文基于一个 React + TypeScript + Vite 的管理系统项目,该项目需要频繁部署到远程服务器。传统的手动部署方式存在以下问题:
- 手动操作容易出错
- 部署流程繁琐,耗时较长
- 缺乏版本回滚机制
- 部署状态不透明
技术栈选择
核心依赖
json
{
"scp2": "^0.5.0", // 文件传输
"ssh2": "^1.17.0", // SSH 连接
"chalk": "^4.1.2" // 控制台美化
}
选择理由:
scp2
: 轻量级,支持递归上传,API 简洁ssh2
: 功能完整,支持命令执行和文件传输chalk
: 提供丰富的控制台输出样式
架构设计
部署流程设计
graph TD
A[开始部署] --> B[构建项目]
B --> C[处理服务器目录]
C --> D[上传 dist 目录]
D --> E[部署完成]
B --> B1[删除本地 dist]
B --> B2[执行 npm run build]
B --> B3[验证构建结果]
C --> C1[删除 distOld]
C --> C2[重命名 dist 为 distOld]
D --> D1[建立 SSH 连接]
D --> D2[递归上传文件]
D --> D3[保持文件权限]
核心类设计
typescript
class DeployScript {
private projectRoot: string;
private distPath: string;
private serverConfig: ServerConfig;
// 核心方法
build(): void; // 项目构建
handleServerDirectories(): void; // 服务器目录处理
uploadDist(): void; // 文件上传
executeSSHCommand(): Promise; // SSH 命令执行
}
核心功能实现
1. 项目构建模块
javascript
build() {
console.log('1.开始构建项目...');
// 检查 package.json 是否存在
const packageJsonPath = path.join(this.projectRoot, 'package.json');
if (!this.exists(packageJsonPath)) {
throw new Error('❌ 未找到 package.json 文件');
}
// 清理旧的构建产物
if (this.exists(this.distPath)) {
console.log('删除现有的 dist 目录...');
fs.rmSync(this.distPath, { recursive: true, force: true });
}
// 执行构建命令
this.execCommand('npm run build');
// 验证构建结果
if (!this.exists(this.distPath)) {
throw new Error('❌ 构建失败,未生成 dist 目录');
}
console.log('✅ 项目构建完成');
}
设计亮点:
- 构建前清理旧文件,避免缓存问题
- 构建后验证结果,确保部署文件完整
- 详细的错误提示,便于问题定位
2. SSH 命令执行模块
javascript
async executeSSHCommand(command) {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
console.error('❌ SSH命令执行失败:', err);
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('close', (code, signal) => {
conn.end();
if (code === 0) {
console.log('SSH命令执行成功');
resolve(stdout);
} else {
console.error('❌ SSH命令执行失败,退出码:', code);
reject(new Error(`SSH命令执行失败: ${stderr}`));
}
});
// 处理输出流
stream.on('data', data => {
stdout += data.toString();
});
stream.stderr.on('data', data => {
stderr += data.toString();
});
});
});
conn.on('error', err => {
console.error('❌ SSH连接失败:', err);
reject(err);
});
conn.connect(this.serverConfig);
});
}
技术要点:
- 使用 Promise 包装异步操作,支持 async/await
- 完整的错误处理和状态码检查
- 分离 stdout 和 stderr 输出流
3. 服务器目录管理
javascript
async handleServerDirectories() {
console.log('2.开始处理服务器端目录...');
try {
// 1. 删除 distOld 目录(如果存在)
console.log('🗑️ 删除服务器上的 distOld 目录...');
await this.executeSSHCommand(
`rm -rf ${this.serverConfig.deployPath}/distOld`
);
// 2. 将现有的 dist 目录重命名为 distOld(如果存在)
console.log('📦 将现有的 dist 目录重命名为 distOld...');
await this.executeSSHCommand(
`if [ -d "${this.serverConfig.deployPath}/dist" ]; then mv ${this.serverConfig.deployPath}/dist ${this.serverConfig.deployPath}/distOld; fi`
);
console.log('✅ 服务器端目录处理完成');
} catch (error) {
console.error('❌ 服务器端目录处理失败:', error.message);
throw error;
}
}
设计优势:
- 实现版本回滚机制,保留上一版本
- 使用条件判断,避免目录不存在时的错误
- 原子性操作,确保部署过程的一致性
4. 文件上传模块
javascript
uploadDist() {
console.log('3.开始上传 dist 目录到服务器...');
const server = {
host: this.serverConfig.host,
port: this.serverConfig.port,
username: this.serverConfig.username,
password: this.serverConfig.password,
path: this.serverConfig.deployPath + '/dist',
};
const scpOptions = {
...server,
preserve: true, // 保持文件权限和时间戳
recursive: true, // 递归上传
};
scpClient.scp('./dist', scpOptions, err => {
if (!err) {
console.log(chalk.blue('🎉 RWA系统自动化部署完毕!'));
console.log(chalk.green(`📁 dist目录已上传到: ${this.serverConfig.deployPath}/dist`));
} else {
console.log(chalk.red('❌ RWA系统自动化部署出现异常'), err);
}
});
}
特性说明:
- 递归上传整个目录结构
- 保持文件权限和时间戳
- 彩色控制台输出,提升用户体验
配置管理
服务器配置
javascript
this.serverConfig = {
host: 'xx.xx.xx', // 服务器IP
port: 22, // SSH端口
username: 'root', // 用户名
password: 'xxxxx', // 密码
deployPath: '/data/xxxxx/', // 部署路径
};
环境变量支持
建议将敏感信息移至环境变量:
javascript
// .env 文件
DEPLOY_HOST=xxxx
DEPLOY_PORT=22
DEPLOY_USERNAME=root
DEPLOY_PASSWORD=xxxx
DEPLOY_PATH=/data/xxxx/
使用方式
1. 安装依赖
bash
npm install scp2 ssh2 chalk
2. 配置服务器信息
修改 deploy.js
中的 serverConfig
对象。
3. 执行部署
bash
# 方式一:直接运行脚本
node scripts/deploy.js
# 方式二:使用 npm script
npm run deploy
最佳实践
1. 安全性
- 使用 SSH 密钥认证替代密码认证
- 敏感信息使用环境变量管理
- 限制服务器访问权限
2. 可靠性
- 实现完整的错误处理机制
- 添加部署前检查步骤
- 保留版本回滚能力
3. 可维护性
- 模块化设计,职责分离
- 详细的日志输出
- 配置文件外部化
总结
通过本文分享的自动化部署方案,我们实现了:
- 一键部署:从构建到部署的全流程自动化
- 版本管理:支持版本回滚,降低部署风险
- 错误处理:完善的异常处理和用户提示
这套方案不仅提高了部署效率,还大大降低了人为错误的风险。在实际项目中,可以根据具体需求进行定制化改造,比如集成 CI/CD 流程、添加更多环境支持等。
相关资源
所有代码如下
1. 不需要切换到root用户
javascript
//所有代码如下(不需要切换到root用户)
#!/usr/bin/env node
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import scpClient from 'scp2'; // 自动化部署
import { Client } from 'ssh2'; // SSH连接
import chalk from 'chalk'; // 控制台颜色输出
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* 一键部署脚本
* 1. 执行 npm run build
* 2. 将 dist 目录打包为 zip 文件
*/
class DeployScript {
constructor() {
this.projectRoot = path.resolve(__dirname, '..');
this.distPath = path.join(this.projectRoot, 'dist');
this.outputPath = path.join(this.projectRoot, 'dist.zip');
// 服务器配置
this.serverConfig = {
host: 'xxxxx', // 服务器的IP地址
port: 22, // 服务器端口,默认一般为22
username: 'root', // 用户名
password: 'xxxxx!', // 密码
deployPath: '/data/xxxx/', // 项目部署的服务器目标位置
};
}
/**
* 执行命令
*/
execCommand(command, cwd = this.projectRoot) {
try {
console.log(`🔄 执行命令: ${command}`);
execSync(command, {
cwd,
stdio: 'inherit',
encoding: 'utf8',
});
console.log(`✅ 命令执行成功: ${command}`);
} catch (error) {
console.error(`❌ 命令执行失败: ${command}`);
console.error(error.message);
throw error;
}
}
/**
* 检查文件或目录是否存在
*/
exists(path) {
return fs.existsSync(path);
}
/**
* 构建项目
*/
build() {
console.log('1.开始构建项目...');
// 检查 package.json 是否存在
const packageJsonPath = path.join(this.projectRoot, 'package.json');
if (!this.exists(packageJsonPath)) {
throw new Error('❌ 未找到 package.json 文件');
}
// 检查 dist 目录是否存在,如果存在则删除
if (this.exists(this.distPath)) {
console.log('删除现有的 dist 目录...');
fs.rmSync(this.distPath, { recursive: true, force: true });
}
// 执行构建命令
this.execCommand('npm run build');
// 检查构建结果
if (!this.exists(this.distPath)) {
throw new Error('❌ 构建失败,未生成 dist 目录');
}
console.log('✅ 项目构建完成');
}
/**
* 通过SSH执行服务器端命令
*/
async executeSSHCommand(command) {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
// console.log('🔗 SSH连接成功');
conn.exec(command, (err, stream) => {
if (err) {
console.error('❌ SSH命令执行失败:', err);
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('close', (code, signal) => {
conn.end();
if (code === 0) {
console.log('SSH命令执行成功');
resolve(stdout);
} else {
console.error('❌ SSH命令执行失败,退出码:', code);
reject(new Error(`SSH命令执行失败: ${stderr}`));
}
});
stream.on('data', data => {
stdout += data.toString();
});
stream.stderr.on('data', data => {
stderr += data.toString();
});
});
});
conn.on('error', err => {
console.error('❌ SSH连接失败:', err);
reject(err);
});
conn.connect({
host: this.serverConfig.host,
port: this.serverConfig.port,
username: this.serverConfig.username,
password: this.serverConfig.password,
});
});
}
/**
* 处理服务器端目录操作
*/
async handleServerDirectories() {
console.log('2.开始处理服务器端目录...');
try {
// 1. 删除 distOld 目录(如果存在)
console.log('🗑️ 删除服务器上的 distOld 目录...');
await this.executeSSHCommand(
`rm -rf ${this.serverConfig.deployPath}/distOld`
);
// 2. 将现有的 dist 目录重命名为 distOld(如果存在)
console.log('📦 将现有的 dist 目录重命名为 distOld...');
await this.executeSSHCommand(
`if [ -d "${this.serverConfig.deployPath}/dist" ]; then mv ${this.serverConfig.deployPath}/dist ${this.serverConfig.deployPath}/distOld; fi`
);
console.log('✅ 服务器端目录处理完成');
} catch (error) {
console.error('❌ 服务器端目录处理失败:', error.message);
throw error;
}
}
/**
* 上传 dist 目录到服务器
*/
uploadDist() {
console.log('3.开始上传 dist 目录到服务器...');
const server = {
host: this.serverConfig.host,
port: this.serverConfig.port,
username: this.serverConfig.username,
password: this.serverConfig.password,
path: this.serverConfig.deployPath + '/dist',
};
// 使用scp2上传整个dist文件夹(包括文件夹本身)
const scpOptions = {
...server,
preserve: true, // 保持文件权限和时间戳
recursive: true, // 递归上传
};
scpClient.scp('./dist', scpOptions, err => {
if (!err) {
console.log(chalk.blue('🎉 RWA系统自动化部署完毕!'));
console.log(
chalk.green(
`📁 dist目录已上传到: ${this.serverConfig.deployPath}/dist`
)
);
} else {
console.log(chalk.red('❌ RWA系统自动化部署出现异常'), err);
}
});
}
/**
* 主执行函数
*/
async run() {
try {
console.log('🚀 开始一键部署流程...\n');
// 步骤1: 构建项目
this.build();
// 步骤3: 处理服务器端目录
await this.handleServerDirectories();
// 步骤5: 上传 dist 目录
this.uploadDist();
} catch (error) {
console.error('\n❌ 部署失败:', error.message);
process.exit(1);
}
}
}
// 执行部署脚本
const deployScript = new DeployScript();
deployScript.run();
本文基于实际项目经验总结,如有问题欢迎交流讨论。