cnpm核心模块npminstall源码阅读

836 阅读4分钟

前段时间的一次 使用cnpm踩坑引发的一次学习记录,期间也阅读了cnpm核心模块npminstall的源码。

npminstall的实现与pnpm类似,具有相似的(依赖包)存储结构。以下是pnpm官方介绍:

pnpm使用 硬链接软链接,在磁盘上只保存一次依赖包。而npmyarn,若有100个项目依赖了相同版本的lodash,在磁盘上会有100次拷贝;但pnpm只会保存一次,再次安装只会在该项目中的node_modules里建立起一个硬链接。 因此pnpm和cnpm相比起yarn和npm具有的优点:

  • 节省大量的磁盘空间
  • 有更快地install速度

开始源码解读,以下只截取了核心代码,部分伪代码。

1、 入口 lib/index.js -> lib/local_install.js

async options => {
  options = formatInstallOptions(options); // 预处理存有所有的依赖相关数据的options
  options.spinner && options.spinner.start(); // 加载loading
 
  if (options.trace) {
    //开启cpu、memory使用情况监控
  }

  try {
    await _install(options);
  } catch (err) {}
}

2、_install

async function _install(options) {
  const rootPkgFile = path.join(options.root, 'package.json');
  const rootPkg = await utils.readJSON(rootPkgFile); 
  // 读取package.json文件
  
  if (options.installRoot) await preinstall();
  // 调用npm preinstall钩子(阮一峰《npm scripts 使用指南》)
  
  const nodeModulesDir = path.join(options.targetDir, 'node_modules');
  await utils.mkdirp(nodeModulesDir); // 创建node_modules目录
  
  const mapper = async childPkg => {
    await installOne(); // 安装
  };

  await pMap(pkgs, mapper, 10); // const pMap = require('p-map');
}

划重点 - p-map:

作者在之前对npmisntall的重构加入使用了p-map。它可以替代 Promise.all() 且提供并发数限制能力,这里是第三个参数 10。 github链接

installOne 开始处理单个依赖

async function installOne(parentDir, childPkg, options) {
  if (!(await needInstall())) {
     // 是否需要安装的判断
     return;
  }
  
  const res = await _install();
}

// 有些依赖已安装过就不需要再次安装,所以只做一个版本的判断,如果range版本无效或者已下载的版本较低就clean up,重新安装
async function needInstall(parentDir, childPkg, options) {
  if (semver.validRange(childPkg.version) && semver.satisfies(pkg.version, childPkg.version)) {
    await utils.rimraf(path.join(parentDir, 'node_modules', childPkg.name));
    return false;
  } else { return true }
}

3、_install

async function _install(parentDir, pkg, ancestors, options) {
  // 默认最新的版本
  if (!pkg.version) {
    pkg.version = '*';
  }
  
  let p = npa(pkg.name ? `${pkg.name}@${pkg.version}` : pkg.version);
  // const npa = require('npm-package-arg'), 仓库地址 https://github.com/npm/npm-package-arg
  // 对包名进行解析,like: foo@1.2, @bar/foo@1.2, foo@user/foo, http://x.com/foo.tgz, git+https://github.com/user/foo, bitbucket:user/foo, foo.tar.gz
  
  // 若已下载过,命中缓存,建立软链接,并返回不再download
  const key = `install:${pkg.name}@${pkg.version}`;
  const c = options.cache[key]; // {package: packageInfo, dir: realDir}
  if (c) {
    await linkModule(); 
    // linkModule不仅建立了包目录软链, 还建立了bin软链(lib/bin.js)。调用fs.symlink
    return {
      exists: true,
      dir: realPkgDir,
    };
  }
  
  await download();
}

4、download

async (pkg, options) => {
  // 之前npm-package-arg对包名进行了解析,这里对解析后返回的包type做了判断
  if (pkg.type === 'local') {
    return await local();
  }
  if (pkg.type === 'remote') {
    return await remote();
  }
  if (pkg.type === 'git' || pkg.type === 'hosted') {
    return await git();
  }
  return await npm();
};

type具有以下值, 在npm-package-arg文档中可以看到

5、type为npm时的处理,代码在lib/download/npm.js

async function download(pkg, options) {
  // 由于是并发请求,可能出现同时下载同个依赖的情况,以下处理确保一个版本只下载一次
  const key = `download:${pkg.name}@${pkg.version}`;
 
  if (options.cache[key]) {
    // 如果命中了下载缓存,但是还未下载结束,会进行等待,
    // 等待下载完成后再return,此时exists就为true了
    if (!options.cache[key].done) {
      const err = await options.events.await(key);
      if (err) {
        throw err;
      }
    }
    return {
      exists: true,
      dir: ungzipDir,
    };
  }
  
  // 若没有命中下载缓存,done为false
  options.cache[key] = {
    done: false,
  };
  
  // 创建未解压包目录
  await utils.mkdirp(ungzipDir);
  
  // 计数
  let count = 0;
  
  // 解析包的源地址
  const tarballUrls = utils.parseTarballUrls(pkg.dist.tarball);
  
  while (count < 3) {
    tarballUrl = tarballUrls[tarballUrlIndex++];
    if (!tarballUrl) {
      tarballUrlIndex = 0;
      tarballUrl = tarballUrls[tarballUrlIndex];
    }
    try {
      // getTarballStream下载拿到包原始流, 请求方法在 lib/get.js
      const stream = await getTarballStream(tarballUrl, pkg, options);
      let useTarFormat = false;
      if (count === 1 && lastErr && lastErr.code === 'Z_DATA_ERROR') {
        useTarFormat = true;
      }
      
      // checkShasumAndUngzip 校验文件的SHA1值,并解压
      await checkShasumAndUngzip(ungzipDir, stream, pkg, useTarFormat, options);
      lastErr = null;
      break;
    } catch (err) {
      lastErr = err;
      count++;
      // retry download on any error
      // sleep for a while to wait for server become normal
      if (count < 3) {
        await utils.sleep(count * 500);
      }
    }
  }
  
  options.cache[key].done = true;
  options.events.emit(key);
 }

再重点讲一下这里处理的细节:

  1. 由于是并发请求,可能出现同时下载同个依赖的情况,因此确保一个版本只下载一次 这里也做了个download缓存 如果发现正在下载,则等待下载结束才return
  2. retry download on any error, sleep for a while to wait for server become normal 任何下载错误都会等待(count*500)毫秒再次发起请求,直到count次失败, count = 3

粗略的总结

下载方法 getTarballStream 和 校验资源HASH值、解压方法checkShasumAndUngzip、以及下载之后的处理就不在这里解析了。有兴趣的同学可自行阅读。

源码中大量的try catch实现了非常细粒度的错误处理。

cachelink是npminstall快速高效install的核心实现。

阅读npminstall源码不仅使我了解到了p-mapnpm-package-argmz等多个优秀库,也更加熟悉了大量的node原生api~,还有很多细节处理思路,对我目前的项目难点攻克有非常大的帮助 ~

相关链接

cnpm 核心模块 npminstall 升级到 async 总结 (npminstall作者)

semver

Linux中的硬链接和软链接的概念、区别及用法

p-map