chundev
日期:2026-04-21 技術棧:Vite 6 + React 18 + react-markdown + vite-plugin-singlefile
想把一份多檔 Markdown 文件包成單一 HTML(雙擊開檔、可 AirDrop、沒有伺服器也能看)時,vite-plugin-singlefile + ?raw import 是最乾淨的解。
但如果 markdown 內容本身寫了 [foo.md](foo.md) 這類相對連結,點下去會直接 404——因為瀏覽器真的以為要去讀外部檔案。
用 react-markdown 的 components.a 把 md 路徑攔截、轉成 hash 內部導航即可。
需求是產出一份「雙擊 index.html 就能看全部內容」的離線文件檢視器:
.md 檔這其實是很常見的離線技術手冊、內部知識包、演練腳本等情境。
?raw import + viteSingleFilevite.config.tsimport { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
base: "./",
build: {
assetsInlineLimit: 100_000_000, // 提高門檻,確保所有 asset 都能 inline
cssCodeSplit: false, // 關閉 CSS 分割,全部併成一份
chunkSizeWarningLimit: 100_000_000,
},
});
content.ts)import chapter1 from "./content/01-intro.md?raw";
import chapter2 from "./content/02-setup.md?raw";
export const SECTIONS = [
{ id: "intro", label: "簡介", body: chapter1 },
{ id: "setup", label: "安裝設定", body: chapter2 },
];
?raw 的作用:Vite 官方 import suffix,把檔案內容當作字串在 build 階段直接 inline 進 JS bundle。這跟執行期 fetch("./01-intro.md") 本質不同——?raw 是編譯期行為,build 完字串已經嵌在 JS 裡了。
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
<ReactMarkdown remarkPlugins={[remarkGfm]}>{section.body}</ReactMarkdown>
pnpm build 之後 dist/ 只剩 一份 index.html(我的專案約 1MB),JS / CSS / 所有 markdown 字串全部 inline。雙擊即開,沒有任何網路請求。
開起來看似完美,但點擊內文裡的 [下一章](02-setup.md) 時,瀏覽器跳轉到 file:///.../02-setup.md——404 / 檔案不存在。
看起來就像「HTML 沒有真的把內容吃進去」,但實際上字串全都在 bundle 裡,只是 markdown 原始碼寫的相對連結被 react-markdown 原封不動產生成 <a href="02-setup.md">,瀏覽器當成真的檔案連結去處理。
react-markdown 預設把 [text](url) 直接轉成 <a href={url}>text</a>,不做 URL resolutionfile:// 協議下,相對 URL 會解析成磁碟路徑關鍵洞察:「HTML 自包含」只保證渲染需要的資源都在檔案裡,但內容本身的超連結指向什麼還是由 markdown 原始碼決定。
<a> 把 md 路徑轉成 hash 導航react-markdown 的 components prop 允許覆寫任何 HTML 元素的渲染。因為 vite-plugin-singlefile 的限制頁明確寫了 hash-based routing 可以用於 file:// 協議(Web History API 反而不行),最乾淨的解就是:
location.hash 驅動章節切換(不用 React Router)<a>,把 .md 連結轉成 #<section-id>http(s) 連結開新分頁、其他連結保留原行為const MD_TO_SECTION: Record<string, string> = {
"01-intro.md": "intro",
"02-setup.md": "setup",
// ...
};
function resolveMdHref(href: string): string | null {
if (!href) return null;
if (/^https?:\/\//i.test(href)) return null;
const withoutHash = href.split("#")[0] ?? "";
const filename = withoutHash.split("/").pop() ?? "";
const id = MD_TO_SECTION[filename];
return id ? `#${id}` : null;
}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...rest }) => {
const mapped = resolveMdHref(href ?? "");
if (mapped) {
return (
<a
{...rest}
href={mapped}
onClick={(e) => {
e.preventDefault();
navigateToSection(mapped.slice(1));
}}
>
{children}
</a>
);
}
const external = href && /^https?:\/\//i.test(href);
return (
<a
{...rest}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer noopener" : undefined}
>
{children}
</a>
);
},
}}
>
{section.body}
</ReactMarkdown>
useEffect(() => {
const onHash = () => {
const id = window.location.hash.slice(1);
if (id && SECTIONS.some((s) => s.id === id)) setActiveId(id);
};
window.addEventListener("hashchange", onHash);
onHash();
return () => window.removeEventListener("hashchange", onHash);
}, []);
這樣點連結會更新 location.hash,hash 變化驅動 state 切換章節——全程都在 file:// 協議下運作。
| 嘗試 | 做法 | 結果 | 原因 |
|---|---|---|---|
| ❌ 1 | 懷疑 ?raw 沒真的 inline |
wc -c dist/index.html 看到 1MB |
其實有 inline,字串都在 JS bundle |
| ❌ 2 | 懷疑 viteSingleFile 沒生效 | 看 build log 有 Inlining: index-xxx.js |
有生效 |
| ❌ 3 | 想把 md 連結從原始檔裡刪掉 | 不想改內容 | 不優雅,且會失去章節跳轉能力 |
| ✅ 4 | 用 components.a 攔截,轉 hash 導航 |
點擊正常跳章節 | react-markdown 的官方 extension point |
最有用的 debug 指令:
# 驗證內容真的 inline 在 HTML
grep -c "某個只會出現在 md 檔裡的獨特字串" dist/index.html
# 如果輸出 > 0,就代表字串真的嵌進去了,問題不在 inline
| 方案 | 優點 | 缺點 |
|---|---|---|
| react-markdown + components.a(本文作法) | 官方 API、保留 React 組件化、無需自己 sanitize HTML | 需要建立 md filename → section id 對照表 |
在 markdown 原始檔把 [foo](foo.md) 改成 [foo](#foo) |
不寫 JS 攔截邏輯 | 汙染原始內容、來源 md 若也用於其他 renderer 會失去語意 |
| 寫一個 remark 插件在解析階段改 URL | 最「正確」的層級、不需要維護對照表 | 學習曲線陡、只為了一個小改動 overkill |
決策理由:保留 markdown 原始碼的可攜性(同一份 md 也可以給別的 renderer 用),把呈現層的路由決策留在應用程式裡,這是職責分離最乾淨的切法。
?raw 是編譯期 import,不是執行期 fetch,build 完字串已經嵌在 JS bundle 裡vite-plugin-singlefile 不會 inline 的東西:public/ 目錄、SVG 檔、sourcemap;需要的話要用 inlinePattern 明確指定file:// 協議下哪些能用:hash routing ✅、cookies ❌、Web History API ❌、Service Worker ❌——設計離線 app 時先查清楚這個清單react-markdown 的 components prop 是覆寫渲染的正規路徑,能拿到 node(hast element)+ 標準 HTML props,比用 regex 改 markdown 原始碼乾淨太多inlinePattern / removeViteModuleLoader 等配置與 file:// 協議限制清單寫得很清楚?raw / ?url / ?inline 的官方說明