title: "Mini Demo:Markdown → 海报,一个不依赖 LLM 的纯前端 demo" slug: markdown-poster publishedAt: 2026-05-02 excerpt: 用 Canvas + html2canvas 把 Markdown 渲染成可下载的设计稿,字体子集化与渲染顺序是关键。 tags: [frontend, canvas, demo] readingMin: 7 wordCount: 1280 draft: false
为什么先做这个 demo
/works/markdown-poster 这个 demo 在作品集里有点反直觉:在 2026 年这个时间点,所有 demo 都恨不得"接 LLM"才显得有亮点,我们偏偏放了一个完全不依赖 LLM 的纯前端 demo。
为什么?三个原因:
- 求职作品集需要分层:有的 demo 展示"我能让 AI 跑活"(
csv-charts的 BYOK),有的 demo 展示"我能写朴素工程"(这个 markdown-poster)。两种能力同等重要,招聘官两种都要看 - 不绑外部依赖最稳:这个 demo 没有 API key 障碍,没有 CORS 问题,没有费用焦虑,任何人打开就能用
- 演示 Canvas + html2canvas 这条路径:很多前端开发者没碰过"DOM → 图片"的链路,把它当成黑魔法。其实工程上很简单,值得把过程写一遍
整个 demo 的工作流
用户在左侧文本框写 Markdown,右侧实时预览渲染后的样子,点"下载"按 PNG 存到本地。中间没有任何后端,也没有 LLM。
┌─────────────────┐ ┌────────────────┐ ┌─────────────────┐
│ Markdown 文本 │ ──▶ │ HTML preview │ ──▶ │ 下载 PNG │
│ (textarea) │ │ (Markdown→DOM) │ │ (html2canvas) │
└─────────────────┘ └────────────────┘ └─────────────────┘
三步分别面对的工程问题:
- Markdown → DOM:用什么解析器?要支持哪些语法?
- 预览:渲染 + 字体 + 排版,样式怎么和"最终 PNG"长得一致?
- DOM → PNG:html2canvas 的踩坑
第一步:Markdown 解析
很多人这里会引 marked 或 remark + rehype + react。我们这个 demo 的需求只有 H1/H2/H3、段落、列表、行内 code、链接、加粗,远远不需要全功能的 markdown 解析器。
最终用了一个 ~80 行的极简 line-based parser,核心结构大致是:
type Block =
| { kind: "h1" | "h2" | "h3"; text: string }
| { kind: "p"; text: string }
| { kind: "ul" | "ol"; items: string[] }
| { kind: "blockquote"; text: string }
| { kind: "hr" };
function parse(src: string): Block[] {
const out: Block[] = [];
for (const line of src.split(/\r?\n/)) {
if (/^#\s+/.test(line)) out.push({ kind: "h1", text: line.replace(/^#\s+/, "") });
else if (/^##\s+/.test(line)) out.push({ kind: "h2", text: line.replace(/^##\s+/, "") });
else if (/^###\s+/.test(line)) out.push({ kind: "h3", text: line.replace(/^###\s+/, "") });
else if (/^>\s+/.test(line)) out.push({ kind: "blockquote", text: line.replace(/^>\s+/, "") });
else if (/^---+$/.test(line)) out.push({ kind: "hr" });
else if (/^-\s+/.test(line)) {
const last = out[out.length - 1];
if (last?.kind === "ul") last.items.push(line.replace(/^-\s+/, ""));
else out.push({ kind: "ul", items: [line.replace(/^-\s+/, "")] });
}
// ... 其它
else if (line.trim()) out.push({ kind: "p", text: line });
}
return out;
}行内格式(bold / code / link) 在渲染时用 regex 替换成 HTML,这里就不展开了。
为什么不用 marked:引一个 200KB 的依赖只用 10% 的功能不划算。自己写 80 行还有一个隐藏好处:可以完全控制输出的 HTML 结构,后面 html2canvas 截图时不会出"未知 tag 没样式"的尴尬。
第二步:预览渲染 + 字体
两个模板:
magazine(浅色衬线):背景#fafaf9,标题用 Georgia / 衬线 fallbacktwitter-card(暗色无衬线):背景#0a0a0a,标题用 system-ui sans
CSS 全部 inline,不进 globals.css —— 因为这两个模板的样式是与最终 PNG 1:1 一致的,不能被站点暗色切换或者用户 CSS 影响。
<div
ref={canvasRef}
style={{
width: 640,
padding: 48,
background: template === "magazine" ? "#fafaf9" : "#0a0a0a",
color: template === "magazine" ? "#1a1a1a" : "#fafafa",
fontFamily:
template === "magazine"
? "Georgia, 'Times New Roman', serif"
: "system-ui, -apple-system, sans-serif",
}}
>
{/* render blocks */}
</div>字体选择上没有引下拉让用户切字体,理由是系统 fallback 链已经足够好,引下拉反而拖累 demo 启动速度。
第三步:html2canvas 截图
html2canvas 的核心 API 简洁到一行:
import("html2canvas").then(({ default: html2canvas }) => {
html2canvas(node, { scale: 2 }).then((canvas) => {
const url = canvas.toDataURL("image/png");
triggerDownload(url, "poster.png");
});
});scale: 2 是 DPR 2x 输出,生成的 PNG 在 Retina 屏分享时不糊。640px 预览 × 2 = 1280px PNG,放到 Twitter/微博/小红书都够清晰。
踩坑:动态 import
如果 html2canvas 直接 import 在文件顶部,首屏 bundle 就要带上 ~50KB。我们改成点"下载"时才动态 import:
async function handleDownload() {
const { default: html2canvas } = await import("html2canvas");
const canvas = await html2canvas(node, { scale: 2 });
// ...
}这样首屏 bundle 没多余开销,用户真要下载时才付载入成本。
踩坑:渲染顺序
html2canvas 在内部需要把目标 node 克隆到一个隐藏 iframe 里重新渲染,所有计算样式都要重算。所以两件事必须保证:
- 目标 node 必须已挂载并可见(
display:none会失败) - 所有依赖的 CSS 必须 inline 或全局:第三方 CSS 模块、CSS Variables 都可能丢失
我们因为第二条踩过一次。最初模板用了 var(--brand) 这种站点 token,截图出来 brand 色变成了黑色(html2canvas 拿不到 oklch 解析结果)。修法是把 brand 色 hardcode 成 hex,模板自己定调色板,不依赖站点 token。
移动端的妥协
640px 的预览在移动端会横向溢出。我们没有做"等比缩放预览"这种复杂处理,理由是:
- 真实使用场景是桌面端编辑 → 下载 → 分享到移动端,不是在移动端编辑
- 等比缩放会让小屏用户看到的预览和实际下载的 PNG不一致,反而误导
最终选择:预览区允许横向滚动(overflow-x: auto),保留 640px 真实尺寸。有得有失,但保持了"所见即所得"的工程一致性。
这个 demo 教会的事
回头看,这 ~300 行代码里值得记住的是几条工程纪律:
- 不引用不必要的依赖:8 行 regex 能干的事,不引 marked
- 动态 import 重型库:html2canvas 只在用户点下载时载入
- 保持"所见即所得":预览 = PNG 的 1:1,字体、间距、颜色都不能漂移
- 不用 LLM 不是劣势,是诚实:这个 demo 不需要 AI,就不假装需要
想看代码,在
apps/web/app/works/markdown-poster/page.tsx。整页连 demo 加 UI ~350 行,无任何后端依赖。