<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh_CN">
  <title>初月_Yuechu 官方主页</title>
  <subtitle>月初时代</subtitle>
  <link href="https://www.ycera.cn/" rel="alternate" type="text/html"/>
  <link href="https://www.ycera.cn/atom.xml" rel="self" type="application/atom+xml"/>
  <id>https://www.ycera.cn/</id>
  <updated>2026-10-31T00:00:00.000Z</updated>
  <entry>
    <title>你好，初月</title>
    <link href="https://www.ycera.cn/posts/welcome/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/welcome/</id>
    <published>2026-10-31T00:00:00.000Z</published>
    <updated>2026-10-31T00:00:00.000Z</updated>
    <summary>初月_Yuechu 的个人博客正式开张，这里会记录一些日常与想法。</summary>
    <content type="html"><![CDATA[<p>欢迎来到我的新网站。</p>
<p>这里是初月_Yuechu 的个人博客，一个用来存放文字、记录日常、整理想法的地方。网站刚刚搭好，很多东西还在慢慢添置，但至少从今天开始，它会有一个真正属于自己的角落。</p>
<h2>关于名字</h2>
<p>初月，月初的月，月初的初。名字里有「开始」的意思，也希望这里能一直保持刚开始写东西时的那份认真。</p>
<h2>之后会写什么</h2>
<ul>
<li>日常生活的片段与随想</li>
<li>折腾网站、工具和代码的记录</li>
<li>读到的好书、看过的作品</li>
<li>偶尔一些没有归类的碎碎念</li>
</ul>
<h2>最后</h2>
<p>且视他人之疑目如盏盏鬼火，大胆地去走你的夜路。</p>
<p>如果你偶然路过这里，欢迎随便看看。之后的故事，就从这一篇开始。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="随笔"/>
  </entry>
  <entry>
    <title>从主题到 npm 包：同一份代码的两种活法</title>
    <link href="https://www.ycera.cn/posts/theme-to-npm-package/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/theme-to-npm-package/</id>
    <published>2026-10-31T00:00:00.000Z</published>
    <updated>2026-10-31T00:00:00.000Z</updated>
    <summary>同一个博客主题，既可以直接改源码，也可以作为 npm 包安装到别人项目里。双模式带来的约束比想象中多。</summary>
    <content type="html"><![CDATA[<p>最初我以为博客主题只有一种使用方式：克隆仓库、改配置、写文章。后来才发现，同一份代码还可以发布成 npm 包，让其他人在自己的 Astro 项目里安装使用。</p>
<p>这两种模式共享同一套源码，但运行环境完全不同。</p>
<h2>两种模式的分界</h2>
<pre><code>flowchart TB
  Source[主题源码]
  Source --&gt; A[源码模式：项目直接持有 src/]
  Source --&gt; B[npm 模式：用户项目安装 shirones]
  A --&gt; A1[配置在 src/config]
  B --&gt; B1[配置覆盖在用户项目]
  B --&gt; B2[内容在 shirones/content]
</code></pre>
<p>源码模式下，项目拥有所有文件，想改哪里就改哪里。npm 模式下，主题代码在 <code>node_modules</code> 里，用户的配置和内容必须放在包外的位置。</p>
<p>==同一个改动，在两种模式下都成立，才算真正完成。==</p>
<h2>配置不能假设自己在哪</h2>
<p>源码模式里，读取 <code>src/config/siteConfig.ts</code> 看起来理所当然。到了 npm 模式，这个路径可能属于主题包，而不是用户项目。</p>
<p>因此配置读取需要遵循「用户覆盖优先」的规则，而不是直接读主题目录里的默认值。</p>
<pre><code>const userConfig = readUserConfig("site");
const finalConfig = userConfig
  ? mergeWithDefaults(userConfig)
  : defaults;
</code></pre>
<p>:::warning[不要在主题代码里随意读 process.cwd()]
<code>process.cwd()</code> 指向用户项目根目录，不代表主题文件也在那里。读取主题自有文件时，应该基于包自身路径，而不是当前工作目录。
:::</p>
<h2>构建配置需要重新生成</h2>
<p>Astro 的集成机制允许主题在启动时重建 <code>astro.config.mjs</code>，把必需的插件、路由和资源注入用户项目。这意味着配置文件不是静态的，而是会根据模式动态生成。</p>
<p>:::tip[集成层的职责是「补齐」，不是「覆盖」]
集成应该只补用户没有配置的部分，并保留用户自己的设置。直接覆盖用户的 Astro 配置，会让主题变得难以共存。
:::</p>
<h2>发布前必须检查依赖边界</h2>
<p>最容易出问题的地方，是「本地能构建，安装后失败」。原因通常是某个包写在了 <code>devDependencies</code>，但运行时需要它。</p>
<p>项目专门有一条检查命令，用来发现这种情况：</p>
<pre><code>pnpm check:package-manifest
</code></pre>
<p>它会在发布前扫描源码里的 import，确认每个运行时依赖都出现在正确的位置。</p>
<table>
<thead>
<tr>
<th>依赖类型</th>
<th>用途</th>
<th>是否随包安装</th>
</tr>
</thead>
<tbody>
<tr>
<td>dependencies</td>
<td>运行时必需</td>
<td>是</td>
</tr>
<tr>
<td>peerDependencies</td>
<td>由用户项目提供</td>
<td>否</td>
</tr>
<tr>
<td>devDependencies</td>
<td>仅主题仓库开发使用</td>
<td>否</td>
</tr>
</tbody>
</table>
<p>:::caution[侧效应导入也要留意]
某些包只是在类型声明或副作用文件里被引用，不会被普通打包分析捕捉。检查时需要区分「声明文件」和「运行时代码」，否则容易漏掉真正的缺失依赖。
:::</p>
<h2>文档也是交付物</h2>
<p>npm 模式最容易让用户困惑的，不是代码，而是「我的配置应该放哪」「内容目录在哪」「为什么改了没生效」。这些问题需要在文档里给出明确答案，而不是让用户读源码。</p>
<p>:::details[双模式最累的部分是什么？]
不是写两套代码，而是每次改动都要问：这个改动在另一种模式下还成立吗？路径、配置、资源归属，任何一项假设错了，都会在发布后才暴露。
:::</p>
<h2>小结</h2>
<p>从主题到 npm 包，增加的是一层交付责任。源码模式可以只对自己负责，npm 模式必须对陌生环境负责。</p>
<p>也正因为如此，双模式反而逼着我把路径、配置和依赖边界想清楚。<strong>能经得起别人安装的代码，通常也会让自己的项目更干净</strong>。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>零额外负担：可选功能应该怎么设计</title>
    <link href="https://www.ycera.cn/posts/zero-extra-burden/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/zero-extra-burden/</id>
    <published>2026-10-30T00:00:00.000Z</published>
    <updated>2026-10-30T00:00:00.000Z</updated>
    <summary>评论、统计、小组件这些功能，开着很好用，关掉却常常留下残留。本站用一个简单原则约束它们。</summary>
    <content type="html"><![CDATA[<p>博客主题很容易变成「功能堆叠」：评论、统计、音乐、动画、各种小部件。每加一个功能，作者就多一份配置负担，读者就多一份加载成本。</p>
<p>本站采用一个很朴素的原则：<strong>关掉的功能，必须像从未存在过一样</strong>。</p>
<h2>什么叫「零额外负担」</h2>
<p>一个可选功能在 <code>enable: false</code> 或完全未配置时，应该满足四个条件：</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>要求</th>
</tr>
</thead>
<tbody>
<tr>
<td>网络</td>
<td>零外部请求</td>
</tr>
<tr>
<td>DOM</td>
<td>零节点、零布局偏移</td>
</tr>
<tr>
<td>包体</td>
<td>零新增依赖进入主包</td>
</tr>
<tr>
<td>兼容</td>
<td>不要求旧文章补字段</td>
</tr>
</tbody>
</table>
<p>==「关掉」不是「隐藏」，而是根本不参与构建。==</p>
<h2>配置层先做判断</h2>
<p>最简单的做法，是在配置解析阶段就把关闭的功能过滤掉，而不是等渲染时再 <code>if</code> 一下：</p>
<pre><code>export const navBarConfig: NavBarConfig = {
  links: pruneUnavailableNavLinks(defaultLinks, unavailableFeatureRoutes),
};
</code></pre>
<p>这样导航、页面入口和站点地图都拿到同一份结果，不会出现「导航没了，但页面还在」的半关闭状态。</p>
<h2>需要交互的功能按需加载</h2>
<p>有些功能即使开启，也不应该进入首屏主包。例如评论系统和图表渲染，只有在页面真正需要时才动态加载：</p>
<pre><code>flowchart LR
  A[页面构建] --&gt; B{功能开启?}
  B -- 否 --&gt; C[不生成任何资源]
  B -- 是 --&gt; D[生成触发点]
  D --&gt; E{用户触发?}
  E -- 否 --&gt; F[零网络请求]
  E -- 是 --&gt; G[动态加载模块]
</code></pre>
<p>:::tip[把「关闭」当成默认状态测试]
不要只在功能开启时测试页面。关掉功能后重新构建，检查 DOM、网络请求和包体是否真的干净，才能确认零负担成立。
:::</p>
<h2>为什么不用「留着以后可能有用」</h2>
<p>未使用的代码不会自动消失。它会进入依赖树、增加构建时间、带来安全更新负担，还会让后来的人误以为它是必要部分。</p>
<p>:::warning[功能开关不是垃圾回收]
加一个 <code>enable</code> 字段很容易，但关掉之后残留的样式、资源和数据读取不会自己消失。每个可选功能都需要明确的生命周期。
:::</p>
<h2>对内容作者的友好性</h2>
<p>零负担还有一层含义：<strong>不强迫旧内容迁移</strong>。新增一个可选字段时，旧文章不需要批量补 frontmatter；字段缺失时应该有合理默认值，而不是构建失败。</p>
<p>:::details[这条原则从哪里来？]
它来自一次次「只是加个小功能」带来的连锁反应：配置越来越多、页面越来越慢、关掉之后还要清理残留。把约束写进规则，比事后补救更省力。
:::</p>
<h2>小结</h2>
<p>可选功能的设计难点，不在开启时的效果，而在关闭时的干净程度。一个功能只有做到「开则可用、关则无痕」，才值得长期留在主题里。</p>
<p>这也是本站愿意为一些小功能写更多判断逻辑的原因：<strong>复杂度应该由需要它的人承担，而不是由所有人承担</strong>。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>搜索、RSS 与站点地图：静态站也要可被发现</title>
    <link href="https://www.ycera.cn/posts/search-rss-sitemap/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/search-rss-sitemap/</id>
    <published>2026-10-29T00:00:00.000Z</published>
    <updated>2026-10-29T00:00:00.000Z</updated>
    <summary>页面能打开只是第一步。让别人找得到、订阅得到、抓取得到，才算真正发布。</summary>
    <content type="html"><![CDATA[<p>静态博客没有数据库，也没有后台索引服务，但这不代表内容只能靠人记住网址。本站用几种「构建期生成」的方式，让内容可被搜索、订阅和抓取。</p>
<h2>搜索索引在构建时生成</h2>
<p>Pagefind 会扫描构建产物里的 HTML，生成一套可以前端查询的索引。整个过程不需要服务器：</p>
<pre><code>pnpm build
npx pagefind --site dist
</code></pre>
<p>构建完成后，搜索框直接查询本地索引。文章内容变化时，索引也会在下次构建时更新。</p>
<p>:::note[只索引真正的页面]
搜索索引只收录带 <code>data-pagefind-body</code> 标记的内容页。归档、标签、关于页这类导航页面不会挤占搜索结果，用户搜到的基本都是文章本身。
:::</p>
<h2>RSS 和 Atom 是长期订阅的基础</h2>
<p>RSS 看起来像上个时代的东西，但它仍然是内容订阅最稳定的方式之一。本站同时生成 RSS、Atom 和 JSON 订阅源，读者可以用任意阅读器订阅。</p>
<table>
<thead>
<tr>
<th>输出</th>
<th>路径</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>RSS</td>
<td><code>/rss.xml</code></td>
<td>通用订阅</td>
</tr>
<tr>
<td>Atom</td>
<td><code>/atom.xml</code></td>
<td>标准化订阅</td>
</tr>
<tr>
<td>Sitemap</td>
<td><code>/sitemap-index.xml</code></td>
<td>搜索引擎抓取</td>
</tr>
<tr>
<td>llms.txt</td>
<td><code>/llms.txt</code></td>
<td>面向 AI 阅读的站点摘要</td>
</tr>
</tbody>
</table>
<p>:::tip[订阅源要跟着内容一起构建]
不要手写订阅文件。让构建流程从文章集合生成，既能保证顺序正确，也不会出现「文章更新了，RSS 还是旧的」。
:::</p>
<h2>关闭的页面不能进站点地图</h2>
<p>站点地图应该只包含真正可访问的页面。如果某个功能被关闭，页面会跳转到 404，但它仍然可能出现在地图里，造成无效抓取。</p>
<p>本站用一份统一的禁用页面清单来过滤：</p>
<pre><code>if (skillsConfig.enable === false) disabled.push("skills");
if (projectsConfig.enable === false) disabled.push("projects");
</code></pre>
<p>配置和数据只维护一处，页面入口、导航裁剪和站点地图都读同一份状态。</p>
<p>==站点地图不是「页面清单」，而是「可访问页面清单」。==</p>
<h2>面向机器阅读的文本</h2>
<p>除了给搜索引擎看的 sitemap，本站还生成 <code>llms.txt</code> 和 <code>llms-full.txt</code>，用纯文本描述站点结构和主要内容。这不是为了排名，而是为了让自动阅读工具更容易理解站点。</p>
<p>:::details[需要多少 SEO 技巧？]
对个人博客来说，最重要的是标题、描述、可访问的 URL 和稳定的订阅源。复杂的排名技巧收益有限，先把基础做对更重要。
:::</p>
<h2>发布检查清单</h2>
<p>:::steps{title="每次发布后可以检查这几项"}</p>
<ol>
<li>
<p><strong>搜索索引是否更新</strong></p>
<p>构建日志里应该出现 Pagefind 的索引结果，页面数量与文章数量大致对应。</p>
</li>
<li>
<p><strong>订阅源是否包含新文章</strong></p>
<p>打开 RSS 或 Atom，确认最新一篇出现在列表顶部。</p>
</li>
<li>
<p><strong>站点地图是否排除了关闭页面</strong></p>
<p>检查 <code>sitemap-index.xml</code>，确认没有已关闭功能的路径。</p>
</li>
<li>
<p><strong>描述和标题是否完整</strong></p>
<p>分享卡片和搜索结果都依赖 frontmatter 里的 <code>title</code> 与 <code>description</code>。
:::</p>
</li>
</ol>
<h2>小结</h2>
<p>搜索、订阅和站点地图都不是「上线之后再补」的功能。它们和文章一起生成，才能保证内容在发布的那一刻就是可发现的。</p>
<p>对静态站点来说，这可能是最划算的一类工程投入：构建一次，长期受益。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>一次字体构建失败带来的教训</title>
    <link href="https://www.ycera.cn/posts/local-fonts-lesson/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/local-fonts-lesson/</id>
    <published>2026-10-28T00:00:00.000Z</published>
    <updated>2026-10-28T00:00:00.000Z</updated>
    <summary>构建时突然报 CannotFetchFontFile，页面还没生成就失败了。问题不在代码，而在构建阶段依赖了外部 CDN。</summary>
    <content type="html"><![CDATA[<p>那天改完配置，执行 <code>pnpm build</code>，终端直接抛出：</p>
<pre><code>[CannotFetchFontFile] Failed to fetch font file
https://cdn.jsdelivr.net/fontsource/fonts/jetbrains-mono@latest/...
</code></pre>
<p>本地开发一切正常，构建却失败了。原因很明确：Astro 的字体提供方默认会从 CDN 拉取字体文件，而当时网络无法访问那个地址。</p>
<h2>构建阶段的外部依赖是隐藏风险</h2>
<p>很多构建流程看起来是「本地执行」，实际上仍然依赖远程资源：字体、图标、封面图、第三方数据。只要其中任何一个不可达，构建就会中断。</p>
<p>:::caution[能构建成功，不等于能在任何环境构建]
同一份代码，在作者机器上能过，在 CI 或另一台机器上失败，通常就是外部依赖造成的。构建可复现性比构建速度更值得优先保证。
:::</p>
<h2>把字体放回本地</h2>
<p>解决方式不是给 CDN 加重试，而是把字体文件放进仓库，改用本地数据源：</p>
<pre><code>{
  id: "outfit-body",
  family: "Outfit",
  role: "body",
  source: "local",
  variants: [
    { file: "src/assets/fonts/outfit-latin-400-normal.woff2", weight: 400 },
    { file: "src/assets/fonts/outfit-latin-500-normal.woff2", weight: 500 },
    { file: "src/assets/fonts/outfit-latin-700-normal.woff2", weight: 700 },
  ],
}
</code></pre>
<p>改完之后，构建不再请求外部字体地址，失败问题也随之消失。</p>
<p>:::steps{title="本地字体的处理流程"}</p>
<ol>
<li>
<p><strong>准备字体文件</strong></p>
<p>从依赖包或官方渠道获取 woff2，放进 <code>src/assets/fonts/</code>。</p>
</li>
<li>
<p><strong>声明本地来源</strong></p>
<p>在字体配置里把 <code>source</code> 改为 <code>local</code>，并列出每个字重对应的文件。</p>
</li>
<li>
<p><strong>构建时子集化</strong></p>
<p>构建工具会根据页面实际用到的字符生成子集，减少体积。</p>
</li>
<li>
<p><strong>检查预算</strong></p>
<p>运行字体检查脚本，确认总大小没有超过约定上限。
:::</p>
</li>
</ol>
<h2>子集化不等于可以随便加字体</h2>
<p>中文全量字体动辄几 MB，即使子集化，多个字重叠加也会迅速膨胀。本站的字体预算约为 6 MB，实际构建后约 844 KB，仍然有空间，但不能无节制地增加。</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>大小</th>
</tr>
</thead>
<tbody>
<tr>
<td>西文正文字体</td>
<td>约 30 KB</td>
</tr>
<tr>
<td>等宽字体</td>
<td>约 66 KB</td>
</tr>
<tr>
<td>中文字体子集</td>
<td>约 749 KB</td>
</tr>
</tbody>
</table>
<p>==字体优化的第一步不是压缩，而是删掉不需要的字重。==</p>
<h2>中文字重的现实限制</h2>
<p>本站使用的中文字体只有一个 Medium 字重。标题需要的 600/700 会由浏览器合成，效果不如真实字重扎实。</p>
<p>:::note[这是一个已知取舍]
换成带 400/500/700 的中文字体可以彻底解决，但文件体积会明显增加。当前阶段我选择先保证构建稳定和加载速度，把字体升级留到后续。
:::</p>
<h2>这次故障留下的经验</h2>
<ul>
<li>构建流程里尽量不依赖远程资源；</li>
<li>能本地化的资源就本地化；</li>
<li>出问题时先看「构建阶段请求了什么」，而不是只盯着报错行。</li>
</ul>
<p>:::tip[一个检查习惯]
如果你的构建在同一台机器上偶尔成功、偶尔失败，先检查网络依赖。可复现的构建，才有资格谈持续集成。
:::</p>
<p>字体问题看起来很小，但它提醒我：<strong>网站能否构建成功，取决于最薄弱的那条外部链路</strong>。把链路收回来，心里才踏实。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>i18n 工程化：十种语言，不靠硬编码</title>
    <link href="https://www.ycera.cn/posts/i18n-engineering/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/i18n-engineering/</id>
    <published>2026-10-27T00:00:00.000Z</published>
    <updated>2026-10-27T00:00:00.000Z</updated>
    <summary>界面文案看起来只是字符串，但一旦散落在组件里，就会变成最难维护的隐形成本。</summary>
    <content type="html"><![CDATA[<p>做多语言最容易犯的错误，是在组件里直接写一句中文，然后想着「以后有空再抽出来」。结果是文案越写越多，最后没人知道哪些已经翻译、哪些还是漏网之鱼。</p>
<p>本站的做法是从一开始就把界面文案集中管理。</p>
<h2>一个 Key，十份翻译</h2>
<p>所有用户可见的文案都通过 <code>I18nKey</code> 枚举引用，再交给翻译模块解析。组件里不出现具体语言：</p>
<pre><code>import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";

const title = i18n(I18nKey.archive);
</code></pre>
<p>至于 <code>archive</code> 最终显示成「归档」还是「Archive」，由当前语言决定，组件不需要知道。</p>
<table>
<thead>
<tr>
<th>层级</th>
<th>职责</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>I18nKey</code></td>
<td>定义所有可翻译的文案键</td>
</tr>
<tr>
<td>语言模块</td>
<td>提供各语言的翻译值</td>
</tr>
<tr>
<td><code>i18n()</code></td>
<td>运行时查找与回退</td>
</tr>
<tr>
<td>组件</td>
<td>只引用键，不写具体文案</td>
</tr>
</tbody>
</table>
<h2>占位符必须同名</h2>
<p>带参数的文案是最容易出错的地方。例如「共 {count} 篇」在每种语言里都必须保留 <code>{count}</code> 这个占位符名，否则替换时就会丢值。</p>
<p>:::warning[翻译不是改写]
参数名属于接口契约，不属于语言表达。可以调整语序、增加量词，但不能把 <code>{count}</code> 改成 <code>{数量}</code>，否则代码找不到对应的值。
:::</p>
<h2>回退策略要明确</h2>
<p>当某个语言缺少某个键时，系统需要一个确定的回退顺序。本站优先使用当前语言，缺失时回退到默认语言，而不是直接显示键名。</p>
<p>:::note[键名不应该出现在界面上]
如果用户看到 <code>archive</code> 这样的英文键，说明回退链断了。构建和测试阶段应该尽量把这类问题暴露出来，而不是留给读者发现。
:::</p>
<h2>为什么不用「自动翻译」省事</h2>
<p>自动翻译可以解决一部分重复劳动，但界面文案往往很短，缺少上下文。一个词在不同页面可能有完全不同的语气。机器翻译很难保证这种一致性。</p>
<p>==界面文案是产品语气的一部分，不能只当成字符串替换。==</p>
<p>:::tip[新增文案的流程]
先在 <code>I18nKey</code> 里加键，再补十个语言模块，最后在组件里引用。顺序反过来，就容易出现「组件已经上线，翻译还没跟上」的情况。
:::</p>
<h2>中文站点的特殊处理</h2>
<p>本站默认语言是简体中文，但 i18n 结构并没有因此简化。这样做的好处是：以后如果增加英文界面，不需要回头重构组件，只需要补翻译值。</p>
<p>:::details[分类和标签也要翻译吗？]
分类、标签和系列名属于内容，不是界面文案。它们由作者直接填写，因此不进入 <code>I18nKey</code> 体系。界面框架（如「分类」「标签」这些标题）才需要翻译。
:::</p>
<h2>小结</h2>
<p>i18n 的难点不在翻译本身，而在「让所有文案都有归属」。当每个字符串都能追溯到一个键，遗漏和重复才会变得可见。</p>
<p>对个人博客来说，这套机制可能有点重；但它带来的确定性，在长期维护中很划算。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>Swup 与持久化外壳：无刷新导航的代价</title>
    <link href="https://www.ycera.cn/posts/swup-navigation/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/swup-navigation/</id>
    <published>2026-10-26T00:00:00.000Z</published>
    <updated>2026-10-26T00:00:00.000Z</updated>
    <summary>页面切换没有白屏，体验确实更好。但持久化外壳也意味着，很多逻辑不能只写一次就完事。</summary>
    <content type="html"><![CDATA[<p>第一次体验 Swup 时，最直观的感受是「顺」。点击链接，页面内容淡入替换，导航栏和侧栏原地不动，没有整页白屏。</p>
<p>但真正开始维护时，我发现无刷新导航并不是免费的。它改变的不只是体验，还有代码的生命周期。</p>
<h2>外壳与内容的分工</h2>
<pre><code>flowchart TB
  Shell[持久化外壳：导航、侧栏、页脚]
  Container[#swup-container：可替换页面内容]
  Shell --&gt; Container
  Container --&gt; PageA[页面 A]
  Container --&gt; PageB[页面 B]
</code></pre>
<p>外壳只渲染一次，页面内容在每次导航时被替换。这样避免了重复渲染，但也带来一个关键问题：<strong>外壳里的代码不会因为路由变化而重新执行</strong>。</p>
<h2>生命周期比事件名更重要</h2>
<p>如果一段逻辑依赖当前页面的数据，它就不能只在首次加载时运行。常见做法是监听 Swup 的生命周期：</p>
<pre><code>document.addEventListener("astro:page-load", () =&gt; {
  initPageScopedFeatures();
});
</code></pre>
<p>:::warning[直接加载和客户端导航是两条路径]
首次打开页面时，走的是普通加载流程；点击站内链接时，走的是 Swup 替换流程。只在其中一条路径上初始化，就会出现「刷新后正常，点进去失效」或者相反的问题。
:::</p>
<p>我在处理筛选器、代码块增强和图表初始化时，都同时验证了这两种路径。测试时不能只刷新页面，还要从其他页面点进来。</p>
<h2>事件委托比重新绑定更稳</h2>
<p>页面内容被替换后，之前绑定在旧 DOM 上的事件监听器也会一起消失。与其每次导航后重新绑定，不如把监听器放在持久化的父节点上，用事件委托处理：</p>
<pre><code>document.addEventListener("click", (event) =&gt; {
  const target = event.target as HTMLElement;
  const button = target.closest("[data-copy]");
  if (!button) return;
  copyToClipboard(button.dataset.copy ?? "");
});
</code></pre>
<p>:::spoiler[那为什么不干脆关掉 Swup？]
关掉当然最省事，但页面切换的连贯感也会消失。我的判断是：只要把生命周期约定写清楚，维护成本是可以接受的；真正危险的是「以为它会自动处理」。
:::</p>
<h2>体验与复杂度的交换</h2>
<table>
<thead>
<tr>
<th>方面</th>
<th>普通整页导航</th>
<th>Swup 无刷新导航</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>
</tbody>
</table>
<p>==无刷新导航买到的是体验，付出的是对生命周期的理解。==</p>
<p>:::tip[一个检查清单]
改动涉及页面级交互时，问自己三个问题：首次加载会执行吗？客户端导航后会执行吗？从旧页面带回的状态需要清理吗？这三个问题答完，大部分 Swup 问题就提前避开了。
:::</p>
<h2>小结</h2>
<p>Swup 不是「装上就变好」的插件。它要求开发者明确区分「一次性外壳」和「可替换页面」，并在正确的生命周期里做正确的事。</p>
<p>理解这一点之后，无刷新导航才真正成为体验优化，而不是隐藏的 bug 来源。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>Markdown 扩展管线：让文章多一点表达力</title>
    <link href="https://www.ycera.cn/posts/markdown-pipeline/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/markdown-pipeline/</id>
    <published>2026-10-25T00:00:00.000Z</published>
    <updated>2026-10-25T00:00:00.000Z</updated>
    <summary>普通 Markdown 够用，但不够有趣。本站通过 remark/rehype 管线加入提示框、步骤流、选项卡等语法，并按需加载样式。</summary>
    <content type="html"><![CDATA[<p>Markdown 的优势是简单，劣势也是简单。写技术文章时，我经常需要「提示」「步骤」「对照代码」这些结构，而标准语法里没有。</p>
<p>解决方案不是换一个富文本编辑器，而是在 Markdown 解析管线上做扩展。</p>
<h2>从文本到 HTML 的旅程</h2>
<pre><code>flowchart LR
  A[Markdown 源文件] --&gt; B[remark 解析与转换]
  B --&gt; C[rehype 生成 HTML 节点]
  C --&gt; D[自定义组件节点]
  D --&gt; E[Astro 渲染输出]
</code></pre>
<p>remark 负责理解 Markdown 结构，rehype 负责把结构变成 HTML。自定义语法就插在这两层之间：先识别作者写的指令，再把它转换成对应的组件节点。</p>
<p>:::tip[扩展的前提是不破坏原文]
所有自定义语法都有一个共同约束：非法或残缺的输入必须回退成普通 Markdown，不能静默吞掉内容。这样即使解析器升级，文章也不会凭空少一段。
:::</p>
<h2>一个容器语法长什么样</h2>
<p>下面是本站提示框的写法：</p>
<pre><code>:::tip[部署前检查]
确认构建通过，再推送生产环境。
:::
</code></pre>
<p>渲染后就是带图标和强调色的提示块。它不需要在 frontmatter 里开启开关，也不需要额外配置——只要文章里出现这个语法，对应样式和运行时就会被收集。</p>
<h2>扩展不是越多越好</h2>
<p>每加一种语法，就多一份解析逻辑、一套样式和一组测试。真正需要控制的不是「支持多少种」，而是「哪些语法值得长期维护」。</p>
<table>
<thead>
<tr>
<th>语法</th>
<th>用途</th>
<th>运行时成本</th>
</tr>
</thead>
<tbody>
<tr>
<td>提示框</td>
<td>强调注意事项</td>
<td>纯 CSS</td>
</tr>
<tr>
<td>步骤流</td>
<td>教程与流程</td>
<td>纯 CSS</td>
</tr>
<tr>
<td>选项卡</td>
<td>对照不同方案</td>
<td>少量客户端脚本</td>
</tr>
<tr>
<td>Mermaid</td>
<td>流程图与关系图</td>
<td>按需加载渲染器</td>
</tr>
<tr>
<td>代码树</td>
<td>展示多文件结构</td>
<td>纯 CSS + 少量交互</td>
</tr>
</tbody>
</table>
<p>==只在实际写作中反复用到的语法，才值得进入长期清单。==</p>
<h2>按需加载是怎么做到的</h2>
<p>构建时会扫描文章内容，判断出现了哪些语法，再只把这些语法的样式和脚本注入当前页面。结果是：</p>
<ul>
<li>一篇纯文字文章不会带上 Mermaid 渲染器；</li>
<li>没有选项卡的页面不会加载选项卡脚本；</li>
<li>语法清单集中在 manifest 里，新增语法时只需要登记一次。</li>
</ul>
<pre><code>// 页面只加载实际用到的语法资源
const usedSyntaxes = detectSyntaxes(articleMarkdown);
const assets = resolveAssets(usedSyntaxes, manifest);
</code></pre>
<p>:::warning[自定义语法也有学习成本]
语法越丰富，作者越需要记住写法。我的做法是：常用语法控制在少数几种，复杂语法只在确实需要时使用，并在文章里给出可复制的示例。
:::</p>
<h2>写作与渲染的分工</h2>
<p>理想状态下，作者只需要关心「我想表达什么结构」，而不是「这个结构对应哪个 HTML 标签」。提示框、步骤、选项卡这些语法，本质上都是在降低表达成本。</p>
<p>:::details[如果语法失效了怎么办？]
管线对非法输入保留原文，所以最坏情况是读者看到原始的 <code>:::tip</code> 文本，而不是整段内容消失。这比「渲染失败导致空白」安全得多。
:::</p>
<h2>小结</h2>
<p>Markdown 扩展管线的价值，不是把文章变成网页应用，而是让文字拥有更清晰的层次。只要守住「按需加载」和「非法回退」两条底线，扩展就可以成为写作的助力，而不是维护负担。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>内容集合与 Frontmatter：把写作变成数据</title>
    <link href="https://www.ycera.cn/posts/content-collections-frontmatter/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/content-collections-frontmatter/</id>
    <published>2026-10-24T00:00:00.000Z</published>
    <updated>2026-10-24T00:00:00.000Z</updated>
    <summary>文章不是普通文件，而是有结构的数据。Astro Content Collections 让这件事在构建阶段就被检查。</summary>
    <content type="html"><![CDATA[<p>写博客最怕的不是没内容，而是内容写完了，页面却是空的。少一个冒号、日期格式不对、字段名拼错，都可能让文章悄悄消失。</p>
<p>Astro 的内容集合把这类问题从「发布后才发现」提前到了「构建时直接报错」。</p>
<h2>Frontmatter 是文章的第一层结构</h2>
<p>::: field-group</p>
<p>::: field title
@type string
@required</p>
<p>文章标题。归档、搜索、RSS 和分享卡片都会用到。</p>
<p>:::</p>
<p>::: field published
@type date
@required</p>
<p>发布日期，决定归档位置和列表排序。格式必须是 <code>YYYY-MM-DD</code>。</p>
<p>:::</p>
<p>::: field draft
@type boolean
@default false
@optional</p>
<p>草稿开关。开启后只在开发环境可见，正式构建会被排除。</p>
<p>:::</p>
<p>::: field tags
@type string[]
@default []
@optional</p>
<p>标签数组，会进入标签聚合页。中文标签会直接作为展示文本。</p>
<p>:::</p>
<p>::::</p>
<p>这些字段看起来简单，但它们共同决定了一篇文章在站点里的位置、可见性和关联关系。</p>
<h2>一个最小但完整的例子</h2>
<pre><code>title: 你好，新网站
published: 2026-10-31
description: 第一篇正式文章。
tags:
  - 随笔
  - 建站
category: 随笔
draft: false
</code></pre>
<p>只要 <code>title</code> 和 <code>published</code> 存在，文章就能被识别。其余字段都有默认值，不需要为了「填满模板」而写一堆空字段。</p>
<p>:::important[日期不是随便写的字符串]
<code>published</code> 必须是日历日期。如果同一天发布多篇文章，可以额外提供带时区偏移的 <code>publishedAt</code>，例如 <code>2026-10-31T20:00:00+08:00</code>。只写日期时，系统会用文章 ID 做稳定排序。
:::</p>
<h2>写作流程已经固定下来</h2>
<p>:::steps{title="从新建到发布"}</p>
<ol>
<li>
<p><strong>生成骨架</strong></p>
<p>运行 <code>pnpm.cmd new-post my-post</code>，脚手架会按站点时区写好日期。</p>
</li>
<li>
<p><strong>写正文</strong></p>
<p>文件名就是 URL slug，正文直接使用 Markdown 和自定义语法。</p>
</li>
<li>
<p><strong>本地预览</strong></p>
<p>运行 <code>pnpm.cmd dev</code>，在浏览器里确认排版、图片和链接。</p>
</li>
<li>
<p><strong>正式发布</strong></p>
<p>确认 <code>draft</code> 为 <code>false</code>，执行构建并部署 <code>dist/</code>。
:::</p>
</li>
</ol>
<p>这套流程的价值在于：<strong>写作时只需要关心内容，结构检查交给工具</strong>。</p>
<h2>分类、标签和系列不是三套系统</h2>
<p>一开始我以为分类、标签、系列要分别维护，后来发现它们其实都从文章字段长出来：</p>
<ul>
<li><code>tags</code> 聚合出标签页；</li>
<li><code>category</code> 聚合出分类页；</li>
<li><code>series</code> 指向系列实体，<code>seriesOrder</code> 决定阅读顺序。</li>
</ul>
<p>文章是唯一的数据源，其他页面都是它的投影。这样做的好处是不会出现「文章改了标签，标签页却没更新」的同步问题。</p>
<p>:::tip[先写内容，再补结构]
新文章开始时，我只写标题、日期和正文。等文章真正成形，再补标签、分类和系列。过早设计分类体系，往往会写出很多用不上的空分类。
:::</p>
<h2>一点心得</h2>
<p>内容集合最容易被低估的地方，是它把「写作规范」变成了可以执行的代码。规范写在文档里，靠人记住；schema 写在代码里，构建时会替你检查。</p>
<p>对于一个人维护的博客来说，后者的价值更大——因为没有人会替你 review frontmatter。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>Tailwind 4 与 Stylus 共存的边界</title>
    <link href="https://www.ycera.cn/posts/tailwind-and-stylus/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/tailwind-and-stylus/</id>
    <published>2026-10-23T00:00:00.000Z</published>
    <updated>2026-10-23T00:00:00.000Z</updated>
    <summary>一个项目里同时出现 Tailwind 和 Stylus，听起来像重复建设。关键是划清它们各自负责的区域。</summary>
    <content type="html"><![CDATA[<p>第一次看到项目里同时使用 Tailwind 4 和 Stylus 时，我的反应是：为什么不统一成一种？</p>
<p>用了一段时间后，我反而理解了这种组合。它们解决的不是同一类问题。</p>
<h2>两种写法，两种职责</h2>
<p>Tailwind 擅长的是<strong>页面级、组合式</strong>的样式：间距、布局、对齐、响应式断点。这类样式变化频繁，写在模板里最直观。</p>
<p>Stylus 擅长的是<strong>组件内部、结构化</strong>的样式：伪元素、嵌套选择器、复杂的状态组合、动画关键帧。这类样式需要和组件结构绑定，放在独立的样式块里更好维护。</p>
<table>
<thead>
<tr>
<th>区域</th>
<th>主要工具</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>页面布局与间距</td>
<td>Tailwind</td>
<td>改动频繁，就地调整</td>
</tr>
<tr>
<td>组件内部结构</td>
<td>Stylus</td>
<td>嵌套和状态组合更清晰</td>
</tr>
<tr>
<td>设计令牌</td>
<td>CSS 变量</td>
<td>跨工具共享</td>
</tr>
<tr>
<td>响应式断点</td>
<td>Stylus 变量 + Tailwind</td>
<td>统一数值来源</td>
</tr>
</tbody>
</table>
<p>==工具没有优劣，边界不清才是问题。==</p>
<h2>断点必须有单一来源</h2>
<p>最容易出错的地方，是同一套断点在两个工具里各写一遍。项目通过一个共享的 <code>breakpoints.styl</code> 把数值集中起来：</p>
<pre><code>bp-sm = 640px
bp-md = 768px
bp-lg = 1024px
bp-xl = 1280px
</code></pre>
<p>组件内部使用 Stylus 变量，页面布局使用 Tailwind 的响应式前缀。数值只维护一次，改的时候不会漏掉另一半。</p>
<p>:::caution[Stylus 的 &amp; 不总是拼接]
在 Stylus 里写嵌套时，<code>&amp;__element</code> 会把父选择器直接拼成新的类名，而 <code>&amp; .element</code> 才是后代选择器。这两种写法看起来很像，语义却完全不同。改样式前先确认当前文件用的是哪一种约定。
:::</p>
<h2>一个具体例子</h2>
<p>下面这段样式负责筛选器里的清除按钮。它需要绝对定位、圆形点击区域、悬停背景和图标尺寸，这些都属于组件内部细节，所以放在 Stylus 里：</p>
<pre><code>&amp;__search-clear
  position absolute
  right 0.5rem
  top 50%
  transform translateY(-50%)
  display inline-flex
  border-radius var(--shape-corner-full)

  &amp;:hover
    background var(--surface-container-high)
</code></pre>
<p>而它在模板里的位置和外部间距，则继续交给 Tailwind：</p>
<pre><code>&lt;div class="relative w-full max-w-lg"&gt;
  &lt;input class="w-full" /&gt;
&lt;/div&gt;
</code></pre>
<p>:::spoiler[那为什么不全部用 Tailwind？]
复杂组件的状态组合会写出很长的 class 字符串，可读性下降；动画关键帧和伪元素也不适合塞进模板。把内部结构交给 Stylus，模板会轻很多。
:::</p>
<h2><code>!important</code> 要有主人</h2>
<p>样式冲突时，最省事的做法是加 <code>!important</code>，但这也是最容易失控的做法。项目对它有明确约束：只在确实需要覆盖第三方或运行时样式的边界使用，并且要写清原因。</p>
<p>我的经验是：<strong>先找层级问题，再考虑 <code>!important</code></strong>。大多数「改不动」的样式，其实是选择器优先级或者加载顺序的问题，而不是真的需要强制覆盖。</p>
<p>:::tip[共存的关键是约定]
Tailwind 管外、Stylus 管内，令牌管共享值。只要这条边界稳定，两个工具就不会互相打架，反而各自发挥长处。
:::</p>
<p>现在再问我为什么不统一，我的答案会是：统一工具很轻松，统一职责才难。后者做到了，前者反而没那么重要。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>设计令牌与 M3E：统一不等于千篇一律</title>
    <link href="https://www.ycera.cn/posts/m3e-design-tokens/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/m3e-design-tokens/</id>
    <published>2026-10-22T00:00:00.000Z</published>
    <updated>2026-10-22T00:00:00.000Z</updated>
    <summary>颜色、圆角、字号、动效如果每个组件各写一套，界面迟早会失控。记录本站用设计令牌收敛视觉的过程。</summary>
    <content type="html"><![CDATA[<p>一个网站刚开始做的时候，组件少，随手写几个颜色值并不会出问题。等到页面变多，问题就会出现：这个卡片的圆角是 12px，那个是 16px；这个标题用 500 字重，那个用 400；两个看起来差不多的灰色，其实是完全不同的值。</p>
<p>本站使用 M3E（Material 3 Expressive）的设计令牌体系，把这类问题提前收进变量里。</p>
<h2>令牌不是「多写一层变量」</h2>
<p>::: field-group</p>
<p>::: field --m3e-type-body-large
@type font shorthand
@default 500 1.0625rem/1.7
@required</p>
<p>正文级排版。字号、行高和字重一起定义，避免组件里各写各的。</p>
<p>:::</p>
<p>::: field --shape-corner-l
@type length
@default 1rem
@optional</p>
<p>大圆角容器使用。卡片、弹层和面板保持同一套几何语言。</p>
<p>:::</p>
<p>::: field --m3e-duration-medium
@type time
@default 300ms
@optional</p>
<p>中等时长动效。配合统一的缓动曲线使用，避免每个组件自己调时间。</p>
<p>:::</p>
<p>::::</p>
<p>这些变量的意义不在于「集中管理」，而在于<strong>约束决策范围</strong>。当我想给一个卡片加圆角时，不是问「多少像素好看」，而是问「它属于哪个圆角层级」。</p>
<h2>排版是最容易失控的部分</h2>
<p>中文和英文的视觉密度不同，同一套字号在两种语言里读起来完全不一样。最初本站的正文偏小，中文显得又细又密；后来我把正文调到 17px，行高提到 1.85，并把正文和标题的字重分别调到 500 和 600，阅读压力才明显下降。</p>
<table>
<thead>
<tr>
<th>令牌</th>
<th>调整前</th>
<th>调整后</th>
</tr>
</thead>
<tbody>
<tr>
<td>body-large</td>
<td>16px / 400</td>
<td>17px / 500</td>
</tr>
<tr>
<td>body-medium</td>
<td>14px</td>
<td>15px</td>
</tr>
<tr>
<td>body-small</td>
<td>12px</td>
<td>13px</td>
</tr>
<tr>
<td>title-medium</td>
<td>16px / 500</td>
<td>17px / 600</td>
</tr>
</tbody>
</table>
<p>==字号不是孤立数字，它和行高、字重、字体一起决定阅读感受。==</p>
<p>:::tip[一个实用原则]
先改令牌，再改组件。令牌是全局语言，组件只是这句话的具体说法。只改单个组件，短期看有效，长期会把设计系统撕成碎片。
:::</p>
<h2>颜色和表面要成对出现</h2>
<p>Material 3 的 surface / on-surface 配对，解决的是「文字放在什么背景上」的问题。只定义一个主色是不够的，还必须定义它对应的可读前景色。</p>
<pre><code>.card {
  background: var(--surface-container);
  color: var(--on-surface);
}

.card--muted {
  background: var(--surface-container-low);
  color: var(--on-surface-variant);
}
</code></pre>
<p>这样切换明暗主题时，不需要在每个组件里写条件判断，变量会自己完成替换。</p>
<p>:::note[为什么仍然允许少量硬编码]
图片上的白色文字、遮罩层的黑色背景，这类与具体内容强相关的场景，固定黑白值反而更可靠。项目规则里把这类情况列为「有记录的例外」，而不是一刀切禁止。
:::</p>
<h2>动效也要有名字</h2>
<p>动效最容易被忽略，但它同样需要约束。本站把常用时长和缓动收进 <code>--m3e-duration-*</code> 与 <code>--m3e-easing-*</code>，并统一尊重 <code>prefers-reduced-motion</code>。</p>
<p>这样做的好处是：当我给一个面板加展开动画时，不需要重新思考「应该用 200ms 还是 400ms」，而是直接选择「短时长」或「中时长」。视觉节奏自然就统一了。</p>
<p>:::important[统一的反面不是个性]
设计令牌统一的是底层语言，不是抹掉每个页面的特点。相册可以更沉浸，文章可以更安静，时间线可以更紧凑——它们只是用同一套词汇说话。
:::</p>
<p>回头看，设计系统真正节省的不是写 CSS 的时间，而是<strong>反复做小决定的时间</strong>。把那些决定提前做完，才能把注意力留给内容本身。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>Svelte 5 在博客里到底负责什么</title>
    <link href="https://www.ycera.cn/posts/svelte-5-in-blog/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/svelte-5-in-blog/</id>
    <published>2026-10-21T00:00:00.000Z</published>
    <updated>2026-10-21T00:00:00.000Z</updated>
    <summary>博客大部分页面是静态的，但搜索、筛选、标签切换这些地方需要交互。Svelte 5 在这里的分工很明确。</summary>
    <content type="html"><![CDATA[<p>静态博客也需要一点交互：搜索框、标签筛选、主题切换、折叠面板。关键在于——<strong>不要因为一点点交互，就让整个页面都变成客户端应用</strong>。</p>
<p>Svelte 5 在这个项目里的角色，是「局部增强」，而不是「接管页面」。</p>
<h2>先静态，再交互</h2>
<p>大部分文章页、归档页、关于页都是纯 Astro 组件，构建时直接输出 HTML。只有真正需要响应点击和输入的部分，才会写成 Svelte 组件，并通过 hydration 指令挂载。</p>
<p>::: tabs#hydration</p>
<p>@tab 立即加载#立即</p>
<p>适合首屏就需要立即可用的组件，例如导航栏里的搜索入口。对应的指令是 <code>client:load</code>，代价是脚本会在页面加载后马上执行。</p>
<p>@tab 滚动到附近再加载#可见</p>
<p>适合首屏之外、滚动到附近才需要工作的组件。对应的指令是 <code>client:visible</code>，文章底部的相关推荐、相册列表都可以用这个策略。</p>
<p>@tab 仅客户端#仅客户端</p>
<p>完全跳过服务端渲染。对应的指令是 <code>client:only</code>，只适合依赖浏览器 API、无法在构建时渲染的组件。</p>
<p>:::</p>
<p>这三种策略的区别，本质上是在回答一个问题：<strong>这个交互最早什么时候可能被用到？</strong></p>
<h2>Runes 让状态更直白</h2>
<p>Svelte 5 的 runes 写法比过去的响应式语法更接近普通 JavaScript：</p>
<pre><code>&lt;script lang="ts"&gt;
  let query = $state("");
  let selectedTag = $state("");
  let filtered = $derived(
    items.filter((item) =&gt; item.title.includes(query))
  );
&lt;/script&gt;

&lt;input bind:value={query} /&gt;
</code></pre>
<p><code>$state</code> 声明可变状态，<code>$derived</code> 声明派生值。比起手动维护依赖关系，这种写法更容易读，也更不容易漏掉更新。</p>
<p>:::warning[不要在同一个组件里混用两种写法]
项目里有些组件仍然是旧语法。Svelte 5 允许逐步迁移，但同一个文件里混用 runes 和旧式响应式声明，会让状态来源变得难以追踪。我的做法是：改哪个组件，就把哪个组件完整迁移，而不是东拼西凑。
:::</p>
<h2>一个真实的取舍</h2>
<p>筛选器需要读取 URL 参数，让刷新和分享后的状态保持一致。这段逻辑只能在浏览器里运行，所以放在 <code>onMount</code> 中；但它又不需要整个页面都变成客户端组件。</p>
<p>==交互的边界应该由需求决定，而不是由框架决定。==</p>
<p>我把筛选器拆成独立组件，只让它自己承担状态，页面其余部分继续静态输出。这样一来：</p>
<ul>
<li>没有筛选需求的读者，不会为这段脚本付出首屏成本；</li>
<li>需要交互的读者，点开筛选器后功能完整可用；</li>
<li>构建时仍然能生成可读的 HTML，搜索引擎和 RSS 不受影响。</li>
</ul>
<p>:::details[那动画呢？]
筛选切换时的淡入和加载指示器也用 Svelte 状态驱动，但动画本身遵循系统的 <code>prefers-reduced-motion</code> 设置。交互可以有趣，但不应该强迫所有人接受。
:::</p>
<h2>小结</h2>
<p>Svelte 5 在这个博客里不是主角，而是配角。它负责把少数需要响应的地方做得顺滑，其余部分交给 Astro 在构建时完成。这种分工听起来保守，但对内容站来说，保守往往意味着更快的首屏和更少的故障面。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
  <entry>
    <title>为什么这个博客选择 Astro</title>
    <link href="https://www.ycera.cn/posts/why-astro/" rel="alternate" type="text/html"/>
    <id>https://www.ycera.cn/posts/why-astro/</id>
    <published>2026-10-20T00:00:00.000Z</published>
    <updated>2026-10-20T00:00:00.000Z</updated>
    <summary>从零开始搭博客时，我在 SPA、服务端渲染和静态生成之间犹豫了很久，最后选择了 Astro。这篇文章讲讲原因。</summary>
    <content type="html"><![CDATA[<p>搭博客之前，我先问了自己一个问题：这个站点到底需要什么？</p>
<p>答案很朴素——写文章、放图片、偶尔加一点交互。没有用户登录，没有实时数据，也不需要复杂的后台。既然如此，把一个以内容为主的网站做成单页应用，反而是在给未来埋负担。</p>
<p>*[SSG]: Static Site Generation，静态站点生成，在构建阶段把页面预先生成成 HTML。</p>
<h2>我想要的三个东西</h2>
<p>:::steps{title="选择框架时的三个硬指标"}</p>
<ol>
<li>
<p><strong>首屏要快</strong></p>
<p>读者点开链接后，应该尽快看到文字，而不是先等一大包 JavaScript 下载和执行。</p>
</li>
<li>
<p><strong>写作要简单</strong></p>
<p>内容应该能直接写成 Markdown，而不是先学一套页面搭建系统。</p>
</li>
<li>
<p><strong>交互要克制</strong></p>
<p>只有真正需要交互的地方才加载脚本，其余部分保持纯静态。
:::</p>
</li>
</ol>
<p>Astro 刚好同时满足这三点。它默认输出 SSG 页面，组件在构建时渲染成 HTML；需要交互的部分再单独标记，作为「岛屿」加载。</p>
<h2>静态生成不是退步</h2>
<p>很多人把静态站点理解成「功能少」，但对我来说，它更像是一种取舍：把复杂度放在构建阶段，把简单留给访问者。</p>
<table>
<thead>
<tr>
<th>方案</th>
<th>首屏</th>
<th>运维</th>
<th>适合内容站吗</th>
</tr>
</thead>
<tbody>
<tr>
<td>纯 SPA</td>
<td>需要先加载框架</td>
<td>简单</td>
<td>一般</td>
</tr>
<tr>
<td>服务端渲染</td>
<td>依赖服务器</td>
<td>需要持续运维</td>
<td>可以，但偏重</td>
</tr>
<tr>
<td>静态生成</td>
<td>直接返回 HTML</td>
<td>可以放在任意静态托管</td>
<td>很合适</td>
</tr>
</tbody>
</table>
<p>==对内容站来说，静态生成不是妥协，而是把复杂度放到了正确的位置。==</p>
<h2>真正吸引我的是内容集合</h2>
<p>Astro 的 Content Collections 让 Markdown 不只是文件，而是有 schema 的数据。文章缺少标题、日期写错格式，构建阶段就会直接报错，而不是等发布之后才发现页面空白。</p>
<pre><code>const posts = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
  schema: z.object({
    title: z.string(),
    published: z.date(),
    tags: z.array(z.string()).optional().default([]),
  }),
});
</code></pre>
<p>这段 schema 看起来普通，但它把「写作约定」变成了机器可以检查的规则。</p>
<p>:::tip[我的体会]
选择框架时，不要只看它能不能实现功能，还要看它默认把你推向哪种工作方式。Astro 的默认值，正好是我想要的：内容优先、脚本克制、构建期校验。
:::</p>
<h2>部署也变得简单</h2>
<p><code>pnpm build</code> 之后得到的是一个 <code>dist/</code> 目录，里面全是 HTML、CSS、图片和少量按需加载的脚本。把它放到任意静态托管上就能访问，不需要 Node 进程常驻。</p>
<p>:::note[代价也要说清楚]
静态站点不适合实时性很强的场景。如果以后我要做评论、搜索或者动态数据，就得额外引入服务或构建期快照。这不是 Astro 的问题，而是所有静态方案都要面对的现实。
:::</p>
<p>现在回头看，选择 Astro 并不是因为它「最新」或者「最流行」，而是因为它和我对博客的理解一致：<strong>内容应该比框架更重要</strong>。</p>
]]></content>
    <author><name>初月_Yuechu</name></author>
    <category term="建站"/>
  </entry>
</feed>
