Vue + Vite + ssh、archiver 一键部署前端项目

最近在做一个项目,需要将前端项目部署到服务器上,但是手动部署又比较麻烦费时间,所以研究了一下一键部署。
由于项目是 Vue + Vite 构建的,需要用到 ssh 和 archiver 来实现一键部署。

实现思路

  • 需提前在nginx配置反向代理,将请求转发到部署的项目目录,
  1. 使用 ssh2 模块连接服务器
  2. 使用 archiver 模块将项目打包成 zip 文件
  3. 使用 fs 模块将 zip 文件上传到服务器
  4. 使用 ssh2 模块在服务器上解压 zip 文件
  5. 使用 ssh2 模块在服务器上删除 zip 文件

代码实现

  • 在项目根目录创建文件夹 scripts

清空打包目录

  • 在 scripts 文件夹下创建 clean.js 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const distPath = path.resolve(__dirname, 'dist');

console.log('正在准备清理构建目录...');
console.log('目标目录:', distPath);

try {
if (fs.existsSync(distPath)) {
console.log('执行系统命令删除...');
try {
if (process.platform === 'win32') {
execSync(`rd /s /q "${distPath}"`, { stdio: 'inherit' });
} else {
execSync(`rm -rf "${distPath}"`, { stdio: 'inherit' });
}
} catch (e) {
console.log('系统命令执行可能有误或目录已占用,尝试 Node.js 删除...');
fs.rmSync(distPath, { recursive: true, force: true });
}

if (fs.existsSync(distPath)) {
console.error('错误: dist 目录仍然存在!清理失败。');
console.error('请手动关闭所有占用该文件夹的窗口 (VSCode, 资源管理器, 终端等) 并重试。');
process.exit(1);
} else {
console.log('清理成功 (verified).');
}
} else {
console.log('dist 目录不存在,无需清理。');
}
} catch (error) {
console.error('清理过程中发生异常:', error);
process.exit(1);
}

打包、上传、部署至服务器

  • 在 scripts 文件夹下创建 deploy.mjs 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import { createWriteStream, existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { ZipArchive } from 'archiver';
import dotenv from 'dotenv';
import { Client } from 'ssh2';

const projectRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
const distDir = resolve(projectRoot, 'dist');
const archivePath = resolve(projectRoot, 'dist.zip');
const dryRun = process.argv.includes('--dry-run');
const deployTarget = process.argv.find(argument => ['dev', 'prod'].includes(argument));
const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';

dotenv.config({ path: resolve(projectRoot, '.env.deploy.local'), quiet: true });
dotenv.config({ path: resolve(projectRoot, '.env.deploy'), quiet: true });

const config = {
target: process.env.DEPLOY_TARGET,
host: process.env.DEPLOY_HOST,
port: Number(process.env.DEPLOY_PORT),
username: process.env.DEPLOY_USER,
password: process.env.DEPLOY_PASSWORD,
readyTimeout: 20_000,
};
const remoteDir = process.env.DEPLOY_REMOTE_DIR ?? '/opt/front';
const remoteArchive = `${remoteDir}/dist.zip`;

function run(command, args) {
return new Promise((resolvePromise, reject) => {
// Windows executes .cmd launchers through its command shell; POSIX systems
// keep shell execution disabled so arguments remain properly isolated.
const child = spawn(command, args, {
cwd: projectRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.once('error', reject);
child.once('exit', code => {
if (code === 0) resolvePromise();
else reject(new Error(`${command} exited with code ${code}`));
});
});
}

async function createArchive() {
await rm(archivePath, { force: true });

await new Promise((resolvePromise, reject) => {
const output = createWriteStream(archivePath);
const archive = new ZipArchive({ zlib: { level: 9 } });
output.once('close', resolvePromise);
output.once('error', reject);
archive.once('error', reject);
archive.pipe(output);
archive.glob('**/*', { cwd: distDir, dot: false }, { prefix: 'dist' });
archive.finalize();
});
}

function connect() {
return new Promise((resolvePromise, reject) => {
const client = new Client();
client.once('ready', () => resolvePromise(client));
client.once('error', reject);
client.connect(config);
});
}

function upload(client) {
return new Promise((resolvePromise, reject) => {
client.sftp((sftpError, sftp) => {
if (sftpError) return reject(sftpError);
sftp.fastPut(archivePath, remoteArchive, putError => {
if (putError) reject(putError);
else resolvePromise();
});
});
});
}

function executeRemote(client) {
const command = [
'set -e',
`cd ${remoteDir}`,
'unzip -tq dist.zip',
'rm -rf dist',
'unzip -o dist.zip',
'nginx -s reload',
].join(' && ');

return new Promise((resolvePromise, reject) => {
client.exec(command, (execError, stream) => {
if (execError) return reject(execError);
stream.pipe(process.stdout);
stream.stderr.pipe(process.stderr);
stream.once('close', code => {
if (code === 0) resolvePromise();
else reject(new Error(`Remote deployment command exited with code ${code}`));
});
});
});
}

async function deploy() {
if (!deployTarget) {
throw new Error(
'Deployment target is missing. Use pnpm run deploy:dev or pnpm run deploy:prod.',
);
}
if (config.target !== deployTarget) {
throw new Error(
`DEPLOY_TARGET is "${config.target ?? ''}", but the command targets "${deployTarget}". ` +
'Comment/uncomment the matching environment block in .env.deploy.local.',
);
}
if (!config.host) {
throw new Error('DEPLOY_HOST is missing. Set it in .env.deploy.local.');
}
if (!config.username) {
throw new Error('DEPLOY_USER is missing. Set it in .env.deploy.local.');
}
if (!config.password) {
throw new Error('DEPLOY_PASSWORD is missing. Set it in .env.deploy.local.');
}
if (!Number.isInteger(config.port) || config.port < 1) {
throw new Error('DEPLOY_PORT must be a valid port number.');
}
if (!/^\/[A-Za-z0-9._/-]+$/.test(remoteDir)) {
throw new Error('DEPLOY_REMOTE_DIR must be an absolute path containing safe characters.');
}

console.log(`[1/4] Building application for ${deployTarget} deployment...`);
await run(pnpmCommand, ['run', 'build:prod']);
if (!existsSync(distDir)) throw new Error('Build completed without creating dist/.');

console.log('[2/4] Creating dist.zip...');
await createArchive();

if (dryRun) {
console.log('Dry run completed: build and dist.zip are ready; no server changes were made.');
return;
}

console.log(`[3/4] Uploading dist.zip to ${config.host}:${remoteDir}/...`);
const client = await connect();
try {
await upload(client);
console.log('[4/4] Replacing dist and reloading Nginx...');
await executeRemote(client);
} finally {
client.end();
}

await rm(archivePath, { force: true });
console.log('Deployment completed successfully.');
}

deploy().catch(error => {
console.error(`Deployment failed: ${error.message}`);
process.exitCode = 1;
});

本地部署配置文件

  • 在项目根目录创建文件 .env.deploy.local
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # Test environment
    # DEPLOY_TARGET=dev
    # DEPLOY_HOST=服务器ip地址
    # DEPLOY_PORT=22
    # DEPLOY_USER=root
    # DEPLOY_PASSWORD=服务器密码
    # DEPLOY_REMOTE_DIR=前端文件在服务器上的路径

    # Production environment
    # DEPLOY_TARGET=prod
    # DEPLOY_HOST=服务器ip地址
    # DEPLOY_PORT=22
    # DEPLOY_USER=root
    # DEPLOY_PASSWORD=服务器密码
    # DEPLOY_REMOTE_DIR=前端文件在服务器上的路径

部署命令

  • 在项目根目录执行命令
    1
    2
    3
    pnpm run deploy:dev 

    pnpm run deploy:prod

部署成功后访问项目地址验证即可