1. 换掉 nvm,先算清楚时间账
1.1 每个新终端都要背的 nvm 启动包袱
我用 nvm 大概用了四年多,真正让我下决心换到 fnm(Fast Node Manager)的不是某一个惊天 bug,而是每天无数次打开新终端时那半秒到一秒的卡顿。你可能觉得一秒不算什么,但我一天至少要开几十个终端、跑无数次 nvm use,日积月累就是每天几分钟纯耗在等待上,而且它卡在每次 shell 启动的最前面,直接影响手感。
nvm 之所以慢,是因为它本质上是一个 shell 脚本。每当你打开一个新的终端,shell 都会重新 source 一遍完整的 nvm 脚本,这个脚本不仅要定义一堆函数,还要设置 PATH、处理各种兼容分支。特别是用 zsh 配合一些插件框架的时候,启动时间会被进一步放大。fnm 不一样,它用 Rust 实现,本体是一个编译好的可执行文件,shell 集成部分只做一件事:调用 fnm env 拿到需要注入的环境变量。
我从实际体感来说,同样一套开发环境,nvm 启动终端大约需要 600ms 到 1s,换到 fnm 之后基本感受不到额外延迟,终端启动时间直接回到了没装版本管理器之前的水平。对于追求启动速度的人来说,这已经足够构成迁移理由。
1.2 跨平台这座山,nvm 一直没翻过去
我平时主要在 macOS 和 Windows 之间切换,偶尔还要在 Linux 服务器上部署。nvm 官方只支持 Linux 和 macOS,Windows 上虽然有 nvm-windows 这样的衍生版本,但它和原版 nvm 的行为差异很大:配置文件名、命令参数、目录结构全都不一样,平时在 macOS 写好的脚本,挪到 Windows 上经常要重新调试。
这种碎片化给你的日常工作带来的麻烦是隐性的:项目 CI 跑在 Linux 上,本地开发一半人用 Windows 一半人用 macOS,每个人的 Node 版本管理方式都不一样,有人用 nvm,有人用 nvm-windows,还有人根本不用版本管理器。而 fnm 是真正意义上的跨平台,Windows、macOS、Linux 都吃同一套命令语法和配置文件,换机器几乎零学习成本。
底层原理其实也值得说一句:nvm 通过修改当前 shell 的 PATH 变量来切换 Node 版本,而 fnm 使用符号链接机制。它维护一个版本存储目录,然后用软链接把当前激活的版本指过去。因为不依赖 shell 函数去修改运行中的进程环境,所以切换的可靠性和速度都有明显提升。
1.3 fnm 的底子:Rust 写的,不是花架子
你可能会问:用 Rust 写就了不起吗?确实了不起,但不是因为 Rust 这个标签本身,而是它带来的实际结果。fnm 安装完只有一个可执行文件,没有脚本链条,没有解释器依赖。这意味着它不仅能被普通 shell 使用,还能被 fish、zsh、bash、PowerShell 等各种环境调用,甚至可以在 CI 容器里直接下载使用。
另一个容易被忽略的点是:fnm 的数据目录设计得很干净。默认情况下,所有版本都装在一个 fnm_multishells 或版本目录里,卸载的时候删掉数据目录和执行文件就行,不会像某些版本管理器一样在系统各处留下零散的脚本文件。
理解了这个设计,后面你再去折腾 shell 集成、CI 脚本或者换终端工具时会顺畅很多。下面开始说安装。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 安装 fnm 其实是在装两样东西:二进制和 shell 钩子
2.1 Windows:scoop / winget / 手动下载三选一
Windows 上安装 fnm 有三个常用的渠道:scoop、winget、直接下载 GitHub Release。我个人的建议是优先用 winget,因为它不需要额外装包管理器:
bash复制winget install Schniz.fnm
如果你已经装了 scoop,也可以走 scoop:
bash复制scoop install fnm
装完之后注意,Windows 下 fnm 默认会使用一个 fnm 子目录作为版本存储位置,而你原本可能配置过 npm 的全局目录,这两者不冲突,但后面安装 Node 版本之后,你在 IDE 里选择的 Node 解释器路径可能会变,这点放到避坑部分细说。
安装到这一步只是把 fnm 可执行文件放到了系统里,还差一步:让 fnm 在每次打开终端时自动初始化。这也是 Windows 用户最容易漏掉的地方。如果你用的是 PowerShell,需要执行:
powershell复制fnm env --use-on-cd | Out-String | Invoke-Expression
建议把这个命令写进 PowerShell 的 $PROFILE 文件里。把它理解为“每次打开终端时,把 fnm 的环境变量注入到当前 shell”,如果没有这行,你后面执行 fnm use 时,Node 命令根本不会指向 fnm 管理的目录。
2.2 macOS / Linux:install 脚本和 Homebrew
macOS 上最简单的做法是:
bash复制brew install fnm
Linux 上可以用官方安装脚本:
bash复制curl -fsSL https://fnm.vercel.app/install | bash
这个安装脚本默认会把 fnm 装到本地用户目录,同时尝试往你的 shell 配置里追加初始化代码。我建议装完之后自己检查一下 shell 配置文件,因为自动追加的内容在不同 shell 里位置不一样,而且有可能会加重复。
装完之后,重点来了:初始化方式要根据 shell 区分。zsh 用户需要在 ~/.zshrc 里加入:
bash复制eval "$(fnm env --use-on-cd)"
bash 用户加到 ~/.bashrc:
bash复制eval "$(fnm env --use-on-cd)"
fish 用户用的是:
fish复制fnm env --use-on-cd | source
--use-on-cd 这个参数很实用,它让 fnm 在检测到你进入一个包含 .node-version 或 .nvmrc 的目录时,自动切换到对应版本,这个能力我们后面专门用一节讲。
2.3 核心:fnm env 和 shell 初始化之间的区别
第一次接触 fnm 的人,常常搞混两件事:fnm install 和 fnm env 到底哪个才让系统真正用上 fnm?
fnm install 是安装 Node 版本本身,它负责下载、解压、校验二进制文件。fnm env 是告诉当前 shell 应该往 PATH 里注入哪些内容,让 node、npm、npx 指向 fnm 管理的不同版本目录。
简单类比:fnm install 相当于买了几台不同配置的电脑放在仓库里,fnm env 相当于给当前这个工位接了一条线,让你能用其中某一台电脑。fnm use 则是从仓库里选一台切换到工位上。
理解这个区别之后,你就知道为什么手动执行 fnm use --default 之后,新开的终端里依然生效——因为它把默认版本信息写进了配置,而新终端启动时通过 fnm env 读取了这个配置。如果 fnm env 没有被初始化,那一切都白搭。
3. 十个最常用的 fnm 命令,按真实使用频率排序
3.1 装版本、删版本、查版本
日常我最常用的几个命令几乎可以用一只手数完,但它们确实覆盖了 90% 的需求。
安装指定版本:
bash复制fnm install 18.20.4
安装 LTS 版本:
bash复制fnm install --lts
安装到最新版本:
bash复制fnm install --latest
查看本地已安装的版本:
bash复制fnm list
删除某个不再使用的版本:
bash复制fnm uninstall 18.20.4
查看远端还可以安装哪些 Node 版本:
bash复制fnm list-remote
这段命令看起来和 nvm 很像,但底层下载方式是纯 Rust 实现的 HTTP 客户端,下载和校验速度比 nvm 自己的实现要快不少。安装完成后,你可以直接看到 fnm 会告诉你当前目录的操作符是什么样的,这也是为什么它在 Windows 上比 nvm-windows 稳定。
3.2 use、default、alias 的逻辑差异
fnm use 切换当前 shell 使用的版本,但这个切换是有作用域限制的。你在终端 A 里 fnm use 18.20.4,不影响终端 B 的版本。它修改的是当前进程 PATH 里的符号链接指向。
如果想让某个版本作为系统默认,可以用:
bash复制fnm default 18.20.4
或者更常见的做法是,安装完之后直接:
bash复制fnm use --default 18.20.4
另一个容易被忽视的是 alias。fnm 允许你给某个版本起一个名字,最常见的是把 LTS 版本别名为 lts-latest:
bash复制fnm alias 18.20.4 lts-latest
这样以后你可以直接用 fnm use lts-latest,不需要记住那一串版本号。如果你维护多个项目,有的项目用 16,有的用 18,有的用 20,别名能帮你省下大量无意义的记忆成本。
3.3 exec 和 run:不切换也能跑指定版本
很多时候我不希望切换当前终端的全局 Node 版本,只想临时用某个特定版本跑一条命令。比如某个老项目只能用 Node 16 构建,但我当前默认版本是 18。
这时如果执行 fnm use 16,会污染当前终端后续所有命令,比较麻烦。更好的方式是:
bash复制fnm exec --using=16 npm install
这会让 fnm 临时把 Node 16 装进 PATH,然后执行后面的命令,命令结束后当前终端保持不变。CI 脚本里这个命令尤其好用,后文会有专门说明。
还有一个复刻 npx 场景的 fnm run,它和 fnm exec 类似,但在执行完成后直接退出。说实话我日常用 fnm exec 更多,因为它的语义更明确:临时环境。
4. 让项目自己选择 Node 版本:.node-version 的正确姿势
4.1 从 .nvmrc 到 .node-version
很多人第一次接触版本管理器,切换版本都是靠手动执行 fnm use。但团队协作时,手动切换是灾难的源头——新同事 clone 项目后,根本不知道该用哪个 Node 版本。
fnm 支持读取项目根目录下的 .node-version 文件,也兼容 .nvmrc。你只需要在项目里创建一个 .node-version 文件,里面写入:
code复制20.11.1
或更宽容一点的:
code复制20
然后在 shell 初始化时加上 --use-on-cd,当你 cd 进入这个目录时,fnm 会自动检查本地有没有安装这个版本。如果没装,它会提示你执行安装;如果装了,它自动切换。
我在实际项目中推荐统一使用 .node-version 而不是 .nvmrc,因为前者不只是 fnm 认识,很多工具链都原生支持,比如 direnv、asdf、pyenv 也都认得这个文件。.nvmrc 是 nvm 生态的命名习惯,虽然 fnm 兼容它,但从长远和跨工具兼容来说,.node-version 更规范。
4.2 编辑器、CI、脚本怎么联动
项目里有了 .node-version 之后,受益最大的其实是 IDE 和 CI,而不是终端。
以 VS Code 为例,如果在终端里启动 VS Code,且你的 shell 初始化已经正确配置了 fnm,那么打开集成终端时,它会继承当前 shell 的 Node 版本环境。但如果你是通过 GUI 图标启动 VS Code 的,它可能不会加载你 shell 里的初始化脚本,这会导致一个经典现象:终端里 node -v 是 20,VS Code 的集成终端里却是系统里的老版本或者根本没识别。
针对 GUI 启动的环境,我建议在 VS Code 设置里显式指定 "terminal.integrated.env.linux" 等信息,或者直接在项目配置里告诉 VS Code 用哪个 Node 解释器。更省心的办法是装一个支持读取 .node-version 的扩展,让 IDE 根据项目自动选择解释器。
CI 里的联动更加直接。GitHub Actions 里你可以用现成的 setup-fnm action,但更轻量的方式是直接在脚本里执行:
bash复制eval "$(fnm env --use-on-cd)"
fnm install
这行命令会让 CI 读取仓库根目录的 .node-version 并安装对应版本,随后执行 node -v 就能确认版本正确。整个过程不依赖任何第三方 action,也能跑得快。
5. 下载慢、安装失败?镜像源配置一次讲透
5.1 Node 二进制镜像怎么让 fnm 生效
fnm 默认从 Node 官网二进制分发站下载,国内网络环境下这个速度很多时候都不理想。解决方案是配置镜像源。
fnm 提供了一个环境变量 FNM_NODE_DIST_MIRROR,用来指定 Node 二进制包的下载地址。在你原有的 shell 配置里加一行:
bash复制export FNM_NODE_DIST_MIRROR="https://npmmirror.com/mirrors/node/"
设置在 fnm env 初始化之前,确保 fnm 启动后就能拿到这个镜像地址。
配置完之后,建议先执行 fnm uninstall 把之前下载失败的残留版本清掉,再重新 fnm install --lts,否则它会因为本地已有相同版本记录而直接跳过下载。这个细节很多人没注意,经常出现配置了镜像但安装速度没变化的情况。
5.2 fnm 本身的安装镜像
不止是 Node 二进制,fnm 自身也会从 GitHub Release 下载。安装脚本默认拉取 GitHub Release 包,这个速度在某些网络环境里同样很慢。你可能需要先用镜像方式手动下载安装包,或者从有加速功能的下载渠道拿到压缩包后手动放置到本地目录,再配置 PATH。
安装完之后,fnm 会记录自己的版本号,后续通过 fnm upgrade 升级时也是走 GitHub Release。如果升级慢,我的建议是不要卡在这上面,fnm 版本更新对你日常使用的影响没那么大,稳定够用就好。真正影响日常效率的是 Node 二进制下载速度,优先把 FNM_NODE_DIST_MIRROR 配好。
5.3 安装到一半失败的恢复
我遇到过不少次安装卡在下载中的情况,这时候容易陷入一个误区:反复执行 fnm install,却发现每次都卡在同一个位置。
这很可能是因为 fnm 下载到一部分时产生了临时文件,再次执行安装时,它检测到了部分文件,没有重新完整下载。处理方式很简单:先清掉缓存目录里对应的临时文件,再重装。fnm 的默认目录在 Windows 是 %LOCALAPPDATA%\fnm,在 macOS/Linux 是 ~/.local/share/fnm。你可以直接删除整个 node-versions 子目录里对应的版本文件夹,但这样做会同时删掉该版本已安装的内容,所以操作前先确认没有在用这个版本。
6. 避坑实录:迁移到 fnm 之后我踩过的坑
6.1 fnm use 明明执行了,node -v 还是老版本
这是新用户遇到最多的问题,而且一出现就让人怀疑 fnm 是坏的。真实原因多半是 shell 初始化没写对,或者 fnm env 注入的 PATH 次序不对。
排查思路如下:先执行 fnm env,看输出的 PATH 里 fnm 目录是否排在系统 node 目录之前。如果 which node 指向的是 /usr/local/bin/node 或 /c/Program Files/nodejs/node.exe,说明 shell 里 PATH 顺序不对。
处理方式是确保 eval "$(fnm env --use-on-cd)" 在 shell 配置文件的靠后位置执行,这样它注入的 PATH 片段会优先于系统目录。有些发行版在 /etc/profile 或 ~/.profile 里定义了 Node 路径,也会干扰 fnm。你也可以直接检查有没有旧版 Node 的环境变量残留在系统层面。
6.2 卸载 nvm 之后 PATH 里的孤儿路径
从 nvm 迁移到 fnm 时,建议先装好 fnm 并确认它能正常工作,再考虑卸载 nvm。不要一上来就把 nvm 整个目录删了,因为你的 ~/.zshrc 或 ~/.bashrc 里还残留着 export NVM_DIR=... 和 [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" 这类代码。直接删目录会导致每次打开终端都报错。
正确操作顺序:
- 先配置好 fnm 并切换到一个可用的 Node 版本。
- 把 shell 配置里的 nvm 初始化代码注释掉或删除。
- 新开终端验证
node -v正常。 - 最后再删除 nvm 的安装目录。
- 顺手清理 PATH 里残留的
$NVM_DIR/versions/node/*/bin之类的路径。
这套顺序能让你平滑迁移,不至于出现“新的没配好,旧的已经删了”的尴尬局面。
6.3 node:util 导出错误:版本串台的典型症状
标题里提到过一个热搜词:“node.js 18 the requested module 'node:util' does not provide an export named”。这个问题我在从 16 切到 18 时遇到过。表面看是某个 npm 包调用了 node:util 里不存在的导出,深层次原因往往是你的全局 CLI 工具或 npm 全局包是用旧版本 Node 装的,而当前 shell 切换到了新版本 Node,导致模块加载路径错乱。
和 fnm 相关的场景是:你之前用 nvm 在 Node 16 下面全局安装了某个脚手架工具,之后切到了 Node 18,然后打算卸掉 nvm 改用 fnm。此时全局工具目录里还留着 Node 16 编译出来的原生模块,和当前 Node 18 的运行时不匹配。
解决办法是先确定当前 Node 版本和全局包安装目录:
bash复制npm root -g
然后如果跨大版本切换,我倾向于把全局依赖重装一遍:
bash复制npm install -g npm@latest
再重装你需要的全局工具。这不是 fnm 的锅,而是任何版本管理器都会遇到的问题,但迁移期最容易触发。
6.4 在 CI 里用 fnm 的正确时机
CI 里用 fnm 和本地用有很大的区别,本地你追求的是交互流畅、自动切换,CI 里最看重的是可重复性和速度。
我的建议是:CI 里不要依赖 --use-on-cd,也不要依赖 shell 配置自动加载。宁可显式写出每一步:
yaml复制- name: Setup fnm
run: |
curl -fsSL https://fnm.vercel.app/install | bash
eval "$(fnm env)"
- name: Use Node version
run: |
fnm install
fnm use
node -v
这里的 fnm install 和 fnm use 会自动读取仓库根目录的 .node-version 文件,所以不需要硬编码版本号,维护成本更低。
一个容易踩的坑是:CI 容器的基础镜像里已经预装了某个 Node 版本,而且 PATH 优先级很高。这时 fnm env 注入的目录可能排在后面,导致 node -v 显示的是系统内置版本。解决办法是显式把 fnm 的目录前置到 PATH,或者在 runner 配置里禁用预装 Node。
6.5 其他版本管理器(volta / n)混用
很多人的机器上不止一个版本管理器,以前用 nvm,后来又试了 volta,现在切 fnm,结果 PATH 里可能同时有 nvm、fnm、volta 生成的多个路径片段。这些管理器在切换时会互相覆盖符号链接,造成版本显示和实际运行不一致。
如果你决定用 fnm,就要接受它作为主控版本管理器,把其他管理器的初始化代码从 shell 配置里清理干净。不要抱侥幸心理觉得可以同时用多个管理器——短期可能没问题,但一旦遇到奇怪的 bug,排查成本会成倍增加。
| 常见报错 | 可能原因 | 处理方式 |
|---|---|---|
command not found: fnm |
fnm 可执行文件不在 PATH 里 | 检查安装路径,确认 shell 配置已加入 fnm 目录 |
fnm use 后 node -v 不变 |
PATH 顺序被系统 Node 抢占 | 检查 which node,调整 fnm env 初始化位置 |
| 下载卡住或超时 | 默认源太慢 | 配置 FNM_NODE_DIST_MIRROR 镜像 |
node:util 导出报错 |
全局包跨 Node 大版本串台 | 重装全局安装的 npm 包 |
| 新开终端版本被重置 | 未配置 --use-on-cd |
在 fnm env 初始化参数中加入 --use-on-cd |
6.6 一个小技巧:配合 .gitignore 和 docker
最后分享一个我在团队里推行的做法。既然 .node-version 决定了 Node 版本,把它视为项目规范的一部分,应该写进 README。同时,如果你的项目用 Docker,建议在 Dockerfile 里也读取同一个文件,保证 CI、本机和容器跑的 Node 大版本完全一致。
伪代码大致是:
dockerfile复制ARG NODE_VERSION=$(cat .node-version)
FROM node:${NODE_VERSION}
这套组合拳下来,团队成员之间因为 Node 版本不一致导致的诡异问题基本可以绝迹。我在实际团队里推广之后,最直观的变化是跨平台沟通成本显著下降,Windows 和 macOS 的同事不再为版本差异互相扯皮。如果你正在从 nvm 迁移过来,或者第一次考虑给 Node.js 装版本管理器,fnm 目前是我最推荐的选择:装一次,三平台通用,命令简单,速度飞快。
