前段时间的一次 使用cnpm踩坑引发的一次学习记录,期间也阅读了cnpm核心模块npminstall的源码。
npminstall的实现与pnpm类似,具有相似的(依赖包)存储结构。以下是pnpm官方介绍:
pnpm使用 硬链接 和 软链接,在磁盘上只保存一次依赖包。而npm或yarn,若有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);
}
再重点讲一下这里处理的细节:
- 由于是并发请求,可能出现同时下载同个依赖的情况,因此确保一个版本只下载一次 这里也做了个download缓存 如果发现正在下载,则等待下载结束才return
- 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实现了非常细粒度的错误处理。
cache和link是npminstall快速高效install的核心实现。
阅读npminstall源码不仅使我了解到了p-map、npm-package-arg、mz等多个优秀库,也更加熟悉了大量的node原生api~,还有很多细节处理思路,对我目前的项目难点攻克有非常大的帮助 ~
相关链接
cnpm 核心模块 npminstall 升级到 async 总结 (npminstall作者)