<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/feeds/style.xsl"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>julia-evans</title>
    <description>rssume processed feed for julia-evans</description>
    <link>/feeds/julia-evans</link>
    <atom:link href="/feeds/julia-evans" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Thu, 4 Jun 2026 09:56:06 +0000</lastBuildDate>
    <generator>rssume</generator>
    <item>
      <title>远离 Tailwind，学习构建我的 CSS 结构</title>
      <description>[AI 摘要] The author describes migrating from Tailwind CSS to vanilla CSS, sharing lessons on structuring CSS code for better maintainability.</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> The author describes migrating from Tailwind CSS to vanilla CSS, sharing lessons on structuring CSS code for better maintainability.</div><p>你好！八年前，我<a href="https://jvns.ca/blog/2018/11/01/tailwind--write-css-without-the-css/" rel="noopener noreferrer">兴奋地写了关于发现 Tailwind 的文章</a>。</p>
<p>那时我真的不知道如何组织我的 CSS 代码，在一堆完全混乱的东西和 Tailwind 之间选择时，我非常高兴地选择了 Tailwind。它帮助我制作了许多小网站！</p>
<p>过去一周左右，我一直在将一些网站从 Tailwind 迁移到更语义化的 HTML 和纯 CSS，这非常有趣且令人兴奋，所以我学到了一些东西！</p>
<p>像往常一样，我不是全职前端开发者，所以我的 CSS 学习多年来都是断断续续发生的。</p>
<h3 id="it-turns-out-tailwind-taught-me-a-lot">事实证明 Tailwind 教了我很多</h3>
<p>当我开始思考如何组织 CSS 时，一开始我很害怕：我不太擅长组织我的 CSS！但接着我开始阅读关于如何组织 CSS 的博客文章（比如<a href="https://www.miriamsuzanne.com/2022/09/06/layers/" rel="noopener noreferrer">一整套层叠层</a>或<a href="https://jacobb.nyc/writing/how-i-write-css-in-2024" rel="noopener noreferrer">我在 2024 年如何写 CSS</a>），然后我意识到几件事：</p>
<ol>
<li>每个 CSS 代码库都有许多不同的事情在发生（布局！字体！颜色！通用组件！）</li>
<li>为每种事情设置系统或指南非常有用，否则事情会变得混乱</li>
<li>Tailwind 对其中一些事情有系统，而且我已经知道这些系统！也许我可以模仿我喜欢的系统！</li>
</ol>
<p>例如，Tailwind 有：</p>
<ul>
<li>一个重置样式表</li>
<li>一个<a href="https://jvns.ca/blog/2026/05/04/css-colour-palettes/" rel="noopener noreferrer">调色板</a></li>
<li>一个<a href="https://v2.tailwindcss.com/docs/font-size" rel="noopener noreferrer">字体比例</a></li>
</ul>
<h3 id="the-systems-i-m-going-to-talk-about">我要谈论的系统</h3>
<p>我将谈论我的 CSS 代码库的几个方面，以及我目前对每个方面想要强加的规则类型的一些想法。其中一些是从 Tailwind 复制过来的，一些不是。</p>
<ol>
<li>重置</li>
<li>组件</li>
<li>颜色</li>
<li>字体大小</li>
<li>工具类</li>
<li>基础样式</li>
<li>间距</li>
<li>响应式设计</li>
<li>构建系统</li>
</ol>
<h3 id="1-reset">1. 重置</h3>
<p>我只是复制了 Tailwind 的"<a href="https://v2.tailwindcss.com/docs/preflight" rel="noopener noreferrer">预检样式</a>"，通过进入 <code>tailwind.css</code> 并复制前大约 200 行。</p>
<p>我注意到我随着时间推移已经与 Tailwind 的 CSS 重置建立了关系，例如 Tailwind 在每个元素上设置 <code>box-sizing: border-box</code>（这意味着元素的宽度包括其内边距）：</p>
<pre><code>* { box-sizing: border-box; }
</code></pre>
<p>我认为如果没有这些，切换到写 CSS 会是一个真正的调整，我确信 Tailwind 重置中还有许多其他东西（比如 <code>html {line-height: 1.5;}</code>）是我潜意识里习惯的，甚至没有意识到它们的存在。</p>
<h3 id="2-components">2. 组件</h3>
<p>接下来的这部分是 CSS 的大部分！</p>
<p>这里的想法是按照“组件”来组织 CSS，这在精神上类似于 Vue 或 React 组件。（尽管网站中可能没有任何 JavaScript）</p>
<p>基本上，想法是：</p>
<ol>
<li>每个“组件”有一个唯一的类</li>
<li>一个组件的 CSS 永远不会覆盖任何其他组件的 CSS</li>
<li>每个组件有自己的 CSS 文件</li>
</ol>
<p>因此编辑一个组件的 CSS 不会神秘地破坏另一个组件中的任何东西。而且可能我想要更改的 80% 的 CSS 都在各种组件文件中，所以如果我编辑一个 100 行的组件，我只需要考虑那 100 行。这对我来说更容易思考。</p>
<p>例如，这段 HTML 可能是 <code>.zine</code> “组件”：</p>
<pre><code>&lt;figure class="zine horizontal"&gt;
    &lt;img src="whatever.jpg"&gt;
&lt;/figure&gt;
</code></pre>
<p>CSS 看起来像这样，使用嵌套选择器：</p>
<pre><code>.zine {
  ...
  &amp;.horizontal {
    ...
  }
  &amp;.vertical {
    ...
  }
  &amp;:hover {
    ...
  }
}
</code></pre>
<p>我没有做任何程序化的事情（比如 web 组件或<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope" rel="noopener noreferrer">@scope</a>）来确保组件不会相互干扰，但仅仅有一个约定并尽力而为已经感觉是一个很大的改进。</p>
<p>接下来：在整个站点中保持一些一致性并使这些组件彼此协调的惯例！</p>
<h3 id="3-colours">3. 颜色</h3>
<p><code>colours.css</code> 有一堆像这样的变量，我可以根据需要使用。颜色真的很难，我不打算在这次重构中重新审视我对颜色的使用，所以我保持原样。</p>
<p>我在这里试图强制执行的唯一指导原则是，站点中使用的所有颜色都列在此文件中。</p>
<pre><code>:root {
  --pink: #fea0c2;
  --pink-light: #F9B9B9;
  --red: #f91a55;
  --orange: rgb(222, 117, 31);
  ...
}
</code></pre>
<h3 id="4-font-sizes">4. 字体大小</h3>
<p>我欣赏 Tailwind 的一点是，如果我想设置字体大小，我可以直接想“嗯，我想要文本大一些”，写 <code>text-lg</code>，然后就完成了！如果它不够大，我可能会用 <code>xl</code> 或 <code>2xl</code> 代替。无需尝试记住我是在使用 <code>em</code>、<code>px</code> 还是 <code>rem</code>。</p>
<p>所以我定义了一堆变量，从 Tailwind 取来，像这样：</p>
<pre><code>  --size-xs: 0.75rem;
  --line-height-xs: 1rem;

  --size-sm: 0.875rem;
  --line-height-sm: 1.25rem;
</code></pre>
<p>然后，如果我想设置字体大小，我可以这样做。它比 Tailwind 稍微啰嗦一些，但我现在很满意。</p>
<pre><code>h3 {
  font-size: var(--size-lg);
  line-height: var(--line-height-lg);
}
</code></pre>
<h3 id="5-utilities">5. 工具类</h3>
<p>有些东西像按钮一样出现在许多不同的组件中。我称这些为“工具类”。</p>
<p>我从 Tailwind 复制了一些工具类（比如 <code>.sr-only</code>，用于只应出现在屏幕阅读器用户中的内容）。</p>
<p>这部分很小，我试图谨慎对待这里的更改。</p>
<h3 id="6-the-base">6. 基础样式</h3>
<p>“基础”样式是我自己选择的应用于整个站点的样式。我必须保持这一部分非常小，因为我不够自信在整个站点上强制执行许多样式。我现在只对这两个感觉可以，而且我可能会更改 <code>&lt;section&gt;</code> 那个：</p>
<pre><code>/* put a 950px column in the middle of each &lt;section&gt; */
section {
  --inner-width: 950px;
  padding: 3rem max(1rem, (100% - var(--inner-width))/2);
}

a {
  color: var(--orange);
}
</code></pre>
<p>我认为对于基础样式，我将从底部向上开始工作——首先从几乎没有基础样式开始，然后随着我识别出想要的共同内容，将一些样式从组件移入基础样式。</p>
<h3 id="7-spacing">7. 间距</h3>
<p>我还没有完全确定管理内边距和外边距的方法。不过，我肯定试图比我用 Tailwind 时更有原则，在 Tailwind 中我只是随意地在各处放置内边距和外边距，直到它看起来符合我的要求。</p>
<p>现在我正在努力让外部布局组件尽可能多地负责间距。例如，如果我有一个 <code>&lt;section&gt;</code>，其中有一堆我想要它们之间有空间的子元素，我可以使用这个来均匀地间隔子元素：</p>
<pre><code>section &gt; *+* {
  margin-top: 1rem;
}
</code></pre>
<p>一些灵感博客文章：</p>
<ul>
<li><a href="https://piccalil.li/blog/my-favourite-3-lines-of-css/" rel="noopener noreferrer">猫头鹰选择器</a></li>
<li><a href="https://kyleshevlin.com/no-outer-margin/" rel="noopener noreferrer">“无外部边距”</a></li>
</ul>
<h3 id="8-responsive-design-use-more-grid">8. 响应式设计：使用更多网格！</h3>
<p>我用 Tailwind 做响应式设计的方式是大量使用媒体查询。Tailwind 有这种 <code>md:text-xl</code> 语法，意思是“在 <code>md</code> 或更大尺寸时应用 <code>text-xl</code> 样式”。</p>
<p>我现在尝试一些非常不同的方法，即制作更灵活的 CSS 网格布局，不需要那么多断点。这很难，但学习网格的可能性非常有趣，而且这是一个我认为用 Tailwind 不可能实现的好例子。</p>
<p>例如，我一直在学习如何使用 <code>auto-fit</code> 来自动在大屏幕上使用 2 列，在小屏幕上使用 1 列，像这样：</p>
<pre><code>  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), max-content));
  justify-content: center;
</code></pre>
<p>我还大量使用了<a href="https://wizardzines.com/comics/css-grid-areas/" rel="noopener noreferrer"><code>grid-template-areas</code></a>，这是一个我认为你无法用 Tailwind 实现的惊人功能。</p>
<p>一些灵感：</p>
<ul>
<li>来自 CSS Tricks 的<a href="https://css-tricks.com/a-responsive-grid-layout-with-no-media-queries/" rel="noopener noreferrer">无媒体查询的响应式网格布局</a></li>
</ul>
<h3 id="the-build-system-esbuild">9. 构建系统：esbuild</h3>
<p>在开发中，我不需要构建系统：CSS 现在有内置的导入语句，像这样：</p>
<pre><code>@import "reset.css";
@import "typography.css";
@import "colors.css";
</code></pre>
<p>以及内置的嵌套选择器，像这样：</p>
<pre><code>.page {
  h2 { ...}
}
</code></pre>
<p>如果我想，我可以使用 <code>esbuild</code> 为生产环境打包 CSS 文件。它看起来像这样。</p>
<pre><code>esbuild style.css --bundle --loader:.svg=dataurl  --loader:.woff2=file --outfile=/tmp/out.css
</code></pre>
<p>尽管我通常避免使用 CSS 和 JS 构建系统，但我不介意使用 esbuild（我<a href="https://jvns.ca/blog/2021/11/15/esbuild-vue/" rel="noopener noreferrer">在 2021 年这里写过</a>），因为它是基于 Web 标准的，而且是一个静态的 Go 二进制文件。</p>
<h3 id="why-migrate-away-from-tailwind">为什么迁离 Tailwind？</h3>
<p>有几个人问我为什么迁离 Tailwind。一些促成因素是：</p>
<ul>
<li>自 2018 年以来，Tailwind 变得更依赖构建系统，我认为在不使用构建系统的情况下无法使用较新版本的 Tailwind。所以我多年来一直使用 Tailwind v2。（还有<a href="https://litewindcss.com/" rel="noopener noreferrer">litewind</a>似乎）</li>
<li>一直主张应该与构建系统一起使用 Tailwind，但我从未真正这样做过，所以我在许多项目中都有 2.8MB 的 <code>tailwind.min.css</code> 文件（压缩后 270K），这感觉有点傻。</li>
<li>我比开始使用 Tailwind 时更擅长 CSS 了</li>
<li>最终 Tailwind 是有限的：如果你想在 CSS 中做奇怪的事情，用 Tailwind 不总是可能的。这些限制可能非常有用（这篇文章的很多部分是关于我重新实现 Tailwind 的一些限制！），但此时我希望能够选择和挑选。</li>
<li>我最终得到的网站在同一个项目中混合了纯 CSS 和 Tailwind，维护起来并不有趣</li>
<li>我好奇编写更多语义化 HTML 的感觉如何。</li>
</ul>
<h3 id="css-features-i-m-curious-about">我好奇的 CSS 功能</h3>
<p>在做这件事时，我了解了很多我未使用但好奇有朝一日会学习的 CSS 功能：</p>
<ul>
<li><code>@layer</code>（来自<a href="https://www.miriamsuzanne.com/2022/09/06/layers/" rel="noopener noreferrer">一整套层叠层</a>）</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope" rel="noopener noreferrer">@scope</a>）（尤其是<a href="https://drafts.csswg.org/css-cascade-6/#example-463550a5" rel="noopener noreferrer">这个规范中如何在“组件”CSS 设计中使用 @scope 的例子！</a>）</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Containment/Container_queries" rel="noopener noreferrer">容器查询</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Subgrid" rel="noopener noreferrer">子网格</a></li>
</ul>
<h3 id="one-last-reason-i-moved-away-from-tailwind">我迁离 Tailwind 的最后一个原因</h3>
<p>在这篇文章中，我谈了很多从使用 Tailwind 学到的东西，这都是真的。</p>
<p>但我三年前读了一篇叫<a href="https://thoughtbot.com/blog/tailwind-and-the-femininity-of-css" rel="noopener noreferrer">Tailwind 和 CSS 的女性气质</a>的文章，它真的留在了我的记忆中。老实说，我可能一开始对 CSS 的态度有点像那篇文章描述的那样：</p>
<blockquote>
<p>他们听说它很简单，所以认为它很容易。但当他们尝试使用它时，它不起作用。这一定是语言的错，因为他们知道他们很聪明，而这应该是很容易的。</p>
</blockquote>
<p>但在过去 10 年里，我学会了真正喜欢和尊重 CSS 作为一种技术。</p>
<p>所以我几年前决定，我想通过提高 CSS 技能并认真对待它作为技术来回应“CSS 很难”，而不是贬低它。这样做改变了一切：我了解到我的许多挫败感（“居中是不可能的”）在 CSS 中早已得到解决，而且“居中”意味着什么并不总是直接的，它有多种方式是合理的。CSS 很难是因为它在解决一个难题！</p>
<p>我对过去 10-15 年构建的新 CSS 功能印象深刻（其中一些我在本文中讨论过！），它们如何让 CSS 更容易使用，而花时间提高我的 CSS 技能一直是一次非常酷的体验。</p>
<p>那篇文章让我觉得 Tailwind 促进了 CSS 专业知识的贬值，这不是我想参与的事情，即使 Tailwind 对我个人来说是一个有用的工具。尤其是在这个大型语言模型（LLMs）时代，感觉比以往任何时候都更重视人类的专业知识。</p>
<p>另一篇批评 Tailwind 的博客文章影响了我：</p>
<ul>
<li><a href="https://joshcollinsworth.com/blog/tailwind-is-smart-steering" rel="noopener noreferrer">经典摇滚、马里奥赛车，以及为什么我们无法就 Tailwind 达成一致</a></li>
</ul>
<h3 id="that-s-all-for-now">目前就这些！</h3>
<p>感谢<a href="https://melody.dev/" rel="noopener noreferrer">Melody Starling</a>，她最初设计并编写了 <a href="https://wizardzines.com" rel="noopener noreferrer">wizardzines.com</a> 的 CSS，网站所有酷炫有趣的东西都归功于 Melody。</p>
<p>另外，我在做这件事时读了很多关于 CSS 的精彩博客文章（来自<a href="https://css-tricks.com/" rel="noopener noreferrer">CSS Tricks</a>、<a href="https://www.smashingmagazine.com/" rel="noopener noreferrer">Smashing Magazine</a> 等），我试着在本文中链接其中一些，我真的很感激 CSS 社区中的人们分享他们的实践。</p><p><em>由 mimo-v2.5 模型翻译，花费 9328 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2026/05/15/moving-away-from-tailwind--and-learning-to-structure-my-css-/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2026/05/15/moving-away-from-tailwind--and-learning-to-structure-my-css-/</guid>
      <pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>CSS 色板资源链接</title>
      <description>[AI 摘要] 该文分享了作者在告别 Tailwind 后收藏的一系列 CSS 调色板、生成器及颜色工具资源。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 该文分享了作者在告别 Tailwind 后收藏的一系列 CSS 调色板、生成器及颜色工具资源。</div><p>前阵子我决定新项目不再使用 Tailwind，转而编写原生 CSS。</p>
<p>但我怀念 Tailwind 的一点是它的<a href="https://v2.tailwindcss.com/docs/customizing-colors#color-palette-reference" rel="noopener noreferrer">调色板</a>（<a href="https://gist.github.com/jvns/9e59b2cd1fe12601084ba78dded072fe" rel="noopener noreferrer">CSS 格式版本在此</a>）。如果我需要浅蓝色，直接使用 <code>blue-100</code> 即可；若不喜欢，可尝试 <code>blue-200</code> 或 <code>blue-50</code>。我对颜色不太在行，因此拥有一套由更擅长颜色的人精心设计的合理调色板，对我来说差别很大。</p>
<p>但我对这些 Tailwind 颜色也有些腻了，于是今天在 Mastodon 上询问还有哪些其他调色板可用。一位朋友说他也想要这些调色板的链接，所以我写了这篇博文，好让我的朋友和其他人都能看到：)</p>
<h3 id="my-favourites">我最喜欢的</h3>
<p>我最喜欢的是：</p>
<ul>
<li><a href="https://uchu.style/" rel="noopener noreferrer">uchū</a>（<a href="https://code.webb.page/nevercease/uchu.git/tree/dist/uchu.css" rel="noopener noreferrer">CSS 文件</a>，<a href="https://code.webb.page/nevercease/uchu.git/about/documentation/FAQ.md" rel="noopener noreferrer">常见问题</a>）</li>
<li><a href="https://stephango.com/flexoki" rel="noopener noreferrer">flexoki</a>（<a href="https://github.com/kepano/flexoki/blob/main/css/flexoki.css" rel="noopener noreferrer">CSS 文件</a>）</li>
<li><a href="https://www.reasonable.work/artifacts/ra005-reasonable-colors/" rel="noopener noreferrer">reasonable colours</a>，似乎专注于无障碍访问（<a href="https://github.com/matthewhowell/reasonable-colors/blob/master/reasonable-colors.css" rel="noopener noreferrer">CSS 文件</a>）</li>
</ul>
<h3 id="more-colour-palettes">更多调色板</h3>
<ul>
<li><a href="https://webawesome.com/docs/color-palettes" rel="noopener noreferrer">web awesome</a></li>
<li><a href="https://www.radix-ui.com/colors/docs/palette-composition/scales" rel="noopener noreferrer">radix</a></li>
<li><a href="https://designsystem.digital.gov/design-tokens/color/system-tokens/" rel="noopener noreferrer">美国网页设计系统</a></li>
<li><a href="https://m2.material.io/design/color/the-color-system.html" rel="noopener noreferrer">material design</a></li>
</ul>
<h3 id="colourscheme-generators">配色方案生成器</h3>
<p>大家还推荐了许多配色方案生成器：</p>
<ul>
<li><a href="https://harmonizer.evilmartians.com/" rel="noopener noreferrer">harmonizer</a></li>
<li><a href="https://www.tints.dev/" rel="noopener noreferrer">tints.dev</a></li>
<li><a href="https://coolors.co/" rel="noopener noreferrer">coolors</a></li>
<li><a href="https://colorpalette.pro/" rel="noopener noreferrer">colorpalette.pro</a></li>
</ul>
<p>我一直觉得这类生成器太难使用，但或许有一天我的颜色知识会进步到能成功使用配色方案生成器，所以我还是把这些链接放在这里。</p>
<p>更多颜色工具：</p>
<ul>
<li><a href="https://www.colorhexa.com/E97339" rel="noopener noreferrer">colorhexa</a> 提供了一些关于色盲的信息。</li>
</ul>
<h1 id="oklch"><code>oklch</code></h1>
<p><a href="https://gomakethings.com/generative-colors-with-css/" rel="noopener noreferrer">CSS 生成颜色</a>展示了一个如何使用 <code>oklch</code> CSS 函数动态生成颜色的示例。</p><p><em>由 mimo-v2.5 模型翻译，花费 2217 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2026/05/04/css-colour-palettes/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2026/05/04/css-colour-palettes/</guid>
      <pubDate>Mon, 4 May 2026 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>在浏览器中测试Vue组件</title>
      <description>[AI 摘要] 作者分享了在不使用Node.js的情况下，使用QUnit框架在浏览器中直接为Vue组件编写和运行端到端集成测试的实践经验。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 作者分享了在不使用Node.js的情况下，使用QUnit框架在浏览器中直接为Vue组件编写和运行端到端集成测试的实践经验。</div><p>大家好！我在这里的长期项目之一是
<a href="https://jvns.ca/#javascript" rel="noopener noreferrer">探索如何在不使用Node</a>或其他服务端JavaScript运行时的情况下编写前端JavaScript。</p>
<p>我在前端JavaScript项目中经常遇到的一个问题是不知道如何为它们编写测试。我过去尝试过使用Playwright，但它似乎很慢且笨重，因为总是需要启动这些新的浏览器进程，而且需要一些Node代码来编排测试。</p>
<p>结果就是我干脆不测试我的前端代码，这感觉不太好。通常我也不会经常更新我的项目，所以这个问题不常出现，但如果有更自信地进行更改的能力就好了！因此，一种我喜欢的前端测试方法已经列入我的愿望清单很久了。</p>
<h3 id="idea-just-run-the-tests-in-the-browser-tab">想法：直接在浏览器标签页中运行测试</h3>
<p>不久前，Alex Chan写了一篇很棒的文章，名为<a href="https://alexwlchan.net/2023/testing-javascript-without-a-framework/" rel="noopener noreferrer">Testing JavaScript without a (third-party) framework</a>，这是对我之前关于如何编写一个运行在浏览器页面中的微型单元测试框架系列文章的回应。</p>
<p>我当时很喜欢这篇文章，但它只谈到了单元测试，而我想为我的Vue组件编写端到端的集成测试，我不知道该怎么做。</p>
<p>所以，当我前几天和<a href="https://bsky.app/profile/polotek.bsky.social" rel="noopener noreferrer">Marco</a>交谈时，他说了类似“你知道，你可以直接在浏览器中运行你的Vue组件测试”的话，我想“嘿，我应该再试一次！！！”。</p>
<p>我昨天刚完成这一切，所以肯定还有很多可以改进的地方，但我想在忘记之前写下我对这个过程的一些观察。</p>
<p>这对我来说有点棘手，因为Vue网站通常假设你以某种方式在构建过程中使用Node（有很多“第一步：<code>npm install THING</code>”），而我不想使用Node/Deno等。但结果证明这并不太复杂。</p>
<p>我将要讨论测试的项目是这个<a href="https://jvns.ca/blog/2023/03/31/zine-feedback-site/" rel="noopener noreferrer">我在2023年编写的zine反馈站点</a>。</p>
<h3 id="the-test-framework-qunit">测试框架：QUnit</h3>
<p>我使用了<a href="https://qunitjs.com/" rel="noopener noreferrer">QUnit</a>。它工作得很好，但关于它如何工作，我没什么有趣的话可说，所以我就不说了。我认为Alex的“编写你自己的测试框架”的方法也能行。我遵循了<a href="https://qunitjs.com/browser/" rel="noopener noreferrer">这些说明</a>。</p>
<p>我确实很欣赏QUnit有一个“重新运行测试”按钮，它只会重新运行1个测试。因为我的测试中有大量的网络请求，有一种只运行单个测试的方式使调试测试变得不那么混乱。</p>
<h3 id="step-1-set-up-the-component-for-testing">第一步：为测试设置组件</h3>
<p>我需要做的第一件事是在测试环境中设置我的Vue组件。</p>
<p>我更改了我的主应用程序，将所有的组件放入<code>window._components</code>，大概像这样：</p>
<pre><code>const components = {
  'Feedback': FeedbackComponent,
  ...
}
window._components = components;
</code></pre>
<p>然后我能够编写一个<code>mountComponent</code>函数，它基本上与我的正常主应用程序做完全相同的事情（使用我想用的组件渲染一个小模板）。唯一的区别是：</p>
<ol>
<li>我可以选择性地传递一些额外数据作为它的props。</li>
<li>它将组件挂载到一个临时的、不可见的div上，该div在测试完成后将从DOM中移除。该div被定位在页面外（<code>position: absolute; top: -10000, ...</code>），所以你看不到它。</li>
</ol>
<p>使用<code>mountComponent</code>函数的样子如下：</p>
<pre><code>const {div} = mountComponent(
  '&lt;Page :feedbacks="feedbacks" id=2 /&gt;',
  {feedbacks: [testFeedback]},
);
</code></pre>
<p>这是它的代码：</p>
<pre><code>function mountComponent(template, data) {
  const app = Vue.createApp({
    template: template,
    data: () =&gt; data,
  })
  for (const [c, v] of Object.entries(window._components)) {
    app.component(c, v);
  }
  const div = document.getElementById('qunit-fixture')
             .appendChild(document.createElement('div'));
  return div;
}
</code></pre>
<p>结果是一个div，我可以在其中以编程方式点击、填写表单数据、检查是否出现了正确的内容等。</p>
<h3 id="step-2-add-some-fixture-data">第二步：添加一些测试夹具数据</h3>
<p>因为我正在编写端到端集成测试以确保我的客户端JavaScript与服务器正常工作，所以我需要数据库中有一些测试数据。所以我写了大约25行SQL来在我的数据库中设置一些测试数据，并在开发服务器上添加了一个端点来运行SQL，将测试数据重置到已知状态。</p>
<pre><code>async function reset() {
    return fetch('/api/reset_test_data', {method: "POST"})
}
</code></pre>
<p>然后我只需在任何需要测试数据的测试开始时运行<code>await reset()</code>。</p>
<p>我的<code>reset()</code>函数实际上并不总是完全重置所有内容，这有点糟糕，但作为开始是可行的，而且总是可以改进的。</p>
<h3 id="step-3-a-basic-test">第三步：一个基本测试</h3>
<p>这是一个基本测试的样子！基本上我们正在渲染div并确保它包含一些大致正确的数据。</p>
<pre><code>QUnit.test('renders feedback content', async function (assert) {
  const {div} = mountComponent(
    '&lt;Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 /&gt;',
    {feedbacks: [testFeedback]},
  );
  assert.ok(div.textContent.includes('loved this section'));
})
</code></pre>
<p>这些都是基本的部分！现在是我在过程中遇到的几个问题：</p>
<h3 id="waiting-for-parts-of-the-page-to-render">等待页面的某些部分渲染</h3>
<p>我的测试中有很多网络请求，它们需要时间完成，Vue代码也需要时间处理结果并更新DOM。</p>
<p>我想我们很久以前就学到了，在测试中放置随机的<code>sleep()</code>调用并希望时间正确是很慢、很脆弱且极其令人沮丧的，所以我需要一种不同的方式。</p>
<p>据我所知，处理这个问题的通常方式是想办法从DOM中判断是否可以继续。比如“如果这个按钮可见，我们就可以‘’”。</p>
<p>所以我写了一个小<code>waitFor()</code>函数，每20轮询一次条件是否完成。它在2秒后超时。</p>
<p>使用它看起来像这样：</p>
<pre><code>QUnit.test("click item", async function (assert) {
  const {div} = mountComponent(
    '&lt;Feedback zine_id="test123" image_width="800px" /&gt;',
    {});
  const item = await waitFor(() =&gt; div.querySelector('.feedback-item'));
  item.click();
  // rest of test goes here... 
})
</code></pre>
<p>看起来有很多这个概念的实现，它们都比我的更完善。（通过快速谷歌搜索：<a href="https://www.npmjs.com/package/qunit-wait-for" rel="noopener noreferrer">qunit-wait-for</a>，<a href="https://playwright.dev/docs/testing-library#replacing-waitfor" rel="noopener noreferrer">playwright expect.poll</a>）</p>
<h3 id="figuring-out-the-right-thing-to-wait-for-is-not-straightforward">弄清楚要等待的正确事情并不简单</h3>
<p>在某些情况下，我<em>认为</em>我已经确定了DOM中需要等待的正确内容（“就等这个文本区域出现！”），但结果由于我程序工作方式的一些内部细节，实际上我需要等待稍后其他难以确定的东西。</p>
<p>最终我修改了我的一个组件，在它完成一个重要操作时在DOM中添加一些随机值（比如<code>data-this-thing-is-ready=true</code>），这感觉不太好。</p>
<p>我最好的猜测是，修复这类测试问题的正确方法是进行重构，同时使应用程序对用户更可靠：如果DOM中有一个元素实际上还没有准备好让用户交互，也许我还不应该显示它！</p>
<h3 id="adding-some-css-classes-to-identify-things-but-is-that-right">添加一些CSS类来标识事物（但这样对吗？）</h3>
<p>我最终向HTML元素添加了一些类，这些类是我需要在测试中找到的，要么是因为我需要点击它们，要么是等待它们出现在DOM中。</p>
<p>我以后可能会改变这种方法——前端测试框架似乎建议避免使用CSS类，而是使用类似<a href="https://playwright.dev/docs/api/class-framelocator#frame-locator-get-by-role" rel="noopener noreferrer">getByRole</a>的东西，或者作为最后手段，使用类似<a href="https://testing-library.com/docs/dom-testing-library/intro/" rel="noopener noreferrer">data-testid</a>的东西。感觉有一种方法可以让应用程序同时更易于访问且更易于测试。</p>
<h3 id="filling-out-forms-is-tricky">填写表格很棘手</h3>
<p>要填写表单，我不能只设置<code>value</code>，我还需要触发一个事件来告诉Vue元素已经更改。例如，<code>checkbox</code>和<code>textarea</code>需要不同类型的事件。</p>
<pre><code>textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));
</code></pre>
<pre><code>checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
</code></pre>
<p>这有点烦人，这让我意识到为什么我可能想要使用某种UI测试库，例如：</p>
<ul>
<li>Testing Library的<a href="https://testing-library.com/docs/example-react-formik" rel="noopener noreferrer">填写表单示例</a>看起来与我正在做的事情非常不同。</li>
<li>Vue Test Utils：他们的<a href="content/post/2026-05-02-testing-javascript-in-the-browser.markdown" rel="noopener noreferrer">表单处理部分</a>看起来简化了很多。</li>
</ul>
<h3 id="test-coverage">测试覆盖率</h3>
<p>我想了解我的测试覆盖率是多少，结果Chrome实际上有一个内置的用于JavaScript和CSS的<a href="https://developer.chrome.com/docs/devtools/coverage" rel="noopener noreferrer">代码覆盖率</a>功能！</p>
<p>我的JavaScript通过esbuild打包成一个名为<code>bundle.js</code>的文件，所以我可以直接查看<code>bundle.js</code>并查看哪些行没有被覆盖。</p>
<p>这个过程有点繁琐：我必须在Chrome开发工具中关闭源映射才能使其工作，而且为了查看覆盖率数据，需要执行一系列不太明显的特定操作。</p>
<h3 id="this-was-so-fun">这太有趣了！</h3>
<p>和这些文章一样，我从未真正作为前端或后端开发者工作过（除了为自己工作！），我觉得自己一直在学习如何做最基本的事情。</p>
<p>我真的非常享受这个过程。我的前端项目总是感觉非常脆弱，因为它们没有测试，也许有一天我会有一套我信任的测试套件！</p>
<p>一些我仍在思考的事情：</p>
<ul>
<li>在写这篇文章时，我发现了这个名为<a href="https://testing-library.com/" rel="noopener noreferrer">Testing Library</a>的前端测试库，它有很多关于如何编写测试的指南，这些指南与我最初的想法大不相同。我尝试用Testing Library重写所有内容，感觉相当不错，所以我们会看到进展如何。他们提供了一个<code>.umd.js</code>文件，无需Node即可工作。</li>
<li>我不确定我对完全没有命令行运行这些测试的方式感觉如何。也许有一种简单的方法可以主要在浏览器中工作，但如果我想的话，也有在CI中运行它们的方式？</li>
</ul><p><em>由 mimo-v2.5 模型翻译，花费 6377 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2026/05/02/testing-vue-components-in-the-browser/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2026/05/02/testing-vue-components-in-the-browser/</guid>
      <pubDate>Sat, 2 May 2026 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>tcpdump和dig手册页示例</title>
      <description>[AI 摘要] 作者为tcpdump和dig工具的手册页添加了面向初学者和不常用用户的基础示例。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 作者为tcpdump和dig工具的手册页添加了面向初学者和不常用用户的基础示例。</div><p>你好！上个月关于<a href="https://jvns.ca/blog/2026/02/18/man-pages/" rel="noopener noreferrer">手册页的思考</a>让我最大的收获是：手册页中的示例真的非常有用，因此我着手为我最爱的两个工具的手册页添加（或改进）示例。</p>
<p>以下是成果：</p>
<ul>
<li><a href="https://bind9.readthedocs.io/en/stable/manpages.html#dig-dns-lookup-utility" rel="noopener noreferrer">dig手册页（现已包含示例）</a></li>
<li><a href="https://www.tcpdump.org/manpages/tcpdump.1.html#lbAF" rel="noopener noreferrer">tcpdump手册页示例</a>（这是在原有示例基础上的更新）</li>
</ul>
<h3 id="the-goal-include-the-most-basic-examples">目标：包含最基础的示例</h3>
<p>这里的目标纯粹是提供关于如何使用该工具的最基础示例，面向那些不常使用tcpdump或dig（或从未使用过！）且不记得其工作原理的人。</p>
<p>到目前为止，提出“我想为这个工具的初学者和不常使用的用户写一个示例章节”这个说法一直非常奏效。它易于解释，根据我听到的用户对手册页的需求，我认为这很合理，维护者们似乎也认为它很有说服力。</p>
<p>感谢Denis Ovsienko、Guy Harris、Ondřej Surý以及所有审阅文档更改的人，这是一次愉快的经历，激励我继续在手册页上做更多工作。</p>
<h3 id="why-improve-the-man-pages">为何要改进手册页？</h3>
<p>我现在有兴趣参与工具官方文档的工作，因为：</p>
<ul>
<li>手册页实际上可以达到接近100%的准确性！经过审查流程以确保信息真实无误具有很大价值。</li>
<li>即使是关于“tcpdump最常用的标志是什么”这样的基本问题，维护者通常也了解一些我不知道的实用功能！例如，在处理这些tcpdump示例时我了解到，使用<code>tcpdump -w out.pcap</code>将数据包保存到文件时，加上<code>-v</code>参数来打印已捕获数据包数量的实时摘要非常有用。这非常有用，我不知道，而且我认为我自己可能永远不会注意到它。</li>
</ul>
<p>说实话，我总觉得文档会很难读，所以我通常会跳过它，转而去读一篇博客文章、Stack Overflow评论或者问朋友，所以现在这个位置对我来说有点奇怪。但现在我感到乐观，也许文档不一定非得糟糕？也许它可以像阅读一篇非常棒的博客文章一样好，同时又确保了正确性？我最近一直在使用Django的文档，它真的很好！我们拭目以待。</p>
<h3 id="on-avoiding-writing-the-man-page-language">关于避免编写手册页语言</h3>
<p><code>tcpdump</code>项目工具的手册页<a href="https://raw.githubusercontent.com/the-tcpdump-group/tcpdump/refs/heads/master/tcpdump.1.in" rel="noopener noreferrer">是用roff语言编写的</a>，这有点难用，我真的不想学它。</p>
<p>我的解决方法是编写一个<a href="https://gist.github.com/jvns/a31036bf70f0675811b1b2a86b122aeb" rel="noopener noreferrer">非常基本的markdown-to-roff脚本</a>来将Markdown转换为roff，遵循手册页已有的类似约定。也许我可以直接用pandoc，但它生成的输出看起来差别很大，所以我觉得写自己的脚本可能更好。谁知道呢。</p>
<p>能够直接使用现有Markdown库来解析Markdown AST，然后实现我自己的代码生成方法来以在这种情境下似乎合理的方式格式化内容，我觉得这很酷。</p>
<h3 id="man-pages-are-complicated">手册页很复杂</h3>
<p>受了解<a href="https://mandoc.bsd.lv/" rel="noopener noreferrer">mandoc</a>项目（BSD系统及一些Linux系统，我想Mac OS也用它来格式化手册页）的启发，我深入研究了<code>roff</code>的历史、它自70年代以来的发展以及现在是谁在维护它。不过今天我就不多说了，也许改天再说。</p>
<p>总的来说，BSD和Linux在文档工作方式上似乎存在技术和文化上的分歧，我仍然没有真正理解，但我一直对BSD世界正在发生的事情感到好奇。</p>
<p><a href="https://comments.jvns.ca/post/116206906990442943" rel="noopener noreferrer">评论区在此</a>。</p><p><em>由 mimo-v2.5 模型翻译，花费 2717 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2026/03/10/examples-for-the-tcpdump-and-dig-man-pages/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2026/03/10/examples-for-the-tcpdump-and-dig-man-pages/</guid>
      <pubDate>Tue, 10 Mar 2026 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>关于改进手册页的笔记</title>
      <description>[AI 摘要] 本文探讨了改进命令行工具手册页的各种方法，包括添加选项摘要、分类组织、速查表和示例，以提升可用性。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文探讨了改进命令行工具手册页的各种方法，包括添加选项摘要、分类组织、速查表和示例，以提升可用性。</div><p>大家好！去年花了些时间研究 Git 的手册页后，我开始更深入地思考什么才是好的手册页。</p>
<p>我曾为许多工具（tcpdump、git、dig 等）编写速查表，这些工具的手册页是其主要文档。因为我经常发现手册页难以快速找到所需信息。</p>
<p>最近我在想——手册页本身能否包含出色的速查表？什么能让手册页更易用？我的思考还处于早期阶段，但想先记录下一些快速笔记。</p>
<p>我在 Mastodon 上询问了大家最喜欢的手册页，以下是我从那些手册页中看到的一些有趣示例。</p>
<h3 id="an-options-summary">选项摘要</h3>
<p>如果你读过很多手册页，可能在<code>SYNOPSIS</code>部分见过类似这样的内容：当你列出几乎所有字母时，就显得很难读</p>
<pre><code>ls [-@ABCFGHILOPRSTUWabcdefghiklmnopqrstuvwxy1%,]

grep [-abcdDEFGHhIiJLlMmnOopqRSsUVvwXxZz]
</code></pre>
<p><a href="https://download.samba.org/pub/rsync/rsync.1" rel="noopener noreferrer">rsync 手册页</a>有一个我从未见过的解决方案：它将 SYNOPSIS 保持得非常简洁，像这样：</p>
<pre><code> Local:
     rsync [OPTION...] SRC... [DEST]
</code></pre>
<p>然后有一个"选项摘要"部分，每个选项用一行总结，如下：</p>
<pre><code>--verbose, -v            increase verbosity
--info=FLAGS             fine-grained informational verbosity
--debug=FLAGS            fine-grained debug verbosity
--stderr=e|a|c           change stderr output mode (default: errors)
--quiet, -q              suppress non-error messages
--no-motd                suppress daemon-mode MOTD
</code></pre>
<p>之后还有通常的 OPTIONS 部分，对每个选项进行完整描述。</p>
<h3 id="an-options-section-organized-by-category">按类别组织的选项部分</h3>
<p><a href="https://man7.org/linux/man-pages/man1/strace.1.html" rel="noopener noreferrer">strace 手册页</a>按类别（如"通用"、"启动"、"跟踪"、"过滤"和"输出格式"）组织其选项，而不是按字母顺序。</p>
<p>作为实验，我尝试将<code>grep</code>手册页改造成一个按类别分组的"选项摘要"部分，你可以<a href="https://gist.github.com/jvns/9f5966633875a4758e0d947a5b4dbdcf" rel="noopener noreferrer">在这里查看结果</a>。我不确定自己对结果的看法，但这是一个有趣的练习。写的时候我在想，我总记不住<code>grep</code>的<code>-l</code>选项名称。在手册页中找到它似乎要花很长时间，我一直在思考什么样的结构能让我更容易找到。也许是类别划分？</p>
<h3 id="a-cheat-sheet">速查表</h3>
<p>有几个人向我推荐了 Perl 手册页系列（<code>perlfunc</code>、<code>perlre</code>等），我注意到的一件事是<a href="https://linux.die.net/man/1/perlcheat" rel="noopener noreferrer">man perlcheat</a>，它有像这样的速查表部分：</p>
<pre><code> SYNTAX
 foreach (LIST) { }     for (a;b;c) { }
 while   (e) { }        until (e)   { }
 if      (e) { } elsif (e) { } else { }
 unless  (e) { } elsif (e) { } else { }
 given   (e) { when (e) {} default {} }
</code></pre>
<p>我觉得这很酷，这让我想知道是否有其他方法可以在手册页中编写简洁的80字符宽 ASCII 速查表。</p>
<h3 id="examples-are-very-popular">示例非常受欢迎</h3>
<p>一个常见的评论是"我喜欢任何有示例的手册页"。有人提到了 OpenBSD 手册页，<a href="https://man.openbsd.org/tail" rel="noopener noreferrer">openbsd tail</a>手册页中有我最后使用 tail 的两种方式的示例。</p>
<p>我最常在手册页末尾看到 EXAMPLES 部分，但有些手册页（比如前面的<a href="https://download.samba.org/pub/rsync/rsync.1" rel="noopener noreferrer">rsync 手册页</a>）以示例开头。在处理<a href="https://git-scm.com/docs/git-add" rel="noopener noreferrer">git-add</a>和<a href="https://git-scm.com/docs/git-rebase" rel="noopener noreferrer">git rebase</a>手册页时，我在开头放了一个简短示例。</p>
<h3 id="a-table-of-contents-and-links-between-sections">目录和部分之间的链接</h3>
<p>这不是手册页本身的特性，但终端中的一个问题是很难知道手册页包含哪些部分。</p>
<p>在处理 Git 手册页时，Marie 和我做的一件事是在 Git 站点托管的 HTML 版本手册页侧边栏中<a href="https://git-scm.com/docs/git-rebase" rel="noopener noreferrer">添加了目录</a>。</p>
<p>我也想在某个时候为 Git 手册页的 HTML 版本添加更多超链接，这样你就可以点击"不兼容选项"直接跳转到该部分。在 Git 项目中添加这样的链接非常容易，因为 Git 的手册页是用 AsciiDoc 生成的。</p>
<p>我认为添加目录和内部超链接是一种不错的折中方案，可以改善手册页格式（至少在 HTML 版本中），而无需维护完全不同的文档形式。不过，要实现这一点，你需要像 Git 的 AsciiDoc 系统那样的工具链。</p>
<p>如果有某种通用系统能轻松查找手册页中的特定选项（"-a 是做什么的？"）就太好了。我所知的最佳技巧是使用手册页分页器搜索类似<code>^ *-a</code>的内容，但我总是忘记这样做，而是会翻阅手册页中每个<code>-a</code>的实例，直到找到所需内容。</p>
<h3 id="examples-for-every-option">每个选项的示例</h3>
<p><a href="https://curl.se/docs/manpage.html" rel="noopener noreferrer">curl 手册页</a>为每个选项提供了示例，HTML 版本中还有目录，可以更方便地跳转到感兴趣的选项。</p>
<p>例如，<code>--cert</code>的示例很容易让你看到可能还需要传递<code>--key</code>选项，像这样：</p>
<pre><code>  curl --cert certfile --key keyfile https://example.com
</code></pre>
<p>他们的实现方式是每个选项有一个文件，文件中包含"示例"字段。</p>
<h3 id="formatting-data-in-a-table">在表格中格式化数据</h3>
<p>很多人说<a href="https://www.man7.org/linux/man-pages/man7/ascii.7.html" rel="noopener noreferrer">man ascii</a>是他们最喜欢的手册页，它看起来像这样：</p>
<pre><code> Oct   Dec   Hex   Char                     
 ───────────────────────────────────────────
 000   0     00    NUL '\0' (null character)
 001   1     01    SOH (start of heading)   
 002   2     02    STX (start of text)      
 003   3     03    ETX (end of text)        
 004   4     04    EOT (end of transmission)
 005   5     05    ENQ (enquiry)            
 006   6     06    ACK (acknowledge)        
 007   7     07    BEL '\a' (bell)          
 010   8     08    BS  '\b' (backspace)     
 011   9     09    HT  '\t' (horizontal tab)
 012   10    0A    LF  '\n' (new line)      
</code></pre>
<p>显然<code>man ascii</code>是一个不寻常的手册页，但我认为这个手册页酷的地方（除了拥有 ASCII 参考总是很有用之外）是表格格式使扫描信息非常容易。这让我想知道是否有更多机会在手册页中以"表格"形式显示信息，使其更易于浏览。</p>
<h3 id="the-gnu-approach">GNU 的方法</h3>
<p>当我谈论手册页时，经常提到 GNU coreutils 手册页（例如<a href="https://man7.org/linux/man-pages/man1/tail.1.html" rel="noopener noreferrer">man tail</a>）没有示例，而 OpenBSD 手册页<a href="https://man.openbsd.org/tail" rel="noopener noreferrer">有示例</a>。</p>
<p>我不打算过多讨论这个，因为它似乎是一个相当政治性的话题，我肯定无法在这里公正地处理，但以下是我认为真实的一些事情：</p>
<ul>
<li>GNU 项目更倾向于在"info"手册而非手册页中维护文档。<a href="https://www.gnu.org/software/coreutils/manual/coreutils.html" rel="noopener noreferrer">此页面</a>表示"手册页不再维护"。</li>
<li>有三种方式阅读"info"手册：HTML 版本、在 Emacs 中使用，或使用独立的<code>info</code>工具。我听说一些 Emacs 用户喜欢 Emacs 的 info 浏览器。我想我从未与任何使用独立<code>info</code>工具的人交谈过。</li>
<li><a href="https://www.gnu.org/software/coreutils/manual/html_node/tail-invocation.html" rel="noopener noreferrer">tail 的 info 手册条目</a>链接在手册页底部，并且确实有示例</li>
<li>FSF 曾<a href="https://www.fsf.org/gnu-press" rel="noopener noreferrer">销售 GNU 软件手册的印刷书籍</a>（也许他们现在有时还<a href="https://shop.fsf.org/" rel="noopener noreferrer">在卖</a>？）</li>
</ul>
<p>达到一定复杂度后，手册页会变得非常难以导航：虽然我从未使用过 coreutils 的 info 手册，也可能不会使用，但我几乎肯定会更喜欢使用<a href="https://www.gnu.org/software/bash/manual/bash.html" rel="noopener noreferrer">GNU Bash 参考手册</a>或<a href="https://sourceware.org/glibc/manual/latest/html_mono/libc.html" rel="noopener noreferrer">GNU C 库参考手册</a>的 HTML 文档，而不是通过手册页。</p>
<h3 id="a-fere-more-man-page-adjacent-things">一些与手册页相关的工具</h3>
<p>以下是我认为有趣的一些工具：</p>
<ul>
<li><a href="https://fishshell.com/" rel="noopener noreferrer">fish shell</a>附带一个<a href="https://github.com/fish-shell/fish-shell/blob/master/share/tools/create_manpage_completions.py" rel="noopener noreferrer">Python 脚本</a>，可以从手册页自动生成 Tab 补全</li>
<li><a href="https://tldr.sh" rel="noopener noreferrer">tldr.sh</a>是一个社区维护的示例数据库，例如你可以运行<code>tldr grep</code>。很多人告诉我他们觉得它很有用。</li>
<li><a href="https://kapeli.com/dash" rel="noopener noreferrer">Dash</a> Mac 文档浏览器中有一个不错的手册页查看器。我仍然使用终端手册页查看器，但我喜欢它包含目录，看起来像这样：</li>
</ul>
<img src="https://jvns.ca/images/dash.webp">
<h3 id="it-s-interesting-to-think-about-a-constrained-format">思考受限格式很有趣</h3>
<p>手册页是一种受限格式，思考在如此有限的格式化选项下能做什么很有趣。</p>
<p>尽管我非常喜欢写作，但我一直有从不阅读文档的坏习惯，所以对我来说思考手册页中哪些内容真正有用有点困难，我不确定这篇帖子中的大多数内容是否会改善我的体验。（除了示例，我非常喜欢示例）</p>
<p>因此，我很想听听你们认为哪些手册页设计良好以及喜欢它们什么，<a href="https://comments.jvns.ca/post/116093529820975727" rel="noopener noreferrer">评论区在这里</a>。</p><p><em>由 mimo-v2.5 模型翻译，花费 6651 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2026/02/18/man-pages/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2026/02/18/man-pages/</guid>
      <pubDate>Wed, 18 Feb 2026 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>开始使用 Django 的一些笔记</title>
      <description>[AI 摘要] 这是一篇关于作者初次学习并使用 Django Web 框架的个人体验笔记，分享了其相比 Rails 更显式、内置管理界面、ORM 及迁移系统、良好文档和内置功能等优点。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 这是一篇关于作者初次学习并使用 Django Web 框架的个人体验笔记，分享了其相比 Rails 更显式、内置管理界面、ORM 及迁移系统、良好文档和内置功能等优点。</div><p>你好！我最喜欢的事情之一，就是开始学习一门我从未尝试过但已经存在 20 多年的“老旧无聊技术”。当发现我将来可能遇到的每一个问题都已经被解决过上千次，我可以轻松搞定一切时，感觉真的很好。</p>
<p>我很久以前就觉得学习像 Rails、Django 或 Laravel 这样流行的 Web 框架会很酷，但一直没能真正付诸行动。不过几个月前，我开始学习 Django 来制作一个网站，到目前为止我很喜欢它，这里有一些简短的笔记！</p>
<h3 id="less-magic-than-rails">比 Rails 魔法少</h3>
<p>我在 2020 年花了一些时间<a href="https://jvns.ca/blog/2020/11/09/day-1--a-little-rails-/" rel="noopener noreferrer">尝试学习 Rails</a>，虽然它很酷，而且我真的很想喜欢 Rails（Ruby 社区很棒！），但我发现如果我将一个 Rails 项目闲置几个月，当我回来时，就很难记起如何进行任何操作，因为（例如）如果在你的 <code>routes.rb</code> 文件中写着 <code>resources :topics</code>，这本身并不能告诉你 <code>topics</code> 路由是在哪里配置的，你需要记住或查阅相关约定。</p>
<p>能够将一个项目搁置数月甚至数年，然后回来继续工作，对我来说非常重要（我所有的项目都是这样运作的！），而 Django 对我来说感觉更容易，因为事物更加显式。</p>
<p>在我的小型 Django 项目中，感觉我只需要关注 5 个主要文件（除了设置文件）：<code>urls.py</code>、<code>models.py</code>、<code>views.py</code>、<code>admin.py</code> 和 <code>tests.py</code>，如果我想知道其他东西（比如 HTML 模板）在哪里，它通常会从其中一个文件中被显式引用。</p>
<h3 id="a-built-in-admin">一个内置的管理后台</h3>
<p>对于这个项目，我想要有一个管理界面来手动编辑或查看数据库中的某些数据。Django 有一个非常好的内置管理界面，我只需少量代码就能对其进行自定义。</p>
<p>例如，这是我其中一个管理类的部分内容，它设置了“列表”视图中要显示的字段、用于搜索的字段以及默认的排序方式。</p>
<pre><code>@admin.register(Zine)
class ZineAdmin(admin.ModelAdmin):
    list_display = ["name", "publication_date", "free", "slug", "image_preview"]
    search_fields = ["name", "slug"]
    readonly_fields = ["image_preview"]
    ordering = ["-publication_date"]
</code></pre>
<h3 id="it-s-fun-to-have-an-orm">拥有一个 ORM 很有趣</h3>
<p>过去我的态度是“ORM？谁需要它们？我可以自己写 SQL 查询！”。然而，到目前为止我一直很享受 Django 的 ORM，我觉得 Django 用 <code>__</code> 来表示 <code>JOIN</code> 连接的方式很酷，就像这样：</p>
<pre><code>Zine.objects
    .exclude(product__order__email_hash=email_hash)
</code></pre>
<p>这个查询涉及 5 张表：<code>zines</code>、<code>zine_products</code>、<code>products</code>、<code>order_products</code> 和 <code>orders</code>。为了使其工作，我只需告诉 Django 存在一个关联“订单”和“产品”的 <code>ManyToManyField</code>，以及另一个关联“杂志”和“产品”的 <code>ManyToManyField</code>，这样它就知道如何连接 <code>zines</code>、<code>orders</code>、<code>products</code> 表了。</p>
<p>我确实<em>可以</em>手写那个查询，但编写 <code>product__order__email_hash</code> 打字量少得多，感觉更容易阅读，而且老实说，我想我要花一点时间才能弄清楚如何构造这个查询（它需要做的事情比仅仅连接这些表多一些）。</p>
<p>我完全不担心 ORM 生成的查询的性能，所以我目前对 ORM 相当兴奋，尽管我肯定最终会发现一些令人沮丧的地方。</p>
<h3 id="automatic-migrations">自动迁移！</h3>
<p>ORM 的另一个优点是迁移！</p>
<p>如果我在 <code>models.py</code> 中添加、删除或更改一个字段，Django 会自动生成一个迁移脚本，例如 <code>migrations/0006_delete_imageblob.py</code>。</p>
<p>我想我也可以编辑这些脚本，但到目前为止，我只是直接运行生成的脚本而没有做任何更改，效果一直很好。这感觉真的像魔法一样。</p>
<p>我意识到能够轻松进行迁移对我现在很重要，因为我在弄清楚数据模型如何工作时，会频繁地更改它。</p>
<h3 id="i-like-the-docs">我喜欢文档</h3>
<p>我过去有个坏习惯，就是<a href="https://www.youtube.com/watch?v=krMw1QTP2no" rel="noopener noreferrer">从不阅读文档</a>，但我真的很喜欢到目前为止读过的 Django 文档的部分内容。这并非偶然：Jacob Kaplan-Moss 有一个 <a href="https://pyvideo.org/pycon-us-2011/pycon-2011--writing-great-documentation.html" rel="noopener noreferrer">2011 年 PyCon 的演讲</a>，讲述了 Django 的文档文化。</p>
<p>例如，<a href="https://docs.djangoproject.com/en/6.0/topics/db/models/" rel="noopener noreferrer">模型入门文档</a> 列出了在使用 ORM 时你可能需要设置的最重要的常见字段。</p>
<h3 id="using-sqlite">使用 sqlite</h3>
<p>在尝试操作 Postgres 却搞不懂发生了什么之后有过糟糕体验，我决定改用 SQLite 来运行我所有的小型网站。效果好多了，我特别喜欢只需要执行 <code>VACUUM INTO</code> 然后复制生成的单个文件就能进行备份。</p>
<p>我一直在遵循<a href="https://alldjango.com/articles/definitive-guide-to-using-django-sqlite-in-production" rel="noopener noreferrer">这些指南</a>在生产环境中将 SQLite 与 Django 一起使用。</p>
<p>我认为这应该没问题，因为我预计这个网站每天最多只有几百次写入，比 <a href="https://messwithdns.net/" rel="noopener noreferrer">Mess with DNS</a> 少得多，后者有更多的写入量并且一直运行良好（尽管写入分布在三个不同的 SQLite 数据库上）。</p>
<h3 id="built-in-email-and-more">内置的电子邮件（以及更多）</h3>
<p>Django 似乎非常“开箱即用”，我喜欢这点——如果我需要 CSRF 保护、<code>Content-Security-Policy</code>，或者想发送电子邮件，这些功能都在里面！</p>
<p>例如，我想在开发模式下将 Django 发送的电子邮件保存到文件中（这样就不会向真实的人发送真实邮件），这只需要一点点配置。</p>
<p>我在 <code>settings/dev.py</code> 中放入了这个：</p>
<pre><code>EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = BASE_DIR / "emails"
</code></pre>
<p>然后在 <code>settings/production.py</code> 中这样设置生产环境的电子邮件：</p>
<pre><code>EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.whatever.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "xxxx"
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_API_KEY')
</code></pre>
<p>这让我觉得，如果我想要其他一些基本的网站功能，很可能在 Django 中已经内置了简便的实现方式。</p>
<h3 id="the-settings-file-still-feels-like-a-lot">设置文件仍然感觉很多</h3>
<p>我仍然对 <code>settings.py</code> 文件感到有点望而生畏：Django 的设置系统通过在一个文件中设置一堆全局变量来工作，我有点担心……如果我打错了其中一个变量的名字怎么办？我怎么知道？如果我输入 <code>WSGI_APPLICATOIN = "config.wsgi.application"</code> 而不是 <code>WSGI_APPLICATION</code> 怎么办？</p>
<p>我想我已经习惯了有 Python 语言服务器告诉我是否有拼写错误，所以现在当我无法依赖语言服务器支持时，感觉有点无所适从。</p>
<h3 id="that-s-all-for-now">这就是目前的所有内容了！</h3>
<p>我以前从未真正成功地为一个项目使用过真正的 Web 框架（现在我几乎所有的网站要么是单个 Go 二进制文件，要么是静态网站），所以我很想看看接下来会怎样！</p>
<p>我仍然有很多东西需要学习，我还没有真正深入了解 Django 的表单验证工具或认证系统。</p>
<p>感谢 Marco Rogers 说服我给 ORM 一个机会。</p>
<p>（我们仍在尝试 Mastodon 评论系统！<a href="https://comments.jvns.ca/post/115969229107460589" rel="noopener noreferrer">这是 Mastodon 上的评论</a>！告诉我你最喜欢的 Django 功能！）</p><p><em>由 mimo-v2.5 模型翻译，花费 4994 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2026/01/27/some-notes-on-starting-to-use-django/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2026/01/27/some-notes-on-starting-to-use-django/</guid>
      <pubDate>Tue, 27 Jan 2026 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>Git 的数据模型（及其他文档更新）</title>
      <description>[AI 摘要] 作者通过撰写新数据模型文档和根据测试读者反馈改进手册页，为 Git 文档做出了贡献。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 作者通过撰写新数据模型文档和根据测试读者反馈改进手册页，为 Git 文档做出了贡献。</div><p>大家好！去年秋天，我决定花一些时间来改进 Git 的文档。我早就想为开源文档做贡献了——通常如果我觉得某个软件的文档可以改进，我会写一篇博客文章或一份 zine 之类的。但这次我转念一想：我能不能直接去改进官方文档呢？</p>
<p>于是 <a href="https://marieflanagan.com/" rel="noopener noreferrer">Marie</a> 和我对 Git 文档做了一些改动！</p>
<h3 id="a-data-model-for-git">Git 的一个数据模型</h3>
<p>在参与文档工作一段时间后，我们注意到 Git 在其文档中频繁使用“对象”、“引用”或“索引”等术语，却没有很好地解释这些术语的含义，或者它们如何与“提交”和“分支”等其他核心概念相关联。因此，我们撰写了一份新的“数据模型”文档！</p>
<p>你可以<a href="https://github.com/git/git/blob/master/Documentation/gitdatamodel.adoc" rel="noopener noreferrer">在此处阅读该数据模型</a>（目前版本）。我估计在某个时候（下一次发布之后？），它也会出现在 <a href="https://git-scm.com" rel="noopener noreferrer">Git 网站</a>上。</p>
<p>我对此感到很兴奋，因为理解 Git 如何组织其提交和分支数据，多年来真正帮助我理解了 Git 的工作原理。我认为有一份简短（1600 词！）且准确的数据模型版本是很重要的。</p>
<p>“准确”这部分证明并非易事：我知道 Git 数据模型如何工作的基础知识，但在审查过程中，我学到了一些新的细节，并不得不做出相当多的修改（例如，暂存区如何存储合并冲突）。</p>
<h3 id="updates-to-git-push-git-pull-and-more">更新 <code>git push</code>、<code>git pull</code> 及更多内容</h3>
<p>我还致力于更新一些 Git 核心手册页的介绍部分。我很快意识到，“仅凭我的最佳判断去尝试改进”是行不通的：为什么维护者应该相信我的版本更好？</p>
<p>在讨论开源文档改动时，我经常看到一个问题：两位软件的专家用户会争论某个解释是否清晰（“我觉得 X 是个好方法！” “不，我觉得 Y 更好！”）。</p>
<p>我认为这样做效率不高（软件的专家用户往往不擅长判断某个解释对非专家是否清晰），因此我需要找到一种方法，能够以更基于证据的方式找出手册页中的问题。</p>
<h3 id="getting-test-readers-to-identify-problems">让测试读者识别问题</h3>
<p>我在 Mastodon 上邀请测试读者阅读当前版本的文档，并告诉我他们觉得困惑的地方或他们有什么问题。大约 80 位测试读者留下了评论，我学到了很多！</p>
<p>人们留下了大量极好的反馈，例如：</p>
<ul>
<li>他们不理解的术语（什么是路径规范？“引用”是什么意思？“上游”在 Git 中是否有特定含义？）</li>
<li>特定的、令人困惑的句子</li>
<li>建议添加的内容（“我经常做 X，我觉得这里应该包含进去”）</li>
<li>不一致之处（“这里暗示 X 是默认值，但别处暗示 Y 是默认值”）</li>
</ul>
<p>大多数测试读者使用 Git 至少有 5-10 年，我认为这效果很好——如果一群经常使用 Git 超过 5 年的测试读者都觉得某个句子或术语无法理解，那就很容易论证文档应该被更新得更清晰。</p>
<p>我觉得这种“让软件用户对现有文档发表评论，然后修复他们发现的问题”的模式效果非常好，我期待将来可能再次尝试。</p>
<h3 id="the-man-page-changes">手册页的改动</h3>
<p>我们最终更新了以下 4 个手册页：</p>
<ul>
<li><code>git add</code> (<a href="https://github.com/git/git/blob/2b3ae040/Documentation/git-add.adoc" rel="noopener noreferrer">改动前</a>, <a href="https://github.com/git/git/blob/e0bfec3dfc356f7d808eb5ee546a54116b794397/Documentation/git-add.adoc" rel="noopener noreferrer">改动后</a>)</li>
<li><code>git checkout</code> (<a href="https://github.com/git/git/blob/2b3ae040/Documentation/git-checkout.adoc" rel="noopener noreferrer">改动前</a>, <a href="https://github.com/git/git/blob/e0bfec3dfc356f7d808eb5ee546a54116b794397/Documentation/git-checkout.adoc" rel="noopener noreferrer">改动后</a>)</li>
<li><code>git push</code> (<a href="https://github.com/git/git/blob/2b3ae040/Documentation/git-push.adoc" rel="noopener noreferrer">改动前</a>, <a href="https://github.com/git/git/blob/e0bfec3dfc356f7d808eb5ee546a54116b794397/Documentation/git-push.adoc" rel="noopener noreferrer">改动后</a>)</li>
<li><code>git pull</code> (<a href="https://github.com/git/git/blob/2b3ae040/Documentation/git-pull.adoc" rel="noopener noreferrer">改动前</a>, <a href="https://github.com/git/git/blob/e0bfec3dfc356f7d808eb5ee546a54116b794397/Documentation/git-pull.adoc" rel="noopener noreferrer">改动后</a>)</li>
</ul>
<p>其中 <code>git push</code> 和 <code>git pull</code> 的改动对我来说是最有趣的：除了更新这些页面的介绍部分，我们最终还撰写了：</p>
<ul>
<li><a href="https://github.com/git/git/blob/e0bfec3dfc356f7d808eb5ee546a54116b794397/Documentation/urls-remotes.adoc#upstream-branches" rel="noopener noreferrer">一个描述“上游分支”这一术语含义的部分</a>（之前并未真正解释）</li>
<li><a href="https://github.com/git/git/blob/e0bfec3dfc356f7d808eb5ee546a54116b794397/Documentation/git-push.adoc#options" rel="noopener noreferrer">一个清理后的关于“推送引用规范”是什么的描述</a></li>
</ul>
<p>进行这些改动真的让我体会到维护开源文档是多么费力的一件事：要写出既清晰又正确的内容并不容易，有时我们不得不做出妥协，例如句子“<code>git push</code> 可能会失败，如果你还没有为当前分支设置上游，这取决于 <code>push.default</code> 的设置。”有点模糊，但“取决于”具体意味着什么其实非常复杂，理清这一点是一个很大的工程。</p>
<h3 id="on-the-process-for-contributing-to-git">关于为 Git 贡献代码的流程</h3>
<p>我花了一段时间才理解 Git 的开发流程。我不打算在此详细描述（那可能是另一整篇博文了！），但简要说明几点：</p>
<ul>
<li>Git 有一个 <a href="https://git-scm.com/community#discord" rel="noopener noreferrer">Discord 服务器</a>，其中有一个“我的第一次贡献”频道，提供入门帮助。我发现 Discord 上的人非常友好。</li>
<li>我使用了 <a href="https://gitgitgadget.github.io/" rel="noopener noreferrer">GitGitGadget</a> 来进行所有贡献。这意味着我可以提交一个 GitHub 拉取请求（我熟悉的工作流程），GitGitGadget 会将我的 PR 转换为 Git 开发者使用的系统（带附件补丁的电子邮件）。GitGitGadget 工作得很好，我非常感激不必学习如何通过电子邮件使用 Git 发送补丁。</li>
<li>其他情况下，我使用我的常规电子邮件客户端（Fastmail 的 Web 界面）回复邮件，并按照邮件列表规范将文本限制在每行 80 个字符内。</li>
</ul>
<p>我还发现 <a href="https://lore.kernel.org/git/" rel="noopener noreferrer">lore.kernel.org</a> 上的邮件列表存档很难浏览，所以我匆忙拼凑了<a href="https://github.com/jvns/git-list-viewer" rel="noopener noreferrer">我自己的 Git 列表查看器</a>，以便更轻松地阅读冗长的邮件列表讨论。</p>
<p>许多人帮助我了解贡献流程并审查这些改动：感谢 Emily Shaffer、Johannes Schindelin（GitGitGadget 的作者）、Patrick Steinhardt、Ben Knoble、Junio Hamano 等人。</p>
<p>（我正在实验<a href="https://comments.jvns.ca/post/115861337435768520" rel="noopener noreferrer">在 Mastodon 上使用评论功能，你可以在此查看评论</a>）</p><p><em>由 mimo-v2.5 模型翻译，花费 4795 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2026/01/08/a-data-model-for-git/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2026/01/08/a-data-model-for-git/</guid>
      <pubDate>Thu, 8 Jan 2026 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>从vim切换到Helix的笔记</title>
      <description>[AI 摘要] 本文作者分享了从 Vim 切换到 Helix 编辑器三个月的体验，包括选择原因、与 Vim 的对比以及遇到的优缺点。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文作者分享了从 Vim 切换到 Helix 编辑器三个月的体验，包括选择原因、与 Vim 的对比以及遇到的优缺点。</div><p>你好！今年夏天早些时候，我和一位朋友聊到我有多<a href="https://jvns.ca/blog/2024/09/12/reasons-i--still--love-fish/" rel="noopener noreferrer">喜欢使用 fish shell</a>，以及我有多喜欢它不需要我进行配置。他们说对 <a href="https://helix-editor.com/" rel="noopener noreferrer">Helix</a> 文本编辑器也有同样的感觉，所以我决定尝试一下。</p>
<p>我已经使用它三个月了，以下是一些笔记。</p>
<h3 id="why-helix-language-servers">为什么选择 Helix：语言服务器</h3>
<p>我想尝试 Helix 的动机在于，我一直在尝试设置一个可用的语言服务器环境（这样我就能做诸如“跳转到定义”之类的事情），而在 Vim 或 Neovim 中搭建一个感觉良好的环境实在费时费力。</p>
<p>在使用 Vim/Neovim 20 年后，我尝试过“从零开始构建自己的自定义配置”和“使用别人预先构建的配置系统”，尽管我热爱 Vim，但想到一切都能直接工作而无需在配置上花费精力，我还是感到兴奋。</p>
<p>Helix 内置了语言服务器支持，能在任何语言中轻松执行诸如“重命名此符号”之类的操作，感觉很棒。</p>
<h3 id="the-search-is-great">搜索功能很棒</h3>
<p>Helix 中我最喜欢的一点是它的搜索功能！如果我在整个代码库中搜索某个字符串，它会让我滚动浏览可能的匹配文件，并看到匹配行的完整上下文，就像这样：</p>
<img src="/images/helix-search.png">
<p>作为对比，这里是我之前一直在用的 Vim ripgrep 插件的样子：</p>
<img src="/images/vim-ripgrep.png">
<p>它没有显示该行周围的其他上下文。</p>
<h3 id="the-quick-reference-is-nice">快速参考提示很好</h3>
<p>我喜欢 Helix 的一点是，当我按下 <code>g</code> 键时，会弹出一个小帮助菜单，告诉我可以跳转到哪里。我非常欣赏这一点，因为我不常使用“跳转到定义”或“跳转到引用”功能，经常忘记相应的键盘快捷键。</p>
<img src="/images/goto.png" width="300px">
<h3 id="some-vim-helix-translations">一些 Vim 到 Helix 的键位映射</h3>
<ul>
<li>Helix 没有像 <code>ma</code>、<code>'a</code> 这样的标记功能，我一直在使用 <code>Ctrl+O</code> 和 <code>Ctrl+I</code> 来返回（或前进到）上一个光标位置。</li>
<li>我认为 Helix 也有宏功能，但在所有我以前会使用宏的情况下，我一直在使用多光标。我非常喜欢多光标，胜过总是编写宏。如果我想在文档中批量更改某些内容，我的工作流程是：按 <code>%</code>（全选），然后按 <code>s</code>（使用正则表达式）选择我想要更改的内容，然后就可以直接进行所需的编辑。</li>
<li>Helix 没有 Neovim 风格的标签页，取而代之的是一个不错的缓冲区切换器（<code>&lt;space&gt;b</code>），我可以用它来切换到我想要的缓冲区。这里有一个<a href="https://github.com/helix-editor/helix/pull/7109" rel="noopener noreferrer">实现 Neovim 风格标签页的拉取请求</a>。还有一个设置 <code>bufferline="multiple"</code>，配合 <code>gp</code>、<code>gn</code> 进行上/下一个“标签页”，以及用 <code>:bc</code> 关闭“标签页”，可以起到类似标签页的作用。</li>
</ul>
<h3 id="some-helix-annoyances">一些令我烦恼的 Helix 问题</h3>
<p>以下是我目前在使用 Helix 时遇到的所有令我烦恼的地方。</p>
<ul>
<li>我非常喜欢 Helix 的 <code>:reflow</code> 功能，但它处理列表的方式不如 Vim 的 <code>gq</code> 重排文本那样好。（<a href="https://github.com/helix-editor/helix/issues/3332" rel="noopener noreferrer">GitHub issue</a>）</li>
<li>如果我在创建 Markdown 列表，在列表项末尾按“回车”不会延续列表。对于项目符号列表，有一个<a href="https://github.com/helix-editor/helix/wiki/Recipes#continue-markdown-lists--quotes" rel="noopener noreferrer">部分解决方案</a>，但我不知道编号列表的解决方法。</li>
<p>目前还没有持久化撤销：在 Vim 中，我可以使用<a href="https://vimdoc.sourceforge.net/htmldoc/options.html#'undofile'" rel="noopener noreferrer">撤销文件</a>，这样即使退出后也能撤销更改。Helix 还没有这个功能。（<a href="https://github.com/helix-editor/helix/pull/9154" rel="noopener noreferrer">GitHub PR</a>）</p>
<li>Helix 不会在磁盘上的文件更改后自动重新加载，我必须运行 <code>:reload-all</code>（<code>:ra&lt;tab&gt;</code>）来手动重新加载。这不是什么大问题。</li>
<li>有时它会崩溃，大概每周一次。我想可能与<a href="https://github.com/helix-editor/helix/issues/12582" rel="noopener noreferrer">这个问题</a>有关。</li>
</ul>
<p>崩溃信息大致如下：</p>
<pre><code>thread 'main' panicked at helix-core/src/transaction.rs:499:9:
Positions [(2959, AfterSticky), (2959, AfterSticky)] are out of range for changeset len 2945!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
</code></pre>
<p>“Markdown 列表”和文本重排的问题经常困扰我，因为我花很多时间编辑 Markdown 列表，但我仍然继续使用 Helix，所以我想它们还不至于让我特别生气。</p>
<h3 id="switching-was-easier-than-i-thought">切换比想象中容易</h3>
<p>我曾担心重新学习 20 年的 Vim 肌肉记忆会非常困难。</p>
<p>结果比预期要容易。我在一次度假时开始使用 Helix，进行一个副业的小型编码项目，一两周后，那种迷失方向的感觉就消失了。我想在 Vim 和 Helix 之间来回切换可能会比较困难，但我最近不需要使用 Vim，所以我不知道这是否对我来说会成为问题。</p>
<p>第一次尝试 Helix 时，我试图强制它使用更接近 Vim 的键位绑定，但这对我不管用。直接学习“Helix 的方式”要容易得多。</p>
<p>仍然有些事情让我困惑：例如，Vim 中的 <code>w</code> 和 Helix 中的 <code>w</code> 对“单词”的定义不同（Helix 的包含单词后的空格，Vim 的不包含）。</p>
<h3 id="using-a-terminal-based-text-editor">使用基于终端的文本编辑器</h3>
<p>多年来，我主要使用 GUI 版本的 Vim/Neovim，所以切换到在终端中实际使用编辑器需要一些调整。</p>
<p>我最终决定：</p>
<ol>
<li>每个项目都有自己独立的终端窗口，该窗口中的所有标签页（主要）共享同一个工作目录。</li>
<li>我把 Helix 标签页设为终端窗口中的第一个标签页。</li>
</ol>
<p>效果相当好，我可能实际上比我之前的工作流程更喜欢这种方式。</p>
<h3 id="my-configuration">我的配置</h3>
<p>我很欣赏我的配置非常简单，相比之下，我的 Neovim 配置有数百行。它主要只有 4 个键盘快捷键。</p>
<pre><code>theme = "solarized_light"
[editor]
# 与系统剪贴板同步
default-yank-register = "+"

[keys.normal]
# 我不喜欢默认的 "切换注释" 快捷键是 Ctrl+C
"#" = "toggle_comments"

# 我不想学习另一种方式去行首/行尾
# 所以我重新映射了 ^ 和 $
"^" = "goto_first_nonwhitespace"
"$" = "goto_line_end"

[keys.select]
"^" = "goto_first_nonwhitespace"
"$" = "goto_line_end"

[keys.normal.space]
# 我经常写文字，需要不断重排，
# 思念 Vim 的 `gq` 快捷键
l = ":reflow"
</code></pre>
<p>还有一个单独的 <code>languages.toml</code> 配置文件，我可以在其中设置一些语言偏好，例如关闭自动格式化。例如，我的 Python 配置如下：</p>
<pre><code>[[language]]
name = "python"
formatter = { command = "black", args = ["--stdin-filename", "%{buffer_name}", "-"] }
language-servers = ["pyright"]
auto-format = false
</code></pre>
<h3 id="we-ll-see-how-it-goes">我们拭目以待</h3>
<p>三个月并不算长，我有可能在某个时候决定回到 Vim。例如，我之前写过一篇关于<a href="https://jvns.ca/blog/2023/02/28/some-notes-on-using-nix/" rel="noopener noreferrer">切换到 Nix</a> 的文章，但大约 8 个月后，我又切换回了 Homebrew（不过我仍然使用 NixOS 管理一个小型服务器，并且对此仍然满意）。</p><p><em>由 mimo-v2.5 模型翻译，花费 5139 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2025/10/10/notes-on-switching-to-helix-from-vim/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2025/10/10/notes-on-switching-to-helix-from-vim/</guid>
      <pubDate>Fri, 10 Oct 2025 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>新Zine：终端的神秘规则</title>
      <description>[AI 摘要] 本文介绍了新Zine《终端的神秘规则》的发布，解释了终端内部工作原理和写作背景。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文介绍了新Zine《终端的神秘规则》的发布，解释了终端内部工作原理和写作背景。</div><p>你好！经过数月深入撰写关于终端的博客文章，我于周二发布了一本新Zine，名为《终端的神秘规则》！</p>
<p>你可以在这里以12美元购买：<a href="https://wizardzines.com/zines/terminal" rel="noopener noreferrer">https://wizardzines.com/zines/terminal</a>，或者在此获取<a href="https://wizardzines.com/zines/all-the-zines/" rel="noopener noreferrer">我所有Zine的15本套装</a>。</p>
<p>这是封面：</p>
<div>
<a href="https://wizardzines.com/zines/terminal" rel="noopener noreferrer">
  <img width="600px" src="https://jvns.ca/images/terminal-cover-small.jpg">
  </a>
</div>
<h3 id="the-table-of-contents">目录</h3>
<p>以下是目录：</p>
<a href="https://wizardzines.com/zines/terminal/toc.png" rel="noopener noreferrer">
  <img width="600px" src="https://jvns.ca/images/terminal-toc-small.png">
</a>
<h3 id="why-the-terminal">为什么是终端？</h3>
<p>我已经使用终端20年了，但尽管我对终端非常自信，我总是对它有一点不安的感觉。通常事情运行顺利，但有时出问题时，感觉调查起来不可能，或者至少像会引发一大堆问题。</p>
<p>所以我开始尝试列出我在终端中遇到的一些奇怪问题，并意识到终端有许多微小的不一致性，比如：</p>
<ul>
<li>有时你可以使用方向键移动，但有时按下方向键只会打印<code>^[[D</code></li>
<li>有时你可以用鼠标选择文本，但有时不能</li>
<li>有时运行命令时会被保存到历史记录中，有时则不会</li>
<li>有些shell允许你用上箭头查看之前的命令，有些则不允许</li>
</ul>
<p>如果你每天使用终端10或20年，即使你不完全理解<em>为什么</em>这些事情会发生，你可能也会对它们建立直觉。</p>
<p>但对它们有直觉并不等同于理解它们为什么发生。在写这本Zine时，我实际上做了很多工作来弄清楚终端中<em>正在发生什么</em>，以便能够讨论如何推理它。</p>
<h3 id="the-rules-aren-t-written-down-anywhere">规则没有被写在任何地方</h3>
<p>事实证明，终端工作方式的“规则”（如何编辑你输入的命令？如何退出程序？如何修复你的颜色？）极难完全理解，因为“终端”实际上由许多不同的软件部分组成（你的终端模拟器、操作系统、shell、核心工具如<code>grep</code>，以及你安装的每个其他随机终端程序），这些部分由不同的人编写，对事物应如何工作有不同的想法。</p>
<p>所以我想写点东西来解释：</p>
<ul>
<li>终端的4个部分（你的shell、终端模拟器、程序和TTY驱动程序）如何协同工作</li>
<li>一些核心约定，关于你可以期望终端中的事情如何工作</li>
<li>许多使用终端程序的技巧和窍门</li>
</ul>
<h3 id="this-zine-explains-the-most-useful-parts-of-terminal-internals">这本Zine解释了终端内部最有用的部分</h3>
<p>终端内部是一团糟。很多部分就是这样，因为有人在80年代做了决定，现在无法更改，老实说，我认为学习终端内部的所有内容并不值得。</p>
<p>但有些部分并不太难理解，并且确实可以让你在终端中的体验更好，比如：</p>
<ul>
<li>如果你理解<strong>你的shell</strong>负责什么，你可以配置你的shell（或使用不同的shell！）来更轻松地访问历史记录、获得出色的选项卡补全等</li>
<li>如果你理解<strong>转义码</strong>，当<code>cat</code>一个二进制文件到标准输出搞乱你的终端时，你就不会那么害怕，你可以直接输入<code>reset</code>然后继续</li>
<li>如果你理解<strong>颜色</strong>如何工作，你可以摆脱终端中糟糕的颜色对比度，从而真正阅读文本</li>
</ul>
<h3 id="i-learned-a-surprising-amount-writing-this-zine">我写这本Zine时学到了惊人的东西</h3>
<p>当我写<a href="https://wizardzines.com/zines/git" rel="noopener noreferrer">《Git如何工作》</a>时，我以为我了解Git的工作原理，我是对的。但终端是不同的。尽管我对终端完全自信，并且每天使用它20年，我对终端的工作原理有很多误解，而且（除非你是<code>tmux</code>之类的作者）我认为你很有可能也有。</p>
<p>一些我学到的实际上对我有用的事情：</p>
<ul>
<li>我更好地理解了终端的结构，所以当奇怪的终端事情发生在我身上时，我更有信心调试（我甚至能够对fish提出一个小<a href="https://github.com/fish-shell/fish-shell/issues/10834" rel="noopener noreferrer">改进</a>！）。准确找出是哪个软件导致终端中发生奇怪的事情仍然不容易，但我现在好多了。</li>
<li>你可以编写一个shell脚本<a href="https://jvns.ca/til/vim-osc52/" rel="noopener noreferrer">通过SSH复制到你的剪贴板</a></li>
<li><code>reset</code>在底层如何工作（它相当于执行<code>stty sane; sleep 1; tput reset</code>）——基本上我了解到我不需要担心记住<code>stty sane</code>或<code>tput reset</code>，我只需要运行<code>reset</code>即可</li>
<li>如何查看程序打印出的不可见转义码（运行<code>unbuffer program &gt; out; less out</code>）</li>
<li>为什么我Mac上的内置REPL如<code>sqlite3</code>如此恼人（它们使用<code>libedit</code>而不是<code>readline</code>）</li>
</ul>
<h3 id="blog-posts-i-wrote-along-the-way">我在此过程中写的博客文章</h3>
<p>像往常一样，我写了很多关于各种副任务的博客文章：</p>
<ul>
<li><a href="https://jvns.ca/blog/2025/02/13/how-to-add-a-directory-to-your-path/" rel="noopener noreferrer">如何将目录添加到你的PATH</a></li>
<li><a href="https://jvns.ca/blog/2024/11/26/terminal-rules/" rel="noopener noreferrer">终端问题遵循的“规则”</a></li>
<li><a href="https://jvns.ca/blog/2024/11/29/why-pipes-get-stuck-buffering/" rel="noopener noreferrer">为什么管道有时会“卡住”：缓冲</a></li>
<li><a href="https://jvns.ca/blog/2025/02/05/some-terminal-frustrations/" rel="noopener noreferrer">一些终端挫折</a></li>
<li><a href="https://jvns.ca/blog/2024/10/31/ascii-control-characters/" rel="noopener noreferrer">我终端中的ASCII控制字符</a>关于“Ctrl+A、Ctrl+B、Ctrl+C等是什么情况？”</li>
<li><a href="https://jvns.ca/blog/2024/07/08/readline/" rel="noopener noreferrer">在终端中输入文本很复杂</a></li>
<li><a href="https://jvns.ca/blog/2025/01/11/getting-a-modern-terminal-setup/" rel="noopener noreferrer">获取“现代”终端设置涉及什么？</a></li>
<li><a href="https://jvns.ca/blog/2024/07/03/reasons-to-use-job-control/" rel="noopener noreferrer">使用shell作业控制的原因</a></li>
<li><a href="https://jvns.ca/blog/2025/03/07/escape-code-standards/" rel="noopener noreferrer">ANSI转义码标准</a>，这实际上是我试图弄清楚我认为<code>terminfo</code>数据库在今天是否服务良好</li>
</ul>
<h3 id="people-who-helped-with-this-zine">帮助这本Zine的人</h3>
<p>很久以前，我大多自己写Zine，但随着每个项目，我得到越来越多的帮助。我从九月到六月的每个工作日都与<a href="https://marieflanagan.com" rel="noopener noreferrer">Marie Claire LeBlanc Flanagan</a>会面，一起做这本Zine。</p>
<p>封面由Vladimir Kašiković设计，Lesley Trites做了文字编辑，Simon Tatham（<a href="https://www.chiark.greenend.org.uk/~sgtatham/putty/" rel="noopener noreferrer">PuTTY</a>的作者）做了技术审查，我们的运营经理Lee做了转录以及其他无数事情，<a href="https://github.com/doy" rel="noopener noreferrer">Jesse Luehrs</a>（他是我认识的极少数真正理解终端神秘内部工作原理的人之一）与我进行了许多极其有用的对话，关于终端中发生的事情。</p>
<h3 id="get-the-zine">获取这本Zine</h3>
<p>以下是再次获取这本Zine的链接：</p>
<ul>
<li>获取<a href="https://wizardzines.com/zines/terminal" rel="noopener noreferrer">《终端的神秘规则》</a></li>
<li>获取<a href="https://wizardzines.com/zines/all-the-zines/" rel="noopener noreferrer">我所有Zine的15本套装</a>。</li>
</ul>
<p>像往常一样，你可以获取PDF版本在家打印，或者获取打印版本寄送到你家。唯一的注意事项是打印订单将在<strong>八月</strong>发货——我需要等待订单进来，以便在发送给印刷商之前了解应该印刷多少份。</p><p><em>由 mimo-v2.5 模型翻译，花费 5759 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2025/06/24/new-zine--the-secret-rules-of-the-terminal/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2025/06/24/new-zine--the-secret-rules-of-the-terminal/</guid>
      <pubDate>Thu, 26 Jun 2025 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>使用 `make` 编译 C 程序（面向非 C 程序员）</title>
      <description>[AI 摘要] 这篇文章介绍非 C 程序员如何使用 `make` 工具来编译 C 程序，并提供了实用步骤和技巧。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 这篇文章介绍非 C 程序员如何使用 `make` 工具来编译 C 程序，并提供了实用步骤和技巧。</div><p>我从来不是一名 C 程序员，但偶尔需要从源代码编译 C/C++ 程序。这对我来说一直有点困难：很长一段时间，我的方法基本上是“安装依赖项，运行 <code>make</code>，如果不行，就试着找到别人编译好的二进制文件，或者放弃”。</p>
<p>“希望别人已经编译好”在我使用 Linux 时效果不错，但自从两年前我开始使用 Mac 后，我遇到更多必须自己编译程序的情况。</p>
<p>所以，让我们来谈谈编译 C 程序可能需要做什么！我将使用几个我编译过的 C 程序示例，并讨论一些可能出错的地方。以下是三个我们将要讨论编译的程序：</p>
<ul>
<li><a href="https://mj.ucw.cz/sw/paperjam/" rel="noopener noreferrer">paperjam</a></li>
<li><a href="https://www.sqlite.org/download.html" rel="noopener noreferrer">sqlite</a></li>
<li><a href="https://git.causal.agency/src/tree/bin/qf.c" rel="noopener noreferrer">qf</a>（一个分页器，可以使用 <code>rg -n THING | qf</code> 从搜索结果快速打开文件）</li>
</ul>
<h3 id="step-1-install-a-c-compiler">步骤 1：安装 C 编译器</h3>
<p>这很简单：在 Ubuntu 系统上，如果我没有 C 编译器，我会用以下命令安装一个：</p>
<pre><code>sudo apt-get install build-essential
</code></pre>
<p>这会安装 <code>gcc</code>、<code>g++</code> 和 <code>make</code>。在 Mac 上的情况更复杂，但大致是“安装 Xcode 命令行工具”。</p>
<h3 id="step-2-install-the-program-s-dependencies">步骤 2：安装程序的依赖项</h3>
<p>与一些较新的编程语言不同，C 没有依赖管理器。因此，如果程序有任何依赖项，你需要自己寻找它们。幸运的是，正因为如此，C 程序员通常保持依赖项非常 minimal，并且依赖项通常可以在你使用的任何包管理器中找到。</p>
<p>几乎总有一个部分在 README 中解释如何获取依赖项，例如在 <a href="https://mj.ucw.cz/sw/paperjam/" rel="noopener noreferrer">paperjam</a> 的 README 中，它说：</p>
<blockquote>
<p>要编译 PaperJam，你需要 libqpdf 和 libpaper 库的头文件（通常作为 libqpdf-dev 和 libpaper-dev 包提供）。</p>
</blockquote>
<blockquote>
<p>你可能需要 <code>a2x</code>（来自 <a href="http://www.methods.co.nz/asciidoc/a2x.1.html" rel="noopener noreferrer">AsciiDoc</a>）来构建手册页。</p>
</blockquote>
<p>所以在基于 Debian 的系统上，你可以这样安装依赖项。</p>
<pre><code>sudo apt install -y libqpdf-dev libpaper-dev
</code></pre>
<p>如果 README 给出了包的名称（如 <code>libqpdf-dev</code>），我基本上总是假设它指的是“在基于 Debian 的 Linux 发行版中”：如果你在 Mac 上，<code>brew install libqpdf-dev</code> 将不起作用。我仍然没有完全掌握在 Mac 上开发的技巧，所以我还没有太多提示。我想在这种情况下，如果你使用 Homebrew，可能是 <code>brew install qpdf</code>。</p>
<h3 id="step-3-run-configure-if-needed">步骤 3：运行 <code>./configure</code>（如果需要）</h3>
<p>一些 C 程序附带一个 <code>Makefile</code>，而另一些则附带一个名为 <code>./configure</code> 的脚本。例如，如果你下载 <a href="https://www.sqlite.org/download.html" rel="noopener noreferrer">sqlite 的源代码</a>，它里面有一个 <code>./configure</code> 脚本，而不是 Makefile。</p>
<p>我对这个 <code>./configure</code> 脚本的理解是：</p>
<ol>
<li>你运行它，它会输出大量难以理解的信息，然后它要么生成一个 <code>Makefile</code>，要么因为缺少某些依赖项而失败</li>
<li><code>./configure</code> 脚本是 <a href="https://www.gnu.org/software/automake/manual/html_node/Autotools-Introduction.html" rel="noopener noreferrer">autotools</a> 系统的一部分，我除了“运行它来生成 <code>Makefile</code>”之外，从未需要学习其他知识。</li>
</ol>
<p>我认为可能有一些选项可以传递给 <code>./configure</code> 脚本以生成不同的 <code>Makefile</code>，但我从未这样做过。</p>
<h3 id="step-4-run-make">步骤 4：运行 <code>make</code></h3>
<p>下一步是运行 <code>make</code> 来尝试构建程序。关于 <code>make</code> 的一些说明：</p>
<ul>
<li>有时你可以运行 <code>make -j8</code> 来并行化构建，使其更快</li>
<li>在编译程序时，它通常会输出大量编译器警告。我总是忽略它们。我没写这个软件！编译器警告不是我的问题。</li>
</ul>
<h3 id="compiler-errors-are-often-dependency-problems">编译器错误通常是依赖项问题</h3>
<p>这是我在 Mac 上编译 <code>paperjam</code> 时遇到的一个错误：</p>
<pre><code>/opt/homebrew/Cellar/qpdf/12.0.0/include/qpdf/InputSource.hh:85:19: error: function definition does not declare parameters
   85 |     qpdf_offset_t last_offset{0};
      |                   ^
</code></pre>
<p>多年来，我了解到最好不要太过度思考这类问题：如果它提到 <code>qpdf</code>，很可能只是意味着我在包含 <code>qpdf</code> 依赖项的方式上做错了什么。</p>
<p>现在，让我们谈谈一些正确包含 <code>qpdf</code> 依赖项的方法。</p>
<h3 id="the-world-s-shortest-introduction-to-the-compiler-and-linker">世界上最短的编译器和链接器介绍</h3>
<p>在讨论如何修复依赖项问题之前：构建 C 程序分为两个步骤：</p>
<ol>
<li><strong>编译</strong>代码为<strong>目标文件</strong>（使用 <code>gcc</code> 或 <code>clang</code>）</li>
<li>将这些目标文件<strong>链接</strong>成最终的二进制文件（使用 <code>ld</code>）</li>
</ol>
<p>构建 C 程序时知道这一点很重要，因为有时你需要向编译器和链接器传递正确的标志，以告诉它们在哪里找到你正在编译的程序的依赖项。</p>
<h3 id="make-uses-environment-variables-to-configure-the-compiler-and-linker"><code>make</code> 使用环境变量配置编译器和链接器</h3>
<p>如果我在 Mac 上运行 <code>make</code> 来安装 <code>paperjam</code>，我会得到这个错误：</p>
<pre><code>c++ -o paperjam paperjam.o pdf-tools.o parse.o cmds.o pdf.o -lqpdf -lpaper
ld: library 'qpdf' not found
</code></pre>
<p>这不是因为 <code>qpdf</code> 没有安装在我的系统上（实际上已经安装了！）。但是编译器和链接器不知道如何<em>找到</em> <code>qpdf</code> 库。要修复这个问题，我们需要：</p>
<ul>
<li>向编译器传递 <code>"-I/opt/homebrew/include"</code>（告诉它在哪里找到头文件）</li>
<li>向链接器传递 <code>"-L/opt/homebrew/lib -liconv"</code>（告诉它在哪里找到库文件，并链接 <code>iconv</code>）</li>
</ul>
<p>我们可以让 <code>make</code> 通过环境变量向编译器和链接器传递这些额外参数！要了解这是如何工作的：在 <code>paperjam</code> 的 Makefile 中，你可以看到许多环境变量，比如这里的 <code>LDLIBS</code>：</p>
<pre><code>paperjam: $(OBJS)
	$(LD) -o $@ $^ $(LDLIBS)
</code></pre>
<p>你放入 <code>LDLIBS</code> 环境变量的所有内容都会作为命令行参数传递给链接器（<code>ld</code>）。</p>
<h3 id="secret-environment-variable-cppflags">秘密环境变量：<code>CPPFLAGS</code></h3>
<p><code>Makefiles</code> 有时定义自己的环境变量并传递给编译器/链接器，但 <code>make</code> 还有许多“隐式”环境变量，它会自动传递给 C 编译器和链接器。这里有<a href="https://www.gnu.org/software/make/manual/html_node/Implicit-Variables.html#index-CFLAGS0" rel="noopener noreferrer">完整的隐式环境变量列表</a>，但其中之一是 <code>CPPFLAGS</code>，它会自动传递给 C 编译器。</p>
<p>（严格来说，使用 <code>CXXFLAGS</code> 可能更正常，但这个特定的 <code>Makefile</code> 硬编码了 <code>CXXFLAGS</code>，所以设置 <code>CPPFLAGS</code> 是我找到的唯一方法，无需编辑 <code>Makefile</code>）</p>
<small>
顺便说一句：我花了很长时间才意识到 `make` 与 C/C++ 的紧密关系——我曾认为 `make` 只是一个通用的构建系统（当然你可以用它来构建任何东西！），但它有许多针对构建 C/C++ 程序的便利功能，而这些功能在构建其他类型的程序时没有。
</small>
<h3 id="two-ways-to-pass-environment-variables-to-make">向 <code>make</code> 传递环境变量的两种方式</h3>
<p>我通过 <a href="https://www.owlfolio.org/" rel="noopener noreferrer">@zwol</a> 了解到实际上有两种方式向 <code>make</code> 传递环境变量：</p>
<ol>
<li><code>CXXFLAGS=xyz make</code>（通常的方式）</li>
<li><code>make CXXFLAGS=xyz</code></li>
</ol>
<p>它们之间的区别是 <code>make CXXFLAGS=xyz</code> 会覆盖 <code>Makefile</code> 中设置的 <code>CXXFLAGS</code> 的值，而 <code>CXXFLAGS=xyz make</code> 不会。</p>
<p>我不确定哪种方式是标准，但我将在本文中使用第一种。</p>
<h3 id="how-to-use-cppflags-and-ldlibs-to-fix-this-compiler-error">如何使用 <code>CPPFLAGS</code> 和 <code>LDLIBS</code> 修复此编译器错误</h3>
<p>现在我们已经讨论了 <code>CPPFLAGS</code> 和 <code>LDLIBS</code> 如何传递给编译器和链接器，以下是我最终成功构建程序的命令！</p>
<pre><code>CPPFLAGS="-I/opt/homebrew/include" LDLIBS="-L/opt/homebrew/lib -liconv" make paperjam
</code></pre>
<p>这会将 <code>-I/opt/homebrew/include</code> 传递给编译器，将 <code>-L/opt/homebrew/lib -liconv</code> 传递给链接器。</p>
<p>另外，我不想假装我“神奇地”知道这些是正确的参数，找出它们涉及许多困惑的搜索，我在本文中跳过了。我会说：</p>
<ul>
<li><code>-I</code> 编译器标志告诉编译器在哪里找到头文件，如 <code>/opt/homebrew/include/qpdf/QPDF.hh</code></li>
<li><code>-L</code> 链接器标志告诉链接器在哪里找到库，如 <code>/opt/homebrew/lib/libqpdf.a</code></li>
<li><code>-l</code> 链接器标志告诉链接器链接哪些库，如 <code>-liconv</code> 表示“链接 <code>iconv</code> 库”，或 <code>-lm</code> 表示“链接数学库”</li>
</ul>
<h3 id="tip-how-to-just-build-1-specific-file-make-filename">提示：如何只构建一个特定文件：<code>make $FILENAME</code></h3>
<p>昨天我发现了一个很酷的工具，叫做 <a href="https://git.causal.agency/src/tree/bin/qf.c" rel="noopener noreferrer">qf</a>，你可以用它从 <code>ripgrep</code> 的输出快速打开文件。</p>
<p><code>qf</code> 位于一个包含各种工具的大目录中，但我只想编译 <code>qf</code>。所以我只编译了 <code>qf</code>，像这样：</p>
<pre><code>make qf
</code></pre>
<p>基本上，如果你知道（或可以猜测）你正在构建的文件的输出文件名，你可以通过运行 <code>make $FILENAME</code> 来告诉 <code>make</code> 只构建那个文件。</p>
<h3 id="tip-you-don-t-need-a-makefile">提示：你不需要 Makefile</h3>
<p>我有时编写没有依赖项的 5 行 C 程序，我刚学到如果我有一个名为 <code>blah.c</code> 的文件，我可以这样编译它，无需创建 <code>Makefile</code>：</p>
<pre><code>make blah
</code></pre>
<p>它会被自动扩展为 <code>cc -o blah blah.c</code>，节省了一些打字。我不知道我是否会记住这个（我可能仍然会输入 <code>gcc -o blah blah.c</code>），但这似乎是一个有趣的技巧。</p>
<h3 id="tip-look-at-how-other-packaging-systems-built-the-same-c-program">提示：查看其他打包系统如何构建相同的 C 程序</h3>
<p>如果你在构建 C 程序时遇到困难，也许其他人也遇到过构建问题！每个 Linux 发行版都有每个构建包的构建文件，所以即使你不能直接从该发行版安装包，也许你可以从该 Linux 发行版获得如何构建包的提示。意识到这一点（感谢我的朋友 Dave）对我来说是一个巨大的顿悟时刻。</p>
<p>例如，<a href="https://github.com/NixOS/nixpkgs/blob/405624e81a9b65378328accb0a11c3e5369e651c/pkgs/by-name/pa/paperjam/package.nix#L35" rel="noopener noreferrer">nix 包中 <code>paperjam</code> 的这一行</a>说：</p>
<pre><code>  env.NIX_LDFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-liconv";
</code></pre>
<p>这基本上是说“在 Mac 上构建时传递链接器标志 <code>-liconv</code>”，所以这是一个我们可以用来构建它的线索。</p>
<p>同一个文件还说 <code>  env.NIX_CFLAGS_COMPILE = "-DPOINTERHOLDER_TRANSITION=1";</code>。我不确定这意味着什么，但当我尝试构建 <code>paperjam</code> 包时，我确实得到一个关于某个叫 <code>PointerHolder</code> 的错误，所以我猜这与“PointerHolder 过渡”有关。</p>
<h3 id="step-5-installing-the-binary">步骤 5：安装二进制文件</h3>
<p>一旦你成功编译了程序，你可能想要将其安装在某处！一些 <code>Makefile</code> 有一个 <code>install</code> 目标，允许你通过 <code>make install</code> 在系统上安装工具。我对此总是有点害怕（它会把文件放在哪里？如果我以后想卸载它们怎么办？），所以如果我编译的是一个相当简单的程序，我通常只是手动复制二进制文件来安装它，像这样：</p>
<pre><code>cp qf ~/bin
</code></pre>
<h3 id="step-6-maybe-make-your-own-package">步骤 6：也许制作你自己的包！</h3>
<p>一旦我搞清楚了如何做所有这些事情，我意识到我可以利用我新的 <code>make</code> 知识为 Homebrew 贡献一个 <code>paperjam</code> 包！然后我就可以在未来的系统上直接 <code>brew install paperjam</code>。</p>
<p>好处是即使不同打包系统的细节不同，它们从根本上都使用 C 编译器和链接器。</p>
<h3 id="it-can-be-useful-to-understand-a-little-about-c-even-if-you-re-not-a-c-programmer">即使你不是 C 程序员，了解一点 C 也很有用</h3>
<p>我认为所有这些都是一个有趣的例子，说明了解 C 程序如何工作的基础知识（比如“它们有头文件”）即使你从未计划在你的生活中编写非平凡的 C 程序，也可能是有用的。</p>
<p>自己能够编译 C/C++ 程序感觉很好，尽管我仍然对所有的编译器和链接器标志不完全自信，并且我仍然计划除了“你运行 <code>./configure</code> 来生成 <code>Makefile</code>”之外，不学习任何关于 autotools 如何工作的知识。</p>
<p>我在这篇文章中省略了两件事：</p>
<ul>
<li><code>LD_LIBRARY_PATH / DYLD_LIBRARY_PATH</code>（你使用它来告诉动态链接器在运行时在哪里找到动态链接的文件），因为我不记得最后一次遇到 <code>LD_LIBRARY_PATH</code> 问题是什么时候，而且找不到例子。</li>
<li><code>pkg-config</code>，我认为这很重要，但我还不理解。</li>
</ul><p><em>由 mimo-v2.5 模型翻译，花费 9379 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2025/06/10/how-to-compile-a-c-program/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2025/06/10/how-to-compile-a-c-program/</guid>
      <pubDate>Tue, 10 Jun 2025 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>ANSI 转义码的标准</title>
      <description>[AI 摘要] 本文探讨了 ANSI 转义码的相关标准（如 ECMA-48、xterm 序列、terminfo）及其尚未完全统一的现状，展望了终端体验未来改善的可能性。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文探讨了 ANSI 转义码的相关标准（如 ECMA-48、xterm 序列、terminfo）及其尚未完全统一的现状，展望了终端体验未来改善的可能性。</div><p>大家好！今天我想聊聊 ANSI 转义码。</p>
<p>长期以来，我对 ANSI 转义码只有一个模糊的概念（“就是在终端里把文字变红之类的东西”），但完全不清楚它们应该在哪里定义，是否存在相关标准。我对它们总有一种“神龙见首不见尾”的模糊感觉。今年在学习终端相关知识时，我了解到：</p>
<ol>
<li>ANSI 转义码对终端的可用性改进功不可没（你知道在 SSH 连接到远程机器时，有办法复制到系统剪贴板吗？？这涉及到一个名为 <a href="https://jvns.ca/til/vim-osc52/" rel="noopener noreferrer">OSC 52</a> 的转义码！）</li>
<li>它们并未完全标准化，因此并不总是能可靠地工作。而且由于它们是不可见的，排查转义码相关问题会令人极其沮丧。</li>
</ol>
<p>所以我想为自己整理一份关于现有转义码标准的清单，因为我想知道它们是否<em>注定</em>要显得不可靠且令人头疼，或者未来我们是否可以更放心地依赖它们。</p>
<ul>
<li><a href="#what-s-an-escape-code" rel="noopener noreferrer">什么是转义码？</a></li>
<li><a href="#ecma-48" rel="noopener noreferrer">ECMA-48</a></li>
<li><a href="#xterm-control-sequences" rel="noopener noreferrer">xterm 控制序列</a></li>
<li><a href="#terminfo" rel="noopener noreferrer">terminfo</a></li>
<li><a href="#should-programs-use-terminfo" rel="noopener noreferrer">程序应该使用 terminfo 吗？</a></li>
<li><a href="#is-there-a-single-common-set-of-escape-codes" rel="noopener noreferrer">是否存在“单一通用集”的转义码？</a></li>
<li><a href="#some-reasons-to-use-terminfo" rel="noopener noreferrer">使用 terminfo 的一些理由</a></li>
<li><a href="#some-more-documents-standards" rel="noopener noreferrer">更多文档/标准</a></li>
<li><a href="#why-i-think-this-is-interesting" rel="noopener noreferrer">为什么我觉得这很有趣</a></li>
</ul>
<h3 id="what-s-an-escape-code">什么是转义码？</h3>
<p>你是否曾在终端中按下左箭头键，然后看到 <code>^[[D</code>？</p>
<p>那就是一个转义码！它被称为“转义码”，是因为第一个字符是“转义”字符，通常写作 <code>ESC</code>、<code>\x1b</code>、<code>\E</code>、<code>\033</code> 或 <code>^[</code>。</p>
<p>转义码是你的终端模拟器与其中运行的程序进行各种信息（颜色、鼠标移动等）通信的方式。转义码有两种：</p>
<ol>
<li><strong>输入码</strong>：当按键或鼠标移动无法用 Unicode 表示时，你的终端模拟器会发送输入码。例如，“左箭头键”是 <code>ESC[D</code>，“Ctrl+左箭头”可能是 <code>ESC[1;5D</code>，而鼠标点击可能是类似 <code>ESC[M :3</code> 的东西。</li>
<li><strong>输出码</strong>：程序可以打印输出码来着色文本、移动光标、清屏、隐藏光标、复制文本到剪贴板、启用鼠标报告、设置窗口标题等。</li>
</ol>
<p>现在让我们来谈谈标准！</p>
<h3 id="ecma-48">ECMA-48</h3>
<p>我找到的第一个与转义码相关的标准是 <a href="https://ecma-international.org/wp-content/uploads/ECMA-48_5th_edition_june_1991.pdf" rel="noopener noreferrer">ECMA-48</a>，它最初发布于 1976 年。</p>
<p>ECMA-48 做了两件事：</p>
<ol>
<li>定义了一些转义码的通用<em>格式</em>（比如“CSI”码，即 <code>ESC[</code> 加上某些内容，以及“OSC”码，即 <code>ESC]</code> 加上某些内容）。</li>
<li>定义了一些具体的转义码，比如“将光标向左移动”是 <code>ESC[D</code>，或者“将文本变为红色”是 <code>ESC[31m</code>。在规范中，“光标左移”被称为 <code>CURSOR LEFT</code>，而用于改变颜色的则被称为 <code>SELECT GRAPHIC RENDITION</code>。</li>
</ol>
<p>这些格式是可扩展的，因此为他人在未来定义更多转义码留出了空间。如今流行的许多转义码并未在 ECMA-48 中定义：例如，终端应用程序（如 vim、htop 或 tmux）通常支持使用鼠标，但 ECMA-48 并未定义鼠标的转义码。</p>
<h3 id="xterm-control-sequences">xterm 控制序列</h3>
<p>有许多转义码未在 ECMA-48 中定义，例如：</p>
<ul>
<li>启用鼠标报告（你点击了终端的哪个位置？）</li>
<li>带括号粘贴（你是粘贴了那段文本还是手动输入的？）</li>
<li>OSC 52（终端应用程序可以使用它将文本复制到系统剪贴板）</li>
</ul>
<p>我相信（如果我错了请纠正我！）这些以及一些其他的转义码源自 xterm，记录在 <a href="https://invisible-island.net/xterm/ctlseqs/ctlseqs.html" rel="noopener noreferrer">XTerm 控制序列</a>中，并且已被其他终端模拟器广泛实现。</p>
<p>这份“xterm 支持什么”的清单严格来说并非一个标准，但 xterm 的影响力极大，因此它似乎是一份重要的文档。</p>
<h3 id="terminfo">terminfo</h3>
<p>在 80 年代（某种程度上今天也如此，但据我理解在 80 年代要严重得多），终端实际支持的转义码存在巨大的差异。</p>
<p>为了应对这种情况，出现了一个名为“terminfo”的数据库，其中包含了各种终端的转义码信息。</p>
<p>terminfo 的标准似乎是 <a href="https://publications.opengroup.org/c243-1" rel="noopener noreferrer">X/Open Curses</a>，不过需要创建账户才能查看该标准（不知为何）。它定义了数据库格式以及用于访问数据库的 C 库接口（“curses”）。</p>
<p>例如，你可以运行这段 bash 代码片段来查看你的系统所知所有不同终端的“清屏”可能对应的所有转义码：</p>
<pre><code>for term in $(toe -a | awk '{print $1}')
do
  echo $term
  infocmp -1 -T "$term" 2&gt;/dev/null | grep 'clear=' | sed 's/clear=//g;s/,//g'
done
</code></pre>
<p>在我的系统上（以及可能我使用过的每一个系统？），terminfo 数据库由 ncurses 管理。</p>
<h3 id="should-programs-use-terminfo">程序应该使用 terminfo 吗？</h3>
<p>我认为有趣的是，应用程序处理 ANSI 转义码主要有两种方法：</p>
<ol>
<li>使用 terminfo 数据库来确定使用哪些转义码，这取决于 <code>TERM</code> 环境变量的值。例如，Fish 就是这样做的。</li>
<li>识别一组在“足够多”终端模拟器中都能工作的“单一通用集”转义码，并直接硬编码它们。</li>
</ol>
<p>一些采用方法 #2（“不使用 terminfo”）的程序/库的例子包括：</p>
<ul>
<li><a href="https://github.com/mawww/kakoune/commit/c12699d2e9c2806d6ed184032078d0b84a3370bb" rel="noopener noreferrer">kakoune</a></li>
<li><a href="https://github.com/prompt-toolkit/python-prompt-toolkit/blob/165258d2f3ae594b50f16c7b50ffb06627476269/src/prompt_toolkit/input/ansi_escape_sequences.py#L5-L8" rel="noopener noreferrer">python-prompt-toolkit</a></li>
<li><a href="https://github.com/antirez/linenoise" rel="noopener noreferrer">linenoise</a></li>
<li><a href="https://github.com/rockorager/libvaxis" rel="noopener noreferrer">libvaxis</a></li>
<li><a href="https://github.com/chalk/chalk" rel="noopener noreferrer">chalk</a></li>
</ul>
<p>我好奇为什么人们可能正在远离 terminfo，然后发现了这篇非常有趣且极其详细的 <a href="https://twoot.site/@bean/113056942625234032" rel="noopener noreferrer">Fish 维护者对 terminfo 的吐槽</a>，其中认为：</p>
<blockquote>
<p>（terminfo 作者们）做了大量当时极其重要且有益的工作。我的观点是，它现在已经不再那么重要了。</p>
</blockquote>
<p>我无法充分概括它，所以我不打算总结它，但我认为它值得一读。</p>
<h3 id="is-there-a-single-common-set-of-escape-codes">是否存在“单一通用集”的转义码？</h3>
<p>我刚才谈到了使用一组适用于大多数人的“通用集”转义码的想法。但这个集合是什么？有任何共识吗？</p>
<p>我完全不知道这个问题的答案，但通过一些阅读，它似乎是一些组合：</p>
<ul>
<li>VT100 支持的代码（尽管有些在现代终端上已不相关）</li>
<li>ECMA-48 中的内容（我认为其中也有一些不再相关的东西）</li>
<li>xterm 支持的内容（不过我猜其中并非所有内容都得到了足够广泛的实现）</li>
</ul>
<p>最终可能还是“识别你认为用户最常使用的终端模拟器并在其中测试”，就像网页开发人员在决定可以使用哪些 CSS 功能时所做的那样。</p>
<p>不过我认为终端领域没有类似 <a href="https://caniuse.com/" rel="noopener noreferrer">Can I use…?</a> 或 <a href="https://web-platform-dx.github.io/web-features/" rel="noopener noreferrer">Baseline</a> 的资源。（理论上 terminfo 应该是终端的“caniuse”，但当人们发明新终端功能时，它似乎通常需要 10 年以上才能添加，这使其非常局限。）</p>
<h3 id="some-reasons-to-use-terminfo">使用 terminfo 的一些理由</h3>
<p>我也在 Mastodon 上问了为什么人们在 2025 年仍然认为 terminfo 有价值，得到了几个我认为合理的理由：</p>
<ul>
<li>有些人期望能够使用 <code>TERM</code> 环境变量来控制程序的行为（例如使用 <code>TERM=dumb</code>），而在后 terminfo 时代，没有关于这应如何工作的标准。</li>
<li>尽管终端模拟器之间的差异比 80 年代<em>少</em>了，但远非零：有图形终端、Linux 帧缓冲控制台、通过串行控制台连接服务器的情况、Emacs shell 模式，以及可能我遗漏的其他情况。</li>
<li>不存在一个关于什么是“单一通用集”转义码的统一标准，而且有时程序使用的转义码实际上并未得到足够广泛的实现。</li>
</ul>
<h3 id="terminfo-user-agent-detection">terminfo 和用户代理检测</h3>
<p>ncurses 使用 <code>TERM</code> 环境变量来决定使用哪些转义码的方式，让我想起了过去网络服务器有时如何使用浏览器用户代理来决定提供哪个版本的网站。</p>
<p>它似乎也产生了一些相似的结果——iTerm2 将自己报告为“xterm-256color”的方式，类似于 Safari 的用户代理是“Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15”。在这两种情况下，终端模拟器/浏览器最终都改变了它的用户代理，以绕过效果不佳的用户代理检测。</p>
<p>在 Web 上，我们最终决定用户代理检测不是一个好做法，转而专注于标准化，以便向所有浏览器提供相同的 HTML/CSS。但我不知道这种方法是否是终端的未来——我认为今天的终端格局比曾经的 Web 更加碎片化，而且资金也少得多。</p>
<h3 id="some-more-documents-standards">更多文档/标准</h3>
<p>以下是一些与转义码相关的其他文档和标准，顺序不分先后：</p>
<ul>
<li><a href="https://man7.org/linux/man-pages/man4/console_codes.4.html" rel="noopener noreferrer">Linux console_codes 手册页</a> 记录了 Linux 支持的转义码。</li>
<li><a href="https://vt100.net/docs/vt100-ug/chapter3.html" rel="noopener noreferrer">VT 100</a> 如何处理转义码和控制序列。</li>
<li><a href="https://sw.kovidgoyal.net/kitty/keyboard-protocol/" rel="noopener noreferrer">kitty 键盘协议</a>。</li>
<li><a href="https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda" rel="noopener noreferrer">OSC 8</a> 用于终端中的链接（以及关于<a href="https://github.com/Alhadis/OSC8-Adoption?tab=readme-ov-file" rel="noopener noreferrer">采用情况</a>的说明）。</li>
<li>来自 tmux 的 <a href="https://github.com/tmux/tmux/blob/882fb4d295deb3e4b803eb444915763305114e4f/tools/ansicode.txt" rel="noopener noreferrer">ANSI 标准摘要</a>。</li>
<li>来自 iTerm 的 <a href="https://iterm2.com/feature-reporting/" rel="noopener noreferrer">终端功能报告规范</a>。</li>
<li>Sixel 图形。</li>
</ul>
<h3 id="why-i-think-this-is-interesting">为什么我觉得这很有趣</h3>
<p>我有时会听到有人说 unix 终端是“过时的”，由于我非常喜爱终端，我总是好奇哪些增量变化可能会让它感觉不那么“过时”。</p>
<p>也许如果我们有一个更清晰的标准格局（就像在 Web 上那样！），终端模拟器开发者就能更容易地构建新功能，终端应用程序的作者也能更自信地采用这些功能，从而让我们所有人都能从中受益，并在终端中获得更丰富的体验。</p>
<p>显然，标准化 ANSI 转义码并不容易（ECMA-48 首次发布已近 50 年，而我们仍未做到！）。我甚至不知道所有的挑战是什么。但 HTML/CSS/JS 的情况曾经也极其糟糕，而现在好多了，所以也许还有希望。</p><p><em>由 mimo-v2.5 模型翻译，花费 7741 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2025/03/07/escape-code-standards/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2025/03/07/escape-code-standards/</guid>
      <pubDate>Fri, 7 Mar 2025 00:00:00 +0000</pubDate>
    </item>
    <item>
      <title>如何将目录添加到你的PATH</title>
      <description>[AI 摘要] 本文提供了在bash、zsh和fish中添加目录到PATH环境变量的详细步骤、配置文件查找和常见问题解决方案。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文提供了在bash、zsh和fish中添加目录到PATH环境变量的详细步骤、配置文件查找和常见问题解决方案。</div><p>今天我和一个朋友聊到如何将目录添加到PATH。对我来说，这似乎“显而易见”，因为我长期使用终端，但当我搜索操作说明时，实际上找不到一个解释所有步骤的——很多只是说“将此添加到<code>~/.bashrc</code>”，但如果你不用bash呢？如果你的bash配置文件在另一个文件里呢？而且，你该如何确定要添加哪个目录？</p>
<p>因此，我想尝试写下更完整的指导，并提及我多年来遇到的一些陷阱。</p>
<p>以下是目录：</p>
<ul>
<li><a href="#step-1-what-shell-are-you-using" rel="noopener noreferrer">步骤1：你使用的是哪个shell？</a></li>
<li><a href="#step-2-find-your-shell-s-config-file" rel="noopener noreferrer">步骤2：找到你的shell配置文件</a>
<ul>
<li><a href="#a-note-on-bash-s-config-file" rel="noopener noreferrer">关于bash配置文件的说明</a></li>
</ul>
</li>
<li><a href="#step-3-figure-out-which-directory-to-add" rel="noopener noreferrer">步骤3：确定要添加的目录</a>
<ul>
<li><a href="#step-3-1-double-check-it-s-the-right-directory" rel="noopener noreferrer">步骤3.1：确认是正确的目录</a></li>
</ul>
</li>
<li><a href="#step-4-edit-your-shell-config" rel="noopener noreferrer">步骤4：编辑你的shell配置</a></li>
<li><a href="#step-5-restart-your-shell" rel="noopener noreferrer">步骤5：重启你的shell</a></li>
<li>问题：
<ul>
<li><a href="#problem-1-it-ran-the-wrong-program" rel="noopener noreferrer">问题1：运行了错误的程序</a></li>
<li><a href="#problem-2-the-program-isn-t-being-run-from-your-shell" rel="noopener noreferrer">问题2：程序未从你的shell运行</a></li>
<li><a href="#problem-3-duplicate-path-entries-making-it-harder-to-debug" rel="noopener noreferrer">问题3：重复的PATH条目使调试更难</a></li>
<li><a href="#problem-4-losing-your-history-after-updating-your-path" rel="noopener noreferrer">问题4：更新PATH后丢失历史记录</a></li>
</ul>
</li>
<li>备注：
<ul>
<li><a href="#a-note-on-source" rel="noopener noreferrer">关于source的说明</a></li>
<li><a href="#a-note-on-fish-add-path" rel="noopener noreferrer">关于fish_add_path的说明</a></li>
</ul>
</li>
</ul>
<h3 id="step-1-what-shell-are-you-using">步骤1：你使用的是哪个shell？</h3>
<p>如果你不确定自己使用哪个shell，这里有一个方法来找出。运行以下命令：</p>
<pre><code>ps -p $$ -o pid,comm=
</code></pre>
<ul>
<li>如果你使用<strong>bash</strong>，它会输出<code>97295 bash</code></li>
<li>如果你使用<strong>zsh</strong>，它会输出<code>97295 zsh</code></li>
<li>如果你使用<strong>fish</strong>，它会输出类似“In fish, please use $fish_pid”的错误（<code>$$</code>在fish中语法无效，但错误信息会告诉你使用的是fish，你可能已经知道）</li>
</ul>
<p>此外，Linux上默认使用bash，Mac OS上默认使用zsh（截至2024年）。我只会在指导中涵盖bash、zsh和fish。</p>
<h3 id="step-2-find-your-shell-s-config-file">步骤2：找到你的shell配置文件</h3>
<ul>
<li>在zsh中，可能是<code>~/.zshrc</code></li>
<li>在bash中，可能是<code>~/.bashrc</code>，但情况复杂，见下一节的说明</li>
<li>在fish中，可能是<code>~/.config/fish/config.fish</code>（如果你想要100%确定，可以运行<code>echo $__fish_config_dir</code>）</li>
</ul>
<h3 id="a-note-on-bash-s-config-file">关于bash配置文件的说明</h3>
<p>Bash有三个可能的配置文件：<code>~/.bashrc</code>、<code>~/.bash_profile</code>和<code>~/.profile</code>。</p>
<p>如果你不确定你的系统使用哪个，我建议这样测试：</p>
<ol>
<li>将<code>echo hi there</code>添加到<code>~/.bashrc</code></li>
<li>重启你的终端</li>
<li>如果看到“hi there”，表示<code>~/.bashrc</code>正在被使用！太好了！</li>
<li>否则，移除它并尝试同样的方法用<code>~/.bash_profile</code></li>
<li>如果前两个选项都不起作用，也可以尝试<code>~/.profile</code></li>
</ol>
<p>（网上有很多<a href="https://blog.flowblok.id.au/2013-02/shell-startup-scripts.html" rel="noopener noreferrer">详细的流程图</a>解释bash如何决定使用哪个配置文件，但在我看来，内化它们不值得，直接测试是最快的方法）</p>
<h3 id="step-3-figure-out-which-directory-to-add">步骤3：确定要添加的目录</h3>
<p>假设你正在尝试安装并运行一个名为<code>http-server</code>的程序，但它不起作用，像这样：</p>
<pre><code>$ npm install -g http-server
$ http-server
bash: http-server: command not found
</code></pre>
<p>你如何找到<code>http-server</code>所在的目录？老实说，这通常不容易——答案往往是“取决于npm如何配置”。几个想法：</p>
<ul>
<li>通常当设置新的安装程序（如<code>cargo</code>、<code>npm</code>、<code>homebrew</code>等）时，首次设置时它会打印一些关于如何更新PATH的说明。所以如果你注意的话，可以那时获取说明。</li>
<li>有时安装程序会自动更新你的shell配置文件来更新<code>PATH</code></li>
<li>有时直接谷歌“npm安装东西在哪里？”会找到答案</li>
<li>有些工具有子命令告诉你它们配置安装在哪里，例如：
<ul>
<li>Node/npm：<code>npm config get prefix</code>（然后附加<code>/bin/</code>）</li>
<li>Go：<code>go env GOPATH</code>（然后附加<code>/bin/</code>）</li>
<li>asdf：<code>asdf info | grep ASDF_DIR</code>（然后附加<code>/bin/</code>和<code>/shims/</code>）</li>
</ul>
</li>
</ul>
<h3 id="step-3-1-double-check-it-s-the-right-directory">步骤3.1：确认是正确的目录</h3>
<p>一旦你找到一个可能正确的目录，确保它确实是正确的！例如，我发现我的机器上<code>http-server</code>在<code>~/.npm-global/bin</code>中。我可以尝试在该目录运行程序<code>http-server</code>来确认是否正确：</p>
<pre><code>$ ~/.npm-global/bin/http-server
Starting up http-server, serving ./public
</code></pre>
<p>它工作了！现在你知道需要添加到<code>PATH</code>的目录，让我们进入下一步！</p>
<h3 id="step-4-edit-your-shell-config">步骤4：编辑你的shell配置</h3>
<p>现在我们有了需要的两个关键信息：</p>
<ol>
<li>要添加到PATH的目录（例如<code>~/.npm-global/bin/</code>）</li>
<li>你的shell配置文件的位置（例如<code>~/.bashrc</code>、<code>~/.zshrc</code>或<code>~/.config/fish/config.fish</code>）</li>
</ol>
<p>现在需要添加的内容取决于你的shell：</p>
<p><strong>bash指导：</strong></p>
<p>打开你的shell配置文件，添加一行如下：</p>
<pre><code>export PATH=$PATH:~/.npm-global/bin/
</code></pre>
<p>（显然将<code>~/.npm-global/bin</code>替换为你要添加的实际目录）</p>
<p><strong>zsh指导：</strong></p>
<p>你可以做与bash相同的事情，但zsh也有一些稍微花哨的语法，如果你喜欢的话可以使用：</p>
<pre><code>path=(
  $path
  ~/.npm-global/bin
)
</code></pre>
<p><strong>fish指导：</strong></p>
<p>在fish中，语法不同：</p>
<pre><code>set PATH $PATH ~/.npm-global/bin
</code></pre>
<p>（在fish中你也可以使用<code>fish_add_path</code>，下面有一些<a href="#a-note-on-fish-add-path" rel="noopener noreferrer">说明</a>）</p>
<h3 id="step-5-restart-your-shell">步骤5：重启你的shell</h3>
<p>现在，一个极其重要的步骤：如果你不重启shell，更新你的shell配置不会生效！</p>
<p>两种方法：</p>
<ol>
<li>打开一个新的终端（或终端标签页），也许关闭旧的以免混淆</li>
<li>运行<code>bash</code>启动新的shell（如果你使用zsh则运行<code>zsh</code>，使用fish则运行<code>fish</code>）</li>
</ol>
<p>我发现这两种方法通常都工作得很好。</p>
<p>你应该完成了！尝试运行你试图运行的程序，希望它现在能工作。</p>
<p>如果没有，这里有一些你可能遇到的问题：</p>
<h3 id="problem-1-it-ran-the-wrong-program">问题1：运行了错误的程序</h3>
<p>如果运行的是程序的错误<strong>版本</strong>，你可能需要将目录添加到PATH的<em>开头</em>而不是末尾。</p>
<p>例如，在我的系统上，我安装了两个版本的<code>python3</code>，我可以通过运行<code>which -a</code>看到：</p>
<pre><code>$ which -a python3
/usr/bin/python3
/opt/homebrew/bin/python3
</code></pre>
<p>你的shell将使用的第一个列出的<strong>版本</strong>。</p>
<p>如果你想使用Homebrew版本，你需要将该目录（<code>/opt/homebrew/bin</code>）添加到PATH的<strong>开头</strong>，在你的shell配置文件中这样写（是<code>/opt/homebrew/bin/:$PATH</code>而不是通常的<code>$PATH:/opt/homebrew/bin/</code>）</p>
<pre><code>export PATH=/opt/homebrew/bin/:$PATH
</code></pre>
<p>或者在fish中：</p>
<pre><code>set PATH ~/.cargo/bin $PATH
</code></pre>
<h3 id="problem-2-the-program-isn-t-being-run-from-your-shell">问题2：程序未从你的shell运行</h3>
<p>所有这些指导仅当你在<strong>从你的shell运行程序</strong>时有效。如果你从IDE、GUI、cron作业或其他方式运行程序，你需要以不同的方式添加目录到PATH，具体情况可能取决于场景。</p>
<p><strong>在cron作业中</strong></p>
<p>一些选项：</p>
<ul>
<li>使用程序的完整路径，例如<code>/home/bork/bin/my-program</code></li>
<li>将完整的PATH作为crontab的第一行（类似PATH=/bin:/usr/bin:/usr/local/bin:...）。你可以通过运行<code>echo "PATH=$PATH"</code>获取你在shell中使用的完整PATH。</li>
</ul>
<p>老实说，我不确定在IDE/GUI中如何处理，因为我很久没遇到过，如果有人指出正确方向，我会在这里添加指导。</p>
<h3 id="problem-3-duplicate-path-entries-making-it-harder-to-debug">问题3：重复的PATH条目使调试更难</h3>
<p>如果你编辑PATH并运行<code>bash</code>（或<code>zsh</code>，或<code>fish</code>）启动新shell，通常会导致重复的<code>PATH</code>条目，因为每次启动shell时shell都会向<code>PATH</code>添加新内容。</p>
<p>我个人认为我没有遇到过这种重复导致任何问题的情况，但如果你试图理解PATH的内容，重复会使调试更难。</p>
<p>你可以处理的一些方法：</p>
<ol>
<li>如果你正在调试<code>PATH</code>，打开一个新终端来做，这样你得到“新鲜”状态。这应该避免重复。</li>
<li>在shell配置文件末尾去重<code>PATH</code>（例如在zsh中，显然你可以用<code>typeset -U path</code>做到）</li>
<li>添加时检查目录是否已在<code>PATH</code>中（例如在fish中，我认为你可以用<code>fish_add_path --path /some/directory</code>做到）</li>
</ol>
<p>如何在shell中去重<code>PATH</code>取决于shell，并不总是有内置方法，所以你需要查找如何在你的shell中完成。</p>
<h3 id="problem-4-losing-your-history-after-updating-your-path">问题4：更新PATH后丢失历史记录</h3>
<p>这里是bash或zsh中容易遇到的情况：</p>
<ol>
<li>运行一个命令（它失败）</li>
<li>更新<code>PATH</code></li>
<li>运行<code>bash</code>重新加载配置</li>
<li>按上箭头几次重新运行失败的命令（或打开新终端）</li>
<li>失败的命令不在历史记录中！为什么？</li>
</ol>
<p>这发生是因为在bash中，默认情况下，历史记录在退出shell之前不会保存。</p>
<p>修复的一些选项：</p>
<ul>
<li>代替运行<code>bash</code>重新加载配置，运行<code>source ~/.bashrc</code>（或在zsh中<code>source ~/.zshrc</code>）。这将在当前会话内重新加载配置。</li>
<li>配置你的shell持续保存历史记录，而不是仅在shell退出时保存。（如何做到取决于你使用bash还是zsh，zsh中的历史选项有点复杂，我不太确定最佳方法）</li>
</ul>
<h3 id="a-note-on-source">关于<code>source</code>的说明</h3>
<p>当你首次安装<code>cargo</code>（Rust的安装程序）时，它会给你这些设置PATH的说明，根本没有提到具体目录。</p>
<pre><code>This is usually done by running one of the following (note the leading DOT):

. "$HOME/.cargo/env"        	# For sh/bash/zsh/ash/dash/pdksh
source "$HOME/.cargo/env.fish"  # For fish
</code></pre>
<p>这个想法是你将那行添加到你的shell配置，他们的脚本会自动设置你的<code>PATH</code>（以及其他可能的东西）。</p>
<p>这很常见（例如<a href="https://github.com/Homebrew/install/blob/deacfa6a6e62e5f4002baf9e1fac7a96e9aa5d41/install.sh#L1072-L1087" rel="noopener noreferrer">Homebrew</a>建议你eval <code>brew shellenv</code>），有两种方法：</p>
<ol>
<li>直接按工具的建议做（例如添加<code>. "$HOME/.cargo/env"</code>到你的shell配置）</li>
<li>弄清楚他们建议运行的脚本会添加哪些目录到PATH，然后手动添加。以下是我会做的方法：
<ul>
<li>在shell中运行<code>. "$HOME/.cargo/env"</code>（如果使用fish则用fish版本）</li>
<li>运行<code>echo "$PATH" | tr ':' '\n' | grep cargo</code>来弄清楚它添加了哪些目录</li>
<li>看到它说<code>/Users/bork/.cargo/bin</code>，缩短为<code>~/.cargo/bin</code></li>
<li>将目录<code>~/.cargo/bin</code>添加到PATH（用本文中的指导）</li>
</ul>
</li>
</ol>
<p>我不认为按工具的建议做有什么问题（它可能是“最佳方式”！），但个人我通常使用第二种方法，因为我更喜欢确切知道我正在更改什么配置。</p>
<h3 id="a-note-on-fish-add-path">关于<code>fish_add_path</code>的说明</h3>
<p>fish有一个方便的函数<code>fish_add_path</code>，你可以运行它来添加目录到<code>PATH</code>，像这样：</p>
<pre><code>fish_add_path /some/directory
</code></pre>
<p>这很酷（它是一个简单的命令！），但我已经停止使用它，原因有几个：</p>
<ol>
<li>有时<code>fish_add_path</code>会更新每个未来会话的<code>PATH</code>（用“通用变量”），有时它只更新当前会话的<code>PATH</code>，我很难判断它会做哪个。理论上文档解释了这一点，但我无法理解。</li>
<li>如果你需要在几周或几个月后从<code>PATH</code>中<em>移除</em>该目录（可能因为你犯了个错误），这有点难做到（<a href="https://github.com/fish-shell/fish-shell/issues/8604" rel="noopener noreferrer">不过这个github issue的评论中有说明</a>）。</li>
</ol>
<h3 id="that-s-all">就这样了</h3>
<p>希望这能帮助一些人。如果你有其他在添加目录到PATH时遇到的主要陷阱，或者对这篇文章有疑问，请告诉我（在Mastodon或Bluesky上）！</p><p><em>由 mimo-v2.5 模型翻译，花费 10235 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2025/02/13/how-to-add-a-directory-to-your-path/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2025/02/13/how-to-add-a-directory-to-your-path/</guid>
      <pubDate>Thu, 13 Feb 2025 12:27:56 +0000</pubDate>
    </item>
    <item>
      <title>一些终端使用中的挫败感</title>
      <description>[AI 摘要] 该文分类总结了一项针对1600名资深终端用户的调查结果，列举了他们在使用终端时遇到的主要挫败点。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 该文分类总结了一项针对1600名资深终端用户的调查结果，列举了他们在使用终端时遇到的主要挫败点。</div><p>几周前，我进行了一次终端使用调查（你可以<a href="https://jvns.ca/terminal-survey/results-bsky.html" rel="noopener noreferrer">在此查看结果</a>），在调查的最后我问了这样一个问题：</p>
<blockquote>
<p>对你来说，使用终端最令人感到挫败的是什么？</p>
</blockquote>
<p>有1600人回答了这个问题，我决定花几天时间将所有回复分类。在这个过程中，我了解到对定性数据进行分类并不容易，但我尽力了。为了加快分类速度，我专门构建了一个<a href="https://github.com/jvns/classificator" rel="noopener noreferrer">工具</a>。</p>
<p>与我的所有调查一样，这个方法论并不是特别科学。我只是在Mastodon和Twitter上发布了调查，运行了几天，收集了那些恰好看到并愿意回答的人的意见。</p>
<p>以下是主要的挫败感类别！</p>
<p>我认为在阅读这些评论时，有必要记住以下几点：</p>
<ul>
<li>参与此调查的人中，有40%使用终端已超过<strong>21年</strong>。</li>
<li>参与调查的人中，有95%使用终端至少有4年。</li>
</ul>
<p>这些评论并非来自完全的初学者。</p>
<p>以下是各类挫败感！括号中的数字是持有该种挫败感的人数。我主要将这些记录下来是因为我自己正在尝试编写一本关于终端的小册子，我想了解人们遇到了哪些困难。</p>
<h3 id="remembering-syntax-115">记忆语法（115）</h3>
<p>人们提到了记忆以下内容的困难：</p>
<ul>
<li>awk、jq、sed等命令行工具的语法</li>
<li>重定向的语法</li>
<li>tmux、文本编辑器等的快捷键</li>
</ul>
<p>一个示例评论：</p>
<blockquote>
<p>要掌握全部功能，有太多细小的“琐碎”细节需要记忆。即使过了这么多年，我有时还是会忘记标准错误输出是2还是1，或者忘记<code>&gt;</code>和<code>&gt;&gt;</code>哪个是哪个。</p>
</blockquote>
<h3 id="switching-terminals-is-hard-91">切换终端环境很难（91）</h3>
<p>人们提到了在切换系统（例如家庭/工作电脑或使用SSH时）时遇到的困难，包括：</p>
<ul>
<li>不同操作系统键盘快捷键的差异（如Linux与Mac）</li>
<li>系统没有他们喜欢的文本编辑器（“没有vim”或“只有vim”）</li>
<li>同一命令的不同版本（如Mac OS的grep与GNU grep）</li>
<li>没有标签页补全</li>
<li>不熟悉的shell（“zsh和bash之间的细微差别”）</li>
</ul>
<p>以及同一系统内部的差异，例如分页器彼此不一致（git diff的分页器、其他分页器）。</p>
<p>一个示例评论：</p>
<blockquote>
<p>我习惯了fish和vi模式，但当我通过SSH连接到服务器或容器时，这些都不可用。</p>
</blockquote>
<h3 id="color-85">颜色（85）</h3>
<p>颜色方面有很多问题，例如：</p>
<ul>
<li>程序设置的颜色在浅色背景上难以阅读</li>
<li>找到一个喜欢的颜色方案（并使其在不同应用中保持一致）</li>
<li>颜色在多层SSH/tmux等中不起作用</li>
<li>不喜欢默认颜色</li>
<li>完全不想要颜色，却难以将其关闭</li>
</ul>
<p>这条评论让我感同身受：</p>
<blockquote>
<p>在终端模拟器和fish之间合理配置我的终端主题（我几年前做过这件事，记得当时非常繁琐和棘手，现在感觉被当前的主题“锁定”了，因为它能工作，我非常害怕再次触碰任何相关配置）。</p>
</blockquote>
<h3 id="keyboard-shortcuts-84">键盘快捷键（84）</h3>
<p>关于键盘快捷键的评论中，有一半是关于在Linux/Windows上，终端的复制/粘贴快捷键与操作系统其他部分不同。</p>
<p>除了复制/粘贴，还有一些其他快捷键问题：</p>
<ul>
<li>在基于浏览器的终端中使用<code>Ctrl-W</code>却关闭了窗口</li>
<li>终端只支持有限的键盘快捷键（不支持<code>Ctrl-Shift-</code>、<code>Super</code>、<code>Hyper</code>，很多<code>ctrl-</code>快捷键不可能实现，比如<code>Ctrl-,</code>）</li>
<li>操作系统阻止你使用终端快捷键（例如，Mac OS默认使用<code>Ctrl+左箭头</code>做其他事情）</li>
<li>在终端中使用emacs遇到的问题</li>
<li>退格键不起作用（2人提到）</li>
</ul>
<h3 id="other-copy-and-paste-issues-75">其他复制粘贴问题（75）</h3>
<p>除了“复制粘贴的键盘快捷键不同”之外，还有很多其他复制粘贴问题，例如：</p>
<ul>
<li>通过SSH复制</li>
<li>tmux和终端模拟器以不同方式处理复制粘贴</li>
<li>处理多个不同的剪贴板（系统剪贴板、vim剪贴板、Linux上的“中键点击”剪贴板、tmux的剪贴板等）并可能需要同步它们</li>
<li>从终端复制时随机添加空格</li>
<li>粘贴多行命令时会自动以令人恐惧的方式执行</li>
<li>希望有一种不使用鼠标就能复制文本的方法</li>
</ul>
<h3 id="discoverability-55">可发现性（55）</h3>
<p>有很多关于这方面的评论，都归结为同一个基本抱怨——很难发现有用的工具或功能！这条评论很好地总结了这一点：</p>
<blockquote>
<p>自主学习有多难。我知道的大部分东西都是多年来随机的人们告诉我的零碎知识的集合。</p>
</blockquote>
<h3 id="steep-learning-curve-44">陡峭的学习曲线（44）</h3>
<p>很多评论提到它通常具有陡峭的学习曲线。举几个例子：</p>
<blockquote>
<p>用了15年之后，我现在的速度并不比5年甚至10年前快多少。</p>
</blockquote>
<p>以及</p>
<blockquote>
<p>我知道通过学习更多关于快捷键和命令的知识以及配置终端，可以让我的生活更轻松，但我没有花时间去做，因为这感觉让人不堪重负。</p>
</blockquote>
<h3 id="history-42">历史记录（42）</h3>
<p>一些shell历史记录的问题：</p>
<ul>
<li>历史记录不在终端标签页之间共享（16人提到）</li>
<li>历史记录长度限制太短（4人提到）</li>
<li>恢复终端标签页时历史记录未被恢复</li>
<li>因终端崩溃而丢失历史记录</li>
<li>不知道如何搜索历史记录</li>
</ul>
<p>一个示例评论：</p>
<blockquote>
<p>在我弄明白之前，这浪费了很多时间，而且仍然让我烦恼的是，zsh上的“历史记录”缓冲区太小；我必须输入“history 0”才能获得任何有用长度的历史记录。</p>
</blockquote>
<h3 id="bad-documentation-37">糟糕的文档（37）</h3>
<p>人们提到了：</p>
<ul>
<li>文档通常晦涩难懂</li>
<li>手册页缺乏示例</li>
<li>程序没有手册页</li>
</ul>
<p>这是一条有代表性的评论：</p>
<blockquote>
<p>找到好的示例和文档。手册页往往不够用，不得不在Stack Overflow中摸索。</p>
</blockquote>
<h3 id="scrollback-36">回滚（36）</h3>
<p>回滚方面的一些问题：</p>
<ul>
<li>程序输出数据过多，导致丢失回滚历史</li>
<li>调整终端大小会搞乱回滚内容</li>
<li>缺少时间戳</li>
<li>在后台启动的GUI程序输出内容，干扰了其他程序的输出</li>
</ul>
<p>一个示例评论：</p>
<blockquote>
<p>调整终端大小（特别是：使其变窄）会导致回滚内容的重新换行出错，因为命令的输出格式是基于终端窗口宽度设置的。</p>
</blockquote>
<h3 id="it-feels-outdated-33">“感觉过时”（33）</h3>
<p>很多评论提到终端感觉受制于遗留决策，用户常常需要学习感觉非常深奥的实现细节。一个示例评论：</p>
<blockquote>
<p>大多数遗留包袱，如果能有一个全新的命令行接口实现就好了。</p>
</blockquote>
<h3 id="shell-scripting-32">Shell脚本（32）</h3>
<p>很多关于POSIX shell脚本的抱怨。普遍感觉shell脚本很难，但转向其他不那么标准的脚本语言（如fish、nushell等）也会带来自己的问题。</p>
<blockquote>
<p>Shell脚本。我放弃shell脚本而转向其他脚本语言的容忍度很低。它太混乱也太强大了。搞砸的代价可能很高，所以我甚至懒得去做。</p>
</blockquote>
<h3 id="more-issues">更多问题</h3>
<p>一些被至少提到10次的其他问题：</p>
<ul>
<li>(31) 不一致的命令行参数：是-h，还是help，还是--help？</li>
<li>(24) 在不同系统间同步dotfiles（配置文件）</li>
<li>(23) 性能（例如“我的shell启动时间太长”）</li>
<li>(20) 窗口管理（可能涉及tmux标签页、终端标签页和多个终端窗口的组合。那个shell会话去哪了？）</li>
<li>(17) 普遍感到害怕/不安（“令人崩溃的恐惧，担心我会用某个命令做出某种神秘的坏事，而我完全不知道如何修复或撤销它，甚至不知道到底发生了什么”）</li>
<li>(16) terminfo（终端信息）问题（“尝试新的终端模拟器并SSH到其他地方时，不得不了解terminfo”）</li>
<li>(16) 缺乏图像支持（sixel等）</li>
<li>(15) SSH问题（例如连接断开时不得不重新开始）</li>
<li>(15) 各种tmux/screen问题（例如tmux与终端模拟器之间缺乏集成）</li>
<li>(15) 打字错误和打字速度慢</li>
<li>(13) 终端因各种原因被搞乱（按下<code>Ctrl-S</code>、用<code>cat</code>显示二进制文件等）</li>
<li>(12) shell中的引号/转义</li>
<li>(11) 各种Windows/PowerShell问题</li>
</ul>
<h3 id="n-a-122">无（122）</h3>
<p>还有122个回答类似于“没什么”或“唯一的挫败是我无法在终端里做<em>所有</em>事情”。</p>
<p>一个示例评论：</p>
<blockquote>
<p>我想我已经找到了解决大部分/所有挫败感的方法。</p>
</blockquote>
<h3 id="that-s-all">以上就是全部了！</h3>
<p>我不打算对这些结果做过多评论，但以下是一些我认为相关的类别：</p>
<ul>
<li>记忆语法和历史记录（通常需要记住的东西是你之前运行过的！）</li>
<li>可发现性和学习曲线（可发现性差绝对是学习困难的一个主要原因）</li>
<li>“切换系统很难”和“感觉过时”（那些30年或40年来几乎没有变化的工具有很多问题，但它们确实倾向于在任何系统上都<em>存在</em>，这非常有用，也使得它们很难被停用）</li>
</ul>
<p>尝试以合理的方式对所有这些结果进行分类，真的让我对社会科学研究员的技能感到钦佩。</p><p><em>由 mimo-v2.5 模型翻译，花费 6257 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2025/02/05/some-terminal-frustrations/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2025/02/05/some-terminal-frustrations/</guid>
      <pubDate>Wed, 5 Feb 2025 16:57:00 +0000</pubDate>
    </item>
    <item>
      <title>什么是配置&quot;现代&quot;终端环境所涉及的？</title>
      <description>[AI 摘要] 本文探讨了配置一个功能齐全的现代终端环境所涉及的复杂性和诸多挑战。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文探讨了配置一个功能齐全的现代终端环境所涉及的复杂性和诸多挑战。</div><p>你好！最近我进行了一次终端使用调查，并询问了用户们对哪些方面感到困扰。其中一人评论道：</p>
<blockquote>
<p>要获得现代终端体验，涉及太多组件了。真希望它们能开箱即用。</p>
</blockquote>
<p>我最初的反应是“哦，配置现代终端体验并不难，你只需要……”，但想得越多，“你只需要……”的清单就越长，而且我不断想到越来越多的注意事项。</p>
<p>因此，我决定写些笔记，谈谈我个人对“现代”终端体验的理解，以及我认为什么因素让人们难以实现它。</p>
<h3 id="what-is-a-modern-terminal-experience">什么是“现代终端体验”？</h3>
<p>以下是我个人认为重要的几点，并注明了系统中由哪部分负责实现：</p>
<ul>
<li><strong>多行复制粘贴支持</strong>：如果你在shell中粘贴3条命令，它不应该立即全部执行！那太吓人了！（<strong>shell</strong>，<strong>终端模拟器</strong>）</li>
<li><strong>无限的shell历史记录</strong>：如果我在shell中运行了一个命令，它应该被永久保存，而不是在500条历史记录后就被删除。另外，我希望命令在运行时立即保存到历史记录，而不是在退出shell会话时才保存（<strong>shell</strong>）</li>
<li><strong>有用的提示符</strong>：没有在提示符中显示<strong>当前目录</strong>和<strong>当前git分支</strong>，我简直无法工作（<strong>shell</strong>）</li>
<li><strong>24位色</strong>：这对我很重要，因为发现在支持24位色的终端中配置neovim的主题比在仅支持256色的终端中容易得多（<strong>终端模拟器</strong>）</li>
<li><strong>剪贴板集成</strong>：让vim和我的操作系统实现剪贴板集成，这样当我在Firefox中复制内容后，只需在vim中按<code>p</code>键就能粘贴（<strong>文本编辑器</strong>，可能还需要操作系统/终端模拟器的支持）</li>
<li><strong>良好的自动补全</strong>：例如，git这类命令应该有针对其特定命令的补全（<strong>shell</strong>）</li>
<li><strong><code>ls</code>命令带颜色</strong>（<strong>shell配置</strong>）</li>
<li><strong>我喜欢的终端主题</strong>：我花很多时间在终端里，我希望它看起来美观，并且其主题能与我的终端编辑器主题匹配。（<strong>终端模拟器</strong>，<strong>文本编辑器</strong>）</li>
<li><strong>自动修复终端</strong>：如果某个程序输出了一些奇怪的转义代码弄乱了终端，我希望能够自动重置，这样终端就不会一团糟（<strong>shell</strong>）</li>
<li><strong>快捷键绑定</strong>：我希望<code>Ctrl+左箭头</code>能正常工作（<strong>shell</strong>或<strong>应用程序</strong>）</li>
<li><strong>在<code>less</code>等程序中能够使用滚轮</strong>：（<strong>终端模拟器</strong>和<strong>应用程序</strong>）</li>
</ul>
<p>终端便利性功能有无数种，不同的人看重不同的方面，但这些是我无法缺少的功能。</p>
<h3 id="how-i-achieve-a-modern-experience">我如何实现“现代体验”</h3>
<p>我的基本方法是：</p>
<ol>
<li>使用<code>fish</code> shell。基本不做配置，除了：
<ul>
<li>将<code>EDITOR</code>环境变量设置为我最喜欢的终端编辑器</li>
<li>将<code>ls</code>别名设置为<code>ls --color=auto</code></li>
</ul>
</li>
<li>使用任何支持24位色的终端模拟器。过去我用过GNOME Terminal、Terminator和iTerm，但我对此并不挑剔。除了选择字体外，我基本不配置它。</li>
<li>使用<code>neovim</code>，配合我过去大约9年来非常缓慢构建的配置（我上次删除vim配置并从头开始是9年前的事了）</li>
<li>使用<a href="https://github.com/chriskempson/base16" rel="noopener noreferrer">base16框架</a>为所有内容设置主题</li>
</ol>
<p>影响我方法的一些因素：</p>
<ul>
<li>我不常SSH到其他机器上工作</li>
<li>与其想出基于键盘的方式来完成所有事情，我宁愿稍微使用鼠标</li>
<li>我处理很多小项目，而不是一个大项目</li>
</ul>
<h3 id="some-out-of-the-box-options-for-a-modern-experience">一些“开箱即用”的“现代”体验选项</h3>
<p>如果你想要一个良好的体验，但又不想花大量时间在配置上呢？搞清楚如何配置vim让我满意确实花了我大概十年，那可是很长的时间！</p>
<p>关于如何以最小配置获得合理终端体验，我的最佳建议是：</p>
<ul>
<li>shell：要么使用<code>fish</code>，要么使用<code>zsh</code>配合<a href="https://ohmyz.sh/" rel="noopener noreferrer">oh-my-zsh</a></li>
<li>终端模拟器：几乎所有支持24位色的都可以，例如以下这些都很流行：
<ul>
<li>Linux：GNOME Terminal、Konsole、Terminator、xfce4-terminal</li>
<li>Mac：iTerm（Terminal.app不支持256色）</li>
<li>跨平台：kitty、alacritty、wezterm或ghostty</li>
</ul>
</li>
<li>shell配置：
<ul>
<li>将<code>EDITOR</code>环境变量设置为你最喜欢的终端文本编辑器</li>
<li>也许可以将<code>ls</code>别名设置为<code>ls --color=auto</code></li>
</ul>
</li>
<li>文本编辑器：这是个难题，也许<a href="https://micro-editor.github.io/" rel="noopener noreferrer">micro</a>或<a href="https://helix-editor.com/" rel="noopener noreferrer">helix</a>？我都没有认真使用过它们，但它们看起来都是非常酷的项目，而且我认为你能直接在micro中使用所有常见的GUI编辑器命令（<code>Ctrl-C</code>复制、<code>Ctrl-V</code>粘贴、<code>Ctrl-A</code>全选）并且它们能按预期工作，这太棒了。我可能会尝试切换到helix，但重新训练我的vim肌肉记忆似乎太难了。而且helix目前还没有GUI或插件系统。</li>
</ul>
<p>就个人而言，我<strong>不会</strong>使用xterm、rxvt或Terminal.app作为终端模拟器，因为我过去发现它们缺少核心功能（比如Terminal.app的24位色支持），这会让我的终端使用起来更困难。</p>
<p>我并不想假装获得“现代”终端体验比实际更容易——我认为有两个问题让它变得困难。我们来谈谈吧！</p>
<h3 id="issue-1-with-getting-to-a-modern-experience-the-shell">获得“现代”体验的问题一：shell</h3>
<p>bash和zsh是目前最流行的两个shell，它们都没有提供我愿意开箱即用的默认体验，例如：</p>
<ul>
<li>你需要自定义提示符</li>
<li>它们默认不带git补全功能，你需要自己设置</li>
<li>默认情况下，bash只存储500（！）条历史记录，而（至少在Mac OS上）zsh默认只配置存储2000条，这仍然不算多</li>
<li>我发现bash的标签页补全非常令人沮丧，如果存在多个匹配项，你无法通过标签页切换</li>
</ul>
<p>尽管<a href="https://jvns.ca/blog/2024/09/12/reasons-i--still--love-fish/" rel="noopener noreferrer">我喜欢fish</a>，但它不符合POSIX标准的事实确实让很多人难以转换。</p>
<p>当然，学习如何在bash中自定义提示符是完全可能的，而且甚至不需要太复杂（在bash中我可能会从类似<code>export PS1='[\u@\h \W$(__git_ps1 " (%s)")]\$ '</code>的配置开始，或者使用<a href="https://starship.rs/" rel="noopener noreferrer">starship</a>）。但每一个这些“不复杂”的事情确实会累积起来，特别是当你需要在多个系统上同步配置时，情况就更糟了。</p>
<p>获得“现代”shell体验的一个极其流行的解决方案是<a href="https://ohmyz.sh/" rel="noopener noreferrer">oh-my-zsh</a>。它看起来是个很棒的项目，我知道很多人用得非常开心，但我过去在使用类似配置系统时遇到了困难——看起来目前基础的oh-my-zsh添加了大约3000行配置，而且我常常发现，额外的配置系统会让事情出错时的调试变得更困难。我个人倾向于使用这个系统添加很多额外插件，导致系统变慢，然后对速度感到沮丧，最后完全删除它并从头开始写新配置。</p>
<h3 id="issue-2-with-getting-to-a-modern-experience-the-text-editor">获得“现代”体验的问题二：文本编辑器</h3>
<p>在我最近进行的终端调查中，最受欢迎的终端文本编辑器无疑是<code>vim</code>、<code>emacs</code>和<code>nano</code>。</p>
<p>我认为终端文本编辑器的主要选择有：</p>
<ul>
<li>使用vim或emacs并根据你的喜好进行配置，如果你投入时间，你可能可以获得任何想要的功能</li>
<li>使用nano并接受你将获得相当有限的体验（例如，我认为你不能在nano中用鼠标选择文本然后“剪切”）</li>
<li>使用<code>micro</code>或<code>helix</code>，它们似乎提供了相当好的开箱即用体验，但可能会偶尔遇到使用非主流文本编辑器的问题</li>
<li>尽量避免使用终端文本编辑器，也许使用VSCode，使用VSCode的终端满足所有终端需求，并且几乎从不在终端中编辑文件。或者我知道很多人在终端中使用<code>code</code>作为他们的<code>EDITOR</code>。</li>
</ul>
<h3 id="issue-3-individual-applications">问题三：单个应用程序</h3>
<p>最后一个问题是，有时我使用的单个程序会有点烦人。例如，在我的Mac OS机器上，<code>/usr/bin/sqlite3</code>不支持<code>Ctrl+左箭头</code>快捷键。修复这个问题以在SQLite中获得合理的终端体验有点复杂，我不得不：</p>
<ul>
<li>意识到问题发生的原因（Mac OS不提供GNU工具，而“Ctrl-左箭头”支持来自GNU readline）</li>
<li>找到解决方法（从homebrew安装sqlite，它确实有readline支持）</li>
<li>调整我的环境（将Homebrew的sqlite3放入我的PATH）</li>
</ul>
<p>我发现调试这类应用程序特定的问题真的不容易，而且常常觉得不“值得”——我通常会处理各种小不便，因为不想花几个小时去研究它们。我能够弄清楚这个问题的唯一原因是我最近花了大量时间思考终端问题。</p>
<p>使用终端程序获得“现代”体验的一个重要部分是使用更新的终端程序，例如，我不愿费心学习在<code>top</code>中排序列的快捷键，但在<code>htop</code>中，我只需用鼠标点击列标题就能排序。所以我改用htop！但发现新的更“现代”的命令行工具并不容易（不过我<a href="https://jvns.ca/blog/2022/04/12/a-list-of-new-ish--command-line-tools/" rel="noopener noreferrer">在此列出了一个清单</a>），找到实际喜欢使用的工具需要时间，而且如果你SSH到另一台机器，它们可能并不总是可用。</p>
<h3 id="everything-affects-everything-else">一切都会影响其他一切</h3>
<p>配置终端让一切变得“好用”的一个棘手之处在于，改变工作流中一个看似微小的方面可能会真正影响其他一切。例如，我现在不使用tmux。但如果我需要再次使用tmux（例如，因为我需要SSH到另一台机器上做大量工作），我就需要考虑几件事，比如：</p>
<ul>
<li>如果我想让tmux的复制与我的系统剪贴板在SSH上同步，我需要确保我的终端模拟器支持<a href="https://old.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/" rel="noopener noreferrer">OSC 52</a></li>
<li>如果我想使用iTerm的tmux集成（它将tmux标签页变成iTerm标签页），我就需要改变我设置颜色的方式——目前我在shell启动时运行一个<a href="https://github.com/chriskempson/base16-shell/blob/588691ba71b47e75793ed9edfcfaa058326a6f41/scripts/base16-solarized-light.sh" rel="noopener noreferrer">shell脚本</a>来设置颜色，但这意味着恢复tmux会话时颜色会丢失。</li>
</ul>
<p>可能还有更多我没考虑到的事情。“使用tmux意味着我必须改变管理颜色的方式”听起来不太可能，但这确实发生在我身上，我决定“好吧，我现在不想改变管理颜色的方式，所以我想我不会使用那个功能了！”</p>
<p>也很难记住我依赖哪些功能——例如，也许我当前的终端<em>确实</em>支持OSC 52，并且因为通过SSH从tmux复制一直都能正常工作，我甚至没意识到那是我需要的东西，然后当我切换终端时它神秘地停止工作。</p>
<h3 id="change-things-slowly">缓慢地改变</h3>
<p>就个人而言，即使我认为我的设置并不<em>那么</em>复杂，我也花了20年才达到这个水平！因为终端配置更改很可能产生意想不到且难以理解的结果，我发现如果我一次性更改大量终端配置，一旦出现问题，就很难理解哪里出错了，这会让人非常困惑。</p>
<p>所以我通常倾向于做出非常小的改变，并接受改变可能需要我花<em>非常长</em>的时间来适应。例如，我一两年前从使用<code>ls</code>切换到了<a href="https://github.com/eza-community/eza" rel="noopener noreferrer">eza</a>，虽然我喜欢它（因为<code>eza -l</code>默认打印人类可读的文件大小），但我仍然不太确定。但有时进行大的改变是值得的，比如我10年前从bash切换到fish，我非常高兴我这样做了。</p>
<h3 id="getting-a-modern-terminal-is-not-that-easy">获得“现代”终端并不那么容易</h3>
<p>试图解释配置终端有多“容易”实际上只是让我想到它有点难，而且我有时仍然会感到困惑。</p>
<p>我发现终端中从来没有一种完美的配置方式能与每一件其他事情兼容。我只需要尝试，找到某种对我来说局部稳定的状态，并接受如果我开始使用一个新工具，它可能会打乱系统，我可能需要重新思考。</p><p><em>由 mimo-v2.5 模型翻译，花费 7997 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2025/01/11/getting-a-modern-terminal-setup/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2025/01/11/getting-a-modern-terminal-setup/</guid>
      <pubDate>Sat, 11 Jan 2025 09:46:01 +0000</pubDate>
    </item>
    <item>
      <title>终端程序所遵循的“规则”</title>
      <description>[AI 摘要] 这篇文章描述了终端程序遵循的一些常见但非官方的、观察到的行为“规则”。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 这篇文章描述了终端程序遵循的一些常见但非官方的、观察到的行为“规则”。</div>最近我一直在思考，终端中发生的一切都是以下部分的某种组合：
<ol>
<li>你的<strong>操作系统</strong>的任务</li>
<li>你的<strong>shell</strong>的任务</li>
<li>你的<strong>终端模拟器</strong>的任务</li>
<li><strong>你恰好正在运行的程序</strong>（比如 <code>top</code> 或 <code>vim</code> 或 <code>cat</code>）的任务</li>
</ol>
<p>前三个（你的操作系统、shell和终端模拟器）都算是已知量——如果你在Linux上的GNOME终端中使用bash，你大概可以推断出所有这些组件如何互动，它们的一些行为也被POSIX标准化。</p>
<p>但第四个（“你恰好正在运行的程序”）感觉可能做任何事情。你怎么知道一个程序会如何表现？</p>
<p>这篇文章有点长，所以这里有一个快速的目录：</p>
<ul>
<li><a href="#programs-behave-surprisingly-consistently" rel="noopener noreferrer">程序表现得出奇地一致</a></li>
<li><a href="#these-are-meant-to-be-descriptive-not-prescriptive" rel="noopener noreferrer">这些是描述性的，而非规定性的</a></li>
<li><a href="#it-s-not-always-obvious-which-rules-are-the-program-s-responsibility-to-implement" rel="noopener noreferrer">并非总是显而易见哪些“规则”是程序应实现的责任</a></li>
<li><a href="#rule-1-noninteractive-programs-should-quit-when-you-press-ctrl-c" rel="noopener noreferrer">规则1：非交互式程序应在你按 <code>Ctrl-C</code> 时退出</a></li>
<li><a href="#rule-2-tuis-should-quit-when-you-press-q" rel="noopener noreferrer">规则2：TUI应在你按 <code>q</code> 时退出</a></li>
<li><a href="#rule-3-repls-should-quit-when-you-press-ctrl-d-on-an-empty-line" rel="noopener noreferrer">规则3：REPL应在你按 <code>Ctrl-D</code> 时退出</a></li>
<li><a href="#rule-4-don-t-use-more-than-16-colours" rel="noopener noreferrer">规则4：不要使用超过16种颜色</a></li>
<li><a href="#rule-5-vaguely-support-readline-keybindings" rel="noopener noreferrer">规则5：大致支持readline键绑定</a></li>
<li><a href="#rule-5-1-ctrl-w-should-delete-the-last-word" rel="noopener noreferrer">规则5.1：<code>Ctrl-W</code> 应删除最后一个单词</a></li>
<li><a href="#rule-6-disable-colours-when-writing-to-a-pipe" rel="noopener noreferrer">规则6：写入管道时禁用颜色</a></li>
<li><a href="#rule-7-means-stdin-stdout" rel="noopener noreferrer">规则7：<code>-</code> 表示标准输入/标准输出</a></li>
<li><a href="#these-rules-take-a-long-time-to-learn" rel="noopener noreferrer">这些“规则”需要很长时间才能学会</a></li>
</ul>
<h3 id="programs-behave-surprisingly-consistently">程序表现得出奇地一致</h3>
<p>据我所知，关于终端中的程序应如何行为，并没有真正的标准——我所知道的最接近的是：</p>
<ul>
<li>POSIX，它主要规定了你的终端模拟器/OS/shell应如何协同工作。我认为它确实指定了<code>cp</code>等核心工具的一些行为，但据我所知，它没有对例如<code>htop</code>应如何表现发表任何意见。</li>
<li>这些<a href="https://clig.dev/" rel="noopener noreferrer">命令行界面指南</a>。</li>
</ul>
<p>但尽管没有标准，根据我的经验，终端中的程序行为相当一致。所以我想写下一份程序大多遵循的“规则”清单。</p>
<h3 id="these-are-meant-to-be-descriptive-not-prescriptive">这些是描述性的，而非规定性的</h3>
<p>我这里的目标不是说服终端程序作者他们<em>应该</em>遵循任何这些规则。这些规则有很多例外，而且这些例外通常有很好的理由。</p>
<p>但对我来说，知道一个随机的新终端程序会有什么行为非常有用。与其说“呃，程序可能做任何事”，不如说“好的，这里有我期望的基本规则，然后我可以保留一份简短的例外清单”。</p>
<p>所以我只是写下我对我使用终端20年来所观察到的程序行为方式的总结，我认为它们这样行为的原因，以及一些“打破”该规则的例子。</p>
<h3 id="it-s-not-always-obvious-which-rules-are-the-program-s-responsibility-to-implement">并非总是显而易见哪些“规则”是程序应实现的责任</h3>
<p>有一些常见的约定，我认为显然是程序应实现的责任，比如：</p>
<ul>
<li>配置文件应放在 <code>~/.BLAHrc</code> 或 <code>~/.config/BLAH/FILE</code> 或 <code>/etc/BLAH/</code> 之类的地方</li>
<li><code>--help</code> 应打印帮助文本</li>
<li>程序应将“常规”输出打印到标准输出，错误打印到标准错误</li>
</ul>
<p>但在这篇文章中，我将专注于那些不100%明显是程序责任的事情。例如，对我来说，按 <code>Ctrl-D</code> 应该退出REPL感觉像一条“自然法则”，但程序通常需要明确实现支持——即使 <code>cat</code> 不需要实现 <code>Ctrl-D</code> 支持，<code>ipython</code> <a href="https://github.com/prompt-toolkit/python-prompt-toolkit/blob/a2a12300c635ab3c051566e363ed27d853af4b21/src/prompt_toolkit/shortcuts/prompt.py#L824-L837" rel="noopener noreferrer">需要</a>。（更多关于此的内容见下文“规则3”）</p>
<p>理解哪些事情是程序的责任，可以使不同程序的实现略有不同时，减少意外感。</p>
<h3 id="rule-1-noninteractive-programs-should-quit-when-you-press-ctrl-c">规则1：非交互式程序应在你按 <code>Ctrl-C</code> 时退出</h3>
<p>这条规则的主要原因是，非交互式程序如果在没有设置 <code>SIGINT</code> 信号处理程序的情况下，按 <code>Ctrl-C</code> 时默认会退出，所以这有点像“你应该表现得像默认行为”的规则。</p>
<p>让很多人困惑的是，这不适用于<strong>交互式</strong>程序，如 <code>python3</code> 或 <code>bc</code> 或 <code>less</code>。这是因为在交互式程序中，<code>Ctrl-C</code> 有另一个作用——如果程序正在运行一个操作（例如 <code>less</code> 中的搜索或 <code>python3</code> 中的一些Python代码），那么 <code>Ctrl-C</code> 会中断该操作，但不会停止程序。</p>
<p>作为交互式程序中如何工作的一个例子：这是<a href="https://github.com/prompt-toolkit/python-prompt-toolkit/blob/a2a12300c635ab3c051566e363ed27d853af4b21/src/prompt_toolkit/key_binding/bindings/vi.py#L2225" rel="noopener noreferrer">prompt-toolkit</a>（iPython用于处理输入的库）中的代码，当你按 <code>Ctrl-C</code> 时会中止搜索。</p>
<h3 id="rule-2-tuis-should-quit-when-you-press-q">规则2：TUI应在你按 <code>q</code> 时退出</h3>
<p>TUI程序（如 <code>less</code> 或 <code>htop</code>）通常在你按 <code>q</code> 时退出。</p>
<p>这条规则不适用于任何按 <code>q</code> 退出没有意义的程序，比如 <code>tmux</code> 或文本编辑器。</p>
<h3 id="rule-3-repls-should-quit-when-you-press-ctrl-d-on-an-empty-line">规则3：REPL应在你按 <code>Ctrl-D</code> 时退出</h3>
<p>REPL（如 <code>python3</code> 或 <code>ed</code>）通常在你按 <code>Ctrl-D</code> 时退出。这条规则类似于 <code>Ctrl-C</code> 规则——原因是默认情况下，如果你在一个以“烹饪模式”运行的程序（如 <code>cat</code>）中，操作系统会在你按 <code>Ctrl-D</code> 时返回一个 <code>EOF</code>。</p>
<p>我使用的大多数REPL（sqlite3、python3、fish、bash等）实际上并不使用烹饪模式，但它们都实现了这个键盘快捷键来模仿默认行为。</p>
<p>例如，这是<a href="https://github.com/prompt-toolkit/python-prompt-toolkit/blob/a2a12300c635ab3c051566e363ed27d853af4b21/src/prompt_toolkit/shortcuts/prompt.py#L824-L837" rel="noopener noreferrer">prompt-toolkit中当你按Ctrl-D时退出的代码</a>，而这是<a href="https://github.com/bminor/bash/blob/6794b5478f660256a1023712b5fc169196ed0a22/lib/readline/readline.c#L658-L672" rel="noopener noreferrer">readline中相同的代码</a>。</p>
<p>我实际上在很久以前认为这是一个“终端物理定律”，因为我基本上从未见过它被打破，但你可以从上面的链接看到，这只是每个单独的输入库必须实现的东西。</p>
<p>有人指出Erlang REPL在按 <code>Ctrl-D</code> 时不会退出，所以我想并非每个REPL都遵循这个“规则”。</p>
<h3 id="rule-4-don-t-use-more-than-16-colours">规则4：不要使用超过16种颜色</h3>
<p>终端程序很少使用基本16种ANSI颜色之外的颜色。这是因为如果你用十六进制代码指定颜色，很可能会与某些用户的背景颜色冲突。例如，如果我打印一些颜色为 <code>#EEEEEE</code> 的文本，在白色背景上它几乎是看不见的，但在深色背景上看起来还不错。</p>
<p>但如果你坚持使用默认的16种基本颜色，你就有更大的机会让用户在终端模拟器中配置这些颜色，使它们与背景颜色配合得相当好。坚持使用默认16种基本颜色的另一个原因是，它对终端模拟器支持什么颜色的假设较少。</p>
<p>我通常看到打破这条“规则”的唯一程序是文本编辑器，例如Helix默认使用紫色背景，这不是默认的ANSI颜色。Helix打破这条规则似乎没问题，因为Helix不是一个“核心”程序，我假设任何不喜欢该配色方案的Helix用户会直接更改主题。</p>
<h3 id="rule-5-vaguely-support-readline-keybindings">规则5：大致支持readline键绑定</h3>
<p>我使用的几乎每个程序，如果这样做有意义，都支持 <code>readline</code> 键绑定。例如，以下是许多不同的程序和链接到它们定义 <code>Ctrl-E</code> 到行尾的位置：</p>
<ul>
<li>ipython（<a href="https://github.com/prompt-toolkit/python-prompt-toolkit/blob/a2a12300c635ab3c051566e363ed27d853af4b21/src/prompt_toolkit/key_binding/bindings/emacs.py#L72" rel="noopener noreferrer">Ctrl-E定义在这里</a>）</li>
<li>atuin（<a href="https://github.com/atuinsh/atuin/blob/a67cfc82fe0dc907a01f07a0fd625701e062a33b/crates/atuin/src/command/client/search/interactive.rs#L407" rel="noopener noreferrer">Ctrl-E定义在这里</a>）</li>
<li>fzf（<a href="https://github.com/junegunn/fzf/blob/bb55045596d6d08445f3c6d320c3ec2b457462d1/src/terminal.go#L611" rel="noopener noreferrer">Ctrl-E定义在这里</a>）</li>
<li>zsh（<a href="https://github.com/zsh-users/zsh/blob/86d5f24a3d28541f242eb3807379301ea976de87/Src/Zle/zle_bindings.c#L94" rel="noopener noreferrer">Ctrl-E定义在这里</a>）</li>
<li>fish（<a href="https://github.com/fish-shell/fish-shell/blob/99fa8aaaa7956178973150a03ce4954ab17a197b/share/functions/fish_default_key_bindings.fish#L43" rel="noopener noreferrer">Ctrl-E定义在这里</a>）</li>
<li>tmux的命令提示符（<a href="https://github.com/tmux/tmux/blob/ae8f2208c98e3c2d6e3fe4cad2281dce8fd11f31/key-bindings.c#L490" rel="noopener noreferrer">Ctrl-E定义在这里</a>）</li>
</ul>
<p>这些程序都不直接使用 <code>readline</code>，它们只是模仿emacs/readline键绑定。它们并不总是<em>完全</em>模仿它们：例如atuin似乎使用 <code>Ctrl-A</code> 作为前缀，所以 <code>Ctrl-A</code> 不会到行首。</p>
<p>另外所有这些程序似乎都实现了自己的内部剪切和粘贴缓冲区，所以你可以用 <code>Ctrl-U</code> 删除一行，然后用 <code>Ctrl-Y</code> 粘贴它。</p>
<p>例外是：</p>
<ul>
<li>一些程序（如 <code>git</code>、<code>cat</code> 和 <code>nc</code>）没有任何行编辑支持（除了退格键、<code>Ctrl-W</code> 和 <code>Ctrl-U</code>）</li>
<li>一如既往，文本编辑器是个例外，每个文本编辑器都有自己的文本编辑方法</li>
</ul>
<p>我在<a href="https://jvns.ca/blog/2024/07/08/readline/" rel="noopener noreferrer">在终端中输入文本很复杂</a>中写了更多关于这个“程序支持哪些键绑定？”问题的内容。</p>
<h3 id="rule-5-1-ctrl-w-should-delete-the-last-word">规则5.1：<code>Ctrl-W</code> 应删除最后一个单词</h3>
<p>我从未见过一个程序（除了文本编辑器）<code>Ctrl-W</code><em>不</em>删除最后一个单词。这类似于 <code>Ctrl-C</code> 规则——默认情况下，如果一个程序在“烹饪模式”下，操作系统会在你按 <code>Ctrl-W</code> 时删除最后一个单词，按 <code>Ctrl-U</code> 时删除整行。所以程序通常会模仿这种行为。</p>
<p>除了文本编辑器，我想不出任何其他例外，但如果有的话我很想听听！</p>
<h3 id="rule-6-disable-colours-when-writing-to-a-pipe">规则6：写入管道时禁用颜色</h3>
<p>大多数程序在写入管道时会禁用颜色。例如：</p>
<ul>
<li><code>rg blah</code> 会在输出中高亮显示所有 <code>blah</code> 的出现，但如果输出到管道或文件，它会关闭高亮。</li>
<li><code>ls --color=auto</code> 在写入终端时会使用颜色，但在写入管道时不会</li>
</ul>
<p>这两个程序在写入终端时也会格式化输出：ls会将文件组织成列，ripgrep会用标题分组匹配项。</p>
<p>如果你想强制程序使用颜色（例如因为你想要查看颜色），你可以使用 <code>unbuffer</code> 强制程序的输出像这样成为tty：</p>
<pre><code>unbuffer rg blah |  less -R
</code></pre>
<p>我确信有一些程序“打破”了这条规则，但我现在想不出任何例子。一些程序有一个 <code>--color</code> 标志，你可以用来强制颜色开启，在上面的例子中，你也可以这样做 <code>rg --color=always | less -R</code>。</p>
<h3 id="rule-7-means-stdin-stdout">规则7：<code>-</code> 表示标准输入/标准输出</h3>
<p>通常，如果你向程序传递 <code>-</code> 而不是文件名，它会从标准输入读取或写入标准输出（以适当的方式）。例如，如果你想用 <code>black</code> 格式化你剪贴板上的Python代码然后复制它，你可以运行：</p>
<pre><code>pbpaste | black - | pbcopy
</code></pre>
<p>（<code>pbpaste</code> 是Mac程序，你可以在Linux上用 <code>xclip</code> 做类似的事情）</p>
<p>我的印象是，大多数程序如果这样做有意义都会实现这一点，我现在想不出任何例外，但我确信有很多例外。</p>
<h3 id="these-rules-take-a-long-time-to-learn">这些“规则”需要很长时间才能学会</h3>
<p>这些规则花了我很长时间才学会，因为我必须：</p>
<ol>
<li>学习该规则是否适用（“<code>Ctrl-C</code> 将退出程序”）</li>
<li>注意到一些例外（“好的，<code>Ctrl-C</code> 将退出 <code>find</code> 但不退出 <code>less</code>”）</li>
<li>下意识地弄清楚模式是什么（“<code>Ctrl-C</code> 通常会退出非交互式程序，但在交互式程序中它可能会中断当前操作而不是退出程序”）</li>
<li>最终也许将其制定成我所知道的明确规则</li>
</ol>
<p>老实说，我对终端的很多理解仍然处于“潜意识模式识别”阶段。我之所以花时间使事情明确化的唯一原因是因为我一直在试图向其他人解释它如何工作。希望将这些“规则”明确地写下来能让其他人学习这些内容快一点。</p><p><em>由 mimo-v2.5 模型翻译，花费 9608 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2024/11/26/terminal-rules/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2024/11/26/terminal-rules/</guid>
      <pubDate>Thu, 12 Dec 2024 09:28:22 +0000</pubDate>
    </item>
    <item>
      <title>管道为何有时会“卡住”：缓冲机制</title>
      <description>[AI 摘要] 本文解释了Unix管道有时输出延迟的原因是程序缓冲机制，并提供了多种解决方案来禁用缓冲。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文解释了Unix管道有时输出延迟的原因是程序缓冲机制，并提供了多种解决方案来禁用缓冲。</div><p>这是一个困扰我多年的终端小众问题，但直到几周前我才真正理解。假设你运行以下命令来监视日志文件中的特定输出：</p>
<pre><code>tail -f /some/log/file | grep thing1 | grep thing2
</code></pre>
<p>如果日志行添加到文件的速度相对较慢，我看到的结果就是……什么都没有！无论日志文件中是否有匹配项，都不会有任何输出。</p>
<p>我默认接受了这个现象：“呃，我猜管道有时候就是会卡住，不显示输出，真奇怪”，然后我会改用 <code>grep thing1 /some/log/file | grep thing2</code> 来处理，这样就能工作。</p>
<p>所以在过去几个月深入研究终端时，我非常兴奋地终于明白了这到底是为什么。</p>
<h3 id="why-this-happens-buffering">原因：缓冲</h3>
<p>“管道卡住”的原因在于，程序在将输出写入管道或文件之前进行缓冲是非常常见的做法。所以管道本身工作正常，问题在于程序根本没有将数据写入管道！</p>
<p>这是出于性能考虑：立即将所有输出写入会使用更多系统调用，因此更高效的做法是积累数据直到有大约 8KB 的数据要写入（或直到程序退出），然后再将其写入管道。</p>
<p>在这个例子中：</p>
<pre><code>tail -f /some/log/file | grep thing1 | grep thing2
</code></pre>
<p>问题在于 <code>grep thing1</code> 会保存所有匹配项，直到积累大约 8KB 的数据才写入，而这可能永远不会发生。</p>
<h3 id="programs-don-t-buffer-when-writing-to-a-terminal">程序写入终端时不进行缓冲</h3>
<p>我觉得这很令人困惑的部分原因是，<code>tail -f file | grep thing</code> 完全可以正常工作，但当你添加第二个 <code>grep</code> 时，它就停止工作了！原因是 <code>grep</code> 处理缓冲的方式取决于它是否写入终端。</p>
<p>以下是 <code>grep</code>（以及许多其他程序）决定如何缓冲输出的方式：</p>
<ul>
<li>使用 <code>isatty</code> 函数检查标准输出是否为终端
<ul>
<li>如果是终端，则使用行缓冲（立即打印每一行）
</li><li>否则使用“块缓冲”——只有在有大约 8KB 或更多数据时才打印</li>
</ul>
</li>
</ul>
<p>因此，如果 <code>grep</code> 直接写入你的终端，你会立即看到输出；但如果写入管道，你就看不到了。</p>
<p>当然，每个程序的缓冲大小不总是 8KB，这取决于实现。对于 <code>grep</code>，缓冲由 libc 处理，而 libc 的缓冲区大小定义在 <code>BUFSIZ</code> 变量中。<a href="https://github.com/bminor/glibc/blob/c69e8cccaff8f2d89cee43202623b33e6ef5d24a/libio/stdio.h#L100" rel="noopener noreferrer">这是 glibc 中的定义位置</a>。</p>
<p>（顺便说一句：“程序写入终端时不使用 8KB 输出缓冲区”并非终端物理定律，程序完全可以使用 8KB 缓冲区写入终端，但这会非常奇怪，我想不出任何程序会这样行为）</p>
<h3 id="commands-that-buffer-commands-that-don-t">缓冲命令与不缓冲命令</h3>
<p>这种缓冲行为的一个烦人之处在于，你需要记住哪些命令在写入管道时会缓冲输出。</p>
<p>一些<strong>不</strong>缓冲输出的命令：</p>
<ul>
<li>tail
</li><li>cat
</li><li>tee</li>
</ul>
<p>我认为几乎所有其他命令都会缓冲输出，特别是那些你可能用于批量处理的命令。以下是一些常见命令的列表，它们在写入管道时会缓冲输出，以及禁用块缓冲的标志。</p>
<ul>
<li>grep (<code>--line-buffered</code>)
</li><li>sed (<code>-u</code>)
</li><li>awk（有 <code>fflush()</code> 函数）
</li><li>tcpdump (<code>-l</code>)
</li><li>jq (<code>-u</code>)
</li><li>tr (<code>-u</code>)
</li><li>cut（无法禁用缓冲）</li>
</ul>
<p>这些就是我能想到的所有命令，许多其他 Unix 命令（如 <code>sort</code>）可能会缓冲输出也可能不会，但这无关紧要，因为 <code>sort</code> 在完成接收输入之前无法做任何事。</p>
<p>另外，我尽力测试了 Mac OS 和 GNU 版本的这些命令，但存在很多差异，我可能犯了一些错误。</p>
<h3 id="programming-languages-where-the-default-print-statement-buffers">默认“print”语句会缓冲的编程语言</h3>
<p>此外，以下是一些编程语言，其默认 print 语句在写入管道时会缓冲输出，以及一些禁用缓冲的方法（如果你需要）：</p>
<ul>
<li>C（使用 <code>setvbuf</code> 禁用）
</li><li>Python（使用 <code>python -u</code>，或 <code>PYTHONUNBUFFERED=1</code>，或 <code>sys.stdout.reconfigure(line_buffering=False)</code>，或 <code>print(x, flush=True)</code> 禁用）
</li><li>Ruby（使用 <code>STDOUT.sync = true</code> 禁用）
</li><li>Perl（使用 <code>$| = 1</code> 禁用）</li>
</ul>
<p>我假设这些语言这样设计是为了让默认的 print 函数在批量处理时速度更快。</p>
<p>另外，输出是否缓冲可能取决于你的打印方式，例如在 C++ 中，<code>cout &lt;&lt; "hello\n"</code> 在写入管道时会缓冲，但 <code>cout &lt;&lt; "hello" &lt;&lt; endl</code> 会刷新输出。</p>
<h3 id="when-you-press-ctrl-c-on-a-pipe-the-contents-of-the-buffer-are-lost">在管道上按 <code>Ctrl-C</code> 时，缓冲区内容会丢失</h3>
<p>假设你运行这个命令作为一种 hacky 方式来监视到 <code>example.com</code> 的 DNS 请求，而你忘了给 tcpdump 传递 <code>-l</code>：</p>
<pre><code>sudo tcpdump -ni any port 53 | grep example.com
</code></pre>
<p>当你按 <code>Ctrl-C</code> 时，会发生什么？在一个理想的完美世界中，我<em>希望</em>发生的是 <code>tcpdump</code> 刷新其缓冲区，<code>grep</code> 搜索 <code>example.com</code>，然后我就能看到所有错过的输出。</p>
<p>但在现实中，所有程序都被终止，<code>tcpdump</code> 缓冲区中的输出丢失了。</p>
<p>我想这个问题可能无法避免——我花了一点时间用 <code>strace</code> 查看这是如何工作的，<code>grep</code> 在 <code>tcpdump</code> 之前接收到 <code>SIGINT</code>，所以即使 <code>tcpdump</code> 尝试刷新缓冲区，<code>grep</code> 也已经死了。</p>
<small>
<p>经过进一步调查，有一个解决方法：如果你找到 <code>tcpdump</code> 的 PID 并执行 <code>kill -TERM $PID</code>，那么 tcpdump 会刷新缓冲区，你就可以看到输出了。这有点麻烦，但我测试过，似乎有效。</p>
</small>
<h3 id="redirecting-to-a-file-also-buffers">重定向到文件也会缓冲</h3>
<p>不仅仅是管道，这个也会缓冲：</p>
<pre><code>sudo tcpdump -ni any port 53 &gt; output.txt
</code></pre>
<p>重定向到文件没有相同的“<code>Ctrl-C</code> 会完全销毁缓冲区内容”的问题——根据我的经验，它通常表现得更符合预期，缓冲区的内容会在程序退出前写入文件。我不完全确定这是否总是可以依赖的。</p>
<h3 id="a-bunch-of-potential-ways-to-avoid-buffering">一系列避免缓冲的潜在方法</h3>
<p>好的，让我们来讨论解决方案。假设你运行了这个命令：</p>
<pre><code>tail -f /some/log/file | grep thing1 | grep thing2
</code></pre>
<p>我在 Mastodon 上询问人们如何在实践中解决这个问题，有 5 种基本方法。如下：</p>
<h4 id="solution-1-run-a-program-that-finishes-quickly">解决方案1：运行一个快速完成的程序</h4>
<p>历史上，我的解决方案是完全避免“命令缓慢写入管道”的情况，而是运行一个能快速完成的程序，像这样：</p>
<pre><code>cat /some/log/file | grep thing1 | grep thing2 | tail
</code></pre>
<p>这与原始命令的作用不同，但它确实意味着你可以避免思考这些奇怪的缓冲问题。</p>
<p>（你也可以使用 <code>grep thing1 /some/log/file</code>，但我经常更喜欢使用一个“不必要”的 <code>cat</code>）</p>
<h4 id="solution-2-remember-the-line-buffer-flag-to-grep">解决方案2：记住 grep 的行缓冲标志</h4>
<p>你可以记住 grep 有一个避免缓冲的标志，并像这样传递它：</p>
<pre><code>tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
</code></pre>
<h4 id="solution-3-use-awk">解决方案3：使用 awk</h4>
<p>有些人说，如果他们特别处理多个 grep 的情况，他们会重写为使用单个 <code>awk</code>，像这样：</p>
<pre><code>tail -f /some/log/file |  awk '/thing1/ &amp;&amp; /thing2/'
</code></pre>
<p>或者你会编写更复杂的 <code>grep</code>，像这样：</p>
<pre><code>tail -f /some/log/file |  grep -E 'thing1.*thing2'
</code></pre>
<p>（<code>awk</code> 也会缓冲，所以为了让它工作，你希望 <code>awk</code> 是管道中的最后一个命令）</p>
<h4 id="solution-4-use-stdbuf">解决方案4：使用 <code>stdbuf</code></h4>
<p><code>stdbuf</code> 使用 LD_PRELOAD 来关闭 libc 的缓冲，你可以像这样用它来关闭输出缓冲：</p>
<pre><code>tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
</code></pre>
<p>像任何 LD_PRELOAD 解决方案一样，它有点不可靠——它不适用于静态二进制文件，我认为如果程序不使用 libc 的缓冲就不工作，并且在 Mac OS 上并不总是有效。Harry Marr 有一篇很棒的 <a href="https://hmarr.com/blog/how-stdbuf-works/" rel="noopener noreferrer">stdbuf 工作原理</a> 文章。</p>
<h4 id="solution-5-use-unbuffer">解决方案5：使用 <code>unbuffer</code></h4>
<p><code>unbuffer program</code> 会强制程序的输出为 TTY，这意味着它的行为方式就像在 TTY 上一样（更少缓冲、彩色输出等）。你可以在本例中这样使用它：</p>
<pre><code>tail -f /some/log/file | unbuffer grep thing1 | grep thing2
</code></pre>
<p>与 <code>stdbuf</code> 不同，它总是有效，尽管它可能有不想要的副作用，例如 <code>grep thing1</code> 也会为匹配项着色。</p>
<p>如果你想安装 unbuffer，它在 <code>expect</code> 包中。</p>
<h3 id="that-s-all-the-solutions-i-know-about">这些就是我所知的所有解决方案！</h3>
<p>对我来说，很难说哪个“最好”，我个人最可能使用 <code>unbuffer</code>，因为我知道它总是有效。</p>
<p>如果我了解到更多解决方案，我会尝试将它们添加到这篇文章中。</p>
<h3 id="i-m-not-really-sure-how-often-this-comes-up">我不确定这经常发生</h3>
<p>对我来说，程序缓慢地向管道流入数据的情况并不常见，通常如果我使用管道，大量数据会快速写入，由管道中的所有内容处理，然后所有程序退出。我目前能想到的例子有：</p>
<ul>
<li>tcpdump
</li><li><code>tail -f</code>
</li><li>以不同方式监视日志文件，如 <code>kubectl logs</code>
</li><li>缓慢计算的输出</li>
</ul>
<h3 id="what-if-there-were-an-environment-variable-to-disable-buffering">如果有环境变量来禁用缓冲会怎样？</h3>
<p>我认为如果有一个标准的环境变量来关闭缓冲，就像 Python 中的 <code>PYTHONUNBUFFERED</code>，会很酷。我从 Mark Dominus 在 2018 年的<a href="https://blog.plover.com/Unix/stdio-buffering.html" rel="noopener noreferrer">几篇</a><a href="https://blog.plover.com/Unix/stdio-buffering-2.html" rel="noopener noreferrer">博客文章</a>中得到了这个想法。也许可以像 <a href="https://no-color.org/" rel="noopener noreferrer">NO_COLOR</a> 那样使用 <code>NO_BUFFER</code>？</p>
<p>设计似乎很难正确；Mark 指出 NETBSD 有<a href="https://man.netbsd.org/setbuf.3" rel="noopener noreferrer">名为 <code>STDBUF</code>、<code>STDBUF1</code> 等的环境变量</a>，可以让你对缓冲有很多控制，但我想大多数开发者不想实现许多不同的环境变量来处理一个相对较小的边缘情况。</p>
<p>我也好奇是否有任何程序只是在一段时间后（比如 1 秒）自动刷新其输出缓冲区。理论上感觉会很好，但我想不出任何程序这样做，所以我想象有一些缺点。</p>
<h3 id="stuff-i-left-out">我遗漏的内容</h3>
<p>这篇文章没有讨论的一些事情，因为最近这些文章已经变得相当长，说真的，真的有人想读 3000 字关于缓冲的文章吗？</p>
<ul>
<li>行缓冲与完全无缓冲输出的区别
</li><li>向 stderr 缓冲与向 stdout 缓冲的不同
</li><li>本文只讨论<strong>程序内部</strong>发生的缓冲，你的操作系统的 TTY 驱动程序有时也会进行一点缓冲
</li><li>除了“你正在写入管道”之外，你可能需要刷新输出的其他原因</li>
</ul><p><em>由 mimo-v2.5 模型翻译，花费 7797 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2024/11/29/why-pipes-get-stuck-buffering/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2024/11/29/why-pipes-get-stuck-buffering/</guid>
      <pubDate>Fri, 29 Nov 2024 08:23:31 +0000</pubDate>
    </item>
    <item>
      <title>如何在无构建系统的情况下导入前端 JavaScript 库</title>
      <description>[AI 摘要] 本文介绍了在没有构建系统的情况下导入前端JavaScript库的方法，包括不同文件类型的识别和使用工具如import maps、esm.sh等。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 本文介绍了在没有构建系统的情况下导入前端JavaScript库的方法，包括不同文件类型的识别和使用工具如import maps、esm.sh等。</div><p>我喜欢在没有构建系统的情况下编写 JavaScript（<a href="https://jvns.ca/blog/2023/02/16/writing-javascript-without-a-build-system/" rel="noopener noreferrer">without a build system</a>），昨天我又一次遇到了需要在不使用构建系统的情况下导入 JavaScript 库的问题，花了很长时间才弄清楚如何导入，因为库的设置说明假定你正在使用构建系统。</p>
<p>幸运的是，到现在我基本上已经学会了如何处理这种情况，要么成功使用该库，要么决定它太难而切换到另一个库，所以这是我希望多年前就有的关于导入 JavaScript 库的指南。</p>
<p>我将只讨论在前端使用 JavaScript 库，并且只关于如何在无构建系统的设置中使用它们。</p>
<p>在这篇文章中，我将讨论：</p>
<ol>
<li>库可能提供的三种主要 JavaScript 文件类型（ES 模块、"经典"全局变量类型和 CommonJS）</li>
<li>如何确定 JavaScript 库在其构建中包含了哪些类型的文件</li>
<li>在代码中导入每种类型文件的方法</li>
</ol>
<h3 id="the-three-kinds-of-javascript-files">三种 JavaScript 文件类型</h3>
<p>库可以提供 3 种基本类型的 JavaScript 文件：</p>
<ol>
<li>定义全局变量的"经典"类型文件。这种文件你可以直接使用 <code>&lt;script src&gt;</code>，它就能正常工作。如果你能得到它，这很好，但并非总是可用。</li>
<li>一个 ES 模块（可能依赖或不依赖其他文件，我们将谈到这一点）。</li>
<li>一个"CommonJS"模块。这是用于 Node 的，如果不使用构建系统，你根本无法在浏览器中使用它。</li>
</ol>
<p>我不确定"经典"类型是否有更好的名称，但我将只称它为"经典"。另外，有一种叫"AMD"的类型，但我不确定它在 2024 年的相关性。</p>
<p>现在我们知道了三种文件类型，让我们谈谈如何确定库实际提供了哪些类型！</p>
<h3 id="where-to-find-the-files-the-npm-build">在哪里找到文件：NPM 构建</h3>
<p>每个 JavaScript 库都有一个<strong>构建</strong>，它上传到 NPM。你可能在想（就像我最初那样）——Julia！重点是我们不使用 Node 来构建我们的库！为什么我们要谈论 NPM？</p>
<p>但是如果你使用来自 CDN 的链接，比如 <a href="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" rel="noopener noreferrer">https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js</a>，你仍然在使用 NPM 构建！CDN 上的所有文件最初都来自 NPM。</p>
<p>正因为如此，即使我根本不打算使用 Node 来构建我的库，我有时也喜欢 <code>npm install</code> 该库——我只会创建一个新的临时文件夹，在那里 <code>npm install</code>，然后在完成后删除它。我喜欢能够在我的文件系统上浏览 NPM 构建中的文件，因为这样我就能 100% 确定我看到了库在其构建中提供的所有内容，并且 CDN 没有对我隐藏任何东西。</p>
<p>所以让我们 <code>npm install</code> 几个库，并尝试弄清楚它们的构建中提供了哪些类型的 JavaScript 文件！</p>
<h3 id="example-library-1-chart-js">示例库 1：chart.js</h3>
<p>首先让我们看看 <a href="https://www.chartjs.org" rel="noopener noreferrer">Chart.js</a>，一个绘图库。</p>
<pre><code>$ cd /tmp/whatever
$ npm install chart.js
$ cd node_modules/chart.js/dist
$ ls *.*js
chart.cjs  chart.js  chart.umd.js  helpers.cjs  helpers.js
</code></pre>
<p>这个库似乎有 3 个基本选项：</p>
<p><strong>选项 1：</strong> <code>chart.cjs</code>。<code>.cjs</code> 后缀告诉我这是一个 <strong>CommonJS 文件</strong>，用于在 Node 中使用。这意味着如果不进行某种构建步骤，它在浏览器中直接使用是不可能的。</p>
<p><strong>选项 2：<code>chart.js</code></strong>。<code>.js</code> 后缀本身并不告诉我们这是哪种类型的文件，但如果我打开它，我看到 <code>import '@kurkle/color';</code>，这立即表明它是一个 ES 模块——<code>import ...</code> 语法是 ES 模块语法。</p>
<p><strong>选项 3：<code>chart.umd.js</code></strong>。"UMD"代表"Universal Module Definition"，我认为这意味着你可以使用这个文件，无论是通过基本的 <code>&lt;script src&gt;</code>、CommonJS，还是某种我不理解的叫 AMD 的东西。</p>
<h3 id="how-to-use-a-umd-file">如何使用 UMD 文件</h3>
<p>当我使用 Chart.js 时，我选择了选项 3。我只需要将这个添加到我的代码中：</p>
<pre><code>&lt;script src="./chart.umd.js"&gt; &lt;/script&gt;
</code></pre>
<p>然后我就可以使用全局 <code>Chart</code> 环境变量来使用该库。不能更简单了。我只是将 <code>chart.umd.js</code> 复制到我的 Git 仓库中，这样我就不用担心使用 NPM 或 CDN 挂掉之类的问题。</p>
<h3 id="the-build-files-aren-t-always-in-the-dist-directory">构建文件并不总是在 <code>dist</code> 目录中</h3>
<p>许多库会将其构建放在 <code>dist</code> 目录中，但并非总是如此！构建文件的位置在库的 <code>package.json</code> 中指定。</p>
<p>例如，这里是 Chart.js 的 <code>package.json</code> 的一个摘录。</p>
<pre><code>  "jsdelivr": "./dist/chart.umd.js",
  "unpkg": "./dist/chart.umd.js",
  "main": "./dist/chart.cjs",
  "module": "./dist/chart.js",
</code></pre>
<p>我认为这是说如果你想使用 ES 模块（<code>module</code>），你应该使用 <code>dist/chart.js</code>，但 jsDelivr 和 unpkg CDN 应该使用 <code>./dist/chart.umd.js</code>。我猜 <code>main</code> 是用于 Node 的。</p>
<p><code>chart.js</code> 的 <code>package.json</code> 还说 <code>"type": "module"</code>，根据 <a href="https://nodejs.org/api/packages.html#modules-packages" rel="noopener noreferrer">这个文档</a>，这告诉 Node 默认将文件视为 ES 模块。我认为它并没有具体告诉我们哪些文件是 ES 模块，哪些不是，但它确实告诉我们<em>其中一些</em>是 ES 模块。</p>
<h3 id="example-library-2-atcute-oauth-browser-client">示例库 2：<code>@atcute/oauth-browser-client</code></h3>
<p><a href="https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/browser-client" rel="noopener noreferrer"><code>@atcute/oauth-browser-client</code></a> 是一个用于在浏览器中使用 OAuth 登录 Bluesky 的库。</p>
<p>让我们看看它在其构建中提供了哪些类型的 JavaScript 文件！</p>
<pre><code>$ npm install @atcute/oauth-browser-client
$ cd node_modules/@atcute/oauth-browser-client/dist
$ ls *js
constants.js  dpop.js  environment.js  errors.js  index.js  resolvers.js
</code></pre>
<p>这里似乎唯一合理的根文件是 <code>index.js</code>，它看起来像这样：</p>
<pre><code>export { configureOAuth } from './environment.js';
export * from './errors.js';
export * from './resolvers.js';
</code></pre>
<p>这种 <code>export</code> 语法意味着它是一个 <strong>ES 模块</strong>。这意味着我们可以在没有构建步骤的情况下在浏览器中使用它！让我们看看如何做到这一点。</p>
<h3 id="how-to-use-an-es-module-with-importmaps">如何使用带有 importmaps 的 ES 模块</h3>
<p>使用 ES 模块并不像只添加一个 <code>&lt;script src="whatever.js"&gt;</code> 那么简单。相反，如果 ES 模块有依赖项（就像 <code>@atcute/oauth-browser-client</code> 一样），步骤是：</p>
<ol>
<li>在 HTML 中设置一个 import map。</li>
<li>在你的 JS 代码中添加像 <code>import { configureOAuth } from '@atcute/oauth-browser-client';</code> 这样的导入语句。</li>
<li>像这样在 HTML 中包含你的 JS 代码：<code>&lt;script type="module" src="YOURSCRIPT.js"&gt;&lt;/script&gt;</code></li>
</ol>
<p>我们需要 import map 而不是像 <code>import { BrowserOAuthClient } from "./oauth-client-browser.js"</code> 这样做的原因是，该模块内部有更多的导入语句，比如 <code>import {something} from @atcute/client</code>，我们需要告诉浏览器从哪里获取 <code>@atcute/client</code> 及其所有其他依赖项的代码。</p>
<p>这是我为 <code>@atcute/oauth-browser-client</code> 使用的 importmap 的样子：</p>
<pre><code>&lt;script type="importmap"&gt;
{
  "imports": {
    "nanoid": "./node_modules/nanoid/bin/dist/index.js",
    "nanoid/non-secure": "./node_modules/nanoid/non-secure/index.js",
    "nanoid/url-alphabet": "./node_modules/nanoid/url-alphabet/dist/index.js",
    "@atcute/oauth-browser-client": "./node_modules/@atcute/oauth-browser-client/dist/index.js",
    "@atcute/client": "./node_modules/@atcute/client/dist/index.js",
    "@atcute/client/utils/did": "./node_modules/@atcute/client/dist/utils/did.js"
  }
}
&lt;/script&gt;
</code></pre>
<p>让这些 import maps 工作相当棘手，我觉得一定有一个工具可以自动生成它们，但我还没找到。肯定可以写一个脚本使用 <a href="https://esbuild.github.io/api/#metafile" rel="noopener noreferrer">esbuild 的 metafile</a> 自动生成 importmaps，但我还没这么做，也许有更好的方法。</p>
<p>我昨天决定设置 importmaps 来让 <a href="https://github.com/jvns/bsky-oauth-example" rel="noopener noreferrer">github.com/jvns/bsky-oauth-example</a> 运行，所以那个仓库里有一些示例代码。</p>
<p>另外，有人向我指出了 Simon Willison 的 <a href="https://simonwillison.net/2023/May/2/download-esm/" rel="noopener noreferrer">download-esm</a>，它将下载一个 ES 模块并重写导入以直接指向 JS 文件，这样你就不需要 importmaps。我还没试过，但看起来是个好主意。</p>
<h3 id="problems-with-importmaps-too-many-files">importmaps 的问题：文件太多</h3>
<p>但我确实在浏览器中使用 importmaps 时遇到了一些问题——它需要下载几十个 JavaScript 文件来加载我的网站，而我的开发环境中的 Web 服务器不知为何跟不上。我不断看到文件随机加载失败，然后不得不重新加载页面，希望这次能成功。</p>
<p>当我将网站部署到生产环境时，这个问题就不再存在了，所以我想这是我的本地开发环境的问题。</p>
<p>另外，关于 ES 模块的普遍一个稍微烦人的事情是你需要运行一个 Web 服务器来使用它们，我确信这是有充分理由的，但当你只需打开你的 <code>index.html</code> 文件而无需启动 Web 服务器时，这更容易。</p>
<p>由于"文件太多"的问题，我认为实际上以这种方式使用带有 importmaps 的 ES 模块对我来说并不那么有吸引力，但知道它是可能的很好。</p>
<h3 id="how-to-use-an-es-module-without-importmaps">如何在不使用 importmaps 的情况下使用 ES 模块</h3>
<p>如果 ES 模块没有依赖项，那就更容易了——你不需要 importmaps！你可以直接：</p>
<ul>
<li>在你的 HTML 中放 <code>&lt;script type="module" src="YOURCODE.js"&gt;&lt;/script&gt;</code>。<code>type="module"</code> 很重要。</li>
<li>在 <code>YOURCODE.js</code> 中放 <code>import {whatever} from "https://example.com/whatever.js"</code>。</li>
</ul>
<h3 id="alternative-use-esbuild">替代方案：使用 esbuild</h3>
<p>如果你不想使用 importmaps，你也可以使用像 <a href="https://esbuild.github.io/" rel="noopener noreferrer">esbuild</a> 这样的构建系统。我在 <a href="https://jvns.ca/blog/2021/11/15/esbuild-vue/" rel="noopener noreferrer">Some notes on using esbuild</a> 中谈到了如何做，但这篇博文是关于完全避免构建系统的方法，所以我不会在这里讨论那个选项。我仍然喜欢 esbuild，我认为在这种情况下它是一个好选择。</p>
<h3 id="what-s-the-browser-support-for-importmaps">importmaps 的浏览器支持如何？</h3>
<p><a href="https://caniuse.com/import-maps" rel="noopener noreferrer">CanIUse</a> 说 importmaps 处于"Baseline 2023：主要浏览器新近可用"，所以我的感觉是在 2024 年这仍然可能有点太新了？我想我会对一些有趣的实验性代码使用 importmaps，这些代码只供像我这样的人和 12 个人使用，但如果我想让我的代码更广泛可用，我会使用 <code>esbuild</code>。</p>
<h3 id="example-library-3-atproto-oauth-client-browser">示例库 3：<code>@atproto/oauth-client-browser</code></h3>
<p>让我们看看最后一个示例库！这是一个与 <code>@atcute/oauth-browser-client</code> 不同的 Bluesky 认证库。</p>
<pre><code>$ npm install @atproto/oauth-client-browser
$ cd node_modules/@atproto/oauth-client-browser/dist
$ ls *js
browser-oauth-client.js  browser-oauth-database.js  browser-runtime-implementation.js  errors.js  index.js  indexed-db-store.js  util.js
</code></pre>
<p>同样，这里似乎唯一真正的候选文件是 <code>index.js</code>。但这是一个与之前示例库不同的情况！让我们看看 <code>index.js</code>：</p>
<p>在 <code>index.js</code> 中有一堆像这样的东西：</p>
<pre><code>__exportStar(require("@atproto/oauth-client"), exports);
__exportStar(require("./browser-oauth-client.js"), exports);
__exportStar(require("./errors.js"), exports);
var util_js_1 = require("./util.js");
</code></pre>
<p>这种 <code>require()</code> 语法是 CommonJS 语法，这意味着我们根本不能在浏览器中使用这个文件，我们需要某种构建步骤，而且 ESBuild 也不行。</p>
<p>另外，在这个库的 <code>package.json</code> 中，它说 <code>"type": "commonjs"</code>，这是另一种表明它是 CommonJS 的方式。</p>
<h3 id="how-to-use-a-commonjs-module-with-esm-sh-https-esm-sh">如何使用 <a href="https://esm.sh" rel="noopener noreferrer">esm.sh</a> 来使用 CommonJS 模块</h3>
<p>最初我以为在不学习构建系统的情况下使用 CommonJS 模块是不可能的，但后来有人在 Bluesky 上告诉了我 <a href="https://esm.sh" rel="noopener noreferrer">esm.sh</a>！它是一个将任何东西转换成 ES 模块的 CDN。<a href="https://www.skypack.dev/" rel="noopener noreferrer">skypack.dev</a> 做类似的事情，我不确定区别是什么，但有人提到如果一个不行，有时他们会尝试另一个。</p>
<p>对于 <code>@atproto/oauth-client-browser</code>，使用它似乎很简单，我只需要在 HTML 中放这个：</p>
<pre><code>&lt;script type="module" src="script.js"&gt; &lt;/script&gt;
</code></pre>
<p>然后在 <code>script.js</code> 中放这个。</p>
<pre><code>import { BrowserOAuthClient } from "https://esm.sh/@atproto/oauth-client-browser@0.3.0"
</code></pre>
<p>它似乎就能正常工作，这很酷！当然，这仍然是在某种程度上使用构建系统——只是 esm.sh 在运行构建而不是我。我对这种方法的主要担忧是：</p>
<ul>
<li>我并不真正信任 CDN 会永远保持工作——通常我喜欢将依赖项复制到我的仓库中，这样它们就不会因为某些原因在未来消失。</li>
<li>我听说 CDN 有安全问题，这让我害怕。</li>
<li>我并不真正理解 esm.sh 在做什么。</li>
</ul>
<h3 id="esbuild-can-also-convert-commonjs-modules-into-es-modules">esbuild 也可以将 CommonJS 模块转换为 ES 模块</h3>
<p>我还了解到，你也可以使用 <code>esbuild</code> 将 CommonJS 模块转换为 ES 模块，尽管有一些限制——<code>import { BrowserOAuthClient } from</code> 语法不起作用。这是一个关于此的 <a href="https://github.com/evanw/esbuild/issues/442" rel="noopener noreferrer">GitHub 问题</a>。</p>
<p>我认为 <code>esbuild</code> 方法可能比 <code>esm.sh</code> 方法更吸引我，因为它是我电脑上已有的工具，所以我更信任它。不过，我还没怎么实验过这个。</p>
<h3 id="summary-of-the-three-types-of-files">三种文件类型总结</h3>
<p>以下是三种 JS 文件类型的总结，如何使用它们，以及如何识别它们。</p>
<p>没有帮助的是，<code>.js</code> 或 <code>.min.js</code> 文件扩展名可能是这三种选项中的任何一种，所以如果文件是 <code>something.js</code>，你需要做更多的调查工作来弄清楚你在处理什么。</p>
<ol>
<li><strong>"经典" JS 文件</strong>
<ul>
<li><strong>如何使用：</strong> <code>&lt;script src="whatever.js"&gt;&lt;/script&gt;</code></li>
<li><strong>识别方式：</strong>
<ul>
<li>该网站在其设置说明中有一个大的友好横幅，写着"使用此与 CDN！"或类似内容。</li>
<li><code>.umd.js</code> 扩展名。</li>
<li>尝试将其放入 <code>&lt;script src=...</code> 标签中，看看是否有效。</li>
</ul>
</li>
</ul>
</li>
<li><strong>ES 模块</strong>
<ul>
<li><strong>使用方式：</strong>
<ul>
<li>如果没有依赖项，直接在你的代码中 <code>import {whatever} from "./my-module.js"</code>。</li>
<li>如果有依赖项，创建一个 importmap 并 <code>import {whatever} from "my-module"</code>。
<ul>
<li>或使用 <a href="https://simonwillison.net/2023/May/2/download-esm/" rel="noopener noreferrer">download-esm</a> 来消除对 importmap 的需求。</li>
</ul>
</li>
<li>使用 <a href="https://esbuild.github.io/" rel="noopener noreferrer">esbuild</a> 或任何 ES 模块打包工具。</li>
</ul>
</li>
<li><strong>识别方式：</strong>
<ul>
<li>寻找 <code>import </code> 或 <code>export </code> 语句。（不是 <code>module.exports = ...</code>，那是 CommonJS）。</li>
<li><code>.mjs</code> 扩展名。</li>
<li>可能在 <code>package.json</code> 中有 <code>"type": "module"</code>（虽然对我来说这并不清楚具体指哪个文件）。</li>
</ul>
</li>
</ul>
</li>
<li><strong>CommonJS 模块</strong>
<ul>
<li><strong>使用方式：</strong>
<ul>
<li>使用 <a href="https://esm.sh/#docs" rel="noopener noreferrer">https://esm.sh</a> 将其转换为 ES 模块，例如 <code>https://esm.sh/@atproto/oauth-client-browser@0.3.0</code>。</li>
<li>以某种方式使用构建（？？）</li>
</ul>
</li>
<li><strong>识别方式：</strong>
<ul>
<li>在代码中寻找 <code>require()</code> 或 <code>module.exports = ...</code>。</li>
<li><code>.cjs</code> 扩展名。</li>
<li>可能在 <code>package.json</code> 中有 <code>"type": "commonjs"</code>（虽然对我来说这并不清楚具体指哪个文件）。</li>
</ul>
</li>
</ul>
</li>
</ol>
<h3 id="it-s-really-nice-to-have-es-modules-standardized">ES 模块标准化真是太好了</h3>
<p>从我的角度来看，CommonJS 模块和 ES 模块之间的主要区别是 ES 模块实际上是一个标准。这让我感觉更有信心使用它们，因为浏览器承诺为 Web 标准永远保持向后兼容性——如果我今天使用 ES 模块编写一些代码，我可以确信 15 年后它仍然会以同样的方式工作。</p>
<p>这也让我对使用像 <code>esbuild</code> 这样的工具感觉更好，因为即使 esbuild 项目消亡了，因为它是在实现一个标准，感觉未来可能会有另一个类似的工具我可以用来替换它。</p>
<h3 id="the-js-community-has-built-a-lot-of-very-cool-tools">JS 社区已经构建了许多非常酷的工具</h3>
<p>很多时候，当我谈论这些东西时，我会得到像"我讨厌 JavaScript！！！它是最差的！！！"这样的回应。但我的经验是，有很多很棒的 JavaScript 工具（我昨天才了解到 <a href="https://esm.sh" rel="noopener noreferrer">https://esm.sh</a>，它看起来很棒！我喜欢 esbuild！），而且如果我花时间了解事物如何运作，我可以利用其中一些工具，让我的生活轻松很多。</p>
<p>所以这篇文章的目的肯定不是抱怨 JavaScript，而是理解这个领域，以便我能以一种让我感觉良好的方式使用工具。</p>
<h3 id="questions-i-still-have">我仍然有的问题</h3>
<p>以下是我仍然有的一些问题，如果我找到答案，我会将答案添加到文章中。</p>
<ul>
<li>是否有工具可以自动为我本地设置的 ES 模块生成 importmaps？（显然有：<a href="https://jspm.org/getting-started" rel="noopener noreferrer">jspm</a>）</li>
<li>我如何在我的电脑上将 CommonJS 模块转换为 ES 模块，就像 <a href="https://esm.sh" rel="noopener noreferrer">https://esm.sh</a> 做的那样？（显然 esbuild 可以某种程度上做到这一点，尽管 <a href="https://github.com/evanw/esbuild/issues/442" rel="noopener noreferrer">命名导出不起作用</a>）</li>
<li>当人们通常将 CommonJS 模块构建为常规 JS 代码时，是什么代码在做那个？显然有像 webpack、rollup、esbuild 这样的工具，但这些工具都实现自己的 JS 解析器/静态分析吗？那里有多少 JS 解析器？</li>
<li>有没有办法将 ES 模块打包成一个文件（比如 <code>atcute-client.js</code>），但在浏览器中我仍然可以从该文件导入多个不同的路径（比如同时导入 <code>@atcute/client/lexicons</code> 和 <code>@atcute/client</code>）？</li>
</ul>
<h3 id="all-the-tools">所有工具</h3>
<p>以下是我们在这篇文章中讨论的所有工具的列表：</p>
<ul>
<li>Simon Willison 的 <a href="https://simonwillison.net/2023/May/2/download-esm/" rel="noopener noreferrer">download-esm</a>，它将下载一个 ES 模块并将导入转换为指向 JS 文件，这样你就不需要 importmap。</li>
<li><a href="esm.sh" rel="noopener noreferrer">https://esm.sh/</a> 和 <a href="https://www.skypack.dev/" rel="noopener noreferrer">skypack.dev</a></li>
<li><a href="https://esbuild.github.io/" rel="noopener noreferrer">esbuild</a></li>
<li><a href="https://jspm.org/getting-started" rel="noopener noreferrer">JSPM</a> 可以生成 importmaps。</li>
</ul>
<p>写这篇文章让我想到，尽管我通常不想每次更新项目都运行一个构建，但我可能愿意有一个构建步骤（使用 <code>download-esm</code> 或类似的东西），只在设置项目时运行<strong>一次</strong>，除了可能在我更新依赖版本时再也不运行。</p>
<h3 id="that-s-all">就是这样！</h3>
<p>感谢 <a href="https://polotek.net/" rel="noopener noreferrer">Marco Rogers</a> 教会了我这篇文章中的许多东西。我可能在这篇文章中犯了一些错误，我很想知道它们是什么——在 Bluesky 或 Mastodon 上告诉我！</p><p><em>由 mimo-v2.5 模型翻译，花费 13951 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2024/11/18/how-to-import-a-javascript-library/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2024/11/18/how-to-import-a-javascript-library/</guid>
      <pubDate>Mon, 18 Nov 2024 09:35:42 +0000</pubDate>
    </item>
    <item>
      <title>新推出的TIL微博客</title>
      <description>[AI 摘要] 作者开设了“今日所学”微博客板块，专门保存不便单独成文的实用工具与知识片段。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 作者开设了“今日所学”微博客板块，专门保存不便单独成文的实用工具与知识片段。</div><p>几周前，我在网站上新增了一个名为<a href="https://jvns.ca/til/" rel="noopener noreferrer">TIL</a>（"今日所学"）的板块。</p>
<h3 id="the-goal-save-interesting-tools-facts-i-posted-on-social-media">目标：保存我在社交媒体上分享的有趣工具与知识</h3>
<p>我喜欢在Mastodon/Bluesky上发布类似"嘿，这个东西很酷"的内容，例如<a href="https://github.com/dbcli/litecli" rel="noopener noreferrer">出色的SQLite交互工具litecli</a>，或是Go语言交叉编译直接就能用这个惊人特性，又或是<a href="https://www.latacora.com/blog/2018/04/03/cryptographic-right-answers/" rel="noopener noreferrer">加密技术的正确答案</a>，以及<a href="https://diffdiff.net/" rel="noopener noreferrer">这款优秀的差异比较工具</a>。通常我不愿为此类内容撰写完整博文，因为我除了"这东西很有用"外确实说不出更多。</p>
<p>最近我发现没有地方存放这些内容很困扰——比如前些天想使用<a href="https://diffdiff.net/" rel="noopener noreferrer">diffdiff</a>时，完全想不起它的名字。</p>
<h3 id="the-solution-make-a-new-section-of-this-blog">解决方案：为博客新增板块</h3>
<p>于是我快速创建了<a href="https://jvns.ca/til/" rel="noopener noreferrer">/til/</a>目录，添加了自定义样式（让文章看起来更像推文），制作了快速创建新文章的Rake任务（<code>rake new_til</code>），并设置了独立的RSS订阅源。</p>
<p>这个板块或许更像为自己而建：现在若忘记"加密技术正确答案"的链接，我希望能通过TIL页面查找。（你可能想问"朱莉娅，为何不用书签？"但使用书签这事我半生都未能坚持，也不指望未来会改变——不知为何，公开发布内容对我来说更容易坚持）</p>
<p>目前效果不错，通常我能在两分钟内快速发布内容，这正是我的初衷。</p>
<h3 id="inspired-by-simon-willison-s-til-blog">灵感源自Simon Willison的TIL博客</h3>
<p>我的页面受<a href="https://til.simonwillison.net/" rel="noopener noreferrer">Simon Willison优秀的TIL博客</a>启发，不过我的文章篇幅更短。</p>
<h3 id="i-don-t-necessarily-wheat-everything-to-be-archived">并非所有内容都需要归档</h3>
<p>这个想法的产生源于我曾在Twitter上投入大量时间，因此一直在思考如何处理自己的推文。</p>
<p>我常看到"POSSE"（先发布在自有平台，再同步到其他渠道）的建议。虽然这个理念在原则上吸引我，但社交媒体的部分魅力恰恰在于它的临时性。我可以发布投票、提问、观察或笑话，它们会随着时间推移自然淡去。</p>
<p>我更倾向于明确哪些类别内容值得保存在"我的专属网站"上：</p>
<ul>
<li>此处发布的博文！</li>
<li>漫画发布于<a href="https://wizardzines.com/comics/" rel="noopener noreferrer">https://wizardzines.com/comics/</a>！</li>
<li>现在TIL发布于<a href="https://jvns.ca/til/" rel="noopener noreferrer">https://jvns.ca/til/</a>）</li>
</ul>
<p>其余内容则任其自然留存。</p>
<p>但我确实相信建立邮件列表的价值——博文与漫画都设有邮件列表和RSS订阅源供读者订阅。或许我也会将每周TIL文章摘要加入"本周博文"邮件列表。</p><p><em>由 mimo-v2.5 模型翻译，花费 2212 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2024/11/09/new-microblog/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2024/11/09/new-microblog/</guid>
      <pubDate>Sat, 9 Nov 2024 09:24:29 +0000</pubDate>
    </item>
    <item>
      <title>我终端中的ASCII控制字符</title>
      <description>[AI 摘要] 文章介绍了终端中ASCII控制字符的功能、限制和历史背景。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 文章介绍了终端中ASCII控制字符的功能、限制和历史背景。</div><p>你好！我最近一直在思考终端，昨天对所有这些“控制码”，比如 <code>Ctrl-A</code>、<code>Ctrl-C</code>、<code>Ctrl-W</code> 等，产生了好奇。它们到底是什么情况？</p>
<h3 id="a-table-of-ascii-control-characters">ASCII控制字符表</h3>
<p>这里有一个包含所有33个ASCII控制字符的表格，以及它们在我的机器上（运行Mac OS）大致的功能。虽然有很多注意事项，但我会解释它的含义以及我所知道的这个图表的所有问题。</p>
<p><a href="https://jvns.ca/ascii.html" rel="noopener noreferrer"><img src="https://jvns.ca/images/ascii-control.png"></a></p>
<p>你也可以以<a href="https://jvns.ca/ascii.html" rel="noopener noreferrer">HTML页面形式</a>查看（我把它做成图片只是为了在RSS中显示）。</p>
<h3 id="different-kinds-of-codes-are-mixed-together">不同类型的码混杂在一起</h3>
<p>关于这个图表，让我感到惊讶的第一件事是，有33个控制码，大致可以分为以下几类：</p>
<ol>
<li>由操作系统终端驱动程序处理的码，例如当操作系统看到 <code>3</code>（<code>Ctrl-C</code>）时，它会向当前程序发送 <code>SIGINT</code> 信号</li>
<li>其他所有码都原样传递给应用程序，应用程序可以随心所欲地处理它们。其中一些子类别：
<ul>
<li>对应键盘上实际按键的码（<code>Enter</code>、<code>Tab</code>、<code>Backspace</code>）。例如，当你按下 <code>Enter</code> 时，你的终端会接收到 <code>13</code>。</li>
<li><code>readline</code> 使用的码：“应用程序可以随心所欲”通常意味着“它会或多或少地执行 <code>readline</code> 库的功能，无论应用程序是否实际使用 <code>readline</code>”，因此我标记了 <code>readline</code> 使用的一些码</li>
<li>其他码，例如我认为 <code>Ctrl-X</code> 在终端中通常没有标准含义，但 emacs 大量使用它</li>
</ul>
</li>
</ol>
<p>哪些码属于哪个类别并没有真正的结构，它们只是随机分布的，因为这是有机演变而来的。</p>
<p>（如果你对 readline 感兴趣，我在 <a href="https://jvns.ca/blog/2024/07/08/readline/" rel="noopener noreferrer">在终端中输入文本很复杂</a> 中写了更多关于 readline 的内容，而且网上有很多 <a href="https://github.com/chzyer/readline/blob/master/doc/shortcut.md" rel="noopener noreferrer">速查表</a>）</p>
<h3 id="there-are-only-33-control-codes">只有33个控制码</h3>
<p>另一件让我有点惊讶的事情是，只有33个控制码——从A到Z，再加7个（<code>@, [, \, ], ^, _, ?</code>）。这意味着，如果你想在终端应用程序中使用 <code>Ctrl-1</code> 作为键盘快捷键，那其实没什么意义——至少在我的机器上，<code>Ctrl-1</code> 和直接按 <code>1</code> 完全一样，<code>Ctrl-3</code> 和 <code>Ctrl-[</code> 一样，等等。</p>
<p>另外，<code>Ctrl+Shift+C</code> 不是控制码——它的功能取决于你的终端模拟器。在Linux上，<code>Ctrl-Shift-X</code> 通常由终端模拟器用于复制、打开新标签页或粘贴等，它根本不会发送到TTY。</p>
<p>另外，我经常使用 <code>Ctrl+左箭头</code>，但这不是控制码，而是发送一个ANSI转义序列（<code>ctrl-[[1;5D</code>），这是另一回事，在本文中我们绝对没有篇幅讨论。</p>
<p>这种“只有33个码”的情况与图形用户界面中键盘快捷键的工作方式完全不同，在GUI中你可以为任何键设置 <code>Ctrl+KEY</code>。</p>
<h3 id="the-official-ascii-names-aren-t-very-meaningful-to-me">官方的ASCII名称对我没什么意义</h3>
<p>这33个控制码中的每一个在ASCII中都有一个名称（例如 <code>3</code> 是 <code>ETX</code>）。当所有这些控制码最初被定义时，它们根本不是用于计算机或终端，而是用于 <a href="https://falsedoor.com/doc/ascii_evolution-of-character-codes.pdf" rel="noopener noreferrer">电报机</a>。电报机与UNIX终端不同，因此许多码被重新赋予了其他含义。</p>
<p>就我个人而言，我觉得这些ASCII名称没什么用，因为50%的情况下，ASCII中的名称与该码在当今UNIX系统上的实际功能无关。所以，完全忽略ASCII名称似乎更容易，而不是试图弄清楚哪些仍然匹配其原始含义。</p>
<h3 id="it-s-hard-to-use-ctrl-m-as-a-keyboard-shortcut">很难将Ctrl-M用作键盘快捷键</h3>
<p>另一件有点奇怪的事情是，<code>Ctrl-M</code> 字面上与 <code>Enter</code> 相同，<code>Ctrl-I</code> 与 <code>Tab</code> 相同，这使得这两个键很难用作键盘快捷键。</p>
<p>通过一些快速研究，似乎有些人确实仍然使用 <code>Ctrl-I</code> 和 <code>Ctrl-M</code> 作为键盘快捷键（<a href="https://github.com/tmux/tmux/issues/2705" rel="noopener noreferrer">这里有一个例子</a>），但要做到这一点，你需要配置你的终端模拟器以不同的方式处理它们。</p>
<p>对我来说，主要收获是如果我编写终端应用程序，我应该避免使用 <code>Ctrl-I</code> 和 <code>Ctrl-M</code> 作为键盘快捷键。</p>
<h3 id="how-to-identify-what-control-codes-get-sent">如何识别发送了哪些控制码</h3>
<p>在写这篇文章时，我需要进行大量实验以弄清楚各种组合键的功能，因此我编写了这个Python脚本 <a href="https://gist.github.com/jvns/a2ea09dbfbe03cc75b7bfb381941c742" rel="noopener noreferrer">echo-key.py</a> 来打印它们。</p>
<p>可能有更官方的方式，但我很感激有一个可以自定义的脚本。</p>
<h3 id="caveat-on-canonical-vs-noncanonical-mode">注意事项：关于规范模式与非规范模式</h3>
<p>其中两个码（<code>Ctrl-W</code> 和 <code>Ctrl-U</code>）在表格中标记为“由操作系统处理”，但实际上它们并非总是由操作系统处理，这取决于终端是处于“规范”模式还是“非规范模式”。</p>
<p>在 <a href="https://www.man7.org/linux/man-pages/man3/termios.3.html" rel="noopener noreferrer">规范模式</a>下，程序只有在按下 <code>Enter</code> 时才接收输入（操作系统负责在按下 <code>Backspace</code> 或 <code>Ctrl-W</code> 时删除字符）。但在非规范模式下，程序在按下键时立即接收输入，<code>Ctrl-W</code> 和 <code>Ctrl-U</code> 码被传递给程序，由程序自行处理。</p>
<p>通常在非规范模式下，程序会以与操作系统类似的方式处理 <code>Ctrl-W</code> 和 <code>Ctrl-U</code>，但有一些小差异。</p>
<p>使用规范模式的程序示例：</p>
<ul>
<li>可能几乎所有非交互式程序，如 <code>grep</code> 或 <code>cat</code></li>
<li><code>git</code>，我认为</li>
</ul>
<p>使用非规范模式的程序示例：</p>
<ul>
<li><code>python3</code>、<code>irb</code> 和其他REPL</li>
<li>你的shell</li>
<li>任何全屏TUI，如 <code>less</code> 或 <code>vim</code></li>
</ul>
<h3 id="caveat-all-of-the-os-terminal-driver-codes-are-configurable-with-stty">注意事项：所有“操作系统终端驱动程序”码都可以通过 <code>stty</code> 配置</h3>
<p>我说过 <code>Ctrl-C</code> 发送 <code>SIGINT</code>，但从技术上讲这不一定正确，如果你真的想，你可以使用一个叫做 <code>stty</code> 的工具重新映射所有标记为“操作系统终端驱动程序”的码，加上Backspace，并且你可以用 <code>stty -a</code> 查看映射。</p>
<pre><code>$ stty -a
cchars: discard = ^O; dsusp = ^Y; eof = ^D; eol = ;
	eol2 = ; erase = ^?; intr = ^C; kill = ^U; lnext = ^V;
	min = 1; quit = ^\; reprint = ^R; start = ^Q; status = ^T;
	stop = ^S; susp = ^Z; time = 0; werase = ^W;
</code></pre>
<p>我个人从未重新映射过这些码，也无法想象我会这样做的理由（我认为这对我来说将是混乱和灾难的根源），但我在 <a href="TODO" rel="noopener noreferrer">Mastodon上询问</a> 后，人们说他们使用 <code>stty</code> 最常见的原因是：</p>
<ul>
<li>使用 <code>stty sane</code> 修复损坏的终端</li>
<li>设置 <code>stty erase ^H</code> 以更改Backspace的工作方式</li>
<li>设置 <code>stty ixoff</code></li>
<li>有些人甚至将 <code>SIGINT</code> 映射到其他键，比如他们的 <code>DELETE</code> 键</li>
</ul>
<h3 id="caveat-on-signals">注意事项：关于信号</h3>
<p>两个信号注意事项：</p>
<ol>
<li>如果 <code>ISIG</code> 终端模式被关闭，那么操作系统就不会发送信号。例如 <code>vim</code> 关闭了 <code>ISIG</code></li>
<li>显然在BSD系统上，有一个额外的控制码（<code>Ctrl-T</code>）发送 <code>SIGINFO</code></li>
</ol>
<p>你可以使用 <code>strace</code> 查看程序设置的终端模式，像这样，终端模式通过 <code>ioctl</code> 系统调用设置：</p>
<pre><code>$ strace -tt -o out  vim
$ grep ioctl out | grep SET
</code></pre>
<p>以下是 <code>vim</code> 启动时设置的模式（缺少 <code>ISIG</code> 和 <code>ICANON</code>！）：</p>
<pre><code>17:43:36.670636 ioctl(0, TCSETS, {c_iflag=IXANY|IMAXBEL|IUTF8,
c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST, c_cflag=B38400|CS8|CREAD,
c_lflag=ECHOK|ECHOCTL|ECHOKE|PENDIN, ...}) = 0
</code></pre>
<p>并且在退出时重置模式：</p>
<pre><code>17:43:38.027284 ioctl(0, TCSETS, {c_iflag=ICRNL|IXANY|IMAXBEL|IUTF8,
c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD,
c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE|PENDIN, ...}) = 0
</code></pre>
<p>我认为vim在这里使用的特定模式组合可能被称为“原始模式”，<a href="https://linux.die.net/man/3/cfmakeraw" rel="noopener noreferrer">man cfmakeraw</a> 谈到了这一点。</p>
<h3 id="there-are-a-lot-of-conflicts">存在很多冲突</h3>
<p>与“只有33个码”相关的是，存在很多冲突，系统的不同部分想要将相同的码用于不同的事情，例如默认情况下 <code>Ctrl-S</code> 会冻结你的屏幕，但如果你关闭它，<code>readline</code> 将使用 <code>Ctrl-S</code> 进行向前搜索。</p>
<p>另一个例子是，在我的机器上，有时 <code>Ctrl-T</code> 会发送 <code>SIGINFO</code>，有时会转置两个字符，有时会做完全不同的事情，取决于：</p>
<ul>
<li>程序是否设置了 <code>ISIG</code></li>
<li>程序是否使用 <code>readline</code> / 模仿readline的行为</li>
</ul>
<h3 id="caveat-on-backspace-and-other-backspace">注意事项：关于“退格键”和“其他退格键”</h3>
<p>在这个图表中，我将码127标记为“退格键”，码8标记为“其他退格键”。呃，什么？</p>
<p>我认为这是Mastodon回复中最大的讨论话题——显然这背后有很多历史，而我以前从未听说过。</p>
<p>首先，这是在我的机器上的工作方式：</p>
<ol>
<li>我按下 <code>Backspace</code> 键</li>
<li>TTY接收到字节 <code>127</code>，在ASCII中称为 <code>DEL</code></li>
<li>操作系统终端驱动程序和readline都将 <code>127</code> 映射到“退格键”（因此它在规范模式和非规范模式下都有效）</li>
<li>前一个字符被删除</li>
</ol>
<p>如果我按下 <code>Ctrl+H</code>，在使用readline时，它与 <code>Backspace</code> 效果相同，但在没有readline支持的程序中（例如 <code>cat</code>），它只是打印出 <code>^H</code>。</p>
<p>显然，上面的步骤2对某些人来说是不同的——他们的 <code>Backspace</code> 键发送字节 <code>8</code> 而不是 <code>127</code>，因此如果他们想让Backspace工作，他们需要配置操作系统（使用 <code>stty</code>）来设置 <code>erase = ^H</code>。</p>
<p>Debian策略手册中有一个关于键盘配置的 <a href="https://www.debian.org/doc/debian-policy/ch-opersys.html#keyboard-configuration" rel="noopener noreferrer">惊人部分</a>，描述了根据Debian策略，<code>Delete</code> 和 <code>Backspace</code> 应该如何工作，这似乎与我今天在Mac上的工作方式非常相似。我的理解（通过 <a href="https://tech.lgbt/@Diziet/113396035847619715" rel="noopener noreferrer">这篇Mastodon帖子</a>）是，这个策略是在90年代编写的，因为在90年代关于 <code>Backspace</code> 应该做什么有很多混淆，需要一个标准来使一切正常工作。</p>
<p>这里还有更多历史终端内容，但就目前而言，这就是我要说的。</p>
<h3 id="there-s-probably-a-lot-more-diversity-in-how-this-works">可能在工作方式上还有更多多样性</h3>
<p>我可能错过了更多“在我的机器上的工作方式”可能与其他人机器上工作方式不同的地方，而且我也可能在关于我的机器如何工作方面犯了一些错误。但这就是我今天要说的全部。</p>
<p>我还知道一些被遗漏的内容：根据 <code>stty -a</code>，<code>Ctrl-O</code> 是“丢弃”，<code>Ctrl-R</code> 是“重印”，<code>Ctrl-Y</code> 是“延迟挂起”。我不知道如何让这些真正做任何事情（按下它们没有明显的动作，有些人告诉我它们历史上曾经做过什么，但我不清楚它们在2024年是否有用），而且很多时候实践中它们似乎只是被传递给应用程序，所以我只标记 <code>Ctrl-R</code> 和 <code>Ctrl-Y</code> 为 <code>readline</code>。</p>
<h3 id="not-all-of-this-is-that-useful-to-know">并非所有这些都有用</h3>
<p>另外，我想说我认为这篇文章的内容有点有趣，但我不认为它们一定有用。在过去的20年里，我每天都在成功地使用终端，而不需要知道这些——我只是知道 <code>Ctrl-C</code>、<code>Ctrl-D</code>、<code>Ctrl-Z</code>、<code>Ctrl-R</code>、<code>Ctrl-L</code> 在实践中的功能（也许加上 <code>Ctrl-A</code>、<code>Ctrl-E</code> 和 <code>Ctrl-W</code>），并且大部分时间都不担心细节，这几乎总是完全没问题，除了当我 <a href="https://jvns.ca/blog/2022/07/20/pseudoterminals/" rel="noopener noreferrer">尝试使用xterm.js</a> 时。</p>
<p>但我学习它很有趣，所以也许它对你也很有趣。</p><p><em>由 mimo-v2.5 模型翻译，花费 17583 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2024/10/31/ascii-control-characters/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2024/10/31/ascii-control-characters/</guid>
      <pubDate>Thu, 31 Oct 2024 08:00:10 +0000</pubDate>
    </item>
    <item>
      <title>在《Mess With DNS》中用更少内存查找IP地址</title>
      <description>[AI 摘要] 作者通过优化内存中的IP地址数据库，成功将Mess With DNS服务的内存占用减少了70MB。</description>
      <content:encoded><![CDATA[<div style="background:#f0f4f8;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;margin:12px 0;font-size:14px;color:#555"><strong>[AI 摘要]</strong> 作者通过优化内存中的IP地址数据库，成功将Mess With DNS服务的内存占用减少了70MB。</div><p>过去三年左右，我遇到了一个问题：<a href="https://messwithdns.net/" rel="noopener noreferrer">Mess With DNS</a> 定期因内存不足而被OOM终止。</p>
<p>这对我而言并非紧急事务：通常它只会宕机几分钟然后重启，且最多每天发生一次，所以我一直未予理会。但上周它开始真正造成问题，因此我决定深入研究。</p>
<p>这是一段曲折的探索过程，我学到了很多，以下是目录：</p>
<ul>
<li><a href="#there-s-about-100mb-of-memory-available" rel="noopener noreferrer">可用内存约100MB</a></li>
<li><a href="#the-problem-oom-killing-the-backup-script" rel="noopener noreferrer">问题：OOM终止备份脚本</a></li>
<li><a href="#attempt-1-use-sqlite" rel="noopener noreferrer">尝试一：使用SQLite</a>
<ul>
<li><a href="#problem-how-to-store-ipv6-addresses" rel="noopener noreferrer">问题：如何存储IPv6地址</a></li>
<li><a href="#problem-it-s-500x-slower" rel="noopener noreferrer">问题：速度慢了500倍</a></li>
<li><a href="#time-for-explain-query-plan" rel="noopener noreferrer">分析EXPLAIN QUERY PLAN的时刻</a></li>
</ul>
</li>
<li><a href="#attempt-2-use-a-trie" rel="noopener noreferrer">尝试二：使用字典树</a>
<ul>
<li><a href="#some-notes-on-memory-profiling" rel="noopener noreferrer">关于内存分析的几点说明</a></li>
</ul>
</li>
<li><a href="#attempt-3-make-my-array-use-less-memory" rel="noopener noreferrer">尝试三：让我的数组占用更少内存</a>
<ul>
<li><a href="#idea-3-1-deduplicate-the-name-and-country" rel="noopener noreferrer">想法3.1：去重Name和Country字段</a></li>
<li><a href="#how-big-are-asns" rel="noopener noreferrer">ASN有多大？</a></li>
<li><a href="#idea-3-2-use-netip-addr-instead-of-net-ip" rel="noopener noreferrer">想法3.2：使用netip.Addr替代net.IP</a></li>
<li><a href="#the-result-saved-70mb-of-memory" rel="noopener noreferrer">结果：节省了70MB内存！</a></li>
</ul>
</li>
</ul>
<h3 id="there-s-about-100mb-of-memory-available">可用内存约100MB</h3>
<p>我在一个内存约465MB的虚拟机上运行Mess With DNS。根据<code>ps aux</code>命令（<code>RSS</code>列）显示，内存分配大致如下：</p>
<ul>
<li>PowerDNS占用100MB</li>
<li>Mess With DNS占用200MB</li>
<li><a href="https://fly.io/blog/ssh-and-user-mode-ip-wireguard/" rel="noopener noreferrer">hallpass</a>占用40MB</li>
</ul>
<p>剩余约110MB可用内存。</p>
<p>前一阵我设置了<a href="https://tip.golang.org/doc/gc-guide" rel="noopener noreferrer">GOMEMLIMIT</a>为250MB，试图确保当Mess With DNS使用超过250MB内存时垃圾回收器会运行。我认为这有所帮助，但并未完全解决问题。</p>
<h3 id="the-problem-oom-killing-the-backup-script">问题：OOM终止备份脚本</h3>
<p>几周前，我首次开始使用<a href="https://jvns.ca/til/restic-for-backing-up-sqlite-dbs/" rel="noopener noreferrer">restic备份</a>Mess With DNS的数据库。</p>
<p>这运行得还算顺利，但由于Mess With DNS在没有太多额外内存的情况下运行，我想<code>restic</code>有时需要比系统可用更多的内存，因此备份脚本有时被OOM终止。</p>
<p>这是个问题，因为</p>
<ol>
<li>备份有时可能损坏</li>
<li>更重要的是，restic运行时会获取锁，因此如果我想让备份继续工作，必须手动解锁。像这样的手动操作是我尝试在网络服务中极力避免的（谁有时间做这些！），所以我真的想解决这个问题。</li>
</ol>
<p>可能有不止一种解决方案，但我决定尝试让Mess With DNS使用更少内存，从而在系统上留下更多可用内存，主要因为这似乎是个有趣的问题。</p>
<h3 id="what-s-using-memory-ip-addresses">占用内存的是什么：IP地址</h3>
<p>过去我多次运行过Mess With DNS的内存分析，所以我清楚知道是什么占用了Mess With DNS大部分内存：IP地址。</p>
<p>启动时，Mess With DNS将<a href="https://iptoasn.com/" rel="noopener noreferrer">这个可以查询每个IP地址所属ASN的数据库</a>加载到内存中，以便当它收到DNS查询时，可以获取源IP地址（如<code>74.125.16.248</code>）并告诉你该IP属于<code>GOOGLE</code>。</p>
<p>这个数据库本身使用了约117MB内存，而简单的<code>du</code>命令告诉我这太多了——原始文本文件只有37MB！</p>
<pre><code>$ du -sh *.tsv
26M	ip2asn-v4.tsv
11M	ip2asn-v6.tsv
</code></pre>
<p>它最初的工作方式是我有这样一个结构体数组：</p>
<pre><code>type IPRange struct {
	StartIP net.IP
	EndIP   net.IP
	Num     int
	Name    string
	Country string
}
</code></pre>
<p>我通过二分查找来搜索它，判断是否有任何范围包含我查找的IP。基本上是最简单的实现方式，速度极快，我的机器每秒可以执行约900万次查找。</p>
<h3 id="attempt-1-use-sqlite">尝试一：使用SQLite</h3>
<p>最近我一直在使用SQLite，所以我的第一个想法是——也许我可以将所有这些数据存储在磁盘上的SQLite数据库中，为表添加索引，这样就能使用更少内存。</p>
<p>于是我：</p>
<ul>
<li>编写了一个快速的Python脚本，使用<a href="https://sqlite-utils.datasette.io/en/stable/" rel="noopener noreferrer">sqlite-utils</a>将TSV文件导入SQLite数据库</li>
<li>调整我的代码以从数据库中选择数据</li>
</ul>
<p>这确实解决了初始的内存目标（经过垃圾回收后它几乎不使用内存了，因为表在磁盘上！），尽管我不确定如果需要同时进行大量查询，这个解决方案会造成多少垃圾回收开销。我快速做了内存分析，发现每次查找大约分配1KB内存。</p>
<p>让我谈谈使用SQLite时遇到的问题。</p>
<h3 id="problem-how-to-store-ipv6-addresses">问题：如何存储IPv6地址</h3>
<p>SQLite不支持大整数，而IPv6地址是128位的，所以我决定将它们存储为文本。我认为<code>BLOB</code>可能更好，我原以为<code>BLOB</code>不能比较，但<a href="https://www.sqlite.org/datatype3.html#sort_order" rel="noopener noreferrer">sqlite文档</a>说它们可以。</p>
<p>我最终得到这个模式：</p>
<pre><code>CREATE TABLE ipv4_ranges (
   start_ip INTEGER NOT NULL,
   end_ip INTEGER NOT NULL,
   asn INTEGER NOT NULL,
   country TEXT NOT NULL,
   name TEXT NOT NULL
);
CREATE TABLE ipv6_ranges (
   start_ip TEXT NOT NULL,
   end_ip TEXT NOT NULL,
   asn INTEGER,
   country TEXT,
   name TEXT
);
CREATE INDEX idx_ipv4_ranges_start_ip ON ipv4_ranges (start_ip);
CREATE INDEX idx_ipv6_ranges_start_ip ON ipv6_ranges (start_ip);
CREATE INDEX idx_ipv4_ranges_end_ip ON ipv4_ranges (end_ip);
CREATE INDEX idx_ipv6_ranges_end_ip ON ipv6_ranges (end_ip);
</code></pre>
<p>同时我了解到Python有一个<code>ipaddress</code>模块，所以我可以使用<code>ipaddress.ip_address(s).exploded</code>来确保IPv6地址被扩展，这样字符串比较才能正确比较它们。</p>
<h3 id="problem-it-s-500x-slower">问题：速度慢了500倍</h3>
<p>我运行了一个快速的微基准测试，类似这样。它打印出每秒可以查找17,000个IPv6地址，IPv4地址也类似。</p>
<p>这相当令人沮丧——每秒查找17k地址还算可以（Mess With DNS流量不大），但与原始的二分查找代码相比，原始代码每秒可以处理900万次查找。</p>
<pre><code>	ips := []net.IP{}
	count := 20000
	for i := 0; i &lt; count; i++ {
		// create a random IPv6 address
		bytes := randomBytes()
		ip := net.IP(bytes[:])
		ips = append(ips, ip)
	}
	now := time.Now()
	success := 0
	for _, ip := range ips {
		_, err := ranges.FindASN(ip)
		if err == nil {
			success++
		}
	}
	fmt.Println(success)
	elapsed := time.Since(now)
	fmt.Println("number per second", float64(count)/elapsed.Seconds())
</code></pre>
<h3 id="time-for-explain-query-plan">分析EXPLAIN QUERY PLAN的时刻</h3>
<p>我从未真正用过sqlite的EXPLAIN，所以这看起来是个有趣的机会，看看查询计划在做什么。</p>
<pre><code>sqlite&gt; explain query plan select * from ipv6_ranges where '2607:f8b0:4006:0824:0000:0000:0000:200e' BETWEEN start_ip and end_ip;
QUERY PLAN
`--SEARCH ipv6_ranges USING INDEX idx_ipv6_ranges_end_ip (end_ip&gt;?)
</code></pre>
<p>看起来它只使用了<code>end_ip</code>索引，而没有使用<code>start_ip</code>索引，所以也许它比二分查找慢是合理的。</p>
<p>我试图找出是否有办法让SQLite同时使用两个索引，但找不到，也许它自己知道怎么做最好。</p>
<p>这时我放弃了SQLite解决方案，我不喜欢它更慢，而且它也比直接做二分查找复杂得多。我宁愿保持更接近二分查找的方式。</p>
<p>我尝试了几件无法让SQLite同时使用两个索引的事情：</p>
<ul>
<li>使用复合索引而不是两个单独的索引</li>
<li>运行<code>ANALYZE</code></li>
<li>使用<code>INTERSECT</code>来交集<code>start_ip &lt; ?</code>和<code>? &lt; end_ip</code>的结果。这确实让它使用了两个索引，但似乎也让查询字面上慢了1000倍，可能因为它需要在内存中创建两个子查询的结果并交集它们。</li>
</ul>
<h3 id="attempt-2-use-a-trie">尝试二：使用字典树</h3>
<p>我的下一个想法是使用<a href="https://medium.com/basecs/trying-to-understand-tries-3ec6bede0014" rel="noopener noreferrer">字典树</a>，因为我模糊地觉得字典树可能使用更少内存，然后我找到了一个叫做<a href="https://github.com/seancfoley/ipaddress-go" rel="noopener noreferrer">ipaddress-go</a>的库，它允许你使用字典树查找IP地址。</p>
<p>我尝试使用它，<a href="https://gist.github.com/jvns/3ce617796b22127017590ac62c57fddd" rel="noopener noreferrer">代码在这里</a>，但我觉得我做错了什么，因为与我的朴素数组+二分查找相比：</p>
<ul>
<li>它使用了更多的内存（仅存储IPv4地址就用了800MB）</li>
<li>查找速度慢得多（每秒只能处理10万次，而不是900万次）</li>
</ul>
<p>我不太确定这里出了什么问题，所以我放弃了这种方法，决定只尝试让我的数组使用更少内存，坚持使用简单的二分查找。</p>
<h3 id="some-notes-on-memory-profiling">关于内存分析的几点说明</h3>
<p>我学到的一件关于内存分析的事情是，你可以使用<code>runtime</code>包查看程序当前分配了多少内存。这就是我获得本文所有内存数字的方式。以下是代码：</p>
<pre><code>func memusage() {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&amp;m)
	fmt.Printf("Alloc = %v MiB\n", m.Alloc/1024/1024)
	// write mem.prof
	f, err := os.Create("mem.prof")
	if err != nil {
		log.Fatal(err)
	}
	pprof.WriteHeapProfile(f)
	f.Close()
}
</code></pre>
<p>我还了解到，如果你使用<code>pprof</code>分析堆文件，有两种分析方式：你可以向<code>go tool pprof</code>传递<code>--alloc-space</code>或<code>--inuse-space</code>。我不知道我以前怎么会没意识到这一点，但<code>alloc-space</code>会告诉你所有分配的内容，而<code>inuse-space</code>只包含当前正在使用的内存。</p>
<p>总之，我经常运行<code>go tool pprof -pdf --inuse_space mem.prof &gt; mem.pdf</code>。另外每次使用pprof时，我发现自己都会参考<a href="https://jvns.ca/blog/2017/09/24/profiling-go-with-pprof/" rel="noopener noreferrer">我自己的pprof入门</a>，它可能是我写过的、我自己使用最频繁的博客文章。我应该把<code>--alloc-space</code>和<code>--inuse-space</code>加进去。</p>
<h3 id="attempt-3-make-my-array-use-less-memory">尝试三：让我的数组占用更少内存</h3>
<p>我原本这样存储我的ip2asn条目：</p>
<pre><code>type IPRange struct {
	StartIP net.IP
	EndIP   net.IP
	Num     int
	Name    string
	Country string
}
</code></pre>
<p>我有三个改进想法：</p>
<ol>
<li><code>Name</code>和<code>Country</code>字段有很多重复，因为很多IP范围属于同一个ASN</li>
<li><code>net.IP</code>底层是<code>[]byte</code>，这感觉涉及一个不必要的指针，有没有办法把它内联到结构体中？</li>
<li>也许我不需要同时存储起始IP和结束IP，通常范围是连续的，所以我也许可以重新安排，只存储起始IP。</li>
</ol>
<h3 id="idea-3-1-deduplicate-the-name-and-country">想法3.1：去重Name和Country字段</h3>
<p>我想我可以将ASN信息存储在一个数组中，然后在我的<code>IPRange</code>结构体中只存储数组索引。以下是结构体，以便你理解我的意思：</p>
<pre><code>type IPRange struct {
	StartIP netip.Addr
	EndIP   netip.Addr
	ASN     uint32
	Idx     uint32
}

type ASNInfo struct {
	Country string
	Name    string
}

type ASNPool struct {
	asns   []ASNInfo
	lookup map[ASNInfo]uint32
}
</code></pre>
<p>这行得通！它将内存使用量从117MB降至65MB——节省了50MB。我对此感觉良好。</p>
<p><a href="https://github.com/jvns/mess-with-dns/blob/94f77b4bb1597b5e2a6768e33bd6c285919aa1bf/api/streamer/ip2asn/ip2asn.go#L18-L54" rel="noopener noreferrer">这是那部分的所有代码</a>。</p>
<h3 id="how-big-are-asns">ASN有多大？</h3>
<p>顺便说一下——我用<code>uint32</code>存储ASN，这正确吗？我查看了ip2asn文件，最大的似乎是401307，尽管有几行写着<code>4294901931</code>，这个数字大得多，但也在uint32的范围内。所以我绝对可以使用<code>uint32</code>。</p>
<pre><code>59.101.179.0	59.101.179.255	4294901931	Unknown	AS4294901931
</code></pre>
<h3 id="idea-3-2-use-netip-addr-instead-of-net-ip">想法3.2：使用<code>netip.Addr</code>替代<code>net.IP</code></h3>
<p>事实证明，我不是唯一觉得<code>net.IP</code>使用不必要内存的人——2021年，Tailscale团队为Go发布了一个新的IP地址库，解决了这个问题以及许多其他问题。<a href="https://tailscale.com/blog/netaddr-new-ip-type-for-go" rel="noopener noreferrer">他们为此写了一篇很棒的博客文章</a>。</p>
<p>我惊喜地发现，这个新的IP地址库不仅存在，而且完全符合我的需求，现在它还是Go标准库中的<a href="https://pkg.go.dev/net/netip#Addr" rel="noopener noreferrer">netip.Addr</a>。切换到<code>netip.Addr</code>非常容易，又节省了20MB内存，降至46MB。</p>
<p>我没有尝试我的第三个想法（从结构体中移除结束IP），因为周六早晨我已经编程了足够长的时间，我对进展感到满意。</p>
<p>当我想“嘿，我不喜欢这个，肯定有更好的方法”然后立即发现有人已经做了我想要的东西，思考得比我多得多，实现得比我好得多时，这种感觉总是很棒。</p>
<h3 id="all-of-this-was-messier-in-real-life">所有这些在现实生活中更混乱</h3>
<p>尽管我试图以简单的线性方式解释“我尝试了X，然后尝试了Y，然后尝试了Z”，但这有点说谎——我总是试图将我实际的调试过程（完全混乱）弄得看起来更线性、更易理解，因为现实太烦人以至于难以写下。更像是：</p>
<ul>
<li>尝试sqlite</li>
<li>尝试字典树</li>
<li>重新思考我对sqlite得出的所有结论，回去再次查看结果</li>
<li>等等，索引呢</li>
<li>非常非常晚地意识到我可以使用<code>runtime</code>检查所有东西使用了多少内存，开始这样做</li>
<li>再看看字典树，也许我误解了一切</li>
<li>放弃并回到二分查找</li>
<li>再次查看所有字典树/sqlite的数字，确保我没有误解</li>
</ul>
<h3 id="a-note-on-using-512mb-of-memory">关于使用512MB内存的说明</h3>
<p>有人问我为什么不直接给虚拟机更多内存。我完全可以负担得起一个1GB内存的虚拟机，但我觉得512MB确实<em>应该</em>足够（实际上256MB就应该够了），所以我宁愿保持在这个约束内。这就像一个有趣的谜题。</p>
<h3 id="a-few-ideas-from-the-replies">来自回复的一些想法</h3>
<p>大家提出了很多我没想过的好点子。记录下来，如果以后我想再有一个有趣的性能优化日的话，作为灵感。</p>
<ul>
<li>尝试Go的<a href="https://pkg.go.dev/unique" rel="noopener noreferrer">unique</a>包用于<code>ASNPool</code>。有人尝试过，它使用更多内存，可能是因为Go的指针是64位的</li>
<li>尝试用<code>GOARCH=386</code>编译以使用32位指针节省空间（也许结合使用<code>unique</code>！）</li>
<li>应该可以将所有IPv6地址仅存储为64位，因为地址中只有前64位是公开的</li>
<li><a href="https://en.m.wikipedia.org/wiki/Interpolation_search" rel="noopener noreferrer">插值搜索</a>可能比二分查找更快，因为IP地址是数字</li>
<li>尝试MaxMind数据库格式，使用<a href="https://github.com/maxmind/mmdbwriter" rel="noopener noreferrer">mmdbwriter</a>或<a href="https://github.com/ipinfo/mmdbctl" rel="noopener noreferrer">mmdbctl</a></li>
<li>Tailscale的<a href="https://github.com/tailscale/art" rel="noopener noreferrer">art</a>路由表包</li>
</ul>
<h3 id="the-result-saved-70mb-of-memory">结果：节省了70MB内存！</h3>
<p>我部署了新版本，现在Mess With DNS使用更少了内存！好耶！</p>
<p>其他几点说明：</p>
<ul>
<li>查找速度稍慢——在我的微基准测试中，从每秒900万次降至600万次，可能是因为我添加了一点间接层。使用更少内存和稍多CPU似乎是个不错的权衡。</li>
<li>它仍然比原始文本文件使用更多内存（46MB vs 37MB），我猜指针占空间，这没关系。</li>
</ul>
<p>老实说，我不确定这是否能解决我所有的内存问题，可能不能！但我很享受，学到了一些关于SQLite的知识，我仍然不知道对字典树作何感想，它让我比以往更爱二分查找了。</p><p><em>由 mimo-v2.5 模型翻译，花费 10806 tokens</em></p>]]></content:encoded>
      <link>https://jvns.ca/blog/2024/10/27/asn-ip-address-memory/</link>
      <guid isPermaLink="false">https://jvns.ca/blog/2024/10/27/asn-ip-address-memory/</guid>
      <pubDate>Sun, 27 Oct 2024 07:47:04 +0000</pubDate>
    </item>
  </channel>
</rss>
