pnpm 问题记录

image

背景

2022 前端技术领域会有哪些新的变化? 话题中我曾回答到,越来越多的项目会开始使用 pnpm。

这是我正在推动的一件事,使用 pnpm 替换现在的 yarn 。无论是 csr 、ssr、monorepos 等类型项目都正在进行中,有近 10个项目已经迁移完成。
当时 yarn 的 pnp 特性出来的时候,观望过一阵子,没有大面积火起来,遂放弃 …
现在是注意到 vite、modernjs 等使用了 pnpm,其设计理念与node_modules的目录结构也能让业务更加快速安全,所以决定开始全面使用 pnpm。

下面记录与分享一下最近使用 pnpm 遇到的问题与解决的过程~

✅ 已解决的问题

jest 单测运行失败

  • 问题描叙: 使用 pnpm 后,原有的 jest 单测失败了
  • 问题解决: jest 低版本不支持软链接, 升级 jest 大于等于 25.2.0 版本即可
  • 报错信息:
    ca958dffa2ef75ad06f068262b471a7ea952fc25
  • 问题分析:
    1. 根据报错的堆栈找到报错的包,发现其 packge.json dependencies 字段明确声明了依赖的 @xxx/fetch 的版本。但是从报错的信息看,实际运行测试时 import 的却是错误的版本了!
    2. 这里就需要思考一下 jest 是如何运行一个单测用例。如果是简单的 node xxx.test.js 运行一个单测那就不会有上面引用到错误版本依赖的问题,因为按照 node require 模块的规则是不会解析出错。
    3. 让我们回头看一个简单的 jest 单测用例,可以大胆推测一下每个 describe 或者是 it 语句就是一个单独的沙盒环境。如果简单的运行 node xxx.test.js 那就只会存在一个沙盒环境,所有的测试用例会共用一个上下文,这样明显不利于 jest 每个单测隔离的原则
    4. 所以我们初步判断 jest 会自己创建若干个沙盒环境去运行对应的测试代码。而用户的 src 待测试的代码在 jest 看来会通过 fs.readFileSync 去获取到内容字符串,然后不同类型文件经过 babelTransform 或者 tsTransform 得到 js 代码,最后通过 vm 或者 eval, new Function 这样去运行。
    5. 所以说代码中的 import require 等语句的路径是 jest 去静态分析补充完整的,低版本的 jest resolve 不支持软链接是完全有可能的,所以我们顺着 jest 的发版日志,找到最近支持软链接的版本问题解决。
    6. 同理 nextjs 等项目如果有问题也需要找到最近支持软链接的版本进行升级
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Copyright 2004-present Facebook. All Rights Reserved.

'use strict';

jest.useFakeTimers();

describe('timerGame', () => {
beforeEach(() => {
jest.spyOn(global, 'setTimeout');
});
it('waits 1 second before ending the game', () => {
const timerGame = require('../timerGame');
timerGame();

expect(setTimeout).toBeCalledTimes(1);
expect(setTimeout).toBeCalledWith(expect.any(Function), 1000);
});

it('calls the callback after 1 second via runAllTimers', () => {
const timerGame = require('../timerGame');
const callback = jest.fn();

timerGame(callback);

// At this point in time, the callback should not have been called yet
expect(callback).not.toBeCalled();

// Fast-forward until all timers have been executed
jest.runAllTimers();

// Now our callback should have been called!
expect(callback).toBeCalled();
expect(callback).toBeCalledTimes(1);
});
});

node-gyp rebuild failures

  • 问题描叙: 使用 pnpm 后,项目中依赖的 Node.js C++ 插件 rebuild 失败
  • 问题解决: pnpm 低版本 bug,升级 pnpm 大于等于 6.23.1 版本即可,相关 issue issues/2135
  • 报错信息:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    # pnpm i better-sqlite3
    Packages: +11
    +++++++++++
    Resolving: total 11, reused 11, downloaded 0, done
    node_modules/.pnpm/registry.npmjs.org/integer/2.1.0/node_modules/integer: Running install script, done in 2s
    node_modules/.pnpm/registry.npmjs.org/better-sqlite3/5.4.3/node_modules/better-sqlite3: Running install script, failed in 393ms
    .../5.4.3/node_modules/better-sqlite3 install$ node-gyp rebuild
    │ gyp info it worked if it ends with ok
    │ gyp info using node-gyp@6.0.0
    │ gyp info using node@12.13.0 | linux | x64
    │ gyp info find Python using Python version 3.6.8 found at "/usr/bin/python3"
    │ gyp info spawn /usr/bin/python3
    │ gyp info spawn args [
    │ gyp info spawn args '/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/gyp_main.py',
    │ gyp info spawn args 'binding.gyp',
    │ gyp info spawn args '-f',
    │ gyp info spawn args 'make',
    │ gyp info spawn args '-I',
    │ gyp info spawn args '/root/2/node_modules/.pnpm/registry.npmjs.org/better-sqlite3/5.4.3/node_modules/better-sqlite3/build/config.gypi',
    │ gyp info spawn args '-I',
    │ gyp info spawn args '/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/addon.gypi',
    │ gyp info spawn args '-I',
    │ gyp info spawn args '/root/.cache/node-gyp/12.13.0/include/node/common.gypi',
    │ gyp info spawn args '-Dlibrary=shared_library',
    │ gyp info spawn args '-Dvisibility=default',
    │ gyp info spawn args '-Dnode_root_dir=/root/.cache/node-gyp/12.13.0',
    │ gyp info spawn args '-Dnode_gyp_dir=/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp',
    │ gyp info spawn args '-Dnode_lib_file=/root/.cache/node-gyp/12.13.0/<(target_arch)/node.lib',
    │ gyp info spawn args '-Dmodule_root_dir=/root/2/node_modules/.pnpm/registry.npmjs.org/better-sqlite3/5.4.3/node_modules/better-sqlite3',
    │ gyp info spawn args '-Dnode_engine=v8',
    │ gyp info spawn args '--depth=.',
    │ gyp info spawn args '--no-parallel',
    │ gyp info spawn args '--generator-output',
    │ gyp info spawn args 'build',
    │ gyp info spawn args '-Goutput_dir=.'
    │ gyp info spawn args ]
    │ Traceback (most recent call last):
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/gyp_main.py", line 50, in <module>
    │ sys.exit(gyp.script_main())
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/__init__.py", line 554, in script_main
    return main(sys.argv[1:])
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/__init__.py", line 547, in main
    return gyp_main(args)
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/__init__.py", line 532, in gyp_main
    │ generator.GenerateOutput(flat_list, targets, data, params)
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/generator/make.py", line 2215, in GenerateOutput
    │ part_of_all=qualified_target in needed_targets)
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/generator/make.py", line 794, in Write
    │ extra_mac_bundle_resources, part_of_all)
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/generator/make.py", line 978, in WriteActions
    │ part_of_all=part_of_all, command=name)
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/generator/make.py", line 1724, in WriteDoCmd
    │ force = True)
    │ File "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/gyp/pylib/gyp/generator/make.py", line 1779, in WriteMakeRule
    │ cmddigest = hashlib.sha1(command if command else self.target).hexdigest()
    │ TypeError: Unicode-objects must be encoded before hashing
    │ gyp ERR! configure error
    │ gyp ERR! stack Error: `gyp` failed with exit code: 1
    │ gyp ERR! stack at ChildProcess.onCpExit (/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/lib/configure.js:351:16)
    │ gyp ERR! stack at ChildProcess.emit (events.js:210:5)
    │ gyp ERR! stack at Process.ChildProcess._handle.onexit (internal/child_process.js:272:12)
    │ gyp ERR! System Linux 4.15.0-33-generic
    │ gyp ERR! command "/usr/bin/node" "/usr/lib/node_modules/pnpm/lib/node_modules/node-gyp/bin/node-gyp.js" "rebuild"
    │ gyp ERR! cwd /root/2/node_modules/.pnpm/registry.npmjs.org/better-sqlite3/5.4.3/node_modules/better-sqlite3
    │ gyp ERR! node -v v12.13.0
    │ gyp ERR! node-gyp -v v6.0.0
    │ gyp ERR! not ok
    └─ Failed in 393ms
     ERROR  Command failed with exit code 1.
  • 问题分析: 会不会是 c++ 版本的问题?而得到的信息为该镜像使用 yarn 却是好的! 最终换了同一个 Node.js 版本的镜像又好了,辗转反侧才在 pnpm 的 issue 中找到真正的原因,为 pnpm 低版本的 bug。

同一个版本的包有两份副本

  • 问题描述: 同一个版本的包在 .pnpm 目录下却有两份副本
  • 问题解决: 添加 .pnpmfile.cjs 文件,忽略 peerDependencies,使其对 peer 的处理与 yarn 保持一致
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // .pnpmfile.cjs

    function readPackage(pkg, context) {
    if (pkg.name && pkg.peerDependencies) {
    // https://pnpm.io/zh/how-peers-are-resolved
    pkg.peerDependencies = {}
    }

    return pkg
    }

    module.exports = {
    hooks: {
    readPackage,
    },
    }

  • 报错信息:
    img
  • 问题分析: 这个问题困扰了很久,后仔细阅读了 pnpm 的 How peers are resolved 文档,确认了造成的原因。详细的讨论可见: discussions/4051

only-allow 命令影响到了业务项目

  • 问题描叙: 当你在一个公共包的项目中添加了 preinstall 的勾子,但是实际依赖该包的业务并未使用 pnpm,造成报错
  • 问题解决: only-allow 当作为依赖时不应该进行检查,暂时使用支持了该功能的 only-allow-test 包代替。对应的讨论见 : discussions/4131
    1
    2
    3
    4
    5
    {
    "scripts": {
    "preinstall": "npx only-allow pnpm"
    }
    }

❌ 未解决的问题

pnpm add 与 pnpm i 命令不会去重

  • 问题描叙: 当使用 pnpm add 或者 pnpm i 升级某个包时,会存在某几个版本兼容的包没有进行合并,导致存在多个版本。如 sass: ^1.30.0 和 sass: ‘^1.44.0’没有被合并,但是使用 pnpm update 去升级这个包是会进行合并
  • 问题分析: 由于使用 yarn 的习惯不小心就会发生这种情况,所以希望支持 pnpm deduplicate 去重的命令,每次构建前强制运行一次,反馈后作者表示 pnpm add 命令会保持和 update 命令同样的行为。对应的讨论见 discussions/4143
  • 临时解决: 找到了一个 pnpm update / install / add 命令后都会运行的一个勾子,在其中写了一个自定义的校验函数,如果pkgName 存在 dependencies 或者 devDependencies 中,就只能使用 pnpm update 命令去更新升级
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    // .pnpmfile.cjs

    const argv = process.argv.slice(2)

    function readPackage(pkg, context) {
    // Override the manifest of foo@1.x after downloading it from the registry
    if (pkg.name && pkg.peerDependencies) {
    // Replace bar@x.x.x with bar@2.0.0
    pkg.peerDependencies = {}
    }

    return pkg
    }

    function checkCommand() {
    const command = argv[0]
    const pkgJson = require('./package.json')
    const deps = Object.assign({}, pkgJson.dependencies || {}, pkgJson.devDependencies || {})

    if (['add', 'i', 'install'].some((name) => command === name) && typeof argv[1] === 'string') {
    const { name, version } = getNameAndVersion(argv[1])

    if (deps[name]) {
    throw new Error(
    `【Inspector 依赖检查】: 更新升级依赖请用 "pnpm update ${name}${
    version ? '@' + version : ''
    }" 命令 !!!`
    )
    }
    }
    }

    function getNameAndVersion(nameAndVersion) {
    let name = ''
    let version = ''
    let splitName = nameAndVersion.split('@')

    if (nameAndVersion.startsWith('@')) {
    // @xxxx/pkg@latest or @xxxx/pkg

    if (splitName.length === 3) {
    // @xxxx/pkg@latest
    name = `@` + splitName[1]
    version = splitName[splitName.length - 1]
    } else {
    // @xxxx/pkg
    name = `@` + splitName[1]
    version = ''
    }
    } else {
    // react@latest or react
    if (splitName.length === 2) {
    // @xxxx/pkg@latest
    name = splitName[0]
    version = splitName[1]
    } else {
    // @xxxx/pkg
    name = splitName[0]
    version = ''
    }
    }
    return {
    name,
    version: version || 'latest',
    }
    }

    module.exports = {
    hooks: {
    readPackage,
    afterAllResolved(lockfile) {
    checkCommand()
    return lockfile
    },
    },
    }

cypress e2e 测试运行失败

  • 问题描叙: 使用 pnpm 后,原有的 cypress e2e 测试失败了
  • 问题分析: 经过 debug 发现 cypress 还不支持 pnpm, 于是提了一个 pr,cypress 处理跟进较慢,还未解决