最近在公司一台新Windows开发机上配Node环境,遇到一个很实际的问题:项目A要跑前端老工程,锁的是Node 16;项目B是新技术栈,必须用Node 20+;还有一些临时Demo,想尝鲜最新版本。来回卸载重装Node包显然不现实,于是我把目光重新放回版本管理工具。
搜了一圈,中文社区里Windows相关的Node.JS版本管理教程,十篇里有八篇是讲nvm-windows的。但我在上次重装系统后就决定不再用nvm-windows了,而是换成了fnm(Fast Node Manager)。原因很简单:fnm是用Rust写的,切换版本快且安静,不需要管理员权限,也能在macOS、Linux、Windows下用同一套命令。花了一晚上重装配置完,把过程整理出来,希望能帮到和我一样被Windows上Node版本折腾的人。
这篇文章不打算停留在“照着敲命令就能跑”的层面,我会把为什么要这么配、初始化脚本做了什么、以及Windows下那些容易踩的坑一起讲清楚。不管你是第一次接触Node版本管理,还是从nvm-windows迁移过来,都可以直接参考。
1. 为什么我建议Windows用户选fnm而不是老牌的nvm-windows
1.1 先聊清楚版本管理工具到底在管什么
很多人第一次接触“Node版本管理”时会有个误区:以为它像虚拟机一样装了好几个Node环境。其实不是,它做的事情非常朴素——把不同版本的Node发行包下载到本地,然后在需要时切换PATH中的指向。
Windows用户最痛苦的地方在于,系统PATH里一旦写死了某个C:\Program Files\nodejs,后续所有启动的终端、IDE、构建脚本都会读这个路径。手动改PATH一次两次还可以,项目多了就完全是一场灾难。所以版本管理工具本质上做的是一件事:维护一个本地版本库,动态决定当前终端应该用哪个Node。
fnm把版本放在%LOCALAPPDATA%\fnm\node-versions下,每个版本一个独立目录,彼此不干扰。切换时它并不去修改你系统里那个“公用的nodejs”目录,而是生成一个当前终端专属的临时快照路径,再把快照插入到PATH最前面。这就是它和nvm-windows最核心的机制差异,后面我会单独展开。
1.2 nvm-windows、volta、fnm三选一,我的取舍逻辑
先放一张我选择工具时的对比表,信息基于我实际使用体验和项目维护状态,供你参考:
| 对比维度 | nvm-windows | volta | fnm |
|---|---|---|---|
| 核心语言 | Shell / Batch | Rust | Rust |
| 是否需要管理员权限 | 安装和切换都要 | 不需要 | 不需要 |
| 切换版本方式 | 修改系统PATH并写入注册表 | 通过shim命令代理 | 动态生成multishell快照路径 |
| 按目录自动切换版本 | 不支持(需要手动use) | 支持且自动固定 | 支持(通过--use-on-cd) |
| 全局包隔离 | 所有版本共享同一份全局node_modules | 按版本隔离 | 按版本隔离 |
| 跨平台一致性 | 仅Windows | 支持三大平台 | 支持三大平台 |
| 社区维护活跃度 | 维护节奏较慢,历史坑较多 | 较活跃 | 非常活跃,目前已是大厂和开源圈主流选择之一 |
我最早也是老老实实用nvm-windows,但它有两个点让我很别扭:第一,每次切换版本都要弹一次管理员授权,CI脚本或者自动化批处理里特别难处理;第二,它过度依赖把整个系统PATH改来改去,一旦某次切换中途被杀掉,PATH可能处于一个半坏状态,后面所有命令都会受影响。
volta的设计思路也很好,它会把每个项目绑定到一个Node版本上,整个使用过程非常“无感”。但对一个经常需要在同一目录里反复切换Node版本来比对构建结果的人来说,volta那种“自动固定版本”的模式反而不够灵活。fnm正好站在中间:默认用fnm use显式切换,加了--use-on-cd后也能按目录自动切换,进可攻退可守。
1.3 fnm比nvm“快”的底层原因
nvm-windows切换慢,不只是因为要申请管理员权限,更本质的问题是它每次都会去读全量环境变量、执行注册表操作,再加一层Shell脚本解析。fnm是Rust编译出来的单个二进制文件,内部直接解析自己的版本元数据,然后用Windows的SetEnvironmentVariable机制只更新当前进程的环境变量。
有人实测过,fnm在Windows上的切换时间通常在几百毫秒以内,而nvm-windows往往要等一两秒甚至更久。这在单次操作上感受不明显,但如果你经常在多个项目目录之间来回跑、每次终端启动都要自动切版本,累积起来差别非常大。
还有一个很多人忽略的点:fnm启动一个新终端时,如果检测到.nvmrc文件,它会很快读取并走到对应版本;而nvm-windows因为本身不支持这种自动检测,所以每次打开终端都要手动确认当前是哪个版本。这种体验差异,用过就回不去了。
2. 从安装到首次启动:Windows环境下的完整路径
2.1 安装方式怎么选
fnm在Windows下的安装方式很灵活,官方提供了winget、scoop、chocolatey和手动解压四种主要途径。
我个人的建议是:如果系统是Windows 10 1709以上,直接用winget最省事:
winget install Schniz.fnm装完以后验证一下:
fnm --version如果你习惯用scoop管理命令行工具,也可以:
scoop install fnm手动安装的方式也不复杂:去GitHub Releases页面下载fnm-windows.zip,解压到一个固定目录,比如D:\Tools\fnm,然后把这个目录加入用户PATH。这种方式适合不想引入包管理器、又希望完全掌控安装位置的场景。
无论用哪种方式,装完后都要做同一个关键步骤:初始化Shell环境。因为fnm像一堆“工具链”一样,它需要把自身生成的配置注入到你正在使用的终端里。
2.2 PowerShell环境初始化,以及那条命令到底干了什么
打开Windows Terminal里的PowerShell,执行:
fnm env --use-on-cd | Out-String | Invoke-Expression这一步做完之后,当前PowerShell窗口就会对fnm“有感知”,你可以直接测一下:
fnm list不过这样只是当前窗口临时生效,关掉就没了。要想永久生效,需要把初始化脚本写进PowerShell的$PROFILE。先执行:
notepad $PROFILE如果提示找不到文件,说明Profile还不存在,先执行:
New-Item -ItemType File -Path $PROFILE -Force然后打开文件,把下面这一行加进去:
fnm env --use-on-cd | Out-String | Invoke-Expression保存关闭,重开一个终端窗口,fnm就常驻了。
为什么要用Out-String再Invoke-Expression,而不是直接fnm env --use-on-cd | Invoke-Expression?这算是PowerShell的一个坑:fnm输出的是大段文本脚本,在管道中会被PowerShell当作多行文本流处理,不先合并成单字符串就执行,偶尔会因为换行断句导致报错。加上Out-String强制合并,稳定很多。
--use-on-cd参数的含义是:当你用cd切换目录时,fnm会自动检查当前目录是否存在.nvmrc或package.json中的engines.node字段,然后自动切换到对应Node版本。这个功能很重要,建议在生产配置中一定加上。
2.3 CMD和Git Bash下的另类配置
如果你日常还在用CMD,fnm也能用,但体验并不好。因为CMD没有像PowerShell那样的Profile机制,你只能在每次开CMD时手动执行初始化。我实测过的写法是在系统环境变量里加一个AutoRun项,然后指向一个批量初始化脚本,但这条链路对小白来说既不透明也容易出错。更推荐的方式是直接在Windows Terminal的默认配置里把Shell换成PowerShell,让CMD逐渐退居二线。
Git Bash用户需要在~/.bashrc里加上:
eval "$(fnm env --use-on-cd --shell bash)"注意--shell bash这个参数。fnm在Windows上默认会根据父进程识别Shell类型,但在Git Bash里偶尔会误判成Windows CMD,导致输出的初始化脚本完全不对。显式指定一次更省心。
2.4 初始化可能遇到的PowerShell执行策略问题
很多人在执行notepad $PROFILE之后重启终端,发现fnm还是没有生效,大概率是被PowerShell的执行策略挡了。打开一个新PowerShell窗口,执行:
Get-ExecutionPolicy如果返回Restricted,那任何脚本都不会执行,自然也包括你的Profile。解决办法是运行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的意思是:本地创建的脚本可以运行,从网络下载的脚本必须带可信签名。这样既允许Profile生效,又不会把整个执行策略关成“无限制”,属于兼顾安全和便利的配置。设置完之后,再重开终端验证一次。
3. 核心命令与日常切换流程,照着敲就行
3.1 查看、安装和卸载版本
先看远端有哪些版本可以装:
fnm ls-remote这个输出会比较长,因为Node发版频率很高。如果只想看LTS版本,可以用:
fnm ls-remote --lts安装指定大版本(比如Node 20):
fnm install 20安装时会自动匹配这个大版本下最新的release。安装指定小版本:
fnm install 20.11.0查看本地已经装过哪些版本:
fnm list卸载不再需要的版本:
fnm uninstall 18.20.4我个人建议本地别留太多版本,一到两个LTS大版本加一个尝鲜版本就够了。版本越多,磁盘占用和全局包重复安装的问题越明显。
3.2 当前版本切换与默认版本设置
切换到某个已安装版本:
fnm use 20查看当前正在用的Node版本:
node -v或者:
fnm current如果你想让某个版本成为每次新开终端的默认版本,执行:
fnm default 20这里有个Windows下容易误解的点:在PowerShell里手动执行fnm use 20,只改变当前这个终端窗口的PATH指向,不影响其他已经打开的终端。新开的窗口会直接使用fnm default对应的版本。所以如果你要“全系统切换默认”,务必设置default,而不是只依赖某个窗口里的use。
3.3 .nvmrc与按目录自动切换的完整玩法
.nvmrc是Node生态一个约定俗成的版本声明文件,内容可以写20、20.11.0这种。我在每个前端项目根目录下都会放一个:
20然后确保初始化fnm时带了--use-on-cd参数。之后只要在这个目录下新开终端,fnm会自动切到Node 20;切到另一个写着16的项目目录,终端提示会显示当前Node版本已经跟着变了。
如果项目里不想多放一个.nvmrc文件,fnm也能读取package.json里的engines.node字段,不过它的优先级低于.nvmrc。两种方式配合使用,基本能覆盖绝大多数自动切换需求。
3.4 用alias给版本起名字,以及Corepack的管理
项目多了以后,老记版本号和目录对应关系很累。fnm支持给某个版本起别名:
fnm alias 20.11.0 default-lts-used之后就能用fnm use default-lts-used来切换,不需要记住具体版本号。
另一个值得提到的参数是--corepack-enabled。如果你的初始化命令是:
fnm env --use-on-cd --corepack-enabled | Out-String | Invoke-Expression那fnm会自动启用Node自带的Corepack。Corepack是Node官方用来管理yarn/pnpm的机制,启用后你在项目里执行corepack use pnpm@latest就能把pnpm版本锁进package.json的packageManager字段。对前端工程化来说,这让整个团队用的包管理器版本更一致。
我个人建议所有新配置的机器都加上--corepack-enabled,代价极小,但能少踩很多“我本地pnpm版本和CI不一样”的坑。
3.5 版本切过去之后,环境变量去哪了
这是Windows用户特别容易困惑的一块。很多人在一个终端里执行完fnm use 18后,再用where.exe node发现路径还是旧的,或者在已经打开的另一个终端里node -v没变,就开始怀疑fnm是不是没生效。
fnm的设计是:每一个启用了fnm init的Shell会话,都会得到一个独立的“multishell”环境文件夹,路径类似%LOCALAPPDATA%\fnm_multishells\<随机ID>\。这个文件夹里会放一个指向当前Node版本的符号链接和相关可执行文件,fnm只把这个临时文件夹加到当前会话的PATH最前面。所以每次新开终端,fnm都会重新构造一套隔离的路径快照,旧终端即使开着,PATH也不会被污染。
这个设计的好处是不同终端之间互不干扰,你可以一个终端用Node 16跑老项目,另一个终端用Node 20跑新项目,完全并行。坏处是,不要在已经打开的终端里期待“切换之后所有环境都跟着变”,要重开终端或者重新执行初始化才行。
4. Windows下实战避坑:我踩过的五个问题
4.1 坑一:系统PATH里残留了手动安装的Node路径
这是从“直接安装Node安装包”转过来最常见的坑。装好fnm、设置了默认版本,新开终端执行node -v,结果还是系统里那个老版本。
排查思路很简单:执行where.exe node,看看第一个匹配的路径是哪个。如果指向C:\Program Files\nodejs或C:\Users\<你的用户名>\AppData\Roaming\npm,说明系统PATH里老Node的优先级比fnm生成的multishell路径更高。
解决办法是打开系统环境变量编辑界面,把以下这几类路径清理掉:
C:\Program Files\nodejs\C:\Users\<你的用户名>\AppData\Roaming\npm\- 任何直接指向旧Node安装目录的项
同时检查用户PATH和系统PATH,两个地方都看一遍。清理完重开终端,where.exe node第一行指向的就应该是%LOCALAPPDATA%\fnm_multishells\...下的路径了。
4.2 坑二:IDE里终端不生效,或后台任务找不到Node
VS Code的终端默认确实会读取PowerShell Profile,但有一种情况不生效:你在VS Code里改过terminal.integrated.shell.windows,把默认Shell改成了Git Bash或其他Shell,那Profile就不是PowerShell那一套了。解决方法是在对应Shell自己的rc文件里加fnm初始化,前面Git Bash那一段已经在2.3里写过了。
JetBrains家的IDEA、WebStorm也一样,它们的Terminal工具窗口默认加载的Shell类型和系统终端可能不同。建议在项目启动配置里的环境变量里直接加一条:
Path=%LOCALAPPDATA%\fnm_multishells\当前版本路径;%Path%但这属于硬编码,每次版本一切换就容易失效。更稳妥的办法是让IDE路径用系统默认的PowerShell终端,然后在PowerShell Profile里统一初始化,然后再去IDE设置里确认Shell路径确实指向powershell.exe,这样最不容易出幺蛾子。
4.3 坑三:Git Bash下fnm use成功,但一执行node还是旧版本
这个问题排查了很久才找到原因。Git Bash里执行fnm env --use-on-cd初期化脚本时,它会往PATH里插入一个Windows风格的路径,但Git Bash对Windows的路径转换有一套自己的规则。如果你看到node -v输出的是旧版本,先检查初始化脚本有没有报类似command not found或路径解析异常。
我最终定位到的问题是:Git Bash下需要用--shell bash显式指定Shell类型,否则fnm会按系统默认的PowerShell脚本格式输出,在bash里解析直接失败。配置改完之后,顺手执行:
fnm current node -v which node三者应该保持一致,指向同一个版本才算成功。
4.4 坑四:下载Node版本太慢,卡在安装界面
fnm默认从Node官方源拉取发行包,某些网络环境下速度确实一言难尽。fnm提供了镜像配置环境变量,FNM_NODE_DIST_MIRROR可以指向镜像地址。我在国内开发时一般设成:
$env:FNM_NODE_DIST_MIRROR = "https://npmmirror.com/mirrors/node/"设置完后重启终端,再执行fnm install 20,下载速度会明显提升。
另外,即便Node下载成功了,npm的registry也可能导致依赖安装慢。顺手执行一次:
npm config set registry https://registry.npmmirror.com这两个镜像配合使用,能解决绝大多数Windows上Node前端环境的下载速度问题。
4.5 坑五:切换版本后全局包丢失,或者版本间全局包混乱
从nvm-windows转过来的人最容易踩的坑就在这里。nvm-windows虽然切换机制粗暴,但全局安装的包在不同版本之间是共享的。fnm为了保证版本纯净性,每个Node版本都有自己独立的全局node_modules目录。切换版本后,你会发现之前用npm全局安装的cli工具(比如nodemon、rimraf、create-react-app)全部不见了。
这其实是设计使然,不是bug。应对方式有几种:
最偷懒的方式是每个版本装好后,重新执行一遍需要保留的全局包安装命令。我个人的做法是整理一个清单脚本:
$globalPackages = @( "nodemon", "rimraf", "cross-env" ) foreach ($pkg in $globalPackages) { npm install -g $pkg }然后在切换并确认某个版本稳定后,跑一遍这个脚本即可。
另一种方式是减少全局依赖,能用npx临时执行的就用npx,能放进项目devDependencies的就放项目里。这样即使切换Node版本,全局包丢失影响也很小。
5. 还是想说几句日常使用心得
因为工作原因,我几乎每天都要在Node 16和Node 22之间来回切。用了大半年fnm之后,最大的体会是它把“版本切换”这件事降到了一种“无感”的程度:开终端、进目录、版本自动切好,然后专注写业务逻辑,不需要再为环境问题分心。
如果你问我初始化时最值得注意的是什么,我会说两件事:第一,务必把--use-on-cd放进Profile,自动切换带来的体验提升比任何手动优化都明显;第二,Windows上一定把老的系统Node路径清理干净,否则即使fnm切得很欢,系统里总有“另一个Node”在等着把你绕回去。
最后再分享一个小技巧:fnm的自动更新其实很容易被忽略。如果你用winget装的,可以定期winget upgrade Schniz.fnm;用scoop的话就scoop update fnm。新版本除了修复Windows下的一些路径问题,也会持续优化与Corepack、新版本Node的兼容性。遇到fnm行为异常时,先升级一下再排查,往往能省掉很多时间。