前言
大模型能吐出结构化 JSON,前端能画 ECharts,Word 能装图片------这三件事分开都不难,串成一条流水线就全是坑。我在一个专利导航系统里把这条链路走通了:用户描述需求,大模型返回六组统计数据,前端渲染成图表,最后一键导出一份图文并茂的 Word 报告。
过程中踩的坑比写的代码还多:图表一张都渲染不出来但控制台不报错 、图片插进 Word 只显示左边三分之一 、收起侧边栏后图表右侧留一片空白 、章节标题在 Word 里被套上一圈厚边框。这篇把完整链路拆开讲,包括每个坑的现场、根因和解法。读完你能照着搭出同样的流程,并且避开我踩过的这些。
技术栈:Vue 3.4 + ECharts 5.5 / 6.x + Element Plus,导出走纯前端方案,零额外依赖。

首先,看下系统页面及代码实现的前端效果图:

然后,以下是导出的图文并茂 Word 文档的部分效果展示:


一、场景拆解与链路设计
1.1 一句自然语言到一份图文报告
先说清楚要做什么。用户在对话框里写一段技术描述,大模型抽取要素、生成检索式,后端并行调度几个服务做统计分析,返回一坨 JSON。前端要把这坨 JSON 变成两样东西:页面上可交互的报告 ,和一份可下载的 Word 文档。
后端返回的核心是六组统计数据,结构完全同构:
json
{
"trendAnalysis": [
{ "analysisCol": "appDateYear", "analysisName": "申请年", "analysisTotal": 42,
"list": [{ "key": "2020", "value": 170724 }, { "key": "2023", "value": 168044 }] }
],
"applicantAnalysis": [{ "analysisCol": "applicantName", "list": [...] }],
"inventorAnalysis": [{ "analysisCol": "inventorName", "list": [...] }],
"technicalClassAnalysis": [{ "analysisCol": "ipcSection", "list": [...] }],
"chinaSpecialTopicAnalysis":[{ "analysisCol": "patType", "list": [...] }],
"regionalAnalysis": [{ "analysisCol": "pubCountryCode", "list": [...] }]
}
六个字段长得一模一样,这是后面能用一个构建器覆盖所有图表的前提。看到这种同构结构,第一反应就该是抽象成通用适配器,而不是写六份图表配置。
1.2 三段式分层与职责边界
整条链路我拆成三段,各自职责明确、互不越界,如图1所示。

图1 大模型数据到图文报告的三段式链路
数据层 只做一件事:把大模型返回的原始 JSON 洗成结构稳定的领域模型。排序、去重、拆分、码值映射全在这里做完,出口的数据保证组件可以直接消费,不需要再判断 null、不需要再 JSON.parse。
适配层把领域模型翻译成 ECharts option。这一层不碰 DOM,输出的是纯配置对象,谁来渲染它不关心。
渲染与导出层分两条通道。页面通道要自适应容器宽度,导出通道要固定画布尺寸------这两个需求天然冲突,所以必须分开走,共用同一份 option 但各自初始化实例。这个设计后面第五章会展开讲,它解决了导出图片尺寸失控的问题。
1.3 为什么配置必须与渲染载体解耦
很多人写 ECharts 会把 echarts.init() 和 setOption() 写在一起,配置对象直接内联在初始化代码里。功能上没问题,但只要出现「同一张图要在两个地方以不同尺寸渲染」的需求,就会被迫复制一份配置。
把 option 提取成纯函数的返回值------buildTrendOption(data) 进去数据、出来配置,不产生任何副作用------页面渲染和离屏导出就能共用。这是后面能实现导出通道的关键,不然导出逻辑要把六份图表配置重写一遍。
二、大模型返回数据的归一化
2.1 六类必然遇到的脏数据
大模型链路返回的数据和传统后端接口很不一样。传统接口的字段名、类型、顺序都由代码写死,稳定可预期;而大模型链路中间经过了模型生成、服务聚合、数据库落表几道工序,每一道都可能引入不一致。我遇到的六类问题如图2所示。

图2 大模型返回数据的六类典型问题
逐个说说现场。
顺序不保证 最隐蔽。趋势数据的 list 是按 value 倒序返回的,也就是专利最多的年份排第一。直接丢给折线图,X 轴就成了「2020、2023、2021、2022、2019......」,曲线来回折返,看着像心电图。这个问题不会报错,只会让图表变得莫名其妙。
单字段混装两类内容 最坑。dataSet 字段前半段是反斜杠分隔的数据集名称,\n\n 之后跟着三千多字的政策原文,里面还夹着 PDF 转文本残留的页码标记 --- 3 ---。这显然是 RAG 检索结果和生成结果被拼在了一起。直接渲染就是一整屏乱码般的长文。
嵌套 JSON 字符串 在落库接口里出现。图表数据存在 chartDataJson 字段中,值是一个 JSON 字符串而不是对象,取 list 永远是 undefined。而且同一份数据在实时接口叫 chartData、在历史接口叫 chartDataJson,字段名都不统一。
2.2 归一化层放在哪一层
我把归一化放在 API 层 ,也就是 src/api/main/index.js 里,紧贴请求响应,而不是放在组件里。
理由有三个。第一,同一份数据可能被多个组件消费,放组件里就要洗好几遍。第二,实时接口和历史接口返回结构不同,但业务含义一样,在 API 层归一化后,页面代码完全不用区分数据来源。第三,脏数据处理逻辑很琐碎,塞进组件会让组件迅速膨胀------我这个页面本来就有 3000 行,再往里塞就没法维护了。
javascript
// src/api/main/index.js
export function generateReport({ sessionId, exp, ...params }) {
return post(`/api/chat/green/carbon/net/report/${sessionId}`, params, {
timeout: 300000 // 后端并行调度四路服务,耗时较长
}).then((res) => {
if (res.code === 200 && res.data) return normalizeReport(res.data) // ← 出口即干净
return Promise.reject(buildReportError(res))
})
}
2.3 关键实现:排序、去重、拆分、映射
归一化函数本身没什么魔法,就是一堆纯函数的组合。挑几个有代表性的说。
趋势数据按年份升序重排 。注意 key 是字符串,用 localeCompare 而不是减法:
javascript
function sortTrendAnalysis(list) {
if (!Array.isArray(list)) return []
return list.map((group) => ({
...group,
// 原始按 value 倒序返回,画折线图前必须按年份(key)升序重排
list: [...(group.list || [])].sort((a, b) =>
String(a.key).localeCompare(String(b.key)))
}))
}
按业务主键去重。后端返回的高价值专利列表里,同一个公开号出现过两次,内容完全相同。写成通用工具,支持字段名或取值函数两种传参:
javascript
function dedupeBy(list, keyOrFn) {
if (!Array.isArray(list)) return []
const fn = typeof keyOrFn === 'function' ? keyOrFn : (item) => item?.[keyOrFn]
const seen = new Set()
const result = []
list.forEach((item) => {
const k = fn(item)
if (k == null || seen.has(k)) return
seen.add(k)
result.push(item)
})
return result
}
// 单字段去重
dedupeBy(raw.highValuePatents, 'appNumber')
// 复合键去重
dedupeBy(raw.ftoConflictText, (i) => `${i.patentNumber}|${i.conflictFeature}`)
混装字段拆分。以首个空行为界切开,前半段按反斜杠 split 成列表,后半段清洗掉 PDF 页码残留后作为「知识库来源依据」折叠展示:
javascript
export function parseDataSet(str) {
if (!str || typeof str !== 'string') return { datasets: [], source: '' }
const splitIndex = str.search(/\r?\n\s*\r?\n/) // 首个空行
const head = splitIndex === -1 ? str : str.slice(0, splitIndex)
const tail = splitIndex === -1 ? '' : str.slice(splitIndex)
const datasets = head.split(/\\+/).map(s => s.trim()).filter(Boolean)
return { datasets, source: cleanSourceText(tail) }
}
function cleanSourceText(text) {
return text
.replace(/^[\s\t]*[---\---]\s*\d+\s*[---\---][\s\t]*$/gm, '') // 去 PDF 页码 --- 3 ---
.replace(/\t+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
码值映射用常量表,顺手也给数据字典页面复用:
javascript
export const PAT_TYPE_MAP = {
1: '发明专利', 2: '实用新型', 3: '外观专利',
4: 'PCT外观', 8: 'PCT发明', 9: 'PCT实用新型'
}
export const IPC_SECTION_MAP = {
A: '人类生活必需', B: '作业、运输', C: '化学、冶金', D: '纺织、造纸',
E: '固定建筑物', F: '机械工程', G: '物理', H: '电学'
}
还有一个容易忽略的:专利摘要里带着 <Sup>2</Sup> 这类上下标标签 。原文是「平方米质量在 400~1500g/m<Sup>2</Sup>」,直接渲染页面上就会露出标签文本。转成上标字符再剥掉残余标签:
javascript
function cleanAbstract(abs = '') {
const supMap = { '0':'⁰','1':'¹','2':'²','3':'³','4':'⁴',
'5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹' }
return abs
.replace(/<\s*sup\s*>(.*?)<\s*\/\s*sup\s*>/gi, (_, t) =>
String(t).split('').map(c => supMap[c] || c).join(''))
.replace(/<\s*sub\s*>(.*?)<\s*\/\s*sub\s*>/gi, '$1')
.replace(/<[^>]+>/g, '')
.trim()
}
三、图表适配器:六张图一个构建器
3.1 同构数据的通用构建思路
六个字段结构相同,图表类型却不同:趋势用折线、申请人和发明人用横向条形、IPC 用玫瑰图、专利类型用环形图、区域用柱状图。共性在数据形态,差异在呈现方式。
我的做法是:按图表类型抽通用构建器,按业务字段做薄封装。横向条形图这一类的构建器长这样:
javascript
export function buildRankBarOption(group, options = {}) {
if (isEmptyGroup(group)) return null
const { title, subtext = '', topN = 10, color = '#4CAF50',
filterAnonymous = false } = options
let list = group.list || []
// 发明人榜里「不公告发明人」「请求不公布姓名」常年霸榜,得过滤掉
if (filterAnonymous) {
list = list.filter(i => !/请求不公布|不公布姓名|未公开/.test(String(i.key)))
}
list = list.slice(0, topN)
if (!list.length) return null
const reversed = [...list].reverse() // 横向条形图需倒序,最大值才在顶部
return {
title: { text: title, subtext, left: 'center' },
tooltip: {
trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: (params) => {
const p = params[0]
const full = reversed[p.dataIndex]?.key ?? p.name // 用全称,不用截断后的
return `<div style="font-weight:600">${full}</div>数量:<b>${p.value}</b> 件`
}
},
grid: { left: 130, right: 60, top: 70, bottom: 24 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: reversed.map(i => truncate(i.key, 14)) },
series: [{
type: 'bar',
data: reversed.map(i => i.value),
barMaxWidth: 18,
itemStyle: { borderRadius: [0, 6, 6, 0] },
label: { show: true, position: 'right', formatter: '{c}' }
}]
}
}
// 薄封装:申请人
export function buildApplicantOption(analysis, topN = 10) {
const group = pickGroup(analysis, 'applicantName') || analysis?.[0]
return buildRankBarOption(group, { title: '主要竞争主体分布', topN, color: '#4CAF50' })
}
// 薄封装:发明人(多一个匿名过滤)
export function buildInventorOption(analysis, topN = 10) {
const group = pickGroup(analysis, 'inventorName') || analysis?.[0]
return buildRankBarOption(group, { title: '核心发明人分布', topN,
color: '#26A69A', filterAnonymous: true })
}
Y 轴标签截断但 tooltip 显示全称 这个细节值得注意。申请人名称动辄「中国联合网络通信集团有限公司」十几个字,不截断会把 grid 挤没;但截断后用户又看不全,所以 tooltip 里要从原始数据取全称,而不是从 params.name 拿(那是截断过的)。
3.2 空数据降级与统一出口
有个场景很典型:区域分析字段只返回了一条数据 { key: 'CN', value: 1658000 }。给单条数据画柱状图,出来就是孤零零一根柱子,非常难看。
处理办法是让构建器返回一个带降级标记的结构,交给页面决定怎么展示:
javascript
export function buildRegionalOption(regionalAnalysis) {
const group = pickGroup(regionalAnalysis, 'pubCountryCode') || regionalAnalysis?.[0]
if (isEmptyGroup(group)) return null
const items = group.list.map(i => ({
code: i.key, name: COUNTRY_MAP[i.key] || i.key, value: i.value
}))
// 单一区域画图没有意义,降级为指标卡
if (items.length < 2) return { degraded: true, items }
return { degraded: false, option: { /* 正常柱状图配置 */ } }
}
页面侧根据 degraded 分支渲染,一个渲染成图表,一个渲染成大数字指标卡。这比画一根孤柱好看得多,也比直接隐藏该模块更诚实。
最后给一个统一出口,页面拿到数组直接 v-for:
javascript
export function buildAllChartOptions(report = {}) {
const regional = buildRegionalOption(report.regionalAnalysis)
const charts = [
{ key: 'trend', title: '专利申请与公开趋势', option: buildTrendOption(report.trendAnalysis) },
{ key: 'applicant', title: '主要竞争主体分布', option: buildApplicantOption(report.applicantAnalysis) },
{ key: 'ipc', title: 'IPC 技术分类构成', option: buildIpcClassOption(report.technicalClassAnalysis) },
{ key: 'patType', title: '专利类型构成', option: buildPatTypeOption(report.chinaSpecialTopicAnalysis) },
{ key: 'inventor', title: '核心发明人分布', option: buildInventorOption(report.inventorAnalysis) },
{ key: 'regional', title: '公开国家 / 地区分布',
option: regional && !regional.degraded ? regional.option : null,
degraded: !!regional?.degraded, items: regional?.items || [] }
]
// 过滤掉既无 option 又非降级展示的空图表
return charts.filter(c => c.option || (c.degraded && c.items.length))
}
3.3 按需引入与主题常量
ECharts 5 之后按需引入的写法变了,全量 import * as echarts from 'echarts' 会把包体积拉大一大截。实际只用到三种图表类型的话:
javascript
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, GridComponent,
LegendComponent, DataZoomComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent,
GridComponent, LegendComponent, DataZoomComponent, CanvasRenderer])
export { echarts }
注意 echarts.graphic 在按需引入下依然可用 (渐变色要用到 LinearGradient),但 DataZoomComponent 如果不引入,配置里写了 dataZoom 是静默失效的,不报错------这个我排查了十几分钟才反应过来。
配色、字号、坐标轴样式抽成模块级常量,六张图复用同一份,保证风格统一:
javascript
const TEXT_DARK = '#2C3E2F'
const AXIS_LINE = 'rgba(76, 175, 80, 0.2)'
const SPLIT_LINE = 'rgba(76, 175, 80, 0.08)'
const BASE_TOOLTIP = {
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderColor: 'rgba(76, 175, 80, 0.25)',
padding: [10, 14],
extraCssText: 'box-shadow: 0 4px 16px rgba(76,175,80,0.15); border-radius: 8px;'
}
四、页面渲染的两个时序陷阱
4.1 一个 finally 引发的全空白
这个坑我调了快一个小时。现象是:数据拿到了、chartList 里六个 option 都在、renderCharts() 也执行了,但页面上一张图都没有,控制台干干净净没有任何报错。
最初的代码长这样:
javascript
// ✗ 错误写法
async function goToStep3() {
reporting.value = true
try {
const data = await generateReport({ ... })
report.value = data
chartList.value = buildAllChartOptions(data)
await nextTick()
renderCharts() // ← 此刻 reporting 仍然是 true
} catch (e) {
handleReportError(e)
} finally {
reporting.value = false // ← 太晚了
}
}
问题出在模板结构上。报告区域是这么写的:
html
<div v-if="reporting"> 思考动画 </div>
<div v-else> 报告主体 + 六个图表容器 </div>
renderCharts() 执行时 reporting 还是 true,走的是 v-if 分支,报告主体连同图表容器整棵子树根本没挂载到 DOM 。chartRefs 全是空的,六次循环全部命中 if (!el) return,静默跳过。finally 里才把 reporting 置 false,DOM 这时候才开始渲染------但渲染函数已经跑完了。
正确的时序是把 loading 结束提到 nextTick 之前,如图3所示。

图3 图表渲染的时序陷阱与两种时序对比
javascript
// ✓ 正确写法
report.value = data
chartList.value = buildAllChartOptions(data)
// 关键:必须先结束 loading,报告主体(含图表容器)才会进入 DOM
reporting.value = false
stopTimers()
await nextTick()
renderCharts()
教训是:v-if 控制的子树里如果有需要手动初始化的 DOM 节点,就必须确认渲染函数执行时该分支是激活的。这类问题不会抛异常,只会静默失败,比报错难查得多。
4.2 容器宽高为 0 的静默失败
还有一种更隐蔽的情况:容器确实在 DOM 里,但此刻宽高是 0。过渡动画没结束、父级 display:none 刚解除、CSS Grid 还没完成布局,都会出现这种状态。
echarts.init() 拿到 0×0 的容器不会报错,会正常返回一个实例,只是画布是空的。后续即使数据变化也不会自动恢复。加个显式判断和重试:
javascript
let chartRetry = 0
function renderCharts() {
let pending = 0
chartList.value.forEach((c) => {
if (!c.option) return
const el = chartRefs[c.key]
if (!el) { pending++; return }
// 容器尚未完成布局(宽高为 0)时 init 会得到空图,稍后重试
if (!el.clientWidth || !el.clientHeight) { pending++; return }
if (chartInstances[c.key]) chartInstances[c.key].dispose()
const inst = echarts.init(el)
inst.setOption(c.option)
chartInstances[c.key] = inst
observeChartBox(el)
})
// 仍有未渲染成功的 → 下一帧重试,最多 5 次,避免死循环
if (pending > 0 && chartRetry < 5) {
chartRetry++
requestAnimationFrame(() => setTimeout(renderCharts, 120))
} else {
chartRetry = 0
}
}
重试次数一定要设上限。我最早写的是无限重试,结果某次接口返回空数据、容器高度确实是 0,直接把主线程占满,页面卡死。
4.3 ResizeObserver 处理容器级尺寸变化
图表渲染出来之后,新问题来了:收起左侧历史面板,图表右侧留下一片空白。
原因很直白------我只监听了 window.resize。但折叠侧边栏、收起系统主菜单、展开某个面板,这些操作根本不会触发 window 的 resize 事件,而图表容器的宽度实实在在变了。ECharts 不重绘,画布就还是旧尺寸。
解法是用 ResizeObserver 直接盯容器本身:
javascript
let chartResizeObserver = null
let resizeRaf = null
function observeChartBox(el) {
if (!el || typeof ResizeObserver === 'undefined') return
if (!chartResizeObserver) {
chartResizeObserver = new ResizeObserver(() => {
// 合并同一帧内的多次回调,避免 ResizeObserver loop 警告
if (resizeRaf) cancelAnimationFrame(resizeRaf)
resizeRaf = requestAnimationFrame(() => {
resizeRaf = null
resizeCharts()
})
})
}
chartResizeObserver.observe(el)
}
function resizeCharts() {
Object.values(chartInstances).forEach((inst) => {
if (inst && !inst.isDisposed()) inst.resize()
})
}
// 组件卸载时务必断开,否则内存泄漏
function disposeCharts() {
if (resizeRaf) { cancelAnimationFrame(resizeRaf); resizeRaf = null }
if (chartResizeObserver) { chartResizeObserver.disconnect(); chartResizeObserver = null }
Object.keys(chartInstances).forEach((k) => {
if (chartInstances[k] && !chartInstances[k].isDisposed()) chartInstances[k].dispose()
delete chartInstances[k]
})
}
回调里的 requestAnimationFrame 合帧不是可选项 。侧边栏折叠有 0.3 秒 CSS 过渡,过渡期间 ResizeObserver 会连续触发几十次回调,每次都调 resize() 会明显卡顿,浏览器还会抛 ResizeObserver loop completed with undelivered notifications 警告。合帧之后一帧最多执行一次。
另外过渡结束后再补一次校正,因为最后一帧的尺寸可能还没稳定:
javascript
watch(sidebarCollapsed, () => {
nextTick(() => resizeCharts())
setTimeout(resizeCharts, 340) // CSS 过渡 0.3s,留 40ms 余量
})
五、图表转图片:离屏固定尺寸渲染
5.1 getDataURL 的三个前置条件
ECharts 实例导出图片就一个 API:
javascript
const base64 = inst.getDataURL({
type: 'png',
pixelRatio: 2, // 超采样倍率
backgroundColor: '#ffffff' // 必须显式指定,默认透明
})
看着简单,但有三个前置条件容易忽略。
必须用 Canvas 渲染器 。如果初始化时指定了 renderer: 'svg',getDataURL 返回的是 SVG 的 data URI,塞进 Word 是显示不出来的。
背景色必须显式给。不指定的话导出的 PNG 是透明底,插进 Word 看起来还行,但如果用户把文档背景设成深色,图表的深色文字就糊在一起了。
图表必须已经渲染完成。动画没跑完就导出,会截到中间帧------柱子只长了一半那种。导出前把动画关掉最稳妥。
5.2 为什么不能直接抓页面上的实例
最直觉的做法是遍历页面上已有的实例挨个 getDataURL。我一开始就是这么写的,结果导出的文档里图片大小完全失控。
根源在于页面上的图表宽度是自适应的 。同一张图,1920 屏幕下容器可能 700px,通栏的趋势图能到 1400px;用户折叠了侧边栏又会变宽。乘上 pixelRatio: 2,导出的 PNG 固有像素从 1400px 到 2800px 不等。每次导出的图片尺寸都不一样,长宽比也不一样,文档里六张图大小参差不齐。
更糟的是,如果某张图当前被折叠面板藏起来了,或者滚动到了视口外面,抓出来可能是空白。
5.3 离屏固定尺寸渲染实现
解法是导出时不碰页面实例,另起一个脱离文档流的容器,按固定尺寸重新渲染一遍:
javascript
/** 导出用固定画布尺寸(CSS px),保证六张图比例统一 */
const EXPORT_CHART_SIZE = {
trend: { w: 1080, h: 400 }, // 趋势图信息密度高,给宽一点
applicant: { w: 960, h: 430 },
inventor: { w: 960, h: 430 },
ipc: { w: 960, h: 400 },
patType: { w: 960, h: 400 },
regional: { w: 960, h: 380 },
default: { w: 960, h: 400 }
}
const EXPORT_PIXEL_RATIO = 2
const DOC_IMG_WIDTH = 600 // 文档中的显示宽度,下一章解释这个数字
function renderChartsForExport() {
const images = {}
const holder = document.createElement('div')
// 脱离文档流:不能用 display:none,那样容器宽高会是 0
holder.style.cssText =
'position:fixed;left:-99999px;top:0;opacity:0;pointer-events:none;z-index:-1;'
document.body.appendChild(holder)
try {
chartList.value.forEach((c) => {
if (!c.option) return
const size = EXPORT_CHART_SIZE[c.key] || EXPORT_CHART_SIZE.default
const box = document.createElement('div')
box.style.cssText = `width:${size.w}px;height:${size.h}px;background:#ffffff;`
holder.appendChild(box)
let inst = null
try {
inst = echarts.init(box, null, { width: size.w, height: size.h })
// 导出版去掉缩放滑块与动画:静态图上滑块无意义且占位,
// 动画会导致截图不完整
const opt = { ...c.option, dataZoom: undefined, animation: false }
inst.setOption(opt, true)
const src = inst.getDataURL({ type: 'png',
pixelRatio: EXPORT_PIXEL_RATIO,
backgroundColor: '#ffffff' })
if (src) {
images[c.key] = {
src,
w: DOC_IMG_WIDTH,
h: Math.round(DOC_IMG_WIDTH * size.h / size.w) // 按画布比例算高度
}
}
} catch (e) {
console.warn('[导出] 图表渲染失败:', c.key, e) // 单图失败不阻断整份文档
} finally {
if (inst && !inst.isDisposed()) inst.dispose() // 必须释放
}
})
} finally {
document.body.removeChild(holder)
}
return images
}
几个要点。离屏容器不能用 display:none ,那样子元素的 clientWidth 是 0,回到了 4.2 节那个坑。用 position:fixed; left:-99999px 挪出视口,元素依然参与布局。
echarts.init 的第三个参数显式指定宽高,不依赖容器计算,更稳妥。
每个实例用完必须 dispose()。六张图不释放的话,每次导出都会泄漏六个 canvas 和对应的事件监听,用户连点几次导出内存就上去了。
六、生成图文并茂的 Word 文档
6.1 三种方案的取舍
前端生成 Word 主要有三条路,各有适用场景,如图4所示。

图4 三种导出方案对比与 Word 专有属性速查
我最终选了 HTML + Blob 这条路,理由很实际:这是个验收阶段的项目,需要快速出效果;零依赖意味着不用担心构建配置和包体积;而且报告内容结构相对固定,用 HTML 描述比用 docx 库逐段构建段落树快得多。
如果是长期维护的正式产品,我会选 docxtemplater + 模板文件------设计同事可以直接在 Word 里改模板,前端只负责填数据,分工更清晰。
6.2 HTML + Blob 完整实现
核心原理是:Word 能直接打开 HTML 文件并按 HTML 的样式渲染 。把文件后缀改成 .doc、MIME 类型设成 application/msword,双击就会用 Word 打开。
javascript
async function handleDownloadReport() {
downloading.value = true
try {
await nextTick()
const images = renderChartsForExport() // 先拿到六张图的 base64
const html = buildReportHtml(report.value, images)
// '\ufeff' 是 BOM 头,不加中文会整篇乱码
const blob = new Blob(['\ufeff', html], { type: 'application/msword' })
const ts = formatDate(new Date(), 'YYYYMMDD')
const filename = `${report.value.companyName || '企业'}_导航报告_${ts}.doc`
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url) // 必须释放,否则内存不回收
ElMessage.success('报告已开始下载')
} catch (e) {
ElMessage.error('报告导出失败:' + (e.message || '未知错误'))
} finally {
downloading.value = false
}
}
BOM 头这个坑我第一次做导出时踩过 。不加 \ufeff,Word 会按 GBK 猜测编码,整篇中文变成乱码,而且看不出任何规律,非常迷惑。
HTML 模板部分,图片用 base64 内联,不需要额外的图片文件:
javascript
function buildReportHtml(r, images) {
return `<!DOCTYPE html>
<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 2.2cm 2.4cm; mso-page-orientation: portrait; }
body { font-family: '宋体', SimSun, serif; line-height: 1.75; font-size: 12pt; }
.chapter { font-size: 15pt; font-weight: bold; color: #2E7D32;
font-family: '黑体', SimHei; margin: 26px 0 12px;
padding: 7px 12px; background: #F1F8E9;
border-left: 4px solid #4CAF50;
page-break-after: avoid; /* 标题不落在页尾 */
mso-outline-level: 1; } /* 导航窗格一级 */
.sec { font-size: 13pt; font-weight: bold; color: #2E7D32;
margin: 18px 0 8px; page-break-after: avoid;
mso-outline-level: 2; } /* 导航窗格二级 */
.chart-wrap { margin: 16px 0 22px; text-align: center;
page-break-inside: avoid; } /* 图与图题不被分页截断 */
table { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
td, th { border: 1px solid #C8E6C9; padding: 6px 10px; }
</style>
</head>
<body>
<h1>企业专属专利与数据知识产权双轨导航报告</h1>
<div class="chapter">第一章 创新需求与技术全景解析</div>
<div class="sec">一、宏观技术演进态势</div>
<p>在 ${esc(r.technologyField)} 领域,全球专利申请呈现
<b>${esc(r.trend)}</b> 趋势......</p>
${imgTag(images.trend, '图1 专利申请与公开趋势')}
${imgTag(images.ipc, '图2 IPC 技术分类构成')}
...
</body>
</html>`
}
6.3 Word 对图片尺寸的处理规则
这是整个链路里最反直觉的一环。第一版导出的文档里,图片全都超出版心,只能看到左边三分之一左右。
我最初的写法是给图片加 CSS 限宽:
html
<img src="data:image/png;base64,..." style="width:100%; max-width:620px;" />
在浏览器里这样写完全没问题。但 Word 的 HTML 导入器不解析 img 上的 width 和 max-width 样式声明,这条 CSS 被整个丢弃,回退到按图片固有像素以 96dpi 换算实际尺寸。
算一笔账就明白了。A4 纸宽 21cm,左右边距各 2.4cm,版心可用宽度 16.2cm,约合 612 像素 (96dpi 下 1 英寸 = 96px,1 英寸 = 2.54cm)。而通栏趋势图容器 1400 CSS px 乘以 pixelRatio: 2,固有像素是 2800px,换算过来是 74cm------版心只有 16.2cm,能看到的就只有 22%。真实比例如图5所示。

图5 Word 中图片超宽的真实比例对比
解法是改用 HTML 原生的 width / height 属性。这是属性不是样式,Word 优先采信:
javascript
/**
* Word 的 HTML 导入会忽略 img 上的 CSS width / max-width,
* 仅按图片固有像素以 96dpi 换算,不加原生属性就会撑破版心
*/
function imgTag(img, title) {
if (!img || !img.src) return ''
return `<div class="chart-wrap">
<div class="chart-title">${esc(title)}</div>
<img src="${img.src}" width="${img.w}" height="${img.h}"
style="width:${img.w}px;height:${img.h}px;display:block;margin:0 auto;" />
</div>`
}
width="600" 加上之后,Word 会把固有 2160px 的图缩放到 600px 显示位。固有像素保留反而是好事------相当于 3.6 倍超采样,屏幕上不占地方,打印和放大时依然锐利。CSS 那份也留着,方便有人把 HTML 直接在浏览器里预览。
6.4 导航窗格与几个 mso 开关
文档生成出来之后,甲方提了个需求:希望在 Word 里点「视图 → 导航窗格」,左侧能出现章节目录,方便跳转。
第一反应是把章标题改成 <h1> / <h2> 标签。结果每个章标题外面都多了一圈很厚的边框,非常难看。
查下来是这样:Word 见到 <h1> 会套用内置的「标题 1」样式,这个样式再遇上我给标题设的 padding + 背景底纹 + 左边框,就会把整段包装成一个 para-border-div,并自动补齐四周边框 。我试过用 mso-border-alt: none 逐边关闭,压不住。
正确做法是:不用标题标签,用普通 <div> 加 mso-outline-level 属性。Word 的导航窗格是按段落大纲级别列条目的,跟标签本身无关:
css
.chapter {
font-size: 15pt; font-weight: bold; color: #2E7D32;
padding: 7px 12px; background: #F1F8E9; border-left: 4px solid #4CAF50;
page-break-after: avoid;
mso-outline-level: 1; /* ← 只加这一行,导航窗格就认了 */
}
.sec {
font-size: 13pt; font-weight: bold;
mso-outline-level: 2;
}
mso-outline-level 是纯段落属性,等同于在 Word 里手动打开「段落 → 常规 → 大纲级别」选「1 级」,不参与盒模型渲染,所以不会触发套框。加完之后视觉样式一点没变,导航窗格里的章节结构也齐了。
几个常用的 mso 开关列在一起:
| 声明 | 作用 | 不写会怎样 |
|---|---|---|
mso-outline-level: 1 / 2 |
段落大纲级别,驱动导航窗格与自动目录 | 章节不出现在导航,无法生成目录 |
page-break-inside: avoid |
图与图题不被分页截断 | 图片上半页、图题下半页 |
page-break-after: avoid |
标题不落在页面末行 | 标题孤零零留在页尾 |
@page { size: A4; margin } |
页面尺寸与页边距 | 默认 Letter,版心宽度算错 |
mso-page-orientation |
纸张方向 | 宽表格排不下 |
七、注意事项
7.1 版本与兼容性
ECharts 5.x 与 6.x 的按需引入路径一致 ,但 6.x 移除了几个 3D 相关的内置类型,如果你从旧项目迁移要检查一遍。另外 echarts.graphic.LinearGradient 在按需引入下依然可用,不需要额外 use。
ResizeObserver 在 iOS Safari 13.3 以下不支持 。我的写法里做了 typeof ResizeObserver === 'undefined' 判断,降级到只监听 window.resize------功能会退化但不会报错。如果必须支持老设备,可以引 resize-observer-polyfill。
HTML 转 Word 方案在 WPS 上表现明显更差 。mso-outline-level 在 WPS 里不生效,导航窗格是空的;部分 CSS 的解析也和 Word 有出入。如果目标用户大量使用 WPS,建议直接上 docxtemplater 生成真 OOXML。
7.2 性能与内存
URL.createObjectURL 之后必须 revokeObjectURL。这个 URL 持有整个 Blob 的引用,一份带六张图的报告 base64 之后有好几兆,不释放的话用户点几次导出,内存就明显上去了。
离屏渲染的实例一定要 dispose()。同理,每次导出泄漏六个 canvas,加上 ECharts 内部的事件监听和动画定时器,累积得很快。
base64 会让文档体积膨胀约 33% 。六张 1920×1080 的 PNG,原始大概 1.2MB,转成 base64 内联进 HTML 大约 1.6MB。如果图表更多,或者要发邮件附件,考虑把 pixelRatio 降到 1.5,或者改用后端渲染直接嵌二进制。
7.3 大模型数据特有的坑
永远不要相信字段一定存在 。同一个接口,模型在不同输入下可能返回 null、空数组、空字符串,甚至字段整个缺失。我的做法是归一化函数里每个字段都给默认值,组件里再也不写 ?. 链式判断。
业务级错误可能伪装成 HTTP 500。我们后端返回过这样一个响应:
json
{ "code": 500,
"msg": "IP AGENT REST CODE:{200},MSG:{当前技术描述不涉及算法、模型。}" }
这其实不是系统故障,而是上游给出的明确业务结论------用户提交的是纯机械结构方案,没有算法要素,所以某一路分析走不下去。这种情况弹一个「导出失败,请重试」毫无帮助。我的处理是在 API 层剥离外层包装、提取真实原因,再按关键词归类成几种业务错误类型,页面据此给出针对性引导:
javascript
function buildReportError(res = {}) {
const rawMsg = res.msg || ''
// 提取 MSG:{...} 中的真实原因
const inner = rawMsg.match(/MSG:\s*\{([\s\S]*?)\}\s*$/)
const reason = (inner ? inner[1] : rawMsg).trim() || '未知原因'
const err = new Error(reason)
err.rawMsg = rawMsg
err.reason = reason
if (/不涉及算法|不涉及模型/.test(reason)) err.bizType = 'NO_ALGORITHM'
else if (/未检索到|结果为空/.test(reason)) err.bizType = 'NO_RESULT'
else if (/检索式|语法/.test(reason)) err.bizType = 'BAD_EXPRESSION'
else err.bizType = 'UNKNOWN'
return err
}
页面拿到 bizType 之后渲染不同的失败页,告诉用户「你的技术描述里缺少算法要素,建议补充 LSTM、多元回归这类具体算法名称」,并把主按钮指向该去的那一步。这比一个红色 toast 有用得多。
7.4 交付前的自检清单
导出功能很容易「看起来没问题」,实际打开一堆毛病。我固定检查这几项:
- 文档用 Word 打开而不是浏览器------确认 MIME 类型生效
- 中文没有乱码------确认 BOM 头加了
- 六张图都在,且宽度没有超出版心------确认原生 width 属性生效
- 图片和图题在同一页------确认 page-break-inside 生效
- 导航窗格有章节------确认 mso-outline-level 生效
- 连续点五次导出,观察内存曲线是否持续上涨------确认实例和 URL 都释放了
- 折叠侧边栏、缩放窗口,图表跟随重绘------确认 ResizeObserver 挂上了
八、本文总结
整条链路的核心思路可以压缩成三句话。
数据要在进入组件前洗干净。大模型链路的数据不确定性远高于传统接口,把排序、去重、拆分、映射统一收敛到 API 层,组件才能保持简单。这一层写好了,后面所有环节都轻松。
图表配置要与渲染载体解耦。option 是纯数据,谁渲染、渲染成多大都不是它该关心的事。做到这一点,页面自适应渲染和导出固定尺寸渲染才能共用同一份配置。
Word 不是浏览器 。它对 HTML 和 CSS 的支持是个有限子集,img 的尺寸只认原生属性、大纲级别要靠 mso-outline-level、标题标签会触发内置样式导致意外的边框。把这几条记住,HTML 转 Word 这条路就基本平了。
什么场景适合这套方案:报告结构相对固定、需要快速交付、用户主要用 Microsoft Word 。什么场景不适合:需要精确套用设计好的 Word 模板、目标用户大量使用 WPS、或者要批量后台生成归档 ------这三种情况应该走 docxtemplater 或后端渲染。
最后提一句,导出功能的验收最好让真实用户在真实机器上跑一遍。我在 Chrome 里测得好好的文档,同事用 WPS 打开发现导航窗格是空的------这类问题只有换环境才暴露得出来。
九、更多操作
更多前端实战内容,请看,Vue 个人专栏
本文属于 Vue 企业级实战系列 ,持续更新 Vue2/Vue3、工程化、性能优化、跨域解决方案等 干货,欢迎关注 我的 CSDN 专栏:
本专栏聚焦 Vue(Vue2/Vue3)企业级实战开发 。涵盖组件封装、组合式 API、前端工程化、状态管理、性能优化、项目架构 ,搭配 Element、ECharts 业务实战。汇集真实项目开发经验与避坑方案,帮你搭建完整 Vue 技术体系,持续更新硬核实战内容。
如果本文对你有帮助,欢迎点赞、收藏、评论,你的支持是我持续输出实战干货的动力!
如果你在项目中也遇到类似问题,欢迎留言交流,分享你的场景与解决方案。
