<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Halo CMS 知识库</title>
        <link>https://howiehz.top/mhcga/</link>
        <description>Make Halo CMS Great Again · 分享与 Halo CMS 相关的插件、主题与运营经验。</description>
        <lastBuildDate>Fri, 01 May 2026 19:06:09 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh-Hans</language>
        <copyright>版权所有 © 2025-至今 MHCGA</copyright>
        <atom:link href="https://howiehz.top/mhcga/rss.zh-hans.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[移除页面中多余的插件资源]]></title>
            <link>https://howiehz.top/mhcga/posts/usage/remove-redundant-plugin-assets</link>
            <guid isPermaLink="false">https://howiehz.top/mhcga/posts/usage/remove-redundant-plugin-assets</guid>
            <pubDate>Tue, 14 Apr 2026 08:55:00 GMT</pubDate>
            <description><![CDATA[移除页面中多余的插件资源
 问题背景
在 Halo CMS 中，有些插件为了尽量兼容主题的 Ajax、PJAX、Swup 等全站无刷新方案，会选择把自己的脚本和样式插入到每一个页面。
然而实际部署时，]]></description>
            <content:encoded><![CDATA[<h1 id="移除页面中多余的插件资源" tabindex="-1">移除页面中多余的插件资源 <a class="header-anchor" href="#移除页面中多余的插件资源" aria-label="Permalink to “移除页面中多余的插件资源”">&#8203;</a></h1>
<h2 id="问题背景" tabindex="-1">问题背景 <a class="header-anchor" href="#问题背景" aria-label="Permalink to “问题背景”">&#8203;</a></h2>
<p>在 Halo CMS 中，有些插件为了尽量兼容主题的 Ajax、PJAX、Swup 等全站无刷新方案，会选择把自己的脚本和样式插入到<strong>每一个页面</strong>。</p>
<p>然而实际部署时，很多站点并不真正需要这样做，例如：</p>
<ul>
<li>主题本身并没有使用全站无刷新方案</li>
<li>插件功能只会出现在少量页面</li>
</ul>
<p>由此产生额外的请求、带宽消耗及脚本执行开销。</p>
<p>若不想直接修改插件源码，可使用<a href="https://www.halo.run/store/apps/app-ncyyngrz" target="_blank" rel="noreferrer">页面转换器</a>插件，在不需要的页面移除对应资源。</p>
<h2 id="确定要移除哪些资源" tabindex="-1">确定要移除哪些资源 <a class="header-anchor" href="#确定要移除哪些资源" aria-label="Permalink to “确定要移除哪些资源”">&#8203;</a></h2>
<p>在写规则之前，先确认插件实际注入了哪些资源。可以在浏览器开发者工具中查看页面源码或网络请求，重点留意插件插入的 <code>&lt;script&gt;</code>、<code>&lt;link rel=&quot;stylesheet&quot;&gt;</code> 以及 <code>/plugins/</code> 相关路径。</p>
<p>例如：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::5hypvyv4779pdq04jqy1n::--><code>&lt;link rel=&quot;stylesheet&quot; href=&quot;/plugins/plugin-a/assets/player.css&quot; /&gt;
&lt;script src=&quot;/plugins/plugin-a/assets/player.js&quot;&gt;&lt;/script&gt;</code></pre>
</div><h2 id="配置示例" tabindex="-1">配置示例 <a class="header-anchor" href="#配置示例" aria-label="Permalink to “配置示例”">&#8203;</a></h2>
<p>下面直接给出几个示例。这些规则的共同思路都是：</p>
<ul>
<li>先用 CSS 选择器精确命中插件注入的资源标签。</li>
<li>再用匹配规则描述哪些页面允许保留。</li>
<li>最后在最外层取反，变成“除了这些页面，其他页面都移除”。</li>
</ul>
<h3 id="example-1" tabindex="-1">示例 1：按需移除“评论组件”插件的样式/脚本预加载 <a class="header-anchor" href="#example-1" aria-label="Permalink to “示例 1：按需移除“评论组件”插件的样式/脚本预加载”">&#8203;</a></h3>
<p>作用：在不需要的页面移除<a href="https://www.halo.run/store/apps/app-YXyaD" target="_blank" rel="noreferrer">评论组件</a>插件注入的预加载标签。</p>
<p>范围：</p>
<ul>
<li><code>/archives/**</code> 的文章页保留预加载资源。（<code>/archives</code> 和 <code>/archives/page/*</code> 是文章归档页，不保留预加载资源）</li>
<li><code>/moments</code> 和 <code>/moments/page/*</code> 的瞬间列表页保留预加载资源。</li>
<li><code>/moments/*</code> 的瞬间详情页保留预加载资源。</li>
<li><code>/about</code> 是一个自定义的页面，在此保留预加载资源。导入后可按需调整此规则。</li>
<li>其他页面都移除资源。</li>
</ul>
<div class="language-json"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre><!--::markdown-it-async::4jmxuf2zxxlicnhviidyap::--><code>{
  &quot;$schema&quot;: &quot;https://raw.githubusercontent.com/HowieHz/halo-plugin-transformer/main/ui/public/generated/transformer.schema.json&quot;,
  &quot;version&quot;: 1,
  &quot;resourceType&quot;: &quot;rule&quot;,
  &quot;data&quot;: {
    &quot;enabled&quot;: true,
    &quot;name&quot;: &quot;按需移除“评论组件”插件的样式/脚本预加载&quot;,
    &quot;description&quot;: &quot;&quot;,
    &quot;mode&quot;: &quot;SELECTOR&quot;,
    &quot;match&quot;: &quot;link[href^=\&quot;/plugins/PluginCommentWidget\&quot;]&quot;,
    &quot;position&quot;: &quot;REMOVE&quot;,
    &quot;wrapMarker&quot;: false,
    &quot;runtimeOrder&quot;: 2147483645,
    &quot;matchRuleSource&quot;: {
      &quot;kind&quot;: &quot;RULE_TREE&quot;,
      &quot;data&quot;: {
        &quot;type&quot;: &quot;GROUP&quot;,
        &quot;negate&quot;: true,
        &quot;operator&quot;: &quot;AND&quot;,
        &quot;children&quot;: [
          {
            &quot;type&quot;: &quot;GROUP&quot;,
            &quot;negate&quot;: false,
            &quot;operator&quot;: &quot;OR&quot;,
            &quot;children&quot;: [
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/archives/**&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;EXACT&quot;,
                &quot;value&quot;: &quot;/about&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/moments/page/*&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/moments/*&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;EXACT&quot;,
                &quot;value&quot;: &quot;/moments&quot;
              }
            ]
          },
          {
            &quot;type&quot;: &quot;GROUP&quot;,
            &quot;negate&quot;: true,
            &quot;operator&quot;: &quot;OR&quot;,
            &quot;children&quot;: [
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;EXACT&quot;,
                &quot;value&quot;: &quot;/archives&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/archives/page/*&quot;
              }
            ]
          }
        ]
      }
    }
  }
}</code></pre>
</div><h3 id="示例-2-按需移除-联系表单-插件样式-脚本" tabindex="-1">示例 2：按需移除“联系表单”插件样式/脚本 <a class="header-anchor" href="#示例-2-按需移除-联系表单-插件样式-脚本" aria-label="Permalink to “示例 2：按需移除“联系表单”插件样式/脚本”">&#8203;</a></h3>
<p>作用：在不需要的页面移除<a href="https://www.halo.run/store/apps/app-gSebd" target="_blank" rel="noreferrer">联系表单</a>插件注入的资源。</p>
<p>范围：</p>
<ul>
<li>在 <code>/about</code> 这个自定义页面保留资源。（<code>/about</code> 是自己创建的页面，你可以将其改为其他的。）</li>
<li>其他页面都移除资源。</li>
</ul>
<div class="language-json"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre><!--::markdown-it-async::p0jx6h6bsskzynqqculys::--><code>{
  &quot;$schema&quot;: &quot;https://raw.githubusercontent.com/HowieHz/halo-plugin-transformer/main/ui/public/generated/transformer.schema.json&quot;,
  &quot;version&quot;: 1,
  &quot;resourceType&quot;: &quot;rule&quot;,
  &quot;data&quot;: {
    &quot;enabled&quot;: true,
    &quot;name&quot;: &quot;按需移除”联系表单”插件样式/脚本&quot;,
    &quot;description&quot;: &quot;&quot;,
    &quot;mode&quot;: &quot;SELECTOR&quot;,
    &quot;match&quot;: &quot;link[href^=\&quot;/plugins/PluginContactForm\&quot;], script[src^=\&quot;/plugins/PluginContactForm\&quot;]&quot;,
    &quot;position&quot;: &quot;REMOVE&quot;,
    &quot;wrapMarker&quot;: false,
    &quot;runtimeOrder&quot;: 2147483645,
    &quot;matchRuleSource&quot;: {
      &quot;kind&quot;: &quot;RULE_TREE&quot;,
      &quot;data&quot;: {
        &quot;type&quot;: &quot;GROUP&quot;,
        &quot;negate&quot;: true,
        &quot;operator&quot;: &quot;AND&quot;,
        &quot;children&quot;: [
          {
            &quot;type&quot;: &quot;PATH&quot;,
            &quot;negate&quot;: false,
            &quot;matcher&quot;: &quot;EXACT&quot;,
            &quot;value&quot;: &quot;/about&quot;
          }
        ]
      }
    }
  }
}</code></pre>
</div><h3 id="示例-3-按需移除-shiki-代码高亮插件脚本注入" tabindex="-1">示例 3：按需移除 Shiki 代码高亮插件脚本注入 <a class="header-anchor" href="#示例-3-按需移除-shiki-代码高亮插件脚本注入" aria-label="Permalink to “示例 3：按需移除 Shiki 代码高亮插件脚本注入”">&#8203;</a></h3>
<p>作用：在不需要的页面移除<a href="https://www.halo.run/store/apps/app-kzloktzn" target="_blank" rel="noreferrer">Shiki 代码高亮</a>插件注入的脚本。</p>
<p>范围：同<a href="#example-1">示例 1</a>。</p>
<div class="language-json"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre><!--::markdown-it-async::ywoa37dpo8cb523gqni57t::--><code>{
  &quot;$schema&quot;: &quot;https://raw.githubusercontent.com/HowieHz/halo-plugin-transformer/main/ui/public/generated/transformer.schema.json&quot;,
  &quot;version&quot;: 1,
  &quot;resourceType&quot;: &quot;rule&quot;,
  &quot;data&quot;: {
    &quot;enabled&quot;: true,
    &quot;name&quot;: &quot;按需移除 Shiki 代码高亮插件脚本注入&quot;,
    &quot;description&quot;: &quot;&quot;,
    &quot;mode&quot;: &quot;SELECTOR&quot;,
    &quot;match&quot;: &quot;script[src^=\&quot;/plugins/shiki\&quot;]&quot;,
    &quot;position&quot;: &quot;REMOVE&quot;,
    &quot;wrapMarker&quot;: false,
    &quot;runtimeOrder&quot;: 2147483645,
    &quot;matchRuleSource&quot;: {
      &quot;kind&quot;: &quot;RULE_TREE&quot;,
      &quot;data&quot;: {
        &quot;type&quot;: &quot;GROUP&quot;,
        &quot;negate&quot;: true,
        &quot;operator&quot;: &quot;AND&quot;,
        &quot;children&quot;: [
          {
            &quot;type&quot;: &quot;GROUP&quot;,
            &quot;negate&quot;: false,
            &quot;operator&quot;: &quot;OR&quot;,
            &quot;children&quot;: [
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/archives/**&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;EXACT&quot;,
                &quot;value&quot;: &quot;/about&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/moments/page/*&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/moments/*&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;EXACT&quot;,
                &quot;value&quot;: &quot;/moments&quot;
              }
            ]
          },
          {
            &quot;type&quot;: &quot;GROUP&quot;,
            &quot;negate&quot;: true,
            &quot;operator&quot;: &quot;OR&quot;,
            &quot;children&quot;: [
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;EXACT&quot;,
                &quot;value&quot;: &quot;/archives&quot;
              },
              {
                &quot;type&quot;: &quot;PATH&quot;,
                &quot;negate&quot;: false,
                &quot;matcher&quot;: &quot;ANT&quot;,
                &quot;value&quot;: &quot;/archives/page/*&quot;
              }
            ]
          }
        ]
      }
    }
  }
}</code></pre>
</div>]]></content:encoded>
            <author>howie_hzgo@outlook.com (HowieHz)</author>
        </item>
        <item>
            <title><![CDATA[实现组件化与资源按需加载]]></title>
            <link>https://howiehz.top/mhcga/posts/themes/component-and-on-demand-loading</link>
            <guid isPermaLink="false">https://howiehz.top/mhcga/posts/themes/component-and-on-demand-loading</guid>
            <pubDate>Tue, 10 Mar 2026 19:05:00 GMT</pubDate>
            <description><![CDATA[实现组件化与资源按需加载
 问题背景
在 Halo CMS 主题开发中，很多主题都面临着相同的问题：
1. 资源加载方式低效：所有页面共享一个庞大的 `main.js` 和 `main.css`，即使]]></description>
            <content:encoded><![CDATA[<h1 id="实现组件化与资源按需加载" tabindex="-1">实现组件化与资源按需加载 <a class="header-anchor" href="#实现组件化与资源按需加载" aria-label="Permalink to “实现组件化与资源按需加载”">&#8203;</a></h1>
<h2 id="问题背景" tabindex="-1">问题背景 <a class="header-anchor" href="#问题背景" aria-label="Permalink to “问题背景”">&#8203;</a></h2>
<p>在 Halo CMS 主题开发中，很多主题都面临着相同的问题：</p>
<ol>
<li>资源加载方式低效：所有页面共享一个庞大的 <code>main.js</code> 和 <code>main.css</code>，即使用户只访问了某一个页面，仍然要加载所有页面的所有代码和样式。</li>
<li>版本控制困难：很多主题通过在资源 URL 后添加查询参数（如 <code>?v=1.0.0</code>）来控制版本，这存在安全隐患。恶意用户可以通过访问不存在的版本号（如 <code>?v=2.0.0</code>）来提前缓存和污染 CDN。</li>
<li>组件复用困难：缺少统一的组件体系，开发者很难在不同页面间复用组件，容易导致代码冗余。</li>
</ol>
<h3 id="目标方案" tabindex="-1">目标方案 <a class="header-anchor" href="#目标方案" aria-label="Permalink to “目标方案”">&#8203;</a></h3>
<p>本文将介绍如何通过现代前端技术栈，实现：</p>
<ul>
<li>按页面分包：每个页面只加载自己需要的代码和样式</li>
<li>按组件分包：可复用组件拥有独立的代码包，自动去重</li>
<li>资源按需加载：页面初始化时只加载必要资源，其他资源按需加载</li>
<li>Hash 命名方案：通过内容哈希命名资源文件，彻底解决缓存问题</li>
</ul>
<p>附加收益：</p>
<ul>
<li>可扩展的前端工程能力：可接入 Tailwind 类名压缩、SRI 生成、构建期预压缩等插件能力，持续优化产物质量</li>
<li>自动生成 <code>&lt;link rel=&quot;modulepreload&quot;&gt;</code>：让浏览器提前获取依赖模块，减少后续模块执行前的等待时间</li>
</ul>
<h2 id="核心概念与技术栈" tabindex="-1">核心概念与技术栈 <a class="header-anchor" href="#核心概念与技术栈" aria-label="Permalink to “核心概念与技术栈”">&#8203;</a></h2>
<h3 id="vite-和-rollup-rolldown" tabindex="-1">Vite 和 Rollup/Rolldown <a class="header-anchor" href="#vite-和-rollup-rolldown" aria-label="Permalink to “Vite 和 Rollup/Rolldown”">&#8203;</a></h3>
<p>Vite 是现代化的前端构建工具，采用 Rollup（v8 版本之前）或 Rolldown（v8 版本之后）作为生产构建器。在 Vite 中，<code>build.rollupOptions.input</code> 或 <code>build.rolldownOptions.input</code> 支持配置多个 HTML 入口文件，突破了单一 JS 入口的限制。</p>
<p>本文以 Vite v7 为例进行讲解，使用 <code>build.rollupOptions.input</code> 进行配置。若你使用的是 Vite v8 或更高版本，可直接使用 <code>build.rolldownOptions.input</code> 替代，不会影响功能实现。</p>
<div  class="tip custom-block"><p class="custom-block-title">这意味着什么？</p>
<p>之前的构建流程：<code>index.html</code> → <code>main.js</code> → 一个大的 bundle</p>
<p>Vite 多入口方案：<code>archive.html</code>, <code>post.html</code>, <code>index.html</code> → 独立的 bundle → 自动去重共享代码</p>
</div>
<h3 id="thymeleaf-模板" tabindex="-1">Thymeleaf 模板 <a class="header-anchor" href="#thymeleaf-模板" aria-label="Permalink to “Thymeleaf 模板”">&#8203;</a></h3>
<p>Thymeleaf 是 Halo CMS 使用的服务端模板引擎。它支持片段的概念，可以在模板中定义可复用的片段，并在多个地方插入。</p>
<h2 id="组件化的核心设计理念" tabindex="-1">组件化的核心设计理念 <a class="header-anchor" href="#组件化的核心设计理念" aria-label="Permalink to “组件化的核心设计理念”">&#8203;</a></h2>
<p>在理解具体实现前，需要掌握一个核心理念：<strong>组件是脚本、样式、HTML 的有机整体</strong>。</p>
<p>传统的前端开发中，常常这样组织代码：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::oj9joyq3bdnf3fogte3itc::--><code>src/
  ├── scripts/
  │   └── pagination.js   ← 分页逻辑
  ├── styles/
  │   └── pagination.css  ← 分页样式
  └── templates/
      └── pagination.html ← 分页 HTML</code></pre>
</div><p>问题是：这三个文件虽然逻辑相关，但在物理上分散开来，使用时容易遗漏其中某个文件。</p>
<p>而在组件化架构中，我们将它们放在一起，并通过 Thymeleaf 片段的机制<strong>一次性导入</strong>：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::biznpwret4ikywsu04bvc::--><code>src/components/pagination/
  ├── main.ts    ← 脚本 + 样式导入
  ├── styles.css ← 样式定义
  └── index.html ← Thymeleaf 模板（两个片段）</code></pre>
</div><p>使用时：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::1r9tgh0j9qadpabmswuzl::--><code>&lt;!-- 在 head 中一行代码导入脚本和样式 --&gt;
&lt;th:block th:insert=&quot;~{components/pagination/index :: head}&quot;&gt;&lt;/th:block&gt;

&lt;!-- 在 body 中一行代码导入 HTML 结构 --&gt;
&lt;th:block th:insert=&quot;~{components/pagination/index :: body(...)}&quot;&gt;&lt;/th:block&gt;</code></pre>
</div><p>这种设计带来以下好处：</p>
<ul>
<li>自包含：脚本、样式、HTML 在一个目录中，开发者一目了然</li>
<li>易复用：引入此组件，只需两行 <code>th:insert</code> 代码</li>
<li>自动分包：Vite 会自动为每个组件生成独立的代码包</li>
<li>精确加载：页面只加载实际使用的组件代码，无冗余</li>
</ul>
<h2 id="架构比较" tabindex="-1">架构比较 <a class="header-anchor" href="#架构比较" aria-label="Permalink to “架构比较”">&#8203;</a></h2>
<h3 id="集中化架构" tabindex="-1">集中化架构 <a class="header-anchor" href="#集中化架构" aria-label="Permalink to “集中化架构”">&#8203;</a></h3>
<p>以 <a href="https://github.com/halo-dev/theme-modern-starter/tree/c44c56c7a30b3a65ba56988a8d083d42b62b64e5/" target="_blank" rel="noreferrer">halo-dev/theme-modern-starter@c44c56c</a> 为例：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::c3sh95oqfsgmdzjcizdms::--><code>templates/
  ├── modules
  │   └── layout.html ← 公共布局模板（根级布局片段）
  ├── post.html       ← 文章详情页模板
  └── index.html      ← 首页模板

src/
  └── main.ts         ← 单一入口文件</code></pre>
</div><p>构建结果：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::divkijg5occzculd407hud::--><code>dist/
  ├── main.iife.js ← 所有页面共享的脚本文件
  └── style.css    ← 所有页面共享的样式文件</code></pre>
</div><p>劣势：无论用户访问哪个页面，都需要加载完整的 <code>main.iife.js</code> 和 <code>style.css</code> 文件。这对于适配多个第三方插件的主题来说，会产生更明显的影响。</p>
<h3 id="半组件化架构" tabindex="-1">半组件化架构 <a class="header-anchor" href="#半组件化架构" aria-label="Permalink to “半组件化架构”">&#8203;</a></h3>
<p>以 <a href="https://github.com/HowieHz/halo-theme-higan-hz/tree/95d7b8ee1d985667e7c375c04f19889c0ac6b3ec/src/" target="_blank" rel="noreferrer">HowieHz/halo-theme-higan-hz@95d7b8e</a> 为例：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::kxe0v2j30mqdf633loayr::--><code>src/
├── templates/
│   ├── fragments
│   │   └── layout.html ← 公共布局模板（根级布局片段）
│   ├── post.html       ← 文章详情页模板（包含：&lt;script src=&quot;/src/scripts/pages/post.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;），编译后会替换为对应的样式表和脚本链接。
│   └── index.html      ← 首页模板（包含：&lt;script src=&quot;/src/scripts/pages/index.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;）
├── components/
│   ├── component-a/    ← 组件 A
│   │   ├── main.ts     ← 组件 A 的脚本（包含 import &quot;./styles.css&quot;;）
│   │   ├── styles.css  ← 组件 A 样式
│   │   └── index.html  ← 组件 A 的 HTML 文件
│   └── component-b/    ← 组件 B
│       ├── main.ts
│       ├── styles.css
│       └── index.html
├── styles/
│   ├── main.css        ← 公共样式文件
│   └── pages/          ← 各自的样式文件，在各自的入口文件中被导入
│       ├── post.css
│       └── index.css
└── scripts/
    ├── main.ts         ← 公共脚本文件（包含 import &quot;../styles/main.css&quot;;）
    └── pages/          ← 各自的脚本入口文件
        ├── post.ts     ← 文章详情页脚本（包含 import &quot;../../styles/pages/post.css&quot;;）
        └── index.ts    ← 首页脚本（包含 import &quot;../../styles/pages/index.css&quot;;）</code></pre>
</div><p>构建结果（由 Vite 自动处理）：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::jl1suborgkmhxqkfaqg9::--><code>dist/
  ├── BHmhdQc.js  ← 首页代码（仅此页面需要）
  ├── A1h342c.css ← 首页样式（仅此页面需要）
  ├── 0U3f2Kd.js  ← 文章详情页代码（仅此页面需要）
  ├── QbsQr12.css ← 文章详情页样式（仅此页面需要）
  ├── ChjrFNR.js  ← 共享代码
  ├── B0bwbiH.js  ← 组件 A 的代码
  ├── U12VxHi.css ← 组件 A 的样式
  └── Dt5VXXw.js  ← 组件 B 的代码</code></pre>
</div><p>优势：每个页面只加载自己需要的代码，共享代码自动去重。</p>
<div  class="tip custom-block"><p class="custom-block-title">提示</p>
<p><code>src/components</code> 文件夹下的每一个子文件夹都是一个组件，包含完整的脚本、样式、HTML 文件。<br>
同理，在上面的结构中，<code>templates/index.html</code> 对应 <code>styles/pages/index.css</code> 和 <code>scripts/pages/index.ts</code> 本质也是一个组件，你可以依照自己的理解改变文件组织结构。</p>
</div>
<h3 id="完全组件化架构" tabindex="-1">完全组件化架构 <a class="header-anchor" href="#完全组件化架构" aria-label="Permalink to “完全组件化架构”">&#8203;</a></h3>
<p>实现可参考：<a href="https://github.com/HowieHz/halo-theme-higan-hz/tree/daa7038479243830246e51819bce0a576d39641d/src/templates/" target="_blank" rel="noreferrer">HowieHz/halo-theme-higan-hz@daa7038</a>。</p>
<h2 id="组件化架构实现细节" tabindex="-1">组件化架构实现细节 <a class="header-anchor" href="#组件化架构实现细节" aria-label="Permalink to “组件化架构实现细节”">&#8203;</a></h2>
<h3 id="步骤-1-项目结构设计" tabindex="-1">步骤 1：项目结构设计 <a class="header-anchor" href="#步骤-1-项目结构设计" aria-label="Permalink to “步骤 1：项目结构设计”">&#8203;</a></h3>
<p>首先，建立以下示例文件结构：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::13jstr9q30zjntognj972s::--><code>src/
  ├── styles/
  │   ├── main.css        ← 全局样式
  │   └── pages/
  │       ├── post.css    ← 文章页样式
  │       └── index.css   ← 首页样式
  ├── scripts/
  │   ├── main.ts         ← 全局脚本
  │   └── pages/
  │       ├── post.ts     ← 文章页脚本
  │       └── index.ts    ← 首页脚本
  ├── components/
  │   ├── pagination/     ← 分页组件
  │   │   ├── main.ts
  │   │   ├── styles.css
  │   │   └── index.html
  │   ├── post-list/      ← 文章列表组件
  │   │   ├── main.ts
  │   │   ├── styles.css
  │   │   └── index.html
  │   └── header/         ← 页面头部组件（如页面导航）
  │       ├── main.ts
  │       ├── styles.css
  │       └── index.html
  └── templates/
      ├── fragments
      │   └── layout.html ← 公共布局模板（根级布局片段）
      ├── post.html       ← 文章详情页模板
      └── index.html      ← 首页模板</code></pre>
</div><p>随后在 ts 文件中导入对应 css 文件：</p>
<div  class="tip custom-block"><p class="custom-block-title">提示</p>
<p>在 Typescript 文件导入资源文件，需要配置<a href="https://vite.dev/guide/features#client-types" target="_blank" rel="noreferrer">客户端类型</a>。</p>
</div>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::aan7xpj7k8dfel872nrwao::--><code>// src/scripts/main.ts
import &quot;../styles/main.css&quot;;</code></pre>
</div><div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::tlw218ycy0cjd6k4tc69ig::--><code>// src/scripts/pages/post.ts
import &quot;../../styles/pages/post.css&quot;;</code></pre>
</div><div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::ahkobp327sf073cqkfcg5::--><code>// src/scripts/pages/index.ts
import &quot;../../styles/pages/index.css&quot;;</code></pre>
</div><div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::jl9mn5vf6eud96vudegvf::--><code>// src/components/pagination/main.ts
// src/components/post-list/main.ts
// src/components/header/main.ts
import &quot;./styles.css&quot;;</code></pre>
</div><h3 id="步骤-2-vite-配置" tabindex="-1">步骤 2：Vite 配置 <a class="header-anchor" href="#步骤-2-vite-配置" aria-label="Permalink to “步骤 2：Vite 配置”">&#8203;</a></h3>
<p>配置 Vite 使用 HTML 入口：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::meopic5ry7gg7kt2utth9t::--><code>// vite.config.ts
import { resolve } from &quot;node:path&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
  // 关键配置：必须与 theme.yaml 中 metadata.name 对应
  // 如果 theme.yaml 中配置为 name: howiehz-higan
  // 这里就应该是 &quot;/themes/howiehz-higan/&quot;
  base: &quot;/themes/howiehz-higan/&quot;,

  build: {
    rollupOptions: {
      // 必要配置：指定多个 HTML 文件作为入口
      input: {
        // 页面模板
        post: resolve(__dirname, &quot;src/templates/post.html&quot;),
        index: resolve(__dirname, &quot;src/templates/index.html&quot;),
        // 公共布局模板（根级布局片段）
        layout: resolve(__dirname, &quot;src/templates/fragments/layout.html&quot;),
        // 组件
        pagination: resolve(__dirname, &quot;src/components/pagination/index.html&quot;),
        &quot;post-list&quot;: resolve(__dirname, &quot;src/components/post-list/index.html&quot;),
        header: resolve(__dirname, &quot;src/components/header/index.html&quot;),
      },
      // Vite 默认会为产物生成带内容哈希的文件名；如有命名规范需求，可按需自定义
    },
  },
});</code></pre>
</div><div  class="warning custom-block"><p class="custom-block-title">警告</p>
<p>由于 Vite 的构建机制限制，当前的组织方式可能导致模板文件的生成位置不符合预期。如遇到此问题，请参考<a href="#模板文件生成位置错误">常见问题 - 模板文件生成位置错误</a>章节获取解决方案。</p>
</div>
<h3 id="步骤-3-创建根级布局片段" tabindex="-1">步骤 3：创建根级布局片段 <a class="header-anchor" href="#步骤-3-创建根级布局片段" aria-label="Permalink to “步骤 3：创建根级布局片段”">&#8203;</a></h3>
<p>在所有页面编写之前，需要先创建一个根级的布局片段。这个片段定义了整个 HTML 的基础结构（<code>&lt;html&gt;</code>、<code>&lt;head&gt;</code>、<code>&lt;body&gt;</code> 等），所有具体页面都会基于这个布局：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::z6cuoqtzc1n55s1hsead8p::--><code>&lt;!-- templates/fragments/layout.html --&gt;
&lt;html
  xmlns:th=&quot;http://www.thymeleaf.org&quot;
  th:lang=&quot;${language ?: &#039;en&#039;}&quot;
  th:fragment=&quot;html(title, head, content, header)&quot;
&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;meta name=&quot;color-scheme&quot; content=&quot;light dark&quot; /&gt;
    &lt;title th:text=&quot;${title ?: &#039;Halo&#039;}&quot;&gt;&lt;/title&gt;
    &lt;!-- 所有页面共享的全局脚本和全局样式 --&gt;
    &lt;script src=&quot;/src/scripts/main.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;

    &lt;!-- 页面特定的 head 内容会被插入这里 --&gt;
    &lt;th:block th:insert=&quot;${head}&quot;&gt;&lt;/th:block&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;!-- 页面头部（导航等） --&gt;
    &lt;th:block th:insert=&quot;${header}&quot;&gt;&lt;/th:block&gt;

    &lt;!-- 页面主体内容 --&gt;
    &lt;!-- 每个页面的具体内容会被插入这里 --&gt;
    &lt;main&gt;
      &lt;th:block th:insert=&quot;${content}&quot;&gt;&lt;/th:block&gt;
    &lt;/main&gt;

    &lt;!-- 页面底部 --&gt;
    &lt;footer&gt;
      &lt;!-- 底部内容 --&gt;
    &lt;/footer&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
</div><p>这样每个页面都复用同一个 HTML 结构，避免重复：</p>
<ul>
<li><code>th:fragment=&quot;html(title, head, content, header)&quot;</code>：定义了一个根级片段，接收<strong>至少</strong> 4 个参数；调用时也可额外传入具名参数（如 <code>language</code>），以控制 <code>lang</code> 等属性。</li>
<li><code>th:insert=&quot;${head}&quot;</code>：将页面传入的 head 片段内容插入</li>
<li><code>th:insert=&quot;${content}&quot;</code>：将页面传入的 body 片段内容插入</li>
</ul>
<h3 id="步骤-4-在页面模板文件中引入脚本" tabindex="-1">步骤 4：在页面模板文件中引入脚本 <a class="header-anchor" href="#步骤-4-在页面模板文件中引入脚本" aria-label="Permalink to “步骤 4：在页面模板文件中引入脚本”">&#8203;</a></h3>
<p>在每个 Thymeleaf 模板中，在 <code>&lt;head&gt;</code> 标签内引入该页面对应的脚本。Vite 会自动识别这个脚本作为该页面的构建入口：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::hw9rvjstaege99p3xnv1cl::--><code>&lt;!-- templates/index.html --&gt;
&lt;!DOCTYPE html&gt;
&lt;!-- 此处 html 标签为临时占位符，在步骤 6 会替换为根级布局片段 --&gt;
&lt;html&gt;
  &lt;head th:remove=&quot;tag&quot;&gt;
    &lt;!-- 关键：这行告诉 Vite 这个页面的脚本入口 --&gt;
    &lt;!-- Vite 会为 index.ts 及其所有依赖创建独立的代码包 --&gt;
    &lt;script src=&quot;/src/scripts/pages/index.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;
  &lt;/head&gt;
  &lt;body th:remove=&quot;tag&quot;&gt;
    &lt;!-- 页面内容 --&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
</div><p>构建时流程：</p>
<ol>
<li>Vite 扫描 HTML 中的 <code>&lt;script type=&quot;module&quot;&gt;</code> 标签</li>
<li>发现 <code>src=&quot;/src/scripts/pages/index.ts&quot;</code> 后，将其作为一个独立的构建入口</li>
<li>分析 index.ts 中的所有 import（包括 CSS、其他脚本等）</li>
<li>为该页面生成独立的代码包</li>
</ol>
<div  class="warning custom-block"><p class="custom-block-title">注意</p>
<p>除根级布局片段外，其他模板中的 <code>head</code> 与 <code>body</code> 标签都应添加 <code>th:remove=&quot;tag&quot;</code>，以避免标签嵌套导致解析异常。</p>
</div>
<details  class="details custom-block"><summary>用一个小例子解释 <code>th:remove=&quot;tag&quot;</code></summary>
<p>在上面的例子中，我们看到 <code>th:remove=&quot;tag&quot;</code> 的使用。</p>
<p><code>th:remove=&quot;tag&quot;</code> 是 <code>th:include</code> 弃用后的<a href="https://www.thymeleaf.org/doc/articles/thymeleaf31whatsnew.html#deprecation-of-thinclude" target="_blank" rel="noreferrer">官方解决方案</a>。</p>
<p>将 <code>th:include</code> 语法替换为 <code>th:insert</code> 和 <code>th:remove=&quot;tag&quot;</code> 配合使用。</p>
<p>以下是一个示例：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::v8dfa4ase0kml6fv9j7c59::--><code>&lt;!-- 错误做法，不带 th:remove=&quot;tag&quot; --&gt;
&lt;!-- templates/index.html --&gt;
&lt;head&gt;
  &lt;th:block th:insert=&quot;~{components/pagination/index :: head}&quot;&gt;&lt;/th:block&gt;
&lt;/head&gt;

&lt;!-- 渲染后：会导致 &lt;head&gt;&lt;head&gt;... 嵌套 --&gt;
&lt;!-- 根级布局片段的 head 标签 --&gt;
&lt;head&gt;
  &lt;!-- 页面模板片段的 head 标签 --&gt;
  &lt;head&gt;
    &lt;script type=&quot;module&quot; crossorigin src=&quot;/themes/my-theme/assets/dist/Abc123.js&quot;&gt;&lt;/script&gt;
    &lt;link rel=&quot;stylesheet&quot; crossorigin href=&quot;/themes/my-theme/assets/dist/Def456.css&quot; /&gt;
  &lt;/head&gt;
  &lt;!-- 由于出现了 &lt;/head&gt;，剩下的内容会被浏览器移到 head 标签外，出现 head 标签提前结束的情况 --&gt;
&lt;/head&gt;

&lt;!-- 正确做法：用 th:remove=&quot;tag&quot; 移除此层 head 标签 --&gt;
&lt;!-- templates/index.html --&gt;
&lt;head th:remove=&quot;tag&quot;&gt;
  &lt;th:block th:insert=&quot;~{components/pagination/index :: head}&quot;&gt;&lt;/th:block&gt;
&lt;/head&gt;

&lt;!-- 渲染后：不会出现嵌套 --&gt;
&lt;!-- 仅保留根级布局片段的 head 标签 --&gt;
&lt;head&gt;
  &lt;script type=&quot;module&quot; crossorigin src=&quot;/themes/my-theme/assets/dist/Abc123.js&quot;&gt;&lt;/script&gt;
  &lt;link rel=&quot;stylesheet&quot; crossorigin href=&quot;/themes/my-theme/assets/dist/Def456.css&quot; /&gt;
&lt;/head&gt;</code></pre>
</div></details>
<div  class="tip custom-block"><p class="custom-block-title">提示</p>
<p>在 Thymeleaf 模板文件中添加注释，可使用<a href="https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#thymeleaf-parser-level-comment-blocks" target="_blank" rel="noreferrer">解析级注释块</a>语法。</p>
<p>即 <code>&lt;!--/* 注释内容 */--&gt;</code> 替代 <code>&lt;!-- ... --&gt;</code>。这样注释内容不会出现在最终渲染结果中，可节省传输带宽。</p>
</div>
<h3 id="步骤-5-脚本中导入需要的样式和模块" tabindex="-1">步骤 5：脚本中导入需要的样式和模块 <a class="header-anchor" href="#步骤-5-脚本中导入需要的样式和模块" aria-label="Permalink to “步骤 5：脚本中导入需要的样式和模块”">&#8203;</a></h3>
<p>在页面脚本中导入样式。这样 Vite 会自动识别和处理这些样式依赖：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::ihequgwv4yqu2pv8djgnfo::--><code>// src/scripts/pages/index.ts
import &quot;../../styles/pages/index.css&quot;;

// 导入当前页面需要的模块
// 以 Alpine 为示例
import Alpine from &quot;alpinejs&quot;;
window.Alpine = Alpine;
Alpine.start();</code></pre>
</div><p>Vite 会递归分析所有 import 依赖，自动创建代码块。如果多个页面都引入了同一个模块，Vite 会自动提取成共享的代码块。</p>
<h3 id="步骤-6-在页面模板文件中使用根级布局片段" tabindex="-1">步骤 6：在页面模板文件中使用根级布局片段 <a class="header-anchor" href="#步骤-6-在页面模板文件中使用根级布局片段" aria-label="Permalink to “步骤 6：在页面模板文件中使用根级布局片段”">&#8203;</a></h3>
<p>每个具体页面使用 <code>th:replace</code> 来应用这个布局：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::wx11xs5743f5pc4kc3pizf::--><code>&lt;!-- templates/index.html --&gt;
&lt;!DOCTYPE html&gt;
&lt;!-- th:block 替换掉原本的 html 标签 --&gt;
&lt;th:block
  xmlns:th=&quot;http://www.thymeleaf.org&quot;
  th:replace=&quot;~{fragments/layout :: html(
    title = &#039;首页 | 我的博客&#039;,
    head = ~{:: head},
    content = ~{:: body},
    header = ~{components/header/index :: body}
  )}&quot;
&gt;
  &lt;!-- 该页面在布局中的 head 部分。根据上文根级布局片段定义，会注入到最终渲染的 head 标签中 --&gt;
  &lt;head th:remove=&quot;tag&quot;&gt;
    &lt;!-- 页面特定的 meta 信息 --&gt;
    &lt;meta name=&quot;description&quot; content=&quot;博客首页&quot; /&gt;

    &lt;!-- 该页面的脚本 --&gt;
    &lt;script src=&quot;/src/scripts/pages/index.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;
  &lt;/head&gt;

  &lt;!-- 该页面在布局中的 content 部分 --&gt;
  &lt;body th:remove=&quot;tag&quot;&gt;
    &lt;div class=&quot;index-content&quot;&gt;
      &lt;!-- 首页内容 --&gt;
      &lt;!-- 省略若干内容 --&gt;
    &lt;/div&gt;
  &lt;/body&gt;
&lt;/th:block&gt;</code></pre>
</div><p>构建时步骤：</p>
<ol>
<li>Vite 读取 <code>index.html</code></li>
<li>看到 <code>&lt;script src=&quot;/src/scripts/pages/index.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;</code></li>
<li>分析该脚本的依赖（导入的 CSS、其他模块等）</li>
<li>插入对应资源的引用标签</li>
</ol>
<p>运行时步骤：</p>
<ol>
<li>Thymeleaf 渲染时，<code>th:replace</code> 会用 <code>layout.html</code> 的结构替换 <code>th:block</code> 标签</li>
<li>传入的 <code>head</code> 和 <code>content</code> 参数会被插入到布局中对应的占位符处</li>
</ol>
<h3 id="步骤-7-创建组件和使用组件" tabindex="-1">步骤 7：创建组件和使用组件 <a class="header-anchor" href="#步骤-7-创建组件和使用组件" aria-label="Permalink to “步骤 7：创建组件和使用组件”">&#8203;</a></h3>
<h4 id="创建组件" tabindex="-1">创建组件 <a class="header-anchor" href="#创建组件" aria-label="Permalink to “创建组件”">&#8203;</a></h4>
<p>在组件化架构中，<strong>组件是一个整体</strong>，包含三个部分：</p>
<ol>
<li><strong>脚本</strong> (<code>main.ts</code>)：组件的交互逻辑</li>
<li><strong>样式</strong> (<code>styles.css</code>)：组件的外观</li>
<li><strong>HTML 结构</strong> (<code>index.html</code>)：组件的标签</li>
</ol>
<p>这三个部分紧密相关，经常需要一起被引入。通过 Thymeleaf 的片段机制，我们可以在一个模板文件中定义两个片段，分别对应 &quot;需要在 <code>&lt;head&gt;</code> 引入脚本和样式&quot; 和 &quot;需要在 <code>&lt;body&gt;</code> 显示 HTML&quot;：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::702n93ztb6u1tga39176uq::--><code>&lt;!-- src/components/pagination/index.html --&gt;
&lt;!-- 
  片段 1：head 片段
  作用：在页面的 &lt;head&gt; 中引用这个片段时，会自动导入该组件的脚本/样式
  Vite 会自动识别脚本中的 import 语句，并将关联的 CSS 也一起提取
--&gt;
&lt;head th:remove=&quot;tag&quot;&gt;
  &lt;script src=&quot;main.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;
&lt;/head&gt;
&lt;!-- 
  片段 2：body 片段
  作用：在页面的 &lt;body&gt; 中引用这个片段时，会插入组件的 HTML 结构
--&gt;
&lt;body th:fragment=&quot;body(posts)&quot; th:remove=&quot;tag&quot;&gt;
  &lt;!-- 组件 HTML 内容 --&gt;
  &lt;div class=&quot;pagination&quot;&gt;
    &lt;a th:href=&quot;@{${posts.prevUrl}}&quot; th:if=&quot;${posts.hasPrevious()}&quot;&gt;
      &lt;span&gt;上一页&lt;/span&gt;
    &lt;/a&gt;
    &lt;span th:with=&quot;totalPage = ${posts.totalPages}&quot; th:if=&quot;${posts.totalPages &gt; 1}&quot;&gt;[[${totalPage}]]&lt;/span&gt;
    &lt;a th:href=&quot;@{${posts.nextUrl}}&quot; th:if=&quot;${posts.hasNext()}&quot;&gt;
      &lt;span&gt;下一页&lt;/span&gt;
    &lt;/a&gt;
  &lt;/div&gt;
&lt;/body&gt;</code></pre>
</div><div  class="tip custom-block"><p class="custom-block-title">提示</p>
<p>别忘了在脚本文件中导入样式文件：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::wc2gpyu6zfryxbra7eb5m::--><code>// src/components/pagination/main.ts
import &quot;./styles.css&quot;;</code></pre>
</div></div>
<h4 id="组件如何运作" tabindex="-1">组件如何运作 <a class="header-anchor" href="#组件如何运作" aria-label="Permalink to “组件如何运作”">&#8203;</a></h4>
<p>假设你有一个 <code>pagination</code> 组件：</p>
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::u4kap2zkged8nofjph4dn::--><code>src/components/pagination/
  ├── main.ts       ← 脚本：处理分页交互（比如动态加载）
  ├── styles.css    ← 样式：定义分页器的外观
  └── index.html    ← Thymeleaf 模板：定义两个片段（head 和 body）</code></pre>
</div><p>构建时过程：</p>
<ol>
<li>Vite 会识别组件 HTML 文件中的脚本，分析它的 import 语句</li>
<li>如果脚本中有 import CSS 文件，Vite 会自动提取 CSS 并创建对应的 <code>&lt;link&gt;</code> 标签</li>
<li>以上这些都在构建时自动完成，无需手工干预</li>
</ol>
<p>构建后的组件 HTML 会变成形如：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::l6ozh4b32gnhtvlld0rt1l::--><code>&lt;head th:remove=&quot;tag&quot;&gt;
  &lt;script type=&quot;module&quot; crossorigin src=&quot;/themes/my-theme/assets/dist/Abc123.js&quot;&gt;&lt;/script&gt;
  &lt;link rel=&quot;stylesheet&quot; crossorigin href=&quot;/themes/my-theme/assets/dist/Def456.css&quot; /&gt;
  &lt;!-- ↑ 自动生成，脚本和样式都有了 --&gt;
&lt;/head&gt;

&lt;body th:fragment=&quot;body(posts)&quot; th:remove=&quot;tag&quot;&gt;
  &lt;!-- 组件 HTML 内容 --&gt;
  &lt;div class=&quot;pagination&quot;&gt;
    &lt;!-- ... --&gt;
  &lt;/div&gt;
&lt;/body&gt;</code></pre>
</div><p>当在页面中使用这个组件时：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::lc2s0q19n5g9pjo3e31mf::--><code>&lt;!-- 在 &lt;head&gt; 中引入组件 --&gt;
&lt;th:block th:insert=&quot;~{components/pagination/index :: head}&quot;&gt;&lt;/th:block&gt;
&lt;!-- ↑ 这样做时，Thymeleaf 会在这里插入编译后的标签，自动引入组件的资源 --&gt;

&lt;!-- 在 &lt;body&gt; 中使用组件 --&gt;
&lt;th:block th:insert=&quot;~{components/pagination/index :: body(posts = ${posts})}&quot;&gt;&lt;/th:block&gt;
&lt;!-- ↑ 这样做时，会在此处插入组件的 HTML 结构 --&gt;</code></pre>
</div><h4 id="使用组件" tabindex="-1">使用组件 <a class="header-anchor" href="#使用组件" aria-label="Permalink to “使用组件”">&#8203;</a></h4>
<p>下面给出一个完整示例：在已使用根级布局片段的首页模板中引入组件。</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::4beud6ycq3fctquzmrgrh5::--><code>&lt;!-- templates/index.html --&gt;
&lt;!DOCTYPE html&gt;
&lt;!-- th:block 替换掉原本的 html 标签 --&gt;
&lt;th:block
  xmlns:th=&quot;http://www.thymeleaf.org&quot;
  th:replace=&quot;~{fragments/layout :: html(
    title = &#039;首页 | 我的博客&#039;,
    head = ~{:: head},
    content = ~{:: body},
    header = ~{components/header/index :: body}
  )}&quot;
&gt;
  &lt;!-- 该页面在布局中的 head 部分。根据上文根级布局片段定义，会注入到最终渲染的 head 标签中 --&gt;
  &lt;head th:remove=&quot;tag&quot;&gt;
    &lt;!-- 页面特定的 meta 信息 --&gt;
    &lt;meta name=&quot;description&quot; content=&quot;博客首页&quot; /&gt;

    &lt;!-- 该页面的脚本 --&gt;
    &lt;script src=&quot;/src/scripts/pages/index.ts&quot; type=&quot;module&quot;&gt;&lt;/script&gt;

    &lt;!-- 该页面使用的组件的 head 片段 --&gt;
    &lt;!-- 你可以结合主题配置，使用 th:if，用主题配置项控制是否使用对应组件 --&gt;
    &lt;th:block th:insert=&quot;~{components/post-list/index :: head}&quot;&gt;&lt;/th:block&gt;
    &lt;th:block th:insert=&quot;~{components/pagination/index :: head}&quot;&gt;&lt;/th:block&gt;
  &lt;/head&gt;

  &lt;!-- 该页面在布局中的 content 部分 --&gt;
  &lt;body th:remove=&quot;tag&quot;&gt;
    &lt;div class=&quot;index-content&quot;&gt;
      &lt;!-- 首页内容 --&gt;
      &lt;!-- 省略若干内容 --&gt;

      &lt;!-- 该页面使用的组件的 body 片段 --&gt;
      &lt;th:block th:insert=&quot;~{components/post-list/index :: body(posts = ${posts})}&quot;&gt;&lt;/th:block&gt;
      &lt;th:block th:insert=&quot;~{components/pagination/index :: body(posts = ${posts})}&quot;&gt;&lt;/th:block&gt;
    &lt;/div&gt;
  &lt;/body&gt;
&lt;/th:block&gt;</code></pre>
</div><h2 id="常见问题" tabindex="-1">常见问题 <a class="header-anchor" href="#常见问题" aria-label="Permalink to “常见问题”">&#8203;</a></h2>
<h3 id="模板文件生成位置错误" tabindex="-1">模板文件生成位置错误 <a class="header-anchor" href="#模板文件生成位置错误" aria-label="Permalink to “模板文件生成位置错误”">&#8203;</a></h3>
<p><strong>问题</strong>：构建后的模板文件不在预期的 <code>build.outDir</code> 文件夹内。</p>
<p><strong>原因</strong>：Vite 的构建机制限制。</p>
<p>例子：如果你把 HTML 文件放置在 <code>src/templates/index.html</code>，用以下配置进行编译</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::xhe2os00rvayhp2h16vbbe::--><code>// vite.config.ts
import path from &quot;node:path&quot;;
import { fileURLToPath } from &quot;node:url&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
  base: &quot;/themes/ABC/&quot;, // ABC 替换为主题的 metadata.name
  build: {
    outDir: fileURLToPath(new URL(&quot;./templates/&quot;, import.meta.url)),
    rollupOptions: {
      input: {
        index: path.resolve(__dirname, &quot;src/templates/index.html&quot;),
      },
    },
  },
});</code></pre>
</div><p>最终会编译到 <code>templates/src/templates/index.html</code></p>
<p><strong>解决方案 1</strong>：</p>
<p>将页面模板文件放置在项目根目录，例如将首页模板放在 <code>index.html</code>，使用上述配置进行编译，最终会编译到 <code>templates/index.html</code>，并且完美支持文件监听功能（<code>vite build --watch</code>）。该方案的缺点是根目录文件数量较多。</p>
<p><strong>解决方案 2（最推荐）</strong>：</p>
<p>将页面模板文件放置在 <code>src/templates/</code>，例如将首页模板放在 <code>src/templates/index.html</code>。在构建配置中额外设置 <code>root</code>，用以下配置进行编译：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::qrigq15qj2cmda19iqu41s::--><code>// vite.config.ts
import path from &quot;node:path&quot;;
import { fileURLToPath } from &quot;node:url&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
  root: path.resolve(__dirname, &quot;src/templates/&quot;), // [!code ++]
  base: &quot;/themes/ABC/&quot;, // ABC 替换为主题的 metadata.name
  build: {
    outDir: fileURLToPath(new URL(&quot;./templates/&quot;, import.meta.url)),
    rollupOptions: {
      input: {
        index: path.resolve(__dirname, &quot;src/templates/index.html&quot;),
      },
    },
  },
});</code></pre>
</div><p>最终会编译到 <code>templates/index.html</code>，并且完美支持文件监听功能（<code>vite build --watch</code>）。该方案的缺点是嵌套层数较多，但相对来说是最推荐的方案。</p>
<p>实现可参考：<a href="https://github.com/HowieHz/halo-theme-higan-hz/tree/daa7038479243830246e51819bce0a576d39641d/src/templates/" target="_blank" rel="noreferrer">HowieHz/halo-theme-higan-hz@daa7038</a>。</p>
<p><strong>解决方案 3</strong>：</p>
<p>使用以下自定义插件，移除多余嵌套结构</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::xodpj67gnqaxtzp9uxam5::--><code>// plugins/vite-plugin-move-html.ts
import { promises as fs } from &quot;node:fs&quot;;
import { dirname, isAbsolute, join, normalize, resolve, sep } from &quot;node:path&quot;;

import { type Plugin } from &quot;vite&quot;;

interface MoveHtmlOptions {
  /** Target directory, relative to project root, cannot contain `..` */
  dest: string;
  /** Flatten level, defaults to 0 (no flattening) */
  flatten?: number;
  /** Whether to delete original empty directories, defaults to true */
  removeEmptyDirs?: boolean;
}

/** Ensure path does not contain &#039;..&#039;, and is a relative path within the project */
function assertSafeRelative(p: string) {
  if (isAbsolute(p) || normalize(p).split(sep).includes(&quot;..&quot;)) {
    throw new Error(`Disallowed path: ${p}`);
  }
  return p.replace(/^[\\/]+|[\\/]+$/g, &quot;&quot;);
}

/** Safe join, can only be within rootDir */
/* c8 ignore next 3 */
/* istanbul ignore next */
/* codacy ignore next */
function safeJoin(rootDir: string, ...segments: string[]) {
  const target = normalize(join(rootDir, ...segments));
  // Path traversal validation has been done
  if (!target.startsWith(rootDir + sep)) {
    throw new Error(`Path traversal: ${target}`);
  }
  return target;
}

export default function moveHtmlPlugin(opts: MoveHtmlOptions): Plugin {
  // Validate and normalize dest
  const safeDest = assertSafeRelative(opts.dest);
  const flattenCount = opts.flatten ?? 0;
  const removeEmptyDirs = opts.removeEmptyDirs ?? true;

  return {
    name: &quot;vite-plugin-move-html&quot;,
    apply: &quot;build&quot;,
    enforce: &quot;post&quot;,

    async writeBundle(bundleOptions, bundle) {
      // Normalize output directory, path validation has been done, safe to use resolve
      const outDir = bundleOptions.dir
        ? resolve(bundleOptions.dir)
        : bundleOptions.file
          ? dirname(resolve(bundleOptions.file))
          : (() =&gt; {
              throw new Error(&quot;Neither dir nor file specified in bundleOptions&quot;);
            })();

      // Project root absolute path
      const projectRoot = resolve(process.cwd());

      // Target directory absolute path
      const destDir = safeJoin(projectRoot, safeDest);

      const movedDirs = new Set&lt;string&gt;();

      for (const rawName of Object.keys(bundle)) {
        // Only care about .html, .html.gz, .html.br, .html.zst files
        if (!/(\.html)(\.gz|\.br|\.zst)?$/.test(rawName)) continue;

        // Normalize filename, &#039;../&#039; not allowed
        const name = normalize(rawName);
        if (name.split(sep).includes(&quot;..&quot;)) continue;

        // Source path
        const srcPath = safeJoin(outDir, name);

        // Flatten processing
        const segments = name.split(/[/\\]/);
        const drop = Math.min(flattenCount, segments.length - 1);
        const newSegments = segments.slice(drop);
        const targetPath = safeJoin(destDir, ...newSegments);

        // Ensure directory exists and move
        await fs.mkdir(dirname(targetPath), { recursive: true });
        await fs.rename(srcPath, targetPath);
        movedDirs.add(dirname(srcPath));
      }

      if (removeEmptyDirs) {
        // Delete empty directories from deep to shallow
        const sorted = Array.from(movedDirs).sort((a, b) =&gt; b.length - a.length);
        for (const dir of sorted) {
          let cur = dir;
          while (cur.startsWith(outDir + sep)) {
            try {
              await fs.rmdir(cur);
              cur = dirname(cur);
            } catch {
              break;
            }
          }
        }
      }
    },
  };
}</code></pre>
</div><p>编译配置中添加插件：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::qjynhgr0ra8lh8bf4j0ze::--><code>// vite.config.ts
import path from &quot;node:path&quot;;
import { fileURLToPath } from &quot;node:url&quot;;
import { defineConfig } from &quot;vite&quot;;
import moveHtmlPlugin from &quot;./plugins/vite-plugin-move-html&quot;;

export default defineConfig({
  base: &quot;/themes/ABC/&quot;, // ABC 替换为主题的 metadata.name
  plugins: [
    moveHtmlPlugin({ dest: &quot;templates&quot;, flatten: 2 }), // 移除两层嵌套
  ],
  build: {
    outDir: fileURLToPath(new URL(&quot;./templates/&quot;, import.meta.url)),
    rollupOptions: {
      input: {
        index: path.resolve(__dirname, &quot;src/templates/index.html&quot;),
      },
    },
  },
});</code></pre>
</div><p>即可解决此问题。</p>
<h3 id="模块预加载代码重复加载" tabindex="-1">模块预加载代码重复加载 <a class="header-anchor" href="#模块预加载代码重复加载" aria-label="Permalink to “模块预加载代码重复加载”">&#8203;</a></h3>
<p><strong>问题</strong>：一段 modulepreload polyfill 代码在多个页面中被重复加载。</p>
<p><strong>原因</strong>：Vite 认为组件和页面都是单独的入口，故重复导入了此 polyfill。</p>
<p><strong>解决方案</strong>：</p>
<p>首先在构建配置中禁用此 polyfill：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::prtty58xc9axp0cuacuphl::--><code>// vite.config.ts
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
  build: {
    modulePreload: {
      // https://vite.dev/config/build-options#build-modulepreload
      polyfill: false,
    },
  },
});</code></pre>
</div><p>随后在公共布局模板的 <code>head</code> 标签内手动引入此 polyfill：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::0xc7emjzzke0sbkzenvrrsl::--><code>&lt;!--/* browser polyfill for modulepreload start
https://github.com/vitejs/vite/blob/2436afef044d90f710fdfd714488a71efdd29092/packages/vite/src/node/plugins/modulePreloadPolyfill.ts#L39 

The following polyfill function is meant to run in the browser and adapted from
https://github.com/guybedford/es-module-shims
MIT License
Copyright (C) 2018-2021 Guy Bedford
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the &quot;Software&quot;), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*/--&gt;
&lt;script type=&quot;module&quot;&gt;
  (function () {
    const relList = document.createElement(&quot;link&quot;).relList;
    if (relList &amp;&amp; relList.supports &amp;&amp; relList.supports(&quot;modulepreload&quot;)) {
      return;
    }

    for (const link of document.querySelectorAll(&#039;link[rel=&quot;modulepreload&quot;]&#039;)) {
      processPreload(link);
    }

    new MutationObserver((mutations) =&gt; {
      for (const mutation of mutations) {
        if (mutation.type !== &quot;childList&quot;) {
          continue;
        }
        for (const node of mutation.addedNodes) {
          if (node.tagName === &quot;LINK&quot; &amp;&amp; node.rel === &quot;modulepreload&quot;) processPreload(node);
        }
      }
    }).observe(document, { childList: true, subtree: true });

    function getFetchOpts(link) {
      const fetchOpts = {};
      if (link.integrity) fetchOpts.integrity = link.integrity;
      if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
      if (link.crossOrigin === &quot;use-credentials&quot;) fetchOpts.credentials = &quot;include&quot;;
      else if (link.crossOrigin === &quot;anonymous&quot;) fetchOpts.credentials = &quot;omit&quot;;
      else fetchOpts.credentials = &quot;same-origin&quot;;
      return fetchOpts;
    }

    function processPreload(link) {
      if (link.ep)
        // ep marker = processed
        return;
      link.ep = true;
      const fetchOpts = getFetchOpts(link);
      fetch(link.href, fetchOpts);
    }
  })();
&lt;/script&gt;
&lt;!--/* browser polyfill for modulepreload end */--&gt;</code></pre>
</div><h3 id="脚本链接或插件注入内容出现在了-html-文件末尾" tabindex="-1">脚本链接或插件注入内容出现在了 HTML 文件末尾 <a class="header-anchor" href="#脚本链接或插件注入内容出现在了-html-文件末尾" aria-label="Permalink to “脚本链接或插件注入内容出现在了 HTML 文件末尾”">&#8203;</a></h3>
<p><strong>问题</strong>：构建产物中，脚本引入链接或插件注入内容出现在了 HTML 文件末尾，而非 <code>&lt;head&gt;</code> 内。</p>
<p><strong>原因</strong>：页面模板缺少 <code>&lt;head&gt;</code> 或 <code>&lt;body&gt;</code> 标签，Vite 找不到合适的注入点，只能将内容追加到文件末尾。</p>
<p><strong>解决方案</strong>：确保页面模板包含完整的 <code>&lt;head&gt;</code> 和 <code>&lt;body&gt;</code> 标签，可以参考上文提供的示例结构，也可以参考 <a href="https://github.com/HowieHz/halo-theme-higan-hz/tree/95d7b8ee1d985667e7c375c04f19889c0ac6b3ec/src/templates/components/example" target="_blank" rel="noreferrer">HowieHz/halo-theme-higan-hz</a> 实现的示例组件。</p>
<p>此问题可能出现在使用 <a href="https://www.npmjs.com/package/@vitejs/plugin-legacy" target="_blank" rel="noreferrer">@vitejs/plugin-legacy</a> 插件时。</p>
<h3 id="片段参数声明与传参方式" tabindex="-1">片段参数声明与传参方式 <a class="header-anchor" href="#片段参数声明与传参方式" aria-label="Permalink to “片段参数声明与传参方式”">&#8203;</a></h3>
<p><strong>片段参数声明</strong>：<code>th:fragment</code> 的参数列表决定了该片段接受的参数数量。</p>
<ul>
<li>不声明参数（如 <code>&lt;body th:fragment=&quot;body&quot;&gt;</code>、<code>&lt;body&gt;</code>）：调用时可传入任意数量的参数（包括 0 个），但片段内部无法通过名称引用它们</li>
<li>声明 N 个参数（如 <code>&lt;body th:fragment=&quot;body(a, b)&quot;&gt;</code>）：调用时<strong>至少</strong>提供对应数量的实参</li>
</ul>
<p><strong>传参方式</strong>：调用片段时，支持具名传参与位置传参两种写法，效果等价：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::m8mgm8at4phpdo20hkpag::--><code>&lt;!-- 具名传参 --&gt;
&lt;th:block th:insert=&quot;~{components/xxx/index :: body(param1 = ${var1}, param2 = ${var2})}&quot;&gt;&lt;/th:block&gt;

&lt;!-- 位置传参 --&gt;
&lt;th:block th:insert=&quot;~{components/xxx/index :: body(${var1}, ${var2})}&quot;&gt;&lt;/th:block&gt;</code></pre>
</div><h2 id="相关优化技巧" tabindex="-1">相关优化技巧 <a class="header-anchor" href="#相关优化技巧" aria-label="Permalink to “相关优化技巧”">&#8203;</a></h2>
<p>基于上述方案，以下优化手段均可低成本引入，进一步提升构建产物的质量与安全性。</p>
<h3 id="tailwind-css-类名混淆" tabindex="-1">Tailwind CSS 类名混淆 <a class="header-anchor" href="#tailwind-css-类名混淆" aria-label="Permalink to “Tailwind CSS 类名混淆”">&#8203;</a></h3>
<p>使用 <a href="https://github.com/sonofmagic/tailwindcss-mangle" target="_blank" rel="noreferrer">unplugin-tailwindcss-mangle</a> 将冗长的 Tailwind 类名压缩为短的名称：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::updrew35pb9cfhpiex66sv::--><code>// vite.config.ts
import utwm from &quot;unplugin-tailwindcss-mangle/vite&quot;;

export default defineConfig({
  plugins: [
    utwm(),
    // ... 其他插件
  ],
});</code></pre>
</div><p>构建前：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::nm89u1mrgmstc5cn0ett5::--><code>&lt;div class=&quot;flex items-center justify-between px-4 py-2 bg-white rounded-lg shadow-md&quot;&gt;
  &lt;!-- 页面内容 --&gt;
&lt;/div&gt;</code></pre>
</div><p>构建后：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::q8lux4f5m73br34qs5t::--><code>&lt;div class=&quot;tw-a tw-b tw-c tw-d tw-e tw-f tw-g tw-h tw-i&quot;&gt;
  &lt;!-- 页面内容 --&gt;
&lt;/div&gt;</code></pre>
</div><p>这样可以显著减小 HTML 和 CSS 文件的体积。</p>
<h3 id="子资源完整性校验-sri" tabindex="-1">子资源完整性校验（SRI） <a class="header-anchor" href="#子资源完整性校验-sri" aria-label="Permalink to “子资源完整性校验（SRI）”">&#8203;</a></h3>
<p>使用 <a href="https://github.com/yoyo930021/vite-plugin-sri3" target="_blank" rel="noreferrer">vite-plugin-sri3</a> 自动为所有资源添加 <code>integrity</code> 属性：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::az8fvpll0bwfp1z67d0np::--><code>// vite.config.ts
import { sri } from &quot;vite-plugin-sri3&quot;;

export default defineConfig({
  plugins: [sri()],
});</code></pre>
</div><p>构建后的 HTML 会自动包含完整性属性：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::xqedvt7s7l9rl5701qlc::--><code>&lt;script
  type=&quot;module&quot;
  crossorigin
  src=&quot;/themes/halo-theme/assets/dist/BHmhdQc.js&quot;
  integrity=&quot;sha384-PwPTtDfxEYBuQdSCNhn1tZiFMQSRKJuxAFju1e7R6E19noHRQmLeM6n8jEtACXje&quot;
&gt;&lt;/script&gt;</code></pre>
</div><p>这保证了即使 CDN 被污染，浏览器也会拒绝加载被篡改的资源。</p>
<h3 id="模块预加载" tabindex="-1">模块预加载 <a class="header-anchor" href="#模块预加载" aria-label="Permalink to “模块预加载”">&#8203;</a></h3>
<p>在主 JS 文件加载后，让浏览器预加载可能需要的其他模块。Vite 会<strong>自动</strong>生成 <code>&lt;link rel=&quot;modulepreload&quot;&gt;</code> 标签：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::6tj4j7jurzvka907g66rr::--><code>&lt;link
  rel=&quot;modulepreload&quot;
  crossorigin
  href=&quot;/themes/halo-theme/assets/dist/ChjrFNR.js&quot;
  integrity=&quot;sha384-bMWtZyBUsYF0Kuj4HeUjNMc6UkxB1YaN14SGkf1lC6i4dF5cnHV6iqvlB4e00j/h&quot;
/&gt;</code></pre>
</div><p>modulepreload 对具有多层依赖链的模块性能提升最为显著。</p>
<p>假设模块 A 依赖模块 B，模块 B 依赖模块 C。当页面加载模块 A 时：</p>
<ul>
<li>无 modulepreload：需要串行加载（模块 A → 模块 B → 模块 C），总加载时间较长</li>
<li>使用 modulepreload：浏览器并行预加载所有依赖，显著减少总的加载时间</li>
</ul>
<h3 id="静态资源预压缩" tabindex="-1">静态资源预压缩 <a class="header-anchor" href="#静态资源预压缩" aria-label="Permalink to “静态资源预压缩”">&#8203;</a></h3>
<p>参考<a href="./static-resource-precompression">《实现静态资源预压缩》</a>一文，在构建时生成 gzip、brotli 等多种压缩格式，让服务器直接提供预压缩文件，节省运行时的 CPU 和带宽。</p>
<h2 id="下一步" tabindex="-1">下一步 <a class="header-anchor" href="#下一步" aria-label="Permalink to “下一步”">&#8203;</a></h2>
<p>推荐尝试使用 <a href="https://github.com/halo-sigs/vite-plugin-halo-theme" target="_blank" rel="noreferrer">halo-sigs/vite-plugin-halo-theme</a> 这一 Vite 插件，它很好地实现了本文介绍的理念。</p>
]]></content:encoded>
            <author>howie_hzgo@outlook.com (HowieHz)</author>
        </item>
        <item>
            <title><![CDATA[实现静态资源预压缩]]></title>
            <link>https://howiehz.top/mhcga/posts/themes/static-resource-precompression</link>
            <guid isPermaLink="false">https://howiehz.top/mhcga/posts/themes/static-resource-precompression</guid>
            <pubDate>Fri, 13 Feb 2026 06:19:00 GMT</pubDate>
            <description><![CDATA[
# 实现静态资源预压缩

## 原理

构建时预压缩，以高压缩等级生成对应文件，服务器在运行时直接提供压缩后的文件。

一般约定：

| 算法      | 文件后缀 | 普及情况                                             |
| ]]></description>
            <content:encoded><![CDATA[<h1 id="实现静态资源预压缩" tabindex="-1">实现静态资源预压缩 <a class="header-anchor" href="#实现静态资源预压缩" aria-label="Permalink to “实现静态资源预压缩”">&#8203;</a></h1>
<h2 id="原理" tabindex="-1">原理 <a class="header-anchor" href="#原理" aria-label="Permalink to “原理”">&#8203;</a></h2>
<p>构建时预压缩，以高压缩等级生成对应文件，服务器在运行时直接提供压缩后的文件。</p>
<p>一般约定：</p>
<table tabindex="0">
<thead>
<tr>
<th>算法</th>
<th>文件后缀</th>
<th>普及情况</th>
</tr>
</thead>
<tbody>
<tr>
<td>gzip</td>
<td>.gz</td>
<td><a href="https://caniuse.com/sr-content-encoding-gzip" target="_blank" rel="noreferrer">gzip</a></td>
</tr>
<tr>
<td>brotli</td>
<td>.br</td>
<td><a href="https://caniuse.com/brotli" target="_blank" rel="noreferrer">brotli</a></td>
</tr>
<tr>
<td>zstandard</td>
<td>.zst</td>
<td><a href="https://caniuse.com/zstd" target="_blank" rel="noreferrer">zstd</a></td>
</tr>
</tbody>
</table>
<p>例：如果你看到 <code>1.js.br</code>，说明是由 <code>1.js</code> 使用 brotli 算法预压缩生成的文件。</p>
<p>注：截止于 2026 年 2 月 13 日，zstd 未进入 baseline。建议当前仅部署 gzip 和 brotli 预压缩版本。</p>
<h2 id="优势与劣势" tabindex="-1">优势与劣势 <a class="header-anchor" href="#优势与劣势" aria-label="Permalink to “优势与劣势”">&#8203;</a></h2>
<p>预压缩有以下优势：</p>
<ul>
<li>节约服务器内存资源。</li>
<li>节约服务器 CPU 资源。</li>
<li>节约服务器带宽。</li>
</ul>
<p>劣势：</p>
<ul>
<li>需占用更多的存储空间。</li>
<li>编译时需消耗更多的 CPU 和内存资源，以及更长的耗时。</li>
<li>分发的主题包和插件包会相较于之前更大。</li>
</ul>
<h2 id="如何配置" tabindex="-1">如何配置 <a class="header-anchor" href="#如何配置" aria-label="Permalink to “如何配置”">&#8203;</a></h2>
<h3 id="通用配置" tabindex="-1">通用配置 <a class="header-anchor" href="#通用配置" aria-label="Permalink to “通用配置”">&#8203;</a></h3>
<p>适合预压缩的文件后缀（正则表达式形式）：</p>
<div class="language-js"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre><!--::markdown-it-async::yzolks30vljvt2knf2itwl::--><code>/\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md)$/;</code></pre>
</div><p>注：可根据实际情况补充其他文件，例如：conf, ini, cfg。</p>
<p>对应 MIME：</p>
<!-- markdownlint-disable MD013 -->
<div class="language-plaintext"><button title="Copy Code" class="copy"></button><span class="lang">plaintext</span><pre><!--::markdown-it-async::m4ze2u0qf1vqnr6kll50c::--><code>application/atom+xml application/javascript application/json application/vnd.api+json application/rss+xml application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype application/x-font-ttf application/x-javascript application/xhtml+xml application/xml font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml text/html</code></pre>
</div><!-- markdownlint-enable MD013 -->
<p>注：根据文档，nginx 配置 <code>gzip_types</code>, <code>brotli_types</code>, <code>zstd_types</code> 时，均无需填写 <code>text/html</code>。不论 <code>*_types</code>是否包括<code>text/html</code>，<code>text/html</code> 始终会被动态压缩。相关文档：<a href="https://nginx.org/en/docs/http/ngx_http_gzip_module.html#gzip_types" target="_blank" rel="noreferrer">gzip_types</a>，<a href="https://github.com/google/ngx_brotli#brotli_types" target="_blank" rel="noreferrer">brotli_types</a>，<a href="https://github.com/tokers/zstd-nginx-module#zstd_types" target="_blank" rel="noreferrer">zstd_types</a>。</p>
<h3 id="系统相关配置" tabindex="-1">系统相关配置 <a class="header-anchor" href="#系统相关配置" aria-label="Permalink to “系统相关配置”">&#8203;</a></h3>
<p>在内存受限环境中使用较高压缩等级时，构建过程可能因内存不足被系统终止。
此时建议增加可用虚拟内存：​Windows 扩大虚拟内存（pagefile），​Linux 增加交换分区（swap）。</p>
<p>例如，在 GitHub Actions 的 <code>ubuntu-latest</code>（<a href="https://docs.github.com/zh/actions/reference/runners/github-hosted-runners#standard-github-hosted-runners-for-public-repositories" target="_blank" rel="noreferrer">公开仓库</a>为 ​16GB 内存 + 4GB 交换分区​）中，可在 <code>steps</code> 开头增加以下步骤，额外创建 ​12GB 交换分区，以降低构建失败的风险：</p>
<div class="language-yaml"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre><!--::markdown-it-async::py0i885msazwg4qkosdp::--><code>- name: Add swap space
  run: |
    SWAP_FILE=&quot;/tmp/github-actions-swap-$$&quot;
    MEMORY_LIMIT_GB=12
    # Add swap space to help with memory issues during build
    sudo fallocate -l ${MEMORY_LIMIT_GB}G &quot;$SWAP_FILE&quot;
    sudo chmod 600 &quot;$SWAP_FILE&quot;
    sudo mkswap &quot;$SWAP_FILE&quot;
    sudo swapon &quot;$SWAP_FILE&quot;
    echo &quot;Added ${MEMORY_LIMIT_GB}GB swap at $SWAP_FILE&quot;
    free -h</code></pre>
</div><p>请根据项目实际规模调整交换分区的大小。</p>
<h3 id="构建配置" tabindex="-1">构建配置 <a class="header-anchor" href="#构建配置" aria-label="Permalink to “构建配置”">&#8203;</a></h3>
<ul>
<li>Vite: <a href="#适用于-vite-的配置">适用于 Vite 的配置</a></li>
<li>Rsbuild: <a href="#适用于-rsbuild-的配置">适用于 Rsbuild 的配置</a></li>
<li>webpack: <a href="#适用于-webpack-的配置">适用于 webpack 的配置</a></li>
</ul>
<h4 id="适用于-vite-的配置" tabindex="-1">适用于 Vite 的配置 <a class="header-anchor" href="#适用于-vite-的配置" aria-label="Permalink to “适用于 Vite 的配置”">&#8203;</a></h4>
<p>安装 <a href="https://www.npmjs.com/package/vite-plugin-compression2" target="_blank" rel="noreferrer">vite-plugin-compression2</a> 插件进行预压缩：</p>
<p>选择合适的安装方式：</p>
<div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::wovg7fnwqdiah3eokz82t9::--><code>npm install vite-plugin-compression2 -D</code></pre>
</div><div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::xxqrbl9q33boietgnud8a::--><code>pnpm add vite-plugin-compression2 -D</code></pre>
</div><div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::10inncnn8mzf1roo0q1w7o2::--><code>yarn add vite-plugin-compression2 -D</code></pre>
</div><p>安装完成后配置 <code>vite.config.ts</code>：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::j7me9l12epx9src6guy8i::--><code>// vite.config.ts
import { constants } from &quot;node:zlib&quot;;
import { compression, defineAlgorithm } from &quot;vite-plugin-compression2&quot;;

export default defineConfig({
  // ...
  plugins: [
    // ...
    compression({
      algorithms: [
        // 设置为最大压缩等级 9
        defineAlgorithm(&quot;gzip&quot;, { level: 9 }),
        // 设置为最大压缩等级 11
        defineAlgorithm(&quot;brotliCompress&quot;, {
          params: {
            [constants.BROTLI_PARAM_QUALITY]: 11,
          },
        }),
        // Zstandard 最大压缩等级为 22，但为符合 RFC 9659 规范，实际最大可用值为 19，以防止在 Chrome（v123+）和 Firefox（v126+）中报错
        // 详见 https://github.com/nonzzz/vite-plugin-compression/issues/93
        defineAlgorithm(&quot;zstandard&quot;, {
          params: {
            [constants.ZSTD_c_compressionLevel]: 19,
          },
        }),
      ],
      include: [
        // 可按需补充其他后缀
        /\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md|conf|ini|cfg)$/,
      ],
    }),
  ],
});</code></pre>
</div><p>注意：此插件与 VitePress 兼容性不佳，会导致生成的 <code>.js</code> 预压缩文件中出现多余的 <code>__VP_STATIC_START__</code> and <code>__VP_STATIC_END__</code> 标记。</p>
<h4 id="适用于-rsbuild-的配置" tabindex="-1">适用于 Rsbuild 的配置 <a class="header-anchor" href="#适用于-rsbuild-的配置" aria-label="Permalink to “适用于 Rsbuild 的配置”">&#8203;</a></h4>
<p>安装 <a href="https://www.npmjs.com/package/compression-webpack-plugin" target="_blank" rel="noreferrer">compression-webpack-plugin</a> 插件进行预压缩：</p>
<p>选择合适的安装方式：</p>
<div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::e3a1wketn9q9aygdhc80r4::--><code>npm install compression-webpack-plugin -D</code></pre>
</div><div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::ge27dgf1q2ky4zz0lf843::--><code>pnpm add compression-webpack-plugin -D</code></pre>
</div><div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::3rh1y4qourgr3sssb5wobm::--><code>yarn add compression-webpack-plugin -D</code></pre>
</div><p>安装完成后配置 <code>rsbuild.config.ts</code>：</p>
<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><!--::markdown-it-async::qy5fkr6e6vnnxwobr2kj::--><code>// rsbuild.config.ts
import { constants } from &quot;node:zlib&quot;;
import CompressionPlugin from &quot;compression-webpack-plugin&quot;;

export default defineConfig({
  // ...
  tools: {
    rspack: {
      plugins: [
        new CompressionPlugin({
          algorithm: &quot;gzip&quot;,
          include:
            /\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md|conf|ini|cfg)$/,
          // 设置为最大压缩等级 9
          compressionOptions: { level: 9 },
          // minRatio 配置可参考文档：https://github.com/webpack/compression-webpack-plugin#minratio
          minRatio: Number.MAX_SAFE_INTEGER,
        }),
        new CompressionPlugin({
          algorithm: &quot;brotliCompress&quot;,
          include:
            /\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md|conf|ini|cfg)$/,
          // 设置为最大压缩等级 11
          compressionOptions: {
            params: {
              [constants.BROTLI_PARAM_QUALITY]: 11,
            },
          },
          minRatio: Number.MAX_SAFE_INTEGER,
        }),
        new CompressionPlugin({
          // 必须，否则此插件会将文件命名为 [path][base].gz 与 gzip 冲突
          filename: &quot;[path][base].zst&quot;,
          algorithm: &quot;zstdCompress&quot;,
          include:
            /\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md|conf|ini|cfg)$/,
          // Zstandard 最大压缩等级为 22，但为符合 RFC 9659 规范，实际最大可用值为 19，以防止在 Chrome（v123+）和 Firefox（v126+）中报错
          // 详见 https://github.com/nonzzz/vite-plugin-compression/issues/93
          compressionOptions: {
            params: {
              [constants.ZSTD_c_compressionLevel]: 19,
            },
          },
          minRatio: Number.MAX_SAFE_INTEGER,
        }),
      ],
    },
  },
});</code></pre>
</div><h4 id="适用于-webpack-的配置" tabindex="-1">适用于 webpack 的配置 <a class="header-anchor" href="#适用于-webpack-的配置" aria-label="Permalink to “适用于 webpack 的配置”">&#8203;</a></h4>
<p>安装 <a href="https://www.npmjs.com/package/compression-webpack-plugin" target="_blank" rel="noreferrer">compression-webpack-plugin</a> 插件进行预压缩：</p>
<p>选择合适的安装方式：</p>
<div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::iqsoa7s6a9embm76dyn6i::--><code>npm install compression-webpack-plugin -D</code></pre>
</div><div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::fcvph1uy1yrohj638gl4hl::--><code>pnpm add compression-webpack-plugin -D</code></pre>
</div><div class="language-bash"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre><!--::markdown-it-async::88imf7tc084bc3lp09l5y::--><code>yarn add compression-webpack-plugin -D</code></pre>
</div><p>安装完成后配置 <code>webpack.config.js</code>：</p>
<div class="language-js"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre><!--::markdown-it-async::r4r7g8u5yitcve7x0bmwf6::--><code>// webpack.config.js
const { constants } = require(&quot;zlib&quot;);
const CompressionPlugin = require(&quot;compression-webpack-plugin&quot;);

module.exports = {
  // ...
  plugins: [
    new CompressionPlugin({
      algorithm: &quot;gzip&quot;,
      include:
        /\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md|conf|ini|cfg)$/,
      // 设置为最大压缩等级 9
      compressionOptions: { level: 9 },
      // minRatio 配置可参考文档：https://github.com/webpack/compression-webpack-plugin#minratio
      minRatio: Number.MAX_SAFE_INTEGER,
    }),
    new CompressionPlugin({
      algorithm: &quot;brotliCompress&quot;,
      include:
        /\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md|conf|ini|cfg)$/,
      // 设置为最大压缩等级 11
      compressionOptions: {
        params: {
          [constants.BROTLI_PARAM_QUALITY]: 11,
        },
      },
      minRatio: Number.MAX_SAFE_INTEGER,
    }),
    new CompressionPlugin({
      // 必须，否则此插件会将文件命名为 [path][base].gz 与 gzip 冲突
      filename: &quot;[path][base].zst&quot;,
      algorithm: &quot;zstdCompress&quot;,
      include:
        /\.(atom|rss|xml|xhtml|js|mjs|ts|html|json|css|eot|otf|ttf|svg|ico|bmp|dib|txt|text|log|md|conf|ini|cfg)$/,
      // Zstandard 最大压缩等级为 22，但为符合 RFC 9659 规范，实际最大可用值为 19，以防止在 Chrome（v123+）和 Firefox（v126+）中报错
      // 详见 https://github.com/nonzzz/vite-plugin-compression/issues/93
      compressionOptions: {
        params: {
          [constants.ZSTD_c_compressionLevel]: 19,
        },
      },
      minRatio: Number.MAX_SAFE_INTEGER,
    }),
  ],
};</code></pre>
</div><h3 id="部署配置" tabindex="-1">部署配置 <a class="header-anchor" href="#部署配置" aria-label="Permalink to “部署配置”">&#8203;</a></h3>
<ul>
<li>Halo CMS: <a href="#在-halo-cms-上使用">在 Halo CMS 上使用</a></li>
<li>nginx: <a href="#在-nginx-上使用">在 nginx 上使用</a></li>
<li>Apache: <a href="#在-apache-上使用">在 Apache 上使用</a></li>
</ul>
<h4 id="在-halo-cms-上使用" tabindex="-1">在 Halo CMS 上使用 <a class="header-anchor" href="#在-halo-cms-上使用" aria-label="Permalink to “在 Halo CMS 上使用”">&#8203;</a></h4>
<p>经检查，Halo CMS v2.22.14 会自动采用 <code>.br</code> 文件，但不会自动采用 <code>.zst</code> 文件。</p>
<h4 id="在-nginx-上使用" tabindex="-1">在 nginx 上使用 <a class="header-anchor" href="#在-nginx-上使用" aria-label="Permalink to “在 nginx 上使用”">&#8203;</a></h4>
<!-- markdownlint-disable MD013 -->
<div class="language-nginx"><button title="Copy Code" class="copy"></button><span class="lang">nginx</span><pre><!--::markdown-it-async::zc0x14483rbd8dkoqgy3v::--><code>http {
    # nginx 会根据 Accept-Encoding 决定提供哪种格式的文件，因此不同算法配置顺序不影响结果。

    # 启用 gzip_static 模块以提供预压缩的 .gz 文件
    gzip_static on;

    # 如果找不到静态文件则回退到动态压缩
    gzip on;
    gzip_types application/atom+xml application/javascript application/json application/vnd.api+json application/rss+xml application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype application/x-font-ttf application/x-javascript application/xhtml+xml application/xml font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml;
    # 用于在 HTTP 响应头中添加 Vary: Accept-Encoding 字段
    gzip_vary on;
    # 让 nginx 也动态压缩反向代理的内容
    gzip_proxied expired no-cache no-store private auth;

    # 启用 brotli_static 以提供预压缩的 .br 文件
    # 需要 ngx_brotli 模块: https://github.com/google/ngx_brotli
    # 如果你使用 1Panel 面板：
    #     可前往 /websites 页面，点击设置-&gt;模块-&gt;启用 ngx_brotli-&gt;构建，即可启用。
    brotli_static on;

    # 如果找不到静态文件则回退到动态压缩
    brotli on;
    brotli_types application/atom+xml application/javascript application/json application/vnd.api+json application/rss+xml application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype application/x-font-ttf application/x-javascript application/xhtml+xml application/xml font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml;

    # 启用 zstd_static 以提供预压缩的 .zst 文件
    # 需要 zstd-nginx-module 模块: https://github.com/tokers/zstd-nginx-module
    zstd_static on;

    # 如果找不到静态文件则回退到动态压缩
    zstd on;
    zstd_types application/atom+xml application/javascript application/json application/vnd.api+json application/rss+xml application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype application/x-font-ttf application/x-javascript application/xhtml+xml application/xml font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml;

    server {
        # 其他配置
        listen 80;
        server_name example.com;
        root /var/www/html;

        location / {
            try_files $uri $uri/ /index.html;
        }
    }
}</code></pre>
</div><!-- markdownlint-enable MD013 -->
<h4 id="在-apache-上使用" tabindex="-1">在 Apache 上使用 <a class="header-anchor" href="#在-apache-上使用" aria-label="Permalink to “在 Apache 上使用”">&#8203;</a></h4>
<!-- markdownlint-disable MD013 -->
<div class="language-apache"><button title="Copy Code" class="copy"></button><span class="lang">apache</span><pre><!--::markdown-it-async::xfsfva9dr3j8zh2j7p1hsl::--><code># 启用 mod_deflate 以实现回退动态压缩
&lt;IfModule mod_deflate.c&gt;
    AddOutputFilterByType DEFLATE application/atom+xml application/javascript application/json application/vnd.api+json application/rss+xml application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype application/x-font-ttf application/x-javascript application/xhtml+xml application/xml font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml
&lt;/IfModule&gt;

# 提供预压缩文件
&lt;IfModule mod_rewrite.c&gt;
    RewriteEngine On

    # 如果存在 .zst 文件且客户端支持 zstd，则提供该文件
    RewriteCond %{HTTP:Accept-Encoding} zstd
    RewriteCond %{REQUEST_FILENAME}.zst -f
    RewriteRule ^(.*)$ $1.zst [L]

    # 如果存在 .br 文件且客户端支持 brotli，则提供该文件
    RewriteCond %{HTTP:Accept-Encoding} br
    RewriteCond %{REQUEST_FILENAME}.br -f
    RewriteRule ^(.*)$ $1.br [L]

    # 如果存在 .gz 文件且客户端支持 gzip，则提供该文件
    RewriteCond %{HTTP:Accept-Encoding} gzip
    RewriteCond %{REQUEST_FILENAME}.gz -f
    RewriteRule ^(.*)$ $1.gz [L]
&lt;/IfModule&gt;

# 设置正确的 content-type 和 encoding headers
&lt;FilesMatch &quot;\.js\.gz$&quot;&gt;
    Header set Content-Type &quot;application/javascript&quot;
    Header set Content-Encoding &quot;gzip&quot;
&lt;/FilesMatch&gt;

&lt;FilesMatch &quot;\.css\.gz$&quot;&gt;
    Header set Content-Type &quot;text/css&quot;
    Header set Content-Encoding &quot;gzip&quot;
&lt;/FilesMatch&gt;

&lt;FilesMatch &quot;\.js\.br$&quot;&gt;
    Header set Content-Type &quot;application/javascript&quot;
    Header set Content-Encoding &quot;br&quot;
&lt;/FilesMatch&gt;

&lt;FilesMatch &quot;\.css\.br$&quot;&gt;
    Header set Content-Type &quot;text/css&quot;
    Header set Content-Encoding &quot;br&quot;
&lt;/FilesMatch&gt;

&lt;FilesMatch &quot;\.js\.zst$&quot;&gt;
    Header set Content-Type &quot;application/javascript&quot;
    Header set Content-Encoding &quot;zstd&quot;
&lt;/FilesMatch&gt;

&lt;FilesMatch &quot;\.css\.zst$&quot;&gt;
    Header set Content-Type &quot;text/css&quot;
    Header set Content-Encoding &quot;zstd&quot;
&lt;/FilesMatch&gt;</code></pre>
</div><!-- markdownlint-enable MD013 -->
<h2 id="如何确定生效" tabindex="-1">如何确定生效 <a class="header-anchor" href="#如何确定生效" aria-label="Permalink to “如何确定生效”">&#8203;</a></h2>
<ol>
<li>确定你的浏览器支持所选择的协议：<a href="https://caniuse.com/sr-content-encoding-gzip" target="_blank" rel="noreferrer">gzip</a>，<a href="https://caniuse.com/brotli" target="_blank" rel="noreferrer">brotli</a>，<a href="https://caniuse.com/zstd" target="_blank" rel="noreferrer">zstd</a>。</li>
<li>打开浏览器的开发者工具。</li>
<li>选择“网络”（Network）分页。</li>
<li>刷新网页，点击你要检查的文件（比如 <code>.js</code> 后缀的文件）</li>
<li>查看标头（Headers），找到 <code>Content-Encoding</code>，如果是 <code>br</code>, <code>gzip</code>, <code>zstd</code>，说明正确采用了对应的压缩算法传输。</li>
</ol>
<p>注意：在 114 版本 Edge 上测试，发现在 https 站点/<code>127.0.0.1</code> 的情况下 <code>Accept-Encoding</code>是 <code>gzip, deflate, br, zstd</code>。而在 http 站点上 <code>Accept-Encoding</code> 为 <code>gzip, deflate</code>。
<strong>结论</strong>：想要使用 brotli 和 zstandard 算法的预压缩文件，需要先给站点配置 https。</p>
]]></content:encoded>
            <author>howie_hzgo@outlook.com (HowieHz)</author>
        </item>
        <item>
            <title><![CDATA[静态网页服务插件内部自动重定向]]></title>
            <link>https://howiehz.top/mhcga/posts/usage/static-pages-auto-redirect</link>
            <guid isPermaLink="false">https://howiehz.top/mhcga/posts/usage/static-pages-auto-redirect</guid>
            <pubDate>Thu, 04 Dec 2025 09:35:46 GMT</pubDate>
            <description><![CDATA[静态网页服务插件内部自动重定向
在 Halo 的静态网页服务插件里上传前端构建产物后，最棘手的问题是如何实现路径回退：服务需要像 Netlify/Vercel/GitHub Pages 那样，内部自动]]></description>
            <content:encoded><![CDATA[<h1 id="静态网页服务插件内部自动重定向" tabindex="-1">静态网页服务插件内部自动重定向 <a class="header-anchor" href="#静态网页服务插件内部自动重定向" aria-label="Permalink to “静态网页服务插件内部自动重定向”">&#8203;</a></h1>
<p>在 Halo 的<a href="https://www.halo.run/store/apps/app-gFkMn" target="_blank" rel="noreferrer">静态网页服务插件</a>里上传前端构建产物后，最棘手的问题是如何实现路径回退：服务需要像 <a href="https://vitepress.dev/zh/guide/routing#generating-clean-url" target="_blank" rel="noreferrer">Netlify/Vercel/GitHub Pages</a> 那样，内部自动在“原始路径 -&gt; <code>.html</code> -&gt; <code>/index.html</code>”重定向。</p>
<h2 id="路由策略实现" tabindex="-1">路由策略实现 <a class="header-anchor" href="#路由策略实现" aria-label="Permalink to “路由策略实现”">&#8203;</a></h2>
<p>以下示例以 <code>project-a|project-b|project-c</code> 作为占位目录名（含义是处理这三个目录：<code>/project-a/**</code>、<code>/project-b/**</code>、<code>/project-c/**</code>），部署时请改为实际目录名。以及将 <code>http://halo-backend.internal:8080</code> 改为 Halo CMS 实例地址。</p>
<ul>
<li>原始路径：只匹配无扩展路径，兼容有无尾随斜杠。</li>
<li><code>.html</code>：适合 VitePress 等构建输出的<a href="https://vitepress.dev/zh/guide/routing#generating-clean-url" target="_blank" rel="noreferrer">干净链接</a>。</li>
<li><code>/index.html</code>：最后落到目录入口，适用于 SPA。</li>
</ul>
<div class="language-nginx"><button title="Copy Code" class="copy"></button><span class="lang">nginx</span><pre><!--::markdown-it-async::4yg7i5xftrty3qmb7dho6b::--><code># 1. 匹配无扩展路径（兼容有无尾随斜杠）
location ~ ^/(project-a|project-b|project-c)/(?:[^/]+/)*[^/.]+/?$ {
    # 2. 附加 .html
    rewrite ^/(project-a|project-b|project-c)/(.*?)/?$ /$1/$2.html break;

    proxy_pass http://halo-backend.internal:8080;

    proxy_intercept_errors on;
    error_page 404 = @try_index_html;
}

# 3. 尝试 index.html
location @try_index_html {
    rewrite ^/(.+)\.html$ /$1/index.html break;
    proxy_pass http://halo-backend.internal:8080;
}</code></pre>
</div><p>实例：</p>
<ul>
<li>访客访问以下其中一个地址：
<ul>
<li><code>.../project-a/posts/plugins</code></li>
<li><code>.../project-a/posts/plugins/</code></li>
</ul>
</li>
<li>内部首先尝试 <code>.../project-a/posts/plugins.html</code></li>
<li>如果失败就尝试 <code>.../project-a/posts/plugins/index.html</code></li>
</ul>
<h2 id="组合示例" tabindex="-1">组合示例 <a class="header-anchor" href="#组合示例" aria-label="Permalink to “组合示例”">&#8203;</a></h2>
<p>具体的 <code>proxy_set_header</code>、<code>proxy_cache</code>、<code>Alt-Svc</code> 等细节则可按各自环境补齐。</p>
<div class="language-nginx"><button title="Copy Code" class="copy"></button><span class="lang">nginx</span><pre><!--::markdown-it-async::qsxvyvs25uglfg07i7erkf::--><code># 1. 静态网页服务插件部署的文档 assets 目录优先、长缓存
location ~ ^/(project-a|project-b|project-c)/assets/.*\.(gif|png|jpe?g|svg|webp|avif|css|js|woff2?|ttf|eot|ico)$ {
    proxy_pass http://halo-backend.internal:8080;
    expires 365d; # 可配合 Cache-Control immutable
}

# 2. 无扩展路径：原始 -&gt; .html -&gt; /index.html
location ~ ^/(project-a|project-b|project-c)/(?:[^/]+/)*[^/.]+/?$ {
    rewrite ^/(project-a|project-b|project-c)/(.*?)/?$ /$1/$2.html break;

    proxy_pass http://halo-backend.internal:8080;

    proxy_intercept_errors on;
    error_page 404 = @try_index_html;
}

# 处理 404 后的 /index.html 重试
location @try_index_html {
    rewrite ^/(.+)\.html$ /$1/index.html break;

    proxy_pass http://halo-backend.internal:8080;
}

# 3. Halo CMS 本体静态资源处理（带版本号匹配）
location ~* \.(gif|png|jpe?g|css|js|woff2?|svg|webp|avif)(\?(v|version)=\d+\.\d+\.\d+)?$ {
    proxy_pass http://halo-backend.internal:8080;
    expires 365d;
}

# 4. Halo CMS 本体
location / {
    proxy_pass http://halo-backend.internal:8080;

    if ($uri ~* &quot;\.(gif|png|jpe?g|css|js|woff2?|svg|webp|avif)$&quot;) {
        expires 365d;
    }
}</code></pre>
</div>]]></content:encoded>
            <author>howie_hzgo@outlook.com (HowieHz)</author>
        </item>
        <item>
            <title><![CDATA[实现字数统计]]></title>
            <link>https://howiehz.top/mhcga/posts/themes/word-count</link>
            <guid isPermaLink="false">https://howiehz.top/mhcga/posts/themes/word-count</guid>
            <pubDate>Thu, 04 Dec 2025 08:09:39 GMT</pubDate>
            <description><![CDATA[实现字数统计
 单篇文章统计
::: tip 提示
`post.content?.content` 中的 `post` 变量类型为 `PostVo`，可通过 Finder API 获取。或通过模板渲染]]></description>
            <content:encoded><![CDATA[<h1 id="实现字数统计" tabindex="-1">实现字数统计 <a class="header-anchor" href="#实现字数统计" aria-label="Permalink to “实现字数统计”">&#8203;</a></h1>
<h2 id="单篇文章统计" tabindex="-1">单篇文章统计 <a class="header-anchor" href="#单篇文章统计" aria-label="Permalink to “单篇文章统计”">&#8203;</a></h2>
<div  class="tip custom-block"><p class="custom-block-title">提示</p>
<p><code>post.content?.content</code> 中的 <code>post</code> 变量类型为 <code>PostVo</code>，可通过 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post" target="_blank" rel="noreferrer">Finder API</a> 获取。或通过模板渲染时提供的变量获取，如<a href="https://docs.halo.run/developer-guide/theme/template-variables/post#post" target="_blank" rel="noreferrer">文章模板</a>。</p>
</div>
<h3 id="纯-thymeleaf-模板实现" tabindex="-1">纯 Thymeleaf 模板实现 <a class="header-anchor" href="#纯-thymeleaf-模板实现" aria-label="Permalink to “纯 Thymeleaf 模板实现”">&#8203;</a></h3>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::2yl8n9iqkzrcibjo5a2oq::--><code>&lt;span th:text=&quot;${#strings.length(post.content?.content)}&quot;&gt; 文章字数替换位 &lt;/span&gt;</code></pre>
</div><p>缺点：统计的是文章内容的总字符数，不够精确（可以修订为 <code>#strings.length(post.content?.content)/4</code> 作为估计值）。</p>
<h3 id="javascript-实现" tabindex="-1">JavaScript 实现 <a class="header-anchor" href="#javascript-实现" aria-label="Permalink to “JavaScript 实现”">&#8203;</a></h3>
<p>如果这个页面需要显示文章，你可以这么实现：</p>
<details  class="details custom-block"><summary>计数规则</summary>
<ul>
<li>自动移除 HTML 标签（包括 <code>&lt;script&gt;</code> 和 <code>&lt;style&gt;</code> 标签）</li>
<li>中文、日文、韩文等 CJK 字符按每个字符计 1。</li>
<li>ASCII 连续字母/数字按 1 个单词计数。</li>
<li>标点符号和空格不计入统计。</li>
</ul>
</details>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::7o2tq7x1bcxmhontmw85hp::--><code>&lt;span id=&quot;post-content&quot; class=&quot;post-content&quot; th:utext=&quot;${post.content?.content}&quot;&gt;文章内容替换处&lt;/span&gt;
&lt;span class=&quot;post-word-count&quot;&gt;文章字数替换位&lt;/span&gt;
&lt;span class=&quot;post-word-count&quot;&gt;另一个字数替换位&lt;/span&gt;

&lt;script&gt;
  document.addEventListener(&quot;DOMContentLoaded&quot;, function () {
    const contentEl = document.getElementById(&quot;post-content&quot;);
    if (!contentEl) return;

    const stripped = contentEl.innerHTML
      .replace(/&lt;script[\s\S]*?&lt;\/script&gt;|&lt;style[\s\S]*?&lt;\/style&gt;/gi, &quot;&quot;)
      .replace(/&lt;[^&gt;]+&gt;/g, &quot;&quot;);
    const cjk = (stripped.match(/[\u2E80-\u9FFF]/g) || []).length;
    const words = (stripped.replace(/[\u2E80-\u9FFF]/g, &quot; &quot;).match(/[A-Za-z0-9]+/g) || []).length;
    const count = cjk + words;

    document.querySelectorAll(&quot;.post-word-count&quot;).forEach(function (counterEl) {
      counterEl.textContent = count;
    });
  });
&lt;/script&gt;</code></pre>
</div><p>如果这个页面不需要显示文章内容，只需要取字数内容，你可以这么实现：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::661sofm8x97ijfhg1409ct::--><code>&lt;span class=&quot;word-counter&quot; data-content=&quot;${post.content?.content}&quot;&gt; 文章字数替换位 &lt;/span&gt;

&lt;script&gt;
  document.addEventListener(&quot;DOMContentLoaded&quot;, function () {
    document.querySelectorAll(&quot;.word-counter[data-content]&quot;).forEach(function (el) {
      const raw = el.dataset.content;
      if (!raw) return;
      const text = raw.replace(/&lt;script[\s\S]*?&lt;\/script&gt;|&lt;style[\s\S]*?&lt;\/style&gt;/gi, &quot;&quot;).replace(/&lt;[^&gt;]+&gt;/g, &quot;&quot;);
      const cjk = (text.match(/[\u2E80-\u9FFF]/g) || []).length;
      const words = (text.replace(/[\u2E80-\u9FFF]/g, &quot; &quot;).match(/[A-Za-z0-9]+/g) || []).length;
      el.textContent = cjk + words;
    });
  });
&lt;/script&gt;</code></pre>
</div><p>缺点：</p>
<ul>
<li>渲染会有延迟。</li>
<li>仅需要字数，但依然需要给用户传输完整文章内容，导致 HTML 体积膨胀。</li>
</ul>
<p>可改进点：</p>
<ul>
<li>使用 LocalStorage 缓存计算结果。</li>
</ul>
<h3 id="使用插件提供的-finder-api-实现" tabindex="-1">使用插件提供的 Finder API 实现 <a class="header-anchor" href="#使用插件提供的-finder-api-实现" aria-label="Permalink to “使用插件提供的 Finder API 实现”">&#8203;</a></h3>
<p>可使用 <a href="https://www.halo.run/store/apps/app-di1jh8gd" target="_blank" rel="noreferrer">API 拓展包</a>插件的 <a href="https://github.com/HowieHz/halo-plugin-extra-api/tree/v3.0.0?tab=readme-ov-file#%E6%96%87%E7%AB%A0%E5%AD%97%E6%95%B0%E7%BB%9F%E8%AE%A1-api" target="_blank" rel="noreferrer">extraApiStatsFinder.getPostWordCount</a> 实现此功能。</p>
<details  class="details custom-block"><summary>计数规则</summary>
<ul>
<li>自动移除 HTML 标签（包括 <code>&lt;script&gt;</code> 和 <code>&lt;style&gt;</code> 标签）。</li>
<li>中文、日文、韩文等 CJK 字符按每个字符计 1。</li>
<li>ASCII 连续字母/数字按 1 个单词计数。</li>
<li>标点符号和空格不计入统计。</li>
</ul>
</details>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::tgpm60mzupyv9wlmkw0p9::--><code>&lt;span
  th:if=&quot;${pluginFinder.available(&#039;extra-api&#039;, &#039;3.*&#039;)}&quot;
  th:text=&quot;${extraApiStatsFinder.getPostWordCount({name: post.metadata.name})}&quot;
&gt;
  文章字数替换位
&lt;/span&gt;</code></pre>
</div><h3 id="结合使用" tabindex="-1">结合使用 <a class="header-anchor" href="#结合使用" aria-label="Permalink to “结合使用”">&#8203;</a></h3>
<p>如果安装了 <a href="https://www.halo.run/store/apps/app-di1jh8gd" target="_blank" rel="noreferrer">API 拓展包</a>插件，就使用插件提供的 Finder API，否则回退 <code>#strings.length</code> 方法。</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::go8j1ob8y65dt8m0859yxi::--><code>&lt;span
  th:text=&quot;${pluginFinder.available(&#039;extra-api&#039;, &#039;3.*&#039;) ? 
        extraApiStatsFinder.getPostWordCount({name: post.metadata.name}) : 
        #strings.length(post.content?.content)}&quot;
&gt;
  文章字数替换位
&lt;/span&gt;</code></pre>
</div><h2 id="全站文章统计" tabindex="-1">全站文章统计 <a class="header-anchor" href="#全站文章统计" aria-label="Permalink to “全站文章统计”">&#8203;</a></h2>
<p>由于模板限制，此功能只能通过插件实现。</p>
<h3 id="插件-finder-api-实现" tabindex="-1">插件 Finder API 实现 <a class="header-anchor" href="#插件-finder-api-实现" aria-label="Permalink to “插件 Finder API 实现”">&#8203;</a></h3>
<p>可使用 <a href="https://www.halo.run/store/apps/app-di1jh8gd" target="_blank" rel="noreferrer">API 拓展包</a>插件的 <a href="https://github.com/HowieHz/halo-plugin-extra-api/tree/v3.0.0?tab=readme-ov-file#%E6%96%87%E7%AB%A0%E5%AD%97%E6%95%B0%E7%BB%9F%E8%AE%A1-api" target="_blank" rel="noreferrer">extraApiStatsFinder.getPostWordCount</a> 实现此功能。</p>
<p>统计全部文章已发布版本的总字数：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::rbsczy3ixk9ggfbbxz72re::--><code>&lt;span th:if=&quot;${pluginFinder.available(&#039;extra-api&#039;, &#039;3.*&#039;)}&quot; th:text=&quot;${extraApiStatsFinder.getPostWordCount()}&quot;&gt;
  文章字数替换位
&lt;/span&gt;</code></pre>
</div><h2 id="任意-html-字符统计" tabindex="-1">任意 HTML 字符统计 <a class="header-anchor" href="#任意-html-字符统计" aria-label="Permalink to “任意 HTML 字符统计”">&#8203;</a></h2>
<div  class="tip custom-block"><p class="custom-block-title">提示</p>
<p><code>moment.spec.content?.html</code> 中的 <code>moment</code> 变量类型为 <code>MomentVo</code>，为<a href="https://www.halo.run/store/apps/app-SnwWD" target="_blank" rel="noreferrer">瞬间</a>插件的数据。获取到的 <code>moment.spec.content?.html</code> 为 HTML 文本。</p>
</div>
<h3 id="纯模板实现" tabindex="-1">纯模板实现 <a class="header-anchor" href="#纯模板实现" aria-label="Permalink to “纯模板实现”">&#8203;</a></h3>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::ryf8ltqxg1n9v53b8yobjc::--><code>&lt;span th:text=&quot;${#strings.length(moment.spec.content?.html)}&quot;&gt; 字数替换位 &lt;/span&gt;</code></pre>
</div><p>缺点：统计的是内容的总字符数，包括 HTML 标签，不够精确（可以修订为 <code>#strings.length(moment.spec.content?.html)/4</code> 作为估计值）。</p>
<h3 id="finder-api-实现" tabindex="-1">Finder API 实现 <a class="header-anchor" href="#finder-api-实现" aria-label="Permalink to “Finder API 实现”">&#8203;</a></h3>
<p>可使用 <a href="https://www.halo.run/store/apps/app-di1jh8gd" target="_blank" rel="noreferrer">API 拓展包</a>插件实现的 <a href="https://github.com/HowieHz/halo-plugin-extra-api/tree/v3.0.0?tab=readme-ov-file#html-%E5%86%85%E5%AE%B9%E5%AD%97%E6%95%B0%E7%BB%9F%E8%AE%A1-api" target="_blank" rel="noreferrer">extraApiStatsFinder.getContentWordCount</a> API。</p>
<details  class="details custom-block"><summary>计数规则</summary>
<ul>
<li>自动移除 HTML 标签（包括 <code>&lt;script&gt;</code> 和 <code>&lt;style&gt;</code> 标签）。</li>
<li>中文、日文、韩文等 CJK 字符按每个字符计 1。</li>
<li>ASCII 连续字母/数字按 1 个单词计数。</li>
<li>标点符号和空格不计入统计。</li>
</ul>
</details>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::pknfkzqw5ugi0jpagjepd::--><code>&lt;span
  th:if=&quot;${pluginFinder.available(&#039;extra-api&#039;, &#039;3.*&#039;)}&quot;
  th:text=&quot;${extraApiStatsFinder.getContentWordCount(moment.spec.content?.html)}&quot;
&gt;
  字数替换位
&lt;/span&gt;</code></pre>
</div><h3 id="通过-javascript-实现" tabindex="-1">通过 JavaScript 实现 <a class="header-anchor" href="#通过-javascript-实现" aria-label="Permalink to “通过 JavaScript 实现”">&#8203;</a></h3>
<p>将内容放入 <code>data-html</code>，再用脚本在浏览器端清洗文本并计算字数。</p>
<details  class="details custom-block"><summary>计数规则</summary>
<ul>
<li>自动移除 HTML 标签（包括 <code>&lt;script&gt;</code> 和 <code>&lt;style&gt;</code> 标签）。</li>
<li>中文、日文、韩文等 CJK 字符按每个字符计 1。</li>
<li>ASCII 连续字母/数字按 1 个单词计数。</li>
<li>标点符号和空格不计入统计。</li>
</ul>
</details>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::qkkkr6hw0ojutzx3gprrsa::--><code>&lt;span class=&quot;word-counter&quot; data-html=&quot;${moment.spec.content?.html}&quot;&gt;字数替换位&lt;/span&gt;

&lt;script&gt;
  document.addEventListener(&quot;DOMContentLoaded&quot;, () =&gt; {
    document.querySelectorAll(&quot;.word-counter[data-html]&quot;).forEach((el) =&gt; {
      const raw = el.dataset.html;
      if (!raw) return;

      const text = raw.replace(/&lt;script[\s\S]*?&lt;\/script&gt;|&lt;style[\s\S]*?&lt;\/style&gt;/gi, &quot;&quot;).replace(/&lt;[^&gt;]+&gt;/g, &quot;&quot;);
      const cjk = (text.match(/[\u2E80-\u9FFF]/g) || []).length;
      const words = (text.replace(/[\u2E80-\u9FFF]/g, &quot; &quot;).match(/[A-Za-z0-9]+/g) || []).length;

      el.textContent = cjk + words;
    });
  });
&lt;/script&gt;</code></pre>
</div><p>缺点：</p>
<ul>
<li>渲染会有延迟。</li>
<li>仅需要字数，但依然需要给用户传输完整文章内容，导致 HTML 体积膨胀。</li>
</ul>
<p>可改进点：</p>
<ul>
<li>使用 LocalStorage 缓存计算结果。</li>
</ul>
<h3 id="结合使用两种方案" tabindex="-1">结合使用两种方案 <a class="header-anchor" href="#结合使用两种方案" aria-label="Permalink to “结合使用两种方案”">&#8203;</a></h3>
<p>如果安装了 <a href="https://www.halo.run/store/apps/app-di1jh8gd" target="_blank" rel="noreferrer">API 拓展包</a>插件，就使用插件提供的 Finder API，否则回退 <code>#strings.length</code> 方法。</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::3vt8gpr6zs8agslvo8ckxs::--><code>&lt;span
  th:text=&quot;${pluginFinder.available(&#039;extra-api&#039;, &#039;3.*&#039;) ? 
        extraApiStatsFinder.getContentWordCount(moment.spec.content?.html) : 
        #strings.length(moment.spec.content?.html)}&quot;
&gt;
  字数替换位
&lt;/span&gt;</code></pre>
</div>]]></content:encoded>
            <author>howie_hzgo@outlook.com (HowieHz)</author>
        </item>
        <item>
            <title><![CDATA[实现随机推荐多篇文章]]></title>
            <link>https://howiehz.top/mhcga/posts/themes/random-posts-recommend</link>
            <guid isPermaLink="false">https://howiehz.top/mhcga/posts/themes/random-posts-recommend</guid>
            <pubDate>Thu, 04 Dec 2025 06:14:44 GMT</pubDate>
            <description><![CDATA[实现随机推荐多篇文章
 前言
本文实现了以下示例：
1. 获取多篇随机文章。
   - 方案一：使用官方 Finder API 实现
   - 方案二：纯模板实现
2. 获取多篇随机文章，并按当前页面]]></description>
            <content:encoded><![CDATA[<h1 id="实现随机推荐多篇文章" tabindex="-1">实现随机推荐多篇文章 <a class="header-anchor" href="#实现随机推荐多篇文章" aria-label="Permalink to “实现随机推荐多篇文章”">&#8203;</a></h1>
<h2 id="前言" tabindex="-1">前言 <a class="header-anchor" href="#前言" aria-label="Permalink to “前言”">&#8203;</a></h2>
<p>本文实现了以下示例：</p>
<ol>
<li>获取多篇随机文章。
<ul>
<li><a href="#official-finder-api">方案一：使用官方 Finder API 实现</a></li>
<li><a href="#random-posts">方案二：纯模板实现</a></li>
</ul>
</li>
<li>获取多篇随机文章，并按当前页面文章第一个分类过滤结果。
<ul>
<li><a href="#random-posts-by-first-category">方案一：纯模板实现</a></li>
</ul>
</li>
</ol>
<h2 id="official-finder-api" tabindex="-1">使用官方 Finder API 实现 <a class="header-anchor" href="#official-finder-api" aria-label="Permalink to “使用官方 Finder API 实现”">&#8203;</a></h2>
<blockquote>
<p>自 Halo CMS <a href="https://github.com/halo-dev/halo/releases/tag/v2.24.1" target="_blank" rel="noreferrer">v2.24.1</a></p>
</blockquote>
<p>将以下代码插入模板，会创建一百个指向随机文章的超链接。</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::auhw6peuu5t2zfbs7ltuyk::--><code>&lt;th:block th:with=&quot;posts = ${postFinder.random(100)}&quot;&gt;
  &lt;a th:each=&quot;post : ${posts}&quot; th:href=&quot;${post.status.permalink}&quot;&gt;随机文章&lt;/a&gt;
&lt;/th:block&gt;</code></pre>
</div><p>注：官方提供的 <code>postFinder.random</code> 不具有过滤功能，因此仅能实现获取多篇随机文章。</p>
<h2 id="random-posts" tabindex="-1">纯模板实现：获取多篇随机文章 <a class="header-anchor" href="#random-posts" aria-label="Permalink to “纯模板实现：获取多篇随机文章”">&#8203;</a></h2>
<p>模板代码使用了两个配置项：</p>
<ul>
<li><code>theme.config?.post_styles?.is_post_recommended_articles_show</code> 控制是否启用推荐文章</li>
<li><code>theme.config?.post_styles?.post_recommended_articles_count</code> 控制推荐文章数。</li>
</ul>
<p>示例配置文件如下：</p>
<table><tbody><tr><td>settings.yaml</td><td><div class="language-yaml"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre><!--::markdown-it-async::8154x9cnc4jz3jz52r8qzj::--><code>spec:
  forms:
 - group: post_styles
   label: 文章页样式
   formSchema:
  - $formkit: checkbox
    name: is_post_recommended_articles_show
    label: 文章底部的推荐文章
    value: false
    help: 开启后将在文章底部显示推荐文章列表
  - $formkit: number
    name: post_recommended_articles_count
    if: &quot;$is_post_recommended_articles_show === true&quot;
    label: 推荐文章数量
    value: 3
    min: 1
    max: 10
    help: 设置文章底部显示的推荐文章数量</code></pre>
</div></td></tr></tbody></table>
<p>模板代码示例如下：<br>
（这段代码是设计放置在文章页模板，即 <code>/templates/post.html</code>。如果这段模板代码不是放置在文章页模板，可以将 <code>th:if=&quot;${#lists.size(firstPagePostList) &gt; 1}&quot;</code> 中的 <code>&gt; 1</code> 改为 <code>&gt; 0</code>，并且要去除 <code>&lt;div th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;&gt; .. &lt;/div&gt;</code> 的 <code>th:if</code> 属性。具体含义会在下文解释。）</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::5g8uu6f6z7nd21hz4w8iyp::--><code>&lt;th:block
  th:if=&quot;${theme.config?.post_styles?.is_post_recommended_articles_show}&quot;
  th:with=&quot;n=${#conversions.convert(theme.config?.post_styles?.post_recommended_articles_count, &#039;java.lang.Integer&#039;)},
              postFinderResult=${postFinder.list({
                size: n,
                sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
              })},
              firstPagePostList=${postFinderResult.items}&quot;
&gt;
  &lt;th:block
    th:if=&quot;${#lists.size(firstPagePostList) &gt; 1}&quot;
    th:with=&quot;randomPageNumber=${T(java.lang.Math).floor(T(java.lang.Math).random()*(postFinderResult.totalPages)+1)},
              targetPagePostFinderResult=${postFinder.list({
                page: randomPageNumber,
                size: n,
                sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
              })},
              targetPagePostList=${targetPagePostFinderResult.items}&quot;
  &gt;
    &lt;th:block th:each=&quot;iterPost: ${targetPagePostList}&quot;&gt;
      &lt;div th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;&gt;
        &lt;time th:text=&quot;${#temporals.format(iterPost.spec?.publishTime, &#039;yyyy-MM-dd&#039;)}&quot;&gt;文章发布时间替换位&lt;/time&gt;
        &lt;a th:href=&quot;@{${iterPost.status?.permalink}}&quot; th:text=&quot;${iterPost.spec?.title}&quot;
          &gt;文章超链接替换位（显示文字为标题/超链接为文章链接）&lt;/a
        &gt;
      &lt;/div&gt;
    &lt;/th:block&gt;

    &lt;th:block
      th:if=&quot;${targetPagePostFinderResult.last and not targetPagePostFinderResult.first}&quot;
      th:with=&quot;itemsNeeded=${n-#lists.size(targetPagePostList)}&quot;
    &gt;
      &lt;!--/* 缺项则补 */--&gt;
      &lt;th:block th:if=&quot;${itemsNeeded &gt; 0}&quot;&gt;
        &lt;th:block th:each=&quot;index : ${#numbers.sequence(0,itemsNeeded-1)}&quot;&gt;
          &lt;th:block th:with=&quot;iterPost=${firstPagePostList[index]}&quot;&gt;
            &lt;div th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;&gt;
              &lt;time th:text=&quot;${#temporals.format(iterPost.spec?.publishTime, &#039;yyyy-MM-dd&#039;)}&quot;&gt;文章发布时间替换位&lt;/time&gt;
              &lt;a th:href=&quot;@{${iterPost.status?.permalink}}&quot; th:text=&quot;${iterPost.spec?.title}&quot;
                &gt;文章超链接替换位（显示文字为标题/超链接为文章链接）&lt;/a
              &gt;
            &lt;/div&gt;
          &lt;/th:block&gt;
        &lt;/th:block&gt;
      &lt;/th:block&gt;
    &lt;/th:block&gt;
  &lt;/th:block&gt;
&lt;/th:block&gt;</code></pre>
</div><h3 id="代码示例讲解" tabindex="-1">代码示例讲解 <a class="header-anchor" href="#代码示例讲解" aria-label="Permalink to “代码示例讲解”">&#8203;</a></h3>
<h4 id="第一层" tabindex="-1">第一层 <a class="header-anchor" href="#第一层" aria-label="Permalink to “第一层”">&#8203;</a></h4>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::yawl04oupzkze7y8b63p48::--><code>&lt;th:block
  th:if=&quot;${theme.config?.post_styles?.is_post_recommended_articles_show}&quot;
  th:with=&quot;n=${#conversions.convert(theme.config?.post_styles?.post_recommended_articles_count, &#039;java.lang.Integer&#039;)},
              postFinderResult=${postFinder.list({
                size: n,
                sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
              })},
              firstPagePostList=${postFinderResult.items}&quot;
&gt;
  &lt;!-- ... --&gt;
&lt;/th:block&gt;</code></pre>
</div><ul>
<li><code>th:if=&quot;${theme.config?.post_styles?.is_post_recommended_articles_show}&quot;</code>：
<ul>
<li>读取 <code>theme.config?.post_styles?.is_post_recommended_articles_show</code> 控制是否启用推荐文章。</li>
</ul>
</li>
<li><code>th:with</code> 初始化了多个变量：
<ul>
<li><code>n</code> 读取 <code>theme.config?.post_styles?.post_recommended_articles_count</code> 用于控制推荐文章数。</li>
<li><code>postFinderResult</code> 使用 Halo CMS 提供的 Finder API 中的 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#list" target="_blank" rel="noreferrer">list({...})</a> 获取文章列表数据（查询参数设置了分页条数和排序字段。排序字段无要求；分页条数必须为 <code>n</code>，保证随机出的文章数接近要求数。参数含义详情请参考<a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#%E5%8F%82%E6%95%B0-4" target="_blank" rel="noreferrer">官方文档</a>），变量类型为 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#listresultlistedpostvo" target="_blank" rel="noreferrer">ListResult&lt;ListedPostVo&gt;</a>。</li>
<li><code>firstPagePostList</code> 保存 <code>postFinderResult</code> 的文章列表数据，变量类型为 List&lt;<a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#listedpostvo" target="_blank" rel="noreferrer">ListedPostVo</a>&gt;。</li>
</ul>
</li>
</ul>
<h4 id="第二层" tabindex="-1">第二层 <a class="header-anchor" href="#第二层" aria-label="Permalink to “第二层”">&#8203;</a></h4>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::pvsrrwjt5ljz0z6lbrhui::--><code>&lt;th:block
  th:if=&quot;${#lists.size(firstPagePostList) &gt; 1}&quot;
  th:with=&quot;randomPageNumber=${T(java.lang.Math).floor(T(java.lang.Math).random()*(postFinderResult.totalPages)+1)},
              targetPagePostFinderResult=${postFinder.list({
                page: randomPageNumber,
                size: n,
                sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
              })},
              targetPagePostList=${targetPagePostFinderResult.items}&quot;
&gt;
  &lt;!-- ... --&gt;
&lt;/th:block&gt;</code></pre>
</div><p>使用 <code>#lists.size</code> 检查 <code>firstPagePostList</code> 变量保存的文章数据是否大于 1，如果大于 1 则进入下一层，否则不进行文章推荐。（这段代码原本是设计放置在文章页模板，即 <code>/templates/post.html</code>。如果等于 1，说明当前站点仅有一篇文章，无需进行重复推荐。如果这段模板代码不是放置在文章页模板，可以将 <code>&gt; 1</code> 改为 <code>&gt; 0</code>。）</p>
<ul>
<li><code>th:with</code> 初始化了多个变量：
<ul>
<li><code>randomPageNumber</code>：根据一开始查询结果的总页码数，来随机生成一个数，范围是 <code>[1, 总页码]</code>。正好对应有效页码。</li>
<li><code>targetPagePostFinderResult</code>：使用 Halo CMS 提供的 Finder API 中的 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#list" target="_blank" rel="noreferrer">list({...})</a> 获取文章列表数据（查询参数设置了目标页码、分页条数和排序字段。目标页码为随机出的页码 <code>randomPageNumber</code>；排序字段无要求；分页条数必须为 <code>n</code>，保证随机出的文章数接近要求数。参数含义详情请参考<a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#%E5%8F%82%E6%95%B0-4" target="_blank" rel="noreferrer">官方文档</a>），变量类型为 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#listresultlistedpostvo" target="_blank" rel="noreferrer">ListResult&lt;ListedPostVo&gt;</a>。</li>
<li><code>targetPagePostList</code>：保存 <code>targetPagePostFinderResult</code> 的文章列表数据，变量类型为 List&lt;<a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#listedpostvo" target="_blank" rel="noreferrer">ListedPostVo</a>&gt;。</li>
</ul>
</li>
</ul>
<h4 id="第三层" tabindex="-1">第三层 <a class="header-anchor" href="#第三层" aria-label="Permalink to “第三层”">&#8203;</a></h4>
<h5 id="第三层第一部分" tabindex="-1">第三层第一部分 <a class="header-anchor" href="#第三层第一部分" aria-label="Permalink to “第三层第一部分”">&#8203;</a></h5>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::yr8zjd9q27ou40azyixcp::--><code>&lt;th:block th:each=&quot;iterPost: ${targetPagePostList}&quot;&gt;
  &lt;div th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;&gt;
    &lt;time th:text=&quot;${#temporals.format(iterPost.spec?.publishTime, &#039;yyyy-MM-dd&#039;)}&quot;&gt;文章发布时间替换位&lt;/time&gt;
    &lt;a th:href=&quot;@{${iterPost.status?.permalink}}&quot; th:text=&quot;${iterPost.spec?.title}&quot;
      &gt;文章超链接替换位（显示文字为标题/超链接为文章链接）&lt;/a
    &gt;
  &lt;/div&gt;
&lt;/th:block&gt;</code></pre>
</div><p>使用 <code>th:each</code> 遍历 <code>targetPagePostList</code>。
使用 <code>th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;</code> 避免推荐列表中出现当前文章（这段代码原本是设计放置在文章页模板，即 <code>/templates/post.html</code>。如果这段模板代码不是放置在文章页模板，请去除这个 <code>th:if</code> 属性）。
最内层使用一个 <code>&lt;time&gt;</code> 标签和一个 <code>&lt;a&gt;</code> 标签展示文章信息。</p>
<h5 id="第三层第二部分" tabindex="-1">第三层第二部分 <a class="header-anchor" href="#第三层第二部分" aria-label="Permalink to “第三层第二部分”">&#8203;</a></h5>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::pd1ptgjix9q132416z7z::--><code>&lt;th:block
  th:if=&quot;${targetPagePostFinderResult.last and not targetPagePostFinderResult.first}&quot;
  th:with=&quot;itemsNeeded=${n-#lists.size(targetPagePostList)}&quot;
&gt;
  &lt;!-- ... --&gt;
&lt;/th:block&gt;</code></pre>
</div><p>使用 <code>th:if</code> 检查 <code>targetPagePostFinderResult</code> 属性：如果是最后一页，而且不是第一页，就进行补偿检查。</p>
<ul>
<li>为何需要进行补偿检查：如果总文章数不能被 <code>n</code> 整除导致最后一页查询结果会小于 <code>n</code>。</li>
<li>为何是 <code>targetPagePostFinderResult.last and not targetPagePostFinderResult.first</code> 为才进行补偿检查：
<ul>
<li>如果查询结果不是最后一页，不进入补偿检查。
<ul>
<li>不会出现不能整除导致缺少的情况。</li>
<li>最多因为查询结果中有当前文章，然后被 <code>th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;</code> 过滤，导致最后展示数为 <code>n-1</code>。</li>
<li>由于内层变量无法传递到外层，所以解决 <code>n-1</code> 会使得代码比较复杂：判断如果 <code>post.metadata.name == iterPost.metadata.name</code> 成立，就多补偿一篇。补偿的时候也要进行检查，防止多补偿的一篇文章依然为当前文章。</li>
<li>如果这段模板代码不是放置在文章页模板，去除了 <code>th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;</code> 则不会出现展示数为 <code>n-1</code> 的问题。</li>
</ul>
</li>
<li>如果查询结果是最后一页，也是第一页，不进入补偿检查。
<ul>
<li>说明查询结果只有一页，总文章数小于 <code>n</code>，无需进行补偿</li>
</ul>
</li>
<li>如果是最后一页，而且不是第一页，就进行补偿检查。
<code>th:with</code> 初始化一个变量：</li>
<li><code>itemsNeeded</code>：保存需要补偿的文章数，通过计算 <code>n</code> 减去实际查询结果。</li>
</ul>
</li>
</ul>
<h6 id="第三层第二部分内层-补偿显示部分" tabindex="-1">第三层第二部分内层 - 补偿显示部分 <a class="header-anchor" href="#第三层第二部分内层-补偿显示部分" aria-label="Permalink to “第三层第二部分内层 - 补偿显示部分”">&#8203;</a></h6>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::dhxecxal76bjc1wbs2ufp9::--><code>&lt;th:block th:if=&quot;${itemsNeeded &gt; 0}&quot;&gt;
  &lt;th:block th:each=&quot;index : ${#numbers.sequence(0,itemsNeeded-1)}&quot;&gt;
    &lt;th:block th:with=&quot;iterPost=${firstPagePostList[index]}&quot;&gt;
      &lt;div th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;&gt;
        &lt;time th:text=&quot;${#temporals.format(iterPost.spec?.publishTime, &#039;yyyy-MM-dd&#039;)}&quot;&gt;文章发布时间替换位&lt;/time&gt;
        &lt;a th:href=&quot;@{${iterPost.status?.permalink}}&quot; th:text=&quot;${iterPost.spec?.title}&quot;
          &gt;文章超链接替换位（显示文字为标题/超链接为文章链接）&lt;/a
        &gt;
      &lt;/div&gt;
    &lt;/th:block&gt;
  &lt;/th:block&gt;
&lt;/th:block&gt;</code></pre>
</div><p>如果 <code>itemsNeeded</code> 大于 0，才进行之后的补偿。<br>
使用 <code>#numbers.sequence</code> 创建索引序列，遍历从 0 到 <code>itemsNeeded-1</code>。<br>
复用 <code>firstPagePostList</code> 节约查询次数（这就是为什么笔者将两次查询填写了相同的 <code>sort</code> 参数）。展示 <code>firstPagePostList</code> 中索引数从 0 到 <code>itemsNeeded-1</code> 的文章数据。<br>
使用 <code>th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;</code> 避免推荐列表中出现当前文章（这段代码原本是设计放置在文章页模板，即 <code>/templates/post.html</code>。如果这段模板代码不是放置在文章页模板，请去除这个 <code>th:if</code> 属性）。<br>
最内层展示方法同第三层第一部分，使用一个 <code>&lt;time&gt;</code> 标签和一个 <code>&lt;a&gt;</code> 标签展示文章信息。</p>
<h2 id="random-posts-by-first-category" tabindex="-1">纯模板实现：按当前文章第一个分类过滤 <a class="header-anchor" href="#random-posts-by-first-category" aria-label="Permalink to “纯模板实现：按当前文章第一个分类过滤”">&#8203;</a></h2>
<p>此处对<a href="#random-posts">上述代码</a>进行了增强，仅选取当前文章第一个分类的文章。
需放置于模板 <code>/templates/post.html</code>。
后文详细讲解仅讲解新增代码。</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::vrbqobgratqot7v7n55snl::--><code>&lt;!--/* 根据文章的第一个类别，找相同类别的文章 */--&gt;
&lt;!--/* 文章无分类则不进行推荐 */--&gt;
&lt;th:block
  th:if=&quot;${theme.config?.post_styles?.is_post_recommended_articles_show
          and not #lists.isEmpty(post.categories)}&quot;
  th:with=&quot;firstCategoryName=${post.categories[0].metadata.name},
            n=${#conversions.convert(theme.config?.post_styles?.post_recommended_articles_count, &#039;java.lang.Integer&#039;)},
            postFinderResult=${postFinder.list({
              size: n,
              categoryName: firstCategoryName,
              sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
            })},
            firstPagePostList=${postFinderResult.items}&quot;
&gt;
  &lt;th:block
    th:if=&quot;${#lists.size(firstPagePostList) &gt; 1}&quot;
    th:with=&quot;randomPageNumber=${T(java.lang.Math).floor(T(java.lang.Math).random()*(postFinderResult.totalPages)+1)},
              targetPagePostFinderResult=${postFinder.list({
                page: randomPageNumber,
                size: n,
                categoryName: firstCategoryName,
                sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
              })},
              targetPagePostList=${targetPagePostFinderResult.items}&quot;
  &gt;
    &lt;th:block th:each=&quot;iterPost: ${targetPagePostList}&quot;&gt;
      &lt;div th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;&gt;
        &lt;time th:text=&quot;${#temporals.format(iterPost.spec?.publishTime, &#039;yyyy-MM-dd&#039;)}&quot;&gt;文章发布时间替换位&lt;/time&gt;
        &lt;a th:href=&quot;@{${iterPost.status?.permalink}}&quot; th:text=&quot;${iterPost.spec?.title}&quot;
          &gt;文章超链接替换位（显示文字为标题/超链接为文章链接）&lt;/a
        &gt;
      &lt;/div&gt;
    &lt;/th:block&gt;

    &lt;th:block
      th:if=&quot;${targetPagePostFinderResult.last and not targetPagePostFinderResult.first}&quot;
      th:with=&quot;itemsNeeded=${n-#lists.size(targetPagePostList)}&quot;
    &gt;
      &lt;!--/* 缺项则补 */--&gt;
      &lt;th:block th:if=&quot;${itemsNeeded &gt; 0}&quot;&gt;
        &lt;th:block th:each=&quot;index : ${#numbers.sequence(0,itemsNeeded-1)}&quot;&gt;
          &lt;th:block th:with=&quot;iterPost=${firstPagePostList[index]}&quot;&gt;
            &lt;div th:if=&quot;${post.metadata.name != iterPost.metadata.name}&quot;&gt;
              &lt;time th:text=&quot;${#temporals.format(iterPost.spec?.publishTime, &#039;yyyy-MM-dd&#039;)}&quot;&gt;文章发布时间替换位&lt;/time&gt;
              &lt;a th:href=&quot;@{${iterPost.status?.permalink}}&quot; th:text=&quot;${iterPost.spec?.title}&quot;
                &gt;文章超链接替换位（显示文字为标题/超链接为文章链接）&lt;/a
              &gt;
            &lt;/div&gt;
          &lt;/th:block&gt;
        &lt;/th:block&gt;
      &lt;/th:block&gt;
    &lt;/th:block&gt;
  &lt;/th:block&gt;
&lt;/th:block&gt;</code></pre>
</div><h3 id="代码示例讲解-1" tabindex="-1">代码示例讲解 <a class="header-anchor" href="#代码示例讲解-1" aria-label="Permalink to “代码示例讲解”">&#8203;</a></h3>
<h4 id="第一层-1" tabindex="-1">第一层 <a class="header-anchor" href="#第一层-1" aria-label="Permalink to “第一层”">&#8203;</a></h4>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::6ha2ow9776ildv6c8p7pwd::--><code>&lt;th:block
  th:if=&quot;${theme.config?.post_styles?.is_post_recommended_articles_show
          and not #lists.isEmpty(post.categories)}&quot;
  th:with=&quot;firstCategoryName=${post.categories[0].metadata.name},
            n=${#conversions.convert(theme.config?.post_styles?.post_recommended_articles_count, &#039;java.lang.Integer&#039;)},
            postFinderResult=${postFinder.list({
              size: n,
              categoryName: firstCategoryName,
              sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
            })},
            firstPagePostList=${postFinderResult.items}&quot;
&gt;
  &lt;!-- ... --&gt;
&lt;/th:block&gt;</code></pre>
</div><p><code>th:if</code> 中添加了一个判断项：<code>not #lists.isEmpty(post.categories)</code></p>
<ul>
<li>解释：现在是按当前文章第一个分类过滤结果，因此文章无分类则不进行推荐。</li>
<li><code>th:with</code> 初始化了一个变量：
<ul>
<li><code>firstCategoryName</code>：保存当前文章第一个分类的唯一标识。</li>
</ul>
</li>
</ul>
<h4 id="第二层-1" tabindex="-1">第二层 <a class="header-anchor" href="#第二层-1" aria-label="Permalink to “第二层”">&#8203;</a></h4>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::gdpsr9p61s5u24ltll9j3l::--><code>&lt;th:block
  th:if=&quot;${#lists.size(firstPagePostList) &gt; 1}&quot;
  th:with=&quot;randomPageNumber=${T(java.lang.Math).floor(T(java.lang.Math).random()*(postFinderResult.totalPages)+1)},
              targetPagePostFinderResult=${postFinder.list({
                page: randomPageNumber,
                size: n,
                categoryName: firstCategoryName,
                sort: {&#039;spec.publishTime,desc&#039;, &#039;metadata.creationTimestamp,asc&#039;}
              })},
              targetPagePostList=${targetPagePostFinderResult.items}&quot;
&gt;
  &lt;!-- ... --&gt;
&lt;/th:block&gt;</code></pre>
</div><ul>
<li><code>th:with</code> 初始化了一个变量：
<ul>
<li><code>targetPagePostFinderResult</code>：在原有的基础上新设置了分类标识，为当前文章第一个分类的唯一标识。参数含义详情请参考<a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#%E5%8F%82%E6%95%B0-4" target="_blank" rel="noreferrer">官方文档</a>。</li>
</ul>
</li>
</ul>
<h4 id="第三层-1" tabindex="-1">第三层 <a class="header-anchor" href="#第三层-1" aria-label="Permalink to “第三层”">&#8203;</a></h4>
<p>此层无变化。</p>
<h2 id="拓展" tabindex="-1">拓展 <a class="header-anchor" href="#拓展" aria-label="Permalink to “拓展”">&#8203;</a></h2>
<p>更好的解决方案可能是实现一个 Halo CMS 插件，提供 Finder API 来显示随机文章，仅需要在原有的 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post/#list" target="_blank" rel="noreferrer">list({...})</a> 上进行拓展。</p>
]]></content:encoded>
            <author>howie_hzgo@outlook.com (HowieHz)</author>
        </item>
        <item>
            <title><![CDATA[实现随机文章跳转功能]]></title>
            <link>https://howiehz.top/mhcga/posts/themes/random-post-redirect</link>
            <guid isPermaLink="false">https://howiehz.top/mhcga/posts/themes/random-post-redirect</guid>
            <pubDate>Thu, 04 Dec 2025 05:29:55 GMT</pubDate>
            <description><![CDATA[实现随机文章跳转功能
 纯 Thymeleaf 模板实现
将以下代码插入模板，会创建一个指向随机文章的超链接。
```html
&lt;th:block th:with="allPostList=${]]></description>
            <content:encoded><![CDATA[<h1 id="实现随机文章跳转功能" tabindex="-1">实现随机文章跳转功能 <a class="header-anchor" href="#实现随机文章跳转功能" aria-label="Permalink to “实现随机文章跳转功能”">&#8203;</a></h1>
<h2 id="纯-thymeleaf-模板实现" tabindex="-1">纯 Thymeleaf 模板实现 <a class="header-anchor" href="#纯-thymeleaf-模板实现" aria-label="Permalink to “纯 Thymeleaf 模板实现”">&#8203;</a></h2>
<p>将以下代码插入模板，会创建一个指向随机文章的超链接。</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::ab7sc77ymr4rx1kcrxi7j::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;a
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))},
      postPermalink=${allPostList[randomIndex].status.permalink}&quot;
    th:href=&quot;${postPermalink}&quot;
    &gt;随机文章&lt;/a
  &gt;
&lt;/th:block&gt;</code></pre>
</div><h3 id="原理讲解" tabindex="-1">原理讲解 <a class="header-anchor" href="#原理讲解" aria-label="Permalink to “原理讲解”">&#8203;</a></h3>
<div  class="tip custom-block"><p class="custom-block-title">提示</p>
<p>当没有文章时（<code>allPostList</code> 为空），<code>randomIndex</code> 会是 0，访问 <code>allPostList[0]</code> 会导致错误。因此需要先使用 <code>th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;</code> 在 <code>allPostList</code> 为空时移除元素，避免产生错误。</p>
</div>
<ul>
<li><code>allPostList = ${postFinder.listAll()}</code>：
<ul>
<li>使用 Finder API <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post#listall" target="_blank" rel="noreferrer">postFinder.listAll()</a> 获取所有文章。赋值给 <code>allPostList</code>。</li>
</ul>
</li>
<li><code>randomIndex = ${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))}</code>：
<ul>
<li>生成范围在 <code>[0, #lists.size(allPostList)-1]</code> 的整数，正好对应数组的有效索引。即这行的意思是，生成一个随机索引，赋值给 <code>randomIndex</code>。</li>
</ul>
</li>
<li><code>postPermalink = ${allPostList[randomIndex].status.permalink}</code>：
<ul>
<li>从 <code>allPostList</code> 数组中取出对应索引的文章数据，类型为 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post#listedpostvo" target="_blank" rel="noreferrer">ListedPostVo</a>。然后使用 <code>.status.permalink</code> 取出其超链接，赋值给 <code>postPermalink</code>。</li>
</ul>
</li>
<li><code>th:href=&quot;${postPermalink}&quot;</code>：
<ul>
<li>将这个标签的 <code>href</code> 属性设置为 <code>postPermalink</code>，即刚才取出的超链接。</li>
</ul>
</li>
</ul>
<h3 id="结合-javascript-使用" tabindex="-1">结合 JavaScript 使用 <a class="header-anchor" href="#结合-javascript-使用" aria-label="Permalink to “结合 JavaScript 使用”">&#8203;</a></h3>
<p>插入以下代码到模板中，之后调用 <code>toRandomPost()</code> 即可跳转到随机文章。</p>
<table><tbody><tr><td>window.location.href</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::qb649qrlleg1vqt550nlth::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;script
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    th:inline=&quot;javascript&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))},
      postPermalink=${allPostList[randomIndex].status.permalink}&quot;
  &gt;
    function toRandomPost() {
      // 将地址保存到变量中
      let permalink = /*[[${postPermalink}]]*/ &quot;/&quot;;

      // 跳转到目标链接
      window.location.href = permalink;

      // 省略 permalink 变量，直接作为参数传入的写法：
      // window.location.href = /*[[${postPermalink}]]*/ &quot;/&quot;;
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr><tr><td>window.open</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::auaopcuwa5opq5xslc55u::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;script
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    th:inline=&quot;javascript&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))},
      postPermalink=${allPostList[randomIndex].status.permalink}&quot;
  &gt;
    function toRandomPost() {
      // 将地址保存到变量中
      let permalink = /*[[${postPermalink}]]*/ &quot;/&quot;;

      // 跳转到目标链接
      window.open(permalink);

      // 省略 permalink 变量，直接作为参数传入的写法：
      // window.open(/*[[${postPermalink}]]*/ &quot;/&quot;);
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr><tr><td>pjax.loadUrl</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::nldpmwhoydlv0epdh4rmi::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;script
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    th:inline=&quot;javascript&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))},
      postPermalink=${allPostList[randomIndex].status.permalink}&quot;
  &gt;
    function toRandomPost() {
      // 将地址保存到变量中
      let permalink = /*[[${postPermalink}]]*/ &quot;/&quot;;

      // 跳转到目标链接
      pjax.loadUrl(permalink);

      // 省略 permalink 变量，直接作为参数传入的写法：
      // pjax.loadUrl(/*[[${postPermalink}]]*/ &quot;/&quot;);
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr><tr><td>window.location.href（无 th:inline）</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::b4455ee1n8bmrb1p22cgyj::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;script
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    id=&quot;random-post-script&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))}&quot;
    th:data-permalink=&quot;${allPostList[randomIndex].status.permalink}&quot;
  &gt;
    function toRandomPost() {
      // 跳转到目标链接
      window.location.href = document.getElementById(&quot;random-post-script&quot;).dataset.permalink;
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr><tr><td>window.open（无 th:inline）</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::6cv1orbnlxh9shs09p14p9::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;script
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    id=&quot;random-post-script&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))}&quot;
    th:data-permalink=&quot;${allPostList[randomIndex].status.permalink}&quot;
  &gt;
    function toRandomPost() {
      // 跳转到目标链接
      window.open(document.getElementById(&quot;random-post-script&quot;).dataset.permalink);
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr><tr><td>pjax.loadUrl（无 th:inline）</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::aeuydbygegfbc3dlhcifqn::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;script
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    id=&quot;random-post-script&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))}&quot;
    th:data-permalink=&quot;${allPostList[randomIndex].status.permalink}&quot;
  &gt;
    function toRandomPost() {
      // 跳转到目标链接
      pjax.loadUrl(document.getElementById(&quot;random-post-script&quot;).dataset.permalink);
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr></tbody></table>
<h3 id="拓展" tabindex="-1">拓展 <a class="header-anchor" href="#拓展" aria-label="Permalink to “拓展”">&#8203;</a></h3>
<p>在上面模板代码中 <code>allPostList[randomIndex]</code> 返回的是 <a href="https://docs.halo.run/developer-guide/theme/finder-apis/post#listedpostvo" target="_blank" rel="noreferrer">ListedPostVo</a> 类型的变量。<br>
因此你可以拿到文章更多相关信息，如：标题，创建时间，发布时间，是否置顶，摘要内容等。</p>
<p>将超链接的文字替换为目标文章标题：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::5ha6md3bqz7fz8fubzxhvh::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;a
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))},
      postPermalink=${allPostList[randomIndex].status.permalink},
      postTitle=${allPostList[randomIndex].spec.title}&quot;
    th:href=&quot;${postPermalink}&quot;
    th:text=&quot;${postTitle}&quot;
  &gt;&lt;/a&gt;
&lt;/th:block&gt;</code></pre>
</div><p>弹出一个带有消息和确认按钮的警告框，显示目标文章标题：</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::4v7v7p7wqix8lshbe5u21::--><code>&lt;th:block th:with=&quot;allPostList=${postFinder.listAll()}&quot;&gt;
  &lt;script
    th:if=&quot;${not #lists.isEmpty(allPostList)}&quot;
    th:inline=&quot;javascript&quot;
    th:with=&quot;randomIndex=${T(java.lang.Math).floor(T(java.lang.Math).random()*#lists.size(allPostList))},
      postPermalink=${allPostList[randomIndex].status.permalink},
      postTitle=${allPostList[randomIndex].spec.title}&quot;
  &gt;
    function toRandomPost() {
      alert(/*[[${postTitle}]]*/ &quot;&quot;);

      window.location.href = /*[[${postPermalink}]]*/ &quot;/&quot;;
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div><h2 id="使用官方-finder-api-实现" tabindex="-1">使用官方 Finder API 实现 <a class="header-anchor" href="#使用官方-finder-api-实现" aria-label="Permalink to “使用官方 Finder API 实现”">&#8203;</a></h2>
<blockquote>
<p>自 Halo CMS <a href="https://github.com/halo-dev/halo/releases/tag/v2.24.1" target="_blank" rel="noreferrer">v2.24.1</a></p>
</blockquote>
<p>将以下代码插入模板，会创建一个指向随机文章的超链接。</p>
<div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::oigp9f3fns4cu5yqri2xn::--><code>&lt;th:block th:with=&quot;posts = ${postFinder.random(1)}&quot;&gt;
  &lt;a th:each=&quot;post : ${posts}&quot; th:href=&quot;${post.status.permalink}&quot;&gt;随机文章&lt;/a&gt;
&lt;/th:block&gt;</code></pre>
</div><h3 id="结合-javascript-使用-1" tabindex="-1">结合 JavaScript 使用 <a class="header-anchor" href="#结合-javascript-使用-1" aria-label="Permalink to “结合 JavaScript 使用”">&#8203;</a></h3>
<p>插入以下代码到模板中，之后调用 <code>toRandomPost()</code> 即可跳转到随机文章。</p>
<table><tbody><tr><td>window.location.href（无 th:inline）</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::appp7fczaoopq09qzmmkiq::--><code>&lt;th:block th:with=&quot;posts = ${postFinder.random(1)}&quot;&gt;
  &lt;script th:each=&quot;post : ${posts}&quot; id=&quot;random-post-script&quot; th:data-permalink=&quot;${post.status.permalink}&quot;&gt;
    function toRandomPost() {
      // 跳转到目标链接
      window.location.href = document.getElementById(&quot;random-post-script&quot;).dataset.permalink;
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr><tr><td>window.open（无 th:inline）</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::nr35kivkfgl654mo6qlm2q::--><code>&lt;th:block th:with=&quot;posts = ${postFinder.random(1)}&quot;&gt;
  &lt;script th:each=&quot;post : ${posts}&quot; id=&quot;random-post-script&quot; th:data-permalink=&quot;${post.status.permalink}&quot;&gt;
    function toRandomPost() {
      // 跳转到目标链接
      window.open(document.getElementById(&quot;random-post-script&quot;).dataset.permalink);
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr><tr><td>pjax.loadUrl（无 th:inline）</td><td><div class="language-html"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre><!--::markdown-it-async::sm2ty9bjnwlmx9sryqgr6c::--><code>&lt;th:block th:with=&quot;posts = ${postFinder.random(1)}&quot;&gt;
  &lt;script th:each=&quot;post : ${posts}&quot; id=&quot;random-post-script&quot; th:data-permalink=&quot;${post.status.permalink}&quot;&gt;
    function toRandomPost() {
      // 跳转到目标链接
      pjax.loadUrl(document.getElementById(&quot;random-post-script&quot;).dataset.permalink);
    }
  &lt;/script&gt;
&lt;/th:block&gt;</code></pre>
</div></td></tr></tbody></table>
]]></content:encoded>
            <author>howie_hzgo@outlook.com (HowieHz)</author>
        </item>
    </channel>
</rss>