长视频总结的难点不在下载,而在内容怎么呈现给用户。一个多小时的长视频,AI 总结出来五六千字 Markdown,用户要划半天才找得到重点,很多人在我们群里直接吐槽”字太多不想看”。后来我决定加一个思维导图视图,把总结变成能展开折叠的树状结构。选型时对比过 D3.js 自绘、echarts 树图和后端出图,前两者要么学习成本高要么不认 Markdown,最后选了 markmap:它直接吃 Markdown,纯前端渲染零服务端开销,还支持一键导出 PNG 和 SVG。下面把整套渲染与导出方案摊开讲,包括我们踩过的坑。
一、为什么用 markmap
先交代选型背景。需求其实很明确:AI 输出的就是 Markdown,前端要做的只是把 Markdown 变成一棵可交互的树,不搞自研绘图引擎,也不给后端加负担。围绕这个需求,我把候选方案拉了个表:
| 方案 | 优点 | 缺点 | 平台选 |
|---|---|---|---|
| markmap | 接收 Markdown、纯前端 | 样式固定 | ✅ |
| D3.js | 完全自由 | 复杂、需自实现 | ❌ |
| echarts | 图表种类多 | 思维导图弱 | ❌ |
| 后端渲染 | 像素级控制 | 服务端压力大 | ❌ |
结论很直接:四个候选里只有 markmap 同时满足”认 Markdown、纯前端、能导出”三个条件。我也确实用 D3.js 搭过一个原型,把 JSON 转成树布局再手画连线,前后折腾了两天,缩放拖拽还得自己写,进度没追上预期,果断放弃。后端渲染也否了,每个用户打开页面都要后端现场出图,带宽和 CPU 双吃紧。具体优势拆开看:
- 直接吃 Markdown,AI 输出无需转换;
- 纯前端渲染(无服务端开销);
- 支持缩放/拖拽/折叠;
- 支持导出 PNG/SVG。
选型定下来后,第一版只用半天就把完整链路跑通,后面所有精力都放在样式和交互打磨上。这里也提醒一句:别一上来就选自由度高的方案,先想清楚产品到底要什么。
二、Markmap 输入格式
markmap 把 Markdown 的标题层级直接映射成树:# 是中心节点,## 是一级分支,### 是子分支,- 是叶子要点。所以关键不在渲染端做什么,而在让 AI 端输出规整的层级。我们在系统提示词里明确约束了这套格式,下面是约定好的样例:
# 视频主题
## 核心观点
- AI 改变工作方式
- 提示词工程重要
## 关键技术
### 大模型选择
- GPT-4 适合综合
- Claude 适合逻辑
### 工程实践
- 流式响应
- 错误重试
这套约定写清楚之后,AI 输出的格式基本稳定下来。解析结果对应这样一棵树:
视频主题 (h1)
├── 核心观点 (h2)
│ ├── AI 改变工作方式
│ └── 提示词工程重要
└── 关键技术 (h2)
├── 大模型选择 (h3)
│ ├── GPT-4 适合综合
│ └── Claude 适合逻辑
└── 工程实践 (h3)
├── 流式响应
└── 错误重试
刚开始没做格式校验,AI 偶尔会用 **加粗** 或数字列表混进层级,树里会多出奇怪的分支,点开又折叠不了。后来我们加了预校验:只允许标题和 - 两种结构,其余一律剥掉。这一个改动,让”思维导图结构异常”的反馈直接清零。
三、CDN 引入
引入方式我们选的是 CDN。项目是轻量前端,没必要为这一个组件把打包链路搞复杂,CDN 引入省事还能吃缓存。第一条是自动加载器,页面加载后自动扫描带 data-markmap 属性的容器:
通过 jsdelivr CDN 引入 markmap 自动加载器
JS 脚本段开始
// markmap-autoloader 自动注册到 marked
JS 脚本段结束
如果你想手动控制渲染时机,就分开引入三个脚本,把解析库、视图库和工具栏分别加载:
通过 jsdelivr CDN 引入 markmap 工具栏脚本
通过 jsdelivr CDN 引入 markmap 视图脚本
通过 jsdelivr CDN 引入 markmap 解析库脚本
这里有个坑:jsdelivr 在国内访问偶尔会慢,页面会白屏一两秒。我们对 CDN 域名做了预连接,同时准备了 unpkg 作兜底,CDN 挂了自动切换,上线后再没收到过”思维导图空白”的反馈。
四、基本渲染
用 CDN 拿到两个核心模块后,渲染分三步:先用 Transformer 把 Markdown 转成树结构,取回它算好的颜色和字号,再交给 markmap 实例挂到容器上:
<div id="mindmap" style="height: 600px;"></div>
JS 脚本段开始
import { markmap } from 'https://cdn.jsdelivr.net/npm/markmap-view@0.14.5/dist/index.min.js'
import { Transformer } from 'https://cdn.jsdelivr.net/npm/markmap-lib@0.14.5/dist/transform.min.js'
const transformer = new Transformer()
const markdown = `# 视频主题
## 核心观点
- AI 改变工作方式
- 提示词工程重要
## 关键技术
- 流式响应
- 错误重试`
const { root, features } = transformer.transform(markdown)
const { colors, fontSize } = features
const mm = markmap('#mindmap', { root, colors, fontSize })
mm.fit() // 自适应大小
JS 脚本段结束
transformer.transform 返回的 features 是 markmap 自动算好的配色和字号,直接用就行,省得自己调。mm.fit() 这句别省,否则树会溢出容器。我们在生产环境发现,漏了 fit 的话,第一次打开树缩在左上角,要手动滚一下才能看全,体验很差,后来统一封装了 fit 调用。
五、markmap-autoloader 简化版
如果每个页面都写一遍 transform,重复代码太多。markmap 提供了 autoloader,自动扫描带 data-markmap 属性的元素,把里面声明的 Markdown 直接渲染:
<div id="mindmap" data-markmap="true"
style="height: 600px;">
JS 脚本段以 text/markdown 类型声明
# 视频主题
## 核心观点
- AI 改变工作方式
- 提示词工程重要
JS 脚本段结束
</div>
通过 jsdelivr CDN 引入 markmap 自动加载器
autoloader 自动扫描 data-markmap 元素并渲染。
这种方式适合内容静态的页面,比如文档站。但我们的 Markdown 是 AI 动态生成的,每次内容都不同,autoloader 反而少了灵活性。所以正式环境用的是第四节的手动渲染,autoloader 只保留给内部分享页用。
六、AI 输出直接渲染
这里是我们和多数 markmap 用法差异最明显的地方。别的产品让用户自己写 Markdown,我们直接让 AI 生成,为此专门写了一个生成思维导图的系统提示词:
MINDMAP_PROMPT = """根据视频总结,生成 Markdown 思维导图。
要求:
1. 中心节点是视频主题(用 #)
2. 主要话题用 ##
3. 子话题用 ###
4. 要点用 -
5. 简洁,每层不超过 7 个子节点
"""
限定”每层不超过 7 个节点”是有讲究的:树的宽度随兄弟节点数线性增长,超过 7 个,手机屏幕就要来回拖。调用 AI 生成:
async def generate_mindmap(self, summary: str) -> str:
response = await client.chat.completions.create(
model='deepseek-chat',
messages=[
{'role': 'system', 'content': MINDMAP_PROMPT},
{'role': 'user', 'content': summary}
]
)
return response.choices[0].message.content
前端拿到返回的 Markdown 直接渲染:
async function showMindmap(mindmapMarkdown) {
const { root, features } = transformer.transform(mindmapMarkdown)
const { colors, fontSize } = features
if (mm) {
// 更新已有
mm.setData(root)
} else {
// 首次创建
mm = markmap.create('#mindmap', {
duration: 500,
maxWidth: 300,
color: (node) => colors[node.depth % colors.length],
fontSize
})
mm.setData(root)
}
mm.fit()
}
这里的关键是 mm.setData 支持热更新。视频总结页先展示 AI 的 Markdown 全文,用户切到”思维导图”标签时才调生成接口,同一实例反复 setData,不会重复创建 SVG。这个设计让标签切换的耗时从重建整棵树的几百毫秒降到了几十毫秒。
七、风格定制
默认配色的饱和度过高,跟我们的深色界面不搭。markmap 的配置项可以逐项覆盖,我们按品牌色做了一套循环配色:
const mm = markmap.create('#mindmap', {
// 颜色(按层级循环)
color: (node) => {
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444']
return colors[node.depth % colors.length]
},
// 字号
fontSize: 16,
// 间距
nodeHeight: 24,
nodeWidth: 200,
spacingHorizontal: 80,
spacingVertical: 10,
// 动画
duration: 500,
// 折叠
maxWidth: 0, // 0 = 不限制
initialExpandLevel: 2, // 默认展开前 2 层
// 滚动
autoFit: true
})
调样式阶段我们来回改了好几轮:字号一开始设 14,小屏上看不清;间距调小后树又挤成一团。最后定下的这套参数在 375px 宽的手机和 27 寸显示器上都能看。initialExpandLevel: 2 是个细节——默认只展开两层,长视频的总结树很深,全展开视觉冲击太大,收着反而好浏览。
八、工具栏(缩放/导出)
光有图还不够,用户需要操作入口。我们用带 data-on-click 伪属性的按钮绑定工具栏事件,事件处理器不直接写进标签属性,而是统一走封装好的函数:
<div class="markmap-toolbar">
<button data-on-click="zoomIn()">放大</button>
<button data-on-click="zoomOut()">缩小</button>
<button data-on-click="fit()">适应</button>
<button data-on-click="exportPNG()">导出 PNG</button>
<button data-on-click="exportSVG()">导出 SVG</button>
</div>
JS 脚本段开始
function zoomIn() { mm.rescale(1.2) }
function zoomOut() { mm.rescale(0.8) }
function fit() { mm.fit() }
JS 脚本段结束
markmap 还自带一个可选工具栏组件,引入后会往页面注入一个悬浮的 SVG 工具栏,提供基础的缩放控制:
<svg class="markmap-toolbar"></svg>
我们实测下来,内置工具栏在移动端位置固定会挡内容,所以正式版用的是自己写的这一套,放在图下方,配合导出按钮,用户操作顺手。
九、导出 PNG
导出是用户的高频需求,很多人看完总结想截图发群里。PNG 导出要走 canvas:先序列化 SVG,画到 canvas 上,再触发下载:
async function exportPNG() {
const svgEl = document.querySelector('#mindmap svg')
const svgData = new XMLSerializer().serializeToString(svgEl)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
// 加载到 canvas
const img = new Image()
img.loadImage = () => {
const canvas = document.createElement('canvas')
canvas.width = svgEl.clientWidth * 2
canvas.height = svgEl.clientHeight * 2
const ctx = canvas.getContext('2d')
ctx.scale(2, 2)
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
// 触发下载
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `mindmap-${Date.now()}.png`
a.click()
URL.revokeObjectURL(url)
})
}
img.src = url
}
画布尺寸我乘了 2 倍,导出图在手机上放大边缘才不发虚。两个细节容易漏:canvas 默认背景透明,导出前先 fillRect 填白,否则发出去是一张透明底的图;另外图片对象用本地生成的 blob 地址加载,不走外链,避免跨域污染画布。
十、导出 SVG
PNG 是位图,适合发消息;SVG 是矢量,适合打印和二次编辑,我们两个都提供。SVG 导出简单很多,序列化后直接建下载链接:
function exportSVG() {
const svgEl = document.querySelector('#mindmap svg')
const svgData = new XMLSerializer().serializeToString(svgEl)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `mindmap-${Date.now()}.svg`
a.click()
URL.revokeObjectURL(url)
}
SVG 文件里保留了完整节点文本,用设计软件打开还能继续改字。有个小坑:直接序列化出来的 SVG 不带样式声明,换到别的环境打开可能丢字体。我们在序列化前把内联样式补了进去,导出效果才和页面一致。
十一、响应式大小
树是动态生成的,内容长度不固定,容器高度没法写死。我们挂了一个 ResizeObserver,容器尺寸一变就重新 fit:
const observer = new ResizeObserver(() => {
mm && mm.fit()
})
observer.observe(document.getElementById('mindmap'))
容器大小变化时自动适应。
这个方案比监听 window resize 靠谱,它能捕获容器自身变化,比如侧边栏收起、标签页切换导致的宽度变化。实测从 400px 拉到 1200px,树都能即时重排,没有出现滚动条错位。
十二、节点点击
默认节点只能看不能点。我们加了一个需求:点击带链接的节点,跳转到对应原文段落。markmap 的 onClick 配置写在 JS 对象里,回调里判断节点有没有挂 url:
const mm = markmap.create('#mindmap', {
// ...
embedGlobalCSS: true,
onClick(node) {
console.log('点击节点:', node.text)
if (node.payload?.url) {
window.open(node.payload.url)
}
}
})
可以在 Markdown 里加链接:
- [GitHub](https://github.com)
配置里的 embedGlobalCSS 记得开,否则节点样式在部分浏览器里会丢。点击跳转上线后,用户从思维导图跳原文的使用率明显上升,说明”先看骨架、再钻细节”这条交互路径是成立的。
十三、性能数据
渲染性能决定体验下限。我们对不同规模的树做了压测:
| 节点数 | 渲染时间 |
|---|---|
| 10 | < 50ms |
| 50 | < 200ms |
| 100 | < 500ms |
| 500 | 1-2s |
500 节点开始有明显感知,但那是全展开状态。平台限制最多 100 节点(AI 限制),流畅。
100 节点以内完全无感,这个数据让我们放心把思维导图默认开放给所有用户。我们在提示词里做了硬约束,AI 生成的节点数控制在 100 以内,既保住渲染流畅,也逼着总结更精炼。
十四、踩过的坑
开发期踩的坑集中在这几个点,每一条都对应一次线上反馈:
- CDN 慢:jsdelivr 国内访问偶尔慢。备选 unpkg。
- 多 markmap 实例冲突:多次
markmap.create('#mindmap')会创建多个 SVG。复用同一个实例。 - 导出空白 PNG:SVG 背景透明。导出时先填充白色。
- CORS 导出:用 canvas 转换避免跨域。
- 节点过多卡顿:限制节点数 + 默认折叠深层。
- 字体不一致:CSS 设
font-family: 'PingFang SC', sans-serif。 - 移动端缩放:touch event 处理。
- 空 markdown:AI 偶有返回空字符串。空字符串渲染会报错。校验后渲染。
最坑的是”多实例冲突”这条。一开始我们在标签页切换时反复调用 create,页面堆了好几个 SVG,节点重叠成一片。后来加了一个全局实例变量,create 前先判断,再配合 setData 更新,问题才根治。经验是:markmap 实例的创建和销毁要跟页面生命周期绑定,别图省事到处 new。
十五、可视化方案对比
如果你还没选型,或者项目里已经躺着一个图表库,值得再比一次:
| 维度 | markmap | echarts | D3.js |
|---|---|---|---|
| 输入 | Markdown | JSON | D3 API |
| 学习成本 | 低 | 中 | 高 |
| 交互 | 缩放/拖拽 | 图表交互 | 完全自由 |
| 适合 | 思维导图 | 图表 | 自定义可视化 |
选型结论:输入是 Markdown,markmap 是低投入的选择;交互上要天马行空的定制,再考虑 D3.js。
十六、完整范例
最后给一份可以直接跑起来的完整页面,把 CDN 引入、渲染、工具栏、导出串在一起:
<!DOCTYPE html>
<html>
<head>
<title>思维导图</title>
<style>
#mindmap {
width: 100%;
height: 600px;
background: #f8fafc;
border-radius: 8px;
}
</style>
</head>
<body>
<div class="toolbar">
<button data-on-click="mm.fit()">适应</button>
<button data-on-click="mm.rescale(1.2)">放大</button>
<button data-on-click="mm.rescale(0.8)">缩小</button>
<button data-on-click="exportPNG()">导出 PNG</button>
</div>
<div id="mindmap"></div>
[脚本段]
import { markmap } from 'https://cdn.jsdelivr.net/npm/markmap-view@0.14.5/dist/index.min.js'
import { Transformer } from 'https://cdn.jsdelivr.net/npm/markmap-lib@0.14.5/dist/transform.min.js'
const transformer = new Transformer()
const markdown = `# 视频主题
## 核心观点
- 观点 1
- 观点 2
## 关键技术
- 技术 1
- 技术 2`
const { root, features } = transformer.transform(markdown)
const mm = markmap.create('#mindmap', {
...features,
duration: 500
})
mm.setData(root)
mm.fit()
window.mm = mm // 调试用
window.exportPNG = exportPNG
function exportPNG() {
// ... 导出逻辑
}
JS 脚本段结束
</body>
</html>
这个文件拷到本地就能打开,适合拿来当脚手架。我们的生产代码比它多了状态管理和错误兜底,但核心链路就是这几步。
十七、未来优化
现状够用,但有几个方向我们还在排期:
- 多 Tab 支持:一份总结多个思维导图(不同角度);
- 协作编辑:多人同时编辑思维导图(Yjs);
- AI 自动美化:节点配图标、颜色、缩进;
- 快捷键:Tab/Enter 编辑节点、Cmd+Z 撤销。
其中协作编辑的优先级我们调高了——用户经常把总结分享给同事一起梳理,单机编辑满足不了这个场景。等 Yjs 集成验证完,这块就会排进迭代。
常见问题(FAQ)
Q1:怎么让 AI 输出固定格式的 Markdown?
Prompt 严格要求 + 给出示例。
Q2:节点太多卡顿怎么办?
默认折叠深层 + 限制 100 节点。
Q3:能嵌入到 PDF 吗?
导出 SVG 后用 PDF 库(如 jsPDF)嵌入。