🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
上一篇【前端工程化】monorepo篇-rush管理monorepo实践 讲解
rush对于monorepo的实践操作,给出一个rush搭建的项目模板。本篇我们手写一个monorepo自动发包脚本,支持选包发布,多包发布。通过rush配置自定义命令,全局调用,本篇我们延续之前的自动化发包脚本,增加读取子包和配置rush全局自定义命令,其他逻辑延续
🥑 你能学到什么?
希望在你阅读本篇文章之后,不会觉得浪费了时间。如果你跟着读下来,你将会学到:
- rush自定义脚本
inquirer库的基本使用npm的工程化管理git的tag标记npm包发布流程
实现效果
🍎 系列文章
本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等
- 【前端工程化】项目搭建篇-项目初始化&prettier、eslint、stylelint、lint-staged、husky
- 【前端工程化】项目搭建篇-配置changelog、webpack5打包
- 【前端工程化】项目搭建篇-引入react、ts、babel解析es6+、配置css module
- 【前端工程化】组件库搭建篇-引入storybook、rollup打包组件、本地测试组件库
- 【前端工程化】包管理器篇-三大包管理器、npm工程管理、npm发布流程
- 【前端工程化】自动化篇-Github Action基本使用、自动部署组件库文档、github3D指标统计
- 【前端工程化】自动化篇-手写脚本一键自动tag、发包、引导登录npm
- 【前端工程化】monorepo篇-rush管理monorepo实践
🍑 一、增加rush自定义命令
command-line.json文件下增加命令如下
{
"commandKind": "global",
"name": "publish:pkg",
"summary": "发包",
"description": "发包",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "node common/autoinstallers/script-cli/publish.js",
"autoinstallerName": "script-cli"
}
🥑 二、增加publish.js脚本
这里延续我们之前的模板项目,在script-cli自动安装器下面增加publish.js文件
🍋 三、更改脚本
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实现原理系列
- 【react原理实践】使用babel手搓探索下jsx的原理
- 【喂饭式调试react源码】上手调试源码探究jsx原理
- 【上手调试源码系列】图解react几个核心包之间的关联
- 【上手调试源码系列】react启动流程,其实就是创建三大全局对象
其他
🍋 写在最后
如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!
感兴趣的同学可以关注下我的公众号ObjectX前端实验室
🌟 少走弯路 | ObjectX前端实验室 🛠️「精选资源|实战经验|技术洞见」