从 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
如果这篇文章对你有帮助,欢迎点赞收藏。有任何技术问题欢迎在评论区交流,我会逐条回复。
转载请注明出处。文中代码均来自线上真实项目,可放心参考。