什么是 Yarn duplicate

应用 yarn 作为包管理器的同学可能会发现:常常会呈现 package 在构建时会被反复打包,即便这些 package 的版本是能够相互兼容的。

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

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

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

  • difference between npm and yarn behavior with nested dependencies #3951
  • Yarn installing multiple versions of the same package
  • Yarn v2 supports package deduplication natively

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

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:

  • commander 残缺的 node.js 命令行解决方案;
  • @yarnpkg/lockfile 解析或写入 yarn.lock 文件;
  • semver The semantic versioner for npm,能够用来判断装置版本是否满足 package.json 要求版本。

源码中次要有两个文件:

  1. cli.js,命令行相干能力。解析参数并依据参数执行 index.js 中的办法。
  2. index.js。次要逻辑代码。

能够发现关键点在 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 获取到的 objectinterface 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);

Extract Packages

咱们须要依据指定范畴的参数过滤掉一些 package。

同时 yarn.lock 对象中的 key 都是 lodash@^4.17.15 的模式,可能还存在以 lodash@4.17.16 为 key 的 value,这种键名模式不便于查找数据。

咱们能够对立以 lodash 包名为 key,value 为一个数组,数组项为不同的版本信息,不便后续解决。

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

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 的抽离后,咱们须要补充其中的 satisfiedBy 字段,并且通过其计算出 bestVersion,即实现 computePackageInstances

Compute Package Instances

相干类型定义如下:

interface PackageInstance {  name: string; // package name 如 lodash  bestVersion: string; // 在以后策略下的最佳版本  requestedVersion: string; // 要求的版本 ^15.6.2  installedVersion: string; // 已装置的版本 15.7.2}const computePackageInstances = (  packages: ExtractedPackages,  name: string,  useMostCommon: boolean,  includePrerelease = false): PackageInstance[] => {  // todo};

实现 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);

补充 satisfiedBysatisfies 字段

// 遍历全副的 installedVersionObject.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;

实现 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 的到来。