Yarn duplicate及解決方案

本文首發於 github,更多文章能夠前往 github 閱讀。node

什麼是 Yarn duplicate

使用 yarn 做爲包管理器的同窗可能會發現:app 在構建時會重複打包某個 package 的不一樣版本,即便該 package 的這些版本是能夠兼容的。git

舉個 🌰,假設存在如下依賴關係:github

monorepo-4

當 (p)npm 安裝到相同模塊時,判斷已安裝的模塊版本是否符合新模塊的版本範圍,若是符合則跳過,不符合則在當前模塊的 node_modules 下安裝該模塊。即 lib-a 會複用 app 依賴的 lib-b@1.1.0npm

然而,使用 Yarn v1 做爲包管理器,lib-a 會單獨安裝一份 lib-b@1.2.0json

🤔 思考一下,若是 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 中被鎖定。markdown

若過了一段時間安裝 lib-a,此時 lib-b 的最新版本已是 1.2.0,那麼依舊會出現 Yarn duplicate,因此這個問題仍是比較廣泛的。app

雖然將公司的 Monorepo 項目遷移至了 Rush 以及 pnpm,不少項目依舊仍是使用的 Yarn 做爲底層包管理工具,而且沒有遷移計劃。工具

對於此類項目,咱們可使用 yarn-deduplicate 這個命令行工具修改 yarn.lock 來進行 deduplicate。oop

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。主要邏輯代碼。

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 的到來。

相關文章
相關標籤/搜索