作品集網站更新開發心得

作品集網站重構開發心得
為什麼要做這件事
痛點
說實話,之前的網站最讓我受不了的就是改個錯字都要重新部署。每次在程式碼裡改完文案,push 上去,等 Vercel 跑完 CI/CD,2-3 分鐘就這樣過去了。有時候只是改個標點符號,卻要經歷這整套流程,實在太蠢了。
目標
這次重構最核心的目標:把內容和程式碼分開。
導入 Sanity CMS 之後,現在改文案直接在網頁編輯器改,按保存就即時更新。這才是正常的工作流程啊!
核心功能實現
Sanity CMS 整合
第一次失敗
第一次整合 Sanity 的時候,我天真地以為它就是個普通的 npm 套件,直接裝進 Next.js 專案就好。然後就在 Vercel 部署時翻車了——各種 build error,改了一整天的配置都沒用。
那時候一直問 AI,得到的答案都是些調整 next.config.js 的建議,但根本治標不治本。
找到正確做法
直到我放棄 AI,開始去 GitHub 翻別人的專案,才發現所有人都把前端和 CMS 分開部署。
原來 Sanity Studio 本身就是一個獨立的專案,應該部署到 Sanity 的雲端上,前端只需要裝 @sanity/client 透過 API 拿資料就好。搞清楚這件事之後,十分鐘就解決了。
學到什麼
AI 很強,但還是要知道什麼時候該自己動手查資料。看別人的實作往往比繞圈子問 AI 快多了。
Blog Markdown 渲染
CodeBlock:從簡單到複雜
一開始只是想做個有語法高亮的程式碼區塊,結果越做越上癮,功能越加越多:
- 語法高亮:rehype-highlight 處理
- Mac 風格視窗:紅黃綠三個圓點,瞬間質感 +100
- 複製按鈕:方便讀者快速複製
- 檔案名稱顯示:markdown 寫
filename="app.ts"就會顯示在左上角 - 特定行高亮:用
{2-4}語法標記重點行 - 行號顯示:左側固定欄位,程式碼可以水平捲動但行號不會跑掉
這些功能看起來理所當然,但實作過程真的踩了超多坑。
坑 1:語法高亮消失
Mac 風格視窗做好後,我興高采烈地加上語法高亮,結果發現高亮又不見了。debug 了半天才發現,原來是我用了 getTextContent() 把 HTML 結構拆掉了。
rehype-highlight 會把程式碼包成一堆 <span class="hljs-keyword"> 這種標籤來產生顏色,但我把它們全部轉成純文字,當然就沒顏色了...
解決方法: 不要破壞 HTML 結構,把完整的 React children 傳進去就好。純文字只用在複製功能就夠了。
<CodeBlock codeContent={children}>
{codeString} // 純文字版本只用於複製
</CodeBlock>
坑 2:行高亮時機問題
問題在於 rehype-highlight 是在 React 渲染之後才執行的,所以我的 useEffect 太早跑,抓到的還是沒有 highlight 的 HTML。
最後發現要用 setTimeout 延遲一下,等 highlight 完成。我測試了不同的延遲時間:
- 0ms → 失敗(太快了)
- 50ms → 不穩定,有時候可以有時候不行
- 100ms → 穩定 ✓
- 200ms → 太慢,使用者會感覺到延遲
所以最後用 100ms。
useEffect(() => {
if (!preRef.current || !highlightLines || highlightLines.length === 0) return;
const timeoutId = setTimeout(() => {
const lines = codeElement.innerHTML.split("\n");
// 處理行高亮...
}, 100);
return () => clearTimeout(timeoutId);
}, [highlightLines]);
坑 3:行號對齊問題
這個問題真的很隱蔽,一開始幾行看起來完美對齊,但滾到第 50 行、第 100 行時,行號和代碼行會逐漸產生偏移。
問題根源: Tailwind 的 leading-6 是相對單位,rehype-highlight 又有自己的 line-height,兩者優先級不同,導致累積誤差。
解決方法: 統一使用固定的 1.5rem,用 inline style 確保優先級最高
<div style={{ lineHeight: "1.5rem" }}>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i} style={{ lineHeight: "1.5rem" }}>
{i + 1}
</div>
))}
</div>
<pre style={{ margin: 0, lineHeight: "1.5rem" }}>
{codeContent || children}
</pre>
然後在全局 CSS 強制覆蓋:
pre code.hljs {
line-height: 1.5rem !important;
}
pre code.hljs * {
line-height: 1.5rem !important;
}
為什麼要用絕對單位? 相對單位(1.5)會根據父元素的 font-size 計算,容易有精度誤差。絕對單位(1.5rem)確保每一行都是固定的 24px 高度,即使有 100 行也能完美對齊。
坑 4:metadata 傳遞(最難的部分)
這是整個 CodeBlock 開發過程中最頭痛的問題。我想在 markdown 寫 filename="app.ts" 或 {2-4} 來標記檔案名稱和高亮行數,但這些 metadata 怎樣都抓不到,dataMeta 永遠是 undefined。
我試了四次才找到正解:
嘗試 1:直接讀 node.data.meta ❌
失敗原因:node.data.meta 在到達 React component 時已經消失了
嘗試 2:安裝現成的套件 remark-code-meta ❌
失敗原因:這個套件的行為跟我想要的不一樣,它預設會把 meta 包到 <details> 標籤裡
嘗試 3:翻 git 歷史找線索 △
發現之前有個 /src/lib/rehype-code-meta.ts 檔案被刪掉了,重新創建但還是抓不到
嘗試 4:調整 plugin 順序 △
把 rehypeCodeMeta 移到 rehypeHighlight 前面,行高亮突然可以用了!但 filename 還是顯示不出來
最終解決:自己寫 remarkCodeMeta plugin ✓
經過多次失敗後,我終於意識到關鍵問題:metadata 必須在 remark 階段(markdown → AST)就捕捉,等到 rehype 階段(AST → HTML)就太晚了!
import { visit } from "unist-util-visit";
// Remark plugin - 在 markdown 階段捕捉 meta
export function remarkCodeMeta() {
return (tree: any) => {
visit(tree, "code", (node: any) => {
if (node.meta) {
node.data = node.data || {};
node.data.hProperties = node.data.hProperties || {};
node.data.hProperties.dataMeta = node.meta; // 關鍵:把 meta 存到 hProperties
}
});
};
}
數據流完整解析:
- Markdown 寫:
```typescript filename="app.ts" {2-4} - remarkCodeMeta 在 remark 階段捕捉
node.meta - 存到
node.data.hProperties.dataMeta - 轉換成 HTML 的
data-metaattribute - react-markdown 轉成 React prop
dataMeta - 在
pre({ children })中從codeProps["dataMeta"]成功取得 ✓
成功的那一刻真的超爽!終於看到 filename 顯示出來了。
學到的教訓:
- 理解 remark 和 rehype 的差別很重要(一個處理 markdown AST,一個處理 HTML AST)
- Plugin 執行順序會直接影響結果
- 遇到難題時,翻 git 歷史往往能找到線索
開發時間線
回顧一下這個功能從無到有的演進:
- v1.0 基礎結構:Mac 風格視窗 + 複製按鈕
- v1.1 語法高亮:整合
rehype-highlight - v1.2 攔截 pre 標籤:用
react-markdowncomponents 客製化 - v1.3 保留 HTML 結構:解決語法高亮消失問題
- v1.4 檔案名稱顯示:試了 4 次才用
remarkCodeMeta成功 - v1.5 行高亮:
useEffect+setTimeout(100ms)等待 highlight 完成 - v1.6 行號顯示:
sticky固定行號欄位,支援水平捲動 - v1.7 樣式優化:移除 pre 邊距,覆蓋預設樣式
花最多時間的是 metadata 傳遞(試了 4 次)和行高亮時機問題(測試不同延遲)。但這些坑踩過後,對 markdown 處理流程的理解深刻很多。
Markdown 元件客製化
預設的 markdown 渲染真的太陽春了,跟整個網站的設計語言完全不搭。所以我決定客製化一些常用元素。
設計原則
我先去翻了專案裡其他組件的設計,找出共通的設計語言:
BentoGrid.tsx:圓角、深色邊框、半透明背景ProjectCard.tsx:玻璃擬態、hover 效果globals.css:橙色主色調、深色主題
歸納出幾個原則:
- 深色背景 + 玻璃擬態效果
- 橙色/綠色作為強調色
- 適當的 hover 互動
- 圓角和陰影
Task List 實作
// 客製化 checkbox
input({ type, checked, ...props }) {
if (type === "checkbox") {
return (
<span className="relative inline-flex items-center justify-center w-5 h-5 flex-shrink-0 mr-3">
<input
type="checkbox"
checked={checked}
disabled
className="appearance-none w-5 h-5 rounded border-2 border-zinc-600 bg-zinc-800/50 checked:bg-green-500 checked:border-green-500"
{...props}
/>
{checked && (
<svg className="absolute w-3 h-3 text-white pointer-events-none">
{/* 白色勾勾圖示 */}
</svg>
)}
</span>
);
}
return <input type={type} {...props} />;
}
踩過的坑:
- Checkbox 顏色對比度不足:一開始用橙色,但在深色背景上不夠明顯。改用綠色背景 + 白色勾勾,符合「完成」的語意
- 垂直對齊問題:用
items-center取代items-start
Table 實作
// 卡片風格表格
table({ children, ...props }) {
return (
<div className="my-8 not-prose overflow-hidden rounded-xl border border-zinc-800/50 bg-zinc-900/30 backdrop-blur-sm">
<table className="w-full" {...props}>
{children}
</table>
</div>
);
}
設計迭代:
- v1:傳統表格樣式 → 不符合網站風格
- v2:誇張的陰影和漸層 → 太花俏
- v3:簡化成卡片風格 → ✓
BlogTOC 目錄功能
做了一個側邊欄目錄,會自動抓文章的標題(H1/H2/H3),加上閱讀進度條。手機版改成浮動按鈕 + 環形進度,比較不佔空間。
踩過的坑: 進度條滾到底部會卡在 90% 左右,因為最後一個標題已經過了視窗範圍。
解決方法: 在快到底部(< 100px)時平滑過渡到 100%
if (distanceToBottom < 100) {
const ratio = 1 - distanceToBottom / 100;
progress = baseProgress + remaining * ratio;
}
這樣使用者滾到底部時,進度條會自然地達到 100%,體驗好很多。
體驗優化
Navbar 導航欄動畫
從複雜到簡約
一開始想做很炫的滑鼠追蹤光暈效果,用 CSS 變數追蹤滑鼠位置:
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
<Link
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setMousePosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}}
style={{
"--mouse-x": `${mousePosition.x}px`,
"--mouse-y": `${mousePosition.y}px`,
} as React.CSSProperties}
>
<span
style={{
background: `radial-gradient(circle 80px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(251, 146, 60, 0.15), transparent)`,
}}
/>
</Link>
結果發現太重了,完全不符合「輕盈」的設計目標。
最終方案
簡化成只保留兩個效果:
<Link className="group relative px-1.5 py-1.5 overflow-hidden">
{/* 文字顏色變化 */}
<span className="relative text-white/90 transition-colors duration-200 group-hover:text-orange-300">
{navItem}
</span>
{/* 底部漸變線 - 從中間向兩側展開 */}
<span className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-orange-400 to-transparent scale-x-0 group-hover:scale-x-100 transition-transform duration-200 ease-out" />
</Link>
技術要點:
scale-x-0→scale-x-100讓線條從中間展開- 漸變色
from-transparent via-orange-400 to-transparent讓兩端自然淡出
設計原則: 越簡單越好。一條漸變線 + 顏色變化就夠了,不需要背景光暈、邊框光效這些花俏的東西。
語言選擇器:最複雜的動畫系統
這是整個專案裡最燒腦的部分,涉及多個動畫的協調、狀態管理、跨容器定位計算。
1. 彈性展開動畫
目標: 展開時有回彈效果,收起時平滑自然
<div
style={{
width: isOpen ? "auto" : "60px",
transition: isOpen
? "width 500ms cubic-bezier(0.34, 1.56, 0.64, 1)" // 彈性展開
: "width 400ms cubic-bezier(0.16, 1, 0.3, 1) 150ms", // 平滑收起 + 延遲
}}
>
cubic-bezier 曲線解析:
cubic-bezier(0.34, 1.56, 0.64, 1):第二個值1.56 > 1,會產生超出終點再回彈的效果cubic-bezier(0.16, 1, 0.3, 1):標準的 ease-out 曲線,平滑減速
為什麼展開慢、收起快?
- 展開是「揭示內容」,需要給使用者時間感知
- 收起是「隱藏內容」,快速完成不會讓人覺得拖沓
2. 地球圖標旋轉 + 顏色變化
<svg
style={{
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 500ms cubic-bezier(0.34, 1.56, 0.64, 1), color 300ms ease",
color: (isIconHovered && isOpen) || (!isOpen && isIconHovered)
? "rgb(253 186 116)"
: "inherit",
scale: !isOpen && isIconHovered ? "1.1" : "1",
}}
>
細節:
- 旋轉動畫和展開動畫使用相同的彈性曲線,視覺上更協調
- 未展開時 hover 有放大效果(
scale: 1.1),展開後沒有(避免干擾背景指示器) - 顏色變化用獨立的
300ms ease,比旋轉快一點點
3. 語言選項依次淡入
目標: 展開後,選項依次出現,而不是同時蹦出來
{locales.map((locale, idx) => (
<button
style={{
transitionDelay: isOpen ? `${idx * 50}ms` : "0ms",
opacity: isOpen ? 1 : 0,
transform: isOpen ? "translateY(0)" : "translateY(-5px)",
}}
>
{locale}
</button>
))}
技術要點:
- 使用
idx * 50ms為每個選項添加遞增延遲 - 配合
opacity和translateY創造「從下往上淡入」的效果 - 收起時不延遲(
0ms),讓關閉更快速
4. 背景指示器(最難的部分)
挑戰: 背景指示器要能追蹤三種不同的目標,並根據目標調整形狀:
- 當前選中的語言(橢圓形)
- Hover 的語言選項(橢圓形)
- Hover 的地球圖標(圓形,小一圈)
核心問題: 地球按鈕和語言選項在不同容器內,無法用 offsetLeft 直接計算相對位置。
解決方案: 使用 getBoundingClientRect() 跨容器計算
const getIndicatorStyle = () => {
// 情況 1: Hover 到地球圖標
if (isIconHovered && isOpen && iconButtonRef.current && indicatorContainerRef.current) {
const iconRect = iconButtonRef.current.getBoundingClientRect();
const containerRect = indicatorContainerRef.current.getBoundingClientRect();
const size = 50;
const offset = (60 - size) / 2;
return {
left: iconRect.left - containerRect.left + offset,
width: size,
height: size,
borderRadius: "9999px", // 圓形
opacity: 1,
};
}
// 情況 2: Hover 到語言選項或顯示當前選中
const targetLocale = hoveredLocale || currentLocale;
const buttonElement = buttonRefs.current.get(targetLocale);
return {
left: buttonElement.offsetLeft,
width: buttonElement.offsetWidth,
height: "40px",
borderRadius: "9999px", // 橢圓形
opacity: 1,
};
};
技術難點:
-
跨容器定位計算
const iconRect = iconButtonRef.current.getBoundingClientRect(); const containerRect = indicatorContainerRef.current.getBoundingClientRect(); const relativeLeft = iconRect.left - containerRect.left; -
動態調整形狀
- Hover 到圖標:
width: 50px, height: 50px(圓形) - Hover 到選項:
width: buttonWidth, height: 40px(橢圓形) - 配合
transition-all duration-300讓形狀變化流暢
- Hover 到圖標:
-
居中偏移計算
const size = 50; const offset = (60 - size) / 2; left: relativeLeft + offset;
5. 統一的顏色邏輯
設計原則: 任何被 hover 的元素都變橘色,背景指示器跟隨
color: hoveredLocale === locale
? "rgb(253 186 116)" // Hover 的選項 → 橘色
: (hoveredLocale || isIconHovered) && currentLocale === locale
? "rgb(255 255 255 / 0.9)" // 當前選中但其他地方被 hover → 白色
: currentLocale === locale
? "rgb(253 186 116)" // 當前選中且無 hover → 橘色
: undefined
核心規則:
- Hover 優先級最高 → 顯示橘色
- Hover 到其他地方時,當前選中項退讓 → 變回白色
- 無 hover 時,當前選中項保持橘色
為什麼要這樣設計?
- 避免兩個橘色同時出現,視覺混亂
- 背景指示器 + 橘色文字 = 明確的視覺反饋
- 當前選中項在需要時「退讓」,讓使用者專注於正在 hover 的選項
解決的技術難題
問題 1: 展開時無法點擊 Navbar
症狀: 語言選單展開時,整個 navbar 都點不了
根本原因: 全屏遮罩層(z-index: 5001)覆蓋了 navbar(z-index: 5000)
最終解決方案: 移除遮罩層,改用點擊外部監聽
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".language-switcher-container")) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
優點:
- ✅ 不阻擋任何 UI 元素
- ✅ 點擊外部任何地方都能關閉(包括 navbar)
- ✅ 性能更好(無遮罩層渲染)
- ✅ 符合直覺(點擊外部關閉是常見的 UX 模式)
問題 2: 背景指示器位置計算錯誤
症狀: Hover 到地球圖標時,背景指示器位置偏移
原因: 地球按鈕和背景指示器在不同容器內,offsetLeft 計算的是相對於各自父容器的位置
正確做法: 使用 getBoundingClientRect() 計算絕對位置再轉換
const iconRect = iconButtonRef.current.getBoundingClientRect();
const containerRect = indicatorContainerRef.current.getBoundingClientRect();
left: iconRect.left - containerRect.left + offset;
學到的教訓:
offsetLeft是相對於offsetParent的位置,不是絕對位置- 跨容器定位必須用
getBoundingClientRect() - 記得加上居中偏移
offset,讓指示器在按鈕中央
問題 3: 動畫時機不對
症狀: 語言選項展開時,背景指示器還沒更新位置
原因: React 的批次更新和 CSS transition 時機不同步
解決方案: 使用 requestAnimationFrame 強制更新
React.useEffect(() => {
if (isOpen) {
requestAnimationFrame(() => {
forceUpdate();
});
} else {
setHoveredLocale(null);
}
}, [isOpen]);
Blog 卡片布局優化
從三欄並排到垂直堆疊
改進前:
<div className="flex flex-col md:flex-row gap-6">
{/* 三張卡片橫向排列,每張都是小卡片 */}
</div>
問題:
- 每張卡片太小,內容擠在一起
- 橫向布局無法充分展示圖片
- 與項目卡片的設計風格不一致
改進後:
<div className="flex flex-col gap-6 max-w-6xl mx-auto">
{posts.map((post) => (
<Link className="...">
<div className="flex flex-col lg:flex-row relative">
{/* 文字內容 - 左側 3/5 */}
<div className="w-full lg:w-3/5 min-h-[280px]">
{/* 內容區域 */}
</div>
{/* 圖片 - 右側 2/5 */}
<div className="w-full lg:w-2/5 order-first lg:order-last">
<Image />
</div>
</div>
</Link>
))}
</div>
技術要點:
- 響應式圖片順序:手機版圖片在上(
order-first),桌面版圖片在右(lg:order-last) - 卡片高度統一:
min-h-[280px]確保每張卡片都有足夠高度
設計原則:
- 參考項目卡片的布局,保持視覺一致性
- 垂直堆疊讓每張卡片都能充分展示
- 簡潔的設計更聚焦內容
收穫與反思
技術層面
- 理解底層很重要 — 不懂 remark 和 rehype 的差別,metadata 永遠傳不過去
- 細節決定成敗 — 一個
length === 0的檢查就能避免一堆 bug - Plugin 順序很關鍵 — 順序錯了,功能就壞了
- TypeScript 是好朋友 — 強制思考類型,避免潛在問題
開發心態
- 不要盲目優化 — 先測量,有問題再改。Lighthouse 97 分就夠了
- 取捨要清楚 — 3.5MB icons 換 CMS 靈活性,我覺得值得
- 設計要克制 — 不是越多效果越好,簡單乾淨更重要
- AI 不是萬能的 — 有時候翻 GitHub、看別人的實作更快
動畫設計的四個原則
- 輕盈優先 — 避免過重的視覺效果(移除背景光暈、邊框光效)
- 彈性曲線增加活力 —
cubic-bezier(0.34, 1.56, 0.64, 1)創造回彈效果 - 漸進式揭示 — 語言選項依次出現(
idx * 50ms) - 統一的視覺反饋 — 所有 hover 效果都用橘色
互動設計的三個準則
- 可預測性 — 動畫方向符合直覺(展開向右、淡入向上)
- 即時反饋 — Hover 和點擊立即響應(200-300ms)
- 非阻塞設計 — 移除全屏遮罩層,不阻擋其他 UI
技術選型與取捨
使用 CSS 而不是 Framer Motion
- 簡單的動畫(顏色、位移、旋轉)用 CSS transition 就夠
- 省下 bundle size,效能更好
- Framer Motion 留給真正複雜的動畫(Spotlight 特效)
inline style vs Tailwind
- 動態計算的值(滑鼠位置、背景指示器位置)用 inline style
- 靜態樣式用 Tailwind class
- 兩者結合,各取所長
最大的收穫
邊做邊改,不追求一次到位。遇到問題就解決,慢慢累積功能。寫程式就是這樣,沒有完美的解決方案,只有最適合當下的選擇。
最有成就感的時刻
CodeBlock metadata 傳遞: 當 remarkCodeMeta 終於讓 filename 顯示出來的那一刻,感覺所有的努力都值得了。試了四次、翻遍文檔和 git 歷史,最後自己實作 plugin 解決。
語言選擇器背景指示器: 當背景指示器終於能正確追蹤地球圖標,並在 hover 時變成圓形的那一刻。從 offsetLeft 計算錯誤,到使用 getBoundingClientRect(),再到加上居中偏移,試了好幾次才成功。
這種從卡關到突破的感覺,真的很爽。
最終成果
經過這一輪開發,網站從「能用」變成「好用」了。
CodeBlock 變得很專業: Mac 風格視窗、語法高亮、行號、特定行高亮、檔案名稱顯示、複製按鈕
Markdown 渲染更精緻: Task List 有綠色勾勾、Table 是卡片風格
CMS 整合帶來的自由: 改個錯字不用等 2-3 分鐘部署了,在 Sanity 改完按保存就即時更新
效能還不錯: Lighthouse 跑出來 97/100/100/100
技術棧
- Next.js 15.3.0 + React
- Tailwind CSS
- Sanity CMS
- react-markdown + rehype/remark plugins
- Framer Motion(複雜動畫用)
- react-icons(動態載入圖示)
這次重構最大的收穫,不是學了多少新技術,而是學會什麼時候該停手。功能夠用就好,過度優化反而浪費時間。