update: 2025/12/01

今天给我提了新的需求:需要打包出来全部都是zip包,因为懒得手动去压缩删除,所以又加了个方法把打包后的文件压缩了一遍。期中用到了插件archiver
首先:下载依赖 archiver
然后,增加一个方法 zipDirectory ,最后,在单个应用打包完成之后再压缩,然后删除原本的文件夹。

- 1. 首先,下载依赖:执行 npm install  archiver;
- 2. 打包 js 中引入:const archiver = require('archiver');
- 3. 编写 zipDirectory 方法;
- 4. 在每个文件打包完成后执行,我是在 moveDir 中移除了原本项目的打包目录之后执行的;
- 5. 在最终打包完整之后,再执行一次方法,将总文件夹打包压缩并删除原本的文件夹;
zipDirectory 方法编写
/**
 * 将指定文件夹压缩为 zip 文件
 * @param {String} sourceDir 要压缩的文件夹路径(例如:'project-A/dist')
 * @param {String} outPath 输出的文件路径(例如:'project-A/dist.zip')
 * @returns {Promise}
 */
function zipDirectory(sourceDir, outPath) {
  return new Promise((resolve, reject) => {
    // 创建文件输出流
    const output = fs.createWriteStream(outPath);
    // 设置压缩级别为 9(最高)
    const archive = archiver('zip', { zlib: { level: 9 } });
    // 监听输出流的异常
    output.on('error', err => {
      reject({ code: -1, err });
    })

    // 监听压缩完成事件
    output.on('close', () => {
      console.log(`ZIP压缩完成:${outPath}${archive.pointer()}字节)`);
      resolve();
    });

    // 监听压缩过程中的警告和错误
    archive.on('warning', err => {
      if (err.code == 'ENOENT') {
        console.log('压缩警告:', err);
      } else {
        reject({ code: -1, err })
      }
    })
    archive.on('error', err => {
      reject({ code: -1, err });
    })

    // 将输出流管道连接到 archiver
    archive.pipe(output);
    // 将整个目录添加到压缩包,第二个参数 false 表示不保留 sourceDir 本身的层级
    archive.directory(sourceDir, false);
    // 完成压缩
    archive.finalize();
  })
}
moveDir 中使用
// 移动文件的入口
async function moveDir(project, area) {
  const topDirName = `${TOTAL_DIST_DIR_PREFIX}-${area}`
  const sourceDir = `${project.name}/${project.distName}`;
  const destDirName = `${topDirName}/${project.distName}`
  if (fs.existsSync(sourceDir)) {
    copyDir(sourceDir, destDirName);
    console.log(`√ copied files from ${sourceDir} to ${topDirName}`);
    // 清理打包目录。移动完成,清理原子应用中的打包文件
    fs.rmdirSync(sourceDir, { recursive: true });
    // 压缩打包文件
    console.log('开始压缩')
    const result = await zipDirectory(destDirName, `${destDirName}.zip`);
    if (result && result.code == -1) {
      console.log('压缩出错:', result.err)
    } else {
      console.log(`🎁 已生成ZIP文件: ${topDirName}`);
      // 清理新的打包目录。压缩,清理打包文件夹中的打包文件,只留ZIP文件
      fs.rmdirSync(destDirName, { recursive: true });
    }
  }
}
main 函数中,所有项目打包完成执行
// 显示打包结果
    console.log('\n' + '='.repeat(50));
    if (successCount === PROJECT_CONFIGS.length) {
      console.log('√√√√√所有项目打包完成!');
      // 所有项目打包完成,压缩最外层的文件
      const topDitName = `${TOTAL_DIST_DIR_PREFIX}-${area}`
      const result = await zipDirectory(topDitName, `${topDitName}.zip`);
      if (result && result.code == -1) {
        console.log('压缩出错:', result.err)
      } else {
        console.log(`🎁 已生成ZIP文件: ${topDitName}.zip`);
        // 清理新的打包目录。压缩,清理打包文件夹中的打包文件,只留ZIP文件
        fs.rmdirSync(topDitName, { recursive: true });
      }

=========================================================

最近,公司项目出现了一个小问题:
由于业务量上涨,公司由以前只需要打包构建全国的 SAAS 系统转变为需要给其他地区打本地包,以前确实也有这个小业务,但当时未拆分微前端,拆分了之后分为了好几个子应用,每次打包不仅得在子应用去打分支,改 cdn 改版本号,还得每个子应用单独打包之后再放入一个文件夹去交付,最后多几次不仅人疲劳,还很容易搞错(这不,今天就收到一个反馈说昨天打包的本地化项目,有一个应用打包的地址不对。看一眼,就是打错了,人都麻了,赶紧完善一下我的统筹打包大计)。

主要思想就是,在所有子应用的上层,再加一层文件夹,写个文件,统一运行子项目的打包命令。按顺序解读,我主要做了以下的操作:

一. 编写获取环境变量的方法:getEnvVariables ,方法主题主要是询问版本号 version 、区域 area ,然后返回获取的输入信息:

  • 获取环境变量:getEnvVariables方法获取要变更的环境变量version和area - 微前端项目前端统一执行打包子应用
  • 提问函数:
    创建读取接口question - 微前端项目前端统一执行打包子应用

二、 提示用户确认打包信息

  • 用户确认打包信息: 确认打包信息 - 微前端项目前端统一执行打包子应用
  • 提示用户确认打包方法:
    确认打包信息confirmBuild - 微前端项目前端统一执行打包子应用

三、 检查项目是否存在,有不存在则抛出错误,终止程序,都存在则创建打包文件夹:

  • 检查项目是否存在:
    检查项目是否存在 - 微前端项目前端统一执行打包子应用
  • 创建打包总文件夹,创建之前,先清理已存在的,命令以area作为后缀
    创建打包总文件夹 - 微前端项目前端统一执行打包子应用

四. 然后开始打包项目(这是重点,也是主要的步骤):

  1. 首先定义一个 `successCount` 变量存储打包成功的项目个数,初始值为0;
  2. 循环 `PROJECT_CONFIGS` ,逐个打包,打包成功则返回成功标识,`successCount` 加1,再将子项目的打包文件移动到 `total` 下去,最后删除子项目的打包文件,做到无痕;若失败,则跳过该项目,继续打包其他项目;
  3. 打包完成,输出打包结果如:`successCount` 记录了打包成功的个数,显示全部打包完成还是打包完成几个;
  4. 打包过程如果出错,则抛出异常,终止程序;
  • 打包项目
    打包项目 - 微前端项目前端统一执行打包子应用
  • 打包项目方法:buildProject
    打包项目方法-buildProject - 微前端项目前端统一执行打包子应用
  • buildProject 中环境变量设置及提示:
    buildProject中环境变量设置 - 微前端项目前端统一执行打包子应用
  • 最后 execSync 执行子项目的打包命令:
    执行子项目的打包命令 - 微前端项目前端统一执行打包子应用

至此,所有打包方法已编写完成,在最后抛出 main 函数或者执行就行

// 执行主函数
if (require.main === module) {
  main();
}

module.exports = main;

五、注意事项

  • 函数编写完成了,但是是基于本项目的,仍然有几点需要的注意事项:

     1.  项目目录结构为:total => projectA、projectB、projectC、projectD;
     2. 因为在total下我还会存放其他文件及文件夹,所以没有采用动态获取项目名称的方式,将其写死在了 `PROJECT_CONFIGS` 中,要注意项目文件夹名称与 `PROJECT_CONFIGS` 的对应;
     3. 与 2 相似,`platForm` 的 `cdn` 后缀觉得也没有必要去采用获取的方式,所以也写死在配置里了,使用 `platForm` 存储;
     4. 另外一点需要注意的是:我的代码里面采用的子应用打包后的文件夹名与移动后的文件名是相同的(项目需要打包成什么名字就在子应用配置成什么名字了,就算统一打包也不变),如果需要在 `total` 中使用不同名称,则需要自己另加配置项或写死,在本次打包文件中,我两个使用的是同一个配置项 `distName` ,如果子应用的打包输出文件夹名称修改了,那么相应的需要修改一下  `PROJECT_CONFIGS` 中的配置,否则无法复制成功。
     5. 对于上面的 4 步骤,也可以通过获取子应用的打包文件配置的打包输出文件夹名称来动态生成输出的文件夹名称,但我们这个我觉得没有必要,写死就好了...主要还是懒 ^ - ^;
     6. 对了,我的打包文件处于 `total`  下的`scripts` 下,所以代码中有些路径可能需要对应一下;
     7. `total` 下记得也需要 `package.json` 文件,要在此执行命令打包的;
     8. 拜拜~
    
附代码(还是图片看着舒服一点,可惜图片不能复制):

total -> package.json:

{
	...
	"scripts": {
		...
		"build:cdn": "node scripts/single-run.js"
		...
	},
	"dependencies": {
		"archiver": "^7.0.1"
	}
	...
}

total -> scripts -> single-run.js:

total/scripts/single-run.js
/**
 * 打包构建单套系统
 * 通过键入名称和版本号更改打包内容
 */

const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
const readline = require('readline')
const archiver = require('archiver')

// 配置列表
const PROJECT_CONFIGS = [
  /**
   * name - 子应用的文件目录名,最好使用英文命名,
   * distName - 打包后输出的名字,最好使用英文名
   * platForm - cdn 地址对应的后缀
   **/
  { name: 'edu-web-main', distName: 'dist', platForm: 'web' },
  { name: 'edu-web-canteen', distName: 'edu-canteen', platForm: 'edu-canteen' },
  { name: 'edu-web-adv', distName: 'edu-adv', platForm: 'edu-adv' },
  { name: 'edu-web-k12', distName: 'edu-k12', platForm: 'edu-k12' },
  { name: 'edu-h5-main', distName: 'h5', platForm: 'h5' },
]

// 打包的总目录名称前缀,加入后面打包的地区名,形成 edu-sichuan/edu-fujian等
const TOTAL_DIST_DIR_PREFIX = 'dist/edu'

// 创建读取接口
const r1 = readline.createInterface({
  input: process.stdin,
  output: process.stdout
})

// 提问函数
function question(query) {
  return new Promise((resolve) => {
    r1.question(query, resolve);
  });
}

// 验证区域AREA输入
function validateArea(area) {
  /**
   * 为空时代表打包 saas,依赖于项目中的 cdn 和 version 更改,这里不再注入环境变量
   */
  /* if (!area || area.trim() === '') {
    return '区域不能为空';
  } */
  return true;
}

// 版本号输入验证
function validateVersion(version) {
  /**
   * 为空时代表打包 saas,依赖于项目中的 cdn 和 version 更改,这里不再注入环境变量
   */
  /* if (!version || version.trim === '') {
    return '版本不能为空';
  } */
  return true
}

// 交互式获取环境变量
async function getEnvVariables() {
  console.log('开始统一打包流程');
  console.log('='.repeat(50));

  let version, area;

  // 获取区域输入
  while(true) {
    area = await question('请输入部署区域(例如:chongqing/fujian/fuzhou/sichuan):');
    const areaValidation = validateArea(area);
    if (areaValidation === true) {
      break;
    }
    console.log(`部署区域获取出错:${areaValidation}`);
  }
  // 获取版本号输入
  while(true) {
    version = await question('请输入版本号');
    const versionValidation = validateVersion(version);
    if (versionValidation === true) {
      break;
    }
    console.log(`部署版本获取出错:${versionValidation}`)
  }
  return { area: area.trim(), version: version.trim() };
}

// 检查项目是否存在
function checkProjectExists(project) {
  const projectPath = path.join(__dirname, `../${project}`);
  if (!fs.existsSync(projectPath)) {
    throw new Error(`项目${project} 不存在,路径${projectPath}`);
  }
  const stat = fs.statSync(projectPath);
  if (!stat.isDirectory()) {
    throw new Error(`项目${project} 路径不是目录: ${projectPath}`);
  }
  return true
}

// 执行项目打包(仅通过环境变量传递,不写入文件)
function buildProject(project, area, version) {
  console.log(`\n开始打包项目 ${project.name}...`);
  const projectPath = path.join(__dirname, `../${project.name}`);
  // 检查时 web 项目还是 h5 项目

  try{
    // 检查 package.json 是否存在
    const packageJsonPath = path.join(projectPath, 'package.json');
    if (!fs.existsSync(packageJsonPath)) {
      console.log(`项目 ${project.name} 没有 package.json,跳过`);
      return false
    }

    const cdnPath = `https://cdn-edu.zxepay.com/edu-${area}/${project.platForm}/${version}/`
    // 显示打包信息
    console.log(`项目:${project.name}`);
    console.log(`区域:${area}`);
    console.log(`版本:${version}`);
    console.log(`CDN: ${cdnPath}`);
    console.log(`路径:${projectPath}`);

    // 准备环境变量
    const env = {
      // ...process.env, // 继承当前进程的环境变量,无,无需
      // VUE_APP_VERSION: version,
      // VUE_APP_CDN_AREA_VERSION: version,
      // VUE_APP_CDN_AREA_NAME: area,
      // VUE_APP_CDN_API: cdnPath,
    }
    if (version && area) {
      env.VUE_APP_VERSION = version;
      env.VUE_APP_CDN_AREA_VERSION = version;
      env.VUE_APP_CDN_AREA_NAME = area;
      env.VUE_APP_CDN_API = cdnPath
    }
    console.log('环境变量变更设置成功')

    // 执行打包命令,仅通过环境变量传递参数
    execSync('npm run build:cdn', {
      cwd: projectPath,
      stdio: 'inherit',
      env,
    });
    console.log(`项目 ${project.name} 打包完成`);
    return true;
  } catch(error) {
    console.log(`项目 ${project.name} 打包失败:`, error.message);
    return false;
  }
}

// 确认打包
async function confirmBuild(area, version) {
  console.log('\n' + '='.repeat(50));
  console.log('打包配置确认:');
  console.log(`部署区域:${area}`);
  console.log(`版本号:${version}`);
  console.log(`项目列表:${PROJECT_CONFIGS.map(v => v.name).join(', ')}`);
  console.log('='.repeat(50));

  const answer = await question('确认开始打包?(Y/N)');
  return answer.toLowerCase() == 'y' || answer.toLowerCase() == 'yes';
}

// 打包前清理打包的文件夹,再创建
function mkdirTotal(area) {
  const topDirName = `${TOTAL_DIST_DIR_PREFIX}-${area}`
  // 清理打包目录,确保打包目录存在
  if (fs.existsSync(`${topDirName}`)) {
    fs.rmdirSync(topDirName, { recursive: true });
  }
  fs.mkdirSync(topDirName, { recursive: true });
}

// 复制目录(将子应用打包的结果复制到顶层去)
function copyDir(src, dest) {
  if (!fs.existsSync(dest)) {
    fs.mkdirSync(dest, { recursive: true })
  }
  const items = fs.readdirSync(src)
  for (const item of items) {
    const srcPath = path.join(src, item)
    const destPath = path.join(dest, item)
    const stat = fs.statSync(srcPath)

    if (stat.isDirectory()) {
      copyDir(srcPath, destPath)
    } else {
      fs.copyFileSync(srcPath, destPath)
    }
  }
}

// 移动文件的入口
async function moveDir(project, area) {
  const topDirName = `${TOTAL_DIST_DIR_PREFIX}-${area}`
  const sourceDir = `${project.name}/${project.distName}`;
  const destDirName = `${topDirName}/${project.distName}`
  if (fs.existsSync(sourceDir)) {
    copyDir(sourceDir, destDirName);
    console.log(`√ copied files from ${sourceDir} to ${topDirName}`);
    // 清理打包目录。移动完成,清理原子应用中的打包文件
    fs.rmdirSync(sourceDir, { recursive: true });
    // 压缩打包文件
    console.log('开始压缩')
    const result = await zipDirectory(destDirName, `${destDirName}.zip`);
    if (result && result.code == -1) {
      console.log('压缩出错:', result.err)
    } else {
      console.log(`🎁 已生成ZIP文件: ${topDirName}`);
      // 清理新的打包目录。压缩,清理打包文件夹中的打包文件,只留ZIP文件
      fs.rmdirSync(destDirName, { recursive: true });
    }
  }
}

/**
 * 将指定文件夹压缩为 zip 文件
 * @param {String} sourceDir 要压缩的文件夹路径(例如:'project-A/dist')
 * @param {String} outPath 输出的文件路径(例如:'project-A/dist.zip')
 * @returns {Promise}
 */
function zipDirectory(sourceDir, outPath) {
  return new Promise((resolve, reject) => {
    // 创建文件输出流
    const output = fs.createWriteStream(outPath);
    // 设置压缩级别为 9(最高)
    const archive = archiver('zip', { zlib: { level: 9 } });
    // 监听输出流的异常
    output.on('error', err => {
      reject({ code: -1, err });
    })

    // 监听压缩完成事件
    output.on('close', () => {
      console.log(`ZIP压缩完成:${outPath}(${archive.pointer()}字节)`);
      resolve();
    });

    // 监听压缩过程中的警告和错误
    archive.on('warning', err => {
      if (err.code == 'ENOENT') {
        console.log('压缩警告:', err);
      } else {
        reject({ code: -1, err })
      }
    })
    archive.on('error', err => {
      reject({ code: -1, err });
    })

    // 将输出流管道连接到 archiver
    archive.pipe(output);
    // 将整个目录添加到压缩包,第二个参数 false 表示不保留 sourceDir 本身的层级
    archive.directory(sourceDir, false);
    // 完成压缩
    archive.finalize();
  })
}

async function main() {
  try {
    // 获取环境变量
    const { area, version } = await getEnvVariables();

    // 确认打包
    const confirmed = await confirmBuild(area, version);
    if (!confirmed) {
      console.log('用户取消打包');
      r1.close();
      return
    }

    // 检查所有项目是否存在
    console.log('\n检查项目...');
    for (const project of PROJECT_CONFIGS) {
      try {
        checkProjectExists(project.name);
        console.log(`项目 ${project.name} 存在`);
      } catch(error) {
        console.log(`${error.message}`);
        throw error;
      }
    }
    mkdirTotal(area);
    console.log('\n开始打包项目...');
    let successCount = 0;

    for (const project of PROJECT_CONFIGS) {
      const success = await buildProject(project, area, version);
      if (success) {
        successCount++;
        await moveDir(project, area);
      } else {
        console.log(`跳过项目 ${project.name}, 继续打包其他项目...`);
      }
    }

    // 显示打包结果
    console.log('\n' + '='.repeat(50));
    if (successCount === PROJECT_CONFIGS.length) {
      console.log('√√√√√所有项目打包完成!');
      // 所有项目打包完成,压缩最外层的文件
      const topDitName = `${TOTAL_DIST_DIR_PREFIX}-${area}`
      const result = await zipDirectory(topDitName, `${topDitName}.zip`);
      if (result && result.code == -1) {
        console.log('压缩出错:', result.err)
      } else {
        console.log(`🎁 已生成ZIP文件: ${topDitName}.zip`);
        // 清理新的打包目录。压缩,清理打包文件夹中的打包文件,只留ZIP文件
        fs.rmdirSync(topDitName, { recursive: true });
      }
    } else {
      console.log(`√打包完成:${successCount}/${PROJECT_CONFIGS.length} 个项目打包成功`);
    }
    console.log('='.repeat(50));
  } catch(error) {
    console.log('\n ×××打包过程出错:', error.message);
  } finally {
    r1.close();
  }
}

// 执行主函数
if (require.main === module) {
  main();
}

module.exports = main;


Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐