MDX engine 換血記:從 runtime evaluate 換回 build-time 編譯

我原本為了想像中的未來需求提早選了 runtime evaluate 渲染 MDX,加圖片時才發現 bundler 看不到圖片檔案,繞一圈規劃補丁未果,最後換回 build-time 編譯的 Next.js 內建 MDX,拿回圖片最佳化。

  • MDX
  • Next.js

這兩天我把 blog 的 MDX 渲染引擎整個換掉了——不是因為它壞了,而是因為我當初選型時,賭錯了一個還沒發生的需求。這篇記錄我怎麼發現自己踩了坑、繞了多大一圈規劃補丁,最後決定回頭把地基整個換掉。

示意圖:施工中的建築物在鷹架下抽換地基,比喻把 blog 的 MDX 渲染從 runtime evaluate 換成 build-time 編譯

選型選太早:賭一個還沒發生的需求

最初決定用 next-mdx-remote-client 這一套的時候,其實我沒有想得太清楚。當時的考量是:未來搞不好會把文章內容放進資料庫,如果先選一套支援 runtime evaluate() 的方案,到時候要換內容來源就不必重做。

聽起來合理,但問題是——這個專案實際上仍然是走 SSG(static site generation),文章的 render 前置作業全部發生在 build time,不是 request time。用 evaluate() 這種為 runtime 動態內容設計的機制去處理 SSG,結果就是很多 SSG 本來內建就有的東西,現在都得自己另外寫 script 補回來。

這裡有一個我一開始也誤解的點:我以為「提早選一個更彈性的方案」是穩健的工程決策,實際上是為了一個還沒發生的需求(MDX 進資料庫),提早放棄了已經在用的架構(SSG)該有的紅利。

想加圖片,才發現卡在字串評估

基本的 MDX 轉靜態頁上線之後,我想加圖片功能——設想很單純,用 Next.js 內建的 <Image> 搭配 build-time 最佳化,尺寸、格式都讓 bundler 去算,不用自己手動標。根據 Next.js 官方文件,圖片只要用靜態 import 帶進來,framework 就會自動推算 width/height

問題來了。next-mdx-remote-client 是把 MDX 的 body 當一段字串,在呼叫端執行 evaluate() 求值——這是 runtime 求值機制,沒有 import graph 可言,官方文件 也寫明這是用 Reflect.construct 之類的機制做 runtime evaluation。換句話說,import img from "./hero.png" 這種寫法在這套機制下根本不存在,bundler 在 build 期看不到這個圖片檔案,自然也沒辦法用 <Image> 幫你算尺寸、轉格式。至少在這個版本的 next-mdx-remote-client 下,能用的路徑只剩遠端 URL 或 Markdown 的 ![]()——都繞不開手動管理。

追根究底,這不是這套 library 的缺陷,而是它預設的使用情境本來就是「從遠端拉 MDX 內容進來」——不支援 build-time import image,是合理的限制。真正的問題出在我自己:我想要在 build time 使用一套本質上是為 runtime 設計的工具,等於是搬磚塊砸自己的腳。

在舊地基上疊加:sidecar manifest 計畫

因為已經選了 next-mdx-remote-client,我一開始的直覺不是換引擎,而是想在這個技術選型上繼續把需求疊上去。我想要的是「co-located」——白話說,就是把圖片檔案直接放在跟這篇 MDX 相同的資料夾裡,用相對路徑引用,而不是把所有圖片丟進一個共用的圖片目錄、再自己兜路徑對應。於是我請 Claude Code 幫忙規劃一份因應方案,想辦法讓這種放法的圖片資料能在 build 階段被放到可以被 link 的位置。

第一版方案(方案 A)大致是這樣運作:

  1. 產生 manifest:build 期跑一支獨立的 generate-image-manifest.mjs,用 sharp 加 plaiceholder 算出每張圖的 width、height、blurDataURL,並加上好幾層路徑安全檢查防止路徑穿越。
  2. 鏡像同步資產:另一支 copy-blog-image-assets.mjs 把圖片從 co-located 目錄鏡像複製到 public/blog-assets/
  3. runtime 查表還原:render 端再用一個依 slug 查表的機制,把圖片資訊還原回 MDX component。

這份計畫理論上不算重造輪子——現成的 bundler-based 圖片方案在 runtime-string 架構下本來就用不上,所以才需要自己補一套。但走完安全審查與維運審查之後,問題浮出來了:安全審查抓到路徑穿越風險,得靠好幾層防護才擋得住。

所謂路徑穿越風險,白話說就是如果作者在 <Image src="..."> 裡寫進 ../ 這種往上跳的相對路徑,build 腳本就可能被騙去讀到、複製到 content 資料夾以外的檔案。最後拍板的做法是疊了四層防護才敢放行:只允許 ./ 開頭,連路徑中段夾帶的 ../ 都一併禁掉;把路徑解析成絕對路徑後,檢查有沒有跑出 content 根目錄;連 symlink 也要 resolve 之後重新檢查一次;複製到目的地時再做一次同樣的越界檢查。四層疊起來,才算是真正擋住這個風險。

維運審查則丟出三個硬 blocker:

方案跑完之後,我的主觀感受是:這感覺就是在重造車輪,而且把事情變複雜了。理論上站得住腳的東西,操作起來卻疊了一層又一層的補丁——這就是最終讓我推翻整個計畫的導火線。

退一步:問題不是功能,而是時機

我退一步重新想了一次:如果我一開始選的就是 Next.js 內建的 MDX 系統呢?如果我沒有提早為一個還沒發生的「MDX 進資料庫」需求做技術選型呢?

跟 Claude Code 討論過後,我確認了一件事:next-mdx-remote-client 不支援 build-time import image,本身是合理限制,不是它的問題。真正該檢討的是我自己選型的時機——在需求還沒出現之前,就先幫一個假設中的未來鋪路,結果反而把眼前用得到的東西(build-time 圖片最佳化)給犧牲掉了。

於是決定:不是繼續在 sidecar manifest 這條路上疊補丁,而是把整條 MDX 轉 static page 的渲染引擎,換回 Next.js 內建的 @next/mdx——file-based、build-time 編譯,.mdx 檔案本身就是原始碼的一部分。

換引擎實際長什麼樣

決定方向後,先跑了一輪技術可行性驗證,確認可行才正式動手。整個換血的核心,就是把 render pipeline 從「請求進來才編譯」改成「build 期就把 MDX 編譯成 React 元件」——.mdx 檔案變得跟其他原始碼一樣,在打包階段就處理完,slug 對內容的對應也跟著換成 build 期就查得到的靜態表,不再需要 runtime 動態解析。舊引擎的依賴與檔案全部拿掉,沒有讓新舊兩套並存。

換引擎不是只換一個 config 選項,還牽出幾個連帶的設計決定:因為換成 build-time 編譯,工具鏈對「標題裡能寫什麼語法」變得比較嚴格;.mdx 檔案也等於要被當成跟 repo 原始碼同級的內容來信任;另外還順手把一個會讓快取失準的建置設定關掉,避免文章內容改了、線上卻沒反映。

驗收:測試、數字與代價

換完之後跑了完整驗收,過程中抓到一個原本設計得不夠嚴謹的檢查機制,修正後又經過幾輪審查才收斂。最後確認:所有既有文章渲染結果不變、新的圖片最佳化能力也如預期運作,測試全綠,build 速度也落在可接受範圍。

這次換引擎也明著接受了幾個代價:開發時的即時性稍微變差(有些改動得重啟 dev server 才會生效)、標題語法變得比較受限——但比起讓內容悄悄跟渲染結果對不上,這些代價我覺得換得值得。

換一次地基,而不是修一次功能

回頭看整件事,我做錯的不是選錯了 library,而是在需求還沒出現之前,就替一個假設中的未來鋪路——結果鋪出來的路,自己第一個用不上。繞了一圈規劃 sidecar manifest、被安全審查和維運審查各打一次回票之後,我才承認:與其在錯的地基上一直加固,不如換一次地基。

這次的教訓,大概可以濃縮成一句話:技術選型不是解決今天的問題,就是在賭明天的需求——賭對了是遠見,賭錯了就是要自己拆掉重蓋。下次再想「以後可能用得到」的時候,我會先問自己:這是真的看到需求的影子,還是只是還沒解決眼前的問題。