npm切换pnpm与gitlab ci/cd全流程优化(实战篇)
一、背景&痛点
背景介绍
在日常前端开发中,npm/yarn 安装依赖慢、磁盘占用大、CI/CD 构建时间长等问题屡见不鲜。pnpm 以其独特的硬链接机制和极致的依赖管理效率,成为提效关键的一项重器
实战目标
-
React 项目从 npm 平滑切换到 pnpm
-
GitLab CI/CD pipeline 全流程优化
-
依赖缓存加速、构建产物管理、pipeline 结构优化
二、npm切换pnpm实战
pnpm why ?
传统npm/yarn缺点
-
幽灵依赖
- 你的项目只安装了 A(
npm install A),但因为扁平化结构,A 的依赖 B 也被提升到 node_modules 根目录,这样即使你没在 package.json 里声明 B,也能直接import B from 'B'并正常运行
- 你的项目只安装了 A(
-
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包安装/传统包安装进度对比
-
-
创建一个非扁平的node_modules目录
-
pnpm 把所有包的真实内容放在全局 store(比如
~/.pnpm-store)里 -
在你的项目
node_modules目录下,不会真的复制文件,而是通过硬链接指向 store 里的真实文件 -
同时,为了让 Node.js 的模块查找机制(require/resolve)正常工作,pnpm 会用软链接(symlink)来“还原”依赖树结构
-
举例说明,假设你的项目依赖如下:
-
A ├── B │ └── D └── C └── D -
A依赖B和C,B和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实战
- 安装pnpm(前置说明,pnpm依赖 node 版本18+,安装nvm 方便下载切换node环境,常用命令
nvm list;nvm install;nvm use)
npm install -g pnpm
- 初始化pnpm项目
// 这会自动读取 package-lock.json 或 yarn.lock,生成 pnpm-lock.yaml
pnpm import
// 也可以删除 node_modules package-lock.json yarn.lock 后通过pnpm init来初始化
pnpm init
- 替换命令
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效率提升有所帮助!
欢迎点赞、收藏、评论交流您的优化经验!