
上个月同事丢过来一个需求说客户那边的报表页面已经在浏览器里用 CSS 排得整整齐齐现在要求能一键导出成 Word 文件打开之后表格边框、单元格底色、字号字体、分页位置都得跟网页上看到的一致还特别强调整个流程不能走后端接口。我第一反应是用 docx 那套库重新拼一遍动手写了半天就放弃了——网页上那些 flex 布局、渐变底色、精确到像素的间距根本翻译不过去。后来把思路换成 mhtml-to-word 这个方向不去翻译HTML而是把页面原样打包成一个 Word 能读的 MHTML 容器让 Word 自己去渲染保真度一下子就上来了而且整个转换过程可以完全跑在前端浏览器里一行后端代码都不用写。这就是我想聊的东西基于纯前端把 HTML 转成 Word并且尽量保留原来的显示样式。它解决的是页面已经排好版只想把这份版式原封不动搬到 Word 里这一类问题适合做报表导出、合同/简历/发票生成、后台管理系统里导出 Word按钮的开发同学也适合对前端打包下载机制好奇、想搞清楚 .doc 到底是不是二进制格式的人。下面我会把选型逻辑、MHTML 的文件结构、样式内联的做法、图片和中文编码的坑以及导出后在 Word 里最容易翻车的几个细节全拆开讲代码可以直接抄。1. 需求逼出来的路线选择为什么是 MHTML 而不是 docx1.1 先把原样导出这四个字拆开看很多人接到导出需求时脑子里默认的方案是用 JS 生成一个 docx。这条路听着正规实际做起来会发现它和需求本身是冲突的。docx 是一套 OOXML 的 XML 结构段落、表格、图片都是独立节点样式得用w:pPr、w:tblPr这类标记一个个描述网页上写的 CSS 对它来说等于不存在。你得写一个翻译器把 DOM 树映射成 OOXML 节点树中间任何一层映射不准样式就丢一块。更麻烦的是那些看起来不是布局其实是布局的东西单元格里用display:flex做左右对齐、用position:absolute做角落水印、用伪元素做的序号圆点这些在 OOXML 里没有对应概念只能靠人肉判断降级成表格还是普通段落。所以我把需求拆成了两个层次内容结构要能对应上视觉呈现要能对应上。前者是必须的消费者打开 Word 是要编辑文字的后者是加分项但恰恰是客户最在意的那部分。既然视觉呈现依赖的是浏览器的排版能力那最省事的思路就是——别翻译了把 HTML 原样交给 Word 的渲染引擎去处理。Word 从很早就支持打开 HTML 和 MHTML 格式它内部有一套自己的 HTML 解析与排版逻辑直接把网页喂给它比我们再写一层翻译器靠谱得多。1.2 三条技术路线的实测对比我前后试过三条路结论写在下面这张表里方便你按自己的场景直接选。路线保真度是否需要后端主要问题服务端用文档处理库或办公套件转换最高接近印刷级需要部署重、并发有压力、数据要出浏览器前端生成 OOXMLdocx 类库低只保留结构和基础样式不需要CSS 基本全丢复杂布局要手写翻译前端拼 MHTML存成 .doc较高Word 渲染 HTML 的结果不需要打开时会提示格式与扩展名不匹配第一条路的问题不是技术不行而是场景不合适。报表里的数据经常涉及客户名单、金额、联系方式很多甲方明确要求数据不出内网、不出浏览器。第二条路我实测下来最大的感受是投入产出比太低写了两百多行翻译逻辑最后表格边框对了但整页的留白比例、字体层级全变了样客户一眼就看出这不是我要的那个版本。第三条路听起来有点野路子但它其实是 Word 自己就在用的机制——你把一个网页用 Word 打开另存为单个文件网页拿到的就是一个 MHTML 文件反过来我们造一个结构一样的 MHTMLWord 自然认。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 换算成 ptcolor / background-color保留颜色值转成十六进制最稳text-align / vertical-align / text-indent / letter-spacing保留效果基本一致border 四边保留建议用四边的具体值不用简写padding 四边保留单元格里要配合专用属性width / height / line-height / white-space保留表格里必需display: flex / grid丢弃必须提前降级成 block 或 tableposition: 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; typetext/html; boundary----_NextPart_MHT2WORD ------_NextPart_MHT2WORD Content-Location: file:///C:/fakepath/document.html Content-Type: text/html; charsetutf-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里要写typetext/html告诉解析方主资源是 HTMLboundary是分隔符后面每一段前面都要加两个短横线最后一段的末尾要加两个短横线表示结束换行统一用\r\n用\n在部分环境下会导致尾段解析不完整。3.2 主 part 的头字段一个都不能少主 part 的三个头字段各有各的用处少一个都可能出问题。Content-Location是资源的虚拟路径Word 用它来解析 HTML 里的相对引用所以它必须是file:///开头的完整形式路径里用假目录也没关系只要各段之间的相对关系对得上就行。我一般统一用file:///C:/fakepath/因为这是浏览器上传文件时暴露出来的默认路径看着自然也几乎不会有目录冲突。Content-Type: text/html; charsetutf-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 里写srcimage001.png就能对上写src./image001.png一般也能识别但写绝对路径或者干脆保留原始的 blob 地址就一定找不到。这条规则听起来简单实际做的时候最容易犯的错是给图片重名——同一页面上有两个同名图片Word 只认第一个第二个位置就空着。我现在的做法是统一按出现顺序编号image001.png、image002.png后缀按真实类型给jpg 给.jpgpng 给.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; typetext/html; boundary B ); out.push(); out.push(-- B); out.push(Content-Location: LOC document.html); out.push(Content-Type: text/html; charsetutf-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 charsetutf-8第二层是 MHTML 主段的头字段写charsetutf-8第三层是主段内容用 base64 编码。三层里最容易漏的是第二层因为很多人只在 HTML 里写了 meta 就以为万事大吉而 Word 在处理 MHTML 时是先看段头的 charset 再去看 HTML 内部的 meta段头缺失时它的猜测行为很不稳定。前面提过的 BOM 问题在这里再强调一次不要在 MHTML 字符串最前面加\ufeff。我当时的思路是BOM 能帮浏览器识别编码那也应该能帮 Word 识别实测结果是文件直接打不开把 BOM 去掉立刻恢复正常。BOM 的用途是给纯文本文件用的MHTML 有自己的编码声明机制两者不兼容。另外还有一个不太常见的坑如果 HTML 里出现了nbsp;、copy;这类实体字符而你又没在meta里声明 charsetWord 按默认编码解析时会把实体后的内容一起带偏表现是正文前半段正常、后半段突然变乱码遇到这种半截乱码基本可以断定是编码声明的问题。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())); }这段代码有两个细节值得说。一是过滤条件里把auto、normal、none都跳过了因为计算样式会把大量没写过的属性返回成浏览器的默认值全部抄下来会让每个元素都拖着一长串无关声明体积白白翻好几倍。二是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反而更清楚。替换的时候记得把原来图片上的class、id之类无关属性清掉同时补上width和height的行内尺寸。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 里是两套机制。网页表格靠td的padding撑开内容Word 里对应的是单元格的边距属性如果只用 CSS 的 paddingWord 有时会把它当成内容缩进处理表现是文字贴着单元格左上角而其他三边没有空隙。补一行专用声明就能解决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; } /stylemso-padding-alt的四个值顺序和 padding 一致写上之后单元格四边留白就正常了。mso-table-lspace和mso-table-rspace是用来清掉 Word 默认给表格加的间距的不写的话表格周围会莫名其妙多出一点空白和网页上的紧凑感差一截。行高方面建议统一用line-height而不是height用height钉死容易出现文字垂直居中偏上的问题。6.3 分页控制与表头跨页重复分页是报表类导出的刚需谁都不希望一个客户的信息被切成两页。控制分页的写法有几种实测里最有效的是给块级元素加page-break-before: always。这里有个细节空元素上的分页声明不生效。我一开始写了一个空的div stylepage-break-before:always/div当分页符导出后完全没有分页效果后来改成br stylepage-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 classWordSection1 !-- 导出的正文内容放这里 -- /divsize是纸张尺寸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。上线之后客户反馈显示不对你只要让对方配合截一张控制台输出就能很快判断出是副本结构的问题还是渲染的问题。相比在客户现场反复试导出、反复截图比对这套调试出口省下来的时间比写导出逻辑本身还多。