🚀 神器!一键导出 node_modules 为 .tgz 包,解决依赖管理痛点,内网开发痛点

22 阅读5分钟

🚀 神器!一键导出 node_modules 为 .tgz 包,解决依赖管理痛点

前言

在前端开发中,你是否遇到过以下痛点?

  • 内网环境无法访问 npm 仓库,依赖安装困难
  • 项目迁移时需要重新安装大量依赖,耗时耗力
  • 依赖版本管理混乱,不同环境可能安装不同版本
  • 离线部署时需要手动收集所有依赖包

别担心,今天我将介绍一个我开发的 Node.js 工具 —— export-node-modules-tgz.js,它可以一键导出 node_modules 中的所有包及其依赖为 .tgz 文件,完美解决上述问题!

工具简介

export-node-modules-tgz.js 是一个基于 Node.js 的命令行工具,主要功能包括:

  • ✅ 导出所有顶级依赖及其递归依赖
  • ✅ 支持导出特定包及其依赖
  • ✅ 自动创建输出目录
  • ✅ 智能解析包版本信息
  • ✅ 生成详细的打包报告
  • ✅ 处理作用域包(如 @babel/core)

核心功能实现

1. 依赖收集机制

工具的核心是 collectDependencies 函数,它采用递归方式收集所有依赖:

function collectDependencies(packageName, collected) {
  const normalizedPackageName = packageName.replace(/\\/g, "/");
  const version = getExactVersion(normalizedPackageName);

  if (version) {
    const packageId = `${normalizedPackageName}@${version}`;
    if (!collected.has(packageId)) {
      collected.add(packageId);

      // 从 package.json 读取依赖并递归收集
      const packageJsonPath = path.join(
        NODE_MODULES_DIR,
        normalizedPackageName,
        "package.json",
      );
      try {
        if (fs.existsSync(packageJsonPath)) {
          const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
          if (pkg.dependencies) {
            Object.keys(pkg.dependencies).forEach((depName) => {
              collectDependencies(depName, collected);
            });
          }
        }
      } catch (error) {
        console.warn(
          `⚠️  读取 ${packageName} 的依赖失败: ${error.message}`,
        );
      }
    }
  }
}

这个函数的工作原理是:

  1. 接收包名和已收集的包集合
  2. 标准化包名(处理路径分隔符)
  3. 获取包的精确版本
  4. 生成唯一的包 ID(包名@版本)
  5. 递归收集该包的所有依赖

2. 顶级包扫描

getTopLevelPackages 函数用于扫描 node_modules 中的所有顶级包:

function getTopLevelPackages() {
  const packages = [];
  const items = fs.readdirSync(NODE_MODULES_DIR, { withFileTypes: true });

  items.forEach((item) => {
    if (item.isDirectory()) {
      const itemName = item.name;

      // 跳过 .bin 目录
      if (itemName === ".bin") {
        return;
      }

      if (itemName.startsWith("@")) {
        // 作用域包目录
        const scopePath = path.join(NODE_MODULES_DIR, itemName);
        const scopeItems = fs.readdirSync(scopePath, { withFileTypes: true });
        scopeItems.forEach((scopeItem) => {
          if (scopeItem.isDirectory()) {
            const scopedPackageName = `${itemName}/${scopeItem.name}`;
            packages.push(scopedPackageName);
          }
        });
      } else {
        // 普通包
        packages.push(itemName);
      }
    }
  });

  return packages;
}

这个函数会:

  1. 遍历 node_modules 目录
  2. 跳过 .bin 目录
  3. 处理作用域包(如 @babel/core)
  4. 收集所有顶级包名

3. 包打包机制

packPackage 函数使用 npm pack 命令打包包:

function packPackage(packageId) {
  try {
    console.log(`\n处理中: ${packageId}`);
    execSync(`npm pack ${packageId} --pack-destination ${OUTPUT_DIR}`, {
      stdio: "pipe",
      stderr: "pipe",
    });
    console.log(`✅ 成功: ${packageId}`);
    return true;
  } catch (error) {
    console.error(`❌ 失败: ${packageId}`);
    console.error(`   错误: ${error.stderr.toString().trim()}`);
    return false;
  }
}

这个函数会:

  1. 执行 npm pack 命令
  2. 指定打包目标目录
  3. 捕获并处理错误
  4. 返回打包结果

使用方法

1. 导出所有包

node export-node-modules-tgz.js

2. 导出特定包

node export-node-modules-tgz.js lodash axios @babel/core

3. 查看输出

执行命令后,工具会:

  1. 扫描 node_modules 中的包
  2. 递归收集所有依赖
  3. 打包所有包到 node_modules-tgz 目录
  4. 生成详细的打包报告

实际应用场景

场景一:内网环境部署

  1. 在有网络的环境中运行工具导出所有依赖
  2. node_modules-tgz 目录复制到内网环境
  3. 使用 npm install *.tgz 命令安装所有依赖

场景二:项目迁移

  1. 导出当前项目的所有依赖
  2. 在新环境中解压导出的包
  3. 执行安装命令,快速搭建相同的依赖环境

场景三:依赖版本锁定

  1. 导出特定版本的依赖包
  2. 将这些包纳入版本控制
  3. 确保所有环境使用相同版本的依赖

代码优化建议

  1. 并行打包:当前实现是串行打包,可以使用 Promise.all 并行处理,提高打包速度

  2. 缓存机制:添加缓存机制,避免重复打包相同的包

  3. 配置文件:支持通过配置文件指定导出选项

  4. 进度条:添加打包进度条,提升用户体验

  5. 错误重试:对失败的包添加自动重试机制

完整代码

/**
 * 将 node_modules 包及其依赖导出为 .tgz 文件
 * 使用 Node.js 处理此过程
 * 
 * 使用方法:
 *   # 导出所有包
 *   node export-node-modules-tgz.js
 *   
 *   # 导出特定包
 *   node export-node-modules-tgz.js 包名1 包名2 @作用域/包名3
 */

import { execSync } from "child_process";
import fs from "fs";
import path from "path";

const OUTPUT_DIR = "./node_modules-tgz";
const NODE_MODULES_DIR = "./node_modules";

// 解析命令行参数以获取要导出的特定包
const args = process.argv.slice(2);
const specificPackages = args.length > 0 ? args : null;

if (specificPackages) {
  console.log(`📋 导出特定包: ${specificPackages.join(", ")}`);
} else {
  console.log("📋 导出所有包");
}

// 如果输出目录不存在则创建
if (!fs.existsSync(OUTPUT_DIR)) {
  fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  console.log(`✅ 输出目录已创建: ${OUTPUT_DIR}`);
}

// 检查 node_modules 是否存在
if (!fs.existsSync(NODE_MODULES_DIR)) {
  console.error(
    "❌ 未找到 node_modules 目录!请先运行 `npm install`。",
  );
  process.exit(1);
}

/**
 * 从 package.json 获取包的精确版本
 * @param {string} packageName - 包名(例如 "lodash" 或 "@ant-design/colors")
 * @returns {string|null} - 精确版本或未找到时返回 null
 */
function getExactVersion(packageName) {
  const packageJsonPath = path.join(
    NODE_MODULES_DIR,
    packageName,
    "package.json",
  );
  try {
    if (fs.existsSync(packageJsonPath)) {
      const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
      return pkg.version || null;
    }
  } catch (error) {
    console.warn(
      `⚠️  读取 ${packageName} 的 package.json 失败: ${error.message}`,
    );
  }
  return null;
}

/**
 * 递归收集所有依赖
 * @param {string} packageName - 包名
 * @param {Set} collected - 收集的包 ID 集合
 */
function collectDependencies(packageName, collected) {
  const normalizedPackageName = packageName.replace(/\\/g, "/");
  const version = getExactVersion(normalizedPackageName);

  if (version) {
    const packageId = `${normalizedPackageName}@${version}`;
    if (!collected.has(packageId)) {
      collected.add(packageId);

      // 从 package.json 读取依赖并递归收集
      const packageJsonPath = path.join(
        NODE_MODULES_DIR,
        normalizedPackageName,
        "package.json",
      );
      try {
        if (fs.existsSync(packageJsonPath)) {
          const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
          if (pkg.dependencies) {
            Object.keys(pkg.dependencies).forEach((depName) => {
              collectDependencies(depName, collected);
            });
          }
        }
      } catch (error) {
        console.warn(
          `⚠️  读取 ${packageName} 的依赖失败: ${error.message}`,
        );
      }
    }
  }
}

/**
 * 获取 node_modules 中的所有顶级包
 * @returns {string[]} - 顶级包名数组
 */
function getTopLevelPackages() {
  const packages = [];
  const items = fs.readdirSync(NODE_MODULES_DIR, { withFileTypes: true });

  items.forEach((item) => {
    if (item.isDirectory()) {
      const itemName = item.name;

      // 跳过 .bin 目录
      if (itemName === ".bin") {
        return;
      }

      if (itemName.startsWith("@")) {
        // 作用域包目录
        const scopePath = path.join(NODE_MODULES_DIR, itemName);
        const scopeItems = fs.readdirSync(scopePath, { withFileTypes: true });
        scopeItems.forEach((scopeItem) => {
          if (scopeItem.isDirectory()) {
            const scopedPackageName = `${itemName}/${scopeItem.name}`;
            packages.push(scopedPackageName);
            console.log(`   发现作用域包: ${scopedPackageName}`);
          }
        });
      } else {
        // 普通包
        packages.push(itemName);
        console.log(`   发现包: ${itemName}`);
      }
    }
  });

  return packages;
}

/**
 * 使用 npm pack 打包包
 * @param {string} packageId - 包 ID,格式为 "名称@版本"
 * @returns {boolean} - 成功返回 true,失败返回 false
 */
function packPackage(packageId) {
  try {
    console.log(`\n处理中: ${packageId}`);
    execSync(`npm pack ${packageId} --pack-destination ${OUTPUT_DIR}`, {
      stdio: "pipe",
      stderr: "pipe",
    });
    console.log(`✅ 成功: ${packageId}`);
    return true;
  } catch (error) {
    console.error(`❌ 失败: ${packageId}`);
    console.error(`   错误: ${error.stderr.toString().trim()}`);
    return false;
  }
}

// 主函数
function main() {
  console.log("🔍 扫描 node_modules 中的包...");

  let targetPackages;

  if (specificPackages) {
    // 使用命令行指定的包
    targetPackages = specificPackages;
    console.log(`✅ 使用 ${targetPackages.length} 个指定的包`);
  } else {
    // 获取所有顶级包
    targetPackages = getTopLevelPackages();
    console.log(`✅ 发现 ${targetPackages.length} 个顶级包`);
  }

  // 递归收集所有依赖
  console.log("\n🔗 递归收集所有依赖...");
  const collectedPackages = new Set();

  targetPackages.forEach((pkgName) => {
    collectDependencies(pkgName, collectedPackages);
  });

  const uniquePackages = Array.from(collectedPackages).sort();
  console.log(`✅ 发现 ${uniquePackages.length} 个唯一依赖`);

  // 打包所有包
  console.log("\n📦 开始打包...");
  let successCount = 0;
  let failCount = 0;
  const failedPackages = [];

  uniquePackages.forEach((pkgId, index) => {
    console.log(`\n[${index + 1}/${uniquePackages.length}]`);
    const success = packPackage(pkgId);
    if (success) {
      successCount++;
    } else {
      failCount++;
      failedPackages.push(pkgId);
    }
  });

  // 最终报告
  console.log("\n==================================================");
  console.log("📊 打包报告");
  console.log("==================================================");
  console.log(`✅ 成功: ${successCount} 个`);
  console.log(`❌ 失败: ${failCount} 个`);

  if (failedPackages.length > 0) {
    console.log("⚠️  失败列表:");
    failedPackages.forEach((pkg) => console.log(`   - ${pkg}`));
  }

  console.log(`📁 输出路径: ${path.resolve(OUTPUT_DIR)}`);
  console.log("==================================================");
}

// 运行主函数
main();

最终效果

企业微信截图_20260129115411.png

总结

export-node-modules-tgz.js 是一个简单但强大的工具,它可以帮助开发者轻松解决依赖管理中的各种痛点。通过一键导出 node_modules 为 .tgz 包,我们可以:

  • 简化内网环境部署
  • 加速项目迁移
  • 确保依赖版本一致性
  • 实现离线环境的依赖安装

希望这个工具能为你的开发工作带来便利!如果你有任何问题或建议,欢迎在评论区留言讨论。


相关推荐