使用 yarn 做爲包管理器的同窗可能會發現:常常會出現 package 在構建時會被重複打包,即便這些 package 的版本是能夠互相兼容的。node
舉個 🌰,假設存在如下依賴關係:git
當 (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
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.lock
spa
npx yarn-deduplicate yarn.lock
--strategy <strategy>
默認策略,會盡可能使用已安裝的最大版本。
例一,存在如下 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。
會盡可能使用最少數量的 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 的 package.json,能夠發現該包依賴瞭如下 package:
源碼中主要有兩個文件:
cli.js
,命令行相關能力。解析參數並根據參數執行 index.js
中的方法。index.js
。主要邏輯代碼。
能夠發現關鍵點在 getDuplicatedPackages
。
首先,明確 getDuplicatedPackages
的實現思路。
假設存在如下 yarn.lock
,目標是找出 lodash@^4.17.15
的 bestVersion
。
lodash@^4.17.15: version "4.17.21" lodash@4.17.16: version "4.17.16"
yarn.lock
分析出 lodash@^4.17.15
的 requestedVersion
爲^4.17.15
, installedVersion
爲 4.17.21
;requestedVersion(^4.17.15)
的全部 installedVersion
,即 4.17.21
與 4.17.16
;installedVersion
中挑選出知足當前策略的 bestVersion
(若當前策略爲 fewer
,那麼 lodash@^4.17.15
的 bestVersion
爲 4.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);
咱們須要根據指定範圍的參數過濾掉一些 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
。
相關類型定義以下:
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
能夠分爲三個步驟:
installedVersion
信息;satisfiedBy
字段;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);
補充 satisfiedBy
與 satisfies
字段
// 遍歷所有的 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); } }); });
根據 satisfiedBy
與 satisfies
計算 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;
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 的到來。