本文首发于 github,更多文章能够前往 github 阅读。node
使用 yarn 做为包管理器的同窗可能会发现:app 在构建时会重复打包某个 package 的不一样版本,即便该 package 的这些版本是能够兼容的。git
举个 🌰,假设存在如下依赖关系:github
当 (p)npm 安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,若是符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。即 lib-a 会复用 app 依赖的 lib-b@1.1.0。npm
然而,使用 Yarn v1 做为包管理器,lib-a 会单独安装一份 lib-b@1.2.0。json
🤔 思考一下,若是 app 项目依赖的是 lib-b@^1.1.0,这样是否是就没有问题了?数组
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.lock
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);
复制代码
咱们须要根据指定范围的参数 Options
过滤掉一些 package。
同时 yarn.lock
对象中的 key
都是 lodash@^4.17.15
的形式,这种键名形式不便于查找数据。
能够统一以 lodash
为 key
,value
为一个数组,数组项为不一样版本的信息,方便后续处理,最终咱们须要将 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
字段,而且经过其计算出知足当前 requestedVersion
的 bestVersion
,这个过程称之为 computePackageInstances
。
相关类型定义以下:
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
能够分为三个步骤:
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);
复制代码
具体 version
的 satisfies
字段用于存储当前 installedVersion
知足的所有 requestedVersion
,初始值为 new Set()
,经过该 set
的 size
能够分析出知足 requestedVersion
数量最多的 installedVersion
,用于 fewer
策略。
补充 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;
复制代码
这样,咱们就找到了同一 package 不一样版本的 installedVersion
和所须要的 bestVersion
。
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 的到来。