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

8,340 阅读6分钟

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

原文链接: Yarn Plug'n'Play可否助你脱离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 多个项目共有依赖