Yarn duplicate及解決方案

什麼是 Yarn duplicate

使用 yarn 做爲包管理器的同窗可能會發現:常常會出現 package 在構建時會被重複打包,即便這些 package 的版本是能夠互相兼容的。node

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

monorepo-4

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

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

🤔 思考一下,若是 app 項目依賴的是 lib-b@^1.1.0,這樣是否是就沒有問題了?json

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,因此這個問題仍是比較廣泛的。app

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

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

yarn-deduplicate — The Hero We Need

基本使用

按照默認策略直接修改 yarn.lockspa

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

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 字段

// 遍歷所有的 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;

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

相關文章
相關標籤/搜索