面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
网页内容导出(二):图片导出的 DOM 重建与批注内联

网页内容导出(二):图片导出的 DOM 重建与批注内联

2026年6月10日1,7966分钟
✦AI 生成摘要

modern-screenshot 把 DOM 序列化为 SVG foreignObject 再转 canvas,但批注卡片绝对定位在正文之外,需要手动克隆、重置定位、按 data-rev-id 插回对应锚点。记录图片导出的完整 DOM 重建过程。


目录
  • 1. 从 CSS 声明式到 JavaScript 命令式
  • 2. 选型与基础设施
  • 2.1 为什么是 modern-screenshot
  • 2.2 CSS 变量的读取
  • 2.3 cloneNode 与临时挂载
  • 2.4 加密内容的守卫
  • 3. 核心问题:侧栏批注的内联
  • 3.1 问题的结构
  • 3.2 data-rev-id 桥接
  • 3.3 内联插入与样式重置
  • 3.4 碰撞避让在截图里的消失
  • 4. 扩展到哲思页面
  • 5. 两种导出的处理对比
目录
  • 1. 从 CSS 声明式到 JavaScript 命令式
  • 2. 选型与基础设施
  • 2.1 为什么是 modern-screenshot
  • 2.2 CSS 变量的读取
  • 2.3 cloneNode 与临时挂载
  • 2.4 加密内容的守卫
  • 3. 核心问题:侧栏批注的内联
  • 3.1 问题的结构
  • 3.2 data-rev-id 桥接
  • 3.3 内联插入与样式重置
  • 3.4 碰撞避让在截图里的消失
  • 4. 扩展到哲思页面
  • 5. 两种导出的处理对比
目录
  1. 1. 从 CSS 声明式到 JavaScript 命令式
  2. 2. 选型与基础设施
  3. 2.1 为什么是 modern-screenshot
  4. 2.2 CSS 变量的读取
  5. 2.3 cloneNode 与临时挂载
  6. 2.4 加密内容的守卫
  7. 3. 核心问题:侧栏批注的内联
  8. 3.1 问题的结构
  9. 3.2 data-rev-id 桥接
  10. 3.3 内联插入与样式重置
  11. 3.4 碰撞避让在截图里的消失
  12. 4. 扩展到哲思页面
  13. 5. 两种导出的处理对比
前端Next.js
相关文章
  • 01
    网页内容导出(一):PDF 与 @media print 的分层处理2026/06
  • 02
    Prefetch 的完整图景2026/07
  • 03
    用 View Transitions + Skeleton 消灭页面跳转的割裂感2026/07
← 上一篇网页内容导出(一):PDF 与 @media print 的分层处理
下一篇 →从阅读到知识(二):脱离原书之后

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

1. 从 CSS 声明式到 JavaScript 命令式

上篇记录了 PDF 导出:通过 @media print 的分层 CSS,批注形态、颜色保留、分页控制都是声明式的——写好规则,浏览器执行,JavaScript 只需要调用 window.print()。

图片导出走的是另一条路。modern-screenshot(Chen 注:一个把 DOM 节点转为图片的开源库,原理是将 DOM 序列化为 SVG foreignObject,借助浏览器 SVG 渲染引擎处理样式后再转 canvas 输出 Blob。相比老牌的 html2canvas,对现代 CSS(变量、grid、伪元素)支持更好。) 的工作原理是把 DOM 节点序列化为 SVG <foreignObject>,借助浏览器的 SVG 渲染引擎处理样式,再转 canvas 生成图片。这个过程完全在 JavaScript 里,没有「打印时隐藏/显示」这样的 CSS 钩子可用——内容的组织方式、截图的范围、元素的定位,都必须手动处理。

本站图片导出面临的主要问题是:宽屏下的批注卡片(AnnotationSidebar)绝对定位在正文右侧,和 article 是平级的独立 DOM 节点。直接截图 article 不会带走它们,截出来的图里批注全部消失。


2. 选型与基础设施

2.1 为什么是 modern-screenshot

DOM 转图片的主流方案有两个:

方案原理主要限制
html2canvas逐像素重绘 DOM对复杂 CSS 兼容差,主动维护已停止
modern-screenshotDOM → SVG foreignObject → canvas更忠实还原样式,持续维护

本站大量使用 CSS 变量(主题色、字体都通过 --var 注入),代码块里有 Shiki 生成的内联语法高亮,这两点都是 modern-screenshot 的优势场景。

动态 import 把它排出首屏 bundle,只在触发导出时才加载:

const { domToBlob } = await import("modern-screenshot");

2.2 CSS 变量的读取

截图 wrapper 的背景色不能硬编码,否则深色模式下会得到白底图片:

const bg = getComputedStyle(document.documentElement)
  .getPropertyValue("--bg").trim() || "#ffffff";
wrapper.style.cssText =
  `padding:3rem;background:${bg};display:inline-block;box-sizing:border-box;`;

getComputedStyle(document.documentElement).getPropertyValue("--bg") 读取当前主题下根元素上的自定义属性值,.trim() 处理前导/尾随空白(CSS 自定义属性值会原样保留空白字符),|| "#ffffff" 兜底。

这里只需要手动处理 wrapper 的背景色,因为 wrapper 是新创建的元素,没有继承任何现有样式。domToBlob 内部会遍历整个子树,读取每个节点的计算样式并内联,CSS 变量在这个过程中被正确解析——前提是元素已挂载到文档中。

2.3 cloneNode 与临时挂载

domToBlob 需要元素挂载在文档里才能拿到正确的计算样式。但直接对页面上的元素截图会受到视口和布局约束:正文 article 的宽度受父容器约束,还可能被 overflow: hidden 的祖先裁剪,结果是「页面的一个视口片段」,而不是「这篇文章的完整内容」。

做法是克隆目标元素,把克隆体放进自定义 wrapper,再把 wrapper 临时挂载到 document.body:

const wrapper = document.createElement("div");
wrapper.style.cssText =
  `padding:3rem;background:${bg};display:inline-block;box-sizing:border-box;`;
 
const header = article.closest("div")?.querySelector("header");
if (header) wrapper.appendChild(header.cloneNode(true));
wrapper.appendChild((article as HTMLElement).cloneNode



display:inline-block 让 wrapper 的宽度收缩到内容宽度,不被视口限制。padding:3rem 给图片四周留出边距。scale: 2 输出 2 倍物理像素,在 HiDPI 屏幕上分享不模糊。

removeChild 必须在 await 之后,finally 块里更安全:

try {
  document.body.appendChild(wrapper);
  const blob = await domToBlob(wrapper, { scale: 2 });
  document.body.removeChild(wrapper);
  // 下载...
} finally {
  if (document.body.contains(wrapper)) document.body.removeChild(wrapper);
  setExporting(false);
}

2.4 加密内容的守卫

文章页的导出按钮在 NoteLayout 里始终渲染,不感知是否加密。守卫发生在取节点那一步:

const article = document.querySelector(".prose")?.closest("article")
  ?? document.querySelector(".prose");
if (!article) return;

ProtectedNote 在锁定状态下渲染的是密码输入表单,没有 .prose class,也没有 <article> 元素。querySelector 返回 null,函数直接 return。这和上篇里 PDF 的情况一样——依赖的都是「加密内容不进 DOM」这个服务端保证,而不是前端的显式判断。


3. 核心问题:侧栏批注的内联

3.1 问题的结构

AnnotationSidebar 是一个独立渲染的组件,通过 RevisionContext 接收批注数据,用 position: absolute; left: calc(50% + 28rem + 1.5rem) 定位在页面右侧。它不在 article 里,也不在 header 里——是 NoteLayout 最外层 <div> 的直接子节点:

NoteLayout.tsx(简化结构)
<div style={{ position: "relative" }}>
  <header>...</header>
  <article className="prose">{children}</article>
  <AnnotationSidebar />  {/* 在 article 之外,绝对定位 */}
</div>

直接克隆 article 只会得到正文,批注全部丢失。如果改成克隆整个外层 <div>,侧栏卡片的绝对定位会在新的包含块里错位,结果更难处理。

3.2 data-rev-id 桥接

要把侧栏卡片内联进正文克隆,需要知道哪张卡片对应正文里的哪个锚点。

AnnotationSidebar 的卡片顺序和 RevisionContext.annotations 数组的顺序一致(按垂直位置排序)。但顺序一致不够用——还需要在克隆体里找到对应的锚点节点才能插入。

解法是在 Revision 组件的 <span> 上加一个 data-rev-id 属性:

Revision.tsx
  <span
    ref={spanRef}
+   data-rev-id={hasAnnotation ? id : undefined}
    style={{ position: "relative", display: "inline" }}
  >

id 由 useRevisionId() 生成(基于 React 的 useId()),SSR 和客户端一致,每个 Revision 实例全局唯一。只有 hasAnnotation(有 note 属性)的才写入,和侧栏里只渲染有 note 的卡片保持对应。

有了这个属性,可以建立从「侧栏卡片索引」到「原文锚点」的映射:

const sidebarCards = document.querySelectorAll<HTMLElement>(".annotation-sidebar > div");
const allAnchors = Array.from(document.querySelectorAll<HTMLElement>("[data-rev-id]"));
// 两个数组均按 DOM 中出现顺序,一一对应

querySelectorAll 返回 DOM 顺序,AnnotationSidebar 内部按 DOM 顺序(文章从上到下)渲染——两者顺序一致,索引直接对齐。

3.3 内联插入与样式重置

sidebarCards.forEach((card) => {
  const idx = Array.from(sidebarCards).indexOf(card);
  const anchor = allAnchors[idx];
  if (!anchor) return;
 
  const revId = anchor.getAttribute("data-rev-id");
  if (!revId) return;
 
  const anchorClone













样式重置是必要的:侧栏卡片原本是 position: absolute; left: calc(50% + 28rem + 1.5rem); width: 11rem,克隆进 wrapper 后,left 会相对于 wrapper 的包含块计算,卡片会出现在距左边几百像素的位置,和文章内容完全分离。

重置为 position: relative; display: block 让卡片回到正常文档流,紧跟被批注文字后面另起一行。width: auto 让它自适应截图宽度。insertBefore(inlineCard, anchorClone.nextSibling) 把卡片插在锚点 <span> 后、紧接内容前。

3.4 碰撞避让在截图里的消失

AnnotationSidebar 在屏幕上有一套碰撞避让逻辑——当两个批注的 top 值太接近时,下方的卡片会被推低,防止视觉重叠。这个逻辑在截图里不再需要:内联后的卡片是文档流里的块级元素,浏览器排版引擎自动保证它们不重叠,间距自然。


4. 扩展到哲思页面

哲思页面的每张卡片也有图片导出,但结构比文章简单——没有侧栏批注,不需要 header/article 拼接,直接以整张卡片为截图对象。

用 meta.date 作为卡片的 DOM id(格式 YYYY-MM-DD,每张唯一):

ReflectionCard.tsx
const handleExportImage = async () => {
  const { domToBlob } = await import("modern-screenshot");
  const card = document.getElementById(cardId);  // cardId = meta.date
  if (!card) return;
 
  const wrapper = document.createElement("div");
  const bg















URL.createObjectURL 创建临时对象 URL,a.click() 触发浏览器下载,revokeObjectURL 立即释放——不释放的话这个 URL 会在页面关闭前一直持有 Blob 内存。

加密处理和文章页一样:PrintBar(含两个导出按钮)只在 !isEncrypted 或 isUnlocked 的渲染分支里出现,锁定时按钮根本不进 DOM,无需额外判断。


5. 两种导出的处理对比

回顾两篇的内容,PDF 和图片面对同样的内容结构,但每个问题的解法都不同:

导出 PDF导出图片
批注呈现.revision-print-note 预先写入 DOM,CSS 控制显隐侧栏卡片 clone 后重置定位、按 data-rev-id 内联插入
颜色处理@media print 固定 hex 值 + print-color-adjust: exactgetComputedStyle 读 CSS 变量,domToBlob 内联计算样式
布局控制CSS 声明式(display: none、page-break-*)JavaScript 命令式(cloneNode、临时挂载、样式覆盖)
边距/尺寸@page { margin: 18mm 20mm 20mm }wrapper padding: 3rem、scale: 2
代码高亮var(--shiki-light) 覆盖 token 颜色domToBlob 序列化内联样式时自动携带
加密守卫内容不进服务端 HTML,打印只得到表单 返回 null,直接 return

PDF 的优势是浏览器对分页、字体嵌入、可访问性的原生支持;图片的优势是可以在 JavaScript 层面任意重组 DOM 内容,把分散在页面各处的元素合并进同一张图——这是 @media print 做不到的。

(
true
));
document.body.appendChild(wrapper);
const blob = await domToBlob(wrapper, { scale: 2 });
document.body.removeChild(wrapper);
=
articleClone.
querySelector
(
`[data-rev-id="${
revId
}"]`
);
if (!anchorClone) return;
const inlineCard = card.cloneNode(true) as HTMLElement;
// 重置绝对定位为文档流
inlineCard.style.position = "relative";
inlineCard.style.left = "0";
inlineCard.style.top = "0";
inlineCard.style.width = "auto";
inlineCard.style.marginTop = "0.5rem";
inlineCard.style.display = "block";
anchorClone.parentNode?.insertBefore(inlineCard, anchorClone.nextSibling);
});
=
getComputedStyle
(document.documentElement)
.getPropertyValue("--bg").trim() || "#ffffff";
wrapper.style.cssText =
`padding:3rem;background:${bg};display:inline-block;box-sizing:border-box;`;
wrapper.appendChild(card.cloneNode(true));
document.body.appendChild(wrapper);
const blob = await domToBlob(wrapper, { scale: 2 });
document.body.removeChild(wrapper);
const url = URL.createObjectURL(blob!);
const a = document.createElement("a");
a.href = url;
a.download = `${title}.png`;
a.click();
URL.revokeObjectURL(url);
};
querySelector(".prose")