前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >深入 lerna 发包机制 —— lerna publish

深入 lerna 发包机制 —— lerna publish

作者头像
Nealyang
发布2021-09-27 12:52:41
4.4K0
发布2021-09-27 12:52:41
举报
文章被收录于专栏:全栈前端精选

前言:lerna 作为一个风靡的前端 monorepo 管理工具,在许许多多的大型项目中都得到的实践,现在笔者在公司中开发的 monorepo 工具中,monorepo 中子项目的发包能力就是基于 lerna 来完成,因此本篇文章将讲解发包中最关键的命令即 lerna publish

在上一篇文章中介绍完了 lerna version 的运行机制后,那么在本篇文章中我将继续介绍一下 lerna 发包机制中最关键的一个 command 即 lerna publish

现在我们来继续介绍 lerna publish 运行机制,作为发包机制中的最后决定性的一个指令,lerna publish 的做的工作其实很简单,就是将 monorepo 需要发布的包,发布到 npm registry 上面去。

同样 lerna publish 也分为几种不同的场景去运行:

代码语言:javascript
复制
lerna publish  
# lerna version + lerna publish from-git
lerna publish from-git 
# 发布当前 commit 中打上 annoted tag version 的包
lerna publish from-packages 
# 发布 package 中 pkg.json 上的 version 在 registry(高于 latest version)不存在的包

官方文档,lerna publish 一共有这样几种执行表现形式:

lerna publish 永远不会发布 package.json 中 private 设置为 true 的包

  • 发布自上次发布来有更新的包(这里的上次发布也是基于上次执行lerna publish 而言)
  • 发布在当前 commit 上打上了 annotated tag 的包(即 lerna publish from-git)
  • 发布在最近 commit 中修改了 package.json 中的 version (且该 version 在 registry 中没有发布过)的包(即 lerna publish from-package)
  • 发布在上一次提交中更新了的 unversioned 的测试版本的包(以及依赖了的包)

lerna publish 本身提供了不少的 options,例如支持发布测试版本的包即 (lerna version --canary)。

在上文 lerna version 源码解析中,我们按照 configureProperties -> initialize -> execute 的顺序讲解了 lerna version 的执行顺序,其实在 lerna 中,几乎所有子命令源码的执行顺序都是按照这样一个结构在进行,lerna 本身作为一个 monorepo,主要是使用 core 核心中的执行机制来去分发命令给各个子项目去执行,因此套路都是一样的。

在开始阅读之前,我先提供一个整体的思维导图,可以让读者在开始阅读前有个大致的结构,也便于在阅读过程可以借此来进行回顾:

设置属性(configureProperties)

相比较于 lerna version,lerna publish 的这一步就简单许多,大致就是根据 cli 的 options 对一些参数进行了初始化:

代码语言:javascript
复制
configureProperties() {
    const {
      exact,
      gitHead,
      gitReset,
      tagVersionPrefix = "v",
      verifyAccess,
    } = this.options;

   // 这里的 requiresGit 指的是除了 from-package 的其它发包方式
    if (this.requiresGit && gitHead) {
      throw new ValidationError("EGITHEAD", "--git-head is only allowed with 'from-package' positional");
    }

  // --exact 会指定一个具体的 version 版本,而不会加上 npm 那边的版本兼容前缀
    this.savePrefix = exact ? "" : "^";

    // 用于用户自定义包的版本 tag 前缀,而不是使用默认的 v
    this.tagPrefix = tagVersionPrefix;

    // --no-git-reset 用于避免 lerna publish 将暂存区的未提交的代码都 push 到 git 上
    this.gitReset = gitReset !== false;
  
    // lerna 发包会默认检查用户 npm 权限 
    // 设置 --no-verify-access 跳过检查 
    this.verifyAccess = verifyAccess !== false;

  // npm 发包相关配置
    this.npmSession = crypto.randomBytes(8).toString("hex");
  }

通过注释就可以比较清晰的看到一些 options 以及相关参数的初始化,这里就不详细介绍。

初始化(initialize)

下面直接进来初始化的流程中来,因为涉及到发包相关的流程,这一步的前面过程涉及到的就是一些关于 npm 相关的 config 初始化,之后再根据不同的发包情况去进行对应的事件注册,这一步的事件注册以及执行方式都和 lerna version 源码解析时比较类似,主要过程可以分为三个步骤:

  1. 初始化 npm config 参数
  2. 根据不同的发包情况执行不同的方法
  3. 处理上一步返回的结果

这里不同的发包情况指的即是在文章开头介绍的 lerna publish 的几种执行方式,这里大致梳理一下以下的步骤:

代码语言:javascript
复制
  initialize() {
    // --skip-npm 相当于直接执行 lerna version 
    if (this.options.skipNpm) {
      // 该 api 会在下个 major 被弃用
      this.logger.warn("deprecated", "Instead of --skip-npm, call `lerna version` directly");
     // 这里我们可以看到 lerna 中某个 command 调用其他 command 都是通过这种链式调用的方式
      return versionCommand(this.argv).then(() => false);
    }
    
    // 1. 初始化 npm config 参数

    // session 和 userAgent 都是 npm 发包需要验证的参数
    this.logger.verbose("session", this.npmSession);
    this.logger.verbose("user-agent", this.userAgent);

    // npm config 相关, 存一些 npm config 相关值
    this.conf = npmConf({
      lernaCommand: "publish",
      _auth: this.options.legacyAuth,
      npmSession: this.npmSession,
      npmVersion: this.userAgent,
      otp: this.options.otp,
      registry: this.options.registry,
      "ignore-prepublish": this.options.ignorePrepublish,
      "ignore-scripts": this.options.ignoreScripts,
    });
  
    // --dist-tag 用于 设置发包时候自定义 tag
    // 一般默认 tag 是 latest
    // lerna 中如果没指定 --dist-tag, 正式包的 tag 会用 latest, --canary 的测试包会用 canary
    const distTag = this.getDistTag();

    // 如果该参数存在 会被注入进 npm conf 中
    if (distTag) {
      this.conf.set("tag", distTag.trim(), "cli");
    }

    // 注册运行 lerna.json 里面的 script 的 runner 
    this.runPackageLifecycle = createRunner(this.options);

    // 如果 lerna 子 package 里面的 pkg.json 里面有 pre|post publish 这样的 script 
    // 会跳过 lifecycle script 的执行过程,否则会去递归执行
    this.runRootLifecycle = /^(pre|post)?publish$/.test(process.env.npm_lifecycle_event)
      ? stage => {
          this.logger.warn("lifecycle", "Skipping root %j because it has already been called", stage);
        }
      : stage => this.runPackageLifecycle(this.project.manifest, stage);

    
    // 2. 根据不同的发包情况执行不同的方法
    
    // 通过 promise 构建一个执行链, lerna version 里面讲过
    let chain = Promise.resolve();

    if (this.options.bump === "from-git") {
      chain = chain.then(() => this.detectFromGit());
    } else if (this.options.bump === "from-package") {
      chain = chain.then(() => this.detectFromPackage());
    } else if (this.options.canary) {
      chain = chain.then(() => this.detectCanaryVersions());
    } else {
      chain = chain.then(() => versionCommand(this.argv));
    }

    // 3. 对方法返回的结果做一个处理
    
    return chain.then(result => {
      // 如果上一步是走了 lerna version 的 bump version 过程
      if (!result) {
        return false;
      }

      // lerna version 返回的结果数组里面没有需要更新的 package
      if (!result.updates.length) {
        this.logger.success("No changed packages to publish");
        return false;
      }

      // publish 的时候把 pkg.json 里面设置private 为 false 的包忽略掉 
      this.updates = result.updates.filter(node => !node.pkg.private);
      // 需要更新的包以及对应更新到的version 
      this.updatesVersions = new Map(result.updatesVersions);

      // 再筛选一下需要发包的 packages,根据是否存在 pkg.json
      this.packagesToPublish = this.updates.map(node => node.pkg);

      // 用于发布 lerna 管理的 packages 的一些子目录例如 dist
      // 参考 --contents 这个 options
      if (this.options.contents) {
        // 把这些目录写进需要发包的 pkg.json 中
        for (const pkg of this.packagesToPublish) {
          pkg.contents = this.options.contents;
        }
      }

      // 用于确认上面除了 versioncommand 的其他三种执行情况
      // 例如 versionCommand 有自己的 confirm 过程
      if (result.needsConfirmation) {
        return this.confirmPublish();
      }

      return true;
    });
  }

initialize 前面有介绍主要分为三个步骤来执行,因此 1、3 两个步骤根据注释来理解过程还是比较清晰的,这里主要介绍一下第二步即 根据不同的发包情况来执行不用的方法,具体代码:

代码语言:javascript
复制
if (this.options.bump === "from-git") {
  chain = chain.then(() => this.detectFromGit());
} else if (this.options.bump === "from-package") {
  chain = chain.then(() => this.detectFromPackage());
} else if (this.options.canary) {
  chain = chain.then(() => this.detectCanaryVersions());
} else {
  chain = chain.then(() => versionCommand(this.argv));
}

首先根据上面代码中以及文章开头介绍,可以很清晰的知道具体分为这几种情况:

  • from-git 即根据 git commit 上的 annotaed tag 进行发包
  • from-package 即根据 lerna 下的 package 里面的 pkg.json 的 version 变动来发包
  • --canary 发测试版本的包
  • 剩下不带参数的情况就直接走一个 bump version(即执行 lerna version)

下面从这几种情况做个介绍:

from-git

这一步的执行入口函数是 detectFromGit ,我们直接看这个函数的执行过程:

代码语言:javascript
复制
detectFromGit() {
    
    const matchingPattern = this.project.isIndependent() ? "*@*" : `${this.tagPrefix}*.*.*`;

    let chain = Promise.resolve();

    // 1. 验证当前的 git 工作区域是否干净 通过 git describe 来找
    chain = chain.then(() => this.verifyWorkingTreeClean());

    // 2. 拿到当前 commit 上面的 tag
    chain = chain.then(() => getCurrentTags(this.execOpts, matchingPattern));
  
    // 3. 通过上一步的 tag 拿到需要更新的 pkg 的数组
    chain = chain.then(taggedPackageNames => {
      if (!taggedPackageNames.length) {
        this.logger.notice("from-git", "No tagged release found");

        return [];
      }
      // 独立发包模式就拿到所有包的数组
      if (this.project.isIndependent()) {
        return taggedPackageNames.map(name => this.packageGraph.get(name));
      }
      
      // 固定模式只用拿到一个版本,所有的包都用一个版本
      return getTaggedPackages(this.packageGraph, this.project.rootPath, this.execOpts);
    });

    // 4. 清除掉更新 packages 里面 pkg.json 中设置了 private 为 false 的包
    chain = chain.then(updates => updates.filter(node => !node.pkg.private));

    // 5. updateVersions 存需要发布的包名以及发布的版本
    return chain.then(updates => {
      const updatesVersions = updates.map(node => [node.name, node.version]);

      return {
        updates,
        updatesVersions,
        needsConfirmation: true,
      };
    });
  }

可以看到这一步函数的执行过程还是比较简单明了的,在上面注释中根据不同的方法执行过程分为了 5 个步骤。主要就是根据当前 commit 拿到 tags 里面的 packages 然后返回这些 packages 以及其版本信息。

from-package

这一步执行的入口函数是 detectFromPackage ,直接看执行过程:

代码语言:javascript
复制
  detectFromPackage() {
    let chain = Promise.resolve();

    // 1. 验证当前 git 工作区是否干净,步骤同上
    chain = chain.then(() => this.verifyWorkingTreeClean());

    // 2. 通过 getUnpublishedPackages 筛除 private 为 true 的 package && 拿到需要发布的pkg
    // 这一步用了 npm config 里面的快照来做对比
    chain = chain.then(() => getUnpublishedPackages(this.packageGraph, this.conf.snapshot));
    
    // 3. 验证结果符合预期否
    chain = chain.then(unpublished => {
      if (!unpublished.length) {
        this.logger.notice("from-package", "No unpublished release found");
      }

      return unpublished;
    });

    // 4. updateVersions 存需要发布的包名以及发布的版本,返回结果
    return chain.then(updates => {
      const updatesVersions = updates.map(node => [node.name, node.version]);

      return {
        updates,
        updatesVersions,
        needsConfirmation: true,
      };
    });
  }

这一步主要是在 getUnpublishedPackages 这一步筛选出需要更新的 packages,这里 lerna 作者使用了自己封装的 pacote 库来去做一些关于版本的比对,从而得到需要更新的 packages,这里有想了解的可以自行去阅读一下,不做过多赘述。

--canary

这一步执行的入口函数是 detectCanaryVersions ,直接看执行过程:

代码语言:javascript
复制
detectCanaryVersions() {
  // 初始化处理参数
    const { cwd } = this.execOpts;
    const {
      bump = "prepatch",
      preid = "alpha",
      ignoreChanges,
      forcePublish,
      includeMergedTags,
    } = this.options;
    const release = bump.startsWith("pre") ? bump.replace("release", "patch") : `pre${bump}`;

    let chain = Promise.resolve();

    // 1. 验证当前 git 区是否干净
    chain = chain.then(() => this.verifyWorkingTreeClean());

    // 2. 找到自上次来修改过的 packages 同时筛掉 private 为 false 的 pkg
    chain = chain.then(() =>
      collectUpdates(this.packageGraph.rawPackageList, this.packageGraph, this.execOpts, {
        bump: "prerelease",
        canary: true,
        ignoreChanges,
        forcePublish,
        includeMergedTags,
      }).filter(node => !node.pkg.private)
    );

    const makeVersion = fallback => ({ lastVersion = fallback, refCount, sha }) => {
      // --canary 会通过上一次的 version 来计算出这次的 version 
      const nextVersion = semver.inc(lastVersion.replace(this.tagPrefix, ""), release.replace("pre", ""));
      return `${nextVersion}-${preid}.${Math.max(0, refCount - 1)}+${sha}`;
    };

    // 3. 根据不同的 mode 计算出包及版本相关参数
    if (this.project.isIndependent()) {
      // 独立发包模式
      chain = chain.then(updates =>
        // pMap 是个链式执行过程,上一步的结果会给到下一步
        pMap(updates, node =>
          // 根据 tag 匹配出需要发布的包
          describeRef(
            {
              match: `${node.name}@*`,
              cwd,
            },
            includeMergedTags
          )
             // 通过上面的 makeVersion 方法来计算发布的 canary 版本
            .then(makeVersion(node.version))
             // 返回出去,这里实际上就是个 updateVerions 数组
            .then(version => [node.name, version])
        ).then(updatesVersions => ({
          updates,
          updatesVersions,
        }))
      );
    } else {
      // 固定的模式,那么所有的包都会使用一个版本(lerna.json 里面的版本)
      chain = chain.then(updates =>
        describeRef(
          {
            match: `${this.tagPrefix}*.*.*`,
            cwd,
          },
          includeMergedTags
        )
          // 只用一个 version 去进行计算
          .then(makeVersion(this.project.version))
          .then(version => updates.map(node => [node.name, version]))
          .then(updatesVersions => ({
            updates,
            updatesVersions,
          }))
      );
    }

  // 4. 返回结果
    return chain.then(({ updates, updatesVersions }) => ({
      updates,
      updatesVersions,
      needsConfirmation: true,
    }));
  }

相比较于上面两步, --canary 的处理过程或许看上去要复杂一些,其实不然,根据上面代码注释中的内容可以比较清晰的看到整个执行流程,不过多了几种特殊情况需要去做一些判断,其中比较复杂的第三步,是需要通过 tag 得到一些相关的信息,需要更新的包,然后针对这些包现有的版本去做一些计算,可以参考上面的 makeVersion 方法,这里就根据 lerna 的 mode 分为了两种情况。

其中这里第二步还用到了在 lerna version 中收集变更的包的方法:collectUpdates。具体的执行机制可以参考我的上一篇关于 lerna version 的文章。

bump version

如果不带参数的话,那么这一步就会直接执行一个 lerna version 的过程,一般 lerna publish 的预期行为是这样:

代码语言:javascript
复制
chain = chain.then(() => versionCommand(this.argv));

lerna version 的具体执行机制可以参考我的上一篇文章。

看完这几种情况之后再回到开头,再回顾一下 initialize 这一步最后对结果的一个处理过程,大致 initialize 的一个流程就这样结束了。

最后总结一下 lerna publish 的初始化过程,主要就是根据不同的发包情况,然后计算出需要发布的包的信息,例如包名称和更新版本。用于下一步发包的 execute 做准备。

执行(execute)

lerna publish 的最后一步即发包的过程就是在这里完成,代码结构为:

代码语言:javascript
复制
execute() {
    let chain = Promise.resolve();

    // 1. 验证 npm 源、权限,项目的 License 之内
    chain = chain.then(() => this.prepareRegistryActions());
    chain = chain.then(() => this.prepareLicenseActions());

    if (this.options.canary) {
      // 如果是测试包,更新到测试包的 version
      chain = chain.then(() => this.updateCanaryVersions());
    }

    // 2. 更新本地依赖包版本 && gitHead
    chain = chain.then(() => this.resolveLocalDependencyLinks());
    chain = chain.then(() => this.annotateGitHead());
 
    // 3. 更新写入本地
    chain = chain.then(() => this.serializeChanges());
 
    // 4. 对 package pack
    chain = chain.then(() => this.packUpdated());
  
    // 5. 发布包
    chain = chain.then(() => this.publishPacked());

    if (this.gitReset) {
      // 设置了 --no-git-reset 会把 working tree 的版本修改重置
      // lerna 每次发包都会把更新的 package.json 的 version 的修改提交到 git 上去
      // 如果发测试包,这可能是没必要,因此可以用这个选项把修改 reset 掉
      chain = chain.then(() => this.resetChanges());
    }

   // 做后续的处理
    return chain.then(() => {
      // 发布包的数量
      const count = this.packagesToPublish.length;
      // 发布包的名称以及版本,用于输出展示
      const message = this.packagesToPublish.map(pkg => ` - ${pkg.name}@${pkg.version}`);

      output("Successfully published:");
      output(message.join(os.EOL));

      this.logger.success("published", "%d %s", count, count === 1 ? "package" : "packages");
    });
  }

executelerna publish 的主要部分了,这一步的相对而言信息量比较巨大,我接下来会将上面的步骤拆一拆,一步一步来讲解 execute 这一步是怎么完成 lerna 发包的整个过程的。

首先可以看到上面代码中,我通过注释将这个步骤分成了六步:

1. 验证 npm && 项目license

首先上面可以看到,这一步分为两个方法,一步是做 npm 相关的验证:prepareRegistryActions

代码语言:javascript
复制
prepareRegistryActions() {
    let chain = Promise.resolve();
    if (this.conf.get("registry") !== "https://registry.npmjs.org/") {
      // 这里的 registry 如果 url 是三方的例如公司的源,这里会跳过后面的检查
      return chain;
    }

      // --no-verify-access 停止校验,默认会校验
    if (this.verifyAccess) {
      // 拿用户的 npm username,拿不到在 getNpmUsername 会抛错
      chain = chain.then(() => getNpmUsername(this.conf.snapshot));
      // 根据 username 对要发布的包做个鉴权
      chain = chain.then(username => {
        if (username) {
          return verifyNpmPackageAccess(this.packagesToPublish, username, this.conf.snapshot);
        }
      });

      // 校验用户是否需进行 2fa 的验证 -- 安全验证相关
      chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot));
      chain = chain.then(isRequired => {
        // 记录一下
        this.twoFactorAuthRequired = isRequired;
      });
    }

    return chain;
  }

prepareRegistryActions 执行时会先去校验 registry,如果是第三方的 registry,会停止校验,用户在发包设置了 no-verify-access 就不进行后面校验,默认会校验。

校验过程是首先通过 getNpmUsername 去拿到用户的 username,这里是通过 npm 提供的相关接口来获取,具体流程可以自行参考。拿到 username 之后根据 username 以及本次 publish 中需要发布的包的信息去做一个鉴权,判断用户是否用该包的读写发包权限,没有就会抛错,最后一步是个 2fa 的验证,一般 npm 包都不会开启,主要是为了安全作用做二次验证使用,这里不做具体讲解。

下面在看 license 的校验过程,方法是 prepareLicenseActions

代码语言:javascript
复制
prepareLicenseActions() {
    return Promise.resolve()
      // 通过 glob 的方式去找到待发布的包中没有 licenses 的
      .then(() => getPackagesWithoutLicense(this.project, this.packagesToPublish))
      .then(packagesWithoutLicense => {
      // 对于没有 liecense 的包会打个 warnning 出来
        if (packagesWithoutLicense.length && !this.project.licensePath) {
          this.packagesToBeLicensed = [];

          const names = packagesWithoutLicense.map(pkg => pkg.name);
          const noun = names.length > 1 ? "Packages" : "Package";
          const verb = names.length > 1 ? "are" : "is";
          const list =
            names.length > 1
              ? `${names.slice(0, -1).join(", ")}${names.length > 2 ? "," : ""} and ${
                  names[names.length - 1] /* oxford commas _are_ that important */
                }`
              : names[0];
          this.logger.warn(
            "ENOLICENSE",
            "%s %s %s missing a license.\n%s\n%s",
            noun,
            list,
            verb,
            "One way to fix this is to add a LICENSE.md file to the root of this repository.",
            "See https://choosealicense.com for additional guidance."
          );
        } else {
          // 记录一下
          this.packagesToBeLicensed = packagesWithoutLicense;
        }
      });
  }

这一步并不会对主要流程有什么影响,主要就是找目前待发布的包中没有 license 的,然后给个 warnning 提示,这里找的方式使用过 lerna 自己构造的 project graph 去筛待发布包中不存在 liecense 文件的路径,想了解具体过程参考 getPackagesWithoutLicense

2. 更新本地依赖版本 && 待发布包 gitHead

可以你会对更新本地依赖版本这一步可能会有些迷惑,这里举个例子来解释一下,在 lerna 中,如果 workspaces 之前存在依赖的话,在这次发包中,例如 A 这个包依赖了 B,B 在这次发包中版本升级了,那么这里 A 里面依赖的 B 也要更新到对应的版本。

来看一下这一步:

代码语言:javascript
复制
resolveLocalDependencyLinks() {
    // 先找到依赖过本地包的包
    // lerna 中 A, B 都是 workspace, A 依赖 B 引入的时候是通过 symlink 引入的
    // 因此这里找 B 的依赖包只用判断 A 这里 resolved 的是不是个目录就行
    const updatesWithLocalLinks = this.updates.filter(node =>
      Array.from(node.localDependencies.values()).some(resolved => resolved.type === "directory")
    );

    // 拿到上一步结果之后,就把对应的更新写入 A
    return pMap(updatesWithLocalLinks, node => {
      for (const [depName, resolved] of node.localDependencies) {
        // 注意这里 lerna 是不会处理 B: ../../file/xxx 这种引入情况的
        const depVersion = this.updatesVersions.get(depName) || this.packageGraph.get(depName).pkg.version;
        // 以 A 为例子,这里 A 的 pkg.json 中的B 就要更新到发包的版本
        node.pkg.updateLocalDependency(resolved, depVersion, this.savePrefix);
      }
    });
}

这里涉及到的一些操作方法,都是来自于 lerna 构建的 project graph,这部分可以去参考一下 lerna core 中源码。

这里的 gitHead 是一个 hash 值,用户可以通过 --git-head 来自行指定,如果不指定的话,lerna 这里会默认帮你取当前 commit 的 hash 值,即通过 git rev-parse HEAD 来获取,一般 gitHead 结合 from-package 来使用,先看看代码:

代码语言:javascript
复制
annotateGitHead() {
    try {
      // 用户如果没有默认指定就使用最近的 commit hash 值
      // getCurrentSHA 就是执行了一次 git rev-parse HEAD
      const gitHead = this.options.gitHead || getCurrentSHA(this.execOpts);
      for (const pkg of this.packagesToPublish) {
        // gitHead 是用来关联 package 和 git 记录
        // npm publish 正常情况下需要该字段
        pkg.set("gitHead", gitHead);
      }
    } catch (err) {
    }
  }

在使用 from-package 的方式进行发包的时候,会把这个 githead 字段写在 package.json 里面。

3. 更新写入本地

这一步就是将第二步的一些更新直接写到 lerna 中对应项目里面去,即写到磁盘里面,主要的方法为:

代码语言:javascript
复制
serializeChanges() {
   return pMap(this.packagesToPublish, pkg => pkg.serialize());
}

这个 pkg.serialize() 方法,是可以在 lerna 的 core 中找到的,主要作用就是将相关的更新写入本地磁盘:

代码语言:javascript
复制
serialize() {
  // 这里的 writePkg 封装了 write-package-json 这个方法
  return writePkg(this.manifestLocation, this[PKG]).then(() => this);
}
4. package pack

在讲解之前,我们得先知道 npm pack 这个操作是干什么的,它会打包当前的文件夹内容打包成一个 tar 包,我们在执行 npm publish 的时候会经常看到这个操作:

不过 npm publish 帮我们封装了这个过程,lerna publish 中也会有这个过程,这已经是发包前的最后一个操作了,具体可参考代码:

代码语言:javascript
复制
packUpdated() {
    let chain = Promise.resolve();
  
    // ... 
    const opts = this.conf.snapshot;
    const mapper = pPipe(
      [
        // packDirectory 会给对应 pkg 的文件夹打个 tar 包出来
        // 类似于上面的 npm pack
        pkg =>
          pulseTillDone(packDirectory(pkg, pkg.location, opts)).then(packed => {
            pkg.packed = packed;
            return pkg.refresh();
          }),
      ].filter(Boolean)
    );

   // 这里会按照拓扑序列去对要发布的包进行 pack
    chain = chain.then(() => this.topoMapPackages(mapper));

    return pFinally(chain, () => tracker.finish());
  }

这一步首先可以参考 topoMapPackages 这个方法,他会按照拓扑顺序去对需要更新的包进行 pack,这里 publish 因为涉及到包之间的一些依赖关系,因此只能按照拓扑的顺序去执行,this.packagesToPublish 里面存的是待发布的包:

代码语言:javascript
复制
topoMapPackages(mapper) {
    // 这里是作者的一个注释:
    // we don't respect --no-sort here, sorry
    return runTopologically(this.packagesToPublish, mapper, {
      concurrency: this.concurrency,
      rejectCycles: this.options.rejectCycles,
      graphType: this.options.graphType === "all" ? "allDependencies" : "dependencies",
    });
  }

因此这里会按照拓扑顺序去对要发布的包进行打包成 tar 包的操作,具体执行方法是 packDirectory 这个方法,这个方法我只贴一下打包的那一段逻辑,还有一些其他的预处理逻辑做了一下删除:

代码语言:javascript
复制
const tar = require("tar");
const packlist = require("npm-packlist");

function packDirectory(_pkg, dir, _opts) {
  // ...
  let chain = Promise.resolve();
  // 拿到待发布 pkg 的 目录信息
  chain = chain.then(() => packlist({ path: pkg.contents }));
  // 对目录下面的一些文件夹打包
  chain = chain.then(files =>
    // 具体参数去参考 tar 这个 npm 包
    tar.create(
      {
        cwd: pkg.contents,
        prefix: "package/",
        portable: true,
        mtime: new Date("1985-10-26T08:15:00.000Z"),
        gzip: true,
      },
      files.map(f => `./${f}`)
    )
  );
  // 将文件处理成 stream 形式写到一个临时目录下面
  // 发布完了会删除
  chain = chain.then(stream => tempWrite(stream, getTarballName(pkg)));
  chain = chain.then(tarFilePath =>
    getPacked(pkg, tarFilePath).then(packed =>
      Promise.resolve()
        .then(() => runLifecycle(pkg, "postpack", opts))
        .then(() => packed)
    )
  );
  return chain;
}
5. Package publish

在上一步完成了待发布包的打包操作之后,这一步就是 lerna publish 整个流程的最后一步了!

这一步会将上一次打包的内容直接发布出去,先来看一下代码:

代码语言:javascript
复制
  publishPacked() {
    let chain = Promise.resolve();

    // 前面说过的 2fa 2次验证,这里会验证一下
    if (this.twoFactorAuthRequired) {
      chain = chain.then(() => this.requestOneTimePassword());
    }

    const opts = Object.assign(this.conf.snapshot, {
      // 设置了 tempTag 就先用 lerna-temp
      tag: this.options.tempTag ? "lerna-temp" : this.conf.get("tag"),
    });

    const mapper = pPipe(
      [
        pkg => {
          // 拿到 pkg 上一次发布的 tag,通过 semver.prerelease 进行判断
          const preDistTag = this.getPreDistTag(pkg);
          // 取一下 tag,一般这里会取 opts.tag,针对于每个包的情况不同
          const tag = !this.options.tempTag && preDistTag ? preDistTag : opts.tag;
          // 这里 rewrite 一下 tag
          const pkgOpts = Object.assign({}, opts, { tag });

          // 发布包这个操作通过 npmPublish 这个过程来完成
          return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, this.otpCache)).then(() => {
            return pkg;
          });
        }
      ].filter(Boolean)
    );

    // 这里和上一步 pack 一样,按照拓扑执行
    chain = chain.then(() => this.topoMapPackages(mapper));

    return pFinally(chain, () => tracker.finish());
  }

上一步讲了 topoMapPackages 这个方法,这里同样的,它会按照拓扑顺序去发布待发布的 pkg。

npmPublish 这个方法中,会将前面打包的 pkg 的 tar 包 publish 到 npm 上面去,这里用的是 lerna 作者自己的一个包,感兴趣的可以去 npm 上搜一下:@evocateur/libnpmpublish

这个包可以不用担心 tarball 打包自于哪个 pkg,只要你有个 tarball 它会帮你直接上传到 npm 上面去来完成一次发布,具体的内容可以在 npm 中找到。

这里因为引入了一些外部包加上这里有太多的边界条件处理,这里就不具体去看 npmPublish 这个方法了,贴上发布的那部分代码,可以参考一下:

代码语言:javascript
复制
const { publish } = require("@evocateur/libnpmpublish");

return otplease(innerOpts => publish(manifest, tarData, innerOpts), opts, otpCache).catch(err => {
  // re-throw to break chain upstream
  throw err;
});

那么再走到这一步结束之后,基本上整个 lerna 的发包流程都走完了。

后续的一些收尾工作的处理,可以再拉回 执行(execute) 这一节开头的代码分析那里。

总结

本文从源码角度剖析了一下 lerna publish 的执行机制,对于一些边界的 corner case 有些删减,按照主线讲解了 lerna publish 是怎么完成 lerna monorepo 中的整个发包流程操作的。希望本系列的文章能对你有所帮助。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-09-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 全栈前端精选 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 设置属性(configureProperties)
  • 初始化(initialize)
    • from-git
      • from-package
        • --canary
          • bump version
            • 1. 验证 npm && 项目license
            • 2. 更新本地依赖版本 && 待发布包 gitHead
            • 3. 更新写入本地
            • 4. package pack
            • 5. Package publish
        • 执行(execute)
          • 总结
          相关产品与服务
          腾讯云代码分析
          腾讯云代码分析(内部代号CodeDog)是集众多代码分析工具的云原生、分布式、高性能的代码综合分析跟踪管理平台,其主要功能是持续跟踪分析代码,观测项目代码质量,助力维护团队卓越代码文化。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档