深入理解js包管理工具yarn

661 阅读7分钟

关于yarn

yarnnpm一样也是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.yarnpkg.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.yarnpkg.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把面向对象的思想发挥的淋漓尽致

  • Configyarn相关配置实例
  • cli:全部yarn命令集合实例
  • registries:npm源相关信息实例
    • 涉及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信息等)、兼容性(cpunodejs版本、操作系统等)是否符合package.json中的约定
  • resolveStep:通过解析项目package.json的依赖形成一颗依赖树,并且会解析出整个依赖树上所有包的具体版本信息
  • fetchStep:下载全部依赖包,如果依赖包已经在缓存中存在则跳过下载,反之则下载对应依赖包到缓存文件夹内,当这一步都完成后代表着所有依赖包都已经存在缓存中了
  • linkStep:将缓存的依赖包(因为上一步下载的包都是在缓存中)扁平化的复制副本到项目的依赖目录下
  • buildStep:对于一些二进制包,需要进行编译,在这一步进行

流程讲解

我们继续以yarn add lodash为例

初始化

查找yarnrc文件

// 获取yarnrc文件配置
// 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/yarnrc开始查找
    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.lockfileFolder = 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的相关依赖
  • 通过config的fetchRequestFromCwd方法取出第一层的全部依赖
// 获取当前项目目录下所有依赖
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 resolverfind方法通过package request获取依赖包信息,获取到信息后递归调用find方法,查找每个依赖包的dependenciesoptionalDependecncies中依赖包信息。在解析包的同时使用一个fetchingPatternsSet<string>来保存已经解析和正在解析的依赖包,减少重复请求操作。
    • 在具体解析每个依赖包时,首先会根据依赖包名+版本范围判断当前是否已经解析过(即fetchingPatterns中是否存在相同字符串)
    • 对于未解析过的包,首先会从lockfile中获取到精确的版本信息, 如果lockfile中存在对应依赖包的信息
      • 判断lockfile中对应版本是否已经过时,如果过时则在lockfile中删除关于这条依赖包的相关信息
    • 如果lockfile中不存在该依赖包信息,则向npm源发起请求获取满足range的已知最高版本的依赖包信息
  • 对于已解析过的包,则将其放置到一个延迟队列delayedResolveQueue中先不处理
  • 当依赖树的所有依赖包都递归遍历完成后,再遍历delayedResolveQueue,在已经解析过的包信息中,找到符合可用版本信息的最高版本

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

  • 对第一层所有项目的依赖包获取最新的版本号(调用package resolverfind方法)
/**
 * 查找依赖包版本号
 */ 
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 lockfileEntry = this.lockfile.getLocked(req.pattern);
      if (lockfileEntry) {
        // 存在lockfile的内容
        // 取出依赖版本
        // eq: concat-stream@^1.5.0 => { name: 'concat-stream', range: '^1.5.0', hasVersion: true }
        const {range, hasVersion} = normalizePattern(req.pattern);
        if (this.isLockfileEntryOutdated(lockfileEntry.version, range, hasVersion)) {
          // yarn.lock版本落后
          this.reporter.warn(this.reporter.lang('incorrectLockfileEntry', 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 下载依赖包

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

  • 首先创建一个用于去重的Map<string, PackageReference>。遍历全部依赖包数组,每一个依赖包拼接专属的缓存目录地址dest缓存路径 + npm源-包名-版本-integrity + node_modules + 包名,通过dest做去重操作。
  • 拿到去重后的全部依赖包之后,会先判断的每一个包对应的dest缓存目录是否存在
    • 如果存在则直接从缓存中读文件(根据依赖包不同引用方式作区分)
    • 如果不存在,则根据依赖包不同引用方式去下载依赖包
  • 因为package reference的地址多种情况,如:npm源github源gitlab源文件地址等,所以yarn会根据reference地址调用对应的fetcher获取依赖包
/**
 * 拼接缓存依赖包路径
 * 缓存路径 + 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提示
  • 之后对依赖树进行扁平化处理,生成要拷贝到的目标目录
  • 对扁平化后的目标进行排序
  • 根据flatTree中的dest(要拷贝到的目标目录地址),src(包的对应cache目录地址)中,执行将copy任务,将packagesrc拷贝到dest

根据上图分析,A、B、C是项目第一层依赖

  • 首先看A的依赖了D、C
    • 因为D未被提升过,则D被提升
    • 因为项目已经依赖了C,所以A依赖的C不能提升,否则会有冲突
    • 因为D依赖了B,但是项目已经依赖了B,所以B不能提升
    • 因为B依赖了F,但是F未被提升过,所以F被提升
  • 接下来看B的依赖了E
    • 因为E未被提升过,则E被提升 yarn对于扁平化其实非常简单粗暴,先按照依赖包名的Unicode做排序,然后根据依赖树逐层扁平化

Q&A

  • 如何增加网络请求并发数量 可以增加网络请求并发量:--network-concurrency <number>

  • 网络请求总超时怎么办? 可以设置网络请求超时时长:--network-timeout <milliseconds>

  • 为什么我修改了yarn.lock中某个依赖包的版本号还是不可以?

"@babel/code-frame@^7.0.0-beta.35": 
  version "7.0.0-beta.55" 
  resolved "https://registry.yarnpkg.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字段做对比,如果不一致就代表本次下载是错误的依赖包。

  • 在项目依赖中出现了同依赖包不同版本的情况,我要如何知道实际使用的是哪一个包? 首先我们要看是如何引用依赖包的。

前置场景:

  • package.json中依赖A@1.0.0,B@1.0.0,C@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来检查是否存在问题。

文章参考