【前端工程化】monorepo篇-monorepo多包发布脚本实现

1,190 阅读7分钟

🧑‍💻 写在开头

点赞 + 收藏 === 学会🤣🤣🤣

上一篇【前端工程化】monorepo篇-rush管理monorepo实践 讲解rush对于monorepo的实践操作,给出一个rush搭建的项目模板。本篇我们手写一个monorepo自动发包脚本,支持选包发布,多包发布。通过rush配置自定义命令,全局调用,本篇我们延续之前的自动化发包脚本,增加读取子包和配置rush全局自定义命令,其他逻辑延续

🥑 你能学到什么?

希望在你阅读本篇文章之后,不会觉得浪费了时间。如果你跟着读下来,你将会学到:

  • rush自定义脚本
  • inquirer库的基本使用
  • npm的工程化管理
  • gittag标记
  • npm包发布流程

实现效果

🍎 系列文章

本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等

🍑 一、增加rush自定义命令

command-line.json文件下增加命令如下

{
  "commandKind": "global",
  "name": "publish:pkg",
  "summary": "发包",
  "description": "发包",
  "safeForSimultaneousRushProcesses": true,
  "shellCommand": "node common/autoinstallers/script-cli/publish.js",
  "autoinstallerName": "script-cli"
}

image.png

🥑 二、增加publish.js脚本

这里延续我们之前的模板项目,在script-cli自动安装器下面增加publish.js文件 image.png

🍋 三、更改脚本

1.读取子包目录生成发包列表

这里主要是读取子包目录,获取package.json文件,从中获取子包名,用作选择列表,后续根据选择的包操作,也可以发布全部包。

// 主函数入口
async function monorepoPublishPkg() {
  try {
    // 获取子包package.json
    const packages = fs.readdirSync(projectsDir).filter((file) => {
      const packageJsonPath = path.join(projectsDir, file, "package.json");
      return fs.existsSync(packageJsonPath);
    });

    const choices = packages.map((pkg) => {
      const packageJsonPath = path.join(projectsDir, pkg, "package.json");
      const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
      return { name: packageJson.name, value: pkg };
    });

    choices.unshift({ name: "All packages", value: "all" });

    const answers = await inquirer.prompt([
      {
        type: "list",
        name: "package",
        message: "请选择要发布的包:",
        choices: choices,
      },
    ]);

    if (answers.package === "all") {
      for (const packageName of packages) {
        const packageJsonPath = path.join(
          projectsDir,
          packageName,
          "package.json"
        );
        const packageJson = JSON.parse(
          fs.readFileSync(packageJsonPath, "utf8")
        );
        const { latestVersion, firstPublish } = await getLatestVersion(
          packageJson.name
        );
        await displayOptions(
          packageJson.name,
          firstPublish,
          latestVersion,
          packageJsonPath
        );
      }

      // 调用 rush publish 命令
      console.log("🚀🚀🚀 正在使用 rush publish 发布所有包...");
      const { stdout, stderr } = await exec(
        "rush custom-publish --include-all"
      );
      if (stderr) {
        console.log(`✅ rush publish stderr: ${stderr}`);
      }
      console.log(`🎉🎉🎉 rush publish 成功: ${stdout}`);
    } else {
      const packageJsonPath = path.join(
        projectsDir,
        answers.package,
        "package.json"
      );
      const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
      const { latestVersion, firstPublish } = await getLatestVersion(
        packageJson.name
      );
      await displayOptions(
        packageJson.name,
        firstPublish,
        latestVersion,
        packageJsonPath
      );
    }
  } catch (error) {
    console.error("❌ 发生错误:", error);
  }
}

2.获取版本号逻辑更新

如果是首次发布包,那么是查不到版本号的,所以我们需要设置版本号为v1.0.0,更改逻辑,并且返回firstPublish去禁用选择版本列表的展示

// 获取最新版本号
async function getLatestVersion(packageName) {
  try {
    const { stdout } = await exec(`npm show ${packageName} version`);
    const latestVersion = stdout.trim().replace(/^v/, ""); // 删除可能存在的前导 v
    return {
      latestVersion,
      firstPublish: false,
    };
  } catch (error) {
    console.error(`❌ 获取最新版本失败: ${error.message}`);
    console.log(`⚠️ 包 ${packageName} 不存在,使用默认版本号 1.0.0`);
    return {
      latestVersion: "1.0.0",
      firstPublish: true,
    };
  }
}

2.保证tag标记的唯一性

monorepo仓库中存在多个子包,子包的版本可能都是从1.0.0开始的如果直接用v版本号作为tag可能存在冲突情况,我们将tag标记为包名+版本号,然后会判断是否已经tag过了,如果有则删除重新创建tag,保证记录可追溯。

// 标记tag
function gitOperations(packageName, newVersion) {
  try {
    process.chdir(projectRootPath); // Change the current working directory to project root

    // 获取当前分支名称
    const branchName = execSync("git rev-parse --abbrev-ref HEAD")
      .toString()
      .trim();
    // tag名
    const tagName = `${packageName}-v${newVersion}`;
    // 检查是否有设置 upstream(远程跟踪分支)
    let setUpstream = false;
    try {
      execSync(`git rev-parse --abbrev-ref --symbolic-full-name @{u}`);
    } catch (error) {
      // 如果没有设置 upstream,为远程的同名分支设置 upstream
      const remoteBranchExists = execSync(
        `git ls-remote --heads origin ${branchName}`
      )
        .toString()
        .trim();
      if (remoteBranchExists) {
        execSync(`git branch --set-upstream-to=origin/${branchName}`);
      } else {
        console.error(
          `❌ 远程分支 'origin/${branchName}' 不存在,无法设置 upstream。`
        );
        return;
      }
      setUpstream = true;
    }

    execSync(`git add .`, { stdio: "inherit" });
    execSync(`git commit -m "chore(release): ${tagName}"`, {
      stdio: "inherit",
    });

    // 检查并删除现有标签
    try {
      execSync(`git tag -d ${tagName}`, { stdio: "inherit" });
      execSync(`git push origin :refs/tags/${tagName}`, { stdio: "inherit" });
    } catch (tagError) {
      // 如果标签不存在,忽略错误
    }

    execSync(`git tag ${tagName}`, { stdio: "inherit" });

    // 推送改动到远程分支
    execSync(`git push`, { stdio: "inherit" });
    if (setUpstream) {
      // 如果之前没有 upstream,并且我们为其设置了 upstream,现在也推送它
      execSync(`git push --set-upstream origin ${branchName}`, {
        stdio: "inherit",
      });
    }
    // 推送tag到远程
    execSync(`git push origin ${tagName}`, { stdio: "inherit" });

    console.log(`✅ Git tag ${tagName} 已标记`);
  } catch (error) {
    console.error(`❌ Git 操作失败: ${error.message}`);
  }
}

完整代码&代码仓库

const { execSync } = require("child_process");
const util = require("util");
const fs = require("fs");
const path = require("path");
const inquirer = require("inquirer");
const exec = util.promisify(require("child_process").exec);
const npmLogin = require("npm-cli-login");

// 项目根目录
const projectRootPath = path.join(__dirname, "../../..");
// 子包文件夹
const projectsDir = path.join(projectRootPath, "projects");
// 私域npm
const NPM_REGISTRY_URL = "https://registry.npmjs.org/";

// 解析版本号
function parseVersion(version) {
  const [semver, preReleaseTag = ""] = version.split("-");
  const [major, minor, patch] = semver.split(".").map(Number);
  const [preReleaseLabel, preReleaseVersion] = preReleaseTag.split(".");
  return {
    major,
    minor,
    patch,
    preReleaseLabel,
    preReleaseVersion: preReleaseVersion ? parseInt(preReleaseVersion, 10) : 0,
  };
}

// 检测是否是预发布版本
function isPreRelease(version) {
  return /-/.test(version);
}

// 获取预发布版本号
function getPreReleaseVersion(currentVersion, type) {
  let { major, minor, patch, preReleaseLabel, preReleaseVersion } =
    currentVersion;
  switch (type) {
    case "prepatch":
      patch += 1;
      return `${major}.${minor}.${patch}-0`;
    case "preminor":
      minor += 1;
      return `${major}.${minor}.0-0`;
    case "premajor":
      major += 1;
      return `${major}.0.0-0`;
    case "prerelease":
      if (isPreRelease(`${major}.${minor}.${patch}`)) {
        preReleaseVersion = preReleaseVersion || 0;
        return `${major}.${minor}.${patch}-${preReleaseLabel || "beta"}.${
          preReleaseVersion + 1
        }`;
      } else {
        return `${major}.${minor}.${patch}-beta.0`;
      }
    default:
      throw new Error(`❌ 不支持的预发布版本类型: ${type}`);
  }
}

// 获取最新版本号
async function getLatestVersion(packageName) {
  try {
    const { stdout } = await exec(`npm show ${packageName} version`);
    const latestVersion = stdout.trim().replace(/^v/, ""); // 删除可能存在的前导 v
    return {
      latestVersion,
      firstPublish: false,
    };
  } catch (error) {
    console.error(`❌ 获取最新版本失败: ${error.message}`);
    console.log(`⚠️ 包 ${packageName} 不存在,使用默认版本号 1.0.0`);
    return {
      latestVersion: "1.0.0",
      firstPublish: true,
    };
  }
}

// 更新版本号
function updateVersion(packageJsonPath, newVersion) {
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
  packageJson.version = newVersion;
  fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
  console.log(`✅ 版本号已更新为 ${newVersion}`);
}

// 确保用户已登录npm
async function ensureNpmLoggedIn() {
  try {
    const { stdout } = await exec("npm whoami");
    console.log(`✅ 检测到您已作为${stdout.trim()}登录到npm`);
    return stdout.trim();
  } catch (error) {
    console.error("❌ 您似乎还没有登录到npm。请登录后继续。");
    const answers = await inquirer.prompt([
      {
        type: "input",
        name: "username",
        message: "请输入您的npm用户名:",
      },
      {
        type: "password",
        name: "password",
        message: "请输入您的npm密码:",
      },
      {
        type: "input",
        name: "email",
        message: "请输入您的npm邮箱地址:",
      },
    ]);

    // 以下操作依赖于能够自动化的输入命令到npm login(在这个假设下编写)
    // 实际操作中这可能需要特殊处理,例如通过node-pty实现自动输入
    const { stdout: loginStdout } = await exec(
      `echo "${answers.username}\n${answers.password}\n${answers.email}\n" | npm login`
    );
    console.log("✅ 登录输出流loginStdout", loginStdout);
    return answers.username;
  }
}

// 异步地发布到npm
async function publishToNpm(packageDir) {
  console.log("🚀🚀🚀 正在发布到 npm...");

  try {
    const { stdout, stderr } = await exec("npm publish", { cwd: packageDir });

    if (stderr) {
      console.log(`✅ 发布输出流stderr: ${stderr}`);
    }

    console.log(`🎉🎉🎉 npm包发布成功: ${stdout}`);
  } catch (error) {
    console.error(`❌ 发布失败: ${error.message}`);
    throw error; // 抛出错误以供调用方处理
  }
}

// 标记tag
function gitOperations(packageName, newVersion) {
  try {
    process.chdir(projectRootPath); // Change the current working directory to project root

    // 获取当前分支名称
    const branchName = execSync("git rev-parse --abbrev-ref HEAD")
      .toString()
      .trim();
    // tag名
    const tagName = `${packageName}-v${newVersion}`;
    // 检查是否有设置 upstream(远程跟踪分支)
    let setUpstream = false;
    try {
      execSync(`git rev-parse --abbrev-ref --symbolic-full-name @{u}`);
    } catch (error) {
      // 如果没有设置 upstream,为远程的同名分支设置 upstream
      const remoteBranchExists = execSync(
        `git ls-remote --heads origin ${branchName}`
      )
        .toString()
        .trim();
      if (remoteBranchExists) {
        execSync(`git branch --set-upstream-to=origin/${branchName}`);
      } else {
        console.error(
          `❌ 远程分支 'origin/${branchName}' 不存在,无法设置 upstream。`
        );
        return;
      }
      setUpstream = true;
    }

    execSync(`git add .`, { stdio: "inherit" });
    execSync(`git commit -m "chore(release): ${tagName}"`, {
      stdio: "inherit",
    });

    // 检查并删除现有标签
    try {
      execSync(`git tag -d ${tagName}`, { stdio: "inherit" });
      execSync(`git push origin :refs/tags/${tagName}`, { stdio: "inherit" });
    } catch (tagError) {
      // 如果标签不存在,忽略错误
    }

    execSync(`git tag ${tagName}`, { stdio: "inherit" });

    // 推送改动到远程分支
    execSync(`git push`, { stdio: "inherit" });
    if (setUpstream) {
      // 如果之前没有 upstream,并且我们为其设置了 upstream,现在也推送它
      execSync(`git push --set-upstream origin ${branchName}`, {
        stdio: "inherit",
      });
    }
    // 推送tag到远程
    execSync(`git push origin ${tagName}`, { stdio: "inherit" });

    console.log(`✅ Git tag ${tagName} 已标记`);
  } catch (error) {
    console.error(`❌ Git 操作失败: ${error.message}`);
  }
}

// 设置npm的registry到指定的URL,并返回旧的registry
async function setNpmRegistry() {
  try {
    const { stdout: getRegistryStdout } = await exec(`npm config get registry`);
    const oldNpmRegistry = getRegistryStdout.trim();

    await exec(`npm config set registry ${NPM_REGISTRY_URL}`);

    console.log(`✅ npm registry已设置为: ${NPM_REGISTRY_URL}`);
    return oldNpmRegistry; // 返回旧的registry,以便后续可以恢复
  } catch (error) {
    if (error.stdout) {
      console.error(`❌ 设置npm registry stdout输出流: ${error.stdout}`);
    }
    if (error.stderr) {
      console.error(`❌ 设置npm registry stderr出错: ${error.stderr}`);
    }
    console.error(`❌ 设置npm registry中发生错误: ${error.message}`);
    throw error; // 抛出错误以供调用者处理
  }
}

// 恢复npm的registry为旧的URL
async function restoreNpmRegistry(oldNpmRegistry) {
  if (oldNpmRegistry) {
    try {
      await exec(`npm config set registry ${oldNpmRegistry}`);
      console.log(`✅ npm registry已恢复为: ${oldNpmRegistry}`);
    } catch (error) {
      if (error.stdout) {
        console.error(`✅ 恢复npm registry输出流: ${error.stdout}`);
      }
      if (error.stderr) {
        console.error(`❌ 恢复npm registry出错: ${error.stderr}`);
      }
      console.error(`❌ 恢复npm registry中发生错误: ${error.message}`);
      throw error; // 抛出错误以供调用方处理
    }
  } else {
    console.error(`❌ 未找到旧的npm registry,无法恢复。`);
    throw new Error(`❌ 未找到旧的npm registry,无法恢复。`);
  }
}

// 命令行显示逻辑
async function displayOptions(
  packageName,
  firstPublish,
  latestVersion,
  packageJsonPath
) {
  console.log(
    `✅ 发包脚本启动【自动更新版本号、自动发布到npm】 for package: ${packageName}`
  );
  console.log(
    "!!! 使用前请确保仓库内已经是可发布状态, firstPublish",
    firstPublish
  );
  if (firstPublish) {
    console.log("✅ ✅ ✅ 本次为首次发包");
    // 更新版本号
    updateVersion(packageJsonPath, latestVersion);
    // git增加tag并提交
    gitOperations(packageName, latestVersion);
    // 设置npm源
    const oldRegistryUrl = await setNpmRegistry();
    // 检测是否已经登录npm
    await ensureNpmLoggedIn();
    // 发布到npm
    await publishToNpm(path.dirname(packageJsonPath));
    // 恢复npm源
    await restoreNpmRegistry(oldRegistryUrl);
    return;
  }
  const currentVersion = parseVersion(latestVersion);
  const choices = [
    {
      name: `Major【大版本】 (${parseInt(currentVersion.major) + 1}.0.0)`,
      value: "major",
    },
    {
      name: `Minor【小版本】 (${currentVersion.major}.${
        parseInt(currentVersion.minor) + 1
      }.0)`,
      value: "minor",
    },
    {
      name: `Patch【修订版本】 (${currentVersion.major}.${
        currentVersion.minor
      }.${parseInt(currentVersion.patch) + 1})`,
      value: "patch",
    },
    // { name: `Prepatch【预发修订版本】`, value: 'prepatch' },
    // { name: `Preminor【预发小版本】`, value: 'preminor' },
    // { name: `Premajor【预发大版本】`, value: 'premajor' },
    // { name: `Prerelease【预发版】`, value: 'prerelease' },
    { name: `Specific version【指定版本】`, value: "specific" },
  ];

  const answers = await inquirer.prompt([
    {
      type: "list",
      name: "releaseType",
      message: "请选择版本号的更新类型:",
      choices: choices,
    },
    {
      type: "input",
      name: "specificVersion",
      message: "输入具体的版本号:",
      when: (answers) => answers.releaseType === "specific",
      validate: (input) =>
        /\d+\.\d+\.\d+(-\w+\.\d+)?/.test(input) ||
        "版本号必须符合语义化版本控制规范。",
    },
  ]);

  let newVersion = "";
  // 指定版本号
  if (answers.releaseType === "specific") {
    newVersion = answers.specificVersion;
  } else if (["major", "minor", "patch"].includes(answers.releaseType)) {
    // 非预发版本
    currentVersion[answers.releaseType]++;
    newVersion = `${currentVersion.major}.${currentVersion.minor}.${currentVersion.patch}`;
  } else {
    // 预发布版本
    newVersion = getPreReleaseVersion(currentVersion, answers.releaseType);
  }
  // 更新版本号
  updateVersion(packageJsonPath, newVersion);
  // git增加tag并提交
  gitOperations(packageName, newVersion);
  // 设置npm源
  const oldRegistryUrl = await setNpmRegistry();
  // 检测是否已经登录npm
  await ensureNpmLoggedIn();
  // 发布到npm
  await publishToNpm(path.dirname(packageJsonPath));
  // 恢复npm源
  await restoreNpmRegistry(oldRegistryUrl);
}

// 主函数入口
async function monorepoPublishPkg() {
  try {
    // 获取子包package.json
    const packages = fs.readdirSync(projectsDir).filter((file) => {
      const packageJsonPath = path.join(projectsDir, file, "package.json");
      return fs.existsSync(packageJsonPath);
    });

    const choices = packages.map((pkg) => {
      const packageJsonPath = path.join(projectsDir, pkg, "package.json");
      const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
      return { name: packageJson.name, value: pkg };
    });

    choices.unshift({ name: "All packages", value: "all" });

    const answers = await inquirer.prompt([
      {
        type: "list",
        name: "package",
        message: "请选择要发布的包:",
        choices: choices,
      },
    ]);

    if (answers.package === "all") {
      for (const packageName of packages) {
        const packageJsonPath = path.join(
          projectsDir,
          packageName,
          "package.json"
        );
        const packageJson = JSON.parse(
          fs.readFileSync(packageJsonPath, "utf8")
        );
        const { latestVersion, firstPublish } = await getLatestVersion(
          packageJson.name
        );
        await displayOptions(
          packageJson.name,
          firstPublish,
          latestVersion,
          packageJsonPath
        );
      }

      // 调用 rush publish 命令
      console.log("🚀🚀🚀 正在使用 rush publish 发布所有包...");
      const { stdout, stderr } = await exec(
        "rush custom-publish --include-all"
      );
      if (stderr) {
        console.log(`✅ rush publish stderr: ${stderr}`);
      }
      console.log(`🎉🎉🎉 rush publish 成功: ${stdout}`);
    } else {
      const packageJsonPath = path.join(
        projectsDir,
        answers.package,
        "package.json"
      );
      const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
      const { latestVersion, firstPublish } = await getLatestVersion(
        packageJson.name
      );
      await displayOptions(
        packageJson.name,
        firstPublish,
        latestVersion,
        packageJsonPath
      );
    }
  } catch (error) {
    console.error("❌ 发生错误:", error);
  }
}

monorepoPublishPkg();

🍎 推荐阅读

面试手写系列

react实现原理系列

其他

🍋 写在最后

如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!

感兴趣的同学可以关注下我的公众号ObjectX前端实验室

🌟 少走弯路 | ObjectX前端实验室 🛠️「精选资源|实战经验|技术洞见」