vibecoding.site
← back to blog
约 7 分钟3 标签

Mini Demo:Markdown → 海报,一个不依赖 LLM 的纯前端 demo

用 Canvas + html2canvas 把 Markdown 渲染成可下载的设计稿,字体子集化与渲染顺序是关键。


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。

为什么?三个原因:

  1. 求职作品集需要分层:有的 demo 展示"我能让 AI 跑活"(csv-charts 的 BYOK),有的 demo 展示"我能写朴素工程"(这个 markdown-poster)。两种能力同等重要,招聘官两种都要看
  2. 不绑外部依赖最稳:这个 demo 没有 API key 障碍,没有 CORS 问题,没有费用焦虑,任何人打开就能用
  3. 演示 Canvas + html2canvas 这条路径:很多前端开发者没碰过"DOM → 图片"的链路,把它当成黑魔法。其实工程上很简单,值得把过程写一遍

整个 demo 的工作流

用户在左侧文本框写 Markdown,右侧实时预览渲染后的样子,点"下载"按 PNG 存到本地。中间没有任何后端,也没有 LLM

┌─────────────────┐     ┌────────────────┐     ┌─────────────────┐
│  Markdown 文本   │ ──▶ │  HTML preview  │ ──▶ │  下载 PNG       │
│  (textarea)     │     │  (Markdown→DOM) │     │  (html2canvas) │
└─────────────────┘     └────────────────┘     └─────────────────┘

三步分别面对的工程问题:

  • Markdown → DOM:用什么解析器?要支持哪些语法?
  • 预览:渲染 + 字体 + 排版,样式怎么和"最终 PNG"长得一致?
  • DOM → PNG:html2canvas 的踩坑

第一步:Markdown 解析

很多人这里会引 markedremark + 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 / 衬线 fallback
  • twitter-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 里重新渲染,所有计算样式都要重算。所以两件事必须保证:

  1. 目标 node 必须已挂载并可见(display:none 会失败)
  2. 所有依赖的 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 行,无任何后端依赖。

评论

加载评论中…

留下评论 · 提交后由 PM 审核显示

评论无需注册,审核通过后可见。