深入浅出 Yarn 包管理

335次阅读  |  发布于3年以前

关于yarn

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

为什么是yarn?它和其它工具的区别在哪里?

Tip:这里对比的npm是指npm2 版本

npm区别

cnpm区别

pnpm区别

从做一个简单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把面向对象的思想发挥的淋漓尽致

yarn工作流程

流程概要

这里我们已yarn add lodash 为例,看看一下yarn都在内部做了哪些事情。yarn在安装依赖包时会分为主要 5 个步骤:

流程讲解

我们继续以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 日志。

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 指令

/**
 * 按照`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');
  }
}

获取项目依赖

// 获取当前项目目录下所有依赖
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 的具体版本,以及该包地址等详细信息。

/**
 * 查找依赖包版本号
 */
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.在项目依赖中出现了同依赖包不同版本的情况,我要如何知道实际使用的是哪一个包?

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

首先我们根据当前依赖关系和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

我们可以通过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

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8