在 Mac ARM (Apple Silicon) 上搭建 Node.js 开发环境,版本管理器和包管理器的选择至关重要。本文将系统地介绍如何使用 fnm 管理 Node.js 版本、通过 Corepack 启用 pnpm,并完成从 Yarn 到 pnpm 的完整迁移流程。

提示:本文由 AI 根据对话历史整理,仅供参考

# fnm 安装方式对比:Homebrew vs 官方脚本

fnm (Fast Node Manager) 可以通过两种方式安装,它们的安装结果相同,但管理方式截然不同。

# Homebrew 安装

brew install fnm

通过 Homebrew 软件包管理器下载并安装编译好的二进制文件。更新只需 brew upgrade fnm,卸载只需 brew uninstall fnm,和系统中其他工具统一由 Homebrew 管理。

# 官方脚本安装

curl -fsSL https://fnm.vercel.app/install | bash

利用 curl 下载远程安装脚本并直接执行。它会自动检测系统架构、下载对应二进制文件,并尝试修改 .zshrc.bashrc 配置。版本通常最新,但更新时需要重新运行脚本,且卸载需要手动清理文件。

# 对比表格

特性 brew install fnm curl \| bash (官方脚本)
管理方式 Homebrew 集中管理 独立分散安装
更新命令 brew upgrade fnm 通常需要重新执行脚本
卸载难度 极简 (brew uninstall) 略难(需手动删文件和配置)
配置自动化 需手动添加配置到 .zshrc 脚本通常会尝试自动添加
安全性 Homebrew 仓库有社区审核 直接运行远程脚本存在安全风险
适用人群 追求系统整洁的 macOS 开发者 不想用/没装 Homebrew 的人

建议:如果你是 macOS 用户且已安装 Homebrew,优先使用 brew install fnm。关于 Homebrew 的更多管理技巧,可以参考 Homebrew 包管理与依赖清理指南

无论使用哪种方式安装,都需要在 Shell 配置文件中添加初始化代码(详见下文第三节)。


# fnm 在 M1 Mac 上的文件存储结构

在 ARM 架构的 Mac 上,fnm 的存储结构主要分为 二进制程序本身环境管理目录 两部分。

# 二进制路径

通过 Homebrew 安装时,fnm 可执行文件位于:

/opt/homebrew/bin/fnm

这是 ARM Mac 上 Homebrew 的默认安装路径,与 Intel Mac 的 /usr/local/bin 不同。

# 数据目录

fnm 将所有下载的 Node 运行时、别名和安装包存放在 ~/.local 路径下:

~/.local/share/fnm

# node-versions 目录

这是最核心的目录,存放所有已下载的 Node.js 版本。每个版本都是独立的隔离目录,包含该版本的 binlibincludeshare

node-versions/<VERSION>/installation/
# 例如:
node-versions/v20.10.0/installation/bin/node

# aliases 目录

存放别名文件。当执行 fnm alias v20.10.0 default 时,fnm 会在此创建指向特定版本的符号链接。常见别名包括 default(默认版本)和 lts-latest 等。

# 总结表格

组件 路径 (ARM Mac) 说明
可执行文件 /opt/homebrew/bin/fnm fnm 核心程序
主数据目录 ~/.local/share/fnm 存储所有数据的基础路径
Node 安装包 .../fnm/node-versions/ 各个版本的实体文件
别名设置 .../fnm/aliases/ 存储如 default 等指向关系
环境变量配置 ~/.zshrc 负责在启动时注入 fnm 路径

如果需要自定义存储位置,可以在 .zshrc 中设置:

export FNM_DIR="/your/custom/path"

# .node-version 自动切换与 .zshrc 配置

fnm 支持通过项目根目录的配置文件自动切换 Node 版本,实现"进入目录即切换"的体验。

# 创建 .node-version 文件

在项目根目录创建 .node-version 文件(也支持 .nvmrc),内容仅需版本号:

v20.10.0

也可以通过命令自动生成:

node -v > .node-version

# 配置 Shell 自动加载

确保 ~/.zshrc 中 fnm 的初始化脚本包含 --use-on-cd 参数:

eval "$(fnm env --use-on-cd --shell zsh)"

配置完成后,当你在终端执行 cd my-project 进入该目录时,fnm 会自动读取 .node-version 文件并切换到对应的 Node 版本。如果该版本未安装,它会提示你运行 fnm install

关于 .zshrc 的完整配置与环境变量排查,可以参考 Zsh 终端配置与 Homebrew 环境修复


# Corepack 的概念与启用

# 什么是 Corepack

Corepack 是 Node.js 16.13+ 官方内置的 包管理器管理工具。它的作用是充当一个"代理层":当你输入 pnpm install 时,Corepack 会检查当前项目 package.json 中的 packageManager 字段,自动下载并运行正确版本的 pnpm,无需手动全局安装。

简而言之,fnm 管理 Node.js 本身 的版本,Corepack 管理 包管理器(pnpm/yarn) 的版本。

# 启用 Corepack

由于 Corepack 目前仍处于实验性阶段(虽然已经非常稳定),默认是关闭的,需要手动开启:

corepack enable

# 查看 Corepack 是否已启用

Corepack 不是常驻服务进程,而是一组"二进制代理(Shim)",没有 status 命令。可以通过以下方式确认:

方法一:检查二进制路径(最可靠)

which pnpm
  • 已启用:路径指向 Node 的 bin 目录,例如 ~/.local/share/fnm/node-versions/v20.10.0/installation/bin/pnpm
  • 未启用:终端提示 command not found,或指向手动通过 npm install -g pnpm 安装的全局路径

方法二:尝试运行

corepack --version

如果返回版本号,说明 Corepack 程序本身可用。

# 切换 Node 版本时是否需要重新启用

是的,通常需要。 corepack enable 的本质是在当前 Node 版本的 bin 目录下创建符号链接。当通过 fnm 切换到一个从未启用过 Corepack 的 Node 版本时,那个版本的 bin 目录里没有 pnpmyarn 的代理文件。

可以在 ~/.zshrc 中添加自动化脚本来解决:

# fnm 初始化
eval "$(fnm env --use-on-cd --shell zsh)"

# 自动为新安装的 Node 版本启用 corepack
# 只有当 pnpm 不存在且 corepack 存在时才执行
if ! command -v pnpm &> /dev/null && command -v corepack &> /dev/null; then
  corepack enable
fi

# Corepack vs npm install -g 的区别

Corepack 和 npm install -g 的设计目标完全不同,不能混为一谈。

# 管理对象与适用范围

特性 Corepack npm install -g
管理对象 仅限包管理器 (pnpm, yarn) 所有 Node.js 命令行工具
工作原理 Node.js 内置的"代理层",拦截并切换 pnpm/yarn 版本 将工具的二进制文件直接放入系统路径
典型例子 pnpm, yarn nodemon, serve, typescript, claude-code
版本一致性 严格遵循 package.jsonpackageManager 字段 手动管理,容易出现版本不一致

Corepack 的代码逻辑中硬编码了对 pnpmyarn 的特殊处理,不能用来管理其他 npm 全局包。

# 混合方案(推荐)

在 Mac ARM 架构上,建议采取以下分工:

  1. 包管理器(pnpm, yarn):使用 corepack enable,确保不同项目可以使用不同版本
  2. 常用开发工具(nodemon, serve, typescript 等):使用 npm install -g,但注意在 fnm 环境下全局包跟随 Node 版本
  3. 偶尔使用的工具:使用 npx 临时下载运行,不污染全局环境
# 包管理器 -> Corepack
corepack enable

# 常用工具 -> npm -g
npm install -g nodemon serve

# 偶尔使用 -> npx
npx create-react-app my-app

# 注意事项

在启用 Corepack 之前,建议先卸载手动安装的全局 pnpm/yarn,避免版本冲突:

npm uninstall -g pnpm yarn

在 fnm 环境下,全局包是跟着 Node 版本走的。如果在 v18 下安装了某个工具,切换到 v20 后该工具就找不到了,因为它们存储在不同的目录中。


# 从 Yarn 迁移到 pnpm 的完整步骤

以一个使用 Yarn + Monorepo 的 Hexo 项目为例,演示完整的迁移流程。

# 通过 Corepack 安装 pnpm

corepack enable

# 清理 Yarn 旧文件

rm -rf .yarn .yarnrc.yml yarn.lock node_modules

# 创建 pnpm-workspace.yaml

Yarn 使用 package.json 中的 workspaces 字段管理工作区,pnpm 则需要一个独立的配置文件。在项目根目录创建 pnpm-workspace.yaml

packages:
  - 'packages/*'

# 修改 package.json 中的脚本

scripts 中的 yarn 命令替换为 pnpm 的等效命令。核心区别在于工作区命令的写法:

  • Yarn: yarn workspace <name> <command>
  • pnpm: pnpm --filter <name> <command>

修改后的示例:

{
  "scripts": {
    "build": "pnpm build:uikit && pnpm build:hexo",
    "build:uikit": "pnpm --filter shokax-uikit build",
    "build:hexo": "hexo generate",
    "deploy": "hexo deploy",
    "dev": "hexo server",
    "clean": "pnpm clean:uikit && pnpm clean:hexo",
    "clean:uikit": "pnpm --filter shokax-uikit clean",
    "clean:hexo": "hexo clean",
    "clear": "pnpm clear:uikit && pnpm clear:hexo",
    "clear:uikit": "pnpm --filter shokax-uikit clear",
    "clear:hexo": "rm -rf node_modules pnpm-lock.yaml"
  },
  "packageManager": "pnpm@9.0.0"
}

同时,记得更新 packageManager 字段为实际安装的 pnpm 版本。

# 处理子包

子包的调整要点:

  • 移除 packageManager 字段:交给根目录统一管理
  • 保留脚本和依赖scriptsdependenciesdevDependencies 不需要大的改动
  • 将 pnpm 配置上提到根目录:子包中的 pnpm.overridesonlyBuiltDependencies 需移动到根目录(详见第七节)

# 安装依赖

在项目根目录执行:

pnpm install

这将生成新的 pnpm-lock.yaml 并安装所有依赖。

# 构建验证

如果项目存在依赖链(例如 uikit -> player -> theme),可以利用 pnpm 的拓扑排序自动按顺序构建:

pnpm -r build

# Monorepo 项目的 pnpm 配置

在 Monorepo 结构中,pnpm 提供了一些高级配置项来优化依赖管理和安全性。这些配置需要放在 根目录package.json 中。

# overrides:依赖替换

overrides 可以强制替换依赖树中的特定包。一个常见的用法是通过 nolyfill 替换掉那些为兼容旧环境而存在的 polyfill 包:

{
  "pnpm": {
    "overrides": {
      "array-includes": "npm:@nolyfill/array-includes@latest",
      "array.prototype.flat": "npm:@nolyfill/array.prototype.flat@latest",
      "hasown": "npm:@nolyfill/hasown@latest",
      "object.fromentries": "npm:@nolyfill/object.fromentries@latest",
      "object.groupby": "npm:@nolyfill/object.groupby@latest",
      "object.values": "npm:@nolyfill/object.values@latest"
    }
  }
}

这样做的好处:

  • 减少 node_modules 冗余:避免安装大量微小的兼容包
  • 加快安装速度:减少需要下载和解析的包数量
  • 修复安全隐患:一些老包可能存在依赖漏洞,替换后可直接规避

# onlyBuiltDependencies / allowedBuiltDependencies

这是 pnpm 的安全特性,用于限制哪些包可以运行安装脚本(preinstall/postinstall)。很多 npm 包在安装时会自动运行脚本,这可能成为恶意软件的攻击面。

{
  "pnpm": {
    "onlyBuiltDependencies": [
      "esbuild",
      "hexo-util"
    ]
  }
}

以上配置表示只有 esbuildhexo-util 被允许运行安装脚本,其他依赖的脚本会被 pnpm 拦截。

  • esbuild:需要在安装时根据操作系统下载对应的二进制程序
  • hexo-util:底层工具库涉及原生编译

提示:pnpm 9.x 以后,官方建议使用 allowedBuiltDependencies 替代 onlyBuiltDependencies,虽然目前两者都兼容。


# pnpm 与 Yarn 命令对照表

从 Yarn 迁移到 pnpm 后,以下是常用命令的对照:

操作 Yarn 命令 pnpm 命令
安装所有依赖 yarn pnpm installpnpm i
添加依赖 yarn add <pkg> pnpm add <pkg>
添加开发依赖 yarn add -D <pkg> pnpm add -D <pkg>
运行脚本 yarn <script> pnpm <script>pnpm run <script>
指定工作区运行 yarn workspace <name> <cmd> pnpm --filter <name> <cmd>
全局安装 yarn global add <pkg> pnpm add -g <pkg>
移除依赖 yarn remove <pkg> pnpm remove <pkg>
递归构建 - pnpm -r build
执行本地二进制 yarn exec <cmd> pnpm exec <cmd>

# 常见问题排查

# pnpm server vs pnpm run server:内置命令冲突

如果定义了 "server": "hexo server" 脚本,你可能会发现 pnpm server 没有输出,但 pnpm run server 正常运行。

原因:pnpm server 是 pnpm 的 内置命令(用于管理 pnpm 的后台共享存储服务器进程),而不是脚本快捷方式。pnpm 只有在遇到非内置关键字时才会回退到 package.json 的脚本中。

解决方案:

  • 养成习惯使用 pnpm run server,显式告诉 pnpm 执行脚本
  • 或者将脚本改名devstart,避免与内置命令冲突:
{
  "scripts": {
    "dev": "hexo server"
  }
}

# fnm_multishells 是什么

当你运行 eval "$(fnm env --use-on-cd)" 时,fnm 会在系统临时文件夹(通常是 /tmp)里创建一个 fnm_multishells 目录。

它的作用是实现 多终端窗口独立 Node 版本。为了让你在窗口 A 用 Node 20、窗口 B 用 Node 18 且互不干扰,fnm 需要为每个 Shell 会话创建一个独立的"虚拟路径",里面包含指向特定 Node 版本的符号链接。

你会看到大量以数字命名的子目录,这是因为每次打开新终端窗口都会生成一组文件。这些文件可以安全删除(重启终端即可恢复),正常情况下重启电脑后系统会自动清理。

# 为什么不建议通过 Homebrew 安装 Node

既然已经有了 fnm,不建议再通过 brew install node 安装 Node,原因如下:

  1. 编译风险:Homebrew 安装 Node 时可能触发源码编译,特别是在较旧的 macOS 版本上容易遇到 C++ 编译器不匹配的链接错误(如 ld: symbol(s) not found for architecture arm64
  2. fnm 更轻量:fnm 直接从 Node.js 官网下载预编译的 ARM64 二进制包,解压即用,完全不经过编译过程
  3. 冲突风险:同时通过 Homebrew 和 fnm 管理 Node 会导致全局路径混乱

正确做法是只通过 fnm 安装和管理 Node:

fnm install 20
fnm use 20

# brew install --cask 的含义

在 Homebrew 中,--cask 用于安装 带有图形界面(GUI)的 macOS 应用程序

  • brew install(不带 --cask):安装命令行工具,如 nodegitwget
  • brew install --cask:安装桌面软件,如 Google Chrome、VS Code 等。背后的操作是自动下载 .dmg.zip,将 .app 文件移动到 /Applications,然后清理临时文件

更多 Homebrew 相关知识可以参考 Homebrew 包管理与依赖清理指南