worldzhao / blog

个人博客,内容在 issue 里。
416 stars 16 forks source link

Yarn duplicate及解决方案 #10

Open worldzhao opened 3 years ago

worldzhao commented 3 years ago

什么是 Yarn duplicate

应用级 Monorepo 优化方案 中有提到过 Yarn duplicate。

使用 yarn 作为包管理器的同学可能会发现:app 在构建时会重复打包某个 package 的不同版本,即使该 package 的这些版本是可以兼容的。

举个 🌰,假设存在以下依赖关系:

monorepo-4

当 (p)npm 安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。即 lib-a 会复用 app 依赖的 lib-b@1.1.0。

然而,使用 Yarn v1 作为包管理器,lib-a 会单独安装一份 lib-b@1.2.0。

🤔 思考一下,如果 app 项目依赖的是 lib-b@^1.1.0,这样是不是就没有问题了?

yarn-duplicate

app 安装 lib-b@^1.1.0 时,lib-b 的最新版本是 1.1.0,则 lib-b@1.1.0 会在 yarn.lock 中被锁定。

若过了一段时间安装 lib-a,此时 lib-b 的最新版本已经是 1.2.0,那么依旧会出现 Yarn duplicate,所以这个问题还是比较普遍的。

虽然将公司的 Monorepo 项目迁移至了 Rush 以及 pnpm,很多项目依旧还是使用的 Yarn 作为底层包管理工具,并且没有迁移计划。

对于此类项目,我们可以使用 yarn-deduplicate 这个命令行工具修改 yarn.lock 来进行 deduplicate。

yarn-deduplicate — The Hero We Need

基本使用

按照默认策略直接修改 yarn.lock

npx yarn-deduplicate yarn.lock

处理策略

--strategy <strategy>

highest 策略

默认策略,会尽量使用已安装的最大版本。

例一,存在以下 yarn.lock:

library@^1.0.0:
  version "1.0.0"

library@^1.1.0:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

library@^1.0.0, library@^1.1.0:
  version "1.3.0"

library@^1.0.0, library@^1.1.0 会被锁定在 1.3.0(当前安装的最大版本)。

例二:

将 library@^1.1.0 改为 library@1.1.0

library@^1.0.0:
  version "1.0.0"

library@1.1.0:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

library@1.1.0:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

library@1.1.0 不变,library@^1.0.0 统一至当前安装最大版本 1.3.0。

fewer 策略

会尽量使用最少数量的 package,注意是最少数量,不是最低版本,在安装数量一致的情况下,使用最高版本

例一:

library@^1.0.0:
  version "1.0.0"

library@^1.1.0:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

library@^1.0.0, library@^1.1.0:
  version "1.3.0"

注意:与 highest策略没有区别

例二:

将 library@^1.1.0 改为 library@1.1.0

library@^1.0.0:
  version "1.0.0"

library@1.1.0:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

library@^1.0.0, library@^1.1.0:
  version "1.1.0"

可以发现使用 1.1.0 版本才可以使得安装版本最少。

渐进式更改

一把梭很快,但可能带来风险,所以需要支持渐进式的进行改造。

--packages <package1> <package2> <packageN>

指定特定 Package

--scopes <scope1> <scope2> <scopeN>

指定某个 scope 下的 Package

诊断信息

--list

仅输出诊断信息

yarn-deduplicate 原理解析

基本流程

通过查看 yarn-deduplicate 的 package.json,可以发现该包依赖了以下 package:

源码中主要有两个文件:

  1. cli.js,命令行相关能力。解析参数并根据参数执行 index.js 中的方法。
  2. index.js。主要逻辑代码。

yarn-duplicate-1

可以发现关键点在 getDuplicatedPackages

Get Duplicated Packages

首先,明确 getDuplicatedPackages 的实现思路。

假设存在以下 yarn.lock,目标是找出 lodash@^4.17.15bestVersion

lodash@^4.17.15:
  version "4.17.21"

lodash@4.17.16:
  version "4.17.16"
  1. 通过 yarn.lock 分析出 lodash@^4.17.15requestedVersion^4.17.15installedVersion4.17.21
  2. 获取满足requestedVersion(^4.17.15) 的所有 installedVersion,即 4.17.214.17.16
  3. installedVersion 中挑选出满足当前策略的 bestVersion(若当前策略为 fewer ,那么 lodash@^4.17.15bestVersion4.17.16,否则为 4.17.21)。

👆🏻 这个过程很重要,是后续代码的指导原则

类型定义

const getDuplicatedPackages = (
  json: YarnLock,
  options: Options
): DuplicatedPackages => {
  // todo
};

// 解析 yarn.lock 获取到的 object
interface YarnLock {
  [key: string]: YarnLockVal;
}

interface YarnLockVal {
  version: string; // installedVersion
  resolved: string;
  integrity: string;
  dependencies: {
    [key: string]: string;
  };
}

// 类似于这种结构
const yarnLockInstanceExample = {
  // ...
  "lodash@^4.17.15": {
    version: "4.17.21",
    resolved:
      "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c",
    integrity:
      "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
    dependencies: {
      "fake-lib-x": "^1.0.0", // lodash 实际上没有 dependencies
    },
  },
  // ...
};

// 由命令行参数解析而来
interface Options {
  includeScopes: string[]; // 指定 scope 下的 packages 默认为 []
  includePackages: string[]; // 指定要处理的 packages 默认为 []
  excludePackages: string[]; // 指定不处理的 packages 默认为 []
  useMostCommon: boolean; // 策略为 fewer 时 该值为 true
  includePrerelease: boolean; // 是否考虑 prerelease 版本的 package 默认为 false
}

type DuplicatedPackages = PackageInstance[];

interface PackageInstance {
  name: string; // package name 如 lodash
  bestVersion: string; // 在当前策略下的最佳版本
  requestedVersion: string; // 要求的版本 ^15.6.2
  installedVersion: string; // 已安装的版本 15.7.2
}

最终目标是获取 PackageInstance

获取 yarn.lock 数据

const fs = require("fs");
const lockfile = require("@yarnpkg/lockfile");

const parseYarnLock = (file) => lockfile.parse(file).object;

// file 字段通过 commander 从命令行参数获取
const yarnLock = fs.readFileSync(file, "utf8");
const json = parseYarnLock(yarnLock);

yarn.lock 对象结构美化

我们需要根据指定范围的参数 Options 过滤掉一些 package。

同时yarn.lock 对象中的 key 都是 lodash@^4.17.15 的形式,这种键名形式不便于查找数据。

可以统一以 lodashkeyvalue 为一个数组,数组项为不同的版本信息,方便后续处理,最终我们需要将 yarn.lock 对象转为下面 ExtractedPackages 的结构。

interface ExtractedPackages {
  [key: string]: ExtractedPackage[];
}

interface ExtractedPackage {
    pkg: YarnLockVal;
    name: string;
    requestedVersion: string;
    installedVersion: string;
    satisfiedBy: Set<string>;
}

satisfiedBy 就是用于存储满足此 package requestedVersion 的所有 installedVersion,默认值为 new Set() ,待后续补充。

从该 set 中取出满足策略的 installedVersion ,即为 bestVersion

具体实现如下:

const extractPackages = (
  json,
  includeScopes = [],
  includePackages = [],
  excludePackages = []
) => {
  const packages = {};
  // 匹配 yarn.lock object key 的正则
  const re = /^(.*)@([^@]*?)$/;

  Object.keys(json).forEach((name) => {
    const pkg = json[name];
    const match = name.match(re);

    let packageName, requestedVersion;
    if (match) {
      [, packageName, requestedVersion] = match;
    } else {
      // 如果没有匹配数据,说明没有指定具体版本号,则为 * (https://docs.npmjs.com/files/package.json#dependencies)
      packageName = name;
      requestedVersion = "*";
    }

    // 根据指定范围的参数过滤掉一些 package

    // 如果指定了 scopes 数组, 只处理相关 scopes 下的 packages
    if (
      includeScopes.length > 0 &&
      !includeScopes.find((scope) => packageName.startsWith(`${scope}/`))
    ) {
      return;
    }

    // 如果指定了 packages, 只处理相关 packages
    if (includePackages.length > 0 && !includePackages.includes(packageName))
      return;

    if (excludePackages.length > 0 && excludePackages.includes(packageName))
      return;

    packages[packageName] = packages[packageName] || [];
    packages[packageName].push({
      pkg,
      name: packageName,
      requestedVersion,
      installedVersion: pkg.version,
      satisfiedBy: new Set(),
    });
  });
  return packages;
};

在完成 packages 的抽离后,我们就有了同一个 package 的不同版本信息。

{
    // ...
    "lodash": [
        {
            "pkg": YarnLockVal,
            "name": "lodash",
            "requestedVersion": "^4.17.15",
            "installedVersion": "4.17.21",
            "satisfiedBy": new Set()
        },
        {
            "pkg": YarnLockVal,
            "name": "lodash",
            "requestedVersion": "4.17.16",
            "installedVersion": "4.17.16",
            "satisfiedBy": new Set()
        }
    ]
}

我们需要补充其中每一个数组项的 satisfiedBy 字段,并且通过其计算出满足当前 requestedVersionbestVersion,这个过程称之为 computePackageInstances

Compute Package Instances

相关类型定义如下:

const computePackageInstances = (
  packages: ExtractedPackages,
  name: string,
  useMostCommon: boolean,
  includePrerelease = false
): PackageInstance[] => {
  // todo
};

interface PackageInstance {
  name: string; // package name 如 lodash
  bestVersion: string; // 在当前策略下的最佳版本
  requestedVersion: string; // 要求的版本 ^15.6.2
  installedVersion: string; // 已安装的版本 15.7.2
}

实现 computePackageInstances 可以分为三个步骤:

  1. 获取当前 package 的全部 installedVersion
  2. 补充 satisfiedBy 字段;
  3. 通过 satisfiedBy 计算出 bestVersion

获取全部 installedVersion

/**
 * versions 记录当前 package 所有 installedVersion 的数据
 * satisfies 字段用于存储当前 installedVersion 满足的 requestedVersion
 * 初始值为 new Set()
 * 通过该字段的 size 可以分析出满足 requestedVersion 数量最多的 installedVersion
 * 用于 fewer 策略
 */
interface Versions {
  [key: string]: { pkg: YarnLockVal; satisfies: Set<string> };
}

// 当前 package name 对应的依赖信息
const packageInstances = packages[name];

const versions = packageInstances.reduce((versions, packageInstance) => {
  if (packageInstance.installedVersion in versions) return versions;
  versions[packageInstance.installedVersion] = {
    pkg: packageInstance.pkg,
    satisfies: new Set(),
  };
  return versions;
}, {} as Versions);

具体 versionsatisfies 字段用于存储当前 installedVersion 满足的全部 requestedVersion,初始值为 new Set(),通过该 setsize 可以分析出满足 requestedVersion 数量最多的 installedVersion,用于 fewer 策略。

补充 satisfiedBysatisfies 字段

// 遍历全部的 installedVersion
Object.keys(versions).forEach((version) => {
  const satisfies = versions[version].satisfies;
  // 逐个遍历 packageInstance
  packageInstances.forEach((packageInstance) => {
    // packageInstance 自身的 installedVersion 必定满足自身的 requestedVersion
    packageInstance.satisfiedBy.add(packageInstance.installedVersion);
    if (
      semver.satisfies(version, packageInstance.requestedVersion, {
        includePrerelease,
      })
    ) {
      satisfies.add(packageInstance);
      packageInstance.satisfiedBy.add(version);
    }
  });
});

根据 satisfiedBysatisfies 计算 bestVersion

packageInstances.forEach((packageInstance) => {
  const candidateVersions = Array.from(packageInstance.satisfiedBy);
  // 进行排序
  candidateVersions.sort((versionA, versionB) => {
    // 如果使用 fewer 策略,根据当前 satisfiedBy 中 `satisfies` 字段的 size 排序
    if (useMostCommon) {
      if (versions[versionB].satisfies.size > versions[versionA].satisfies.size)
        return 1;
      if (versions[versionB].satisfies.size < versions[versionA].satisfies.size)
        return -1;
    }
    // 如果使用 highest 策略,使用最高版本
    return semver.rcompare(versionA, versionB, { includePrerelease });
  });
  packageInstance.satisfiedBy = candidateVersions;
  packageInstance.bestVersion = candidateVersions[0];
});

return packageInstances;

这样,我们就找到了同一 package 不同版本的 installedVersion 和所需要的 bestVersion

完成 getDuplicatedPackages

const getDuplicatedPackages = (
  json,
  {
    includeScopes,
    includePackages,
    excludePackages,
    useMostCommon,
    includePrerelease = false,
  }
) => {
  const packages = extractPackages(
    json,
    includeScopes,
    includePackages,
    excludePackages
  );
  return Object.keys(packages)
    .reduce(
      (acc, name) =>
        acc.concat(
          computePackageInstances(
            packages,
            name,
            useMostCommon,
            includePrerelease
          )
        ),
      []
    )
    .filter(
      ({ bestVersion, installedVersion }) => bestVersion !== installedVersion
    );
};

结语

本文通过介绍 Yarn duplicate ,引出 yarn-deduplicate 作为解决方案,并且分析了内部相关实现,期待 Yarn v2 的到来。