<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Fuse.js on Tarragon</title><link>https://tarrragon.github.io/blog/tags/fuse.js/</link><description>Recent content in Fuse.js on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 24 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/fuse.js/index.xml" rel="self" type="application/rss+xml"/><item><title>Fuse.js / MiniSearch：客戶端載入索引的搜尋方案</title><link>https://tarrragon.github.io/blog/posts/fuse.js-/-minisearch%E5%AE%A2%E6%88%B6%E7%AB%AF%E8%BC%89%E5%85%A5%E7%B4%A2%E5%BC%95%E7%9A%84%E6%90%9C%E5%B0%8B%E6%96%B9%E6%A1%88/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/fuse.js-/-minisearch%E5%AE%A2%E6%88%B6%E7%AB%AF%E8%BC%89%E5%85%A5%E7%B4%A2%E5%BC%95%E7%9A%84%E6%90%9C%E5%B0%8B%E6%96%B9%E6%A1%88/</guid><description>&lt;h2 id="客戶端搜尋的問題空間">客戶端搜尋的問題空間&lt;/h2>
&lt;p>靜態站搜尋必須在 build 時或 client runtime 完成。選擇&lt;strong>整包序列化 + client 載入&lt;/strong>這條路時，核心設計軸是：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計軸&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>索引內容&lt;/td>
 &lt;td>由作者在 build time 明確決定要搜哪些欄位、哪些 section&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>索引結構&lt;/td>
 &lt;td>扁平 JSON 陣列，每筆一個頁面，欄位直寫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>runtime 處理&lt;/td>
 &lt;td>在瀏覽器內建索引、記憶體內匹配&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Fuse.js 與 MiniSearch 是這條路上的兩個主要實作。差異在匹配策略（fuzzy vs 全文），共享的是「一包索引載入瀏覽器、之後所有查詢不再出站」這個骨幹。&lt;/p>
&lt;hr>
&lt;h2 id="核心設計build-時序列化--runtime-in-memory">核心設計：build 時序列化 + runtime in-memory&lt;/h2>
&lt;p>&lt;strong>商業邏輯&lt;/strong>：把搜尋放在 client runtime 的關鍵是&lt;strong>搜尋不再跨網路來回&lt;/strong>。第一次載入索引之後，每次打字的匹配都在使用者的 RAM 內完成，不受網路延遲影響、不受後端服務狀態影響、甚至不需要網路連線。&lt;/p>
&lt;p>此設計把「索引存放」從伺服端或 CDN 移到了訪客自己的瀏覽器，換得 runtime 的完全獨立。&lt;/p>
&lt;p>&lt;strong>CASE&lt;/strong>：整個流程兩個時點：&lt;/p>
&lt;p>&lt;strong>Build time（Hugo 階段）&lt;/strong>：Hugo 用 custom output format 產出一份 JSON，每筆一個頁面。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;WAF&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/backend/knowledge-cards/waf/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;說明 WAF 如何在入口層過濾攻擊&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;完整內文…&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Runtime（瀏覽器階段）&lt;/strong>：使用者打開搜尋頁，browser &lt;code>fetch&lt;/code> JSON → library 在 memory 中建索引 → 使用者打字 → library 匹配 → 結果渲染。&lt;/p>
&lt;p>第一次 fetch + build index 通常 100-500ms；之後的每次查詢在 memory 內匹配，一般 &amp;lt;10ms。&lt;/p>
&lt;hr>
&lt;h2 id="架構選擇作者定義索引內容">架構選擇：作者定義索引內容&lt;/h2>
&lt;p>&lt;strong>商業邏輯&lt;/strong>：索引的範圍與欄位由誰決定，這件事決定了搜尋結果的邊界。Fuse.js / MiniSearch 採「作者顯式宣告」的路線 — Hugo template 明確列出哪些 section 進索引、每筆要哪些欄位。&lt;/p>
&lt;p>這個選擇讓搜尋結果成為&lt;strong>作者設計決策的產物&lt;/strong>：想排除 work-log 類別就不列入 range；想讓 tag 也可搜就加一個 &lt;code>tags&lt;/code> 欄位到 JSON；想降低索引大小就只存 &lt;code>title + description&lt;/code> 而不存 &lt;code>content&lt;/code>。&lt;/p>
&lt;p>&lt;strong>CASE&lt;/strong>：&lt;code>layouts/index.json&lt;/code> 決定 JSON 內容：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go-html-template" data-lang="go-html-template">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$pages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">:=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">where&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Site.RegularPages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;Section&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;in&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="k">slice&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;posts&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;backend&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;go&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;python&amp;#34;&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">-}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">[
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">range&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$i&lt;/span>&lt;span class="o">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$p&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">:=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$pages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">-}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,&lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">end&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> { &amp;#34;title&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &amp;#34;url&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.RelPermalink&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &amp;#34;description&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Description&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &amp;#34;content&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Plain&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">end&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">-}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>配套在 &lt;code>hugo.toml&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="nx">outputs&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">home&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;HTML&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;RSS&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;JSON&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="nx">outputFormats&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">mediaType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;application/json&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">baseName&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;index&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">isPlainText&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Build 後 &lt;code>public/index.json&lt;/code> 就是整站可搜內容的權威來源。&lt;/p>
&lt;hr>
&lt;h2 id="整合步驟以-fusejs-為例">整合步驟（以 Fuse.js 為例）&lt;/h2>
&lt;h3 id="1-hugo-產生-indexjson">1. Hugo 產生 index.json&lt;/h3>
&lt;p>&lt;strong>核心動作&lt;/strong>：設定 custom output format，寫 template 輸出 JSON。&lt;/p></description><content:encoded><![CDATA[<h2 id="客戶端搜尋的問題空間">客戶端搜尋的問題空間</h2>
<p>靜態站搜尋必須在 build 時或 client runtime 完成。選擇<strong>整包序列化 + client 載入</strong>這條路時，核心設計軸是：</p>
<table>
  <thead>
      <tr>
          <th>設計軸</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>索引內容</td>
          <td>由作者在 build time 明確決定要搜哪些欄位、哪些 section</td>
      </tr>
      <tr>
          <td>索引結構</td>
          <td>扁平 JSON 陣列，每筆一個頁面，欄位直寫</td>
      </tr>
      <tr>
          <td>runtime 處理</td>
          <td>在瀏覽器內建索引、記憶體內匹配</td>
      </tr>
  </tbody>
</table>
<p>Fuse.js 與 MiniSearch 是這條路上的兩個主要實作。差異在匹配策略（fuzzy vs 全文），共享的是「一包索引載入瀏覽器、之後所有查詢不再出站」這個骨幹。</p>
<hr>
<h2 id="核心設計build-時序列化--runtime-in-memory">核心設計：build 時序列化 + runtime in-memory</h2>
<p><strong>商業邏輯</strong>：把搜尋放在 client runtime 的關鍵是<strong>搜尋不再跨網路來回</strong>。第一次載入索引之後，每次打字的匹配都在使用者的 RAM 內完成，不受網路延遲影響、不受後端服務狀態影響、甚至不需要網路連線。</p>
<p>此設計把「索引存放」從伺服端或 CDN 移到了訪客自己的瀏覽器，換得 runtime 的完全獨立。</p>
<p><strong>CASE</strong>：整個流程兩個時點：</p>
<p><strong>Build time（Hugo 階段）</strong>：Hugo 用 custom output format 產出一份 JSON，每筆一個頁面。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span> <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="s2">&#34;WAF&#34;</span><span class="p">,</span> <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;/backend/knowledge-cards/waf/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;description&#34;</span><span class="p">:</span> <span class="s2">&#34;說明 WAF 如何在入口層過濾攻擊&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;完整內文…&#34;</span> <span class="p">}</span></span></span></code></pre></div><p><strong>Runtime（瀏覽器階段）</strong>：使用者打開搜尋頁，browser <code>fetch</code> JSON → library 在 memory 中建索引 → 使用者打字 → library 匹配 → 結果渲染。</p>
<p>第一次 fetch + build index 通常 100-500ms；之後的每次查詢在 memory 內匹配，一般 &lt;10ms。</p>
<hr>
<h2 id="架構選擇作者定義索引內容">架構選擇：作者定義索引內容</h2>
<p><strong>商業邏輯</strong>：索引的範圍與欄位由誰決定，這件事決定了搜尋結果的邊界。Fuse.js / MiniSearch 採「作者顯式宣告」的路線 — Hugo template 明確列出哪些 section 進索引、每筆要哪些欄位。</p>
<p>這個選擇讓搜尋結果成為<strong>作者設計決策的產物</strong>：想排除 work-log 類別就不列入 range；想讓 tag 也可搜就加一個 <code>tags</code> 欄位到 JSON；想降低索引大小就只存 <code>title + description</code> 而不存 <code>content</code>。</p>
<p><strong>CASE</strong>：<code>layouts/index.json</code> 決定 JSON 內容：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="nx">$pages</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">where</span><span class="w"> </span><span class="na">.Site.RegularPages</span><span class="w"> </span><span class="s">&#34;Section&#34;</span><span class="w"> </span><span class="s">&#34;in&#34;</span><span class="w"> </span><span class="o">(</span><span class="k">slice</span><span class="w"> </span><span class="s">&#34;posts&#34;</span><span class="w"> </span><span class="s">&#34;backend&#34;</span><span class="w"> </span><span class="s">&#34;go&#34;</span><span class="w"> </span><span class="s">&#34;python&#34;</span><span class="o">)</span><span class="w"> </span><span class="cp">-}}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">[
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">$i</span><span class="o">,</span><span class="w"> </span><span class="nx">$p</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">$pages</span><span class="w"> </span><span class="cp">-}}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="cp">{{-</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nx">$i</span><span class="w"> </span><span class="cp">}}</span>,<span class="cp">{{</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  { &#34;title&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.Title</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>,
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    &#34;url&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.RelPermalink</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>,
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    &#34;description&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.Description</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>,
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    &#34;content&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.Plain</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span> }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">-}}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">]</span></span></code></pre></div><p>配套在 <code>hugo.toml</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="nx">outputs</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">home</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;HTML&#34;</span><span class="p">,</span> <span class="s2">&#34;RSS&#34;</span><span class="p">,</span> <span class="s2">&#34;JSON&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">[</span><span class="nx">outputFormats</span><span class="p">.</span><span class="nx">JSON</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">mediaType</span> <span class="p">=</span> <span class="s2">&#34;application/json&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">baseName</span> <span class="p">=</span> <span class="s2">&#34;index&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nx">isPlainText</span> <span class="p">=</span> <span class="kc">true</span></span></span></code></pre></div><p>Build 後 <code>public/index.json</code> 就是整站可搜內容的權威來源。</p>
<hr>
<h2 id="整合步驟以-fusejs-為例">整合步驟（以 Fuse.js 為例）</h2>
<h3 id="1-hugo-產生-indexjson">1. Hugo 產生 index.json</h3>
<p><strong>核心動作</strong>：設定 custom output format，寫 template 輸出 JSON。</p>
<p>見上方「架構選擇」段落的 <code>hugo.toml</code> 與 <code>layouts/index.json</code>。</p>
<h3 id="2-搜尋頁載入-library--index">2. 搜尋頁載入 library + index</h3>
<p><strong>核心動作</strong>：前端一個 <code>&lt;input&gt;</code>、一段 script，完成 fetch + 建索引 + 匹配 + 渲染。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln"> 1</span><span class="cl">{{ define &#34;main&#34; }}
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">&lt;</span><span class="nt">input</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;q&#34;</span> <span class="na">placeholder</span><span class="o">=</span><span class="s">&#34;搜尋…&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">&lt;</span><span class="nt">ul</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;results&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;https://cdn.jsdelivr.net/npm/fuse.js@7/dist/fuse.min.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nx">fetch</span><span class="p">(</span><span class="s1">&#39;{{ &#34;index.json&#34; | relURL }}&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">json</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">data</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="kr">const</span> <span class="nx">fuse</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Fuse</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">keys</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;title&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">,</span> <span class="s1">&#39;content&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">threshold</span><span class="o">:</span> <span class="mf">0.3</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">includeMatches</span><span class="o">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;q&#39;</span><span class="p">).</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="nx">e</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">fuse</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">value</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">20</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;results&#39;</span><span class="p">).</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="nx">results</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">          <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="sb">`&lt;li&gt;&lt;a href=&#34;</span><span class="si">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">item</span><span class="p">.</span><span class="nx">url</span><span class="si">}</span><span class="sb">&#34;&gt;</span><span class="si">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">item</span><span class="p">.</span><span class="nx">title</span><span class="si">}</span><span class="sb">&lt;/a&gt;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="sb">                     &lt;p&gt;</span><span class="si">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">item</span><span class="p">.</span><span class="nx">description</span><span class="si">}</span><span class="sb">&lt;/p&gt;&lt;/li&gt;`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">          <span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">{{ end }}</span></span></code></pre></div><p>30 行內可以跑起來。</p>
<h3 id="3-minisearch-的-api-差異">3. MiniSearch 的 API 差異</h3>
<p><strong>核心動作</strong>：選 MiniSearch 時，API 形狀相近、配置項不同。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">const</span> <span class="nx">mini</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MiniSearch</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">fields</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;title&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">,</span> <span class="s1">&#39;content&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">storeFields</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;title&#39;</span><span class="p">,</span> <span class="s1">&#39;url&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">searchOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">boost</span><span class="o">:</span> <span class="p">{</span> <span class="nx">title</span><span class="o">:</span> <span class="mi">3</span><span class="p">,</span> <span class="nx">description</span><span class="o">:</span> <span class="mi">2</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">prefix</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">fuzzy</span><span class="o">:</span> <span class="mf">0.2</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">mini</span><span class="p">.</span><span class="nx">addAll</span><span class="p">(</span><span class="nx">data</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">mini</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span></span></span></code></pre></div><ul>
<li><code>boost</code> 決定各欄位命中的權重：title 命中比 content 命中重 3 倍</li>
<li><code>prefix: true</code> 讓 &ldquo;WA&rdquo; 命中 &ldquo;WAF&rdquo;</li>
<li><code>fuzzy: 0.2</code> 開啟 approximate match，容錯程度可調</li>
</ul>
<hr>
<h2 id="方案的內在屬性">方案的內在屬性</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Fuse.js / MiniSearch 的特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>覆蓋完整性</td>
          <td>由作者顯式宣告索引範圍 — 要搜什麼完全可控</td>
      </tr>
      <tr>
          <td>可逆性</td>
          <td>移除只需刪除 <code>index.json</code> output、搜尋頁、script reference</td>
      </tr>
      <tr>
          <td>維護成本</td>
          <td>無額外 build step；索引 schema 改動要同步改 template 與 client code</td>
      </tr>
      <tr>
          <td>可理解性</td>
          <td>library 原始碼規模可讀（Fuse.js ~10KB、MiniSearch ~6KB gzipped），API 面積小</td>
      </tr>
      <tr>
          <td>依賴前提</td>
          <td>要求 Hugo 支援 custom output format（所有版本皆支援）；要求 client 能跑 JS</td>
      </tr>
      <tr>
          <td>擴展性</td>
          <td>單次查詢發生在 memory 內 — 查詢效能不受網路或站規模影響；索引載入是首次一次性</td>
      </tr>
  </tbody>
</table>
<p><strong>與 runtime 獨立相關的延伸特徵</strong>：</p>
<ul>
<li><strong>離線可用</strong>：索引載入後所有查詢不需要網路；PWA 加 Cache API 讓索引也能離線快取</li>
<li><strong>自託管</strong>：索引資料不離開你的網域；敏感內容或私有文件特別適合</li>
<li><strong>隱私</strong>：訪客查詢字串不會送到任何第三方服務</li>
</ul>
<p><strong>與 UI 獨立相關的延伸特徵</strong>：</p>
<ul>
<li><strong>樣式與互動 100% 可控</strong>：搜尋框位置、結果卡排版、modal 與否、鍵盤操作 — 每一項都由作者決定</li>
<li><strong>與 theme 緊密整合</strong>：UI 可以直接套用站上其他元件的 CSS variable 與設計 token</li>
</ul>
<hr>
<h2 id="兩家-library-的定位差異">兩家 library 的定位差異</h2>
<p>Fuse.js 與 MiniSearch 共享核心架構，<strong>設計重心不同</strong>：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Fuse.js</th>
          <th>MiniSearch</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>匹配策略</td>
          <td>以 fuzzy / approximate match 為主軸</td>
          <td>傳統全文檢索（詞項匹配 + 評分）</td>
      </tr>
      <tr>
          <td>擅長情境</td>
          <td>錯字容錯、近似詞匹配 — 搜 &ldquo;kubernates&rdquo; 命中 &ldquo;kubernetes&rdquo;</td>
          <td>精確詞匹配、field boosting、prefix 搜尋</td>
      </tr>
      <tr>
          <td>Gzipped 大小</td>
          <td>~10KB</td>
          <td>~6KB</td>
      </tr>
  </tbody>
</table>
<p>兩者的 API 形狀相近，切換成本低。決定用哪一個，主要看<strong>希望怎麼對待 query</strong>：可能有錯字的模糊輸入偏向 Fuse.js，結構化的技術關鍵字偏向 MiniSearch。</p>
<hr>
<h2 id="運作特徵">運作特徵</h2>
<h3 id="index-在首次載入">Index 在首次載入</h3>
<p><strong>核心定義</strong>：索引是一份 JSON，使用者打開搜尋頁時由瀏覽器一次性 fetch。</p>
<p><strong>含義</strong>：首次延遲 = 下載 JSON + library 建索引。常見做法是在 <code>DOMContentLoaded</code> 就 preload JSON，讓使用者看到搜尋框時索引已建好、第一次打字即可查詢。</p>
<p><strong>規模適合度</strong>：幾百到一兩千頁、索引 JSON 幾百 KB 到 1-2MB 的站，體驗最穩定。索引大小由作者在 Hugo template 內決定 — 只索引 title + description 可以把 size 壓到很小。</p>
<h3 id="索引範圍由作者決定">索引範圍由作者決定</h3>
<p><strong>核心定義</strong>：Hugo template 明確列出要進索引的 section 與欄位。</p>
<p><strong>含義</strong>：搜尋結果的邊界是作者設計決策。增減 section、增減 field、調整儲存策略，都在 template 這一層直接生效。</p>
<h3 id="tokenization-依-library-而異">Tokenization 依 library 而異</h3>
<p><strong>核心定義</strong>：Fuse.js 採 character-level 匹配；MiniSearch 預設用空白分詞。</p>
<p><strong>含義</strong>：</p>
<ul>
<li>Fuse.js 對中文天然能搜，不需要斷詞設定</li>
<li>MiniSearch 對中文需要傳自訂 <code>tokenize</code> function，可以一個字一 token，或接 Intl.Segmenter 做詞界切分</li>
</ul>
<h3 id="ui-由作者自己寫">UI 由作者自己寫</h3>
<p><strong>核心定義</strong>：library 只提供搜尋 API，不提供視覺組件。</p>
<p><strong>含義</strong>：排版、鍵盤操作、focus management、ARIA 這些 UI 層責任由作者顯式實作。收穫是與 theme 完全融合的客製體驗。</p>
<hr>
<h2 id="適合的場景">適合的場景</h2>
<ul>
<li>站的規模穩定在幾百到一兩千頁</li>
<li>UI 需要深度客製、與 theme 風格緊密整合</li>
<li>想要最單純的 build pipeline（無 post-build step、無額外工具）</li>
<li>內容敏感、希望索引不離開自家網域</li>
<li>希望搜尋在離線狀態仍可用</li>
<li>需要 fuzzy match（Fuse.js）或精細 field boost + prefix（MiniSearch）</li>
</ul>
]]></content:encoded></item></channel></rss>