npm切换pnpm与gitlab ci/cd全流程优化(实战篇)

324 阅读8分钟

npm切换pnpm与gitlab ci/cd全流程优化(实战篇)

一、背景&痛点

背景介绍

在日常前端开发中,npm/yarn 安装依赖慢、磁盘占用大、CI/CD 构建时间长等问题屡见不鲜。pnpm 以其独特的硬链接机制和极致的依赖管理效率,成为提效关键的一项重器

实战目标

  1. React 项目从 npm 平滑切换到 pnpm

  2. GitLab CI/CD pipeline 全流程优化

  3. 依赖缓存加速、构建产物管理、pipeline 结构优化

二、npm切换pnpm实战

pnpm why ?

传统npm/yarn缺点

  • 幽灵依赖

    • 你的项目只安装了 A(npm install A),但因为扁平化结构,A 的依赖 B 也被提升到 node_modules 根目录,这样即使你没在 package.json 里声明 B,也能直接 import B from 'B' 并正常运行
  • node_modules 体积庞大

    • 多个项目、甚至同一个项目的不同依赖树下会反复复制同一个依赖包,导致磁盘空间大量浪费
  • 安装和 CI/CD 构建慢

    • 每次 npm install 或 yarn install 都要重新下载/解压依赖包,即使本地已经有相同的包

    • CI/CD环境下每次都要完整安装,浪费时间

  • 缓存机制不完善

    • npm/yarn 的缓存机制有限,不能像 pnpm 那样全局共享依赖内容,导致重复下载和解压,每次 pipeline 都要重新装依赖,难以加速
  • 依赖污染风险

    • 全局安装和本地安装混用,容易出现依赖污染,导致本地和线上表现不一致

pnpm优点

  • 节省磁盘空间

    • 如果你用到了某依赖项的不同版本,只会将不同版本间有差异的文件添加到仓库。 例如,如果某个包有 100 个文件,而它的新版本只改变了其中 1 个文件。那么 pnpm update 时只会向存储中额外添加 1 个新文件,而不会因为单个改变克隆整个依赖

    • 所有文件都会存储在硬盘上的某一位置。 当软件包被被安装时,包里的文件会硬链接到这一位置,而不会占用额外的磁盘空间。 这允许你跨项目地共享同一版本的依赖

  • 提高安装速度

    • pnpm分为三个阶段执行安装:

      • 依赖解析,仓库中没有的依赖都被识别并获取到仓库

      • 目录结构计算, node_modules 目录结构是根据依赖计算出来的

      • 链接依赖项,所有以前安装过的依赖项都会直接从存储区中获取并链接到 node_modules

    • pnpm包安装/传统包安装进度对比 image.png image.png

  • 创建一个非扁平的node_modules目录

    • pnpm 把所有包的真实内容放在全局 store(比如 ~/.pnpm-store)里

    • 在你的项目 node_modules 目录下,不会真的复制文件,而是通过硬链接指向 store 里的真实文件

    • 同时,为了让 Node.js 的模块查找机制(require/resolve)正常工作,pnpm 会用软链接(symlink)来“还原”依赖树结构

      • 举例说明,假设你的项目依赖如下:

      • A
        ├── B
        │   └── D
        └── C
            └── D
        
      • A 依赖 B 和 CB 和 C 都依赖 D

      • 在 pnpm 下,node_modules 结构大致如下:

      • node_modules/
        ├── .pnpm/          # 这里是所有依赖的硬链接
        │   ├── b@1.0.0/
        │   ├── c@1.0.0/
        │   ├── d@1.0.0/
        ├── b -> .pnpm/b@1.0.0/node_modules/b      # 软链接
        ├── c -> .pnpm/c@1.0.0/node_modules/c      # 软链接
        
      • node_modules/b 实际是指向 .pnpm/b@1.0.0/node_modules/b 的软链接

      • .pnpm/b@1.0.0/node_modules/b/node_modules/d 又是指向 .pnpm/d@1.0.0/node_modules/d 的软链接

      • 这样,依赖树结构被“还原”出来了,即每个包的 node_modules 目录下都能找到它自己的依赖(通过软链接),满足 Node.js 的模块查找规则

      • 总结一句话就是:pnpm 主要用硬链接节省依赖空间,同时用软链接还原 node_modules 依赖树结构

切换pnpm实战

  1. 安装pnpm(前置说明,pnpm依赖 node 版本18+,安装nvm 方便下载切换node环境,常用命令 nvm list; nvm install ;nvm use
npm install -g pnpm
  1. 初始化pnpm项目
// 这会自动读取 package-lock.json 或 yarn.lock,生成 pnpm-lock.yaml
pnpm import
// 也可以删除 node_modules package-lock.json yarn.lock 后通过pnpm init来初始化
pnpm init
  1. 替换命令
npm install -> pnpm install
npm run start -> pnpm run start
npm run build -> pnpm run build
  • 在执行pnpm install或者pnpm run build的时候可能会遇到package.json not found for dep xxx,解决方案是在根目录添加.npmrc文件,添加如下参数,作用是指定哪些依赖可以被提升(hoist)到根目录的 node_modules 下(public hoist zone),可以解决很多构建错误,但它破坏了依赖隔离,适合临时救火,不推荐长期使用,有选择地使用hoist
public-hoist-pattern[]=*

三、GitLab CI/CD Pipeline 优化

基础.gitlab-ci.yml示例

stages:
  - Install
  - Build

# 禁用一些无用提示或非必要操作
.default-before-script: &default-before-script
  - npm config set fund false
  - npm config set audit false
  - npm config set update-notifier false

.default-install-script: &default-install-script
  - npm install -g pnpm@8.6.0
  # 清理 pnpm 全局存储(store)中未被任何项目引用的包缓存
  - pnpm store prune
  # 安装依赖(固定锁文件、设置缓存目录)
  - pnpm install --unsafe-perm --frozen-lockfile --store-dir=.pnpm-store
  # 安全扫描逻辑,可选如果报错不组织加上||true
  - npm audit --audit-level=high || true

.default-build-script: &default-build-script
  - npm install -g pnpm@8.6.0
  - pnpm install --store-dir=.pnm-store
  - pnpm run build

# dev前缀(开发分支)提交commit缓存优化策略
Custom Install Check Dev:
  stage: Install
  image: your-registry.com/node:20
  tags: [ official ]
  variables:
    PIPELINE_ENV: "PROD"
  cache:
    key: pnpm-cache-dev
    paths:
      - .pnpm-store
    policy: pull-push
  rules:
    - if: '$CI_COMMIT_REF_NAME =~ /^dev(_|$)/'
      when: always
    - when: never
  before_script: *default-before-script
  script: *default-install-script

# release前缀(回归分支/发布分支)提交commit缓存优化策略
Custom Install Check Release:
  stage: Install
  image: your-registry.com/node:20
  tags: [ official ]
  variables:
    PIPELINE_ENV: "PROD"
  cache:
    key: pnpm-cache-release
    paths:
      - .pnpm-store
    policy: pull-push
  rules:
    - if: '$CI_COMMIT_REF_NAME =~ /^release(_|$)/'
      when: always
    - when: never
  before_script: *default-before-script
  script: *default-install-script

# 其他分支不包括dev&release前缀提交commit缓存优化策略
Custom Install Check Others:
  stage: Install
  image: your-registry.com/node:20
  tags: [ official ]
  variables:
    PIPELINE_ENV: "PROD"
  cache:
    key: pnpm-cache-others
    paths:
      - .pnpm-store
    policy: pull-push
  rules:
    - if: '$CI_COMMIT_REF_NAME !~ /^dev(_|$)/ && $CI_COMMIT_REF_NAME !~ /^release(_|$)/'
      when: always
    - when: never
  before_script: *default-before-script
  script: *default-install-script

# dev前缀(开发分支)提交commit并且不是merge request情况的build缓存优化策略
# 这里会读上面install产出的缓存文件pnpm-cache-dev,并且禁止上传
Custom Build Dev:
  stage: Build
  image: your-registry.com/node:20
  tags: [ official ]
  variables:
    PIPELINE_ENV: "PROD"
  cache:
    key: pnpm-cache-dev
    paths:
      - .pnpm-store
    # build的时候禁止上传,提交效率,以及避免将构建临时变更写入缓存
    policy: pull
  # 这里传递构建产物,不要上传无关产物,比如./,全路径
  artifacts:
    paths:
      - dist/
    expire_in: 1 month
  rules:
    - if: '$CI_COMMIT_REF_NAME =~ /^dev(_|$)/ && $CI_PIPELINE_SOURCE != "merge_request_event"'
      when: always
    - when: never
  before_script: *default-before-script
  script: *default-build-script

# release前缀(开发分支)提交commit并且不是merge request情况的build缓存优化策略
# 这里会读上面install产出的缓存文件pnpm-cache-release,并且禁止上传
Custom Build Release:
  stage: Build
  image: your-registry.com/node:20
  tags: [ official ]
  variables:
    PIPELINE_ENV: "PROD"
  cache:
    key: pnpm-cache-release
    paths:
      - .pnpm-store
    # build的时候禁止上传,提交效率,以及避免将构建临时变更写入缓存
    policy: pull
  # 这里传递构建产物,不要上传无关产物,比如./,全路径
  artifacts:
    paths:
      - dist/
    expire_in: 1 month
  rules:
    - if: '$CI_COMMIT_REF_NAME =~ /^release(_|$)/ && $CI_PIPELINE_SOURCE != "merge_request_event"'
      when: always
    - when: never
  before_script: *default-before-script
  script: *default-build-script

# 其他分支不包括dev&release前缀提交commit并且不是merge request情况的build缓存优化策略
# 这里会读上面install产出的缓存文件pnpm-cache-other,并且禁止上传
Custom Build Others:
  stage: Build
  image: your-registry.com/node:20
  tags: [ official ]
  variables:
    PIPELINE_ENV: "PROD"
  cache:
    key: pnpm-cache-others
    paths:
      - .pnpm-store
    # build的时候禁止上传,提交效率,以及避免将构建临时变更写入缓存
    policy: pull
  # 这里传递构建产物,不要上传无关产物,比如./,全路径
  artifacts:
    paths:
      - dist/
    expire_in: 1 month
  rules:
    - if: '$CI_COMMIT_REF_NAME !~ /^dev(_|$)/ && $CI_COMMIT_REF_NAME !~ /^release(_|$)/ && $CI_PIPELINE_SOURCE != "merge_request_event"'
      when: always
    - when: never
  before_script: *default-before-script
  script: *default-build-script

流程总结

  • 关闭 audit、fund、update-notifier 等无用提示,是为了让流水线更加干净和稳定,减少干扰

  • 增加 npm audit 这样的依赖安全扫描,安全管控上也算做了兜底

  • 通过将流程拆分为 Install 和 Build 两个阶段,可以更清晰地划分职责、提高缓存命中率,避免每次构建都重复拉取依赖,提升效率。而根据分支前缀(如 dev_release_ 等)划分不同的 Job 和缓存空间,是为了实现依赖缓存的隔离性,防止构建过程中因为依赖版本或配置差异导致相互污染或冲突

  • 规则(rules)的使用也是关键,它能精准控制 Job 的触发条件,避免所有任务在不必要的情况下运行,节省了 CI 资源。同时借助 .pnpm-store 这种持久化缓存,可以大幅加快依赖安装速度,提升整体构建性能

  • 后续也可以接入eslint job体系,全流程打造全流程闭环,期待一下

四、实战经验&采坑总结

  • pnpm store 路径:建议统一指定 .pnpm-store,方便缓存和管理

  • cache 污染问题:如果分支间依赖差异大,可用分支名隔离 cache

  • pipeline 结构:建议 install、build、deploy 分阶段,便于 artifacts 传递和调试

  • pnpm 版本一致性:CI 和本地建议锁定 pnpm 版本,避免因版本差异导致依赖不一致

  • 首次构建慢:首次没有 cache 时会慢一点,后续极快

五、结语

通过本次实战,React项目从npm平滑切换到pnpm,并结合GitLab CI/CD实现依赖缓存加速、构建产物管理和pipeline install build流程优化。希望对你团队的前端工程化和CI/CD效率提升有所帮助!

欢迎点赞、收藏、评论交流您的优化经验!