對爬蟲來說,我的網站每頁只有 46 個字元:SPA 預渲染的三個階段,不換框架
這個站是 Vite + React 的 SPA。文章內容存在 TypeScript 資料檔裡,路由用 react-router,一切都在瀏覽器端渲染。開發體驗很好,直到我做了一件早該做的事:
用 curl 抓自己的文章頁,去掉 script 和 style,數剩下的可見文字。
| 頁面 | 可見文字 |
|---|---|
| 首頁 | 27 字元 |
| 文章索引 | 16 字元 |
| 一篇 3,000 字的文章 | 46 字元 |
46 個字元,就是 <title> 標籤的內容。正文、標題、日期,全部不存在——它們都活在 JavaScript 執行之後。
Google 會執行 JS,但對零權重的新網域,渲染配額低、排隊久;Bing、社群預覽、LLM 爬蟲則大多根本不跑 JS。也就是說:我每寫一篇文章,對搜尋引擎來說都接近不存在。
為什麼不直接上 Next.js
這是標準答案,我認真考慮過,然後放棄了。理由是比例原則:
這個站有 6 個遊戲、2 個 Three.js 互動實驗、一個 WebSocket 連線的多人遊戲——全部是純 client 端的東西,SSR 對它們毫無意義,遷移卻要把每一頁的 window 存取、動態 import、副作用全部理一遍。為了讓爬蟲看到文章正文,把整個站翻掉一次,風險和工作量完全不成比例。
我要的其實很窄:爬蟲來的時候,回應裡有正文。那就只解這個問題。
階段一:meta 注入(本來就有)
起點是一個 build 後腳本:Vite build 完之後,讀 dist/index.html 當模板,為每條路由產生一份 HTML——替換 title、description、canonical、og 標籤,寫到對應路徑。
這讓每一頁的分享預覽和搜尋標題是對的,是最基本的一層。但 body 還是空的 <div id="root"></div>,所以才會只有 46 字元。
階段二:把正文塞進 #root,然後讓 React 把它丟掉
第二階段是整件事裡投報率最高的一步,核心是一個 React 行為:
createRoot(container).render(app) 掛載時會清空 container 裡的既有內容。
一般情況下這是個註腳,但反過來用它就變成一個特性:你可以在 HTML 的 #root 裡放任何東西給爬蟲看,React 一啟動就整個換掉。爬蟲版和 App 版井水不犯河水——沒有 hydration,就沒有 hydration mismatch。
// build 後腳本裡:把文章正文渲染成 HTML,塞進 #root
if (bodyHtml) {
html = html.replace('<div id="root"></div>', `<div id="root">${bodyHtml}</div>`);
}
// 瀏覽器端:createRoot 掛載時清空容器,爬蟲版 HTML 從不參與 App 運作正文從哪來?文章資料本來就是 TypeScript 物件(段落、標題、程式碼、表格的 block 陣列)。build 腳本用 esbuild 把那個資料模組打包成可以 import 的 ESM,然後一個一百多行的函式把 block 轉成語意 HTML——h1、h2、p、pre、table,class 沿用文章頁元件的樣式,讓 React 接手前後畫面長得一樣。
重點是資料只有一份。App 和爬蟲版讀同一個 TS 模組,不存在「忘了同步」這種事。
| 頁面 | 改造前 | 改造後 |
|---|---|---|
| 34 個文章頁(17 中 + 17 英) | 每頁 46–70 字元 | 1,204–9,980 字元 |
| 中位數 | — | 約 3,800 字元 |
一個晚上的工作量,34 頁從空白變成全文可讀。
階段三:核心頁面上真的 SSR——但只有需要的頁
文章頁解決了,但首頁、文章索引、遊戲列表這些「組合出來的頁面」還是空的——它們的內容不是文章資料,是 React 元件樹本身。
這才輪到 renderToString 出場。Vite 對這件事有一等公民支援:再 build 一個 SSR bundle,入口只做一件事——給一條路由,回傳 HTML 字串:
// entry-server.tsx
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router';
import { AppRoutes } from './App';
export const render = async (url: string) => {
await i18n.changeLanguage(getLocaleFromPath(url));
return renderToString(
<StaticRouter location={url}>
<AppRoutes />
</StaticRouter>,
);
};build 後腳本 import 這個 bundle,對核心路由各呼叫一次,把結果塞進 #root——和階段二同一個洞,但這次內容是真的 React 輸出,所以瀏覽器端可以 hydrate 而不是丟掉重畫。
兩種模式怎麼共存?塞 React 輸出的頁面多帶一個標記,入口依標記決定用哪條路:
// main.tsx:同一個 bundle,兩種掛載模式
if (container.dataset.reactPrerendered === 'true') {
hydrateRoot(container, app) // 真 SSR 頁:接手既有 DOM
} else {
createRoot(container).render(app) // 其他頁:清掉爬蟲版重畫
}於是三種頁面各得其所:文章頁用便宜的爬蟲版正文(反正 React 會重畫)、核心頁用真 SSR + hydration、遊戲和 3D 實驗維持純 client——它們本來就不需要被搜尋。
踩到的三個坑
一、任何在模組載入時就碰瀏覽器 API 的程式碼,SSR build 會直接爆。我的 Firebase 初始化在模組頂層呼叫 getAnalytics(),Node 環境沒有 window,renderToString 還沒開始就死了。解法是老老實實加 guard:
const analytics: Analytics | null =
typeof window === "undefined" ? null : getAnalytics(app);二、爬蟲版 HTML 裡的內部連結要自己處理語系前綴。App 裡的 <Link> 元件會自動把 /article/x 變成 /en/article/x,但爬蟲版是字串拼接,忘了這件事,英文頁的內部連結就全指到中文版——爬蟲會照著走,hreflang 結構就亂了。
三、轉義不能偷懶。文章內容有程式碼區塊,裡面有 <、>、引號。塞進 HTML 前每一個字串都要過 escape,漏一處就是版面破裂或 XSS 面。這種手寫 renderer 的紀律就是:預設全部轉義,沒有例外。
如果你的 SPA 也有這個問題
先量再動手。curl 你自己的頁面,去掉 script,數可見文字——這個數字會直接告訴你有沒有問題、多嚴重。
然後按需求選階段,不要一步跳到底:
一、只有分享預覽不對 → 階段一,一個 meta 注入腳本就夠。
二、內容型頁面(文章、文件)需要被收錄,資料又本來就是結構化的 → 階段二。createRoot 會清空容器這件事,讓爬蟲版正文完全零風險,這是整條路裡最甜的一步。
三、組合型頁面(首頁、列表)也要 → 階段三,Vite SSR build + hydrateRoot,一個 data attribute 讓兩種模式共存。
每一階段都是獨立可交付的增量,做完就上線,量一次成效再決定要不要走下一步。整條路走完,這個站還是那個 Vite SPA——只是爬蟲來的時候,它終於有話可說。
成果可以直接驗證:對這篇文章按右鍵檢視原始碼