深入浅出 Yarn 包管理
关于yarn
yarn
和 npm
一样也是 JavaScript
包管理工具,同样我们还发现有 cnpm
、 pnpm
等等包管理工具,包管理工具有一个就够了,为什么又会有这么多轮子出现呢?
为什么是yarn
?它和其它工具的区别在哪里?
Tip:这里对比的npm
是指npm2
版本
和npm
区别
yarn
在下载和安装依赖包采用的是多线程的方式,而npm
是单线程的方式执行,速度上就拉开了差距yarn
会在用户本地缓存已下载过的依赖包,优先会从缓存中读取依赖包,只有本地缓存不存在的情况才会采取远端请求的方式;反观npm
则是全量请求,速度上再次拉开差距yarn
把所有的依赖躺平至同级,有效的减少了相同依赖包重复下载的情况,加快了下载速度而且也减少了node_modules
的体积;反观npm
则是严格的根据依赖树下载并放置到对应位置,导致相同的包多次下载、node_modules
体积大的问题
和cnpm
区别
cnpm
国内镜像速度更快(其他工具也可以修改源地址)cnpm
将所有项目下载的包收拢在自己的缓存文件夹中,通过软链接把依赖包放到对应项目的node_modules
中
和pnpm
区别
- 和
yarn
一样有一个统一管理依赖包的目录 pnpm
保留了npm2
版本原有的依赖树结构,但是node_modules
下所有的依赖包都是通过软连接的方式保存
yarn
来认识yarn
第一步 - 下载
一个项目的依赖包需要有指定文件来说明,JavaScript
包管理工具使用 package.json
做依赖包说明的入口。
{
"dependencies": {
"lodash": "4.17.20"
}
}
以上面的 package.json
为例,我们可以直接识别 package.json
直接下载对应的包。
import fetch from 'node-fetch';
function fetchPackage(packageJson) {
const entries = Object.entries(packageJson.dependencies);
entries.forEach(async ([key, version]) => {
const url = `https://registry.`yarn`pkg.com/${key}/-/${key}-${version}.tgz`,
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Couldn't fetch package "${reference}"`);
}
return await response.buffer();
});
}
接下来我们再看看另外一种情况:
{
"dependencies": {
"lodash": "4.17.20",
"customer-package": "../../customer-package"
}
}
"customer-package": "../../customer-package"
在我们的代码中已经不能正常工作了。所以我们需要做代码的改造:
import fetch from 'node-fetch';
import fs from 'fs-extra';
function fetchPackage(packageJson) {
const entries = Object.entries(packageJson.dependencies);
entries.forEach(async ([key, version]) => {
// 文件路径解析直接复制文件
if ([`/`, `./`, `../`].some(prefix => version.startsWith(prefix))) {
return await fs.readFile(version);
}
// 非文件路径直接请求远端地址
// ...old code
});
}
第二步 - 灵活匹配规则
目前我们的代码可以正常的下载固定版本的依赖包、文件路径。但是例如:"react": "^15.6.0"
这种情况我们是不支持的,而且我们可以知道这个表达式代表了从 15.6.0
版本到 15.7.0
内所有的包版本。理论上我们应该安装在这个范围中最新版本的包,所以我们增加一个新的方法:
import semver from 'semver';
async function getPinnedReference(name, version) {
// 首先要验证版本号是否符合规范
if (semver.validRange(version) && !semver.valid(version)) {
// 获取依赖包所有版本号
const response = await fetch(`https://registry.`yarn`pkg.com/${name}`);
const info = await response.json();
const versions = Object.keys(info.versions);
// 匹配符合规范最新的版本号
const maxSatisfying = semver.maxSatisfying(versions, reference);
if (maxSatisfying === null)
throw new Error(
`Couldn't find a version matching "${version}" for package "${name}"`
);
reference = maxSatisfying;
}
return { name, reference };
}
function fetchPackage(packageJson) {
const entries = Object.entries(packageJson.dependencies);
entries.forEach(async ([name, version]) => {
// 文件路径解析直接复制文件
// ...old code
let realVersion = version;
// 如果版本号以 ~ 和 ^ 开头则获取最新版本的包
if (version.startsWith('~') || version.startsWith('^')) {
const { reference } = getPinnedReference(name, version);
realVersion = reference;
}
// 非文件路径直接请求远端地址
// ...old code
});
}
那么这样我们就可以支持用户指定某个包在一个依赖范围内可以安装最新的包。
第三步 - 依赖包还有依赖包
现实远远没有我们想的那么简单,我们的依赖包还有自己的依赖包,所以我们还需要递归每一层依赖包把所有的依赖包都下载下来。
// 获取依赖包的dependencies
async function getPackageDependencies(packageJson) {
const packageBuffer = await fetchPackage(packageJson);
// 读取依赖包的`package.json`
const packageJson = await readPackageJsonFromArchive(packageBuffer);
const dependencies = packageJson.dependencies || {};
return Object.keys(dependencies).map(name => {
return { name, version: dependencies[name] };
});
}
现在我们可以通过用户项目的 package.json
获取整个依赖树上所有的依赖包。
第四步 - 转移文件
可以下载依赖包还不够的,我们要把文件都转移到指定的文件目录下,就是我们熟悉的node_modules
里。
async function linkPackages({ name, reference, dependencies }, cwd) {
// 获取整个依赖树
const dependencyTree = await getPackageDependencyTree({
name,
reference,
dependencies,
});
await Promise.all(
dependencyTree.map(async dependency => {
await linkPackages(dependency, `${cwd}/`node_modules`/${dependency.name}`);
})
);
}
第五步 - 优化
我们虽然可以根据整个依赖树下载全部的依赖包并放到了node_modules
里,但是我们发现依赖包可能会有重复依赖的情况,导致我们实际下载的依赖包非常冗余,所以我们可以把相同依赖包放到一个位置,这样就不需要重复下载。
function optimizePackageTree({ name, reference, dependencies = [] }) {
dependencies = dependencies.map(dependency => {
return optimizePackageTree(dependency);
});
for (let hardDependency of dependencies) {
for (let subDependency of hardDependency.dependencies)) {
// 子级依赖是否和父级依赖存在相同依赖
let availableDependency = dependencies.find(dependency => {
return dependency.name === subDependency.name;
});
if (!availableDependency) {
// 父级依赖不存在时,把依赖插入到父级依赖
dependencies.push(subDependency);
}
if (
!availableDependency ||
availableDependency.reference === subDependency.reference
) {
// 从子级依赖中剔除相同的依赖包
hardDependency.dependencies.splice(
hardDependency.dependencies.findIndex(dependency => {
return dependency.name === subDependency.name;
})
);
}
}
}
return { name, reference, dependencies };
}
我们通过逐级递归一层层将依赖从层层依赖展平,减少了重复的依赖包安装。截止到这一步我们已经实现了简易的yarn了~
yarn体系架构看完代码后给我最直观的就是yarn
把面向对象的思想发挥的淋漓尽致
- Config:
yarn
相关配置实例 - cli:全部
yarn
命令集合实例 - registries:
npm
源相关信息实例- 涉及 lock 文件、解析依赖包入口文件名、依赖包存储位置和文件名等
- lockfile:
yarn.lock
对象 - intergrity checker:用于检查依赖包下载是否正确
- package resolver:用于解析
package.json
依赖包不同引用方式- package request:依赖包版本请求实例
- package reference:依赖包关系实例
- package fetcher:依赖包下载实例
- package linker:依赖包文件管理
- package hoister:依赖包扁平化实例
yarn
工作流程流程概要
这里我们已yarn add lodash
为例,看看一下yarn
都在内部做了哪些事情。yarn
在安装依赖包时会分为主要 5 个步骤:
- checking:检查配置项(
.yarnrc
、命令行参数、package.json
信息等)、兼容性(cpu、nodejs 版本、操作系统等)是否符合约定 - resolveStep:解析依赖包信息,并且会解析出整个依赖树上所有包的具体版本信息
- fetchStep:下载全部依赖包,如果依赖包已经在缓存中存在则跳过下载,反之则下载对应依赖包到缓存文件夹内,当这一步都完成后代表着所有依赖包都已经存在缓存中了
- linkStep:将缓存的依赖包扁平化的复制副本到项目的依赖目录下
- buildStep:对于一些二进制包,需要进行编译,在这一步进行
流程讲解
我们继续以yarn add lodash
为例
初始化
查找yarnrc
文件
// 获取`yarn`rc文件配置
// process.cwd 当前执行命令项目目录
// process.argv 用户指定的`yarn`命令和参数
const rc = getRcConfigForCwd(process.cwd(), process.argv.slice(2));
/**
* 生成Rc文件可能存在的所有路经
* @param {*} name rc源名
* @param {*} cwd 当前项目路经
*/
function getRcPaths(name: string, cwd: string): Array<string> {
// ......other code
if (!isWin) {
// 非windows环境从/etc/`yarn`/config开始查找
pushConfigPath(etc, name, 'config');
// 非windows环境从/etc/`yarn`rc开始查找
pushConfigPath(etc, `${name}rc`);
}
// 存在用户目录
if (home) {
// `yarn`默认配置路经
pushConfigPath(CONFIG_DIRECTORY);
// 用户目录/.config/${name}/config
pushConfigPath(home, '.config', name, 'config');
// 用户目录/.config/${name}/config
pushConfigPath(home, '.config', name);
// 用户目录/.${name}/config
pushConfigPath(home, `.${name}`, 'config');
// 用户目录/.${name}rc
pushConfigPath(home, `.${name}rc`);
}
// 逐层向父级遍历加入.${name}rc路经
// Tip: 用户主动写的rc文件优先级最高
while (true) {
// 插入 - 当前项目路经/.${name}rc
unshiftConfigPath(cwd, `.${name}rc`);
// 获取当前项目的父级路经
const upperCwd = path.dirname(cwd);
if (upperCwd === cwd) {
// we've reached the root
break;
} else {
cwd = upperCwd;
}
}
// ......read rc code
}
解析用户输入的指令
/**
* -- 索引位置
*/
const doubleDashIndex = process.argv.findIndex(element => element === '--');
/**
* 前两个参数为node地址、`yarn`文件地址
*/
const startArgs = process.argv.slice(0, 2);
/**
* `yarn`子命令&参数
* 如果存在 -- 则取 -- 之前部分
* 如果不存在 -- 则取全部
*/
const args = process.argv.slice(2, doubleDashIndex === -1 ? process.argv.length : doubleDashIndex);
/**
* `yarn`子命令透传参数
*/
const endArgs = doubleDashIndex === -1 ? [] : process.argv.slice(doubleDashIndex);
初始化共用实例
在初始化的时候,会分别初始化 config
配置项、reporter
日志。
- config 会在 init 时,逐步向父级递归查询
package.json
是否配置了workspace
字段- Tip:如果当前是
workspace
项目则yarn.lock
是以workspac
e 根目录的yarn.lock
为准
- Tip:如果当前是
this.workspaceRootFolder = await this.findWorkspaceRoot(this.cwd);
// `yarn`.lock所在目录,优先和workspace同级
this.`lockfile`Folder = this.workspaceRootFolder || this.cwd;
/**
* 查找workspace根目录
*/
async findWorkspaceRoot(initial: string): Promise<?string> {
let previous = null;
let current = path.normalize(initial);
if (!await fs.exists(current)) {
// 路经不存在报错
throw new MessageError(this.reporter.lang('folderMissing', current));
}
// 循环逐步向父级目录查找访问`package.json`\`yarn`.json是否配置workspace
// 如果任意层级配置了workspace,则返回该json所在的路经
do {
// 取出`package.json`\`yarn`.json
const manifest = await this.findManifest(current, true);
// 取出workspace配置
const ws = extractWorkspaces(manifest);
if (ws && ws.packages) {
const relativePath = path.relative(current, initial);
if (relativePath === '' || micromatch([relativePath], ws.packages).length > 0) {
return current;
} else {
return null;
}
}
previous = current;
current = path.dirname(current);
} while (current !== previous);
return null;
}
执行 add 指令
- 根据上一步得到的
yarn.lock
地址读取yarn.lock
文件。 - 根据
package.json
中的生命周期执行对应script
脚本
/**
* 按照`package.json`的script配置的生命周期顺序执行
*/
export async function wrapLifecycle(config: Config, flags: Object, factory: () => Promise<void>): Promise<void> {
// 执行preinstall
await config.executeLifecycleScript('preinstall');
// 真正执行安装操作
await factory();
// 执行install
await config.executeLifecycleScript('install');
// 执行postinstall
await config.executeLifecycleScript('postinstall');
if (!config.production) {
// 非production环境
if (!config.disablePrepublish) {
// 执行prepublish
await config.executeLifecycleScript('prepublish');
}
// 执行prepare
await config.executeLifecycleScript('prepare');
}
}
获取项目依赖
- 首先获取当前目录下
package.json
的dependencies
、devDependencies
、optionalDependencies
内所有依赖包名+版本号- 因为当前为
workspace
项目,还需要读取workspace
项目中所有子项目的package.json
的相关依赖 - 如果当前是
workspace
项目则读取的为项目根目录的package.json
- 因为当前为
// 获取当前项目目录下所有依赖
pushDeps('dependencies', projectManifestJson, {hint: null, optional: false}, true);
pushDeps('devDependencies', projectManifestJson, {hint: 'dev', optional: false}, !this.config.production);
pushDeps('optionalDependencies', projectManifestJson, {hint: 'optional', optional: true}, true);
// 当前为workspace项目
if (this.config.workspaceRootFolder) {
// 收集workspace下所有子项目的`package.json`
const workspaces = await this.config.resolveWorkspaces(workspacesRoot, workspaceManifestJson);
for (const workspaceName of Object.keys(workspaces)) {
// 子项目`package.json`
const workspaceManifest = workspaces[workspaceName].manifest;
// 将子项目放到根项目dependencies依赖中
workspaceDependencies[workspaceName] = workspaceManifest.version;
// 收集子项目依赖
if (this.flags.includeWorkspaceDeps) {
pushDeps('dependencies', workspaceManifest, {hint: null, optional: false}, true);
pushDeps('devDependencies', workspaceManifest, {hint: 'dev', optional: false}, !this.config.production);
pushDeps('optionalDependencies', workspaceManifest, {hint: 'optional', optional: true}, true);
}
}
}
resolveStep 获取依赖包
- 遍历首层依赖,调用
package resolver
的find
方法获取依赖包的版本信息,然后递归调用find
,查找每个依赖下的dependence
中依赖的版本信息。在解析包的同时使用一个Set(fetchingPatterns)
来保存已经解析和正在解析的package
。 - 在具体解析每个
package
时,首先会根据其name
和range
(版本范围)判断当前依赖包是否为被解析过(通过判断是否存在于上面维护的set
中,即可确定是否已经解析过) - 对于未解析过的包,首先尝试从
lockfile
中获取到精确的版本信息, 如果lockfile
中存在对于的 package 信息,获取后,标记成已解析。如果lockfile
中不存在该package
的信息,则向 registry 发起请求获取满足 range 的已知最高版本的package
信息,获取后将当前package
标记为已解析 - 对于已解析过的包,则将其放置到一个延迟队列
delayedResolveQueue
中先不处理 - 当依赖树的所有
package
都递归遍历完成后,再遍历delayedResolveQueue
,在已经解析过的包信息中,找到最合适的可用版本信息
结束后,我们就确定了依赖树中所有 package
的具体版本,以及该包地址等详细信息。
- 对第一层所有项目的依赖包获取最新的版本号(调用
package resolver
的init
方法)
/**
* 查找依赖包版本号
*/
async find(initialReq: DependencyRequestPattern): Promise<void> {
// 优先从缓存中读取
const req = this.resolveToResolution(initialReq);
if (!req) {
return;
}
// 依赖包请求实例
const request = new PackageRequest(req, this);
const fetchKey = `${req.registry}:${req.pattern}:${String(req.optional)}`;
// 判断当前是否请求过相同依赖包
const initialFetch = !this.fetchingPatterns.has(fetchKey);
// 是否更新`yarn`.lock标志
let fresh = false;
if (initialFetch) {
// 首次请求,添加缓存
this.fetchingPatterns.add(fetchKey);
// 获取依赖包名+版本在`lockfile`的内容
const `lockfile`Entry = this.`lockfile`.getLocked(req.pattern);
if (`lockfile`Entry) {
// 存在`lockfile`的内容
// 取出依赖版本
// eq: concat-stream@^1.5.0 => { name: 'concat-stream', range: '^1.5.0', hasVersion: true }
const {range, hasVersion} = normalizePattern(req.pattern);
if (this.is`lockfile`EntryOutdated(`lockfile`Entry.version, range, hasVersion)) {
// `yarn`.lock版本落后
this.reporter.warn(this.reporter.lang('incorrect`lockfile`Entry', req.pattern));
// 删除已收集的依赖版本号
this.removePattern(req.pattern);
// 删除`yarn`.lock中对包版本的信息(已经过时无效了)
this.`lockfile`.removePattern(req.pattern);
fresh = true;
}
} else {
fresh = true;
}
request.init();
}
await request.find({fresh, frozen: this.frozen});
}
- 对于请求的依赖包做递归依赖查询相关信息
for (const depName in info.dependencies) {
const depPattern = depName + '@' + info.dependencies[depName];
deps.push(depPattern);
promises.push(
this.resolver.find(......),
);
}
for (const depName in info.optionalDependencies) {
const depPattern = depName + '@' + info.optionalDependencies[depName];
deps.push(depPattern);
promises.push(
this.resolver.find(.......),
);
}
if (remote.type === 'workspace' && !this.config.production) {
// workspaces support dev dependencies
for (const depName in info.devDependencies) {
const depPattern = depName + '@' + info.devDependencies[depName];
deps.push(depPattern);
promises.push(
this.resolver.find(.....),
);
}
}
fetchStep 下载依赖包
这里主要是对缓存中没有的依赖包进行下载。
- 已经在缓存中的依赖包,是不需要重新下载的,所以第一步先过滤掉本地缓存中已经存在的依赖包。过滤过程是根据
cacheFolder+slug+node_modules+pkg.name
生成一个path
,判断系统中是否存在该path
,如果存在,证明已经有缓存,不用重新下载,将它过滤掉。 - 维护一个
fetch
任务的queue
,根据resolveStep
中解析出的依赖包下载地址去依次获取依赖包。 - 在下载每个包的时候,首先会在缓存目录下创建其对应的缓存目录,然后对包的 reference 地址进行解析。
- 因为
reference
的地址多种情况,如:npm 源、github 源、gitlab 源、文件地址等,所以yarn
会根据reference
地址调用对应的fetcher
获取依赖包 - 将获取的
package
文件流通过fs.createWriteStream
写入到缓存目录下,缓存下来的是.tgz
压缩文件,再解压到当前目录下 - 下载解压完成后,更新
lockfile
文件
/**
* 拼接缓存依赖包路径
* 缓存路径 + `npm`源-包名-版本-integrity + `node_modules` + 包名
*/
const dest = config.generateModuleCachePath(ref);
export async function fetchOneRemote(
remote: PackageRemote,
name: string,
version: string,
dest: string,
config: Config,
): Promise<FetchedMetadata> {
if (remote.type === 'link') {
const mockPkg: Manifest = {_uid: '', name: '', version: '0.0.0'};
return Promise.resolve({resolved: null, hash: '', dest, package: mockPkg, cached: false});
}
const Fetcher = fetchers[remote.type];
if (!Fetcher) {
throw new MessageError(config.reporter.lang('unknownFetcherFor', remote.type));
}
const fetcher = new Fetcher(dest, remote, config);
// 根据传入的地址判断文件是否存在
if (await config.isValidModuleDest(dest)) {
return fetchCache(dest, fetcher, config, remote);
}
// 删除对应路径的文件
await fs.unlink(dest);
try {
return await fetcher.fetch({
name,
version,
});
} catch (err) {
try {
await fs.unlink(dest);
} catch (err2) {
// what do?
}
throw err;
}
}
linkStep 移动文件
经过fetchStep
后,我们本地缓存中已经有了所有的依赖包,接下来就是如何将这些依赖包复制到我们项目中的node_modules
下。
- 在复制包之前,会先解析
peerDependences
,如果找不到匹配的peerDependences
,进行warning
提示 - 之后对依赖树进行扁平化处理,生成要拷贝到的目标目录
dest
- 对扁平化后的目标
dest
进行排序(使用localeCompare
本地排序规则) - 根据 flatTree 中的
dest
(要拷贝到的目标目录地址),src
(包的对应cache
目录地址)中,执行将copy
任务,将package
从src
拷贝到dest
下
yarn
对于扁平化其实非常简单粗暴,先按照依赖包名的 Unicode 做排序,然后根据依赖树逐层扁平化
1.如何增加网络请求并发数量?
可以增加网络请求并发量:--network-concurrency <number>
2.网络请求总超时怎么办?
可以设置网络请求超时时长:--network-timeout <milliseconds>
3.为什么我修改了yarn.lock
中某个依赖包的版本号还是不可以?
"@babel/code-frame@^7.0.0-beta.35":
version "7.0.0-beta.55"
resolved "https://registry.`yarn`pkg.com/@babel/code-frame/-/code-frame-7.0.0-beta.55.tgz#71f530e7b010af5eb7a7df7752f78921dd57e9ee"
integrity sha1-cfUw57AQr163p993UveJId1X6e4=
dependencies:
"@babel/highlight" "7.0.0-beta.55"
我们随机截取了一段yarn.lock
的代码,如果只修改 version
和 resolved
字段是不够的,因为yarn
还会根据实际下载的内容生成的 integrity
和yarn.lock
文件的 integrity
字段做对比,如果不一致就代表本次下载是错误的依赖包。
4.在项目依赖中出现了同依赖包不同版本的情况,我要如何知道实际使用的是哪一个包?
首先我们要看是如何引用依赖包的。前置场景:
package.json
中依赖A@1.0.0
,B@1.0.0
,C@1.0.0
D@2.0.0
依赖C2.0.0
D@1.0.0
依赖C@1.0.0
A@1.0.0
依赖D@1.0.0
B@1.0.0
依赖D@2.0.0
首先我们根据当前依赖关系和yarn
安装特性可以知道实际安装结构为:
|- A@1.0.0
|- B@1.0.0
|--- D@2.0.0
|----- C@2.0.0
|- C@1.0.0
|- D@1.0.0
- 开发同学直接代码引用
D
实际为D@1.0.0
B
代码中未直接声明依赖C
,但是却直接引用了C
相关对象方法(因为B
直接引用了D
,且D
一定会引用C
,所以C
肯定存在)。此时实际引用非C@2.0.0
,而是引用的C@1.0.0
。- 因为
webpack
查询依赖包是访问node_modules
下符合规则的依赖包,所以直接引用了C@1.0.0
- 因为
我们可以通过yarn list
来检查是否存在问题。
参考资料
[1]yarn官网: https://www.yarnpkg.cn/
[2]我 fork 的yarn源码加了部分中文注释: https://github.com/supergaojian/%60yarn%60
[3]从源码角度分析yarn安装依赖的过程: https://jishuin.proginn.com/p/763bfbd29d7e