pnpm 装 React 原型这件事,说起来简单,但我见过太多人卡在第一步——敲完命令盯着终端里滚动的进度条发呆,或者第二天发现 C 盘少了好几个 G。原型项目的特点很明确:起得快、扔得也快、数量还多,一个人手里同时挂着三五个 Demo 是常态。这种场景下,"能跑起来"只是底线,真正影响手感的是装包够不够快、磁盘够不够省、依赖够不够干净。pnpm 恰好在这三件事上都比传统方案更精明,但它那套基于硬链接和符号链接的严格目录结构,也让不少新手第一次遇到Module not found时一脸茫然。这篇内容就围绕"5 分钟用 pnpm 搭一个 React 原型项目"这条主线展开,从环境自检、脚手架选型、最小可用配置,一直讲到踩坑排查和原型沉淀。前端新手能照着抄完整流程,有经验的同行也能从依赖管理那一节里找到一些平时容易忽略的细节。
1. 原型项目为什么值得专门换用 pnpm
1.1 从"每个项目一份 node_modules"的浪费说起
传统 npm 的安装模型很好理解:每个项目的node_modules都是独立、完整的一份拷贝。一个带 TypeScript、路由、状态管理、UI 组件库的 React 原型,装完之后node_modules目录动辄 300MB 到 500MB,文件数量两万到四万个不等。这个数字单看一个项目不吓人,但你想想实际情况——验证一个弹窗方案建一个、试一套图表库建一个、给同事演示一个——一个月下来七八个项目躺在硬盘里,光依赖就吃掉三四个 G。而且这里面 90% 的内容是完全重复的,React 还是那个 React,TypeScript 还是那个 TypeScript,只是被拷贝了七八遍。
pnpm 的做法是把包体本身存到全局仓库(store)里,项目里的node_modules只保留硬链接。硬链接你可以理解成给同一份文件起了第二个名字——文件内容只有一份,两个名字都指向它,删掉其中一个名字文件还在。所以当你建第十个 React 原型时,React 那一份代码在磁盘上始终只有一份,新项目里增加的只是一个链接条目。我从自己机器的实际观察看,第一个 React + TS 项目装完大概 280MB,后面同样技术栈的新项目增量通常在 30MB 以内,主要是一些版本差异和项目自己的构建产物。对于频繁起原型的场景,这个差距会随着项目数量线性放大。
1.2 安装速度的差距到底来自哪里
很多人以为 pnpm 快是因为"下载快",其实下载环节大家都是从同一个源拉 tarball,差距没那么大。真正的差距在文件写入上。npm 要把解压后的几万个文件逐个写到项目目录,这是典型的随机小文件 IO,在机械硬盘或者被杀的 Windows Defender 面前非常痛苦。pnpm 大部分情况下只需要在 store 里解压一次,项目里创建硬链接,链接操作几乎是瞬时完成的。
我在一台普通办公本上做过非严格对比:全新缓存下,一个 React + TS + 路由 + axios + UI 库的组合,npm 大概 45 秒到 1 分钟,pnpm 在 20 秒上下;如果 store 里已经有这些包的相同版本,pnpm 二次安装经常能压到 3 到 5 秒,快得有点不真实。这也解释了为什么"5 分钟搭原型"这个说法成立——时间不是省在敲命令上,是省在等进度条上。当然你要是第一次装、网络又一般,那 5 分钟确实紧张,这时候别硬撑,先把源配好再说。
1.3 严格的依赖结构是优点也是门槛
npm 从 v3 开始搞扁平化提升,把所有依赖尽量平铺到顶层node_modules,好处是兼容性好,坏处是产生"幽灵依赖"——你的代码里import _ from 'lodash'能跑通,但package.json里根本没写 lodash,只是某个依赖顺带装了它。这种代码在本地岁月静好,换个包管理器或者升级依赖就炸。
pnpm 默认不扁平化,node_modules顶层只放你package.json里明确声明的包,其余全部藏在.pnpm目录里用符号链接串起来。这相当于强制你做"依赖诚实"的人。好处是项目可移植性强,坏处是某些老库、某些插件会找不到自己"以为存在"的包,直接报错。这不是 pnpm 的 bug,是那些包本身写得不规范。理解这一点,后面遇到报错你就不会慌。
1.4 什么情况下我不建议硬上 pnpm
说句公道话,pnpm 不是万能药。如果你接手的是一套历史项目,package.json里一堆包版本互相咬着,node_modules里还有手工 patch 过的文件,这时候贸然换包管理器,大概率要花一整天调依赖,得不偿失。另外某些对目录结构极度敏感的构建插件,在 pnpm 下需要额外的nodeLinker配置才能正常工作。
但对于新建原型这个场景,情况完全相反:没有历史包袱、没有奇怪的 patch、依赖清单干干净净,这正是 pnpm 最舒服的战场。所以我的判断标准很简单——新起的、用完就扔的、拿来验证想法的项目,一律 pnpm;维护多年的老项目,老老实实跟着它原来的走。
2. 五分钟倒计时开始前的环境自检
2.1 Node 版本这一关必须先过
所有现代 React 脚手架都对 Node 版本有硬要求,Vite 5 往上基本要求 Node 18 起步,用 Node 16 会遇到各种奇奇怪怪的语法报错,报错信息还指不到根上,非常浪费时间。动手之前先敲两条命令:
node -v npm -v理想输出是v18.x或v20.x。如果版本太低,别想着绕过,直接去官网装一个新的。这里有个经验:优先装 LTS 版本,别追最新的奇数版本。奇数版本是尝鲜版,生命周期短,某些原生模块的预编译包还没跟上,装依赖时容易触发本地编译,一编译就是几分钟起步,还可能要装构建工具链,5 分钟的计划直接崩盘。
如果你机器上存在多个项目需要不同 Node 版本,可以装一个版本切换工具来管理,切换成本很低。我个人的习惯是给原型项目单独留一个 LTS 版本,避免和主项目互相干扰。
2.2 用 corepack 启用 pnpm,别急着全局安装
Node 16.13 之后自带了一个叫 corepack 的东西,专门用来管理包管理器的版本。相比npm i -g pnpm,我更推荐用 corepack,原因是它能把 pnpm 版本和项目绑定——你在package.json里写上"packageManager": "pnpm@9.x.x",团队成员拉下代码后 corepack 会自动用对应版本,不会出现"你装的 pnpm 9 我装的 pnpm 8,锁文件格式对不上"的尴尬。
启用方式:
corepack enable corepack prepare pnpm@latest --activate pnpm -v第一条命令会在 Node 的 bin 目录里创建 shim(可以理解成转发器),第二条把指定版本的 pnpm 下载并激活。跑完pnpm -v能看到版本号就说明通了。
注意:Windows 上如果
corepack enable报权限错误,用管理员权限开一个终端再执行一次。另外某些环境里 corepack 的下载会被拦,这时候退回npm i -g pnpm也能用,只是少了版本锁定这层保障。
2.3 把源和缓存目录一次配好
这一步很多人跳过,然后在安装阶段卡十分钟。pnpm 读的是.npmrc配置,有两件事值得在第一次就设好:一是 registry 指向一个访问顺畅的源,二是 store 目录的位置。
registry=https://registry.npmmirror.com store-dir=D:\.pnpm-store第一行换了源,第二行把全局仓库放到空间大的盘符。为什么要把 store 单独拎出来?因为它会随着你用过的包版本不断累积,时间长了可能有几个 G。如果你的系统盘本来就紧张,让它待在默认位置迟早出问题。这个路径一经设定就别随便改,改了之后旧项目里的硬链接会全部失效,只能重新装。
配置文件放全局(用户目录下的.npmrc)还是放项目里,我的建议是全局配源和 store,项目里只放和项目相关的配置(比如某些包的提升规则)。全局一份,所有新项目自动继承,省得每次重复。
2.4 一张自检清单省掉一半返工
动手建项目之前,花 30 秒过一遍这几项,能避免后面 80% 的莫名其妙:
| 检查项 | 期望状态 | 不达标的表现 |
|---|---|---|
| Node 版本 | 18.x 或 20.x LTS | 报语法错误、??和?.无法解析 |
| pnpm 可用 | pnpm -v有输出 | 命令不存在或版本低于 8 |
| registry | 能正常拉包 | 安装卡在resolving阶段不动 |
| store 盘空间 | 剩余 5GB 以上 | 安装到一半报ENOSPC |
| 终端权限 | 当前目录可写 | 创建文件失败或生成EPERM |
这套自检看起来啰嗦,但我踩过的坑基本都在里面。特别是最后一条,Windows 上把项目建在C:\Program Files下面然后一路报权限错误的,我见过不止一次。
3. 脚手架选型:为什么原型项目我几乎只用 Vite
3.1 冷启动速度是原型场景的第一生产力
原型项目的使用节奏是"改一下、看一眼、再改一下",热更新反馈的延迟直接影响思路连贯性。基于打包器的老方案在这件事上有天然劣势:启动要先把整个依赖图走一遍,项目稍微大一点就是十几秒起步,改个文件再等一两秒才能在浏览器里看到效果。Vite 的开发模式直接利用浏览器原生模块加载能力,启动时间基本和项目规模无关,几百毫秒就起来了,热更新通常在一两百毫秒内完成。
这个差距在原型阶段被放大了。原型的特点是代码量不大但依赖不少——你可能同时装了路由、图表库、UI 组件库、日期处理,这些东西在老方案里都要参与预打包,启动时间立刻上去了。Vite 的依赖预构建走的是 esbuild,速度快一个量级。所以"5 分钟出原型"这句话能成立,Vite 功不可没,光靠 pnpm 是撑不起来的。
至于另一套曾经很流行的脚手架方案,现在新建项目已经不太推荐了,它的构建体系对现代工具链的适配比较滞后,配置也偏重。原型项目要的是轻,不是全。
3.2 一条命令把项目骨架拉起来
选型定了就动手,创建命令很简单:
pnpm create vite my-proto --template react-ts这条命令会做三件事:下载 create-vite 脚手架、按react-ts模板生成文件、在当前目录下建一个叫my-proto的文件夹。用 TypeScript 模板是我的默认选择,理由不是"TS 更高级"这种空话,而是原型项目经常改结构、加字段,类型提示能在你忘了某个组件要传什么 props 的时候救你一把,而且现在编辑器对 TS 的支持已经很顺滑,写起来的额外负担很小。
如果你连类型都不想管,把react-ts换成react就是纯 JS 模板。不过说实话,两个模板的文件差异极小,多写几个类型注解的成本,远低于后期排查"这个字段到底是 string 还是 number"的时间。
命令里的--template参数也可以省略,此时会进入交互式选择,让你挑框架和变体。交互模式的好处是能看到有哪些可选模板,坏处是多按几次回车。赶时间就直接带参数。
3.3 进去之后的第一件事不是 npm install
创建完成后终端通常会提示你cd进去然后装依赖。这里有个细节:一定要用 pnpm 装,不要顺手敲 npm install。原因很实在,npm 会在项目里生成package-lock.json,pnpm 生成的是pnpm-lock.yaml,两个锁文件同时存在,后续谁说了算就很混乱,CI 上也容易出问题。
正确顺序:
cd my-proto pnpm install pnpm devpnpm install在 store 已有缓存的情况下通常几秒钟结束,然后pnpm dev启动开发服务器,终端会打印一个本地地址,通常是http://localhost:5173。浏览器打开能看到 Vite 和 React 的默认页面,到这一步,"5 分钟"的目标实际上已经达成了。剩下的时间是用来把它改造成你真正想要的原型。
3.4 目录结构先摸清楚再动手改
脚手架生成的目录不大,但每一块的作用值得花一分钟搞清楚,不然后面加配置容易放错地方:
my-proto/ ├── public/ 静态资源,原样拷贝到产物目录 ├── src/ │ ├── assets/ 参与构建的图片等资源 │ ├── App.tsx 根组件 │ ├── main.tsx 入口,挂载 React 到 DOM │ └── index.css 全局样式 ├── index.html 页面模板,Vite 的入口 HTML ├── vite.config.ts 构建与开发服务器配置 ├── tsconfig.json TS 编译配置 └── package.json 依赖与脚本有个地方新手常困惑:index.html在项目根目录,而不是像老方案那样藏在public里。这是 Vite 的设计,它把这个 HTML 当成入口来解析,里面的<script type="module" src="/src/main.tsx">就是整个应用的起点。你要改页面标题、加 meta 标签,直接改这个文件,不用去翻别的地方。
提示:
public目录下的文件不会经过构建处理,文件名也不会带 hash。放 favicon、放一些第三方给的静态 JS 是合适的,但放需要压缩优化的图片就不合适了,那些应该扔到src/assets里让构建流程处理。
4. 把一个空壳改成能用的原型
4.1 依赖装哪些、装在哪一类
空模板只有 react 和 react-dom,做原型肯定不够。但我不建议一上来就把"前端全家桶"全装上,那会让package.json变成一坨谁也不清楚用途的清单。我的习惯是按需分批,第一批只装跑通核心交互必须的东西:
pnpm add react-router-dom zustand axios pnpm add -D @types/nodereact-router-dom管页面跳转,原型里经常要模拟多页面流程;zustand是个极轻的状态库,几行代码就能建一个 store,比引入一整套状态管理方案更适合原型;axios负责请求,用来对接后端接口或者 mock 数据。@types/node放在开发依赖里,是为了在vite.config.ts里能正常使用path模块做路径别名,这个后面会细说。
装包时有两个参数要分清楚:pnpm add写进dependencies,pnpm add -D写进devDependencies。原型项目里这个区分其实不影响运行(构建时都会装),但保持规范有个实际好处——将来把这个原型转成正式项目时,依赖分类是干净的,不用回头整理。
4.2 路径别名:省掉一堆../../../
原型项目目录浅的时候还好,一旦开始拆组件,import Button from '../../../components/Button'这种写法就开始恶心人了。配个别名,把src映射成@,写起来清爽很多。
vite.config.ts里加:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, })光配 Vite 不够,TypeScript 有自己的模块解析逻辑,编辑器里会飘红。tsconfig.json里补上对应的 paths:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }两处都要配,缺一不可。这是新手最容易漏的地方:只配了 Vite,运行时正常但编辑器一路红线;只配了 tsconfig,编辑器不报错但跑起来找不到模块。我一开始也漏过,排查了十几分钟才反应过来是两份配置的事。
4.3 开发服务器代理,绕开跨域这个拦路虎
原型阶段对接后端接口,十有八九会遇到跨域。这时候别去求后端加响应头,自己在前端配个代理最快。vite.config.ts里加server段:
export default defineConfig({ server: { port: 5173, open: true, proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true, rewrite: (p) => p.replace(/^\/api/, ''), }, }, }, })几个参数值得说清楚:target是真实后端地址,changeOrigin把请求头里的 Host 改成目标地址(很多后端会校验这个),rewrite负责把路径前缀去掉——前端写/api/user/list,实际请求到后端是/user/list。这三行配置能省掉大量沟通成本。
open: true是个小福利,启动后自动打开浏览器,省一次手动点击。原型阶段一天重启十几次,这个设置累积起来能省不少事。
4.4 环境变量:区分本地和演示环境
原型经常要在两个地方跑:自己机器上,和丢给同事看的临时环境。接口地址不一样,硬编码进代码里每次改都麻烦。Vite 的环境变量机制很轻,根目录建.env.development和.env.production,变量名必须以VITE_开头才会被注入到客户端代码:
VITE_API_BASE=/api VITE_APP_TITLE=原型演示代码里用import.meta.env.VITE_API_BASE读取。注意这里有个安全常识:带VITE_前缀的变量会被打进产物,任何人都能在浏览器里看到,所以只放接口地址、标题这类公开信息,任何密钥、令牌都不能往这里放。
.env.local这个文件名有特殊待遇,它会被 git 忽略,适合放个人本地的覆盖配置。我通常把团队共用的写进.env.development,自己机器上的特殊配置写进.env.local,互不干扰。
4.5 五分钟实际花在哪了
把上面这些拆开看,时间大致是这样分布的:
| 环节 | 耗时 | 备注 |
|---|---|---|
| 环境自检 | 30 秒 | 版本对了基本一次过 |
| 创建项目 | 10 秒 | 有网络缓存时更快 |
| 首次装依赖 | 10 到 30 秒 | 冷缓存可能到 1 分钟 |
| 装常用依赖 | 5 到 15 秒 | store 命中时几乎瞬时 |
| 配置别名与代理 | 2 分钟 | 主要是想清楚路径怎么写 |
| 启动验证 | 5 秒 | 第一次启动稍慢 |
真正"敲命令"的时间不到一分钟,剩下全在配置和理解上。所以我说 5 分钟是熟练之后的稳定值,第一次做的话给自己留 15 分钟更现实,别被标题带节奏然后怀疑人生。
5. 踩过的坑和对应解法
5.1Module not found但包明明装着
这是 pnpm 新手遇到的第一个经典问题。现象是:代码里import xxx from 'some-lib'报找不到模块,你去node_modules里翻,发现some-lib确实存在。原因几乎总是同一个——这个包是别的依赖带进来的,你的package.json里没有声明它。
pnpm 的严格结构让这种"蹭依赖"的行为暴露出来了。解法也很直接:需要用什么就显式装什么。
pnpm add some-lib这条规矩值得当成习惯来养。它能让你在半年后回头看这个原型时,一眼就知道它依赖了什么,而不是面对一个"能跑但不知道为什么能跑"的黑盒。
5.2 peerDependencies 警告刷屏
装某些 UI 库或者插件时,终端会刷出一片黄色警告,大意是"某某 peer 依赖没有满足"。这类警告需要区分对待:如果它说的是 react 版本不匹配,而你用的是 React 18 而它要 React 17,那就要认真看一眼,可能有兼容问题;如果它说的是某个可选依赖没装,通常不影响使用。
pnpm 默认不会自动安装 peer 依赖,这是有意为之的严格设计。有些团队为了省事会在.npmrc里加auto-install-peers=true,让 pnpm 自动补齐。我的态度是:新项目可以开,老项目谨慎。新项目开了省心,老项目开了可能把原本的依赖版本顶掉,引发连锁反应。
还有一种情况是某个包死活找不到 react,即使你已经装了。这时候可以在.npmrc里针对性地放开提升:
public-hoist-pattern[]=*react*这个配置的意思是"把匹配到的包提升到顶层node_modules",让那些不会正确解析符号链接的老包也能找到。它是妥协方案,不是首选,用的时候心里要清楚这是在给不规范的包让路。
5.3 锁文件冲突和 CI 上的安装失败
多人协作时,pnpm-lock.yaml是最容易起冲突的文件之一。冲突本身不可怕,可怕的是手工 merge 之后锁文件内容不一致,导致本地能跑、CI 挂掉。
处理原则很简单:永远不要手工编辑锁文件。冲突了就把本地改动放一边,重新pnpm install让 pnpm 自己算一遍,然后确认依赖版本符合预期再提交。CI 上则应该用严格模式:
pnpm install --frozen-lockfile这个参数的作用是"锁文件和package.json不一致就直接失败",不允许 CI 环境自己更新锁文件。听起来很严格,但它能保证构建的可重复性——今天构建出来的和三个月后构建出来的依赖完全一致。
5.4 store 出问题时的急救手段
极少数情况下 store 会损坏,表现是安装时报一堆校验失败或者链接错误。这时候不用重装整个环境,pnpm 提供了清理命令:
pnpm store path pnpm store prune第一条告诉你 store 在哪,第二条清理掉没有被任何项目引用的包。注意prune清理的是"孤儿包",不会动正在用的,所以相对安全。如果问题依旧,可以把 store 目录整个删掉重新装——代价是所有项目的硬链接失效,需要各自重新pnpm install,这算是最后手段。
注意:千万不要在项目运行中直接删
node_modules下的内容,尤其是.pnpm目录。硬链接的删除是即时的,删完之后别的项目可能也受影响(如果它们共享同一份文件的话,虽然实际是删链接不删文件,但很容易误操作)。要清就整个目录清掉再重装。
5.5 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
Module not found但包存在 | 幽灵依赖未声明 | pnpm add显式声明 |
装包卡在resolving | 源不可达 | 检查.npmrc的 registry |
| peer 依赖警告刷屏 | 严格模式不自动补 | 评估后开auto-install-peers |
| 端口被占用 | 上次进程没退干净 | 换端口或结束占用进程 |
| 别名不生效 | 只配了 Vite 或只配了 TS | 两处都配 |
| 环境变量读不到 | 变量名没加VITE_前缀 | 改名并重启开发服务器 |
改.env后无效 | 环境变量在启动时注入 | 重启pnpm dev |
| 构建产物白屏 | 部署路径与base不符 | 配base为子路径 |
| store 报校验失败 | 缓存损坏 | pnpm store prune |
| CI 安装失败 | 锁文件不一致 | 本地重新生成并提交 |
这张表我建议存一份,遇到问题时从上往下扫一遍,比漫无目的地搜索快得多。特别是环境变量那两行,"改了.env没重启"和"忘了加前缀"这两个错误,我自己犯过不止一次。
6. 把一次性原型沉淀成可复用的起手式
6.1 模板复用:从复制粘贴到一条命令
原型做多了你会发现,七八个项目的前 80% 配置是一模一样的:别名、代理、目录结构、状态管理的基础模板、请求封装。每次重新配一遍是纯浪费。最朴素的沉淀方式是留一个"种子项目",新建时直接复制目录,改个名字就行。缺点是它会带着上一个项目的残留代码,容易忘删。
稍微进阶一点的做法是把种子项目放到一个内部仓库里,用脚手架的方式拉取。Vite 的create命令支持自定义模板源,团队内部可以维护自己的模板包。这样一条命令就能拿到带好配置和基础封装的骨架,省下每次十几分钟的重复劳动。代价是模板本身需要维护——依赖版本升级、脚手架命令变化,都得跟着改。要不要做这件事,取决于你起原型的频率,一个月两三个的话,复制粘贴其实够用了。
6.2 原型代码要不要留、留多久
这个问题听起来不像技术问题,但它直接影响你的磁盘和心态。我的做法是给原型定一个"保质期":验证完想法、结论已经沉淀成文档或者合并进主项目的,两周内归档或删除。留下的只有两种情况——一是还在迭代中,二是里面有些工具函数值得抽出来复用。
归档的时候有个小技巧:把node_modules删掉再打包。因为里面绝大部分是硬链接和软链接,压缩工具处理起来又慢又占空间,删掉之后一个原型项目的源码压缩包通常只有几百 KB。需要的时候解压出来pnpm install,几秒钟就恢复了。这个习惯让我硬盘上永远清清爽爽。
6.3 一些让原型更像"半成品"的小配置
原型最终往往要给人看,哪怕是演示。花两分钟做这几件事,观感会好很多:一是把index.html的标题和 favicon 换掉,别让人看到一个带着脚手架默认图标的页面;二是配个 404 兜底路由,演示时手滑输错地址不至于露出空白页;三是把开发服务器的host打开,方便同一局域网内的同事用手机扫码看效果。
server: { host: '0.0.0.0', port: 5173, }这个配置让开发服务器监听所有网卡地址,终端会多打印一个局域网 IP,同事直接在手机浏览器输入就能访问。做移动端原型演示的时候,这招比发截图或者录屏直观得多。当然前提是你们在同一个网络下,这个不用多说。
6.4 关于"5 分钟"这个数字的实话
最后聊点实在的。5 分钟能搭出来的,是一个能跑、能改、结构干净的 React 骨架,加上你需要的几个基础依赖。它不包括业务逻辑、不包括像素级的样式、不包括和后端的联调。这个数字的价值在于给你一个心理锚点——让"起一个新项目"这件事的心理成本降到最低,想到就做,不因为"配环境好麻烦"而拖延。
我自己现在的实际节奏是:环境干净、网络正常的情况下,从敲命令到浏览器里看到页面大概两分半,剩下两分半用来配别名、代理和装那三个常用依赖。如果碰上首次装包或者网络抖动,时间会翻倍,这时候我不会硬等,先去把别的事情做了,回来看进度条已经跑完。工具的价值是省时间,不是给人添焦虑,卡住了就让它装,别盯着看。
再分享一个我用了很久的小技巧:把常用的那几条命令写成一个文本片段存在笔记里,新建项目时直接粘进终端。看起来是很笨的办法,但比起每次回忆--template参数是react-ts还是react-tsx,翻笔记只要两秒。工具链再先进,也替代不了这种土办法带来的确定性。