🙆‍♂️深入浅出 Yarn 包管理

avatar
前端工程师 @公众号:ELab团队

关于yarn

yarn  和 npm  一样也是 JavaScript  包管理工具,同样我们还发现有 cnpmpnpm  等等包管理工具,包管理工具有一个就够了,为什么又会有这么多轮子出现呢?

为什么是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把面向对象的思想发挥的淋漓尽致

  • Configyarn相关配置实例
  • cli:全部yarn命令集合实例
  • registriesnpm源相关信息实例
    • 涉及 lock 文件、解析依赖包入口文件名、依赖包存储位置和文件名等
  • lockfileyarn.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 是以 workspace 根目录的yarn.lock 为准
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.jsondependenciesdevDependenciesoptionalDependencies 内所有依赖包名+版本号
    • 如果当前是 workspace 项目则读取的为项目根目录的 package.json
      • 因为当前为 workspace 项目,还需要读取 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 获取依赖包

  1. 遍历首层依赖,调用 package resolverfind 方法获取依赖包的版本信息,然后递归调用 find,查找每个依赖下的 dependence 中依赖的版本信息。在解析包的同时使用一个 Set(fetchingPatterns)来保存已经解析和正在解析的 package
  2. 在具体解析每个 package 时,首先会根据其 namerange(版本范围)判断当前依赖包是否为被解析过(通过判断是否存在于上面维护的 set 中,即可确定是否已经解析过)
  3. 对于未解析过的包,首先尝试从 lockfile 中获取到精确的版本信息, 如果 lockfile 中存在对于的 package 信息,获取后,标记成已解析。如果 lockfile 中不存在该 package 的信息,则向 registry 发起请求获取满足 range 的已知最高版本的 package 信息,获取后将当前 package 标记为已解析
  4. 对于已解析过的包,则将其放置到一个延迟队列 delayedResolveQueue 中先不处理
  5. 当依赖树的所有 package 都递归遍历完成后,再遍历 delayedResolveQueue,在已经解析过的包信息中,找到最合适的可用版本信息

结束后,我们就确定了依赖树中所有 package 的具体版本,以及该包地址等详细信息。

  • 对第一层所有项目的依赖包获取最新的版本号(调用 package resolverinit 方法)
/**
 * 查找依赖包版本号
 */
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 下载依赖包

这里主要是对缓存中没有的依赖包进行下载。

  1. 已经在缓存中的依赖包,是不需要重新下载的,所以第一步先过滤掉本地缓存中已经存在的依赖包。过滤过程是根据 cacheFolder+slug+node_modules+pkg.name 生成一个 path,判断系统中是否存在该 path,如果存在,证明已经有缓存,不用重新下载,将它过滤掉。
  2. 维护一个 fetch 任务的 queue,根据**resolveStep**中解析出的依赖包下载地址去依次获取依赖包。
  3. 在下载每个包的时候,首先会在缓存目录下创建其对应的缓存目录,然后对包的 reference 地址进行解析。
  4. 因为 reference 的地址多种情况,如:npm 源、github 源、gitlab 源、文件地址等,所以yarn会根据 reference 地址调用对应的 fetcher 获取依赖包
  5. 将获取的 package 文件流通过 fs.createWriteStream写入到缓存目录下,缓存下来的是.tgz 压缩文件,再解压到当前目录下
  6. 下载解压完成后,更新 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下。

  1. 在复制包之前,会先解析 peerDependences,如果找不到匹配的 peerDependences,进行 warning 提示
  2. 之后对依赖树进行扁平化处理,生成要拷贝到的目标目录 dest
  3. 对扁平化后的目标 dest 进行排序(使用 localeCompare 本地排序规则)
  4. 根据 flatTree 中的 dest(要拷贝到的目标目录地址),src(包的对应 cache 目录地址)中,执行将 copy 任务,将 packagesrc 拷贝到 dest

yarn对于扁平化其实非常简单粗暴,先按照依赖包名的 Unicode 做排序,然后根据依赖树逐层扁平化

Q&A

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 的代码,如果只修改 versionresolved 字段是不够的,因为yarn还会根据实际下载的内容生成的 integrityyarn.lock 文件的 integrity 字段做对比,如果不一致就代表本次下载是错误的依赖包。

4.在项目依赖中出现了同依赖包不同版本的情况,我要如何知道实际使用的是哪一个包?

首先我们要看是如何引用依赖包的。 前置场景:

  • package.json 中依赖A@1.0.0B@1.0.0C@1.0.0
    • A@1.0.0依赖D@1.0.0
      • D@1.0.0依赖C@1.0.0
    • B@1.0.0依赖D@2.0.0
      • D@2.0.0依赖 C2.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. 喜欢的话别忘了 分享、点赞、收藏 三连哦~。