Pnpm Resolve Plugin

1,487 阅读13分钟

疑问

  • 什么是依赖地狱、依赖提升又是什么
  • 什么是影子依赖(Phantom dependency), 什么机制照成影子依赖问题。
  • package.lock 与 yarn.lock 作用是什么, 解决了什么问题
  • 什么是**语义化版本(Semantic Versioning), **版本号前 ^0.0.1 与 ~0.0.1 与 *0.0.1 区别和含义, 会造成什么问题

常见的包管理工具

  • npm
  • yarn
  • pnpm

npm 泪奔的历史

npm v1/v2 依赖嵌套

npm最早的版本中使用了很简单的嵌套模式进行依赖管理。比如我们在项目中依赖了A模块和C模块,而A模块和C模块依赖了不同版本的B模块,此时生成的node_modules目录如下:

依赖地狱(Dependency Hell)

可以看到这种是嵌套的node_modules结构,每个模块的依赖下面还会存在一个 node_modules 目录来存放模块依赖的依赖。这种方式虽然简单明了,但存在一些比较大的问题。如果我们在项目中增加一个同样依赖2.0版本B的模块D,此时生成的node_modules目录便会如下所示。虽然模块A、D依赖同一个版本B,但B却重复下载安装了两遍,造成了重复的空间浪费。这便是依赖地狱问题。

node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0
    └── node_modules
        └── B@1.0.0

一些著名的梗图: image.png image.png

npm v3 扁平化

npm v3完成重写了依赖安装程序,npm3通过扁平化的方式将子依赖项安装在主依赖项所在的目录中(hoisting提升),以减少依赖嵌套导致的深层树和冗余。此时生成的node_modules目录如下:

image.png image.png

为了确保模块的正确加载,npm也规定了额外的依赖查找算法,核心是递归向上查找node_modules。在安装新的包时,会不停往上级node_modules中查找。如果找到相同版本的包就不会重新安装,在遇到版本冲突时才会在模块下的 node_modules 目录下存放该模块子依赖,解决了大量包重复安装的问题,依赖的层级也不会太深。 扁平化的模式解决了依赖地狱的问题,但也带来了额外的新问题。

幽灵依赖(Phantom dependency)

幽灵依赖主要发生某个包未在package.json中定义,但项目中依然可以引用到的情况下。考虑之前的案例,它的package.json 如下图所示。

image.png image.png 在index.js中我们可以直接require A,因为在package.json声明了该依赖,但是,我们require B也是可以正常工作的。

var A = require('A');
var B = require('B'); // ???

因为B是A的依赖项,在安装过程中,npm会将依赖B平铺到node_modules下,因此require函数可以查找到它。但这可能会导致意想不到的问题:

  • **依赖不兼容:**my-library库中并没有声明依赖B的版本,因此B的major更新对于SemVer体系是完全合法的,这就导致其他用户安装时可能会下载到与当前依赖不兼容的版本。
  • **依赖缺失:**我们也可以直接引用项目中devDepdency的子依赖,但其他用户安装时并不会devDepdency,这就可能导致运行时会立刻报错。
多重依赖(doppelgangers)

image.png 考虑在项目中继续引入的依赖2.0版本B的模块D与而1.0版本B的模块E,此时无论是把B 2.0还是1.0提升放在顶层,都会导致另一个版本存在重复的问题,比如这里重复的2.0。此时就会存在以下问题:

  1. 破坏单例模式:模块C、D中引入了模块B中导出的一个单例对象,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的module,引入的也是不同的对象。如果同时对该对象进行副作用操作,就会产生问题。
  2. types冲突:虽然各个package的代码不会相互污染,但是他们的types仍然可以相互影响,因此版本重复可能会导致全局的types命名冲突。
不确定性(Non-Determinism)

在前端包管理的背景下,确定性指在给定package.json下,无论在何种环境下执行npm install命令都能得到相同的node_modules目录结构。然而npm v3是不确定性的,它node_modules目录以及依赖树结构取决于用户安装的顺序。

考虑项目拥有以下依赖树结构,其npm install产生的node_modules目录结构如右图所示。 image.png image.png 假设当用户使用npm手动升级了模块A到2.0版本,导致其依赖的模块B升级到了2.0版本,此时的依赖树结构如下。 image.png image.png

此时完成开发,将项目部署至服务器,重新执行npm install,此时提升的子依赖B版本发生了变化,产生的node_modules目录结构将会与用户本地开发产生的结构不同,如下图所示。如果需要node_modules目录结构一致,就需要在package.json修改时删除node_modules结构并重新执行npm install。

image.png image.png

npm v5 扁平化+lock

在npm v5中新增了package-lock.json。当项目有package.json文件并首次执行npm install安装后,会自动生成一个package-lock.json文件,该文件里面记录了package.json依赖的模块,以及模块的子依赖。并且给每个依赖标明了版本、获取地址和验证模块完整性哈希值。通过package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果。 image.png image.png

一致性

语义化版本(Semantic Versioning) 依赖版本兼容性就不得不提到npm使用的SemVer版本规范,版本格式如下:

  • 主版本号:不兼容的 API 修改
  • 次版本号:向下兼容的功能性新增
  • 修订号:向下兼容的问题修正

image.png

在使用第三方依赖时,我们通常会在package.json中指定依赖的版本范围,语义化版本范围规定:

  • ~:只升级修订号
  • ^:升级次版本号和修订号
  • *:升级到最新版本

语义化版本规则定义了一种理想的版本号更新规则,希望所有的依赖更新都能遵循这个规则,但是往往会有许多依赖不是严格遵循这些规定的。 因此一些依赖模块子依赖不经意的升级,可能就会导致不兼容的问题产生。因此package-lock.json给每个模块子依赖标明了确定的版本,避免不兼容问题的产生。

yarn 的出现

Yarn 是在2016年开源的,yarn 的出现是为了解决 npm v3 中的存在的一些问题,那时 npm v5 还没发布。Yarn 被定义为快速、安全、可靠的依赖管理。

Yarn v1 lockfile

Yarn 生成的 node_modules 目录结构和 npm v5 是相同的,同时默认生成一个 yarn.lock 文件。对于上文例子,生成的yarn.lock文件如下:


A@^1.0.0:
  version "1.0.0"
  resolved "uri"
 dependencies:
    B "^1.0.0"

B@^1.0.0:
  version "1.0.0"
  resolved "uri"

B@^2.0.0:
  version "2.0.0"
  resolved "uri"

C@^2.0.0:
  version "2.0.0"
  resolved "uri"
 dependencies:
    B "^2.0.0"

D@^2.0.0:
  version "2.0.0"
  resolved "uri"
  dependencies:
    B "^2.0.0"

E@^1.0.0:
  version "1.0.0"
  resolved "uri"
  dependencies:
    B "^1.0.0"

可以看到yarn.lock使用自定义格式而不是JSON,并将所有依赖都放在顶层,给出的理由是便于阅读和审查,减少合并冲突。

Yarn lock vs. npm lock
  • 文件格式不同,npm v5 使用的是 json 格式,yarn 使用的是自定义格式
  • package-lock.json 文件里记录的依赖的版本都是确定的,不会出现语义化版本范围符号(~ ^ *),而 yarn.lock 文件里仍然会出现语义化版本范围符号
  • package-lock.json 文件内容更丰富,实现了更密集的锁文件,包括子依赖的提升信息

npm v5 只需要 package.lock 文件就可以确定 node_modules 目录结构 yarn.lock 无法确定顶层依赖,需要 package.json 和 yarn.lock 两个文件才能确定 node_modules 目录结构。node_modules 目录中 package 的位置是在 yarn 的内部计算出来的,在使用不同版本的 yarn 时可能会引起不确定性。

pnpm出现

pnpm1.0于2017年正式发布,pnpm具有安装速度快、节约磁盘空间、安全性好等优点,它的出现也是为了解决npm和yarn存在的问题。

因为在基于npm或yarn的扁平化node_modules的结构下,虽然解决了依赖地狱、一致性与兼容性的问题,但多重依赖和幽灵依赖并没有好的解决方式。因为在不考虑循环依赖的情况下,实际的依赖结构图为有向无环图(DAG),但是npm和yarn通过文件目录和node resolve算法模拟的实际上是有向无环图的一个超集(多出了很多错误祖先节点和兄弟节点之间的链接),这导致了很多的问题。pnpm也是通过硬链接与符号链接结合的方式,更加精确的模拟DAG来解决yarn和npm的问题。

非扁平化的node_modules

硬链接(hard link)节约磁盘空间

硬链接可以理解为源文件的副本,使得用户可以通过不同的路径引用方式去找到某个文件,他和源文件一样的大小但是事实上却不占任何空间。pnpm 会在全局 store 目录里存储项目 node_modules 文件的硬链接。硬链接可以使得不同的项目可以从全局 store 寻找到同一个依赖,大大节省了磁盘空间。

符号链接(symbolic link)创建嵌套结构

软链接可以理解为快捷方式,pnpm在引用依赖时通过符号链接去找到对应磁盘目录(.pnpm)下的依赖地址。考虑在项目中安装依赖于foo模块的bar模块,生成的node_modules目录如下所示。 image.png image.png

可以看到node_modules下的bar目录下并没有node_modules,这是一个符号链接,实际真正的文件位于.pnpm目录中对应的 @version/node_modules/目录并硬链接到全局store中。而bar的依赖存在于.pnpm目录下@version/node_modules目录下,而这也是软链接到@version/node_modules/目录并硬链接到全局store中。

而这种嵌套node_modules结构的好处在于只有真正在依赖项中的包才能访问,避免了使用扁平化结构时所有被提升的包都可以访问,很好地解决了幽灵依赖的问题。此外,因为依赖始终都是存在store目录下的硬链接,相同的依赖始终只会被安装一次,多重依赖的问题也得到了解决。

官网上的这张图清晰地解释了pnpm的依赖管理机制 image.png

局限性

看起来pnpm似乎很好地解决了问题,但也存在一些局限。

  • 忽略了 package-lock.json。npm 的锁文件旨在反映平铺的 node_modules 布局,但是 pnpm 默认创建隔离布局,无法由 npm 的锁文件格式反映出来,而是使用自身的锁文件pnpm-lock.yaml。
  • 符号链接兼容性。存在符号链接不能适用的一些场景,比如 Electron 应用、部署在 lambda 上的应用无法使用 pnpm。
  • 子依赖提升到同级的目录结构,虽然由于 Node.js 的父目录上溯寻址逻辑,可以实现兼容。但对于类似 Egg、Webpack 的插件加载逻辑,在用到相对路径的地方,需要去适配。
  • 不同应用的依赖是硬链接到同一份文件,如果在调试时修改了文件,有可能会无意中影响到其他项目。

实际体验pnpm

rm -rf node_modules time npm install > /dev/null 2>&1 && time du -d 0 -h node_modules

rm -rf node_modules time pnpm install > /dev/null 2>&1 && time du -d 0 -h node_modules

image.png image.png

实际公司项目中调试

codemao-activity

关键步骤1

# 删除先前 node_modules
rm -rf node_modules

# 改用pnpm 安装
pnpm install

# 开始构建
npm run build 

插件node_module 目录结构

我们会发现node_modules 下包文件少了很多, 我们对比pacakage.json 文件才发现, 基本上包含pacakage.json, 下声明一些依赖包, 但也与其他多余依赖, 应该是类似post_install script 所为[待验证]

这种pnpm install 模式, 所有的依赖都不会提升。 image.png image.png

image.png

构建流程

image.png 几个疑问

  • 为什么babel-loader 无法被resolve

tips:

  1. 查看node_modules
  2. 查看 mlz/pack 的package.json

关键步骤2

# 能否从帮助找到一些答案出来
pnpm install --help

# 删除模块从新安装
rm -rf node_modules


# 采用扁平的方式安装依赖
pnpm install --shamefully-hoist

# 执行构建操作
npm run build

tips: 扁平的安装方式, 其实跟yarn 和npm 安装方式保持一致, 这里注意: 因为pnpm 没有相关lock文件, 对就有项目, 可能会存在依赖版本的问题, 跟先前锁定的版本结构不一致照成的。

这时可以对比node_module目录下结构

执行构建

这时先前的错误已经没有了, 说明之前问题分析是对的

这里webpack 执行构建的流程, 大部分构建流程走完了, image.png image.png

babel-runtime 作用及来源分析

babel-runtime 作用, 就是就是我们配置babel-loader preset-env 预设 对es6 语法进行垫片操作image.png

// 比如如下代码  对对象进行解构时, 
// 其实这里转码时会多出 var obj = require('babel-runtime/core-js/object/xxxx') 东西
// 然后 object({name: 1})

{ ...{name: 1} }

为什么解析不到babel-runtime

基本分析, 跟软链和webpack 莫快递查找有很大关系

关键步骤3(调试)

1. 找到报错位置, 查看调用栈

image.png image.png

从调用栈中基本上可以看到一些信息, 但不够全面

  • 只看和webpack 相关源码信息
  • 只看和模块相关源码信息

image.png

2. 找webpack 源码中几个关键点, 抓住模块相关代码

image.png image.png

  • 模块解析相关模块

大致流程, 读取文件, Parse.js 进行解析, 生成AST语法树, 对语法树进行依赖分析, 形成Dependencies Tree,例如CommonRequireDepenDency, ModuleDependency 等等很多, 从Dependency 到 resovleContext 到 NormalModule

  • NormalModuleFactory.js
  • Parse.js
  • dependencies 相关

3. NormalModuleFactory 中hooks

image.png

  • 条件断点

image.png image.png

这里出现了路径解析问题, 按照软链位置, webpack 是无法reslove 模块, 只有按照真实模块解析路径才能解析出来 image.png image.png image.png

关键步骤4

第一个版本Pnpm-resolver-plguin

const fs = require('fs');


class PnpmResolverPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, options) => {
      const hander = (module) => {
        if (!module.resource) return;

        if (module.resource.match(/\.(ejs)$/)) return;

        try {
          const resource = fs.realpathSync(module.resource);
          const context = getContext(resource);

          if (module.request === module.resource && module.userRequest === module.resource) {
            module.request = resource;
            module.userRequest = resource;
          }

          module.context = context;
          module.resource = resource;
        } catch(err) {
          // debugger;
        }
      };

      compilation.hooks.buildModule.tap('PnpmResolverPlugin', hander);
    });
  }
}

function splitQuery(req) {
  var i = req.indexOf('?');
  if(i < 0) return [req, ''];
  return [req.substr(0, i), req.substr(i)];
}

function dirname(path) {
  if(path === '/') return '/';
  var i = path.lastIndexOf('/');
  var j = path.lastIndexOf('\\');
  var i2 = path.indexOf('/');
  var j2 = path.indexOf('\\');
  var idx = i > j ? i : j;
  var idx2 = i > j ? i2 : j2;
  if(idx < 0) return path;
  if(idx === idx2) return path.substr(0, idx + 1);
  return path.substr(0, idx);
}

function getContext(resource) {
  var splitted = splitQuery(resource);
  return dirname(splitted[0]);
};


module.exports = PnpmResolverPlugin;

这个版本出现两个问题, 一个真实路径没有解析完全, 照成react-router-dom 在webpack中当成两份代码,NormalMoule实例, 最后照成结果, ract-router-dom 内部context 与 react-router 配合不起来。

第二个问题就是build 时运行不起来

image.png 由于源码经过压缩混淆, 所以看不出来, 采用development 进行打包看看 image.png 这似乎可以看出来问题了 image.png

这里调试过程也比较艰辛, 主要通过全局脚本中注入debuger 代码方式, 发现依赖库竟然执行初始化执行两次, 因此说明, 同一份代码被当成两个不同的NormalModule 进行build, 所以有了第二个版本。

第二个版本

const fs = require('fs');


class PnpmResolverPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, {normalModuleFactory}) => {
      normalModuleFactory.hooks.afterResolve.tap('PnpmResolverPlugin', (result) => {
        try {
          const resource = fs.realpathSync(result.resource);
          const context = getContext(resource);

          if (result.userRequest === result.resource) {
            result.userRequest = resource;
          }

          if (result.request === result.resource) {
            result.request = resource;
          }

          result.resource = resource;
          result.context = context;

          result.resourceResolveData.descriptionFilePath = fs.realpathSync(result.resourceResolveData.descriptionFilePath);
          result.resourceResolveData.descriptionFileRoot = fs.realpathSync(result.resourceResolveData.descriptionFileRoot);
          result.resourceResolveData.path = fs.realpathSync(result.resourceResolveData.path);

        } catch(err) {
          // debugger;
        }
      })

    });
  }
}

function splitQuery(req) {
  var i = req.indexOf('?');
  if(i < 0) return [req, ''];
  return [req.substr(0, i), req.substr(i)];
}

function dirname(path) {
  if(path === '/') return '/';
  var i = path.lastIndexOf('/');
  var j = path.lastIndexOf('\\');
  var i2 = path.indexOf('/');
  var j2 = path.indexOf('\\');
  var idx = i > j ? i : j;
  var idx2 = i > j ? i2 : j2;
  if(idx < 0) return path;
  if(idx === idx2) return path.substr(0, idx + 1);
  return path.substr(0, idx);
}

function getContext(resource) {
  var splitted = splitQuery(resource);
  return dirname(splitted[0]);
};


module.exports = PnpmResolverPlugin;

页面被成功打包成功 image.png

第三个版本

尝试采用 pnpm install 非提升模式, 解决babel-loder css-loader 等loader 解析问题

const fs = require('fs');


class PnpmResolverPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, {normalModuleFactory}) => {
      const loaderContext = `${compiler.context}/node_modules/.pnpm`
      const isPnpmModule = fs.existsSync(loaderContext);

      if (!isPnpmModule) return;

      // 这里主要处理 css-loader babel-loader 内置到公司内部脚手架问题
      normalModuleFactory.context = loaderContext;

      // 这里主要处理模块真实路径问题
      normalModuleFactory.hooks.afterResolve.tap('PnpmResolverPlugin', (result) => {
        try {
          const resource = fs.realpathSync(result.resource);
          const context = getContext(resource);

          if (result.userRequest === result.resource) {
            result.userRequest = resource;
          }

          if (result.request === result.resource) {
            result.request = resource;
          }

          result.resource = resource;
          result.context = context;

          result.resourceResolveData.descriptionFilePath = fs.realpathSync(result.resourceResolveData.descriptionFilePath);
          result.resourceResolveData.descriptionFileRoot = fs.realpathSync(result.resourceResolveData.descriptionFileRoot);
          result.resourceResolveData.path = fs.realpathSync(result.resourceResolveData.path);

        } catch(err) {
          // debugger;
        }
      })

    });
  }
}

function splitQuery(req) {
  var i = req.indexOf('?');
  if(i < 0) return [req, ''];
  return [req.substr(0, i), req.substr(i)];
}

function dirname(path) {
  if(path === '/') return '/';
  var i = path.lastIndexOf('/');
  var j = path.lastIndexOf('\\');
  var i2 = path.indexOf('/');
  var j2 = path.indexOf('\\');
  var idx = i > j ? i : j;
  var idx2 = i > j ? i2 : j2;
  if(idx < 0) return path;
  if(idx === idx2) return path.substr(0, idx + 1);
  return path.substr(0, idx);
}

function getContext(resource) {
  var splitted = splitQuery(resource);
  return dirname(splitted[0]);
};


module.exports = PnpmResolverPlugin;

第四个版本

解决webpack5 中问题, 在webpack 中afterResolve 中result 数据结构发生根本性变化, 所以, 不能通过这种修改数据结构hack方式去修改, 所以用到webpack 配置项resolve.symlinks, 省去大量解析代码'

image.png image.png

compiler.options.resolve.symlinks = true;
const fs = require('fs');


class PnpmResolverPlugin {
  apply(compiler) {
    const loaderContext = `${compiler.context}/node_modules/.pnpm`;
    const isPnpmModule = fs.existsSync(loaderContext);
    if (!isPnpmModule) return;


    compiler.options.resolve.modules.push(`${loaderContext}/node_modules`);
    compiler.options.resolve.symlinks = true;

    compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, {normalModuleFactory}) => {
    
      // 这里主要处理 css-loader babel-loader 内置到公司内部脚手架的问题
      normalModuleFactory.context = loaderContext;
      
      // compiler.options.resolve.symlinks = true; 解决实际路径问题
      // 这里主要处理模块真实路径问题
      // normalModuleFactory.hooks.afterResolve.tap('PnpmResolverPlugin', (result) => {
      //   try {
      //     const resource = fs.realpathSync(result.resource);
      //     const context = getContext(resource);

      //     if (result.userRequest === result.resource) {
      //       result.userRequest = resource;
      //     }

      //     if (result.request === result.resource) {
      //       result.request = resource;
      //     }

      //     result.resource = resource;
      //     result.context = context;

      //     result.resourceResolveData.descriptionFilePath = fs.realpathSync(result.resourceResolveData.descriptionFilePath);
      //     result.resourceResolveData.descriptionFileRoot = fs.realpathSync(result.resourceResolveData.descriptionFileRoot);
      //     result.resourceResolveData.path = fs.realpathSync(result.resourceResolveData.path);

      //   } catch(err) {
      //     // debugger;
      //   }
      // })

    });
  }
}

function splitQuery(req) {
  var i = req.indexOf('?');
  if(i < 0) return [req, ''];
  return [req.substr(0, i), req.substr(i)];
}

function dirname(path) {
  if(path === '/') return '/';
  var i = path.lastIndexOf('/');
  var j = path.lastIndexOf('\\');
  var i2 = path.indexOf('/');
  var j2 = path.indexOf('\\');
  var idx = i > j ? i : j;
  var idx2 = i > j ? i2 : j2;
  if(idx < 0) return path;
  if(idx === idx2) return path.substr(0, idx + 1);
  return path.substr(0, idx);
}

function getContext(resource) {
  var splitted = splitQuery(resource);
  return dirname(splitted[0]);
};


module.exports = PnpmResolverPlugin;

终极解决方案

文档

问题

  • 非提升模式 脚手架CI内置loader、babel问题引用不到
  • 非提升模式 影子依赖问题
  • webpack.resolve.symlinks = true 解析模块真实路径

方案

文件 npmrc

# 把脚手架中loader提升到公共(项目)node_modules下
public-hoist-pattern[]=*loader*
# 把babel相关库提升到公共(项目)node_modules下
public-hoist-pattern[]=*babel*

webpack配置

// webpack解析软链位置
options.resolve.symlinks = true 

实际项目落地终极方案

最后

pnpm-resolver-plugin 点个Start