<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>喵洛阁</title>
    <link>https://www.kemiaosw.top</link>
    <description>克喵的博客，记录前端工程、设计与日常折腾。</description>
    <language>zh-CN</language>
    <lastBuildDate>Fri, 07 Nov 2025 00:00:00 GMT</lastBuildDate>
    <generator>Nuxt Content</generator>
    <atom:link href="https://www.kemiaosw.top/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>在博客里写公式</title>
      <link>https://www.kemiaosw.top/posts/math-test</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/math-test</guid>
      <pubDate>Fri, 07 Nov 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>这篇文章既是公式写法的说明，也是排版回归的固定样本 —— 站点的公式门禁
（<code>scripts/verify-math.mjs</code>）会拿它量字号、间距、横向滚动与配色。</p><h2 id="行内公式">行内公式</h2><p>质能方程 <span><span><math><semantics><mrow><mi>E</mi><mo>=</mo><mi>m</mi><msup><mi>c</mi><mn>2</mn></msup></mrow><annotation>E = mc^2</annotation></semantics></math></span></span> 说明质量与能量等价；勾股定理写成 <span><span><math><semantics><mrow><msup><mi>a</mi><mn>2</mn></msup><mo>+</mo><msup><mi>b</mi><mn>2</mn></msup><mo>=</mo><msup><mi>c</mi><mn>2</mn></msup></mrow><annotation>a^2 + b^2 = c^2</annotation></semantics></math></span></span>。
行内公式夹在中文里时基线要对齐：这里的 <span><span><math><semantics><mrow><msub><mi>x</mi><mi>i</mi></msub></mrow><annotation>x_i</annotation></semantics></math></span></span>、<span><span><math><semantics><mrow><msubsup><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></msubsup><msub><mi>x</mi><mi>i</mi></msub></mrow><annotation>\sum_{i=1}^{n} x_i</annotation></semantics></math></span></span> 与 <span><span><math><semantics><mrow><mfrac><mi>a</mi><mi>b</mi></mfrac></mrow><annotation>\frac{a}{b}</annotation></semantics></math></span></span>
都贴着汉字，行距不该被撑开。</p><p>上下标与根式：<span><span><math><semantics><mrow><mi>f</mi><mo>(</mo><mi>x</mi><mo>)</mo><mo>=</mo><mroot><mrow><msup><mi>x</mi><mn>2</mn></msup><mo>+</mo><mn>1</mn></mrow><mn>3</mn></mroot></mrow><annotation>f(x) = \sqrt[3]{x^2 + 1}</annotation></semantics></math></span></span>，希腊字母 <span><span><math><semantics><mrow><mi>α</mi><mi>β</mi><mi>γ</mi><mi>Δ</mi><mi>Ω</mi></mrow><annotation>\alpha \beta \gamma \Delta \Omega</annotation></semantics></math></span></span>。</p><h2 id="块级公式">块级公式</h2><p>用两个美元符号包住，独立成行，水平居中：</p><span><span><span><math><semantics><mrow><munderover><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></munderover><mi>i</mi><mo>=</mo><mfrac><mrow><mi>n</mi><mo>(</mo><mi>n</mi><mo>+</mo><mn>1</mn><mo>)</mo></mrow><mn>2</mn></mfrac></mrow><annotation>\sum_{i=1}^{n} i = \frac{n(n+1)}{2}</annotation></semantics></math></span></span></span><span><span><span><math><semantics><mrow><msubsup><mo>∫</mo><mn>0</mn><mi>∞</mi></msubsup><msup><mi>e</mi><mrow><mo>−</mo><msup><mi>x</mi><mn>2</mn></msup></mrow></msup><mtext> </mtext><mi>d</mi><mi>x</mi><mo>=</mo><mfrac><msqrt><mi>π</mi></msqrt><mn>2</mn></mfrac></mrow><annotation>\int_{0}^{\infty} e^{-x^2} \, dx = \frac{\sqrt{\pi}}{2}</annotation></semantics></math></span></span></span><p>矩阵与分段函数：</p><span><span><span><math><semantics><mrow><mrow><mo>(</mo><mtable><mtr><mtd><mstyle><mi>a</mi></mstyle></mtd><mtd><mstyle><mi>b</mi></mstyle></mtd></mtr><mtr><mtd><mstyle><mi>c</mi></mstyle></mtd><mtd><mstyle><mi>d</mi></mstyle></mtd></mtr></mtable><mo>)</mo></mrow><mrow><mo>[</mo><mtable><mtr><mtd><mstyle><mi>x</mi></mstyle></mtd></mtr><mtr><mtd><mstyle><mi>y</mi></mstyle></mtd></mtr></mtable><mo>]</mo></mrow><mo>=</mo><mrow><mo>[</mo><mtable><mtr><mtd><mstyle><mrow><mi>a</mi><mi>x</mi><mo>+</mo><mi>b</mi><mi>y</mi></mrow></mstyle></mtd></mtr><mtr><mtd><mstyle><mrow><mi>c</mi><mi>x</mi><mo>+</mo><mi>d</mi><mi>y</mi></mrow></mstyle></mtd></mtr></mtable><mo>]</mo></mrow></mrow><annotation>\begin{pmatrix} a &amp; b \\ c &amp; d \end{pmatrix}
\begin{bmatrix} x \\ y \end{bmatrix}
= \begin{bmatrix} ax + by \\ cx + dy \end{bmatrix}</annotation></semantics></math></span></span></span><span><span><span><math><semantics><mrow><mi>f</mi><mo>(</mo><mi>x</mi><mo>)</mo><mo>=</mo><mrow><mo>{</mo><mtable><mtr><mtd><mstyle><msup><mi>x</mi><mn>2</mn></msup></mstyle></mtd><mtd><mstyle><mrow><mi>x</mi><mo>≥</mo><mn>0</mn></mrow></mstyle></mtd></mtr><mtr><mtd><mstyle><mrow><mo>−</mo><mi>x</mi></mrow></mstyle></mtd><mtd><mstyle><mrow><mi>x</mi><mo>&lt;</mo><mn>0</mn></mrow></mstyle></mtd></mtr></mtable></mrow></mrow><annotation>f(x) = \begin{cases}
  x^2 &amp; x \ge 0 \\
  -x  &amp; x &lt; 0
\end{cases}</annotation></semantics></math></span></span></span><h2 id="另一种块级写法">另一种块级写法</h2><p>也可以用 <code>math</code> 围栏，效果与美元符号一致：</p><span><span><math><semantics><mrow><mi>∇</mi><mo>⋅</mo><mi>E</mi><mo>=</mo><mfrac><mi>ρ</mi><msub><mi>ε</mi><mn>0</mn></msub></mfrac></mrow><annotation>\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}
</annotation></semantics></math></span></span><h2 id="公式里的中文">公式里的中文</h2><p>用 <code>\text{}</code> 包住中文，不必退回正文写法：</p><span><span><span><math><semantics><mrow><mtext>课程绩点</mtext><mo>=</mo><mfrac><mtext>课程分数</mtext><mn>10</mn></mfrac><mo>−</mo><mn>5</mn></mrow><annotation>\text{课程绩点} = \frac{\text{课程分数}}{10} - 5</annotation></semantics></math></span></span></span><span><span><span><math><semantics><mrow><mi>P</mi><mo>(</mo><mi>A</mi><mo>∣</mo><mi>B</mi><mo>)</mo><mo>=</mo><mfrac><mrow><mi>P</mi><mo>(</mo><mi>B</mi><mo>∣</mo><mi>A</mi><mo>)</mo><mtext> </mtext><mi>P</mi><mo>(</mo><mi>A</mi><mo>)</mo></mrow><mrow><mi>P</mi><mo>(</mo><mi>B</mi><mo>)</mo></mrow></mfrac></mrow><annotation>P(A \mid B) = \frac{P(B \mid A) \, P(A)}{P(B)}</annotation></semantics></math></span></span></span><h2 id="长公式">长公式</h2><p>超宽的公式在窄屏上局部横向滚动，不撑破页面：</p><span><span><span><math><semantics><mrow><mfrac><mi>∂</mi><mrow><mi>∂</mi><mi>t</mi></mrow></mfrac><msub><mo>∭</mo><mi>Ω</mi></msub><mi>ρ</mi><mtext> </mtext><mi>d</mi><mi>V</mi><mo>+</mo><msub><mo>∬</mo><mrow><mi>∂</mi><mi>Ω</mi></mrow></msub><mi>ρ</mi><mi>v</mi><mo>⋅</mo><mi>d</mi><mi>S</mi><mo>=</mo><munderover><mo>∑</mo><mrow><mi>k</mi><mo>=</mo><mn>1</mn></mrow><mi>m</mi></munderover><mfrac><mrow><msub><mi>α</mi><mi>k</mi></msub><msub><mi>β</mi><mi>k</mi></msub><msub><mi>γ</mi><mi>k</mi></msub></mrow><mrow><msub><mi>δ</mi><mi>k</mi></msub><mo>+</mo><msub><mi>ε</mi><mi>k</mi></msub></mrow></mfrac><mo>−</mo><munderover><mo>∏</mo><mrow><mi>j</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></munderover><mrow><mo>(</mo><mn>1</mn><mo>−</mo><mfrac><msub><mi>λ</mi><mi>j</mi></msub><msub><mi>μ</mi><mi>j</mi></msub></mfrac><mo>)</mo></mrow></mrow><annotation>\frac{\partial}{\partial t} \iiint_{\Omega} \rho \, dV + \iint_{\partial \Omega} \rho \mathbf{v} \cdot d\mathbf{S} = \sum_{k=1}^{m} \frac{\alpha_k \beta_k \gamma_k}{\delta_k + \varepsilon_k} - \prod_{j=1}^{n} \left( 1 - \frac{\lambda_j}{\mu_j} \right)</annotation></semantics></math></span></span></span><h2 id="转义与写错">转义与写错</h2><p>正文里要出现美元符号本身，用反斜杠转义：$100 与 $200 都是普通文本。</p><p>语法写错时只会标红那一处，不会让整页渲染失败：</p><span title="ParseError: KaTeX parse error: Unexpected end of input in a macro argument, expected &#39;}&#39; at end of input: \frac{1}{">\frac{1}{</span>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>排版比配色更决定气质</title>
      <link>https://www.kemiaosw.top/posts/typography-over-color</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/typography-over-color</guid>
      <pubDate>Tue, 28 Oct 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>做前端这些年，我逐渐发现一个规律：<strong>换一套配色，页面会变好看；改一遍排版，页面会变成另一个东西。</strong></p><p>配色是外衣，排版是骨架。骨架不对，穿什么都别扭。</p><h2 id="先说不做什么">先说不做什么</h2><p>大部分「看起来还行但说不出哪里不对」的页面，问题通常很具体：</p><ul><li>所有标题都比正文大 1.5 倍以上，层级靠字号硬堆</li><li>行距 1.5，但段落间距和行距一样，读起来像一大坨</li><li>每块内容都装了圆角卡片，视线找不到落点</li><li>用同一个灰色表达三种不同重要程度的信息</li></ul><p>这些都不是审美问题，是<strong>信息结构没有翻译成视觉语言</strong>。</p><h2 id="我的三个约束">我的三个约束</h2><p>接这个博客的时候，我给自己定了三条硬规则，逼自己不用简单的办法解决层级问题。</p><h3 id="一标题和正文同字号">一、标题和正文同字号</h3><p>字号不动，层级靠字重和空间。</p><table><thead><tr><th>角色</th><th>字号</th><th>字重</th><th>行高</th></tr></thead><tbody><tr><td>页面标题</td><td>28px</td><td>700</td><td>1.25</td></tr><tr><td>正文</td><td>17px</td><td>400</td><td>1.75</td></tr><tr><td>正文内 h2</td><td>17px</td><td>700</td><td>1.4</td></tr><tr><td>正文内 h3</td><td>17px</td><td>400</td><td>1.4</td></tr><tr><td>元信息</td><td>13px</td><td>400</td><td>1.5</td></tr></tbody></table><p>有个细节值得说：中文正文字体（我用的是霞鹜文楷 Lite）通常只有 <strong>400 和 700 两档字重</strong>。如果 h2 和 h3 都用 700，在字号相同的前提下它们会长得一模一样。</p><p>我的解法是让 h3 用 400，但把颜色降到次级灰。层级用「更淡」表达，而不是「更粗」。</p><h3 id="二段落间距必须明显大于行距">二、段落间距必须明显大于行距</h3><p>行距 1.75 的时候，段落间距我用 <code>1.2em</code>。差值不大，但足够让眼睛知道"这里断开了"。</p><p>判断标准很简单：眯起眼睛看，段落应该像一块一块的，不是连续的一条。</p><h3 id="三用-1px-线不用色块">三、用 1px 线，不用色块</h3><p><code>border</code> 比 <code>background</code> 精确得多。</p><pre><code class="language-css">.list-item {
  border-bottom: 1px solid var(--border);
  padding-block: 1rem;
}

.list-item:hover {
  /* 注意：不改背景色，只让标题变重 */
  font-weight: 700;
}</code></pre><p>一行一条线，视线自然往下走。色块会把页面切碎，线不会。</p><h2 id="一个反例">一个反例</h2><p>很多模板会这样处理元信息：</p><pre><code class="language-html">&lt;span class="meta"&gt;2025-10-28 · 阅读 6 分钟 · 设计&lt;/span&gt;</code></pre><p>中间那个 <code>·</code> 看着挺简洁，但它没有传达任何信息，只是把三个不相干的东西粘在一起。</p><p>我改成：</p><pre><code class="language-html">&lt;dl class="meta"&gt;
  &lt;dd&gt;2025-10-28&lt;/dd&gt;
  &lt;dt&gt;6 min&lt;/dt&gt;
&lt;/dl&gt;</code></pre><p>日期用等宽字体，时长用等宽字体，中间靠 1px 竖线或间距分开。<strong>等宽字体只给真实数据用</strong>——日期、字数、时长、代码。它不是装饰。</p><h2 id="结果">结果</h2><p>同样的 zinc 灰阶，同样一个陶土色强调色，只调整字重、行距和分隔方式，页面的气质就从「模板」变成了「有人在排」。</p><p>在暗色模式下这一点尤其明显。因为我没有加任何阴影、没有玻璃拟态、没有渐变，暗色版本不是简单地把白底变黑底，而是<strong>重新配了一次权重关系</strong>：</p><pre><code class="language-js">// 暗色下强调色要提亮，否则在深色底上会糊成一团
const accent = {
  light: '#a94f33', // 对比度 5.45 on #fff
  dark: '#cc785c',  // 对比度 6.07 on #09090b
}</code></pre><p>这两个值我实际算过 WCAG 对比度，不是拍脑袋定的。</p><h2 id="最后">最后</h2><p>排版这件事的好处是：它不需要你有审美天赋，只需要你<strong>对信息结构诚实</strong>。什么重要，什么次要，什么是一组，什么该分开——想清楚了，视觉自然就出来了。</p><p>配色可以抄，排版抄不来，因为排版是判断题。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>特殊字符与代码块压测：&amp; &lt; &gt; &quot; &apos; 全都要</title>
      <link>https://www.kemiaosw.top/posts/xml-escaping-stress-test</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/xml-escaping-stress-test</guid>
      <pubDate>Mon, 20 Oct 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>这篇文章没有别的目的，就是用来把 feed 生成逻辑撑到极限。</p><p>如果你的 RSS 阅读器能正常显示这一篇，说明转义是对的。</p><h2 id="裸字符">裸字符</h2><p>下面这些字符在 XML 里都是危险的，必须转义：</p><ul><li>与号：AT&amp;T、Q&amp;A、R&amp;D</li><li>小于号：<code>a &lt; b</code></li><li>大于号：<code>a &gt; b</code></li><li>引号：他说 "这样" 和 '那样'</li><li>CDATA 结束标记本身：<code>]]&gt;</code></li></ul><p>如果不转义，<code>AT&amp;T</code> 会把整个 feed 撑坏——因为解析器会以为 <code>&amp;A</code> 是一个实体引用的开始。</p><h2 id="代码块里的尖括号">代码块里的尖括号</h2><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="zh-CN"&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8" /&gt;
    &lt;title&gt;喵洛阁 &amp;amp; 朋友&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;p class="note"&gt;如果 a &lt; b 并且 b &gt; c，那么 a &lt; c&lt;/p&gt;
    &lt;script&gt;
      const html = '&lt;div class="x"&gt;' + text + '&lt;/div&gt;'
      if (a &amp;&amp; b) console.log('&amp;lt;ok&amp;gt;')
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>上面这块 HTML 里 <code>&lt;</code>、<code>&gt;</code>、<code>&amp;</code>、引号全都有。它在页面里应该原样显示，在 feed 里应该被正确转义。</p><p>再来一段 TypeScript：</p><pre><code class="language-ts">// 这里的箭头函数、泛型尖括号、比较符号都不应该破坏 XML
type Predicate&lt;T&gt; = (value: T) =&gt; boolean

function filterAnd&lt;T&gt;(items: T[], pred: Predicate&lt;T&gt;): T[] {
  const out: T[] = []
  for (const item of items) {
    if (item &amp;&amp; pred(item) &amp;&amp; (out as unknown[]).length &lt; 100) {
      out.push(item)
    }
  }
  return out
}

const flags = filterAnd([1, 2, 3], (n) =&gt; n &gt; 1 &amp;&amp; n &lt; 3)
console.log('结果：', flags, '&amp; 完成')</code></pre><h2 id="内联代码与引用">内联代码与引用</h2><p>行内代码 <code>const re = /&lt;(\w+)&gt;/g</code> 里也有尖括号。</p><p>下面是一个引用块，里面同时含 HTML 和 shell：</p><blockquote><p>试试 <code>echo "&lt;hello&gt; &amp; goodbye"</code> 这条命令。</p><p>它会输出带尖括号和与号的字符串，而 shell 会尝试把它当成重定向或子命令。</p></blockquote><h2 id="表格">表格</h2><table><thead><tr><th>字符</th><th>XML 实体</th><th>是否必须</th></tr></thead><tbody><tr><td><code>&amp;</code></td><td><code>&amp;amp;</code></td><td>必须</td></tr><tr><td><code>&lt;</code></td><td><code>&amp;lt;</code></td><td>必须</td></tr><tr><td><code>&gt;</code></td><td><code>&amp;gt;</code></td><td>建议</td></tr><tr><td><code>"</code></td><td><code>&amp;quot;</code></td><td>属性内必须</td></tr><tr><td><code>'</code></td><td><code>&amp;apos;</code></td><td>属性内建议</td></tr></tbody></table><h2 id="链接与图片">链接与图片</h2><p>站内绝对链接：<a href="https://www.kemiaosw.top/archive">归档</a>、<a href="https://www.kemiaosw.top/tags">标签</a>、<a href="https://www.kemiaosw.top/about">关于</a>。</p><p>相对链接在 feed 里必须被改写成基于站点的绝对链接，否则阅读器里点不动。</p><p>图片同理：</p><p><img src="https://www.kemiaosw.top/images/covers/charset.png" alt="占位图" /></p><h2 id="一段长文本">一段长文本</h2><p>Lorem ipsum 不如直接说清楚：这一段就是用来测行高和段距的。中文排版里，1.75 的行高配合 17px 的字号，一行大约能放下 44 个汉字，这个宽度在多数屏幕上读起来是舒服的。再宽一点，眼睛回行会累；再窄一点，一句话要折三次，节奏就散了。</p><p>段落间距必须明显大于行距。我用的是 1.2em——看起来差别不大，但眯眼一看就知道哪里断开了。如果段落间距等于行距，整页会糊成一块灰，读者找不到停顿点，读三行就想走。</p><h2 id="结尾">结尾</h2><p>如果这一篇在 reader 里结构完好、代码块没跑版、图片能加载、链接可点，那 feed 就是对的。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>我删掉了站点的 90% 功能</title>
      <link>https://www.kemiaosw.top/posts/deleted-90-percent</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/deleted-90-percent</guid>
      <pubDate>Sun, 12 Oct 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>上一个版本的博客有 14 个页面、3 个第三方脚本、一套后台、还有评论系统。</p><p>我每个月写两篇文章。</p><p>这两件事之间存在严重的不匹配。</p><h2 id="先算笔账">先算笔账</h2><p>我把访问数据拉出来看了看：</p><table><thead><tr><th>页面</th><th>月访问</th><th>占比</th></tr></thead><tbody><tr><td>首页</td><td>1,240</td><td>41%</td></tr><tr><td>文章详情</td><td>1,510</td><td>50%</td></tr><tr><td>归档</td><td>180</td><td>6%</td></tr><tr><td>其他所有页面</td><td>90</td><td>3%</td></tr></tbody></table><p>「其他所有页面」包括：分类索引、标签云、友链、关于、搜索页、404、两个我忘了当初为什么建的空页面。</p><p>它们加起来占了 3% 的流量，却占了我 70% 的维护时间。</p><h2 id="删掉的清单">删掉的清单</h2><ul><li><strong>后台管理</strong> —— 我用 Vim 写 markdown 比用任何后台都快</li><li><strong>评论系统</strong> —— 一年收到 4 条评论，其中 3 条是垃圾</li><li><strong>访问统计的第三方脚本</strong> —— 一个用了 8 年的静态博客，我不需要实时看板</li><li><strong>搜索独立页</strong> —— 搜索应该是弹窗，不该是一次页面跳转</li><li><strong>分类页的独立布局</strong> —— 它和标签页长得一模一样，为什么要写两遍</li><li><strong>暗色模式的切换动画库</strong> —— 我写了 40 行 <code>clip-path</code> 替代了它</li></ul><h2 id="保留的东西">保留的东西</h2><p>删完之后剩下这些，一个都不能少：</p><pre><code class="language-text">/                 首页，最新文章列表
/posts/[slug]     文章
/archive          归档
/categories       分类
/tags             标签
/links            友链
/about            关于
/rss.xml          订阅</code></pre><p>九个路由。其中三个（分类、标签、归档）共用同一个列表组件——它们本来就是同一个东西的不同切片。</p><h2 id="性能是删出来的">性能是删出来的</h2><p>改动前后，用 Lighthouse 在同一台机器上测：</p><table><thead><tr><th>指标</th><th>改前</th><th>改后</th></tr></thead><tbody><tr><td>FCP</td><td>1.8s</td><td>0.4s</td></tr><tr><td>LCP</td><td>2.3s</td><td>0.6s</td></tr><tr><td>JS 体积</td><td>284 KB</td><td>41 KB</td></tr><tr><td>首屏请求数</td><td>23</td><td>6</td></tr></tbody></table><p>没有做任何「优化」。没有拆包、没有预加载、没有 CDN 调优。</p><p>我只是不再发送用户不需要的代码。</p><pre><code class="language-js">// 删之前：每个页面都加载完整运行时
import { Analytics } from '@heavy/analytics'
import { Comments } from '@heavy/comments'
import { AdminWidget } from '@heavy/admin'

// 删之后：什么都没有
// 静态 HTML + 一个 3KB 的搜索索引（首次搜索才加载）</code></pre><h2 id="一个反直觉的发现">一个反直觉的发现</h2><p>删功能最难的从来不是技术，是心理。</p><p>每删一个东西，我都会想：「万一以后要用呢？」</p><p>后来我给自己定了个规则：<strong>如果要重新加回来，成本低于 2 小时，那就先删掉。</strong></p><p>后台管理？重写要 8 小时，不删。评论系统？接第三方要 20 分钟，删。搜索页？改成弹窗要 1 小时，删。</p><p>这条规则让我删掉了大约 60% 的东西，而没有任何一个功能被真的加回来过。</p><h2 id="结论">结论</h2><blockquote><p>一个博客的核心是：<strong>让人能读到文章。</strong> 其他一切都是配角。</p></blockquote><p>我现在写一篇新文章的工作流是：新建一个 <code>.md</code> 文件，写 frontmatter，写正文，<code>git push</code>。</p><p>三十秒后它就在线上了，带 RSS、带搜索索引、带 OG 图。</p><p>这才是写博客该有的摩擦系数。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>中文分词为什么把搜索做坏了</title>
      <link>https://www.kemiaosw.top/posts/cjk-tokenizer</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/cjk-tokenizer</guid>
      <pubDate>Tue, 30 Sep 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>给博客加搜索时，我踩了一个坑：英文关键词搜得挺准，中文关键词一个都搜不到。</p><p>原因很简单——<strong>MiniSearch 默认的 tokenizer 按空格和标点切词</strong>。</p><p>对英文来说这没问题：</p><pre><code class="language-ts">tokenize('hello wonderful world')
// ['hello', 'wonderful', 'world']</code></pre><p>对中文来说这就是灾难：</p><pre><code class="language-ts">tokenize('中文分词为什么把搜索做坏了')
// ['中文分词为什么把搜索做坏了']   ← 一整段算一个词</code></pre><p>整段算一个词意味着：用户搜「分词」，匹配不上；搜「搜索」，也匹配不上。只有把整句原样打进去才可能命中。</p><h2 id="三种切法">三种切法</h2><h3 id="方案一单字切分">方案一：单字切分</h3><pre><code class="language-ts">tokenize('中文分词')
// ['中', '文', '分', '词']</code></pre><p>召回率最高，因为任何两个字以上的词都包含单字。</p><p>但精度是灾难级的：搜「的」会命中所有文章；搜「一」几乎命中全站。搜索结果里全是噪音。</p><h3 id="方案二整词切分需要词典">方案二：整词切分（需要词典）</h3><p>用结巴之类的分词库，或者自己做词典最长匹配：</p><pre><code class="language-ts">tokenize('中文分词为什么把搜索做坏了')
// ['中文', '分词', '为什么', '把', '搜索', '做', '坏', '了']</code></pre><p>质量最好，但代价是：</p><ul><li>要打包一份词典（几百 KB 到几 MB）</li><li>新词、专有名词、缩写经常分错</li><li>前端体积受不了</li></ul><p>对一个 3KB 的搜索来说，这个方案太贵了。</p><h3 id="方案三bigram我的选择">方案三：bigram（我的选择）</h3><p>每两个相邻字符算一个 token：</p><pre><code class="language-ts">tokenize('中文分词')
// ['中文', '文分', '分词']</code></pre><p>关键特性：<strong>任何长度 ≥ 2 的词，都必然包含它自己的完整 bigram。</strong></p><ul><li>「分词」→ 出现在 <code>['中文', '文分', '分词']</code> 里 ✓</li><li>「搜索」→ 出现在「把搜索做」的 bigram 里 ✓</li></ul><p>所以用户输入一个词，就能匹配到包含它的句子，不需要词典。</p><h3 id="为什么不直接用方案三">为什么不直接用方案三</h3><p>纯 bigram 有个缺口：<strong>单字查询召回为 0</strong>。</p><p>用户搜「猫」，没有 bigram 能匹配上「喵洛阁的猫」。</p><p>所以我的实现是 bigram 为主 + 单字兜底：</p><pre><code class="language-ts">function tokenizeCJK(text: string): string[] {
  const tokens: string[] = []
  // 按非字母数字汉字切段
  const segments = text.split(/[^\p{L}\p{N}]+/u).filter(Boolean)

  for (const seg of segments) {
    const isCJK = /[\u4e00-\u9fa5]/.test(seg)

    if (!isCJK) {
      // 英文/数字：整词，转小写
      tokens.push(seg.toLowerCase())
      continue
    }

    const chars = [...seg]
    // bigram
    for (let i = 0; i &lt; chars.length - 1; i++) {
      tokens.push(chars[i] + chars[i + 1])
    }
    // 单字兜底：短查询用
    for (const ch of chars) {
      tokens.push(ch)
    }
  }

  return tokens
}</code></pre><p>单字会让索引变大一些、噪音多一点，但换来的是<strong>单字查询不会零结果</strong>。</p><h2 id="权重比切词更重要">权重比切词更重要</h2><p>切对了之后还有个问题：搜「前端」，一篇标题叫《前端工程化》的和一篇正文里提到一次「前端」的，应该谁排前面？</p><p>MiniSearch 支持字段权重：</p><pre><code class="language-ts">const miniSearch = new MiniSearch({
  fields: ['title', 'description', 'tags', 'category', 'text'],
  storeFields: ['title', 'path', 'date'],
  searchOptions: {
    boost: { title: 8, description: 4, tags: 3, category: 2, text: 1 },
    prefix: true,
    fuzzy: 0.2,
  },
  tokenize: tokenizeCJK,
})</code></pre><p>标题权重 8 倍于正文。这样标题命中永远排在正文命中前面——这也正是验收标准要求的「标题命中权重最高」。</p><h2 id="实测">实测</h2><p>改之前：</p><pre><code class="language-text">搜「前端」→ 0 结果
搜「分词」→ 0 结果</code></pre><p>改之后：</p><pre><code class="language-text">搜「前端」→ 3 结果，第一条《我删掉了站点的 90% 功能》
搜「分词」→ 1 结果，第一条《中文分词为什么把搜索做坏了》
搜「猫」  → 1 结果，《关于这个博客》（正文提到「猫」）
搜「排版」→ 1 结果，《排版比配色更决定气质》</code></pre><h2 id="一句话总结">一句话总结</h2><p>中文搜索的难点不在算法，在<strong>分词策略和你的用户输入习惯是否匹配</strong>。</p><p>bigram 不是最优解，但对一个没有后端、索引要压到 300KB 以内的静态博客来说，它是性价比最高的那个解。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>View Transitions 不是动画库</title>
      <link>https://www.kemiaosw.top/posts/view-transitions</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/view-transitions</guid>
      <pubDate>Thu, 18 Sep 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>第一次看到 <code>document.startViewTransition</code> 的时候，我以为它就是个更简单的 GSAP。</p><p>用了半年之后我发现，它真正解决的问题根本不是"怎么动画"。</p><h2 id="它解决的是什么问题">它解决的是什么问题</h2><p>传统的页面转场动画难写，不是因为 CSS 难，而是因为<strong>你要同时管理两个状态</strong>。</p><p>从列表页跳到详情页，你得：</p><ol><li>给旧页面加一个 "leaving" 类</li><li>等动画结束</li><li>卸载旧页面</li><li>挂载新页面</li><li>给新页面加一个 "entering" 类</li><li>再等动画结束</li></ol><p>六个步骤，任何一步时序错一点就会闪。</p><p>View Transitions 把这六步变成了一个 API：</p><pre><code class="language-ts">document.startViewTransition(() =&gt; {
  // 只在这里改 DOM，浏览器负责前后的快照和过渡
  renderNewPage()
})</code></pre><p>浏览器会：对旧状态截图 → 执行你的回调 → 对新状态截图 → 在两帧之间做过渡。</p><p><strong>你只描述"变成什么样"，不描述"怎么变"。</strong></p><h2 id="三种实际用法">三种实际用法</h2><h3 id="一页面切换">一、页面切换</h3><p>Nuxt 开了 <code>experimental.viewTransition</code> 之后，页面过渡可以完全用 CSS 控制：</p><pre><code class="language-css">::view-transition-old(root) {
  animation: 200ms ease-out both fade-out;
}

::view-transition-new(root) {
  animation: 200ms ease-out both fade-in;
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}</code></pre><p>注意这里没有 JS。转场逻辑全在 CSS 里，JS 只负责换内容。</p><h3 id="二主题切换的圆形扩散">二、主题切换的圆形扩散</h3><p>这个效果很多人以为是手写 <code>clip-path</code> 加 <code>requestAnimationFrame</code>，其实可以完全交给 View Transitions：</p><pre><code class="language-ts">async function toggleTheme(x: number, y: number) {
  const radius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y),
  )

  const transition = document.startViewTransition(() =&gt; {
    document.documentElement.classList.toggle('dark')
  })

  await transition.ready

  document.documentElement.animate(
    {
      clipPath: [
        `circle(0px at ${x}px ${y}px)`,
        `circle(${radius}px at ${x}px ${y}px)`,
      ],
    },
    {
      duration: 200,
      easing: 'ease-out',
      pseudoElement: '::view-transition-new(root)',
    },
  )
}</code></pre><p>关键在 <code>pseudoElement: '::view-transition-new(root)'</code>——你不是在动画真实 DOM，是在动画浏览器的过渡快照层。所以这个动画<strong>不会触发重排</strong>。</p><h3 id="三列表到详情的共享元素">三、列表到详情的共享元素</h3><p>给元素一个 <code>view-transition-name</code>：</p><pre><code class="language-css">.post-card__title { view-transition-name: post-title; }
.post-detail__title { view-transition-name: post-title; }</code></pre><p>两个页面上同名，浏览器就会把它们当成同一个元素，自动做位置和尺寸的插值。</p><pre><code class="language-css">::view-transition-old(post-title),
::view-transition-new(post-title) {
  animation-duration: 250ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}</code></pre><h2 id="必须知道的三个限制">必须知道的三个限制</h2><p><strong>一、同一时间只能有一个 <code>view-transition-name</code> 在页面上。</strong></p><p>如果列表里有 10 张卡片都叫 <code>post-title</code>，浏览器会直接报错、整个过渡失效。所以列表页通常不给每张卡单独命名，只在用户点击时动态设置。</p><p><strong>二、动画期间页面不可交互。</strong></p><p>过渡快照覆盖在真实 DOM 之上。所以别做超过 300ms 的转场。</p><p><strong>三、必须尊重 <code>prefers-reduced-motion</code>。</strong></p><pre><code class="language-ts">const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
const supported = 'startViewTransition' in document

if (!supported || reduceMotion) {
  // 直接切，不做动画
  applyChange()
  return
}</code></pre><p>这不是可选项。前庭功能障碍的用户会因为这种动画头晕。</p><h2 id="什么时候不该用">什么时候不该用</h2><ul><li><strong>表单提交后的状态更新</strong> —— 不需要转场，用户只想知道成功了没</li><li><strong>列表项的 hover 效果</strong> —— 那是微交互，用 <code>transition</code> 就够了</li><li><strong>超过 300ms 的复杂编排</strong> —— 会让人等</li></ul><p>View Transitions 的定位是<strong>页面级的状态切换</strong>，不是"万能动画方案"。</p><h2 id="一句话">一句话</h2><blockquote><p>它把"两套状态之间怎么过渡"这件麻烦事，从你手里拿走了。</p></blockquote><p>这可能不是最酷的动画 API，但它是我用过最省心的那个。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>静态博客的搜索为什么不用后端</title>
      <link>https://www.kemiaosw.top/posts/static-search</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/static-search</guid>
      <pubDate>Fri, 05 Sep 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>每次有人问我"静态博客怎么做搜索"，标准答案通常是这四种：</p><ol><li>Algolia（要付费、要联网、要等索引同步）</li><li>MeiliSearch（要跑一个服务）</li><li>Pagefind（好，但更偏向几千页以上的站）</li><li>搜索引擎的 <code>site:</code> 语法（等于没有）</li></ol><p>我用的是第五种：<strong>构建时生成一个 JSON 索引，前端按需加载</strong>。</p><h2 id="索引长什么样">索引长什么样</h2><pre><code class="language-json">[
  {
    "id": "typography-over-color",
    "title": "排版比配色更决定气质",
    "description": "把同样一套颜色交给两个人排，出来的东西可以完全不同。",
    "tags": ["排版", "设计", "CSS"],
    "category": "设计",
    "text": "做前端这些年我逐渐发现一个规律换一套配色页面会变好看改一遍排版页面会变成另一个东西…",
    "path": "/posts/typography-over-color",
    "date": "2025-10-28T00:00:00.000Z"
  }
]</code></pre><p>就这些字段，一个都不多。</p><h2 id="为什么这么小">为什么这么小</h2><p>假设你有 100 篇文章，每篇 3000 字。</p><ul><li>原始 markdown：100 × 3000 × 3 bytes ≈ 900 KB</li><li>HTML 渲染后：大约 2.5 MB（标签很占地方）</li><li><strong>剥离 HTML 后的纯文本</strong>：约 850 KB</li><li>gzip 之后：<strong>约 180 KB</strong></li></ul><p>关键动作是<strong>剥 HTML</strong>。这一步能砍掉 60% 以上。</p><pre><code class="language-ts">function stripHtml(html: string): string {
  return html
    .replace(/&lt;script[\s\S]*?&lt;\/script&gt;/gi, ' ')
    .replace(/&lt;style[\s\S]*?&lt;\/style&gt;/gi, ' ')
    .replace(/&lt;pre[\s\S]*?&lt;\/pre&gt;/gi, ' ')   // 代码块也剥掉
    .replace(/&lt;[^&gt;]+&gt;/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}</code></pre><p>那行 <code>&lt;pre&gt;</code> 的处理是有争议的。代码块里全是 <code>useState</code>、<code>div</code>、<code>const</code> 这种标识符，进索引只会制造噪音——用户搜「前端」不该命中一个满是 <code>div</code> 的代码块。</p><h2 id="什么时候加载">什么时候加载</h2><p><strong>用户第一次打开搜索框的时候。</strong></p><p>不是页面加载时，不是空闲时，是真正按下 <code>Ctrl+K</code> 的那一刻。</p><pre><code class="language-ts">let indexPromise: Promise&lt;MiniSearch&gt; | null = null

function ensureIndex() {
  if (!indexPromise) {
    indexPromise = fetch('/api/search-index.json')
      .then((r) =&gt; r.json())
      .then((docs) =&gt; {
        const ms = new MiniSearch({ /* ... */ })
        ms.addAll(docs)
        return ms
      })
  }
  return indexPromise
}</code></pre><p>用一个 Promise 做缓存的好处是：连续按三次 <code>Ctrl+K</code> 也只会发一次请求。</p><h2 id="加载要多久">加载要多久</h2><p>本地测：180KB 的 JSON，<code>fetch</code> + <code>JSON.parse</code> + <code>addAll</code> 建索引，<strong>大约 60~90ms</strong>。</p><p>用户感知不到。但如果索引超过 500KB，这个数字会变成 300ms+，就该考虑分片或者换 Pagefind 了。</p><p>所以我的判断线是：<strong>索引 gzip 后 300KB 以内用方案五，超过就用 Pagefind。</strong></p><p>对一个个人博客（几十到几百篇）来说，永远到不了这条线。</p><h2 id="不用后端的三个好处">不用后端的三个好处</h2><p><strong>一、离线可用。</strong> Service Worker 缓存了索引之后，断网也能搜。</p><p><strong>二、零延迟。</strong> 没有网络往返，搜索是同步的（建完索引之后）。</p><p><strong>三、部署即生效。</strong> 写文章 → <code>git push</code> → 构建 → 索引和文章一起上线。不需要"等 Algolia 爬"。</p><h2 id="唯一的问题">唯一的问题</h2><p>索引是构建时生成的，所以：</p><p><strong>草稿不会被搜到。</strong> 这是对的。</p><p><strong>但新文章要重新构建才能搜到。</strong> 对静态博客来说这本来就是事实——文章本身也要重新构建才存在。</p><p>所以这个"缺点"根本不存在。</p><h2 id="结语">结语</h2><p>技术选型的本质是匹配规模。</p><p>Algolia 对一万篇文章的电商站是对的；MeiliSearch 对需要实时索引的社区是对的；<strong>一个 JSON 文件对个人博客是对的</strong>。</p><p>用大炮打蚊子不叫稳健，叫没算清楚。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>别再用卡片装一切</title>
      <link>https://www.kemiaosw.top/posts/stop-using-cards</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/stop-using-cards</guid>
      <pubDate>Fri, 22 Aug 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>打开最近看到的十个个人网站，八个长这样：</p><ul><li>白色背景</li><li>每块内容一个圆角 16px 的卡片</li><li>卡片下面一层 <code>0 4px 6px rgba(0,0,0,.1)</code> 的阴影</li><li>卡片之间 24px 间距</li><li>所有卡片长得一模一样</li></ul><p>这个布局不算丑，但它有一个致命问题：<strong>读者不知道先看哪里。</strong></p><h2 id="卡片传递的错误信息">卡片传递的错误信息</h2><p>在视觉语言里，一个边界明确的容器在说：「这是一组相关的东西，和外面无关。」</p><p>这是有用的信息——但前提是<strong>并非所有东西都是卡片</strong>。</p><p>如果 10 块内容全都是卡片，那这个信号就失效了。就像一篇文章里每个词都加粗，等于没加粗。</p><p>具体到博客：</p><table><thead><tr><th>内容</th><th>是卡片吗</th><th>为什么</th></tr></thead><tbody><tr><td>文章列表项</td><td>不该是</td><td>它们是一串同类项，不是独立单元</td></tr><tr><td>友链</td><td>可以是</td><td>每个友链确实是独立实体</td></tr><tr><td>技能标签</td><td>不该是</td><td>它们属于同一组</td></tr><tr><td>文章正文的代码块</td><td>不该是</td><td>它是正文的一部分</td></tr><tr><td>弹窗</td><td>是</td><td>它确实浮在页面之上</td></tr></tbody></table><h2 id="我用什么代替">我用什么代替</h2><h3 id="列表横线-间距">列表：横线 + 间距</h3><pre><code class="language-css">.post-list {
  display: flex;
  flex-direction: column;
}

.post-item {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  padding-block: 0.875rem;
  border-bottom: 1px solid var(--border);
}

.post-item:last-child {
  border-bottom: none;
}</code></pre><p>一行一条线。视线沿着线往下走，不会被框住。</p><p>而且 <code>border</code> 比 <code>box-shadow</code> 便宜得多——阴影会触发重绘，边框不会。</p><h3 id="分组留白">分组：留白</h3><p>不需要任何视觉元素，<strong>间距本身就是分组</strong>。</p><pre><code class="language-css">.section {
  margin-block-start: 3rem;
}

.section + .section {
  margin-block-start: 4rem;
}</code></pre><p>大间距说「这是新的部分」，小间距说「这些是一起的」。这是人类视觉系统最古老的规则之一，不需要额外的框。</p><h3 id="强调字重和颜色">强调：字重和颜色</h3><p>想突出某一块内容，不要给它加边框，改它的字重。</p><pre><code class="language-css">.post-item:hover .post-item__title {
  font-weight: 700;
}</code></pre><p>从 400 到 700 的变化，比任何阴影都醒目，而且不占空间、不增加视觉噪音。</p><h2 id="那卡片什么时候用">那卡片什么时候用</h2><p>三个条件同时满足才用：</p><ol><li><strong>内容确实是独立的、可以单独存在的单元</strong></li><li><strong>它需要和周围的内容明确区分开</strong></li><li><strong>数量不多</strong>（少于 10 个）</li></ol><p>友链页的每个友链符合这三条——它们确实彼此无关，确实需要区分，数量也不多。</p><p>文章列表不符合第一条：10 篇文章是一个连续的序列，不是 10 个独立单元。</p><h2 id="一个具体的对比">一个具体的对比</h2><p>同一个博客，两种列表：</p><p><strong>卡片式：</strong></p><pre><code class="language-text">┌─────────────────────────┐
│ ┌─────────────────────┐ │
│ │ 排版比配色更决定气质 │ │
│ │ 2025-10-28          │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 我删掉了站点的 90%   │ │
│ │ 2025-10-12          │ │
│ └─────────────────────┘ │
└─────────────────────────┘</code></pre><p><strong>行式：</strong></p><pre><code class="language-text">2025-10-28   排版比配色更决定气质
──────────────────────────────────
2025-10-12   我删掉了站点的 90% 功能
──────────────────────────────────</code></pre><p>行式版本：</p><ul><li>纵向高度减少约 50%，一屏能看更多文章</li><li>日期对齐成一列，可以扫读</li><li>视线路径是单一的从上到下</li><li>没有任何装饰性的视觉重量</li></ul><h2 id="最后">最后</h2><blockquote><p>卡片不是设计元素，是<strong>信息结构的可视化</strong>。</p></blockquote><p>当你不知道该怎么组织内容时，把东西装进卡片是最省事的选择——但也是最偷懒的选择。</p><p>真正该问的问题是：<strong>这些内容之间是什么关系？</strong></p><p>是序列？用列表。
是并列的独立实体？用网格或卡片。
是从属关系？用缩进。
没有关系？用留白隔开。</p><p>想清楚关系，视觉自然就对了。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>自托管字体的一年</title>
      <link>https://www.kemiaosw.top/posts/self-hosted-fonts</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/self-hosted-fonts</guid>
      <pubDate>Fri, 08 Aug 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>中文网站做自托管字体，最大的障碍不是版权，是<strong>体积</strong>。</p><p>一套完整的霞鹜文楷，woff2 格式大约 20-25 MB。用户打开首页就要下载这个？显然不行。</p><p>所以大部分中文站的选择是：用系统字体。然后全站看起来就像一张没洗过的 PPT。</p><h2 id="分片是怎么解决的">分片是怎么解决的</h2><p>关键洞察：<strong>一篇文章不可能用到所有汉字。</strong></p><p>常用的 3500 个汉字覆盖了 99.5% 的日常文本。剩下几万个字可能一辈子都不会出现在我的博客里。</p><p>所以现代中文字体包的做法是：</p><ol><li>按 Unicode 范围把字体切成几百个小文件</li><li>每个文件用 <code>unicode-range</code> 声明自己负责哪些码位</li><li>浏览器只下载<strong>页面实际用到的</strong>那些分片</li></ol><pre><code class="language-css">/* 分片 #12 负责这些码位 */
@font-face {
  font-family: 'LXGW WenKai Lite';
  font-weight: 400;
  font-display: swap;
  src: url('./files/subset-12.woff2') format('woff2');
  unicode-range: U+4E00-4E1F, U+4E30-4E4F;
}</code></pre><p>浏览器渲染一段文字时，会先检查每个字符属于哪个 <code>unicode-range</code>，只下载覆盖到的分片。</p><h2 id="实际数字">实际数字</h2><p>我这个博客用 <code>lxgw-wenkai-lite-webfont</code>：</p><table><thead><tr><th>项目</th><th>数值</th></tr></thead><tbody><tr><td>包总大小</td><td>25.1 MB</td></tr><tr><td>分片数量</td><td>582 个</td></tr><tr><td>单篇长文实际加载</td><td>3-5 个分片</td></tr><tr><td>实际传输</td><td><strong>约 180 KB</strong></td></tr><tr><td>首屏延迟</td><td>0（<code>font-display: swap</code>）</td></tr></tbody></table><p>25MB 的包，实际只用了 180KB。</p><h2 id="font-display-swap-的必要性">font-display: swap 的必要性</h2><pre><code class="language-css">@font-face {
  font-display: swap;
}</code></pre><p><code>swap</code> 的意思是：<strong>先用后备字体渲染，字体加载完再换。</strong></p><p>代价是会看到一次字体闪烁（FOIT → FOUT）。收益是<strong>文字永远可读</strong>。</p><p>另外两个选项都不适合中文：</p><ul><li><code>block</code>：字体没加载完就不显示文字。中文 180KB 可能要 500ms，用户会看到一片空白</li><li><code>optional</code>：网络慢就永远不用自定义字体。会导致同一篇文章有时是文楷有时是黑体</li></ul><p><code>swap</code> 是唯一合理的选项。</p><h2 id="后备字体链要写对">后备字体链要写对</h2><pre><code class="language-css">font-family:
  'Inter Variable',
  'LXGW WenKai Lite',
  'PingFang SC',
  'Microsoft YaHei',
  sans-serif;</code></pre><p>这个链的顺序有讲究：</p><ol><li><strong>Inter</strong> 负责拉丁字母和数字 —— 它的西文比文楷好看得多</li><li><strong>文楷</strong> 负责汉字 —— 在 Inter 没有对应字形时接管</li><li><strong>苹方 / 微软雅黑</strong> —— 文楷分片还没加载完时的后备</li><li><strong>sans-serif</strong> —— 最后兜底</li></ol><p>关键在于第 1 和第 2 的顺序：浏览器是逐字符查找的。英文字符在 Inter 里能找到，就不会去问文楷；汉字在 Inter 里找不到，自然落到文楷。</p><p>这样西文和中文各用各的最优字体，不需要任何 <code>unicode-range</code> 技巧。</p><h2 id="等宽字体只给数据">等宽字体只给数据</h2><pre><code class="language-css">font-family: 'JetBrains Mono', 'LXGW WenKai Lite', ui-monospace, monospace;</code></pre><p>等宽字体我用在四个地方，只有四个：</p><ul><li>日期（<code>2025-10-28</code>）</li><li>阅读时长（<code>6 min</code>）</li><li>代码块</li><li><code>kbd</code> 元素</li></ul><p><strong>不用它做装饰性的小标签。</strong> 那些 <code>MONOSPACE</code> 风格的 eyebrow 文本是模板化的标志之一——它看起来"有技术感"，但没传达任何信息。</p><h2 id="一个坑">一个坑</h2><p>字体分片依赖 <code>unicode-range</code>，而 <code>unicode-range</code> 是<strong>静态声明</strong>的。</p><p>意思是：如果你的文章里出现了某个生僻字，而那个字所在的分片没有被任何 <code>@font-face</code> 覆盖（有些字体包会漏掉扩展区），那个字就会掉到后备字体。</p><p>结果是一篇文章里 99% 是文楷，中间蹦出一个黑体的字。</p><p>我的处理是接受它——比为了一个字加载一整个 3MB 的扩展区划算得多。</p><h2 id="结论">结论</h2><blockquote><p>中文字体不是"能不能自托管"的问题，是"有没有做分片"的问题。</p></blockquote><p>用了分片之后：</p><ul><li>首屏字体成本 ≈ 180KB（可接受）</li><li>视觉一致性 100%（全站统一字体）</li><li>零第三方请求（隐私友好，也不受 CDN 挂掉影响）</li></ul><p>这套方案我已经跑了一年，没有换回去的理由。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>暗色模式不是把颜色反过来</title>
      <link>https://www.kemiaosw.top/posts/dark-mode-design</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/dark-mode-design</guid>
      <pubDate>Fri, 25 Jul 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>我见过太多暗色模式是这样的：</p><pre><code class="language-css">/* 这就是全部实现 */
.dark {
  filter: invert(1) hue-rotate(180deg);
}</code></pre><p>或者稍微认真一点的：</p><pre><code class="language-css">.dark {
  --bg: #000;
  --text: #fff;
}</code></pre><p>两种都错。</p><h2 id="为什么不能简单反转">为什么不能简单反转</h2><h3 id="一纯黑配纯白太刺眼">一、纯黑配纯白太刺眼</h3><p>白底黑字的对比度是 21:1，这是物理极限。</p><p>在亮环境下这没问题——瞳孔小，进光量少。但在暗环境下，你的瞳孔放大了，屏幕成了唯一光源，21:1 的对比度会让眼睛很快疲劳。</p><p>所以暗色模式的标准做法是：</p><pre><code class="language-css">.dark {
  --background: #09090b;  /* 不是 #000 */
  --foreground: #fafafa;  /* 不是 #fff */
}</code></pre><p>对比度降到约 19:1。差别不大，但长时间阅读舒服很多。</p><h3 id="二纯黑背景会让阴影失效">二、纯黑背景会让阴影失效</h3><p>亮色模式下阴影是黑色半透明：</p><pre><code class="language-css">box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);</code></pre><p>暗色模式下把同一个阴影放在 <code>#09090b</code> 上——你什么都看不见。黑色叠黑色。</p><p>这就是为什么<strong>暗色模式通常完全禁用阴影</strong>，改用边框或者亮度差来区分层级：</p><pre><code class="language-css">.dark * {
  box-shadow: none;
}

/* 用亮一点的面表达"浮起来" */
.dark {
  --surface: #18181b;      /* 页面底 #09090b，卡片 #18181b */
  --surface-hover: #27272a;
}</code></pre><h3 id="三强调色必须重新调">三、强调色必须重新调</h3><p>同一个颜色在亮底和暗底上的感知完全不同。</p><p>我的强调色：</p><table><thead><tr><th>模式</th><th>值</th><th>对比度</th><th>说明</th></tr></thead><tbody><tr><td>亮色</td><td><code>#a94f33</code></td><td>5.45 on <code>#fff</code></td><td>深陶土，压得住白底</td></tr><tr><td>暗色</td><td><code>#cc785c</code></td><td>6.07 on <code>#09090b</code></td><td>提亮 + 降饱和，不刺眼</td></tr></tbody></table><p>如果暗色模式还用 <code>#a94f33</code>，它在深色底上会显得<strong>脏</strong>——像褪色的油漆。因为深色底会让低亮度颜色失去光泽。</p><p>反过来，如果亮色模式用 <code>#cc785c</code>，在白色上对比度只有 3.1:1，达不到 WCAG AA 的 4.5:1。</p><p><strong>所以强调色必须有两套值，这不是可选项。</strong></p><h2 id="完整的变量对照">完整的变量对照</h2><pre><code class="language-css">:root {
  --background: #ffffff;
  --foreground: #18181b;
  --muted: #fafafa;
  --muted-foreground: #71717a;
  --border: #e4e4e7;
  --accent: #a94f33;
}

.dark {
  --background: #09090b;
  --foreground: #fafafa;
  --muted: #18181b;
  --muted-foreground: #a1a1aa;
  --border: #27272a;
  --accent: #cc785c;
}</code></pre><p>注意 <code>--muted-foreground</code> 的变化：</p><ul><li>亮色：<code>#71717a</code>，比正文浅，但仍能读</li><li>暗色：<code>#a1a1aa</code>，比正文暗，但仍能读</li></ul><p>方向是反的，但<strong>目的相同：比正文低一级，但保持可读</strong>。</p><p>如果暗色模式直接用亮色模式的 <code>#71717a</code>，它在 <code>#09090b</code> 上的对比度只有 4.3:1——刚好在 AA 线下面。</p><h2 id="三态切换而不是两态">三态切换而不是两态</h2><p>只用两态（亮/暗）有个问题：用户系统里设了「跟随时间自动切换」，但网站不认。</p><p>所以我的实现是三态：</p><pre><code class="language-ts">type ThemeMode = 'light' | 'dark' | 'system'</code></pre><p><code>system</code> 模式下监听 <code>prefers-color-scheme</code>：</p><pre><code class="language-ts">const mq = matchMedia('(prefers-color-scheme: dark)')

mq.addEventListener('change', () =&gt; {
  if (mode.value === 'system') {
    applyTheme(mq.matches)
  }
})</code></pre><p>这样用户在系统里切了主题，网站立刻跟上，不需要手动点。</p><h2 id="防止闪烁fouc">防止闪烁（FOUC）</h2><p>最烦人的 bug：页面先渲染成亮色，200ms 后闪成暗色。</p><p>原因是 CSS 变量在 JS 执行后才生效。解法是在 <code>&lt;head&gt;</code> 里插一段<strong>同步执行</strong>的脚本：</p><pre><code class="language-html">&lt;script&gt;
  (function () {
    try {
      var m = localStorage.getItem('theme-mode')
      var d = m === 'dark' || ((!m || m === 'system') &amp;&amp;
        window.matchMedia('(prefers-color-scheme: dark)').matches)
      document.documentElement.classList.toggle('dark', d)
    } catch (e) {}
  })()
&lt;/script&gt;</code></pre><p>关键点：</p><ul><li>必须是<strong>内联</strong>的（外部脚本要等网络）</li><li>必须<strong>同步</strong>（<code>defer</code>/<code>async</code> 都会晚）</li><li>必须在 <code>&lt;head&gt;</code> 里（<code>&lt;body&gt;</code> 末尾太晚）</li><li>必须包 <code>try/catch</code>（隐私模式下 <code>localStorage</code> 会抛异常）</li></ul><h2 id="一句话">一句话</h2><blockquote><p>暗色模式是第二套设计，不是第一套的滤镜。</p></blockquote><p>它需要独立的对比度核算、独立的阴影策略、独立的强调色。省掉这些步骤，出来的东西就是"能看但难受"。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>一个配置文件能解决多少问题</title>
      <link>https://www.kemiaosw.top/posts/zero-hardcode</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/zero-hardcode</guid>
      <pubDate>Thu, 10 Jul 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>「零硬编码」这句话很容易被当成洁癖。</p><p>毕竟写死一个站名能出什么问题？改的时候全局替换一下不就完了。</p><p>我做这个博客的时候，把这条规则当成了硬性架构约束。半年后的结论是：<strong>它带来的收益远超预期，而且和代码整洁没什么关系。</strong></p><h2 id="四层配置">四层配置</h2><p>我把所有可变内容分成四层：</p><pre><code class="language-text">app/blog.config.ts     站点身份、feed、search
app/theme.config.ts    设计 token（颜色、字号、间距、圆角、动效）
app/pages.config.ts    逐路由的业务参数
app/data/*.ts          友链、关于页元数据、系统文案</code></pre><p>组件里一行硬编码都不许有。</p><pre><code class="language-vue">&lt;!-- 禁止 --&gt;
&lt;h1&gt;喵洛阁&lt;/h1&gt;
&lt;a href="https://github.com/kemiaosw"&gt;GitHub&lt;/a&gt;

&lt;!-- 允许 --&gt;
&lt;h1&gt;{{ site.name }}&lt;/h1&gt;
&lt;a :href="social.url"&gt;{{ social.label }}&lt;/a&gt;</code></pre><h2 id="收益一改一处全站同步">收益一：改一处，全站同步</h2><p>最直接的收益是这个。举个真实例子：我想把「归档」改成「时间线」。</p><pre><code class="language-ts">// 改这一行
nav: [
  { label: '时间线', to: '/archive', icon: 'tabler:archive' },
]</code></pre><p>改完，桌面导航、平板汉堡菜单、移动端底部 Tab 栏，三处同时更新。</p><p>如果这些 label 是硬编码在三处的，我至少要改三个文件，而且很可能漏掉移动端（因为它在另一个组件里，平时不怎么看）。</p><h2 id="收益二配置即文档">收益二：配置即文档</h2><p>打开 <code>blog.config.ts</code>，你立刻知道这个站有哪些页面、有哪些社交链接、RSS 开没开。</p><pre><code class="language-ts">export default defineAppConfig({
  site: {
    name: '喵洛阁',
    nav: [
      { label: '首页', to: '/', icon: 'tabler:home' },
      { label: '归档', to: '/archive', icon: 'tabler:archive' },
      // ...
    ],
  },
})</code></pre><p>这比翻 15 个 <code>.vue</code> 文件去找 <code>to="/archive"</code> 快得多。</p><h2 id="收益三功能开关变成真的开关">收益三：功能开关变成真的开关</h2><p>这是我最没想到的收益。</p><p>提示词的验收标准里有一条：</p><blockquote><p><code>feed</code> / <code>search</code> 任一 <code>enabled: false</code> 后：入口消失、对应路由 404、head 无相关注入</p></blockquote><p>如果没有配置层，这个需求要改 6 个地方：</p><ol><li>删掉 footer 的 RSS 链接</li><li>删掉 <code>&lt;head&gt;</code> 的 <code>&lt;link rel="alternate"&gt;</code></li><li>删掉 <code>server/routes/rss.xml.get.ts</code></li><li>改 <code>nitro.prerender.routes</code></li><li>删掉搜索按钮</li><li>解绑快捷键</li></ol><p>有了配置层，它们全都读同一个字段：</p><pre><code class="language-ts">const { feed, search } = useSiteConfig()

// footer
&lt;NuxtLink v-if="feed.rss.enabled" :to="feed.rss.path"&gt;RSS&lt;/NuxtLink&gt;

// head
if (feed.rss.enabled) links.push({ rel: 'alternate', ... })

// 路由
if (!feed.rss.enabled) throw createError({ statusCode: 404 })</code></pre><p><strong>开关的原子性</strong>从"靠人记得改全"变成了"结构上不可能不同步"。</p><h2 id="收益四主题切换不用碰组件">收益四：主题切换不用碰组件</h2><p>设计 token 全在 <code>theme.config.ts</code>：</p><pre><code class="language-ts">theme: {
  accent: { light: '#a94f33', dark: '#cc785c' },
  radius: '0.5rem',
  contentMaxWidth: '764px',
  animation: { duration: 200, easing: 'ease-out' },
}</code></pre><p>这些值在构建期被编译成 CSS 变量：</p><pre><code class="language-css">:root {
  --accent: #a94f33;
  --radius: 0.5rem;
  --content-max-width: 764px;
  --duration-base: 200ms;
}</code></pre><p>组件里只写 <code>var(--accent)</code>，不写 hex。</p><p>想换强调色？改两行。想换字体？改 <code>fontFamily</code>。想让动画慢一点？改 <code>duration</code>。</p><p><strong>不需要在 30 个 <code>.vue</code> 文件里搜索替换。</strong></p><h2 id="代价是什么">代价是什么</h2><p>诚实地说，这套架构有成本：</p><p><strong>一、多一层间接。</strong> 看 <code>{{ site.name }}</code> 不如看 <code>喵洛阁</code> 直观。新人接手要先去读配置文件。</p><p><strong>二、类型定义要维护。</strong> <code>useSiteConfig()</code> 里的 interface 得和 <code>blog.config.ts</code> 保持同步。</p><p><strong>三、过度抽象的风险。</strong> 如果某个值只在一个组件里用一次，且永远不会变，那抽到配置层就是浪费。</p><p>我的判断标准是：<strong>如果一个值有 ≥ 2 个使用点，或者有「未来可能改」的可能，就抽出来。否则就地写死。</strong></p><p>比如「回到首页」这个按钮的文案——它在 404 页和错误页都用，所以放进了 <code>app/data/system.ts</code>。</p><p>而 <code>aria-hidden="true"</code> 这种纯技术属性，就地写，不进配置。</p><h2 id="怎么验证没漏">怎么验证没漏</h2><p>验收标准里有一条：<code>grep -r "硬编码字符串" app/components app/pages</code> 命中为 0。</p><p>我实际跑的命令是：</p><pre><code class="language-bash"># 找中文字符串（排除注释和 CSS）
grep -rn '[\u4e00-\u9fa5]' app/components app/pages \
  --include='*.vue' | grep -v '^\s*\*' | grep -v '&lt;!--'</code></pre><p>第一次跑出来 47 处，其中 41 处是真硬编码，6 处是误报（CSS 注释里的说明文字）。</p><p>改完之后是 0。</p><h2 id="结论">结论</h2><blockquote><p>零硬编码不是为了"代码漂亮"，是为了<strong>让配置变更具备原子性</strong>。</p></blockquote><p>一个改了会漏的地方，早晚会漏。一个结构上不可能漏的地方，永远不漏。</p><p>这就是架构和整洁的区别。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>慢下来之后我才开始写得下去</title>
      <link>https://www.kemiaosw.top/posts/slow-writing</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/slow-writing</guid>
      <pubDate>Wed, 18 Jun 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>有很长一段时间，我以为自己写不出东西是因为<strong>工具不够好</strong>。</p><p>于是换编辑器：Sublime → VS Code → Obsidian → Typora → 又回到 VS Code。</p><p>换笔记系统：印象笔记 → Notion → Roam → Logseq → 纯 markdown 文件夹。</p><p>换写作方法：卡片笔记法、Zettelkasten、番茄钟、晨间日记。</p><p>三年过去，公开发布的文章：<strong>4 篇</strong>。</p><h2 id="转折点">转折点</h2><p>去年年底我清电脑，翻到一个 2019 年的文件夹，里面躺着 37 个 <code>.md</code> 文件。</p><p>标题都是这样的：</p><ul><li><code>关于性能优化的一些想法.md</code></li><li><code>重构的时机.md</code></li><li><code>为什么我不喜欢 TypeScript.md</code></li></ul><p>每个文件打开都是 200 字左右，写到一半停住。</p><p>那 200 字都不差。它们没写完的原因不是写得不好，是我在写的过程中不断问自己：</p><blockquote><p>这篇有价值吗？别人会不会觉得太浅？这个观点是不是早就有人写过？</p></blockquote><p>然后我就去搜，去对比，去补充，去重写开头。</p><p><strong>一篇文章的写作时间，80% 花在自我怀疑上。</strong></p><h2 id="我改了什么">我改了什么</h2><p>不是换了工具，是换了标准。</p><p>以前的标准：这篇文章要提供<strong>新的、完整的、正确的</strong>见解。</p><p>现在的标准：这篇文章要让我自己<strong>三个月后还能看懂我当时在想什么</strong>。</p><p>这个转变带来的直接后果是：不再追求全面。</p><p>一篇讲「暗色模式」的文章，我不再试图覆盖所有情况（品牌色怎么办、图片怎么处理、打印样式怎么办）。我只写我自己踩过的那三个坑。</p><p><strong>不完整，但真实。</strong></p><h2 id="具体做法">具体做法</h2><h3 id="一先写正文最后写标题">一、先写正文，最后写标题</h3><p>标题是最容易卡住的地方，因为它要同时满足"准确"和"吸引人"。</p><p>所以我现在先写正文，写完再回头看这篇到底在说什么，标题自然就有了。</p><p>有时候标题会变成一句正文里的话。</p><h3 id="二允许文章短">二、允许文章短</h3><p>我有一篇只有 400 字。它讲了一个很小的 CSS 技巧。</p><p>如果按以前的标准，这个长度不值得发。现在我觉得：如果这 400 字能省下别人 20 分钟试错，它就值得存在。</p><h3 id="三不修改超过两遍">三、不修改超过两遍</h3><p>第一遍写完，放一天，第二遍通读改错别字和逻辑断层。</p><p>然后发。</p><p><strong>不再有第三遍。</strong> 第三遍开始就是自我怀疑的循环了。</p><h3 id="四公开发布而不是攒着">四、公开发布，而不是攒着</h3><p>攒稿的诱惑很大：「等我攒够十篇再一起发，显得专业一点。」</p><p>但攒稿的结果通常是：攒到第三篇就停了，因为看不到反馈。</p><p>发出去之后，哪怕只有一个人读，那种"有人看了"的感觉会推着写下一篇。</p><h2 id="现在的状态">现在的状态</h2><p>从去年年底到现在，8 个月，写了 <strong>23 篇</strong>。</p><p>没有一篇是"完美"的。有几篇我现在回头看觉得观点太幼稚。有一篇甚至有个技术错误（后来在评论区被指出来了）。</p><p>但 23 篇 &gt; 4 篇。</p><p>而且我发现一件有意思的事：<strong>写得越多，写得越快。</strong></p><p>第一篇花了三个周末。最近一篇花了一个晚上。</p><p>不是因为变厉害了，是因为<strong>不再为"写得不够好"而停下来</strong>。</p><h2 id="一句话">一句话</h2><blockquote><p>写作的敌人不是没灵感，是完美主义。</p></blockquote><p>完美主义让你在第一个段落就反复推敲，然后永远到不了第二段。</p><p>放弃完美，先写完，再改。这是唯一有效的方法。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>写给 375px 的界面</title>
      <link>https://www.kemiaosw.top/posts/mobile-first-375</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/mobile-first-375</guid>
      <pubDate>Mon, 02 Jun 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>移动端适配做得好不好，看三件事就够了：</p><ol><li>底部有没有 Tab 栏</li><li>主题切换按钮在不在拇指够得着的地方</li><li>环境光暗的时候，界面有没有做适配</li></ol><h2 id="为什么是底部-tab-栏">为什么是底部 Tab 栏</h2><p>单手握持手机时，拇指的自然活动范围是一个以屏幕下缘为圆心的扇形。</p><p>屏幕顶部 1/3 是拇指的「不可达区」。把导航放那里，意味着用户要换手或者用另一只手。</p><p>所以 iOS 和 Android 的主流 App 全都把一级导航放底部。Web 应用也应该这样。</p><p>我的做法：</p><pre><code class="language-text">桌面 ≥1024px    顶部水平导航
平板 768-1023   汉堡菜单
移动 &lt;768px     底部固定 Tab 栏（4-5 格）</code></pre><p>移动端底栏的 5 格是：<strong>首页 / 归档 / 分类 / 标签 + 搜索</strong>。</p><h2 id="安全区被忽略的-iphone-细节">安全区：被忽略的 iPhone 细节</h2><p>全面屏手机底部有一条系统手势条（Home Indicator）。</p><p>如果你的固定底栏紧贴屏幕底缘，手势条会压在它上面：</p><pre><code class="language-css">/* 错误 */
.tabbar {
  position: fixed;
  bottom: 0;
}

/* 正确 */
.tabbar {
  position: fixed;
  bottom: 0;
  padding-bottom: env(safe-area-inset-bottom, 0);
}</code></pre><p><code>env(safe-area-inset-bottom)</code> 在 iPhone 上是 34px，在没有手势条的设备上是 0。</p><p>一行 CSS，避免手势条盖住「搜索」按钮。</p><h2 id="_375px-下的正文排版">375px 下的正文排版</h2><p>我的正文容器在桌面是 764px，移动端自然是 100% 减去左右 padding。</p><p>字号<strong>不能缩</strong>。</p><p>很多人在移动端把正文从 17px 缩到 15px，理由是"屏幕小"。</p><p>这是反的：<strong>屏幕越小，眼睛离屏幕越近，但单行字数也越少</strong>。缩字号会让行更短、折行更多，读起来更累。</p><p>正确的移动端排版：</p><ul><li>字号不变（17px）</li><li>行高不变（1.75）</li><li>padding 收窄（24px → 20px）</li><li>代码块允许横向滚动（不要折行）</li></ul><h2 id="图片的移动端处理">图片的移动端处理</h2><p>全宽图片在小屏幕上没问题，但要注意两点：</p><p><strong>一、高度别用 vh 定死。</strong> 手机竖屏的 vh 包含地址栏高度，会跳。</p><p><strong>二、懒加载是必须的。</strong> 文章第三屏往后的图片，用户可能永远不会滚到。</p><pre><code class="language-html">&lt;img loading="lazy" decoding="async" ... /&gt;</code></pre><p><code>decoding="async"</code> 让图片解码不阻塞主线程，和 lazy 是两个维度的事，都要写。</p><h2 id="断点的哲学">断点的哲学</h2><p>我用的断点很少：</p><pre><code class="language-text">&lt; 768px      移动
768-1023px   平板
≥ 1024px     桌面
≥ 1280px     桌面 + 目录旁栏</code></pre><p>只有四个，因为<strong>断点应该对应真实的设备形态，而不是随便划几刀</strong>。</p><p>平板为什么用汉堡菜单？因为 768-1023px 宽度下，水平导航 + 正文 764px 会挤，但底栏又显得空间浪费。汉堡菜单是这个形态下的正确答案。</p><h2 id="触控目标">触控目标</h2><p>所有可点击元素的触控目标至少 44×44px（iOS HIG 的标准）。</p><p>我的搜索按钮是 32×32px 的图标，但外层有 16px 的点击热区扩展：</p><pre><code class="language-css">.search-trigger {
  width: 32px;
  height: 32px;
  position: relative;
}

.search-trigger::after {
  content: '';
  position: absolute;
  inset: -8px;
}</code></pre><p>视觉小，触控大。这是图标按钮的标准做法。</p><h2 id="一句话">一句话</h2><blockquote><p>移动端适配不是"能用"，是<strong>单手、通勤、暗光下都不难受</strong>。</p></blockquote><p>做到这三点，就已经超过 80% 的网站了。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>无加载不是速度，是连续感</title>
      <link>https://www.kemiaosw.top/posts/no-loading-feel</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/no-loading-feel</guid>
      <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>先做一个思想实验。</p><p>方案 A：点击链接后，页面白屏 300ms，然后新页面完整出现。</p><p>方案 B：点击链接后，立刻显示骨架屏，800ms 后内容填进来。</p><p>哪个更快？A。</p><p>哪个感觉更快？<strong>A。</strong></p><p>骨架屏不是为了让页面"看起来在加载"，它是在承认"我在加载"。而这个承认本身，就是一种延迟信号。</p><h2 id="真正的无加载是什么">真正的「无加载」是什么</h2><p>无加载的意思是：<strong>用户从未看到"等待"这个状态。</strong></p><p>实现它需要三个条件同时成立：</p><ol><li>页面在点击之前就已经准备好了</li><li>切换过程不出现空白帧</li><li>没有任何 loading 指示器</li></ol><h2 id="条件一提前准备">条件一：提前准备</h2><p>SSG（静态生成）把这件事做到了极致：所有页面在构建时就变成了 HTML 文件。</p><p>点击链接时，浏览器请求的是一个已经存在的静态文件——不是"等服务器查询数据库再渲染"。</p><p>但即使如此，仍然有一次网络往返。所以还需要<strong>预取</strong>：</p><pre><code class="language-html">&lt;!-- Nuxt 的 NuxtLink 默认开启 prefetch --&gt;
&lt;NuxtLink to="/archive"&gt;归档&lt;/NuxtLink&gt;</code></pre><p>Nuxt 会在链接进入视口时预取目标页面的 payload。用户真正点击时，数据已经在内存里了。</p><p><strong>关键细节</strong>：外链和友链要显式关闭预取，否则会预取一堆无关域名。</p><pre><code class="language-html">&lt;NuxtLink to="/links" no-prefetch&gt;友链&lt;/NuxtLink&gt;</code></pre><h2 id="条件二不出现空白帧">条件二：不出现空白帧</h2><p>即使数据准备好了，页面切换仍然会有一次 DOM 重建。</p><p>如果没有过渡，你会看到：旧页面消失 → 空白 → 新页面出现。</p><p>那"空白"可能只有 16ms（一帧），但眼睛能看见。</p><p>解法是 <code>out-in</code> 模式的页面过渡：</p><pre><code class="language-ts">// nuxt.config.ts
app: {
  pageTransition: { name: 'page', mode: 'out-in' },
}</code></pre><pre><code class="language-css">.page-enter-active,
.page-leave-active {
  transition: opacity 200ms ease-out, transform 200ms ease-out;
}

.page-enter-from {
  opacity: 0;
  transform: translateY(4px);
}

.page-leave-to {
  opacity: 0;
}</code></pre><p>200ms 的淡入淡出。旧页面淡出的同时新页面淡入，中间没有空白帧。</p><p><strong>为什么是 200ms？</strong> 低于 150ms 感知不到，高于 300ms 开始觉得慢。200ms 是"有过渡但不等待"的窗口。</p><h2 id="条件三删掉-loading-指示器">条件三：删掉 loading 指示器</h2><p>这一条最反直觉。</p><p>大部分项目会加一个顶部进度条（NuxtLoadingIndicator）：</p><pre><code class="language-vue">&lt;!-- 删掉这个 --&gt;
&lt;NuxtLoadingIndicator /&gt;</code></pre><p>进度条的存在本身就在告诉用户："可能要等一下"。</p><p>当你的页面跳转真的在 200ms 内完成时，进度条会<strong>闪一下</strong>——比没有更糟。</p><p>所以我把 <code>NuxtLoadingIndicator</code> 完全删掉了，靠 view transition 自然过渡。</p><h2 id="一个真实的对比">一个真实的对比</h2><p>改之前（有骨架屏 + 进度条）：</p><pre><code class="language-text">点击 → 进度条出现 → 骨架屏 → 内容填入 → 进度条消失
总时长：约 600ms，用户全程在"等待"</code></pre><p>改之后（SSG + 预取 + 过渡）：</p><pre><code class="language-text">点击 → 淡出 → 淡入
总时长：约 200ms，用户感知为"瞬间"</code></pre><h2 id="什么情况下做不到">什么情况下做不到</h2><p>诚实地说，这套方案有三个前提：</p><p><strong>一、内容必须是静态的。</strong> 如果页面需要实时数据（股票、聊天），SSG 帮不上忙。</p><p><strong>二、页面数量要可控。</strong> 全量预渲染 10 万个页面，构建要几小时。</p><p><strong>三、首屏没法这样优化。</strong> 用户第一次访问仍然要下载 HTML + CSS + JS。这是物理限制。</p><p>对一个个人博客来说，这三个前提全都成立——所以它能做到真正的无加载。</p><h2 id="首屏怎么办">首屏怎么办</h2><p>首屏的唯一优化方向是<strong>减小体积</strong>。</p><p>我的做法：</p><ul><li>没有第三方脚本（统计默认关闭）</li><li>字体分片，实际加载约 180KB</li><li>搜索索引懒加载（首次搜索才请求）</li><li>零运行时状态库（没有 Pinia）</li></ul><p>结果：首屏 JS 约 41KB（gzip 后）。</p><h2 id="一句话">一句话</h2><blockquote><p>无加载不是把加载藏起来，是<strong>让加载根本不需要发生</strong>。</p></blockquote><p>这两者的区别在于：前者是障眼法，后者是架构。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
    <item>
      <title>我把 CSS 变量当成了 API</title>
      <link>https://www.kemiaosw.top/posts/css-variables-as-api</link>
      <guid isPermaLink="true">https://www.kemiaosw.top/posts/css-variables-as-api</guid>
      <pubDate>Sun, 20 Apr 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>设计系统最难的部分不是"定义一套变量"，是<strong>保证没人绕过它</strong>。</p><p>你可以写一份完美的 <code>tokens.css</code>，但只要有人在某个组件里写了 <code>style="margin-top: 24px"</code>，系统就开始腐烂了。</p><h2 id="我的做法让-token-只有一个来源">我的做法：让 token 只有一个来源</h2><pre><code class="language-text">app/theme.config.ts   ← 唯一的真相源（TypeScript 对象）
        ↓ 构建期编译
tokens.generated.css  ← 自动生成，不手改
        ↓
组件里的 var(--accent)  ← 只读变量</code></pre><p>关键在中间那一步：<strong>CSS 不是手写的，是生成的。</strong></p><pre><code class="language-ts">// modules/design-tokens.ts（简化）
const theme = await import('../app/theme.config.ts')

const css = `
:root {
  --accent: ${theme.accent.light};
  --background: ${theme.neutral.light.background};
  --content-max-width: ${theme.contentMaxWidth};
  --duration-base: ${theme.animation.duration}ms;
}

.dark {
  --accent: ${theme.accent.dark};
  --background: ${theme.neutral.dark.background};
}
`</code></pre><p>这样如果有人想改强调色，他<strong>只能</strong>去改 <code>theme.config.ts</code>——因为 CSS 文件里改的东西下次构建就没了。</p><h2 id="一个必须解决的坑">一个必须解决的坑</h2><p>Tailwind v4 用 <code>@theme</code> 块定义工具类：</p><pre><code class="language-css">@theme {
  --color-accent: #a94f33;
}</code></pre><p>这会生成 <code>bg-accent</code>、<code>text-accent</code> 等工具类。</p><p><strong>但如果你把 hex 直接写进去，暗色模式就失效了</strong>——因为 <code>--color-accent</code> 被编译进了工具类，<code>.dark</code> 里覆盖 <code>--accent</code> 不会影响它。</p><p>正确做法是让 <code>@theme</code> 指向动态变量：</p><pre><code class="language-css">@theme {
  --color-accent: var(--accent);  /* 不是 hex */
}

:root { --accent: #a94f33; }
.dark { --accent: #cc785c; }</code></pre><p>这样 <code>bg-accent</code> 编译出的 CSS 是 <code>background-color: var(--accent)</code>，会跟随主题切换。</p><p>我第一次写的时候踩了这个坑，暗色模式下所有强调色都不变，排查了半小时。</p><h2 id="静态-token-和动态-token-要分开">静态 token 和动态 token 要分开</h2><p>不是所有 token 都需要跟随主题切换。</p><table><thead><tr><th>类型</th><th>例子</th><th>放哪</th></tr></thead><tbody><tr><td>动态（明暗不同）</td><td>颜色</td><td><code>:root</code> + <code>.dark</code></td></tr><tr><td>静态（明暗相同）</td><td>字号、间距、圆角、动效时长</td><td>只放 <code>:root</code></td></tr></tbody></table><p>把字号也放进 <code>.dark</code> 里是浪费——它不会变，重复声明只会让 CSS 变长。</p><h2 id="为什么不用-css-in-js">为什么不用 CSS-in-JS</h2><p>有人会问：为什么不直接在 JS 里算好样式？</p><p>三个理由：</p><p><strong>一、SSR 友好。</strong> CSS 变量在 HTML 里就能生效，不需要等 JS 执行。</p><p><strong>二、无运行时开销。</strong> 构建期生成，运行时零成本。</p><p><strong>三、可被浏览器优化。</strong> 变量切换只是重绘，不触发重排。</p><h2 id="组件里怎么写">组件里怎么写</h2><pre><code class="language-vue">&lt;template&gt;
  &lt;button class="btn"&gt;点击&lt;/button&gt;
&lt;/template&gt;

&lt;style scoped&gt;
.btn {
  background-color: var(--accent);
  border-radius: calc(var(--radius) / 2);
  padding: var(--space-item) var(--space-block);
  transition: opacity var(--duration-base) var(--easing-base);
}
&lt;/style&gt;</code></pre><p>注意这里<strong>没有一个魔法数字</strong>：</p><ul><li><code>var(--accent)</code> 而不是 <code>#a94f33</code></li><li><code>calc(var(--radius) / 2)</code> 而不是 <code>4px</code></li><li><code>var(--duration-base)</code> 而不是 <code>200ms</code></li></ul><h2 id="怎么验证没绕过">怎么验证没绕过</h2><p>验收标准里有一条：改一个 token，全站视觉同步更新。</p><p>我的验证方法是<strong>改一个不可能看错的值</strong>：</p><pre><code class="language-ts">// theme.config.ts
accent: { light: '#ff0000', dark: '#00ff00' }</code></pre><p>重新构建，全站所有强调色（导航下划线、焦点环、搜索高亮、引用线）都应该变成红绿。</p><p>如果有任何一处没变，说明那里是硬编码的。</p><h2 id="收益">收益</h2><p>半年下来，这套做法带来的实际好处：</p><p><strong>一、换肤成本 ≈ 0。</strong> 想试新的强调色，改两行，刷新。</p><p><strong>二、暗色模式不可能漏。</strong> 因为颜色全是变量，<code>.dark</code> 块里定义一次就够。</p><p><strong>三、设计评审有依据。</strong> 讨论"这个圆角是不是太大"时，看的是 <code>theme.config.ts</code> 里的 <code>radius: '0.5rem'</code>，不是某个组件里的 <code>border-radius: 12px</code>。</p><h2 id="一句话">一句话</h2><blockquote><p>设计 token 的价值不在于"有一份变量表"，而在于<strong>让绕过它变得比遵守它更麻烦</strong>。</p></blockquote><p>这才是系统。</p>]]></description>
      <author>hi@kemiaosw.top (克喵:))</author>
    </item>
  </channel>
</rss>
