利用scp2和ssh2完成前端项目自动化部署解决方案 1.0

前言

最近我参与了几个前端手动部署机器的项目, 部署流程长期依赖人工操作:从本地打包构建,到登录服务器、定位目录,再到上传压缩包、解压缩完成部署 ------ 整套流程步骤繁琐且重复,不仅效率低下,还容易因手动操作出现疏漏。 闲暇时,我基于 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. 可维护性

  • 模块化设计,职责分离
  • 详细的日志输出
  • 配置文件外部化

总结

通过本文分享的自动化部署方案,我们实现了:

  1. 一键部署:从构建到部署的全流程自动化
  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();

本文基于实际项目经验总结,如有问题欢迎交流讨论。

相关推荐
ZJ_2 小时前
功能按钮权限控制(使用自定义指令控制权限)
前端·javascript·vue.js
鹤顶红6533 小时前
Python -- 人生重开模拟器(简易版)
服务器·前端·python
幸运小圣3 小时前
Sass和Less的区别【前端】
前端·less·sass
BXCQ_xuan3 小时前
软件工程实践八:Web 前端项目实战(SSE、Axios 与代理)
前端·axios·api·sse
大棋局3 小时前
基于 UniApp 的弹出层选择器单选、多选组件,支持单选、多选、搜索、数量输入等功能。专为移动端优化,提供丰富的交互体验。
前端·uni-app
racerun3 小时前
CSS Display Grid布局 grid-template-columns grid-template-rows
开发语言·前端·javascript
一只毛驴3 小时前
Canvas 的基本使用及动画效果
前端·javascript
行走在顶尖3 小时前
JS场景应用
前端