深入 lerna 发包机制 —— lerna version

深入 lerna 发包机制 —— lerna version

最近在公司做的 monorepo 相关工具开发的时候有涉及到这方面的内容,于是对这方面进行了一些研究。

lerna 官网关于 lerna 的描述是这样的:

A tool for managing JavaScript projects with multiple packages.

本质上 lerna 是一个用来管理 monorepo 项目的工具,主要解决了下面的问题:

将大型代码仓库分割成多个独立版本化的 软件包(package)对于代码共享来说非常有用。但是,如果某些更改 跨越了多个代码仓库的话将变得很 麻烦 并且难以跟踪,并且, 跨越多个代码仓库的测试将迅速变得非常复杂。
为了解决这些(以及许多其它)问题,某些项目会将 代码仓库分割成多个软件包(package),并将每个软件包存放到独立的代码仓库中。但是,例如 Babel、 React、Angular、Ember、Meteor、Jest 等项目以及许多其他项目则是在 一个代码仓库中包含了多个软件包(package)并进行开发。

今天我们主要来讲解一下关于 lerna 是怎么去完成一个 monorepo 项目中的发包操作的。

lerna 发包设计到两个比较关键的指令分别为 lerna versionlerna publish 这两个指令。

因为篇幅原因,我将分为两个系列去分别介绍这两个指令,本篇文章将从 lerna version 开始,在下篇中我会介绍一下 lerna publish 的工作原理,相信在我介绍完成之后,大家都会对 lerna 的整个发包机制有个比较清晰的了解。

lerna version

根据官方仓库的介绍, lerna version 主要的工作为标识出在上一个 tag 版本以来更新的 monorepo package,然后为这些包 prompt 出版本,在用户完成选择之后修改相关包的版本信息并且将相关的变动 commit 然后打上 tag 推送到 git remote。本质上是为一些发生变动的包进行了一个 bump version 的操作,当然这里用户也可以将这一过程自动化掉,利用 --conventional-commits 这个 api 就可以将这一过程自动化掉,常见的场景是可以在写在一些 CI Config 里面来达到自动发包的目的。

同时它本身还提供了一些相关的 api,感兴趣可以去仓库或者官方文档查询,这里不做详细的介绍。

lerna version 本身在 lerna 中是一个很重要的指令,有许多其他的指令例如在 lerna 中 two primary commands 之一的 lerna publish 都是基于该指令进行相关的工作。在开始实现之前先放出一个lerna version 的整体原理图。

实现架构图

那我们直接开始来看一下 lerna version 的具体实现,具体源码地址为: github.com/lerna/lerna/

lerna 作为一个 monorepo 工具,其本身里面的包也是采用 monorepo 的形式来管理,因此我们可以直接去看 version 的代码结构为:

.
├── CHANGELOG.md 
├── README.md
├── __tests__  // 测试相关目录
├── command.js  // cli 的入口文件,lerna version 的一些选项都可以在其中看到
├── index.js // version 执行逻辑相关的函数文件
├── lib // 相关的工具函数,根据名称可以推断出其功能
│   ├── __mocks__
│   ├── create-release.js
│   ├── get-current-branch.js
│   ├── git-add.js
│   ├── git-commit.js
│   ├── git-push.js
│   ├── git-tag.js
│   ├── is-anything-committed.js
│   ├── is-behind-upstream.js
│   ├── is-breaking-change.js
│   ├── prompt-version.js
│   ├── remote-branch-exists.js
│   └── update-lockfile-version.js
└── package.json

首先可以在 command.js 文件中看到 lerna version 在 cli 中使用的时候提供的一些 标准 Options 例如 allow-branchconventional-commitsignore-changes 等选项。

具体的执行逻辑在 index.js 这个目录中实现。

顺便一提,这里关于 lerna 的命令是怎么进行分发以及执行的,这部分相关的逻辑可以参考 @lerna/command 这个包即源码目录 lerna/core/cli/command 中相关内容。lerna monorepo 一些核心相关概念都在 core 这个目录下面,其中会包括一些例如 monorepo 依赖图的构建等内容,感兴趣可以去研究一下。

因此现在我们开始看 index.js 相关的内容。

这个文件的主要导出了一个叫做 VersionCommand 类的实例,相关的逻辑则在类中实现,大致的结构为:

class VersionCommand extends Command {
  // 一些 options 的检查和整合
  configureProperties() {}

  // 初始化
  initialize() {}

  // 执行逻辑
  execute() {}

  // 其他部分就是一些类里面的工具函数例如 getVersionsForUpdates
  // ...
}

因此 lerna version 由上面的内容可以看出整个执行周期分成三个部分 设置属性 -> 初始化 -> 执行

设置属性(configureProperties)

首先我们先看设置属性这一部分,这部分其实很简单,它会检查一些传递进来的选项是否符合规范(例如 --create-release 只有和 --conventional-commits 放在一起才能执行,否则会报错)。然后把一些 git 相关的options 全部整合到了一个叫做 gitOptions 的对象里面去了。这里简单贴一些代码参考一下:

// 检验 --create-release 使用场景
if (this.createRelease && this.options.conventionalCommits !== true) {
  throw new ValidationError("ERELEASE", "To create a release, you must enable --conventional-commits");
}
if (this.createRelease && this.options.changelog === false) {
  throw new ValidationError("ERELEASE", "To create a release, you cannot pass --no-changelog");
}

// 这里是把一些用户传进来的 git 相关参数放在 gitOptions 中
this.gitOpts = {
  // amend 用来跳 lerna version 的 git push 的,默认为 true, --amend 用户传参数
  amend,
  commitHooks,
  granularPathspec,
  signGitCommit,
  signGitTag,
  forceGitTag,
};

// 用户传了 --exact 就不用给 version 设置 ^ 前缀
// npm 包的 version 前缀相关可以参考: https://docs.npmjs.com/misc/config#save-prefix
this.savePrefix = this.options.exact ? "" : "^";

初始化(initialize)

在正式进入 bump version 过程中时,会有一个初始化的过程,初始化过程中首先会检验一些 monorepo 子 packages 一些 git 仓库相关的设置,拿到需要更新包,然后执行到确认包的版本信息这一步,有一些相对关键的操作都是在一步完成的,等下我们可以详细分析一下:

大致的执行逻辑有:

initialize() {
  // ...
  // 1. git 仓库相关的检验
  if (this.requiresGit) {
  } else {
    // 跳过 git 仓库相关的校验,给个提示
  }

  // 2. 获取到需要更新的包
  this.updates = collectUpdates();
  // 如果该数组为空,就停止执行
  if (!this.updates.length) {}

  // 3. 包相关的生命周期函数(这些函数是在 lerna.json 中设置的,可以在 lerna 文档中找到相关设置)
  this.runPackageLifecycle = createRunner(this.options);

  // 4. 将 获得需要更新版本, 更新版本, 确认更新函数放在一个 tasks 数组中,之后执行 
  const tasks = [
    () => this.getVersionsForUpdates(),
    // versions 是由上一步的 getVersionsForUpdates
    versions => this.setUpdatesForVersions(versions),
    () => this.confirmVersions(),
  ];

  // 5. 检查当前 git 的本地工作区
  if (this.commitAndTag && this.gitOpts.amend !== true) {
    // 这里会把一个检查函数放在 task 函数的队列顶部
    const check = checkUncommittedOnly ? checkWorkingTree.throwIfUncommitted : checkWorkingTree;
    tasks.unshift(() => check(this.execOpts));
  } else {}

  // 6. 执行 task 函数里面内容
  return pWaterfall(tasks);
}

其实看这部分逻辑 lerna 这里还是有些功能等着去 TODO 的,感兴趣的同学可以去参加一波贡献。

首先我们先从 git 仓库相关的检验看起。首先我们要先知道的是,lerna 管理 monorepo 的一些 workflow 都是基于 git 和 npm 完成的,因此作为一个 lerna 的 monorepo 项目,是需要使用 git 来管理仓库的。

这里的 requiresGit 实际上是 versionCommand 类的一个 get 方法,返回的是一个 git 相关的值,存在即可:

// 只要这些参数存在其中之一就证明目前是需要验证 git 相关的内容
get requiresGit() {
    return (
      this.commitAndTag || this.pushToRemote || this.options.allowBranch || this.options.conventionalCommits
    );
  }

requiresGit 内部的检验逻是要发生在计算出需要更新的包以及版本选择 之前的(当然这两步也是在 initialize() 之前完成的)。

具体的检验逻辑:

  • 校验本地是否有没有被 commit 内容
  • 判断当前的分支是否正常
  • 判断当前分支是否在 remote 存在
  • 判断当前分支是否在 lerna.json 允许的allowBranch 设置之中
  • 判断当前分支提交是否落后于 remote

这里相关的操作一些判断操作实际上都借用了 git 相关的命令来完成,就不一一去说明这里的 api 是如何使用的了:例如获取当前分支,实际上就是获取到 git 命令的执行结果:

function currentBranch(opts) {
  log.silly("currentBranch");

  const branch = childProcess.execSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], opts);
  log.verbose("currentBranch", branch);

  return branch;
}

本质上就是执行了一次 git rev-parse --abbrev-ref HEAD 获取到当前分支名称。

其他 git 相关的判断都是和此类似,这里就不做具体的赘述,这里如果有相关的需求是可以在这里学习一下相关工具函数的使用的,里面有一些平时不常用的 git 命令,个人觉得 lerna 这里的工具代码写的还是不错的。

下面就直接去看获取到更新包这部分的逻辑:

this.updates = collectUpdates(
  this.packageGraph.rawPackageList,
  // 项目图
  this.packageGraph,
  // 执行 git 命令的一些 option 有 maxBuffer 和 cwd 两个参数
  this.execOpts,
  // cli 的一些 参数 以及 lerna.json 里面的一些配置和环境变量等
  this.options
).filer(node => {
  // 这里会把满足 pkg.json 里面设置了 private: true 且传递了 --no-private 参数的子package 跳过更新
  if (node.pkg.private && this.options.private === false) {
    // 这里 --no-privte 应该是个默认行为,这是可以给 lerna 来个 pr 的
    return false;
  }

  // 当前包没有 version 参数 也会做个 judge
  if (!node.version) {
    // pkg 的 pkg.json 里面没设置 version 参数,但设置了 private 还是可以跳的
    if (node.pkg.private) {
    } else {
      // 抛错
    }
  }

  // 把有 version 的筛出去
  return !!node.version;
})

这里我们直接去看 collectUpdates 的是怎么拿到需要更新的包的信息的,collectUpdates 之后的 filter 操作写上了注释,之后不做具体介绍,可以自行参考对照。

function collectUpdates(filteredPackages, packageGraph, execOpts, commandOptions) {
   // ...
  // forced 是需要强制发布的包名的一个 Set 对象
  const forced = getPackagesForOption(useConventionalGraduate ? conventionalGraduate : forcePublish);
  // 获取到当前 monorepo 下的 packageList 是个以package name为key的map对象
  const packages =
    filteredPackages.length === packageGraph.size
      ? packageGraph
      : new Map(filteredPackages.map(({ name }) => [name, packageGraph.get(name)]));

  // --since <ref> 参数 lerna version 这边为 undefined
   let committish = commandOptions.since;

  if (hasTags(execOpts)) {
    // 获取到上次 附注tag(即 git tag -a "v3" -m "xx" 创建的 tag)
    const { sha, refCount, lastTagName } = describeRef.sync(execOpts, commandOptions.includeMergedTags);

    // 上一次 tagCommit 到当前的提交数量为 refCount 
    if (refCount === "0" && forced.size === 0 && !committish) {
      log.notice("", "Current HEAD is already released, skipping change detection.");
      return [];
    }

    // 这个是个测试版本的选项
    if (commandOptions.canary) {
      committish = `${sha}^..${sha}`;
    } else if (!committish) {
      // 如果这个地方连 tag 都没有的话, committish 就会成 undefined, 之后就会用初始化的 commit 来算
      committish = lastTagName;
    }
  }

    // 用户使用了 --conventional-commits --conventional-graduate 这个选项
    if (useConventionalGraduate) {
      // 会把所有预发布的包更新成正式包版本,这里只是给个 log
      if (forced.has("*")) {
        // --force-publish 就发布所有 
        log.info("", "Graduating all prereleased packages");
      } else {
        log.info("", "Graduating prereleased packages");
      }
    } else if (!committish || forced.has('*')) {
      // 使用了 --force-publish 或者 tag 没有找到(之前没有使用过lerna发包),就会当成所有包需要更新
      log.info("", "Assuming all packages changed");

      // 这个 collectPackages 会有三个参数,还有个 isCandidate 用来添加筛选条件判断哪些包需要更新
      // 这里 isCandidate 没传意味着传进去的 packages 都要更新
      return collectPackges(packages, {
        onInclude: name => log.verbose("updated", name),
        // 这个 excludeDependents 是由 --no-private 参数来决定去 exclude 掉一些 private: true 的包
        excludeDependents,
      })
    }

  // 下面就是正常的收集情况
  // ...
  return collectPackages(packages, {
    // 这里相比较于上面就多了一个 isCandidate, 只有符合下面 isForced(强制更新), needsBump(跳过一些之前没有被 prereleased 的包)
    // hasDiff(有改动的包,在这里 ignoreChanges 会生效一波)
    isCandidate: (node, name) => isForced(node, name) || needsBump(node) || hasDiff(node),
    onInclude: name => log.verbose("updated", name),
    excludeDependents,
  });
}

根据上面的代码层次我们其实可以比较清晰的看到 collectUpdates 是怎么收集到需要更新的 package 的,首先会从 core 那边拿到当前 monorepo 项目下面的一些 package,然后根据 一些选项 (例如 --force-publish--conventional-commit)以及项目本身 commit 的 tag 信息去得到当前 monorepo 项目下需要更新的包的一个 updates 数组。这里有个细节是这里的 lerna 去获取 tag 的时候,使用的是 git describe 命令,而且它附带的一些参数说明(可以去看下这个 describe-ref.js),这里获取到的 tag 是 annotated tag

这个 updates 数据将后续作为bump version 的一系列操作。

在获取完 updates 数组后,初始化过程这个时候来到了执行 runLifeCycle 函数这一步,这一步就是用于执行 lerna.json 里面用户设置的一些生命周期函数,这里不做太多的讲解。想知道具体的执行函数代码怎么写的可以参考 @lerna/run-lifecycle 这个库函数。

然后就到了初始化的执行的一些步骤,这里会构建一个 tasks 队列,用来执行

// 这里的一些 version 函数都是根据之前获取到的 updates 数组来进行相关的工作
const tasks = [
  // 获得需要更新的 version 
  () => this.getVersionsForUpdates(),
  // 将 versions 设置上去,这里执行的时候是个 reduce 过程,实际上是把 上一步函数返回的 versions 值拿到了返回出来
  versions => this.setUpdatesForVersions(versions),
  // 确认更新
  () => this.confirmVersions(),
];

// 中间有个根据选项在 tasks 队列顶端插入 check 函数,检查一下当前 git 的 working tree 是否正常
// ...

// 按照 reduce 去执行任务队列中的方法
return pWaterfall(tasks);

这里就是初始化的最后一个步骤来,我们主要看 tasks 队列中的三个方法的执行细节。

这里 getVersionsForupdates 过程就是这个 prompt 过程,最后得到一个以之前 updates 数组中的包名为 key,用户选择的 version 作为 value 的一个 Map 对象。

prompt 过程

当然这里我列举的只是一般用户使用的情况,如果选择了 conventional-commits 那么这一步就是自动帮用户生成对应对应的包版本了,具体的执行细节可以参考(下面会涉及到一些 options,这里建议可以结合 lerna version 文档中的一些 api 介绍来阅读)。

if (repoVersion) {
  // 这里是用户传了 lerna version [] 后面数组里面的 semver bump 参数,可以直接跳掉 prompt 过程,然后对对应的版本去直接 bump
  // 这里对应的是正式的 released 版本
  predicate = makeGlobalVersionPredicate(repoVersion);
} else if (increment && independentVersions) {
  // increament 相较于 repoVersion 的区别在于这里对应的是非正式版本的包,处于一个 prereleased 阶段
  // 过程和上面是差不多的,这里对应的是所有的 indepent 包(即用户在 lerna.json 里面设置了 indepent)
  predicate = node => semver.inc(node.version, increment, resolvePrereleaseId(node.prereleaseId));
} else if (increment) {
  // 这里对应的是 fixed 的包 prereleased 版本更新
} else if (conventionalCommits) {
  // 如果是 --conventional-commits 这里就直接帮用户生成了,不用prompt了
  return this.recommendVersions(resolvePrereleaseId);
} else if (independentVersions) {
  // 这个就是对正常的 indepent 包进行 prompt 的一个过程
  predicate = makePromptVersion(resolvePrereleaseId);
} else {
  // fixed 包进行 prompt
  // ...
}

// 上面的 predicate 是个返回 newversion 的函数
// 会放到 getVersion 即其返回的 newVersion,然后再用 reduceVersions 这个方法去生成上面我提到的 Map 对象
// reduceVersion 也是一个典型的 reduce 应用(即上一步的结果可以用于下一步的函数执行)
return Promise.resolve(predicate).then(getVersion => this.reduceVersions(getVersion));

讲完 getVersionsForUpdates() ,下面就到了 setUpdatesForVersions 这个过程,这一步操作就相对简单,拿到上一次的 versions Map 对象,用来做一次预处理,这一步会得到一个 updateVersions (本质上其实就是上一次拿到的 versions)和一个 packagesToVersion(本质上就是 updates 这个数组),这里相当于是做了一下整合。

setUpdatesForVersions(versions) {
    if (this.project.isIndependent() || versions.size === this.packageGraph.size) {
      // 只需要检查部分固定的版本
      this.updatesVersions = versions;
    } else {
      let hasBreakingChange;
      for (const [name, bump] of versions) {
        // 这里直接看代码知道 breakChange 的判断比我介绍要直观多,代码在下面
        hasBreakingChange = hasBreakingChange || isBreakingChange(this.packageGraph.get(name).version, bump);
      }
      if (hasBreakingChange) {
        this.updates = Array.from(this.packageGraph.values());
        // 把 private 设成 true 的包筛出去
        if (this.options.private === false) {
          this.updates = this.updates.filter(node => !node.pkg.private);
        }
        this.updatesVersions = new Map(this.updates.map(node => [node.name, this.globalVersion]));
      } else {
        this.updatesVersions = versions;
      }
    }

    this.packagesToVersion = this.updates.map(node => node.pkg);
  }

上面判断 breackChange 的方法为:

// releaseType 能判断是包的哪一位发生了变化,这里的判断其实有些迷惑行为.jpg
const releaseType = semver.diff(currentVersion, nextVersion);
let breaking;
if (releaseType === "major") {
  // self-evidently
  breaking = true;
} else if (releaseType === "minor") {
  // 0.1.9 => 0.2.0 is breaking
  // lt 是小于
  breaking = semver.lt(currentVersion, "1.0.0");
} else if (releaseType === "patch") {
  // 0.0.1 => 0.0.2 is breaking(?)
  breaking = semver.lt(currentVersion, "0.1.0");
} else {
  // versions are equal, or any prerelease
  breaking = false;
}

最后一步 confirmVersion 其实就是把上一步 setUpdatesForVersions 生成的 packagesToVersion 的值展示出来,让用户确定之前的选择是否正确,然后返回一个 bool 值(即用户选择的是否)。之后在执行阶段再去根据初始化时的这个结果去做一些更新之类的操作。

那么当这里整个初始化的过程就结束了,这里吐槽一下其实初始化这里的很多放到 execute 这个函数中去会比较好一点。因为初始化这里其实已经完成 lerna version 的大部分功能了,最后执行其实本质上执行的也只是一个更新 pkg 中的 version 的操作。

执行(execute)

到这里麻烦读者可以再回到文章开头看一下 lerna version 的整体执行过程,到现在我们已经完成了 version 的更新操作,拿到了一些相关参数。最后剩下的几步就是在需要修改的 pkg 里面对应的包的版本,然后将修改 commit 并打上 tag 推送到 git remote。

这几个步骤就是在执行这里完成。

这里的过程比较简单,和 initialize() 一样,这里初始化了一个 task 数组,然后把相关的操作函数放到 task 最后用 p-reduce 去 run。

execute(){
   // 放入更新包版本的函数
    const tasks = [() => this.updatePackageVersions()];
    // 用户可以通过设置 --no-git-tag-version 来跳过 tag 和 commit 的过程
    // 这个 commitAndTag 默认为 true 
    if (this.commitAndTag) {
      tasks.push(() => this.commitAndTagUpdates());
    } else {
      this.logger.info("execute", "Skipping git tag/commit");
    }

   // pushToRemote 受到 commitAndTag 和 ammend 参数的共同影响
   // 用户可以单独通过 --amend 来让 lerna version 最后不 push
    if (this.pushToRemote) {
      tasks.push(() => this.gitPushToRemote());
    } else {
      this.logger.info("execute", "Skipping git push");
    }

   // 这个 createRelease 使用来设置特定平台的 release发布的,配合 --conventional-commits 来一起使用
   // 这个不做详细的介绍
    if (this.createRelease) {
      this.logger.info("execute", "Creating releases...");
      tasks.push(() =>
        createRelease(
          this.options.createRelease,
          { tags: this.tags, releaseNotes: this.releaseNotes },
          { gitRemote: this.options.gitRemote, execOpts: this.execOpts }
        )
      );
    } else {
      this.logger.info("execute", "Skipping releases");
    }

   // pWaterfall 这个方法就是封装了一下 p-reduce 这个库函数
    return pWaterfall(tasks).then(() => {
      // 这里的 composed 是用来标记当前是 lerna version 还是 lerna publish 在执行
      if (!this.composed) {
        // lerna version 就结束这个过程
        this.logger.success("version", "finished");
      }
      // 如果是 lerna publish 就耀把这些参数返回出去
      return {
        updates: this.updates,
        updatesVersions: this.updatesVersions,
      };
    });
 }

正常逻辑上会按照 更新包的版本 -> 生成 commit 和打 tag -> 提交到 remote这样一个过程来执行,参考 task 的顺序就行。

先看 updatePackageVersions 这个方法,这个方法会帮我们 更新一下包的版本,修改掉文件里面的版本,并且将更新 git add 添加到暂缓区,如果是使用了 --conventional-commits,也会在这一步帮我们生成 CHANGELOG 或者是更新 CHANGELOG。这一步会用一个 changeFiles 来记录修改文件的 path。

大致的代码结构是这样的:

updatePackageVersions() {
  const { conventionalCommits, changelogPreset, changelog = true } = this.options;
  const independentVersions = this.project.isIndependent();
  const rootPath = this.project.manifest.location;
  // 记录修改的文件
  const changedFiles = new Set();

  // 这里的一系列链式调用以及异步方法都是用的 promise,作者甚至在源码这里吐槽想用 async/await...
  let chain = Promise.resolve();

  // 这里的 action 其实也是个 reduce 执行过程,即前一步的函数会把结果给后面
  const actions = [
    // .. 里面是一堆和 pkg 更新有关的函数
    // 在这一步会有个函数更新掉版本
    pkg => {
        // 把新版本设置上
       pkg.set("version", this.updatesVersions.get(pkg.name));

      // 与 pkg 有关的依赖(也要跟着一起更新)
      // 这里也是从 core 那边拿到的依赖图,然后可以得到一个 pkg 依赖了哪些 lerna 内部的库,那些内部库也要跟着更新
      for (const [depName, resolved] of this.packageGraph.get(pkg.name).localDependencies) {
          const depVersion = this.updatesVersions.get(depName);
          if (depVersion && resolved.type !== "directory") {
            // don't overwrite local file: specifiers, they only change during publish
            pkg.updateLocalDependency(resolved, depVersion, this.savePrefix);
          }
       }
      // 更新 pkg-lock.json 中版本,并且将修改反映到 pkg 文件中(pkg.serialize())
      // 然后 then 的时候将修改记录下来在 changeFiles 中
       return Promise.all([updateLockfileVersion(pkg), pkg.serialize()]).then(// ...);
    }
    // ..
  ]

  // 如果是 --conventional-commits 则会帮我们自动生成 CHANGELOG
  // 下面的 updateChangelog 方法也是可以看到的
  if (conventionalCommits && changelog) {
      // we can now generate the Changelog, based on the
      // the updated version that we're about to release.
      const type = independentVersions ? "independent" : "fixed";

      actions.push(pkg =>
        ConventionalCommitUtilities.updateChangelog(pkg, type, {
          changelogPreset,
          rootPath,
          tagPrefix: this.tagPrefix,
        }).then(({ logPath, newEntry }) => {
          // commit the updated changelog
          changedFiles.add(logPath);
          // 添加 released notes
          if (independentVersions) {
            this.releaseNotes.push({
              name: pkg.name,
              notes: newEntry,
            });
          }
          return pkg;
        })
      );
    }
  // pPipe 是封装的一个按照 reduce 顺序执行异步函数的方法
  // 这里会把前面的 action 一一执行,得到最后一步返回的结果
  const mapUpdate = pPipe(actions);

  // 按照拓扑顺序去执行前面拿到的一些需要更新的包,以及需要更新的 pathFile
  chain = chain.then(() =>
      runTopologically(this.packagesToVersion, mapUpdate, {
        concurrency: this.concurrency,
        rejectCycles: this.options.rejectCycles,
      })
   );

  // fixed 类型的 monorepo 项目的更新,会多一个 lerna.json 修改
  // 因为要修改里面的版本,这里也会和上面一样有 --conventional-commits 的判断,不做过多讲解
 if (!independentVersions) {}

 // 前面有说这个值默认是 true,这里会把修改 git add 到缓存区
 if (this.commitAndTag) {
     chain = chain.then(() => gitAdd(Array.from(changedFiles), this.gitOpts, this.execOpts));
 }

 return chain;
}

然后再让我们看生成 commit 这个过程,这一步步骤就比较简单,将前面 add 到缓存区的修改 commit 并且打上 tag。相关逻辑在 commitAndTagUpdates 这个方法上,这一步代码比较简单,可以直接看一下。

commitAndTagUpdates() {
    let chain = Promise.resolve();

  // 这一步会完成 git add . 和 git tag 的操作,commit 的 message 可以用户自行设置或者 lerna 会帮你自动生成
    if (this.project.isIndependent()) {
      chain = chain.then(() => this.gitCommitAndTagVersionForUpdates());
    } else {
      chain = chain.then(() => this.gitCommitAndTagVersion());
    }

    chain = chain.then(tags => {
      this.tags = tags;
    });
    // ...

    return chain;
}

然后最后一步就是将 commit 以及 tag push 到 remote,这一步更简单,lerna 封装了一个 git push 方法,推送到当前的分支的 remote 上去。

gitPushToRemote() {
  this.logger.info("git", "Pushing tags...");

  // gitPush 简单封装了一下 git push 命令
  return gitPush(this.gitRemote, this.currentBranch, this.execOpts);
}

总结

本文主要从源码的角度介绍了一下 lerna version 的工作原理,在使用 lerna 进行发包相关的操作的时候,lerna version 起到了一个很重要的作用,如果单独使用 lerna publish,一般选项情况下也是会首先进到 lerna version 这边走完整个 bump version 的操作,最后再去进行发包,因此 lerna version 是 lerna 进行 monorepo 发包的一个基础。

下一篇文章将讲解一下,lerna publish 做了哪一些工作,是怎么完成将相关的包发布的操作的。

编辑于 2021-03-04 19:50