Rspack 实战,构建流程升级:自动版本管理 + 命令行美化 + dist 压缩,一键输出生产包

在前端项目的生产构建中,我们常需要手动做三件事:更新版本号、查看构建耗时、压缩 dist 包 ------ 这些重复操作不仅效率低,还容易出错(比如忘记更新版本导致线上版本混乱)。

本文就带你基于 Rspack 打造一套「自动化构建流程」:通过改造 build 脚本,实现 版本号自动递增 + 命令行日志美化 + dist 自动压缩 + 构建报告生成 ,最后只需一条 npm run build,就能输出带版本信息的压缩包,还能清晰看到每个环节的耗时。

效果:

arduino 复制代码
$ npm run build:prod

一、核心功能拆解:这套脚本能解决什么问题?

先明确我们要实现的自动化目标,避免无意义的代码堆砌:

  1. 版本号自动管理 :按「主版本。日期。每日构建次数」格式递增(如 1.20240520.3),无需手动改 package.json
  2. 命令行日志美化:用颜色区分不同环节(清理→构建→压缩),显示精确时间和耗时,告别单调黑白色;
  3. dist 自动压缩 :构建完成后自动打包 dist 目录为 ZIP 包,排除 sourcemap 等无用文件,还能显示压缩进度;
  4. 构建报告生成:输出总耗时、各环节耗时、版本号等信息,方便定位构建瓶颈(如压缩耗时过长)。

二、实现步骤:从依赖安装到脚本改造

第一步:安装必备依赖

首先需要安装处理压缩和命令行颜色的依赖,这里用 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);
}
相关推荐
恋猫de小郭2 小时前
Flutter Riverpod 3.0 发布,大规模重构下的全新状态管理框架
android·前端·flutter
wordbaby2 小时前
用 window.matchMedia 实现高级响应式开发:API 全面解析与实战技巧
前端·javascript
huabuyu2 小时前
在 Taro 小程序中实现完整 Markdown 渲染器的实践
前端
晚星star2 小时前
在 Web 前端实现流式 TTS 播放
前端·vue.js
huabuyu2 小时前
基于 Taro 的 Markdown AST 渲染器实现
前端
薄雾晚晴2 小时前
Rspack 性能优化实战:JS/CSS 压缩 + 代码分割,让产物体积直降 40%
前端·javascript
本末倒置1832 小时前
前端面试高频题:18个经典技术难点深度解析与解决方案
前端·vue.js·面试
狗头大军之江苏分军3 小时前
Meta万人裁员亲历者自述:小扎尝到了降本的甜头
前端·后端·github
秃顶老男孩.3 小时前
web中的循环遍历
开发语言·前端·javascript