纯前端 HTML 转 Word:基于 MHTML 的样式保真导出
2026/9/16 20:32:27 网站建设 项目流程

上个月同事丢过来一个需求,说客户那边的报表页面已经在浏览器里用 CSS 排得整整齐齐,现在要求能一键导出成 Word 文件,打开之后表格边框、单元格底色、字号字体、分页位置都得跟网页上看到的一致,还特别强调整个流程不能走后端接口。我第一反应是用 docx 那套库重新拼一遍,动手写了半天就放弃了——网页上那些 flex 布局、渐变底色、精确到像素的间距,根本翻译不过去。后来把思路换成 mhtml-to-word 这个方向:不去"翻译"HTML,而是把页面原样打包成一个 Word 能读的 MHTML 容器,让 Word 自己去渲染,保真度一下子就上来了,而且整个转换过程可以完全跑在前端浏览器里,一行后端代码都不用写。

这就是我想聊的东西:基于纯前端把 HTML 转成 Word,并且尽量保留原来的显示样式。它解决的是"页面已经排好版,只想把这份版式原封不动搬到 Word 里"这一类问题,适合做报表导出、合同/简历/发票生成、后台管理系统里"导出 Word"按钮的开发同学,也适合对前端打包下载机制好奇、想搞清楚 .doc 到底是不是二进制格式的人。下面我会把选型逻辑、MHTML 的文件结构、样式内联的做法、图片和中文编码的坑,以及导出后在 Word 里最容易翻车的几个细节全拆开讲,代码可以直接抄。

1. 需求逼出来的路线选择:为什么是 MHTML 而不是 docx

1.1 先把"原样导出"这四个字拆开看

很多人接到导出需求时,脑子里默认的方案是"用 JS 生成一个 docx"。这条路听着正规,实际做起来会发现它和需求本身是冲突的。docx 是一套 OOXML 的 XML 结构,段落、表格、图片都是独立节点,样式得用w:pPrw:tblPr这类标记一个个描述,网页上写的 CSS 对它来说等于不存在。你得写一个翻译器,把 DOM 树映射成 OOXML 节点树,中间任何一层映射不准,样式就丢一块。更麻烦的是那些"看起来不是布局,其实是布局"的东西:单元格里用display:flex做左右对齐、用position:absolute做角落水印、用伪元素做的序号圆点,这些在 OOXML 里没有对应概念,只能靠人肉判断降级成表格还是普通段落。

所以我把需求拆成了两个层次:内容结构要能对应上,视觉呈现要能对应上。前者是必须的,消费者打开 Word 是要编辑文字的;后者是加分项,但恰恰是客户最在意的那部分。既然视觉呈现依赖的是浏览器的排版能力,那最省事的思路就是——别翻译了,把 HTML 原样交给 Word 的渲染引擎去处理。Word 从很早就支持打开 HTML 和 MHTML 格式,它内部有一套自己的 HTML 解析与排版逻辑,直接把网页喂给它,比我们再写一层翻译器靠谱得多。

1.2 三条技术路线的实测对比

我前后试过三条路,结论写在下面这张表里,方便你按自己的场景直接选。

路线保真度是否需要后端主要问题
服务端用文档处理库或办公套件转换最高,接近印刷级需要部署重、并发有压力、数据要出浏览器
前端生成 OOXML(docx 类库)低,只保留结构和基础样式不需要CSS 基本全丢,复杂布局要手写翻译
前端拼 MHTML,存成 .doc较高,Word 渲染 HTML 的结果不需要打开时会提示格式与扩展名不匹配

第一条路的问题不是技术不行,而是场景不合适。报表里的数据经常涉及客户名单、金额、联系方式,很多甲方明确要求数据不出内网、不出浏览器。第二条路我实测下来最大的感受是"投入产出比太低",写了两百多行翻译逻辑,最后表格边框对了,但整页的留白比例、字体层级全变了样,客户一眼就看出"这不是我要的那个版本"。第三条路听起来有点野路子,但它其实是 Word 自己就在用的机制——你把一个网页用 Word 打开,另存为"单个文件网页",拿到的就是一个 MHTML 文件;反过来,我们造一个结构一样的 MHTML,Word 自然认。

1.3 选 MHTML 要接受的代价

这条路不是没有代价,提前把坑说清楚比事后被质问要好。最直观的一点是:生成的文件扩展名是.doc,内容其实是 MHTML 文本,Word 打开时会弹一个提示框,大意是"文件格式与扩展名不匹配,是否仍要打开"。这个提示没法绕过,只能提前跟使用方说明,或者在导出按钮旁边加一行小字解释。对内部使用的后台系统来说,点一次"是"完全可以接受;对外交付的场景就得斟酌了。

第二点代价是能力边界。这种文档在 Word 里的本质是"HTML 文档",它没有真正的样式表、没有题注和交叉引用、没有可靠的域代码,宏也用不了。如果你的需求里包含"生成带目录的正式文档""需要用户后续用域更新页码",那 MHTML 这条路的收益会明显下降,这类需求更适合老老实实生成 OOXML。我的判断标准很简单:以展示和轻量编辑为主,选 MHTML;以长文档排版和域功能为主,选 OOXML。

2. Word 打开 HTML 时到底认哪些样式

2.1 渲染引擎的老底子决定了保真上限

Word 里的 HTML 渲染能力,血统上来自很早期的那套排版引擎,它的强项是 CSS 的第一代和第二代规范,对后面的新特性支持得零零散散。这话听着是坏消息,但对导出场景反而是好消息:它最擅长的正是表格、浮动、行内块这些"老派"布局,而表格恰恰是报表类页面最常用的结构。所以你会看到一个反直觉的现象——页面上用display:table写的表格导出后几乎完美复刻,而用grid写的卡片网格导出后直接塌成一列。

我踩过的第一个坑就在这里。有次报表用display:grid排了一个三列指标卡,导出后三张卡片全部竖着堆在一起,客户问"是不是导出功能坏了"。实际情况是 Word 完全不认识 grid,只能按块级元素顺序往下排。后来我给导出流程加了一步"布局降级",把 grid 和 flex 的容器在导出副本里换成表格或者带固定宽度的行内块,问题就解决了。这一步的思路后面第 5 节会展开。

2.2 外链样式表和类选择器为什么经常失效

另一个高频翻车点是样式来源。网页上的样式可能来自三个地方:外部样式表、文档内的<style>块、元素上的行内style。Word 对这三者的支持度是递减的——外部样式表通过<link>引入,Word 不会去联网拉取,直接忽略;<style>块里的类选择器它认识一部分,但遇到复杂选择器(比如:nth-child+相邻兄弟、媒体查询)就掉链子;行内 style 是唯一一个几乎百分百生效的通道

所以做导出时,标准动作是"把计算后的样式全部落到行内"。做法是克隆一份 DOM,遍历原节点和克隆节点的对应关系,用getComputedStyle读出浏览器算好的最终值,挑出需要保留的属性写进克隆节点的style属性里。这么干有个副作用:样式内联之后 HTML 字符串的体积会膨胀三到五倍,一个原本 200KB 的页面可能变成 800KB,这个量级还能接受,但如果页面本身就很重,就得考虑只对内联必要的属性做白名单过滤,而不是无脑全抄。

2.3 一份可以直接抄的属性白名单与黑名单

内联的时候到底抄哪些属性,我整理了一张表,是反复试出来的结果。

CSS 属性是否保留说明
font-family / font-size / font-weight / font-style保留字号建议从 px 换算成 pt
color / background-color保留颜色值转成十六进制最稳
text-align / vertical-align / text-indent / letter-spacing保留效果基本一致
border 四边保留建议用四边的具体值,不用简写
padding 四边保留单元格里要配合专用属性
width / height / line-height / white-space保留表格里必需
display: flex / grid丢弃必须提前降级成 block 或 table
position: absolute / fixed丢弃需要重排到正常流里
transform / box-shadow / border-radius丢弃无对应概念,直接删
background-image(渐变)丢弃用纯色兜底
overflow / z-index丢弃无意义

补充一个容易被忽略的细节:getComputedStyle读出来的颜色是rgb(18, 52, 86)这种格式,Word 对它的兼容性不算稳定,同色值有时认有时不认。稳妥的做法是加一步正则转换,把rgb(a,b,c)统一转成#123456。字体族也一样,读出来往往是一长串-apple-system, BlinkMacSystemFont, "Segoe UI", ...,直接塞给 Word 会让它挑到一个系统里根本没有的字体然后回退成宋体,倒不如做一层映射,让每个字体族只留一个 Word 一定认识的名字。

2.4 导出前重建一套"Word 友好 DOM"

到这里可以引出我的核心做法了:不要试图硬啃原页面,而是生成一份专门给 Word 看的干净副本。原页面负责给用户看,那份副本只负责被导出。副本里做的事包括:把 flex 容器换成表格或者带百分比宽度的行内块;把绝对定位的水印挪成正常流里的段落;把伪元素的内容(比如::before里的序号)真正写成 DOM 文本节点,因为 Word 不处理伪元素;把渐变背景替换成取色器吸出来的近似纯色;把外层包一个固定宽度的容器,模拟 A4 正文区域。

这么做的额外好处是排错变简单。当客户说"导出后第 3 页乱了",你可以把这份副本的 HTML 单独存下来用浏览器打开对比,问题到底出在副本结构上还是 Word 的渲染上,一眼就能分清。我现在的项目里,这份副本的生成逻辑单独抽成了一个模块,输入是原始 DOM 和一份配置,输出是已经内联好样式的 HTML 字符串,后面组装 MHTML 的部分只负责把它包起来,职责很清晰。

3. 拆开一个能被 Word 认出来的 MHTML 文件

3.1 多部分 MIME 的整体骨架

MHTML 说穿了就是一个符合 MIME 多部分规范的多媒体文档,它把"主 HTML"和"它引用的所有资源"打成一个包。结构大致是这样的:文件开头声明自己是多部分文档以及用什么分隔符,然后每一段用分隔符切开,段内先写头字段再写内容,最后用带两个短横线的分隔符收尾。一个最小的骨架长这样:

MIME-Version: 1.0 Content-Type: multipart/related; type="text/html"; boundary="----=_NextPart_MHT2WORD" ------=_NextPart_MHT2WORD Content-Location: file:///C:/fakepath/document.html Content-Type: text/html; charset="utf-8" Content-Transfer-Encoding: base64 (base64 编码后的 HTML) ------=_NextPart_MHT2WORD Content-Location: file:///C:/fakepath/image001.png Content-Type: image/png Content-Transfer-Encoding: base64 (base64 编码后的图片) ------=_NextPart_MHT2WORD--

几个关键点:外层Content-Type里要写type="text/html",告诉解析方主资源是 HTML;boundary是分隔符,后面每一段前面都要加两个短横线,最后一段的末尾要加两个短横线表示结束;换行统一用\r\n,用\n在部分环境下会导致尾段解析不完整。

3.2 主 part 的头字段一个都不能少

主 part 的三个头字段各有各的用处,少一个都可能出问题。Content-Location是资源的虚拟路径,Word 用它来解析 HTML 里的相对引用,所以它必须是file:///开头的完整形式,路径里用假目录也没关系,只要各段之间的相对关系对得上就行。我一般统一用file:///C:/fakepath/,因为这是浏览器上传文件时暴露出来的默认路径,看着自然,也几乎不会有目录冲突。

Content-Type: text/html; charset="utf-8"决定了 HTML 的解析方式,charset 一定要写,而且要和 HTML 里的<meta charset>保持一致。Content-Transfer-Encoding: base64说明这一段是 base64 编码的。这行不是可选项,因为 base64 是唯一能保证中文和特殊字符在传输过程中不被破坏的编码方式。

3.3 主 HTML 用 base64 编码的额外好处

我一开始图省事,主 part 直接用原文不编码,结果在部分版本的 Word 上打开后中文变成了一堆方块和问号。原因在于裸文本传输时,行尾的换行符可能被规范化,非 ASCII 字符又有多种编码猜测路径,Word 猜错的概率不低。改成 base64 之后这个不确定性就消失了——编码后的内容是纯 ASCII,不存在猜错的空间,解码完全由头字段里的 charset 决定。

顺带说一个反直觉的点:MHTML 文件开头不要加 BOM。有些文章建议在 Blob 的第一个参数里塞一个\ufeff来保证编码,这个技巧对纯 HTML 导出有用,但对 MHTML 反而有害,因为 BOM 出现在MIME-Version之前会让开头的头字段解析偏移,Word 可能直接判定文件损坏。编码这件事交给段内的 charset 字段就足够了。

3.4 图片 part 与 src 的对应规则

图片段的Content-Location必须和 HTML 里src的写法严格对应。假设主文档的虚拟路径是file:///C:/fakepath/document.html,图片段的路径是file:///C:/fakepath/image001.png,那 HTML 里写src="image001.png"就能对上,写src="./image001.png"一般也能识别,但写绝对路径或者干脆保留原始的 blob 地址就一定找不到。这条规则听起来简单,实际做的时候最容易犯的错是给图片重名——同一页面上有两个同名图片,Word 只认第一个,第二个位置就空着。我现在的做法是统一按出现顺序编号:image001.pngimage002.png,后缀按真实类型给,jpg 给.jpg,png 给.png,别为了省事全写.png,虽然大多数情况也能显示,但偶尔会遇到 Word 按扩展名选错解码器导致图片花屏。

3.5 组装、下载的完整代码

把上面这些规则落地成一个函数,核心就是这么几十行:

// 把任意字符串安全地转成 UTF-8 的 base64,避免 btoa 遇到中文直接抛错 function utf8ToBase64(str) { const bytes = new TextEncoder().encode(str); let bin = ''; const CHUNK = 0x8000; for (let i = 0; i < bytes.length; i += CHUNK) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); } return btoa(bin); } // base64 内容每 76 个字符换一次行,符合 MIME 惯例 function wrap76(b64) { return b64.replace(/(.{76})/g, '$1\r\n'); } function buildMhtml(htmlString, assets) { const B = '----=_NextPart_MHT2WORD'; const LOC = 'file:///C:/fakepath/'; const out = []; out.push('MIME-Version: 1.0'); out.push('Content-Type: multipart/related; type="text/html"; boundary="' + B + '"'); out.push(''); out.push('--' + B); out.push('Content-Location: ' + LOC + 'document.html'); out.push('Content-Type: text/html; charset="utf-8"'); out.push('Content-Transfer-Encoding: base64'); out.push(''); out.push(wrap76(utf8ToBase64(htmlString))); for (const item of assets) { out.push('--' + B); out.push('Content-Location: ' + LOC + item.filename); out.push('Content-Type: ' + item.mime); out.push('Content-Transfer-Encoding: base64'); out.push(''); out.push(wrap76(item.base64)); } out.push('--' + B + '--'); out.push(''); return out.join('\r\n'); } function downloadAsDoc(mhtmlString, filename = '导出文档.doc') { const blob = new Blob([mhtmlString], { type: 'application/msword' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); // 立刻 revoke 在部分浏览器上会让下载中断,延后释放更稳 setTimeout(() => URL.revokeObjectURL(url), 5000); }

assets是形如{ filename, mime, base64 }的数组,图片段的 base64 不带data:image/png;base64,这个前缀,前缀是 Data URI 的语法,MIME 段里出现它会被当成正文内容。这个坑我在第一次调试时踩过,图片位置一直显示成一段乱码文本,把前缀去掉就好了。

4. 图片、字体、中文编码:最容易翻车的三处

4.1 blob: 和外链图片在 Word 里为什么是空白

页面上的图片有两种常见来源:一种是从接口拿到二进制后生成的blob:地址,一种是直接指向某个域名的http(s):地址。这两种在导出时都活不下来。blob:地址只在当前页面的生命周期内有效,Word 拿到这个字符串没有任何办法去取数据;外链地址虽然理论上可取,但 Word 打开 HTML 文件时不会主动联网下载资源,它期望的是资源就在本地或者就在同一个 MHTML 包里。

所以导出前必须把每个<img>解析成真正的字符数据。blob:data:的图片好办,直接fetch拿到arrayBuffer再转 base64 就行。外链图片要注意跨域,如果服务器没给Access-Control-Allow-Originfetch会直接失败,这时候得有个兜底策略——用一张内置的灰色占位图替换,同时在导出结果里给个提示,而不是让整个导出流程抛错中断。我现在的做法是并发处理所有图片,每个图片单独try/catch,失败的记录到一个列表里,导出结束后用 Toast 告诉用户"有 2 张图片因跨域无法嵌入"。

4.2 图片压缩与文件体积控制

图片处理还有个隐患是体积。报表里如果插了几张手机拍的照片,每张三四 MB,转成 base64 之后还要再膨胀大约三分之一,最终生成的 .doc 可能二三十 MB。这种文件在 Word 里打开会明显卡,翻页迟钝,关闭的时候还会卡顿好几秒甚至提示无响应——这个现象其实很好解释:Word 需要把内嵌资源全解码进内存,内嵌资源越大,内存压力越大,关闭时要做的释放工作也越多。单纯怪 Word 卡顿不太公平,问题出在我们塞进去的图片太大了。

解决办法是在嵌入前先过一遍画布缩放。用canvas把图片按最大边长限制重绘一次,再导出成 JPEG,配合质量参数控制体积。下面这组参数是我在报表场景里试出来的一个平衡点,清晰度和体积都能接受:

图片用途最大边长输出格式质量
头像、图标300pxPNG无损
正文插图1200pxJPEG0.85
整页扫描件1800pxJPEG0.8
带透明通道的图800pxPNG无损

注意:canvas重绘会丢掉图片的 EXIF 方向信息,手机竖拍的图导出来可能是横的。稳妥的做法是先用createImageBitmap传入{ imageOrientation: 'from-image' }把方向信息应用进像素,再画到画布上。

4.3 字体映射:网页字体在 Word 里等于不存在

网页字体和本地字体的差别,在导出场景里会被放大。页面上通过@font-face加载的那个字体文件,Word 是不认的,它只会去系统里找名字匹配的字体,找不到就回退。回退的结果通常是宋体或者 Calibri,字号相同的情况下视觉宽度会差出不少,原本一行放得下的标题可能就折成两行了。

我的处理办法是维护一张字体映射表,把页面上出现的字体族收敛成几个 Word 里一定存在的名字。映射的时候优先保证中英文分别落到合适的字体上,因为中文字体里自带的西文字形通常不好看,而西文字体又没有中文字形,Word 会自动做中西文混排的替换,效果比我们硬指定一个字体好。

页面字体族映射为备注
-apple-system / system-ui微软雅黑中文环境首选
Helvetica / Arial / RobotoArial西文通用,兼容性最好
Segoe UI / PingFang SC微软雅黑无衬线正文
Georgia / 思源宋体宋体需要衬线感时用
Consolas / Menlo / monospaceConsolas代码、编号类内容

4.4 中文乱码的三重保险与 BOM 的坑

中文乱码这件事,我踩过三次,最后总结成一个三重保险的做法,缺一不可:第一层是 HTML 头部显式写<meta charset="utf-8">,第二层是 MHTML 主段的头字段写charset="utf-8",第三层是主段内容用 base64 编码。三层里最容易漏的是第二层,因为很多人只在 HTML 里写了 meta 就以为万事大吉,而 Word 在处理 MHTML 时是先看段头的 charset 再去看 HTML 内部的 meta,段头缺失时它的猜测行为很不稳定。

前面提过的 BOM 问题在这里再强调一次:不要在 MHTML 字符串最前面加\ufeff。我当时的思路是"BOM 能帮浏览器识别编码,那也应该能帮 Word 识别",实测结果是文件直接打不开,把 BOM 去掉立刻恢复正常。BOM 的用途是给纯文本文件用的,MHTML 有自己的编码声明机制,两者不兼容。另外还有一个不太常见的坑:如果 HTML 里出现了&nbsp;&copy;这类实体字符,而你又没在<meta>里声明 charset,Word 按默认编码解析时会把实体后的内容一起带偏,表现是正文前半段正常、后半段突然变乱码,遇到这种"半截乱码"基本可以断定是编码声明的问题。

5. 从页面 DOM 到 MHTML 字符串的完整链路

5.1 克隆节点与计算样式内联

整个流程的第一步是拿到一份干净的、样式已经落到行内的 HTML 副本。核心是让原节点和克隆节点同步遍历,这样两棵树的节点顺序一致,可以一一对应:

const KEEP = [ 'font-family', 'font-size', 'font-weight', 'font-style', 'color', 'background-color', 'text-align', 'vertical-align', 'text-decoration', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'width', 'height', 'line-height', 'white-space', 'text-indent', 'letter-spacing' ]; function rgbToHex(value) { return value.replace(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/g, (m, r, g, b) => '#' + [r, g, b].map(n => (+n).toString(16).padStart(2, '0')).join('')); } function inlineComputedStyles(srcRoot, cloneRoot) { const srcWalker = document.createTreeWalker(srcRoot, NodeFilter.SHOW_ELEMENT); const cloneWalker = document.createTreeWalker(cloneRoot, NodeFilter.SHOW_ELEMENT); let s = srcRoot, c = cloneRoot; do { const cs = getComputedStyle(s); let style = ''; for (const prop of KEEP) { const v = cs.getPropertyValue(prop); if (!v || v === 'none' || v === 'normal' || v === 'auto') continue; style += prop + ':' + rgbToHex(v) + ';'; } c.setAttribute('style', style); } while ((s = srcWalker.nextNode()) && (c = cloneWalker.nextNode())); }

这段代码有两个细节值得说。一是过滤条件里把autonormalnone都跳过了,因为计算样式会把大量没写过的属性返回成浏览器的默认值,全部抄下来会让每个元素都拖着一长串无关声明,体积白白翻好几倍。二是border-top这类简写属性在计算样式里是可以直接读的,返回结果形如1px solid rgb(0,0,0),比自己去拼宽高样式颜色三个值省事,也更不容易拼错。

5.2 布局降级与 px 到 pt 的换算

内联完样式之后,紧接着要处理布局降级。我的做法是维护一张选择器映射表,把页面上已知的 flex 容器(通常是.row.flex-between这类类名,或者直接用getComputedStyle(el).display === 'flex'判断)替换成两列或三列的表格结构,每个原 flex item 变成一列。列宽按原来的flex比例分配并换算成百分比,这样 Word 里也能保持左右分栏的效果。

换算单位这件事也得顺手做掉。浏览器的 CSS 像素在 96dpi 下和磅的换算是固定的:1px = 0.75pt。A4 纸的尺寸是 21cm × 29.7cm,换算过来是 595.3pt × 841.9pt,去掉常见的左右各 90pt 页边距,正文区域宽度大约 415pt,反过来换算成像素差不多是 553px。实际操作时不用卡这么死,把导出容器宽度设成 700px 到 794px 之间,配合@page声明的页边距,视觉效果都还不错。字号方面,网页上 14px 的正文换算过来是 10.5pt,正好是中文文档里最常用的五号字;16px 是 12pt,接近小四。这两个档位在 Word 里看着最舒服,遇到 13px、15px 这种不常见的字号,我会就近吸附到 10.5pt 或者 12pt。

5.3 图片资源的收集与占位替换

图片处理的顺序建议是这样:先扫描副本里所有的<img>和带有background-image的元素,建立一份资源清单;然后逐个取数据、压缩、转 base64;最后回填到副本里,<img>的 src 换成统一的虚拟文件名,背景图换成内联的data:地址或者也拆成独立段引用。这里有个取舍——背景图如果用data:内联在 HTML 里,会让主段体积变大,但引用逻辑简单,不用管路径对应;拆成独立段则体积分布更均匀,但要多写一层路径映射。我倾向于统一拆成独立段,因为所有资源走同一套引用规则,调试时看 HTML 里全是image0xx.png反而更清楚。

替换的时候记得把原来图片上的classid之类无关属性清掉,同时补上widthheight的行内尺寸。Word 在图片加载完成前是按元素尺寸占位的,如果没给尺寸,图片插入的瞬间整个版面会跳一下,这种跳动在 Word 里表现为图片位置错乱甚至压到文字上。

5.4 分片处理避免主线程卡死

页面元素一多,整条链路就会变成重任务:遍历上万个节点读计算样式、几十张图片转码、几兆字符串拼接。我实测过一个约 3000 个节点、12 张图片的报表页,同步执行的话主线程要占住两秒多,期间按钮点不动、进度条不刷新,用户会以为页面卡死了。

处理思路是拆分任务并把控制权交还给浏览器。节点遍历按批次做,每处理 300 个节点就await一次requestIdleCallback或者一个setTimeout(0),让浏览器有机会渲染进度;图片转码用Promise.all并发,但限制并发数(我一般设 4),避免同时开十几个canvas把内存拉满。整条链路包成一个async函数,在开始前把按钮置灰、进度归零,完成后再恢复。这一套加上之后,同样的页面导出耗时差不多,但体感上顺畅很多,因为进度条一直在动。

注意:导出过程中如果页面在滚动或者有动画,getComputedStyle读到的是当前时刻的值,可能是动画中间态。稳妥的做法是在导出前给根元素加一个类,把动画和过渡全部关掉,等导出完成再移除。

6. 导出后在 Word 里最容易出问题的几个细节

6.1 表格列宽拖不动是怎么来的

这是反馈最多的一条,原文大概是"导出的表格列宽在 Word 里拖不动,鼠标放到分隔线上没有变化光标"。原因不复杂:内联样式的时候,我们把每个单元格的width都写成了固定的像素值,同时表格本身也带了固定宽度,Word 会把这个表格判定成"固定列宽"的表格,列宽调整被锁住了。

要恢复可拖动,核心是别把宽度钉死在像素上。具体做法是:表格给一个百分比宽度(比如width:100%),列宽也用百分比(width:25%),并且把内联进来的table-layout: fixed去掉让它回到自动布局。这样 Word 会把列宽当成"建议值",用户拖动时分隔线会正常响应。如果业务上确实需要锁定某些列宽(比如第一列是序号列,不想被拖宽),那就只给那一列固定像素宽度,其余列用百分比,混合使用也是可以的。

现象原因处理
拖分隔线无反应单元格宽度全是固定像素改成百分比,去掉固定布局
拖动后列宽反弹表格宽度也写死了表格改width:100%
个别列拖不动该列被写死像素保留必要列,其余放开
整表超宽出界百分比之和超过 100%检查各列百分比总和

6.2 单元格内边距、行高与边框

内边距这件事在 HTML 里和在 Word 里是两套机制。网页表格靠tdpadding撑开内容,Word 里对应的是单元格的边距属性,如果只用 CSS 的 padding,Word 有时会把它当成"内容缩进"处理,表现是文字贴着单元格左上角而其他三边没有空隙。补一行专用声明就能解决:

<style> table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; } td, th { mso-padding-alt: 4pt 8pt 4pt 8pt; vertical-align: middle; } </style>

mso-padding-alt的四个值顺序和 padding 一致,写上之后单元格四边留白就正常了。mso-table-lspacemso-table-rspace是用来清掉 Word 默认给表格加的间距的,不写的话表格周围会莫名其妙多出一点空白,和网页上的紧凑感差一截。行高方面,建议统一用line-height而不是height,用height钉死容易出现文字垂直居中偏上的问题。

6.3 分页控制与表头跨页重复

分页是报表类导出的刚需,谁都不希望一个客户的信息被切成两页。控制分页的写法有几种,实测里最有效的是给块级元素加page-break-before: always。这里有个细节:空元素上的分页声明不生效。我一开始写了一个空的<div style="page-break-before:always"></div>当分页符,导出后完全没有分页效果,后来改成<br style="page-break-before:always">或者给有内容的块加声明才生效。原因是 Word 在解析时会跳过不产生任何内容的空块。

表头跨页重复这件事,标准做法是把表头行放进<thead>里。多数情况下 Word 会正确识别并自动在每页顶部重复;如果遇到不识别的情况(我主要在复杂嵌套表格里碰到过),备选方案是在 Word 里手动设置一次——选中表头行,在表格工具里选择"重复标题行"。这一步没法靠代码保证,比较稳妥的做法是在导出后的使用说明里提一句。

6.4 页边距和纸张尺寸的 @page 写法

页面设置靠的是@page规则加一个容器类:

<style> @page WordSection1 { size: 595.3pt 841.9pt; margin: 72pt 90pt 72pt 90pt; } div.WordSection1 { page: WordSection1; } </style> <div class="WordSection1"> <!-- 导出的正文内容放这里 --> </div>

size是纸张尺寸,margin是上下左右页边距,顺序和 CSS 的 margin 简写一致。两行必须成对出现,光写@page不写容器类的page属性,Word 会忽略这套设置。容器<div>的名字和@page后面的名字要一致,我习惯统一叫WordSection1,多个分节时可以顺延编号。另外,这套声明放在文档的<style>块里就行,不需要内联到元素上,因为它是页面级规则,Word 对它的支持比对元素级样式稳定得多。

7. 文件打得开但显示不对:一套按症状走的排查流程

7.1 先用浏览器验证 MIME 结构

排查的第一步永远是分离问题域:到底是 MHTML 结构不对,还是 Word 的渲染不支持。最快的验证方法是不改扩展名,把生成的字符串直接存成.mht文件,用浏览器打开。浏览器能完整还原出带图片的页面,说明 MIME 结构、图片引用路径、编码这三件事全对,问题一定出在 Word 的 CSS 支持上,接下来就只用关注样式层面。

如果浏览器打开也是一团糟,那就是结构出了问题,按这个顺序查:主段和图片段的Content-Location是否都以file:///开头且前缀一致;HTML 里图片的src是否写成了纯文件名;每段之间是否都用分隔符切开且前后有正确的换行;最后一段结尾是否带了两个短横线。把这四条过一遍,基本能覆盖九成的结构错误。

7.2 二分法定位样式丢失

样式丢失这种问题最忌讳东改一处西改一处,我的做法是准备一个最小可用模板——只有一行标题、一个两行表格、一张小图,确认它能正常导出。然后把自己页面上的元素按块删减,每次保留一半,看问题出现在哪一半。这么做最多七八轮就能定位到具体是哪个元素或哪条样式引起的,比盲猜快得多。

实际排查中我遇到过的几个典型案例也列一下,方便对照。"文件格式与扩展名不匹配"的提示属于正常现象,不用排查,提前告知使用方即可。所有文字变成纯文本没有任何样式,几乎一定是内联样式那一步没生效,去打印一下内联后 HTML 的style属性看看是不是空的。图片位置显示一个红叉,先确认图片段是否真的被拼进去了、base64内容是否带了 Data URI 前缀。打开特别慢、关闭时系统提示无响应,去查图片总体积,八成是没做压缩。

7.3 Word 与 WPS 的差异矩阵

同一份文件在不同办公软件里的表现会有差异,这点必须提前知道,否则测试通过了交付翻车。差异主要集中在 CSS 支持度和默认值处理上:

场景WordWPS
单元格mso-padding-alt完全支持部分版本忽略
表头跨页重复多数场景自动识别识别率更低,常需手动设置
渐变背景不支持,显示纯色部分版本能渲染
分页声明支持支持,但空块的判断更严格
文件格式提示会提示部分版本不提示

对策也很直接:以内核差异更大的那一方为准来设计导出结构。具体说就是尽量不要依赖那些只有 Word 认的mso-属性,它们作为增强可以写,但核心的边框、宽度、对齐一定要用标准 CSS 表达;分页不要依赖空块;表头重复不要指望自动识别,把它写进使用说明里。这么设计出来的文件在两个软件里打开都不会太难看。

最后分享一个我在实际项目里用得比较多的做法:给导出功能留一个隐藏的调试入口,按住某个按键点击导出按钮时,不生成 .doc,而是把中间产物——内联样式后的 HTML 和 MHTML 字符串——直接输出到控制台或者下载成 .txt。上线之后客户反馈"显示不对",你只要让对方配合截一张控制台输出,就能很快判断出是副本结构的问题还是渲染的问题。相比在客户现场反复试导出、反复截图比对,这套调试出口省下来的时间,比写导出逻辑本身还多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询