從 0 到 1 打造一個線上履歷編輯器:Next.js 16 + React 19 + Koa 全端實戰(附 PDF 即時預覽不閃爍的完整方案)
關鍵字:Next.js 16 / React 19 / @react-pdf/renderer / PDF 即時預覽 / Tiptap / Koa2 / Sequelize / SEO 工程化 / 多語言 i18n
閱讀時長:約 25 分鐘 | 程式碼佔比:40% | 適合人群:中高階前端、全端工程師
寫在前面
做一個「線上履歷編輯器」,聽起來是個 CRUD 專案:左邊填表單,右邊出預覽,點個按鈕下載 PDF。
我一開始也是這麼想的。直到真正做下去,才發現坑一個比一個深:
- 使用者敲一個字,右邊 PDF 要不要重新生成?生成一次 300ms,閃一下白屏,體驗直接崩掉;
- 瀏覽器列印出來的 PDF 和預覽對不上,字體、行距、分頁全亂;
- 中文履歷裡夾幾個英文單詞,
@react-pdf/renderer直接把 CJK 字元斷在了不該斷的地方; - 一個中文字體 TTF 動輒 10MB+,全量載入首頁直接 GG;
- 履歷分享連結用自增 ID,別人
id+1就能遍歷所有人的履歷; - 7 套模板 × 13 個模組 × 4 種語言,程式碼不抽象好,改一處崩全場。
這篇文章把 BeautyResume(https://beautyresume.com)這個線上專案中踩過的坑和最終方案完整講清楚。全文都是**可落地的真實程式碼**,不是 Demo 級偽代碼。
一、技術選型:為什麼是這套組合
1.1 整體架構
┌─────────────────────────────────────────────────────────┐
│ 瀏覽器 / 用戶端 │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ 表單編輯區 │→ │ Redux Store │→ │ PDF 預覽區 │ │
│ │ (Tiptap) │ │ (RTK Slice) │ │ (PDF.js Canvas)│ │
│ └──────────────┘ └──────────────┘ └───────────────┘ │
└──────────────────────────┬──────────────────────────────┘
│ HTTPS
┌──────────────────────────▼──────────────────────────────┐
│ Nginx(反向代理 / 靜態資源 / Gzip) │
└──────────┬────────────────────────────┬─────────────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼─────────────────┐
│ Next.js 16 SSR │ │ Koa 2 API Server │
│ (standalone 產物) │ │ (PM2 叢集模式) │
│ App Router │ │ Session + CSRF + 限流 │
└─────────────────────┘ └──────────┬─────────────────┘
│
┌───────────▼──────────┐
│ MySQL 8 + Sequelize │
│ 阿里雲 OSS(靜態/圖片)│
└──────────────────────┘
1.2 技術棧清單
前端(frontend/package.json):
| 領域 | 選型 | 版本 | 選它的理由 |
|---|---|---|---|
| 框架 | Next.js | 16.2.12 | App Router + output: 'standalone',SEO 與部署體積雙贏 |
| UI 庫 | React | 19.2.3 | 並行特性 + useTransition 對重渲染場景是剛需 |
| 狀態 | Redux Toolkit | ^2.5.0 | 履歷資料是深層嵌套大物件,需要細粒度訂閱 |
| PDF 生成 | @react-pdf/renderer | ^4.3.2 | 用 React 寫 PDF,預覽/匯出可共用一套程式碼 |
| PDF 渲染 | react-pdf (PDF.js) | ^10.4.1 | 自己控制 canvas 渲染,規避瀏覽器內建檢視器 |
| 富文本 | Tiptap | ^3.20.4 | ProseMirror 核心,輸出結構化 JSON 而非髒 HTML |
| i18n | next-intl | ^4.13.4 | 與 App Router 的 [locale] 段天然契合 |
| 樣式 | Tailwind CSS | ^4 | 原子化,配合 PDF 端的 StyleSheet 心智一致 |
| 安全 | sanitize-html | ^2.17.6 | 富文本 XSS 防線 |
| E2E | Playwright | ^1.62.1 | 核心鏈路回歸 |
後端(backend/package.json):
| 領域 | 選型 | 理由 |
|---|---|---|
| 框架 | Koa 2.16 | 洋蔥模型對「統一日誌 / 錯誤 / 回應格式」極友好 |
| ORM | Sequelize 6.37 | 參數化查詢天然防注入 |
| 資料庫 | MySQL 8 (mysql2) | |
| 鑑權 | koa-session 7 | 不用 JWT,理由見 3.2 節 |
| 校驗 | Joi 17 | 宣告式 Schema,邊界統一 |
| 密碼 | bcryptjs 3 | |
| 驗證碼 | svg-captcha | 無需 canvas 原生依賴,部署省心 |
一個反直覺的選型:鑑權我們放棄了 JWT,回到 Session。原因很簡單——履歷是隱私資料,需要「改密碼後全端立即下線」的能力。JWT 無狀態的優點在這個場景反而是致命缺陷(簽發出去就撤不回)。具體實作見 3.2 節。
二、核心難點一:PDF 即時預覽如何做到「不閃、不卡、所見即所得」
這是整個專案技術含量最高的部分,也是最值得展開講的。
2.1 先說清楚問題
線上履歷編輯器有一個繞不開的矛盾:
- 所見即所得要求預覽就是最終 PDF,不能是「HTML 模擬一張 A4 紙」;
- 但真·生成 PDF 是個重操作,一份兩頁履歷要 200~400ms;
- 使用者是連續輸入的,每敲一個字觸發一次生成,介面必然抖成帕金森。
很多同類產品的做法是「HTML 預覽 + 匯出時另走一套 PDF 生成」。這條路我們走過,結論是:兩套程式碼必然對不齊。字體度量、行高計算、分頁規則,只要有一處不同,使用者下載的 PDF 就和他看到的不一樣——這是履歷產品的致命傷。
所以我們的方案是:預覽和匯出共用同一份 Document 程式碼,然後用工程手段把效能和閃爍問題解決掉。
2.2 唯一的 Document 入口
frontend/components/pdf/index.tsx:
// PDF 履歷文件 —— 預覽與下載共用同一套程式碼
const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};
/** 預覽和下載 PDF 共用此元件,保證所見即所得 */
export function ResumeDocument({
data,
templateId,
themeColor = DEFAULT_THEME_COLOR,
pdfBodyPx = DEFAULT_PDF_BODY_PX,
locale,
labels,
}: ResumeDocumentProps) {
ensurePdfFontsForRender(locale); // 按語言按需註冊字體子集
const pdfFontFamily = resolvePdfFontFamily(locale);
const resolvedId = resolvePdfTemplateId(templateId);
const Template =
pdfTemplateComponentById[resolvedId] ??
pdfTemplateComponentById[defaultPdfTemplateId] ??
ATemplate;
return (
<Document>
<Template
data={data}
themeColor={themeColor}
pdfBodyPx={pdfBodyPx}
pdfFontFamily={pdfFontFamily}
labels={labels}
/>
</Document>
);
}
預覽頁、下載按鈕、縮圖生成、公開分享頁——四個入口全部呼叫這一個元件。從架構上根絕了「預覽與匯出不一致」的可能。
2.3 為什麼不用 <PDFViewer>
@react-pdf/renderer 官方提供了 <PDFViewer>,本質是把 blob 塞進 <iframe>,交給瀏覽器內建 PDF 檢視器。
線上實測的問題:
- Chrome 內建檢視器會強制加工具列,還會自動縮放裁切,視覺上很不乾淨;
- 每次 blob 變更,iframe 整體重載,白屏一閃;
- 行動端 Safari 行為完全不一致,幾乎不可用。
所以我們改成:usePDF 拿到 blob URL → 交給 react-pdf(PDF.js)用 canvas 自己渲染。
frontend/components/pdf/ResumePdfJsPreview.tsx:
import { usePDF } from '@react-pdf/renderer';
import { Document as PdfJsDocument, Page, pdfjs } from 'react-pdf';
function ensurePdfWorker() {
if (workerConfigured || typeof window === 'undefined') return;
pdfjs.GlobalWorkerOptions.workerSrc = publicAssetUrl('/pdf.worker.min.mjs');
workerConfigured = true;
}
export function ResumePdfJsPreview({ document: pdfDocument, surfaceColor = '#f0f3fd' }) {
ensurePdfWorker();
const [instance, updateInstance] = usePDF();
useEffect(() => {
updateInstance(pdfDocument as Parameters<typeof updateInstance>[0]);
}, [pdfDocument, updateInstance]);
// ...
}
部署細節:PDF.js 的 worker 檔案必須能被獨立訪問。我們在
postinstall鉤子裡加了node scripts/copy-pdf-worker.js,把 worker 從node_modules拷到public/,避免打包器處理 worker 時的各種玄學問題。
2.4 核心技巧:雙緩衝 + 全頁渲染完成才切換
這是消除閃爍的關鍵,思路來自圖形學裡的雙緩衝(Double Buffering)。
樸素做法:新 blob 來了 → 直接替換 file prop → PDF.js 清空 canvas → 重新解析 → 逐頁繪製。中間那段「清空到繪製完成」就是白屏閃爍。
我們的做法:
- 新 PDF 到達時,不動當前顯示的圖層;
- 在下面偷偷疊一個新圖層(
opacity: 0)開始渲染; - 監聽每一頁的
onRenderSuccess,用Set計數; - 集齊所有頁才把新圖層淡入、舊圖層淡出;
- 260ms 淡出動畫結束後回收舊圖層記憶體。
const commitFrame = useCallback((url: string) => {
if (currentUrlRef.current !== url) return; // 已被更新的任務作廢
const previous = visibleUrlRef.current;
if (previous === url) return;
fadeTimersRef.current.forEach((timer) => window.clearTimeout(timer));
fadeTimersRef.current = [];
visibleUrlRef.current = url;
setVisibleUrl(url);
if (previous) {
fadingUrlRef.current = previous;
setFadingUrl(previous);
setFrames((prev) => prev.filter((f) => f.url === url || f.url === previous));
// 260ms 淡出後回收舊幀,防止記憶體洩漏
const clearOldTimer = window.setTimeout(() => {
setFrames((prev) => prev.filter((f) => f.url !== previous));
setFadingUrl((current) =>
current !== previous ? current : ((fadingUrlRef.current = null), null)
);
renderedPagesByUrlRef.current.delete(previous);
}, FRAME_FADE_MS);
fadeTimersRef.current = [clearOldTimer];
}
}, []);
// 逐頁計數,集齊所有頁才 commit
const handlePageRenderSuccess = useCallback(
(url: string, pageNumber: number, pages: number) => {
if (currentUrlRef.current !== url || pages <= 0) return;
let renderedPages = renderedPagesByUrlRef.current.get(url);
if (!renderedPages) {
renderedPages = new Set();
renderedPagesByUrlRef.current.set(url, renderedPages);
}
renderedPages.add(pageNumber);
if (renderedPages.size >= pages) commitFrame(url);
},
[commitFrame]
);
圖層本體用 memo 包裹,並關閉文字層和註解層(預覽不需要選中文字,關掉能省 30%+ 渲染時間):
const PdfFrameLayer = memo(function PdfFrameLayer({
frame, pageWidth, visible, fading, onPageRenderSuccess,
}) {
return (
<div
style={{
opacity: visible ? 1 : 0,
pointerEvents: 'none',
transition: fading ? `opacity ${FRAME_FADE_MS}ms ease` : undefined,
zIndex: fading ? 3 : visible ? 2 : 0,
}}
aria-hidden={!visible}
>
<PdfJsDocument file={frame.url} loading={null} error={null}>
{Array.from({ length: frame.pages }, (_, i) => (
<Page
key={i}
pageNumber={i + 1}
width={pageWidth}
renderTextLayer={false} // 預覽不需要選中文字
renderAnnotationLayer={false} // 也不需要註解
onRenderSuccess={() => onPageRenderSuccess(frame.url, i + 1, frame.pages)}
/>
))}
</PdfJsDocument>
</div>
);
});
關鍵點:currentUrlRef 這個「令牌校驗」非常重要。使用者快速連續輸入時會產生多個並行渲染任務,如果不校驗,舊任務的回呼可能覆蓋新任務的結果,導致預覽內容回退。這是一個典型的競態條件(Race Condition),在非同步渲染場景裡必須顯式處理。
2.5 防抖策略:輸入停頓才重生成
雙緩衝解決了「閃」,但沒解決「算力浪費」。還需要在資料來源做節流:
// 輸入停頓 400ms 後才觸發 PDF 重建;
// 但模板 / 主題色 / 字型大小切換是「離散操作」,立即回應
const debouncedData = useDebouncedValue(resumeData, 400);
const pdfDocument = useMemo(
() => (
<ResumeDocument
data={debouncedData}
templateId={templateId} // 不防抖
themeColor={themeColor} // 不防抖
pdfBodyPx={pdfBodyPx}
locale={locale}
labels={labels}
/>
),
[debouncedData, templateId, themeColor, pdfBodyPx, locale, labels]
);
設計哲學:區分「連續輸入」和「離散操作」。使用者敲字是連續的,防抖;使用者點擊換模板是離散的、有明確預期的,必須立即回應,否則會覺得「點了沒反應」。
三、核心難點二:中文排版與字體體積
3.1 給 @react-pdf/textkit 打補丁修 CJK 斷行
@react-pdf/renderer 的排版引擎 textkit 是按西文斷詞規則設計的:以空格為斷點。而中文沒有空格,一整段中文會被當成「一個超長單詞」,結果就是:
- 要麼整段溢出紙張邊界;
- 要麼在一個英文單詞中間粗暴截斷。
社群 issue 掛了很久沒修,我們的方案是用 patch-package 直接打補丁:
{
"scripts": {
"postinstall": "patch-package && node scripts/copy-pdf-worker.js"
}
}
補丁核心思路是在換行機會(break opportunity)計算中,為 CJK 字元區間注入斷點:
// patches/@react-pdf+textkit+x.x.x.patch 核心邏輯
// CJK 統一表意文字 + 全形標點,每個字元後均可斷行
const CJK_RANGE = /[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u3000-\u303F]/;
// 且需處理「避頭尾」規則:
// 行首禁則:,。、;:!?)》」』】…
// 行尾禁則:(《「『【
經驗分享:遇到三方庫 bug 不要盲目 fork 整個庫來維護。
patch-package是最優解——補丁以 diff 形式提交進倉庫,升級依賴時會明確報衝突提醒你複查,維護成本極低。這是我認為每個前端團隊都該掌握的一個工程技巧。
3.2 字體子集化:從 10MB 到 300KB
中文字體是 PDF 方案最大的體積殺手。思源黑體全量 CJK 有 16MB+,直接註冊進去,使用者首次生成 PDF 要等好幾秒。
我們的三層最佳化:
第一層:按語言按需註冊。 使用者用英文介面寫英文履歷,根本不需要載入中文字體:
export function ensurePdfFontsForRender(locale: Locale) {
if (registeredLocales.has(locale)) return;
if (locale === 'zh-CN' || locale === 'zh-TW') {
Font.register({
family: 'NotoSansSC',
fonts: [
{ src: fontUrl('NotoSansSC-Regular.subset.ttf'), fontWeight: 400 },
{ src: fontUrl('NotoSansSC-Bold.subset.ttf'), fontWeight: 700 },
],
});
} else if (locale === 'ja') {
Font.register({ family: 'NotoSansJP', fonts: [...] });
} else {
Font.register({ family: 'Inter', fonts: [...] }); // 純西文,體積極小
}
registeredLocales.add(locale);
}
第二層:字元集裁剪。 履歷用字高度集中,用 fonttools 按 GB2312 常用字集 + 常見 Emoji + 拉丁字母數字標點做子集:
pyftsubset NotoSansSC-Regular.otf \
--unicodes-file=gb2312.txt \
--output-file=NotoSansSC-Regular.subset.ttf \
--flavor=woff2 --layout-features='*'
效果:16MB → 約 300KB,壓縮 98%。
第三層:託管到 OSS + CDN,強快取一年。 字體是典型的不可變資源,檔名帶 hash,Cache-Control: max-age=31536000, immutable。
補充說明:為什麼不用系統字體?因為 PDF 要保證在任何裝置上打開都一樣。HR 用 Mac 預覽、用 Windows Adobe Reader 打開、用手機微信打開,字體必須內嵌。這是履歷這類「正式文件」的硬性要求。
四、核心難點三:模板系統怎麼抽象才不會失控
7 套模板 × 13 個模組 × 4 種語言 × 主題色 × 字型大小,笛卡爾積一展開就是災難。
4.1 三層抽象
┌─────────────────────────────────────────┐
│ Layer 3: 模板註冊表(Registry) │
│ 宣告式設定:id / 欄數 / ATS / tier │
├─────────────────────────────────────────┤
│ Layer 2: 模板元件(A~G Template) │
│ 只負責「佈局」:單欄?雙欄?側欄在左還是右?│
├─────────────────────────────────────────┤
│ Layer 1: 原子模組(13 個 Section) │
│ 基本資訊 / 教育 / 工作 / 專案 / 技能 ... │
│ 樣式全部由 props 注入,模組本身無主見 │
└─────────────────────────────────────────┘
4.2 註冊表驅動
export const PDF_TEMPLATES: PdfTemplateMeta[] = [
{
id: 'A',
slug: 'classic-simple-resume-template',
columns: 1,
atsFriendly: true, // 單欄純文字,ATS 解析友好
tier: 'free',
},
{
id: 'B',
slug: 'two-column-professional-resume-template',
columns: 2,
atsFriendly: false, // 雙欄,部分 ATS 會讀亂
tier: 'pro',
},
// ...
];
const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};
新增一套模板的成本:寫一個佈局元件 + 註冊表加一條 + 一張縮圖。不需要動任何模組程式碼,不需要動預覽邏輯,不需要動匯出邏輯。
4.3 主題變數:字型大小聯動的排版換算
使用者調節「正文字型大小」(12~20px),如果只改正文,標題不動,整個版式的視覺層次就會崩。所以要做比例聯動:
export function buildTypography(pdfBodyPx: number) {
const base = clamp(pdfBodyPx, 12, 20);
return {
body: base,
small: round(base * 0.86),
sectionTitle: round(base * 1.18),
name: round(base * 2.1),
lineHeight: base <= 13 ? 1.55 : base >= 18 ? 1.38 : 1.46, // 字越大行高比越小
sectionGap: round(base * 1.2),
itemGap: round(base * 0.7),
};
}
排版小知識:行高比例應該隨字型大小反向變化。小字型需要更大的行高比(1.55)保證可讀性,大字型則需要收緊(1.38),否則會顯得鬆散。這是印刷排版的常識,但很多前端會寫死
line-height: 1.5。
五、後端:Koa 洋蔥模型的正確打開方式
5.1 中介軟體裝配順序即架構
backend/app.js,順序是有講究的,每一層的位置都有理由:
const Koa = require("koa");
require("./config/env");
const app = new Koa();
// 只有確認後端僅能經可信反代訪問時,才信任轉發 IP 頭(防 IP 偽造繞過限流)
app.proxy = process.env.TRUST_PROXY === "1";
// 啟動即校驗金鑰強度,設定錯誤就別啟動,別等被打了才發現
const sessionSecret = String(process.env.SESSION_SECRET || "");
if (sessionSecret.length < 32) {
throw new Error("SESSION_SECRET must contain at least 32 characters");
}
for (const name of ["VERIFICATION_CODE_SECRET", "LOG_HASH_SECRET", "WECHAT_WEB_STATE_SECRET"]) {
const value = String(process.env[name] || "");
if (value && value.length < 32) {
throw new Error(`${name} must contain at least 32 characters when configured`);
}
}
app.use(requestContext()); // ① 請求 ID + 結構化日誌(最外層才能覆蓋全鏈路耗時)
app.use(errorHandler()); // ② 統一錯誤捕獲(緊貼外層,兜住內部所有拋錯)
app.use(async (ctx, next) => { // ③ 安全回應頭
ctx.set("X-Content-Type-Options", "nosniff");
ctx.set("X-Frame-Options", "DENY");
ctx.set("Referrer-Policy", "no-referrer");
ctx.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
if (process.env.NODE_ENV === "production" && ctx.secure) {
ctx.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
}
// 隱私介面禁止任何快取
if (ctx.path.startsWith("/private/") || ctx.path.startsWith("/public/user/")) {
ctx.set("Cache-Control", "no-store");
}
await next();
});
app.use(globalLimiter); // ④ 全區 IP 限流 120 req/min
app.use(serve(path.join(__dirname, "public"), { maxage: 365 * 24 * 60 * 60 * 1000 }));
app.use(cors({ // ⑤ CORS 嚴格白名單
origin: (ctx) => {
const origin = ctx.get("origin");
if (!origin) return "";
if (corsOrigins.length === 0) {
return process.env.NODE_ENV === "production" ? "" : origin; // 生產環境不裸奔
}
return corsOrigins.includes(origin) ? origin : "";
},
credentials: true,
}));
onerror(app);
app.use(bodyparser({ // ⑥ 請求體差異化限額
enableTypes: ["json", "form", "text"],
jsonLimit: `${maxResumeContentBytes + 64 * 1024}b`, // 履歷 JSON 需要大額度
formLimit: "64kb", // 表單收緊
textLimit: "64kb",
}));
app.use(json());
app.use(csrfProtection()); // ⑦ CSRF(Origin / Referer / Sec-Fetch-Site 三重校驗)
app.keys = [sessionSecret];
app.use(session({
key: "beautyresume.sid",
httpOnly: true,
signed: true,
sameSite: "lax",
secure: process.env.SESSION_COOKIE_SECURE === "1" || process.env.NODE_ENV === "production",
genid: () => crypto.randomUUID(),
maxAge: 7 * 24 * 60 * 60 * 1000,
renew: true,
overwrite: true,
}, app));
app.use(async (ctx, next) => { // ⑧ 路徑前綴鑑權閘道
if (ctx.path.startsWith("/private")) {
await requireLogin(ctx, next);
return;
}
await next();
});
app.use(index.routes(), index.allowedMethods()); // ⑨ 業務路由
// ⑩ 強制 API 回應為 JSON —— 掛在路由「之後」
app.use(require("./middleware/jsonContentType")());
兩個值得單獨說的設計:
① jsonContentType 為什麼掛在路由後面?
這是整個中介軟體棧裡唯一一個「先 await next() 再幹活」的中介軟體,利用的是洋蔥模型的回程階段:
// middleware/jsonContentType.js
module.exports = () => async (ctx, next) => {
await next(); // 先讓路由跑完
if (ctx.path.startsWith('/api') && ctx.type !== 'application/json') {
ctx.type = 'application/json'; // 回程時統一覆寫
}
};
線上曾出現過:某個異常路徑返回了 Koa 預設的 HTML 錯誤頁,前端 JSON.parse 直接拋例外,整個頁面白屏。加了這層「回程守衛」後,API 路徑永遠返回 JSON,前端可以放心解析。
這就是洋蔥模型的精髓——同一個中介軟體可以同時在「請求進入」和「回應返回」兩個時機幹活。 很多人用 Koa 但只用了它的一半能力。
② 前綴閘道鑑權而不是逐路由掛載
不在每個路由後面掛 requireLogin,而是用一個前綴中介軟體統一攔截 /private/*。好處是:新增私有介面不可能忘記加鑑權——把安全從「靠自覺」變成「靠架構」。路由表裡只需標註更細粒度的 requireAdmin / requireEntitlement。
5.2 為什麼用 Session 而不是 JWT
前面埋的坑,這裡填上。核心是 sessionVersion 欄位實現的「全端強制下線」:
// backend/middleware/auth.js
async function loadCurrentUser(ctx) {
const userId = ctx.session && ctx.session.userId;
if (!userId) return null;
const user = await User.findByPk(userId);
if (!user || user.status !== 'active') {
ctx.session = null;
return null;
}
// 核心:Session 裡存的版本號 與 DB 中的當前版本號 必須一致
if (Number(ctx.session.sessionVersion) !== Number(user.sessionVersion)) {
ctx.session = null; // 版本不匹配 → 這個會話已失效
return null;
}
return user;
}
使用者執行「改密碼 / 解綁微信 / 主動登出所有裝置」時,只需 sessionVersion++:
await user.increment('sessionVersion');
// 此刻,該使用者在所有裝置上的所有會話,下一次請求立即失效
用 JWT 想實現同等效果,你得維護一個黑名單表,每次請求都查一遍——那你已經退化成 Session 了,還平白多了 Token 體積和簽名開銷。
選型原則:無狀態不是銀彈。當業務本質上需要狀態(可撤銷的登入態)時,硬上無狀態方案只會讓架構變形。
5.3 履歷分享連結:拒絕 ID 遍歷
履歷包含姓名、電話、信箱等強隱私資訊。用 /resume/123 這種自增 ID,等於把全站使用者的隱私掛在公網上任人爬取。
方案是加密隨機 Slug:
const crypto = require('crypto');
// 22 位 base64url,熵約 128 bit,暴力枚舉不可行
function generateResumeSlug() {
return crypto.randomBytes(16).toString('base64url');
}
再疊加分享時脫敏:
function maskContact(value, type) {
if (!value) return '';
if (type === 'phone') {
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
if (type === 'email') {
const [name, domain] = value.split('@');
if (!domain) return value;
const visible = name.slice(0, Math.min(2, name.length));
return `${visible}${'*'.repeat(Math.max(1, name.length - 2))}@${domain}`;
}
return value;
}
外加一個搜尋引擎收錄開關(預設關閉),關閉時對分享頁下發 X-Robots-Tag: noindex, nofollow。三道防線:不可枚舉 + 內容脫敏 + 不被索引。
六、SEO 工程化:Sitemap 分片與 hreflang 自動化
內容型產品的流量命脈在 SEO。我們有:核心頁 + 7 個模板詳情頁 + 數百篇職場/面試文章,再 × 4 種語言,URL 數量輕鬆破千。
6.1 分片索引
單個 sitemap.xml 上限是 5 萬條 URL / 50MB,雖然遠沒到,但分片的真正價值在於「增量更新」——文章更新了只需重新生成 sitemap-articles.xml,模板頁紋絲不動,搜尋引擎抓取效率更高。
<!-- public/sitemap.xml -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://beautyresume.com/sitemap-core.xml</loc></sitemap>
<sitemap><loc>https://beautyresume.com/sitemap-templates.xml</loc></sitemap>
<sitemap><loc>https://beautyresume.com/sitemap-articles.xml</loc></sitemap>
</sitemapindex>
6.2 hreflang 全交叉自動生成
多語言 SEO 最容易出錯的地方:hreflang 必須雙向全交叉——每個語言版本的頁面都要列出所有語言版本(包括自己),還要有 x-default。手寫必錯。
// frontend/scripts/generate-sitemap.js
const SITE_ORIGIN = (process.env.SITE_ORIGIN || 'https://beautyresume.com').replace(/\/$/, '');
const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja'];
const DEFAULT_LOCALE = 'zh-CN';
function buildAlternates(pathWithoutLocale) {
const links = LOCALES.map(
(l) => `<xhtml:link rel="alternate" hreflang="${l}" href="${SITE_ORIGIN}/${l}${pathWithoutLocale}" />`
);
links.push(
`<xhtml:link rel="alternate" hreflang="x-default" href="${SITE_ORIGIN}/${DEFAULT_LOCALE}${pathWithoutLocale}" />`
);
return links.join('\n');
}
function buildUrlEntries(pathWithoutLocale, { changefreq = 'weekly', priority = 0.8 } = {}) {
const alternates = buildAlternates(pathWithoutLocale);
// 每個語言各生成一條 <url>,且都掛上完整的 alternates
return LOCALES.map((locale) => `<url>
<loc>${SITE_ORIGIN}/${locale}${pathWithoutLocale}</loc>
<lastmod>${TODAY}</lastmod>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
${alternates}
</url>`).join('\n');
}
配套一個 validate-sitemap.js 校驗指令碼,檢查 URL 可達性、hreflang 對稱性、重複 loc,並接進 CI:
{
"scripts": {
"seo:validate": "yarn sitemap:generate && yarn sitemap:validate",
"build": "node scripts/generate-sitemap.js && next build"
}
}
把 sitemap 生成塞進 build 流程,意味著它永遠不會過期——這比「記得手動更新 sitemap」的人肉流程可靠一萬倍。
6.3 Next.js metadata 與 canonical
// frontend/app/[locale]/layout.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale } = await params;
return {
metadataBase: new URL(SITE_ORIGIN), // 所有相對 URL 自動補全為絕對 URL
alternates: {
canonical: localePath(locale, pathWithoutLocale),
languages: buildLanguageAlternates(pathWithoutLocale),
},
openGraph: { /* ... */ },
};
}
七、部署:Next.js standalone 讓映像檔瘦身 80%
7.1 standalone 產物
next.config.js 裡一行設定:
module.exports = {
output: 'standalone',
};
Next.js 會做依賴樹靜態分析,只把真正被引用到的 node_modules 檔案拷進 .next/standalone。對比資料:
| 方案 | 體積 |
|---|---|
完整 node_modules + .next |
~1.2 GB |
standalone 產物 |
~180 MB |
打包指令碼 scripts/standalone-pack.js 做的事:
.next/standalone/ ← 主體(含精簡版 node_modules 和 server.js)
+ .next/static/ ← 必須手動拷貝!Next 不會自動放進去
+ public/ ← 同上,必須手動拷貝
→ archiver 打成 zip
踩坑警告:
.next/static和public不會被自動包含進 standalone 目錄。這是 Next.js 官方文件裡一句輕描淡寫的備註,但漏了就是「頁面能打開但 CSS/JS 全 404」。第一次部署幾乎人人中招。
7.2 PM2 叢集 + 優雅停機
pm2 start bin/www -i max --name beautyresume-api
backend/bin/www 裡處理優雅停機,避免發版時正在處理的請求被硬切斷:
const server = app.listen(port);
function shutdown(signal) {
console.log(`[${signal}] shutting down gracefully...`);
server.close(async () => {
await sequelize.close(); // 關連線池
process.exit(0);
});
// 兜底:15 秒還沒退乾淨就強殺,防止殭屍行程佔埠
setTimeout(() => process.exit(1), 15000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
7.3 Nginx 關鍵設定
# 靜態資源:hash 檔名,強快取一年
location /_next/static/ {
proxy_pass http://127.0.0.1:3001;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# API 反代到 Koa
location /api/ {
proxy_pass http://127.0.0.1:7001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SSR 頁面
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript
application/xml image/svg+xml font/ttf font/woff2;
gzip_min_length 1024;
配套提醒:只有在 Nginx 正確設定了
X-Forwarded-For的前提下,後端才能開TRUST_PROXY=1。否則攻擊者可以偽造該頭繞過 IP 限流——這兩處設定必須成對出現,缺一不可。
八、一些踩坑與經驗總結
8.1 React 19 下的 PDF 渲染坑
@react-pdf/renderer 內部有自己的 reconciler。在 React 19 的嚴格模式下,usePDF 的 updateInstance 可能被呼叫兩次,產生兩個 blob URL。解決方案就是前面提到的 currentUrlRef 令牌校驗 + 在 useEffect cleanup 中 URL.revokeObjectURL() 回收,否則連續編輯十分鐘能吃掉幾百 MB 記憶體。
8.2 Tiptap 不要用 StarterKit
// ❌ 一把梭,體積翻倍
"@tiptap/starter-kit": "^3.20.4"
// ✅ 按需引入,履歷只需要粗體和列表
"@tiptap/extension-bold": "^3.20.4",
"@tiptap/extension-document": "^3.20.4",
"@tiptap/extension-paragraph": "^3.20.4",
"@tiptap/extension-text": "^3.20.4",
"@tiptap/extension-hard-break": "^3.20.4",
"@tiptap/extension-list": "^3.20.4"
StarterKit 打包了 20+ 擴充(標題、程式碼塊、引用、圖片、表格……),履歷場景一個都用不上。按需引入後編輯器 chunk 減小約 40%。
更重要的是:功能少 = 使用者不會做出奇怪的排版。約束本身就是產品設計。
8.3 Redux 細粒度訂閱
履歷資料是深層巢狀大物件,如果每個表單元件都 useSelector(state => state.resume),任何一個欄位變更都會導致全表單重渲染。
// ❌ 任何欄位變化,所有訂閱元件全部重渲染
const resume = useSelector((s) => s.resume);
// ✅ 只訂閱自己關心的切片
const workList = useSelector((s) => s.resume.work.list, shallowEqual);
// ✅ 衍生資料用 createSelector 記憶化
const selectVisibleSections = createSelector(
[(s) => s.resume.sections, (s) => s.resume.sectionOrder],
(sections, order) => order.filter((id) => sections[id]?.visible)
);
8.4 安全清單(血淚版)
| 項 | 做法 |
|---|---|
| 密碼 | bcrypt,cost ≥ 10,永不明文/MD5 |
| 會話 | httpOnly + signed + sameSite=lax + 生產強制 secure |
| CSRF | Origin / Referer / Sec-Fetch-Site 三重校驗 |
| 限流 | 全區 IP 120/min,登入/驗證碼介面單獨收緊 |
| 注入 | 全程 Sequelize 參數化,禁止字串拼 SQL |
| XSS | 富文字入庫前 sanitize-html 白名單過濾 |
| 越權 | 每個資源操作校驗 resource.userId === ctx.state.user.id |
| 日誌 | 敏感欄位雜湊脫敏(LOG_HASH_SECRET)後再落盤 |
| 金鑰 | 啟動時校驗長度 ≥ 32,不合規直接拒絕啟動 |
| 列舉 | 公開資源一律用加密隨機 Slug |
最後一條最容易被忽略:把「設定校驗」放在啟動階段。讓錯誤在部署時暴露,而不是在被攻擊時暴露。
九、寫在最後
回顧整個專案,最大的收穫不是學會了某個 API,而是幾個可遷移的工程判斷:
-
一致性優先於效能。 預覽和匯出共用一套程式碼,看起來是給自己找麻煩(效能壓力全壓在預覽上),但它從架構層面消滅了「所見非所得」這一整類 bug。效能問題可以用工程手段(雙緩衝、防抖)解決,一致性問題只能靠架構保證。
-
無狀態不是銀彈。 JWT 很潮,但當業務需要「可撤銷的登入態」時,Session + 版本號才是正解。選型要看業務本質,不看技術熱度。
-
把安全變成架構約束,而不是開發紀律。 前綴鑑權閘道、啟動時金鑰校驗、build 時 sitemap 生成——讓正確的事情自動發生,比寫十頁開發規範有用。
-
遇到三方庫 bug,
patch-package優於 fork。 補丁進倉庫,升級時自動提醒複查,維護成本幾乎為零。 -
約束是好設計。 Tiptap 只開粗體和列表,使用者反而做不出醜履歷。
相關連結
- 專案線上地址:https://beautyresume.com
- 履歷模板庫:https://beautyresume.com/zh-CN/templates
- 線上編輯器:https://beautyresume.com/zh-CN/resume-builder
如果這篇文章對你有幫助,歡迎點讚收藏。有任何技術問題歡迎在評論區交流,我會逐條回覆。
轉載請註明出處。文中程式碼均來自線上真實專案,可放心參考。