在前端项目的生产构建中,我们常需要手动做三件事:更新版本号、查看构建耗时、压缩 dist 包 ------ 这些重复操作不仅效率低,还容易出错(比如忘记更新版本导致线上版本混乱)。
本文就带你基于 Rspack 打造一套「自动化构建流程」:通过改造 build 脚本,实现 版本号自动递增 + 命令行日志美化 + dist 自动压缩 + 构建报告生成 ,最后只需一条 npm run build
,就能输出带版本信息的压缩包,还能清晰看到每个环节的耗时。
效果:
arduino
$ npm run build:prod

一、核心功能拆解:这套脚本能解决什么问题?
先明确我们要实现的自动化目标,避免无意义的代码堆砌:
- 版本号自动管理 :按「主版本。日期。每日构建次数」格式递增(如
1.20240520.3
),无需手动改package.json
; - 命令行日志美化:用颜色区分不同环节(清理→构建→压缩),显示精确时间和耗时,告别单调黑白色;
- dist 自动压缩 :构建完成后自动打包
dist
目录为 ZIP 包,排除 sourcemap 等无用文件,还能显示压缩进度; - 构建报告生成:输出总耗时、各环节耗时、版本号等信息,方便定位构建瓶颈(如压缩耗时过长)。
二、实现步骤:从依赖安装到脚本改造
第一步:安装必备依赖
首先需要安装处理压缩和命令行颜色的依赖,这里用 archiver
(ZIP 压缩工具)和 chalk
(命令行颜色美化):
csharp
# pnpm 安装(推荐)
pnpm add archiver chalk rimraf -D
# npm 安装
npm i archiver chalk rimraf --save-dev
archiver
:用于压缩dist
目录为 ZIP 包,支持高压缩级别和进度跟踪;chalk
:给命令行日志添加颜色,区分成功 / 失败 / 提示信息;rimraf
:跨平台删除目录(替代fs.rmdirSync
,避免 Windows 下报错)。
第二步:改造 build 脚本(核心:自动化流程串联)
我们将原来的 build.js
改造成「多环节自动化」脚本,核心流程是:版本号更新 → 清理旧目录 → Rspack 构建 → dist 压缩 → 生成构建报告。
完整 build 脚本代码(build/build.js)
javascript
'use strict'
require('./check-versions')()
process.env.NODE_ENV = 'production'
const ora = require('ora')
const rm = require('rimraf')
const path = require('path')
const chalk = require('chalk')
const rspack = require('@rspack/core')
const config = require('../config')
const rspackConfig = require('./rspack.prod.conf')
const fs = require('fs');
const spinner = ora('building for production...')
// 记录整个构建过程的开始时间
const totalStartTime = Date.now();
console.log(chalk.blue(`[${new Date().toLocaleTimeString()}] 🚀 开始构建流程...`));
// 添加全局 navigator 修复
if (typeof global.navigator === 'undefined') {
global.navigator = {
userAgent: 'Node.js/' + process.version,
platform: process.platform,
language: 'en-US'
};
}
spinner.start()
// 声明变量以避免作用域问题
let versionLine = "未知版本"; // 默认值
let cleanDuration = 0;
let rspackDuration = 0;
let zipDuration = 0; // 在这里声明 zipDuration
try {
function AddZero(time){
if(time<10){
return "0"+time
}else{
return time
}
}
let packageTxt=fs.readFileSync('./package.json','utf8');
let versionData = packageTxt.split('\n');
let packageJson = JSON.parse(packageTxt);
let VersionArr = packageJson.version.split('.');
let date = new Date();
let today = date.getFullYear()+""+AddZero((date.getMonth()+1))+""+AddZero(date.getDate())
if(today == VersionArr[1]){
VersionArr[2] = parseInt(VersionArr[2])+1
}else{
VersionArr[1] = date.getFullYear()+""+AddZero((date.getMonth()+1))+""+AddZero(date.getDate())
VersionArr[2] = 1;
}
versionLine = VersionArr.join('.'); // 赋值给外部声明的变量
for(let i= 0; i<versionData.length;i++){
if(versionData[i].indexOf('"version":')!=-1){
versionData.splice(i,1,' "version": "'+versionLine+'",');
break;
}
}
fs.writeFileSync('./package.json',versionData.join('\n'),'utf8');
console.log(chalk.green.bold('✅ 更新版本号成功!当前版本: ' + versionLine));
} catch(e) {
console.log(chalk.red.bold('❌ 读取文件修改版本号出错:', e.toString()));
}
// 记录清理开始时间
const cleanStartTime = Date.now();
console.log(chalk.blue(`[${new Date().toLocaleTimeString()}] 🧹 开始清理输出目录...`));
rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
if (err) throw err
// 计算清理耗时
const cleanEndTime = Date.now();
cleanDuration = (cleanEndTime - cleanStartTime) / 1000; // 赋值给外部变量
console.log(chalk.green.bold(`✅ 清理完成! 耗时: ${cleanDuration.toFixed(2)} 秒`));
// 确保 navigator 可写
const originalNavigator = global.navigator;
try {
Object.defineProperty(global, 'navigator', {
value: { ...global.navigator },
writable: true,
configurable: true
});
} catch (e) {
console.warn('⚠️ 无法设置可写的 navigator:', e.message);
}
// 记录 Rspack构建开始时间 const rspackStartTime = Date.now();
console.log(chalk.blue(`[${new Date().toLocaleTimeString()}] 📦 开始 Rspack 构建...`));
rspack(rspackConfig, (err, stats) => {
if (originalNavigator) {
global.navigator = originalNavigator;
}
spinner.stop()
if (err) throw err
// 计算 Rspack 构建耗时 const rspackEndTime = Date.now();
rspackDuration = (rspackEndTime - rspackStartTime) / 1000; // 赋值给外部变量
process.stdout.write(stats.toString({
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}) + '\n\n')
if (stats.hasErrors()) {
console.log(chalk.red(' ❌ 构建失败,发现错误!\n'))
process.exit(1)
}
console.log(chalk.cyan.bold(` ✅ Rspack 构建完成! 耗时: ${rspackDuration.toFixed(2)} 秒\n`)); console.log(chalk.yellow(
' 💡 提示: 构建文件需要通过 HTTP 服务器提供服务\n' +
' 直接打开 index.html 可能无法正常工作\n'
))
try {
console.log(chalk.blue(`[${new Date().toLocaleTimeString()}] 🗜️ 开始打包 dist.zip...`));
const zipStartTime = Date.now();
require('./zip-dist');
const zipEndTime = Date.now();
zipDuration = (zipEndTime - zipStartTime) / 1000; // 赋值给已声明的变量
console.log(chalk.green.bold(`✅ dist.zip 打包完成! 耗时: ${zipDuration.toFixed(2)} 秒`));
} catch (zipErr) {
console.error(chalk.red('❌ dist.zip 打包失败:'), zipErr);
// 设置默认值以避免未定义错误
zipDuration = -1; // 使用负数表示失败
}
// 计算总耗时(秒)
const totalEndTime = Date.now();
const totalSeconds = (totalEndTime - totalStartTime) / 1000;
// 格式化总耗时为易读格式
let totalTimeText;
if (totalSeconds < 60) {
// 小于1分钟,直接显示秒
totalTimeText = `${totalSeconds.toFixed(2)} 秒`;
} else {
// 大于等于1分钟,显示分和秒
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
totalTimeText = `${minutes} 分 ${seconds.toFixed(2)} 秒`;
}
// 输出详细时间报告
console.log(chalk.magenta.bold('\n==================== 构建报告 ===================='));
console.log(chalk.magenta(`🔧 版本号更新: ${versionLine}`));
console.log(chalk.magenta(`🧹 清理耗时: ${cleanDuration.toFixed(2)} 秒`));
console.log(chalk.magenta(`📦 Rspack 构建耗时: ${rspackDuration.toFixed(2)} 秒`));
// 安全地显示 ZIP 压缩时间
if (zipDuration >= 0) {
console.log(chalk.magenta(`🗜️ ZIP 压缩耗时: ${zipDuration.toFixed(2)} 秒`));
} else {
console.log(chalk.magenta(`🗜️ ZIP 压缩: 失败`));
}
console.log(chalk.magenta.bold(`🏁 总耗时: ${totalTimeText}`));
console.log(chalk.magenta(`⏰ 开始时间: ${new Date(totalStartTime).toLocaleTimeString()}`));
console.log(chalk.magenta(`⏱️ 结束时间: ${new Date(totalEndTime).toLocaleTimeString()}`));
console.log(chalk.magenta.bold('================================================\n'));
// 如果有压缩错误,在此处退出
if (zipDuration < 0) {
process.exit(1);
}
})
})
第三步:编写 dist 自动压缩脚本(build/zip-dist.js)
单独拆分 zip-dist.js
脚本,负责将 dist
目录压缩为 ZIP 包,支持进度跟踪、旧包删除、大文件过滤等功能:
ini
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const chalk = require('chalk');
// 获取项目根目录
const rootPath = path.resolve(__dirname, '..');
const distPath = path.join(rootPath, 'dist');
const packageJson = require(path.join(rootPath, 'package.json'));
// 确定压缩包名称
const version = packageJson.version;
const date = new Date();
const dateStr = `${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, '0')}${date.getDate().toString().padStart(2, '0')}`;
const timeStr = `${date.getHours().toString().padStart(2, '0')}${date.getMinutes().toString().padStart(2, '0')}`;
const buildEnv = process.env.BUILD_ENV || 'prod';
const zipName = process.env.ZIP_NAME || `dist.zip`;
// 这里修改了压缩包的路径,直接放到 dist 目录
const zipPath = path.join(distPath, zipName); // 原代码是 path.join(rootPath, zipName)
// 删除旧压缩包
if (fs.existsSync(zipPath)) {
try {
fs.unlinkSync(zipPath);
console.log(chalk.green(`✅ 已删除旧压缩包: ${zipName}`));
} catch (err) {
console.error(chalk.red(`❌ 删除旧压缩包失败: ${err.message}`));
}
}
// 确保 dist 目录存在
if (!fs.existsSync(distPath)) {
console.error(chalk.red(`❌ dist 目录不存在,请先运行构建命令`));
process.exit(1);
}
// 创建输出流
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 9 } // 最高压缩级别
});
console.log(chalk.blue(`📦 开始创建压缩包: ${chalk.bold(zipName)}`));
console.log(chalk.blue(`🔍 源目录: ${distPath}`));
// 进度跟踪
let totalFiles = 0;
let processedFiles = 0;
let startTime = Date.now();
// 计算文件总数
function countFiles(dir) {
const files = fs.readdirSync(dir);
let count = 0;
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
count += countFiles(filePath);
} else {
count++;
}
});
return count;
}
// 更新进度显示
function updateProgress() {
if (totalFiles === 0) return;
const percent = Math.round((processedFiles / totalFiles) * 100);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
process.stdout.write(`\r📦 压缩进度: ${percent}% (${processedFiles}/${totalFiles} 文件) [${elapsed}s]`);
}
// 监听事件
output.on('close', () => {
const sizeMB = (archive.pointer() / 1024 / 1024).toFixed(2);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(chalk.green.bold(`✅ 压缩包创建成功`));
console.log(chalk.gray(` ├─ 大小: ${chalk.cyan(`${sizeMB} MB`)}`));
console.log(chalk.gray(` └─ 路径: ${chalk.white(zipPath)}\n`));
});
archive.on('error', (err) => {
console.error(chalk.red(`\n❌ 压缩失败: ${err.message}`));
process.exit(1);
});
archive.on('warning', (err) => {
if (err.code === 'ENOENT') {
console.warn(chalk.yellow(`⚠️ 文件警告: ${err.message}`));
} else {
console.error(chalk.red(`\n❌ 压缩警告: ${err.message}`));
process.exit(1);
}
});
archive.on('entry', (entry) => {
if (entry.name) {
processedFiles++;
// 每处理50个文件或完成时更新进度
if (processedFiles % 50 === 0 || processedFiles === totalFiles) {
updateProgress();
}
}
});
// 管道连接
archive.pipe(output);
// 递归添加目录中的所有文件
function addDirectory(dir, parentDir = '') {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const relativePath = parentDir ? path.join(parentDir, file) : file;
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
addDirectory(filePath, relativePath);
} else {
// 排除 sourcemap 文件
if (filePath.endsWith('.map')) return;
// 排除大文件(可选)
if (stat.size > 30 * 1024 * 1024) {
console.warn(chalk.yellow(`⚠️ 跳过大文件: ${relativePath} (${(stat.size / 1024 / 1024).toFixed(2)} MB)`));
return;
}
archive.file(filePath, { name: relativePath });
}
});
}
// 添加 dist 目录中的所有文件
try {
// 计算文件总数
totalFiles = countFiles(distPath);
console.log(chalk.blue(`📂 发现 ${totalFiles} 个文件待压缩`));
// 添加 dist 目录
addDirectory(distPath);
// 完成压缩
archive.finalize();
} catch (err) {
console.error(chalk.red(`❌ 压缩准备失败: ${err.message}`));
process.exit(1);
}