重要说明:此文档涵盖 Yarn 1(经典版)。
有关 Yarn 2+ 文档和迁移指南,请参阅 yarnpkg.com。

Let's Dev: 软件包管理器

2017 年 7 月 11 日,Maël Nison 发布

大家好!今天,我们将编写一个新的软件包管理器,它比 Yarn 更出色!好吧,也许没有,但至少我们可以从中获得乐趣,了解软件包管理器的运作方式,并考虑在 Yarn 中接下来会发生什么。

魔鬼藏在细节中

为了简洁起见,本文省略了一些小的细节和环境异常,并专注于软件包管理器的宏观架构。例如,我们将假设所有路径都是常规 POSIX 路径。

话虽这么说,但关于这些兼容性层还有很多话要说,而且讨论它们可能是一个有趣的后续话题!如果您有兴趣进一步了解它们,欢迎在@yarnpkg 上发推文!😃

为了全面了解事物的运作方式,我们将逐步、递增地进行,一次添加或扩展一个函数。我们将把每一步视为一个单独的章节,您可以在本段下找到所有章节的索引。别担心 - 它们都很短!请注意,本文将通篇使用 ES2017 特性 - 如果您不熟悉它们,我们建议您阅读以下几本好书:《探索 ES6》和/或《理解 ECMAScript 6》,以及《探索 ES2017》。祝您学习愉快!


  • # 第 1 章 - 英勇下载

    或者:我们下载软件包 tarball 的地方

  • # 第 2 章 - 一个引用统治一切

    或者:我们解析软件包范围的地方

  • # 第 3 章 - 我们的依赖项的依赖项是我们的依赖项

    或者:我们从软件包中提取依赖项的地方

  • # 第 4 章 - 超级依赖项世界

    或者:我们以递归方式执行相同操作的地方

  • # 第 5 章 - 唤醒链接

    或者:我们安装依赖项在文件系统上的地方

  • # 第 6 章 - 优化之王

    或者:我们尽量不将整个世界安装到我们的系统中

  • # 结论 - 确实有一个蛋糕

    或者:我们反思我们学到的知识


第 1 章 - 英勇下载

那么,我们从哪里开始?首先,我们必须考虑软件包管理器是什么。让我们忘记缓存、镜像、锁文件和所有漂亮的命令行内容,让我们专注于核心:软件包管理器是一个下载管理器。您要求它下载一个软件包,它便会愉快地执行。我们从打造一个非常基本的函数开始,该函数仅从互联网下载一些内容。

import fetch from 'node-fetch';

async function fetchPackage(reference) {
  let response = await fetch(reference);

  if (!response.ok) throw new Error(`Couldn't fetch package "${reference}"`);

  return await response.buffer();
}

干得漂亮!我们只需要给此函数提供一个 URL,我们最终将获得引用的软件包!当然,只有当您知道软件包的确切 URL 时,此方法才有效,但这是一个好的开始。罗马不是一天建成的,我们的软件包管理器也不会仅借助单一函数就完成。

好的,接下来是什么?让我们休息一下,看看一个经典的 package.json 文件,以了解我们可以实现什么。

{
    "dependencies": {
        "react": "^15.5.4",
        "babel-core": "6.25.0"
    }
}

哦,对了,还有版本范围!如果我们能够将版本号传递给我们的抓取工具,并让它将其转换为 URL,那就太好了,是不是?那我们就这么做吧!为了使它更简单,我们只添加对固定引用的支持(即支持 1.0.0,但不支持 ^1.0.0)。找到正确的正则表达式可能会很繁琐,但谢天谢地,我们可以依赖出色的 semver 模块,它将为我们处理大部分工作!话虽这么说,我们仍然需要对 fetchPackage 函数的签名进行小小的更改。我们现在使用 {name, reference} 对象来描述包,而不是使用字符串。其中,name 是包名称,reference 是一个可以让我们明确找到此包的标识符。因为这一更改,现在我们可以编写

import semver from 'semver';

async function fetchPackage({ name, reference }) {
  if (semver.valid(reference))
    return await fetchPackage({
      name,
      reference: `https://registry.yarnpkg.com/${name}/-/${name}-${reference}.tgz`,
    });

  // ... same code as before
}

你觉得怎么样?如果我们检测到该引用是一个 semver 版本,那么我们就可以将其转换为 Yarn 注册表中的实际 URL。我们现在拥有的是一个很棒的下载管理器,是不是?好吧,在今天结束之前,让我们快速添加对文件系统路径的支持

import fs from 'fs-extra';

async function fetchPackage({ name, reference }) {
  // In a pure JS fashion, if it looks like a path, it must be a path.
  if ([`/`, `./`, `../`].some(prefix => reference.startsWith(prefix)))
    return await fs.readFile(reference);

  // ... same code as before
}

你觉得怎么样?很简单,对吧?


第二章——一处引用,统揽全局

我们的 fetchPackage 函数非常棒,但是它有一个缺点,而且是一个很大的缺点:就像我们所说的,我们的函数当前只可以提供固定引用。诸如 ^1.0.0 的范围无法得到提供,因为它们可能引用了多个不同的版本,每个版本都有自己的 tarball。因此,为了提供它们,我们需要找到一种方法从这些范围中提取唯一的固定引用。幸运的是,这并不难!请看下面

import semver from 'semver';

async function getPinnedReference({ name, reference }) {
  // 1.0.0 is a valid range per semver syntax, but since it's also a pinned
  // reference, we don't actually need to process it. Less work, yeay!~
  if (semver.validRange(reference) && !semver.valid(reference)) {
    let response = await fetch(`https://registry.yarnpkg.com/${name}`);
    let info = await response.json();

    let versions = Object.keys(info.versions);
    let maxSatisfying = semver.maxSatisfying(versions, reference);

    if (maxSatisfying === null)
      throw new Error(
        `Couldn't find a version matching "${reference}" for package "${name}"`
      );

    reference = maxSatisfying;
  }

  return { name, reference };
}

// getPinnedReference({name: "react", reference: "~15.3.0"})
//     → {name: "react", reference: "15.3.2"}

// getPinnedReference({name: "react", reference: "15.3.0"})
//     → {name: "react", reference: "15.3.0"}

// getPinnedReference({name: "react", reference: "/tmp/react-15.3.2.tar.gz"})
//     → {name: "react", reference: "/tmp/react-15.3.2.tar.gz"}

然后……就是这样了!如果我们看到一个 semver 范围,我们只需查询 NPM 注册表来检索所有可用版本列表。一旦获得它,剩下的就是选择最佳版本(这很容易,因为 semver 模块提供了 maxSatisfying 函数),然后一切就绪。

请注意,对于 semver 版本、直接 URL 或文件系统路径,我们不需要做任何特定的事情,因为它们在任何给定的时间始终只引用一个包。因此,当遇到它们时,我们无需做任何花哨的事情,只需将它们返回即可。

借助此函数,我们现在可以确信发送到 fetchPackage 函数的引用将始终是固定引用!新的一天,是我们取得的又一个重大胜利。


第三章——我们的依赖项的依赖项也是我们的依赖项

在第一章中,我们了解了如何制作一个神奇的函数,这个函数可以从任何地方下载任何包并返回它。在第二章中,我们了解了如何将不稳定的依赖项转换为固定依赖项。这是一个不错的开始!但现在,我们需要解决更大的问题:依赖项。看,由于 Node 生态系统的特殊性,大多数包都依赖于其他包才能正常工作。幸运的是,它们都同意使用一个标准来列出这些依赖项(请记住我们在上面看到的 package.json 文件),因此我们应该能够好好利用它。写下我们的函数。给定一个包,我们希望它返回此包所依赖的依赖项。

无法摆脱工具

即使本文试图将重点放在包管理器的核心原理上,我们还是需要时不时使用一些实用函数。当你遇到从 ./utilities 导入的符号时,不必费心去了解它究竟是如何工作的。它通常是一些繁琐而冗长的代码。话虽如此,本文末尾链接的存储库中提供了所有源,包括实用程序,因此,如果你真的有兴趣,请稍后了解一下!

// This function reads a file stored within an archive
import { readPackageJsonFromArchive } from './utilities';

async function getPackageDependencies({ name, reference }) {
  let packageBuffer = await fetchPackage({ name, reference });
  let packageJson = JSON.parse(await readPackageJsonFromArchive(packageBuffer));

  // Some packages have no dependency field
  let dependencies = packageJson.dependencies || {};

  // It's much easier for us to just keep using the same {name, reference}
  // data structure across all of our code, so we convert it there.
  return Object.keys(dependencies).map(name => {
    return { name, reference: dependencies[name] };
  });
}

// getPackageDependencies({name: "react", reference: "15.6.1"})
//     → [{name: "create-react-class", reference: "^15.6.0"},
//        {name: "prop-types", reference: "^15.5.10"}]

你觉得如何?我们甚至可以用我们自己的 fetchPackage 实现从存储包信息的存档中获取存档!从现在开始,无论人们发送怎样的包给我们,我们都能了解它依赖于哪些其他包。这是一个好的开始,但我们现在必须稍微扩展一下这个能力:不再只解决第一层依赖,而是要解决所有内容。而这就是下一章的内容!


第 4 章 - 超级依赖世界

我们该进行完整的递归了。请看,这个想法是,在把包安装到 node_modules 文件夹中之前,我们必须首先在内存中“安装”它们。你可能会问为什么?通过这种方式进行操作,可以在实际将树持久存在文件系统之前对其进行操作。无论是重复数据删除还是提升,都必须在树上而不是在实际磁盘上执行这些操作(否则会非常慢)。但我们会在另一章里讨论这些内容!现在,让我们专注于从单个 root 依赖项中提取一个完整的依赖项树。由于我们已经编写了所有必需的部分(首先是将临时引用转换为固定引用的函数,然后是获取包依赖项的函数),所以这会很快。让我们开始

async function getPackageDependencyTree({ name, reference, dependencies }) {
  return {
    name,
    reference,
    dependencies: await Promise.all(
      dependencies.map(async volatileDependency => {
        let pinnedDependency = await getPinnedReference(volatileDependency);
        let subDependencies = await getPackageDependencies(pinnedDependency);

        return await getPackageDependencyTree(
          Object.assign({}, pinnedDependency, { dependencies: subDependencies })
        );
      })
    ),
  };
}

这看起来可能难以消化,但请耐心读下去!我们从一个带其依赖项列表的包开始。然后,对于这些依赖项中的每一个,我们首先解决依赖项的引用,使其成为固定引用,然后获取它自己的依赖项,然后对这些子依赖项重复这个周期。最终,我们将有一个树状结构,每个包都将是一个包含其自身依赖项的节点!

为了使用这个函数,我们只需从位于本地工作目录中的 package.json 文件中读取初始依赖项 - 我们可以使用里面所有的内容!

import { resolve } from 'path';
import util from 'util';

// We'll use the first command line argument (argv[2]) as working directory,
// but if there's none we'll just use the directory from which we've executed
// the script
let cwd = process.argv[2] || process.cwd();
let packageJson = require(resolve(cwd, `package.json`));

// Remember that because we use a different format for our dependencies than
// a simple dictionary, we also need to convert it when reading this file
packageJson.dependencies = Object.keys(packageJson.dependencies || {}).map(
  name => {
    return { name, reference: packageJson.dependencies[name] };
  }
);

getPackageDependencyTree(packageJson).then(tree => {
  console.log(util.inspect(tree, { depth: Infinity }));
});

现在,让我们测试一下这个代码。尝试在包含以下 package.json 的目录中运行它

{
  "name": "my-awesome-package",
  "dependencies": {
    "tar-stream": "*"
  }
}

如果一切都按照计划进行,您应该获得以下结果(或类似结果,具体取决于自本文撰写以来是否升级了某个包)

未定义引用

您可能会在以下代码段中看到一个奇怪的引用: undefined。这实际上是正常的!这个引用用在 root 包中,以告知链接器(稍后将对此进行详细介绍)这个包有点特殊。在真实情况下,我们可能需要使用一种特殊类型的引用(例如 root:///path/to/package),但在本例中这没有必要。

{ name: "my-awesome-package",
  reference: undefined,
  dependencies:
   [ { name: 'tar-stream',
       reference: '1.5.4',
       dependencies:
        [ { name: 'bl',
            reference: '1.2.1',
            dependencies:
             [ { name: 'readable-stream',
                 reference: '2.2.11',
                 dependencies:
                  [ { name: 'core-util-is', reference: '1.0.2', dependencies: [] },
                    { name: 'inherits', reference: '2.0.3', dependencies: [] },
                    { name: 'isarray', reference: '1.0.0', dependencies: [] },
                    { name: 'process-nextick-args',
                      reference: '1.0.7',
                      dependencies: [] },
                    { name: 'safe-buffer', reference: '5.0.1', dependencies: [] },
                    { name: 'string_decoder',
                      reference: '1.0.2',
                      dependencies: [ { name: 'safe-buffer', reference: '5.0.1', dependencies: [] } ] },
                    { name: 'util-deprecate', reference: '1.0.2', dependencies: [] } ] } ] },
          { name: 'end-of-stream',
            reference: '1.4.0',
            dependencies:
             [ { name: 'once',
                 reference: '1.4.0',
                 dependencies: [ { name: 'wrappy', reference: '1.0.2', dependencies: [] } ] } ] },
          { name: 'readable-stream',
            reference: '2.2.11',
            dependencies:
             [ { name: 'core-util-is', reference: '1.0.2', dependencies: [] },
               { name: 'inherits', reference: '2.0.3', dependencies: [] },
               { name: 'isarray', reference: '1.0.0', dependencies: [] },
               { name: 'process-nextick-args',
                 reference: '1.0.7',
                 dependencies: [] },
               { name: 'safe-buffer', reference: '5.0.1', dependencies: [] },
               { name: 'string_decoder',
                 reference: '1.0.2',
                 dependencies: [ { name: 'safe-buffer', reference: '5.0.1', dependencies: [] } ] },
               { name: 'util-deprecate', reference: '1.0.2', dependencies: [] } ] },
          { name: 'xtend', reference: '4.0.1', dependencies: [] } ] } ] }

完美。现在,让我们尝试在更大的包上运行它。我们尝试一下 babel-core!使用以下 package.json 文件

{
    "dependencies": {
        "babel-core": "*"
    }
}

别担心,我会耐心等待的。

… 仍然在等待。

… 还在… 等一下,这个脚本还在运行吗?这样不对吧?

此时,我们可以放心地假设我们的代码中出了点问题 - Babel 没有那么大,而且执行过程应该早就停止了。为了更好地了解发生了什么,打开 Yarnpkg 上的babel-core页面,并查看其依赖项。您应该看到 babel-register。不错。现在,打开 Yarnpkg 上的babel-register页面,并查看其自身依赖项。您应该看到… 是的。Babel-core。您现在可以猜出发生了什么了吧?由于循环依赖,我们对 babel-core、babel-register、babel-core 等进行迭代… 最终,我们的代码将耗尽太多 RAM,并会被操作系统终止。这真的很糟糕。

幸运的是,修复相当容易!记住,在 Node 中,node_modules 目录可以嵌套。如果无法在当前目录的 node_modules 中找到某个包,Node 将尝试在父目录的 node_modules,然后是其祖先 node_modules 等中查找,直到找到满足条件的匹配项。我们不妨利用这一点

// Look, we've added an extra optional parameter! ---------------------------------v
async function getPackageDependencyTree(
  { name, reference, dependencies },
  available = new Map()
) {
  return {
    name,
    reference,
    dependencies: await Promise.all(
      dependencies
        .filter(volatileDependency => {
          let availableReference = available.get(volatileDependency.name);

          // If the volatile reference exactly matches the available reference (for
          // example in the case of two URLs, or two file paths), it means that it
          // is already satisfied by the package provided by its parent. In such a
          // case, we can safely ignore this dependency!
          if (volatileDependency.reference === availableReference) return false;

          // If the volatile dependency is a semver range, and if the package
          // provided by its parent satisfies it, we can also safely ignore the
          // dependency.
          if (
            semver.validRange(volatileDependency.reference) &&
            semver.satisfies(availableReference, volatileDependency.reference)
          )
            return false;

          return true;
        })
        .map(async volatileDependency => {
          let pinnedDependency = await getPinnedReference(volatileDependency);
          let subDependencies = await getPackageDependencies(pinnedDependency);

          let subAvailable = new Map(available);
          subAvailable.set(pinnedDependency.name, pinnedDependency.reference);

          return await getPackageDependencyTree(
            Object.assign({}, pinnedDependency, {
              dependencies: subDependencies,
            }),
            subAvailable
          );
        })
    ),
  };
}

此变更向我们的依赖项处理中添加了一个筛选通道:如果有任何依赖项恰好已经满足上游依赖项链中某个已提供的包,那么我们就可以跳过它,因为解析它没有意义。否则,我们会像往常一样继续执行,但我们会将其插入包含我们的依赖项链包的注册表中。这样,我们自己的依赖项将来就可以跳过安装我们了。

如果我们回到 babel-core 示例,步骤如下

- seeing babel-core@*

  - is it available in a parent module? NO
  - resolve it to babel-core@6.25.0
  - resolve its dependencies

    - seeing babel-register@^6.24.1
    - is it available in a parent module? NO
    - resolve it to babel-register@6.24.1
    - resolve its dependencies

      - seeing babel-core@^6.24.1
      - is it available in a parent module? YES, BECAUSE 6.25.0 MATCHES ^6.24.1
      - skip resolution

棒极了。我们现在拥有一个用于计算完整依赖关系树的工作算法。我们几乎完成了,在到达有趣且可选的部分之前,还有两个必选步骤!


在第 4 章中,我们了解了如何获取所有依赖项的完整树。现在,我们只需在其某个位置下载其 tarball 并将其解压到磁盘上即可。第一部分由这个很酷的 fetchPackage 函数轻松实现,我们最近编写过它,我们的链接器只需几行即可

// This function extracts an archive somewhere on the disk
import { extractNpmArchiveTo } from './utilities';

async function linkPackages({ name, reference, dependencies }, cwd) {
  let dependencyTree = await getPackageDependencyTree({
    name,
    reference,
    dependencies,
  });

  // As we previously seen, the root package will be the only one containing
  // no reference. We can simply skip its linking, since by definition it already
  // contains the entirety of its own code :)
  if (reference) {
    let packageBuffer = await fetchPackage({ name, reference });
    await extractNpmArchiveTo(packageBuffer, cwd);
  }

  await Promise.all(
    dependencies.map(async dependency => {
      await linkPackages(dependency, `${cwd}/node_modules/${dependency.name}`);
    })
  );
}

就是这样。此代码会遍历您的树,将每个包解压到其指定目录中(如果您关心 extractArchiveTo 的实现,可以在文章末尾查看存储库),然后对其子级进行迭代,并对每个子级执行相同的操作。这似乎足够好,但我感觉我们可能会忘记某些内容……哦,对了!二进制文件!请注意,NPM 的 package.json 文件为包提供了一种向公众公开实用程序的方法(更多详细信息 此处)。我们需要添加几行来支持此用例

import fs from 'fs-extra';
import path from 'path';

async function linkPackages({ name, reference, dependencies }, cwd) {
  // ... same code as before, except for the end:

  await Promise.all(
    dependencies.map(async ({ name, reference, dependencies }) => {
      let target = `${cwd}/node_modules/${name}`;
      let binTarget = `${cwd}/node_modules/.bin`;

      await linkPackages({ name, reference, dependencies }, target);

      let dependencyPackageJson = require(`${target}/package.json`);
      let bin = dependencyPackageJson.bin || {};

      if (typeof bin === `string`) bin = { [name]: bin };

      for (let binName of Object.keys(bin)) {
        let source = resolve(target, bin[binName]);
        let dest = `${binTarget}/${binName}`;

        await fs.mkdirp(`${cwd}/node_modules/.bin`);
        await fs.symlink(relative(binTarget, source), dest);
      }
    })
  );
}

不错。但是,我仍然觉得缺少了... 脚本!我们缺少安装脚本!包可以指定在安装包后应该运行的命令(例如,它们可能希望根据您的环境编译或转换一些代码)。我们目前不会执行它们,但这应该很容易

import cp from 'child_process';
import util from 'util';

const exec = util.promisify(cp.exec);

async function linkPackages({ name, reference, dependencies }, cwd) {
  // ... same code as before except the end:

  await Promise.all(
    dependencies.map(async ({ name, reference, dependencies }) => {
      // ... same code as before

      if (dependencyPackageJson.scripts) {
        for (let scriptName of [`preinstall`, `install`, `postinstall`]) {
          let script = dependencyPackageJson.scripts[scriptName];

          if (!script) continue;

          await exec(script, {
            cwd: target,
            env: Object.assign({}, process.env, {
              PATH: `${target}/node_modules/.bin:${process.env.PATH}`,
            }),
          });
        }
      }
    })
  );
}

你所有的环境都属于它

请注意,我们仅在此代码段中设置了 PATH 环境变量,但包通常可以访问很多额外的环境变量(更多详细信息 此处)。这些变量很少使用,但如果您计划编写包管理器,那么您必须确保以这样或那样的方式实际上定义了它们。

现在,调用我们的链接器函数将在文件系统上安装我们所需的一切!更好的是,所有构建脚本都将正确运行,这意味着您将最终得到一个有效的 node_modules 目录!干得很好!我们的下一章将讨论性能,现在将会变得非常有趣。


第 6 章——优化的领主

我们的包管理器开始运行了!但是,您可能会注意到某些内容……因为我们没有利用 Node 的解析算法,并且因为我们没有尝试从我们的包树中删除重复项,所以我们最终可能会得到一个非常庞大的 node_modules 文件夹!您可能会认为这没什么问题,但事实证明 过去曾导致问题。例如,在大多数 Windows 安装上,路径的硬限制为 260 个字符。对于深度嵌套的包,此限制通常会被超过并且它会导致问题。幸运的是,Node 的解析算法可以通过允许我们向下移动树中的依赖项来帮助我们解决问题,只要没有冲突即可。

好了,开工吧!我们在此章节中的任务将是通过任何必要的手段减少安装在文件系统上的程序包数量。不过,我们还将尽力保持我们的算法既简单又封装,以便维护人员和贡献者都能轻松地理解它,并且可以在需要时通过单行进行切换或禁用。

下面是一个可能的实现。它并不完美,但这是一个好的开始!不要被其长度吓倒,这大部分只是注释

function optimizePackageTree({ name, reference, dependencies }) {
  // This is a Divide & Conquer algorithm - we split the large problem into
  // subproblems that we solve on their own, then we combine their results
  // to find the final solution.
  //
  // In this particular case, we will say that our optimized tree is the result
  // of optimizing a single depth of already-optimized dependencies (ie we first
  // optimize each one of our dependencies independently, then we aggregate their
  // results and optimize them all a last time).
  dependencies = dependencies.map(dependency => {
    return optimizePackageTree(dependency);
  });

  // Now that our dependencies have been optimized, we can start working on
  // doing the second pass to combine their results together. We'll iterate on
  // each one of those "hard" dependencies (called as such because they are
  // strictly required by the package itself rather than one of its dependencies),
  // and check if they contain any sub-dependency that we could "adopt" as our own.
  for (let hardDependency of dependencies.slice()) {
    for (let subDependency of hardDependency.dependencies.slice()) {
      // First we look for a dependency we own that is called
      // just like the sub-dependency we're iterating on.
      let availableDependency = dependencies.find(dependency => {
        return dependency.name === subDependency.name;
      });

      // If there's none, great! It means that there won't be any collision
      // if we decide to adopt this one, so we can just go ahead.
      if (!availableDependency.length) dependencies.push(subDependency);

      // If we've adopted the sub-dependency, or if the already existing
      // dependency has the exact same reference than the sub-dependency,
      // then it becomes useless and we can simply delete it.
      if (
        !availableDependency ||
        availableDependency.reference === subDependency.reference
      ) {
        hardDependency.dependencies.splice(
          hardDependency.dependencies.findIndex(dependency => {
            return dependency.name === subDependency.name;
          })
        );
      }
    }
  }

  return { name, reference, dependencies };
}

就这样了。我们只需在解析后并链接之前调用此函数,我们便会得到一个更简单的树,它仍然会根据 Node 的解析算法产生有效输出!

真正的魔鬼在于细节

正如我们在本文的引言中所看到的,使包管理器成为复杂软件的大量因素在于细节。我们的优化器代码存在此问题:尽管它在许多情况下都可以使用,但它实际上有一个与二进制文件如何链接相关的令人遗憾的错误。使用上面显示的代码,包二进制文件将不会安装在它们应该安装的地方,因为在优化时,我们丢失了使链接器可以将每个二进制文件正确链接到正确位置的信息。因此,在运行 build 脚本时,将找不到它们。糟糕!

要解决此问题,需要在我们的解析树节点中添加一些字段,然后我们再用这些字段来跟踪节点在树中的原始位置。链接器随后将能够在后处理传递中直接将其子项中的二进制文件链接起来。不幸的是,这也将使代码变得不那么清晰,因此我们选择不在此处实现它。这就是包管理器编写人员的艰辛生活……


结论 - 真的是有 蛋糕

最后!在经历了这一切之后,我们终于有了自己的小型包管理器!你甚至可以在 此存储库 中看到它的完整代码 - 你可以试用它,它真的有用!诚然,它非常基础,有点慢,而且没有太多功能,但我们仍然喜爱它,而且这就是最重要的事情。而且因为它很年轻,所以仍然有很大的发展和改进空间

  • 我们可以实现一个功能强大的 CLI,类似于 Yarn!带有进度条、表情符号,以及所有这些花哨的东西!事实上,该演示已经有了进度条,所以这是一个好的开始!

  • 我们可以将我们的函数拆分为模块!然后,我们的包管理器将是一个简单的 CLI,而我们的获取器/解析器/链接器将从配置文件中加载。想使用符号链接或硬链接而不是复制文件来链接所有内容吗?只需使用另一个链接器,而不是默认链接器!想添加对其他获取器的支持吗?将它们添加到你的配置文件中,然后完成它!事实上,我们甚至 开始对 Yarn 中类似的内容进行试验

  • 我们还可以改进我们的优化器,以便它实际上可以在任何情况下都能发挥作用!;) 假设有一个我们在前一个要点中讨论过的插件架构,我们甚至可以实现不同的优化策略 - 从 [--flat](https://yarn.npmjs.net.cn/lang/en/docs/cli/install/#toc-yarn-install-flat) 选项,以确保我们不会使用任何单个包的多个版本,到可能使用更复杂算法的更深奥的选项,例如 SAT 求解器 - 并且同时不会产生损害包管理器核心体验的任何风险!

  • 我们可以将解析树持久保存到磁盘上的文件,我们称其为 yarn.lock,每次我们需从我们的 getPinnedReferencegetPackageDependencies 功能中处理程序包时,我们将从此文件而不是网络中提取信息!(如果您想知道,这就是 Yarn 的 yarn.lock 和 NPM@5 的 package-lock.json 文件的工作方式)

  • 我们可以将 tar 包保存在某种高速缓存中,这样我们就不用从网络下载多次了。通过此操作,如果高速缓存整理得当,我们甚至可以离线安装程序包!

这只是一个简短的清单,远未详尽!程序包管理器可以实现一系列功能,且每个功能都可以通过多种方式改进。如您所见,未来一片光明:谁能预料未来几年将有哪些新功能和改进?没人能肯定地说,但我 告诉您,留意该博客以观看 Yarn 的最新公告!


我希望您能像我享受撰写本文一样,尽兴阅读此文!如果您想讨论本文,以便纠正错误或仅讨论程序包管理器,可以通过 @arcanis 在 Twitter 上联系我,或在 Yarn 的 Discord 服务器上联系我,在那里核心团队会定期关注 :)