Yarn Plug'n'Play可否助你脱离node_modules苦海?

使用 Yarn(v1.12+)的 Plug’n’Play 机制来取代 node_modules. 目前这还是一个实验性的特性.

背景

node_modules

node_modules早就成为的全民吐槽的对象, 其他语言的开发者看到 node_modules 对 Node 就望而祛步了,
用一个字来形容的话就是’重!’.

如果不了解 Node 模块查找机制, 请点击require() 源码解读

一个简单的前端项目(create-react-app)的大小和文件数:


frontend-project

而 macOS 的/Library目录的大小的文件数:


macos library

一行hello world就需要安装 130MB 以上的依赖模块, 而且文件数是32,313. 相比之下 macOS 的/Library
的空间占用 9.02GB, 文件数只是前者的两倍(67,890). 综上可以看出 node_modules 的特点是:

  • 目录树结构复杂
  • 文件数较多且都比较小
  • 依赖多, 一个简单的项目就要安装好几吨依赖

所以说 node_modules 对于机械硬盘来说是个噩梦, 记得有一次一个同事删除 node_modules 一个下午都没搞定.
对于前端开发者来说, 我们有 N 个需要npm install的项目 😹.

除此之外, Node 的模块机制还有以下缺点:

  • Node 本身并没有模块的概念, 它在运行时进行查找和加载. 这个缺点和‘动态语言与静态语言的优劣对比’相似,
    你可能在开发环境运行得好好的, 可能到了线上就运行不了了, 原因是一个模块没有添加到 package.json

  • Node 模块的查找策略非常浪费. 这个缺点在大部分前端项目中可以进行优化,
    比如 webpack 就可以限定只在项目根目录下的 node_modules 中查找, 但是对于嵌套的依赖, 依然需要 2 次以上的查找

  • node_modules 不能有效地处理重复的包. 两个名称相同但是不同版本的包是不能在一个目录下共存的.
    所以会导致嵌套的 node_modules, 而且这些项目’依赖的依赖’是无法和项目或其他依赖共享的:

    # ① 假设项目依赖a,b,c三个模块, 依赖树为:
    # +- a
    # +- react@15
    # +- b
    # +- react@16
    # +- c
    # +- react@16
    # yarn安装时会按照项目被依赖的次数作为权重, 将依赖提升(hoisting),
    # 安装后的node_modules结构为:
    .
    └── node_modules
       ├── a
       │   ├── index.js
       │   ├── node_modules
       │   │   └── react # @15
       │   └── package.json
       ├── b
       │   ├── index.js
       │   └── package.json
       ├── c
       │   ├── index.js
       │   └── package.json
       └── react # @16 被依赖了两次, 所以进行提升

    # ② 现在假设在①的基础上, 根项目依赖了react@15, 对于项目自己的依赖肯定是要放在node_modules根目录的,
    # 由于一个目录下不能存在同名目录, 所以react@16没有的提升机会.
    # 安装后node_moduels结构为
    .
    └── node_modules
       ├── a
       │   ├── index.js
       │   └── package.json # react@15 提升
       ├── b
       │   ├── index.js
       │   ├── node_modules
       │   │   └── react # @16
       │   └── package.json
       ├── c
       │   ├── index.js
       │   ├── node_modules
       │   │   └── react # @16
       │   └── package.json
       └── react # @15
    # 上面的结果可以看出, react@16出现了重复

为此 Yarn 集成了Plug'n'Play(简称 pnp), 中文名称可以称为’即插即用’, 来解决 node_modules’地狱’.

基本原理

按照普通的按照流程, Yarn 会生成一个 node_modules 目录, 然后 Node 按照它的模块查找规则在 node_modules 目录中查找.
但实际上 Node 并不知道这个模块是什么, 它在 node_modules 查找, 没找到就在父目录的 node_modules 查找, 以此类推.
这个效率是非常低下的.

但是 Yarn 作为一个包管理器, 它知道你的项目的依赖树. 那能不能让 Yarn 告诉 Node? 让它直接到某个目录去加载模块.
这样即可以提高 Node 模块的查找效率, 也可以减少 node_modules 文件的拷贝. 这就是Plug'n'Play的基本原理.

在 pnp 模式下, Yarn 不会创建 node_modules 目录, 取而代之的是一个.png.js文件, 这是一个 node 程序,
这个文件包含了项目的依赖树信息, 模块查找算法, 也包含了模块查找器的 patch 代码(在 Node 环境, 覆盖 Module._load 方法).


使用 pnp 机制的以下优点:

  • 摆脱 node_modules.
    • 时间上: 相比较在热缓存(hot cache)环境下运行yarn install节省 70%的时间
    • 空间上: pnp 模式下, 所有 npm 模块都会存放在全局的缓存目录下, 依赖树扁平化, 避免拷贝和重复
  • 提高模块加载效率. Node 为了查找模块, 需要调用大量的 stat 和 readdir 系统调用.
    pnp 通过 Yarn 获取或者模块信息, 直接定位模块
  • 不再受限于 node_modules 同名模块不同版本不能在同一目录

在 Mac 下 Yarn 的安装速度非常快, 热缓存下仅需几秒. 原因是 SSD + APFS 的 Copy-on-write 机制.
这使得文件的拷贝不用占用空间, 相当于创建一个链接. 所以拷贝和删除的速度非常快.
但是 node_modules 复杂的目录结构和超多的文件, 仍然需要调用大量的系统调用, 这也会拖慢安装过程.


💡 如果觉得 pnp 繁琐或不可靠, 那就赶紧用上 SSD 配合支持 Copy-on-write 的文件系统.


使用 pnp 的风险:

目前前端社区的各种工具都依赖于 node_modules 模块查找机制. 例如

  • Node
  • Electron, electron-builder 等等
  • Webpack
  • Typescript: 定位类型声明文件
  • Babel: 定位插件和 preset
  • Eslint: 定位插件和 preset, rules
  • Jest
  • 编辑器, 如 VsCode
  • …😿

pnp 一个非常新的东西, 在去年 9 月份(2018)面世. 要让这些工具和 pnp 集成是个不小的挑战, 而且这些这些工具
和 pnp 都是在不断迭代的, pnp 还不稳定, 未来可能变化, 这也会带来某些维护方面的负担.

除了模块查找机制, 有一些工具是直接在 node_modules 中做其他事情的, 比如缓存, 存放临时证书. 例如cache-loader, webpack-dev-server

开启 pnp

如果只是单纯的 Node 项目, 迁入过程还算比较简单. 首先在package.json开启 pnp 安装模式:

{
"installConfig": {
"pnp": true
}
}

接着安装依赖:

yarn add express

安装后项目根目录就会出现一个.pnp.js文件. 下一步编写代码:

// index.js
const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => res.send('Hello World!'));

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

接下来就是运行 Node 代码了, 如果直接node index.js会报Error: Cannot find module 'express'异常.
这是因为还没有 patch Node 的模块查找器. 可以通过以下命令运行:

yarn node

# 或者

node --require="./.pnp.js" index.js

.pnp.js文件不应该提交到版本库, 这个文件里面包含了硬编码的缓存目录. 在 Yarn v2 中会进行重构

怎么集成到现有项目?

pnp 集成无非就是重新实现现有工具的模块查找机制. 随着前端工程化的发展, 一个前端项目会集成非常多的工具,
如果这些工具没法适配, 可以说 pnp 很难往前走. 然而这并不是 pnp 能够控制的, 需要这些工具开发者的配合.

社区上不少项目已经集成了 pnp:


Node

对于 Node, pnp 是开箱即用的, 直接使用--require="./.pnp.js"导入.pnp.js文件即可,
.pnp.js会对 Node 的 Module 对象进行 patch, 重新实现模块查找机制

Webpack

Webpack 使用的模块查找器是enhanced-resolve, 可以通过pnp-webpack-plugin插件来扩展enhanced-resolve
来支持 pnp.

const PnpWebpackPlugin = require(`pnp-webpack-plugin`);

module.exports = {
resolve: {
// 扩展模块查找器
plugins: [PnpWebpackPlugin],
},
resolveLoader: {
// 扩展loader模块查找器.
plugins: [PnpWebpackPlugin.moduleLoader(module)],
},
};

jest

jest支持通过resolver来配置查找器:

module.exports = {
resolver: require.resolve(`jest-pnp-resolver`),
};

Typescript

Typescript 也使用自己的模块查找器, TS团队为了性能方面的考虑, 暂时不允许第三方工具来扩展查找器. 也就是说暂时不能用.

在这个issue中, 有人提出使用"moduleResolution": "yarnpnp"或者使用类似ts-loaderresolveModuleName的方式支持 pnp 模块查找.

TS 团队的回应是: pnp(或者 npm 的 tink)还是早期阶段, 未来可能会有变化, 例如.pnp.js文件, 显然不合适那么早入坑.
另外为了优化和控制编译器性能, TS 也没有计划在编译期间暴露接口给第三方执行代码.

所以现在 Typescript 至今也没有类似 babel 的插件机制. 除非自己实现一个’TS compiler host’, 例如ts-loader就自己扩展了插件机制和模块查找机制, 来支持类似ts-import-plugin等插件, 因此ts-loader现在是支持 pnp 的:

const PnpWebpackPlugin = require(`pnp-webpack-plugin`);

module.exports = {
module: {
rules: [
{
test: /\.ts$/,
loader: require.resolve('ts-loader'),
options: PnpWebpackPlugin.tsLoaderOptions(),
},
],
},
};


总结, Typescript暂时不支持, 且近期也没有开发计划, 所以VsCode也别指望了. fork-ts-checker-webpack-plugin也还没跟上. 显然 Typescript 是 pnp 的第一拦路虎


其他工具

总结

综上, pnp 是一个不错的解决方案, 可以解决 Node 模块机制的空间和时间的效率问题. 但是在现阶段, 它还不是成熟, 有
很多坑要踩, 且和社区各种工具集成存在不少问题. 所以还不建议在生产环境中使用.

所以目前阶段对于普通开发者来说, 如果要提升npm安装速度, 还是得上SSD+Copy-On-Write!😂

下面是各种项目的集成情况(✅(支持)|🚧(计划中或不完美)|❌(不支持)):

项目
Webpack
rollup
browserify
gulp
jest
Node
Typescript/VScode IntelliSense
eslint 🚧
flow 🚧
create-react-app 🚧
ts-loader
fork-ts-checker-webpack-plugin 🚧

参考

相关 issues:

其他方案

  • npm tink: a dependency unwinder for javascript
  • pnpm Fast, disk space efficient package manager
  • Yarn Workspaces 多个项目共有依赖