我有一台 2012 年的 MacBook Pro(i7-3540M、8GB RAM),系統停在它能升級的最後一版 macOS Catalina 10.15。這台機器陪我寫了十年程式,期間裝過 Anaconda、MacPorts、Homebrew、Android Studio、Xcode……每一樣都在 shell 設定和開機項目裡留下一點痕跡。最近連開個終端機都要頓一下,磁碟也只剩 53GB,於是決定好好整理一次。整個過程都是從另一台機器 ssh 進去完成的。
成果一覽
| 項目 | 優化前 | 優化後 |
|---|---|---|
| zsh 啟動時間 | 1.2 秒 | 0.04 秒 |
| 磁碟可用空間 | 53 GB | 74 GB |
| 多餘的開機自啟服務 | 26 個 | 全部停用(可還原) |
python3 / pip3 |
分屬 MacPorts / Homebrew | 統一為 MacPorts 3.14 |
| shell 歷史紀錄上限 | 1,000 筆(已在截斷) | 50,000 筆 |
| git | Apple git 2.24,缺 git-lfs | git 2.55 + git-lfs 3.5.1 |
盤點前的小坑:ssh 進去 brew 竟然不存在?
第一次執行 ssh mbp 'brew --version' 得到 command not found,差點以為 Homebrew 壞了。其實是**非互動的 ssh 指令不會讀 .zprofile/.zshrc**,PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin。遠端檢查環境時要明確開一個 login + interactive shell:
1 | ssh mbp 'zsh -lic "brew --version; command -v python3"' |
盤點下來,主要問題有這些:
- zsh 啟動要 1.2 秒。
- PATH 混雜:MacPorts、Homebrew、Python 2.7 framework 全都在裡面,還有 4 個早已不存在的目錄。
- Homebrew 和 MacPorts 有 222 個同名指令,誰生效全看 PATH 順序。
python3和pip3不是同一個 Python。- 開機自啟一大堆:CleanMyMac 2、360、迅雷、搜狗、TeamViewer、MySQL……
- 磁碟用了 92%。
一、zsh 啟動:1.2 秒 → 0.04 秒
先量測,再用 zprof 找兇手
1 | /usr/bin/time zsh -lic exit |
zsh 內建的 profiler 可以直接看出時間花在哪:
1 | zsh -c 'zmodload zsh/zprof; source ~/.zprofile; source ~/.zshrc; zprof' | head |
1 | num calls time self name |
**nvm 佔了 93%**。source nvm.sh 時它會自動執行 nvm use default,要解析版本別名、檢查安裝狀態,在這台老機器上要花 0.6 秒,而且每開一個終端機分頁都要付一次。
延遲載入 nvm
思路是:shell 啟動時只把 default 版本的 bin 目錄放進 PATH,node、npm、npx 立刻可用;真正的 nvm 函式等第一次被呼叫時才載入。
1 | export NVM_DIR="$HOME/.nvm" |
幾個細節:
~/.nvm/alias/default的內容是20。globv20(|.*)只會匹配v20或v20.x.y,不會誤中v200。(Nn)是 null-glob 加數字排序,[-1]取最新的 patch 版。- default 如果是
lts/*、node這類別名就無法這樣解析,這時直接退回原本的載入方式,確保不會壞掉。 - 之後執行
nvm use <版本>時,nvm 會自己從 PATH 移除舊的 node 路徑再加上新的,不會殘留。
我的 nvm 是用 MacPorts 安裝的,所以路徑是 /opt/local/share/nvm/;用官方腳本安裝的話,換成 $NVM_DIR/nvm.sh 即可。
順手修掉的幾件事
歷史紀錄被截斷:Catalina 的 /etc/zshrc 預設 SAVEHIST=1000,而我的 .zsh_history 已經 1015 行,舊紀錄早就在默默被丟掉了。
1 | HISTSIZE=50000 |
compinit 快取:compinit 每次啟動都會跑 compaudit 做權限檢查。改成 dump 檔超過 24 小時才做完整檢查,其餘時間用 -C 直接沿用:
1 | autoload -U compinit |
這裡利用了「匿名函式的參數會先做 glob 展開」:(N.mh+24) 只在檔案超過 24 小時時才匹配,所以 $# 非零就代表該重建了。要 touch 是因為 compinit 在內容沒變時不會改寫 dump;不手動更新 mtime 的話,之後每次啟動都會走完整檢查。
PATH 去重、剔除不存在的目錄:放在 .zshrc 的最後兩行:
1 | typeset -U path |
typeset -U 讓 path 陣列自動去重。$^path(N-/) 對每個元素做 glob 檢查,只留下實際存在的目錄(-/ 也接受指向目錄的 symlink)。這樣不必刪掉任何原有的 PATH 設定:那些已經搬走的工具目錄,哪天恢復了會自動回到 PATH。
安全地測試新設定
直接覆蓋 .zshrc,萬一寫壞,下次登入可能連 shell 都開不起來。用 ZDOTDIR 指向暫存目錄,就能在不動正式設定的情況下完整測試:
1 | mkdir -p /tmp/zt && cp ~/.zprofile /tmp/zt/ && cp new_zshrc /tmp/zt/.zshrc |
結果穩定在 0.03~0.06 秒。(最後那行 nvm use 24 還順便炸出了另一個問題,見第三節。)
插曲:被 nvm 洩漏的 shell 選項
整理時我注意到 setopt 的輸出裡有 nonomatch,也就是 glob 沒有匹配時原樣傳遞,而不是報錯。我一開始以為是 nvm 載入時設的,就在延遲載入後補上了 setopt nonomatch,想「保留原本的行為」。後來寫這篇文章前重新驗證,才發現這個推論是錯的:
1 | zsh -fic 'source nvm.sh; echo before: $(setopt | grep -c nonomatch) |
真正的來源是 nvm 的 nvm_list_aliases() 裡這一行:
1 | nvm_is_zsh && unsetopt local_options nomatch |
作者的本意應該是「在這個函式內暫時關掉 nomatch」,但 unsetopt local_options nomatch 是**同時關掉 LOCAL_OPTIONS 和 NOMATCH**。local_options 自己被關掉了,函式返回時就不會還原,nomatch 的變更於是洩漏到整個 shell。正確寫法是 setopt local_options nonomatch,nvm 在別的函式裡就是這樣寫的。
所以真相是:shell 剛啟動時 nomatch 是正常開啟的,只有在執行過 nvm ls、nvm alias 之後才會變成 nonomatch。我當初會看到它,正是因為那條檢查指令裡剛好跑了 nvm ls。最後我把多加的 setopt nonomatch 拿掉,維持 zsh 的預設行為。
教訓:觀察 shell 狀態的指令,本身也可能改變狀態。
二、python3 和 pip3 不是同一個 Python
1 | python3 /opt/local/bin/python3 Python 3.14.7 ← MacPorts |
兩邊版本號都是 3.14,很容易被騙過去。但 pip3 install 裝進的是 Homebrew Python 的 site-packages,python3 跑的卻是 MacPorts 的直譯器,於是出現經典的「明明裝了卻 ModuleNotFoundError」。pip 則更離譜,指向早已 EOL 的 Python 2.7。
判斷這種錯配最快的方法:
1 | python3 -c 'import sys; print(sys.prefix)' |
統一到 MacPorts
Homebrew 早已不再為 Catalina 提供預編譯套件,什麼都得從原始碼編譯;MacPorts 則仍有 10.15 的預編譯套件。所以選 MacPorts 當主力:
1 | sudo port install py314-pip |
MacPorts 的 python3.14 沒有放 PEP 668 的 EXTERNALLY-MANAGED 標記,所以 pip install 可以直接使用(專案開發還是建議用 venv)。
Python 2.7:移到 PATH 最後,而不是刪掉
原本 .zprofile 把 /Library/Frameworks/Python.framework/Versions/2.7/bin 放在 PATH 最前面。移除之前,先檢查有哪些指令只存在那個目錄:
1 | P=/opt/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin |
結果 python2、pip2 等在 /usr/local/bin 都有連結,但 bCNC(CNC 控制軟體)、bokeh、watchmedo 等只存在於 2.7 目錄。所以改成放到 PATH 最後:
1 | PATH="${PATH}:/Library/Frameworks/Python.framework/Versions/2.7/bin" |
python、pip 由 MacPorts 優先提供,2.7 專屬的工具也還能直接執行。
三、新版 Node.js 在 Catalina 上根本跑不起來
前面測試 nvm use 24 時出現了這個錯誤:
1 | dyld: Symbol not found: __ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE4openEPKcj |
官方 Node 24 的 macOS binary 是針對 macOS 13.5 編譯的,用到了 Catalina 系統 libc++ 沒有的符號。逐一檢查已安裝的版本:
1 | for v in ~/.nvm/versions/node/*; do printf "%s: " ${v##*/}; $v/bin/node -v 2>&1 | head -1; done |
| 版本 | 結果 |
|---|---|
| v20.15.0 | ✅ 正常 |
| v23.11.1 | ✅ 正常 |
| v24.16.0 | ❌ dyld: Symbol not found |
| v26.3.0 | ❌ dyld: Symbol not found |
更糟的是,nvm 的 node/stable 別名會自動指向「已安裝的最新版」,也就是壞掉的 v26。用 nvm uninstall 移除 v24、v26 之後,別名自動回落到可用的 v23。
nvm 安裝時不會檢查 binary 能否在當前系統上執行。在老系統上裝新版 Node 之前,最好先確認它支援的最低 macOS 版本。
四、開機自啟項目:停用 26 個
盤點
macOS 的自啟來源分散在好幾個地方:
1 | ls ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons |
結果相當可觀,分類處理如下:
| 類別 | 項目 | 處理 |
|---|---|---|
| 過時軟體常駐 | CleanMyMac 2、360 安全、迅雷、百度網盤、搜狐影音、Steam 清理 | 停用 |
| 輸入法常駐 | 搜狗(其實已經不在輸入來源清單裡) | 停用 |
| 自動更新程式 | Adobe ARMDC、Google Updater、Java Updater、Paragon Updater | 停用 |
| 開發 / 遠端服務 | MySQL、TeamViewer、VirtualBox、Zoom、Intel HAXM | 停用(需要時手動開) |
| 必要元件 | 網銀安全控制項、Paragon NTFS 驅動、smartd、XQuartz、Wireshark ChmodBPF | 保留 |
停用輸入法的服務之前,先確認它還有沒有在用:
1 | defaults read com.apple.HIToolbox AppleEnabledInputSources | grep -E "Bundle ID|Input Mode" |
用 launchctl disable,不要刪 plist
很多教學會叫你直接刪掉或搬走 plist,但軟體更新時常常會把 plist 再寫回來。launchctl disable 是在 launchd 自己的資料庫裡加上「停用」標記,跟 plist 檔案無關,軟體重寫 plist 也不受影響;要還原只要 enable。
1 | # 使用者 session 內的 agent(包含 /Library/LaunchAgents 裡的) |
兩個實用的細節:
/Library/LaunchAgents裡的 agent 雖然 plist 屬於 root,但它跑在使用者的gui/<uid>domain,停用不需要 sudo。只有/Library/LaunchDaemons的才需要。- 迅雷、搜狐影音這類 App 用
SMLoginItemSetEnabled註冊的登入項目,不在上面三個目錄裡,但一樣會出現在launchctl list,也一樣可以用launchctl disable gui/<uid>/<label>停用。
坑:Label 不一定等於檔名
launchctl 吃的是 plist 裡的 Label,不是檔名。我用 PlistBuddy 逐一核對,果然抓到幾個不一樣的:
1 | for f in ~/Library/LaunchAgents/*.plist /Library/LaunchAgents/*.plist /Library/LaunchDaemons/*.plist; do |
| 檔名 | 實際 Label |
|---|---|
com.teamviewer.teamviewer_service.plist |
com.teamviewer.service |
com.teamviewer.teamviewer_desktop.plist |
com.teamviewer.desktop |
org.virtualbox.vboxwebsrv.plist |
org.virtualbox.vboxwebsvc |
拿檔名去 disable 等於停錯對象,服務照樣開機啟動。另外也發現 Google Keystone 的三個 plist 已經是空檔,Java Helper-Tool 則是指向不存在檔案的失效 symlink。launchd 根本不會載入它們,不需要處理。
寫成可還原的腳本
需要 sudo 的部分整理成腳本,並內建 --restore:
1 |
|
用 SUDO_UID 是為了在 sudo 底下仍能找到原本使用者的 gui domain。
重開機驗證
1 | launchctl list | grep -iE "sogou|macpaw|thunder|teamviewer|vbox" # 應該沒有輸出 |
重開機後 26 個項目一個都沒有回來,開機後的可用記憶體是 83%。
五、磁碟:53GB → 74GB
先找出大戶:
1 | cd ~ && du -sh Library/Caches Library/Developer/CoreSimulator/* Library/Developer/Xcode/* \ |
| 項目 | 大小 | 清理方式 |
|---|---|---|
| iOS 模擬器裝置 | 15 GB | xcrun simctl delete all(Xcode 會自動重建空裝置) |
| 模擬器 dyld 快取 | 4.9 GB | 刪除 ~/Library/Developer/CoreSimulator/Caches/* |
| Homebrew 快取、日誌、舊 portable-ruby | 1.1 GB | brew cleanup --prune=all |
| pip 快取 | 1 GB | pip3 cache purge |
| 無法執行的 Node v24 / v26 | 416 MB | nvm uninstall |
| MacPorts 舊版本(64 個) | — | sudo port uninstall inactive |
幾個注意點:
brew cleanup先用-n做 dry-run 看清單,確認沒有動到Cellar裡正在使用的版本。- MacPorts 每次升級都會把舊版留著(inactive),不清的話會越積越多。我這邊光
sqlite3就留了 3 個舊版。 - APFS 釋放空間是非同步的:刪掉 15GB 的模擬器後,
df只多了 3GB,幾分鐘後才陸續回來。如果一直沒回來,檢查是不是被 Time Machine 本機快照佔住:tmutil listlocalsnapshots /。
~/Downloads 還有 135GB,不過那是個人檔案,留給自己慢慢整理。
六、git-lfs:Catalina 上的版本陷阱
.gitconfig 裡設了 filter.lfs.required = true,git-lfs 卻根本沒裝,clone 任何用到 LFS 的 repo 都會在 checkout 時失敗。
MacPorts 編不起來
1 | sudo port install git-lfs |
1 | go: go.mod requires go >= 1.25.0 (running go 1.24.13; GOTOOLCHAIN=local) |
git-lfs 3.8 需要 Go 1.25,但 MacPorts 在 Catalina 上只提供到 Go 1.24。
改用官方的舊版 binary
上游 Go 從 1.23 起要求 macOS 11,所以要找用較舊 Go 編譯的 git-lfs 版本。v3.5.1 是用 Go 1.21.7 編譯的,下載後先驗證 checksum:
1 | V=v3.5.1 |
sha256sums.asc 本身有 PGP 簽章,要更嚴謹的話可以再用 gpg 驗證。
有趣的是,otool -l 顯示這個 binary 的 minos 是 12.0,但它在 10.15 上照樣能跑。binary 標註的最低版本不一定等於實際需求,以實測為準。
用一個本機 repo 做來回測試,確認 clean 和 smudge 兩個方向都正常:
1 | git init -q src && cd src |
意外收穫與清理
那次失敗的 port install git-lfs 並非全無作用:它先把依賴的 git 2.55 裝好了。/opt/local/bin 在 PATH 上排在 /usr/bin 前面,所以 git 直接從 Apple 的 2.24 升級到 2.55。MacPorts 的 git 預設用 osxkeychain 當 credential helper,已存的帳密照常可用。
不過它也留下了 go、asciidoctor、ronn-ng 這些只在建置時用得到的東西。用 leaves 可以找出「沒被任何套件依賴、也不是自己要求安裝」的 port:
1 | port installed leaves |
--follow-dependencies 會順便移除因此變成孤兒的依賴,這次連 MacPorts 版的 autoconf、automake 也被帶走了。好在 Homebrew 也有提供,指令仍然可用;但清理之前最好先確認會不會有這種連鎖反應。
還沒做的:Homebrew 與 MacPorts 二選一
兩套套件管理器有 222 個同名指令,誰生效完全取決於 PATH 順序,這次的 python/pip 錯配就是這樣來的。長期來看應該只留一套;在 Catalina 上,還有預編譯套件可用的 MacPorts 是比較實際的選擇。這是個大工程,留待下次。
小結
- 先量測再優化:
/usr/bin/time zsh -lic exit加上zprof,一眼就看出 93% 的時間耗在 nvm。 - 延遲載入是 shell 提速最有效的手段:PATH 先放好,重量級的初始化等真的用到再做。
- 改設定前用
ZDOTDIR在暫存目錄測試,不會把自己鎖在 shell 外面。 - **對照
python3 -c 'import sys; print(sys.prefix)'和pip3 -V**,三秒就能抓出 pip 錯配。 - **停用開機項目用
launchctl disable**:不刪檔、不怕軟體重寫 plist、可以還原。記得核對 Label;/Library/LaunchAgents的項目停用不需要 sudo。 - 老系統上的新版軟體一定要實測:Node 24 報 dyld 錯誤、git-lfs 3.8 編不過,都是 Catalina 落後的現實;反過來,標著 minos 12.0 的 binary 卻能跑。
- 觀察狀態的指令也可能改變狀態:nvm 的
unsetopt local_options nomatch讓我一度誤判了 shell 的原始設定。