生命苦短,终端当快。
Life is too short for a slow terminal

原始链接: https://mijndertstuij.nl/posts/life-is-too-short-for-a-slow-terminal/

为了实现“即时”启动的 Shell,作者主张追求极简与深思熟虑,而非堆砌臃肿的框架。通过避免使用 Oh My Zsh 和插件管理器等重量级工具,作者将启动时间控制在了 30 毫秒以内。 核心策略包括: * **拒绝框架:** 仅手动加载你实际需要的插件。 * **缓存补全:** 通过使用 24 小时缓存代替每次启动时的文件审计,来加速 `compinit`。 * **延迟加载:** 将 `nvm` 或 `kubectl` 等大型工具封装在函数中,使其仅在首次调用时加载,而非在 Shell 启动时加载。 * **异步提示符:** 使用如 `pure` 等能立即渲染并将 git 状态获取置于后台的提示符,以防止界面卡顿。 * **性能分析:** 使用 `zsh/zprof` 或 `hyperfine` 等内置工具来识别并移除加载缓慢的配置行。 归根结底,作者强调 Shell 的性能取决于“不装什么”。通过精心构建环境,终端将成为工作流中响应迅速、无缝衔接的延伸,而非“千刀万剐”般的性能杀手。

这篇 Hacker News 讨论探讨了关于终端工作流与图形界面(GUI)应用之间持续存在的争论。 终端支持者认为,命令行工具因其**可组合性**而更胜一筹,用户可以将多个程序的输出通过管道串联起来,从而创建强大且定制化的工作流。他们强调,终端应用具有极高的脚本可操作性,依赖于高效的肌肉记忆,且无需应对浏览不同 GUI 菜单时所产生的额外认知负担。 相反,一些用户认为当前围绕现代终端工具的炒作往往被夸大了。他们指出了性能问题,例如 Ghostty 等热门终端的高 CPU 占用,或是认为其存在不必要的臃肿。另一些人则认为,“终端与 GUI”之争很大程度上是个人偏好和过往经验的问题,并指出 GUI 在艺术创作等任务上仍然更具优势。 讨论中还包含了一些优化终端速度的实用建议,例如用 `mise` 等更快的替代品替换 `nvm` 等较慢的工具,以缩短 shell 的启动时间。总的来说,共识倾向于根据具体任务选择最合适的工具,同时认可基于命令行界面的模块化架构所具备的独特优势。
相关文章

原文

Update: a reader sent some great pushback on how I measured "fast" here, and on a couple of my recommendations. I wrote a follow-up on benchmarking shells properly and where this post falls short.

Practically all of my work happens inside a terminal. Git, kubectl, tmux, ssh'ing into a server, open practically the entire day. Something I use that much has to be fast. Any lag in opening a new tab, typing a character or hitting tab for a completion is something I feel hundreds of times a day. It's death by a thousand cuts.

My shell starts in about 30 milliseconds:

$ for i in {1..5}; do /usr/bin/time zsh -i -c exit; done
0.03 real  0.02 user  0.01 sys
0.03 real  0.02 user  0.01 sys
...

That's a fully loaded interactive shell with completions, syntax highlighting, autosuggestions, fzf and direnv, in less time than a single frame at 30fps. A new tab is instant. There was never some big optimization project behind this either; I've just always kept my shell minimal and fast and over the years that turned into a habit. Here's how I go about it, and all of it can be found in my dotfiles.

No framework

The single biggest win is what's not there: no oh-my-zsh, no prezto or plugin manager. I've honestly never understood the appeal of these frameworks. People install oh-my-zsh with its hundreds of plugins and themes, end up using maybe 5% of what it offers, and then pay (with their time and compute resources) for the other 95% every single time they open a shell. And plugin managers add their own overhead on top of that.

I use exactly three plugins, git-cloned once by my install script and sourced from .zshrc:

source ~/.zsh/fzf-tab/fzf-tab.plugin.zsh
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh
source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

There's no plugin manager doing dependency resolution at startup, and a source of a file that's already on disk is practically free.

Caching completions

compinit is one of the most expensive things in a typical .zshrc. By default it does a security audit of every completion file, every single time you open a shell. The fix is to only do the full run if the cache (.zcompdump) is older than 24 hours, and otherwise skip the check with -C:

autoload -Uz compinit
if [[ -n ~/.zcompdump(
  compinit -C
else
  compinit
fi

That glob qualifier (#qNmh-24) reads as "exists and was modified within the last 24 hours". So one full compinit per day, and cached reads the rest of the time.

Lazy-loading

nvm is probably the most notorious shell startup killer out there; sourcing it eagerly can easily add half a second. But I don't need nvm in every shell, I need it when I type nvm. So I wrap it in a function that replaces itself on first use:

export NVM_DIR="$HOME/.nvm"
nvm() {
  unset -f nvm
  [ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh" --no-use
  [ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
  nvm "$@"
}

The first nvm call deletes the stub, sources the real thing (with --no-use so it doesn't resolve a node version either), and forwards the arguments.

Same idea for kubectl completions, which shell out to the kubectl binary to generate the completion script. I only load them after the first time I actually run kubectl:

kubectl() {
    command kubectl "$@"
    local ret=$?
    if [[ -z $KUBECTL_COMPLETE ]]; then
        source <(command kubectl completion zsh)
        KUBECTL_COMPLETE=1
    fi
    return $ret
}

This pattern works for a lot of things: anything that tells you to put eval "$(tool init zsh)" in your .zshrc is a candidate for lazy loading, because each of those forks a process and evaluates its output at startup. I keep direnv and fzf eager because they're fast and I use them constantly. Be strict about what you actually use a lot.

A non-blocking prompt

A prompt that runs git status synchronously will lag in any decently sized repo. That's the kind of lag you feel on every single Enter press, which is arguably worse than a slow startup. I use pure, which renders the prompt immediately and fills in the git info asynchronously when it's ready. I briefly tried replacing it with zsh's built-in vcs_info, but pure's async behavior is just... better. You can do async git status in your own prompt as well, but pure wraps it rather nicely for my use-case.

The terminal itself

Shell startup is only half the story, because the emulator adds its own input latency. I use Ghostty, which is GPU-accelerated and native, and my config is just seven lines long. Combined with a tmux new -A -s main alias (t), a fresh terminal window drops me right back into my existing session.

Measuring your own shell performance

You don't have to take my word for any of this, you can measure where the time goes in your own terminal. There are three kinds of lag to look for: startup time, prompt lag, and input latency.

Run this a few times (the first run is always slower because of cold caches):

time zsh -i -c exit

I think anything under 100ms is fine, and under 50ms is great. If you're seeing 500ms or more you have some work to do.

For proper statistics, use hyperfine:

hyperfine --warmup 3 'zsh -i -c exit'

Zsh also ships with a profiler. Put this at the very top of your .zshrc:

zmodload zsh/zprof

and this at the very bottom:

zprof

Open a new shell and you get a sorted table of exactly where the time went. The top entries are usually compinit, an nvm.sh source, or some eval "$(...)". Fix the top one, re-run, repeat. Remove both lines when you're done.

If zprof isn't granular enough, you can trace the whole startup with timestamps:

zsh -ixc exit 2>&1 | ts -i '%.s' | sort -rn | head -20

Or set PS4='+%D{%s.%6.}: ' and run zsh -ixc exit 2> startup.log, then look for the big jumps between lines.

Startup can be fast while every prompt redraw is slow. cd into your biggest git repo and press Enter; if there's a delay before the next prompt appears, your prompt is doing synchronous work slowing it down. You can either switch to using an async prompt, or opt to strip out the Git functionality.

Wrapping up

Most of these optimizations are about leaving stuff out. It's about being intentional and only adding things you're going to use. Every one of the dozens of sessions I open per day is instant, and my terminal feels like an extension of my brain instead of an application I'm waiting on. For a tool I use the entire day that's non-negotiable for me.

All of the above lives in my dotfiles repo if you want to steal anything.

联系我们 contact @ memedata.com