 Word:MHTML 打包與樣式固化高還原方案)
1. 為什么我要在純前端做 HTML 轉(zhuǎn) Word 這件事先把場(chǎng)景說(shuō)清楚業(yè)務(wù)后臺(tái)里有一堆動(dòng)態(tài)生成的報(bào)告頁(yè)頁(yè)面是標(biāo)準(zhǔn) HTML 加 CSS 渲染出來(lái)的帶表格、帶顏色、帶自定義字體和對(duì)齊方式。產(chǎn)品經(jīng)理提的需求很樸素——給個(gè)導(dǎo)出 Word 的按鈕導(dǎo)出來(lái)要和頁(yè)面上看到的一模一樣。聽(tīng)起來(lái)簡(jiǎn)單做起來(lái)是另一回事。mhtml-to-word這個(gè)思路的核心是把整頁(yè) HTML 連同樣式一起打包成 MHTMLMIME HTML再讓 Word 去解析這個(gè)單文件。為什么繞這一圈因?yàn)槿绻阒苯影?HTML 字符串丟給 WordWord 的解析器只認(rèn)它自己那套老式 HTML 子集——只認(rèn)內(nèi)聯(lián)樣式不認(rèn)外部 CSS 文件不認(rèn)大部分現(xiàn)代布局屬性flex、grid 基本等于不存在。而 MHTML 是一個(gè)把 HTML 和它引用的資源圖片、CSS打包進(jìn)單個(gè)文件的容器格式Word 打開(kāi)它的時(shí)候會(huì)把它當(dāng)成一個(gè)完整的、自帶資源的網(wǎng)頁(yè)文檔來(lái)處理樣式還原度會(huì)高出一大截。這套方案的適用人群其實(shí)挺明確做企業(yè)級(jí)后臺(tái)、報(bào)表系統(tǒng)、合同生成、簡(jiǎn)歷導(dǎo)出這類(lèi)功能的前端同學(xué)。你不需要后端介入不需要裝 Office 組件不需要 POI 或者 OpenXML SDK瀏覽器里一把梭就能產(chǎn)出.doc文件。代價(jià)是它是一套降級(jí)友好的方案不是像素級(jí)完美復(fù)刻下面我會(huì)把這些邊界一條條講透。我踩過(guò)的最大一個(gè)坑是早期直接用Blob拼 HTML 字符串導(dǎo)出后表格列寬全亂、背景色丟失、中文字體變成宋體默認(rèn)值。后來(lái)改成 MHTML 打包 Base64 內(nèi)聯(lián)資源還原度從能看直接跳到能交付。這篇文章就把這條鏈路從原理到代碼完整拆開(kāi)。2. 方案選型的底層邏輯為什么是 MHTML 而不是別的路2.1 三種主流導(dǎo)出路線的橫向?qū)Ρ惹岸俗?Word 導(dǎo)出繞來(lái)繞去就那么幾條路我把它們攤開(kāi)對(duì)比一下你就知道為什么我最后選了 MHTML。方案實(shí)現(xiàn)方式樣式還原度依賴(lài)適用場(chǎng)景純 HTML 字符串 Blob直接拼application/msword類(lèi)型低只認(rèn)內(nèi)聯(lián)樣式無(wú)簡(jiǎn)單純文本、無(wú)復(fù)雜樣式第三方庫(kù)如 docx 類(lèi)庫(kù)用 JS 按 OpenXML 規(guī)范構(gòu)建文檔中高但需手動(dòng)映射庫(kù)體積大結(jié)構(gòu)化數(shù)據(jù)生成文檔MHTML 打包HTML 資源打包成單文件高接近瀏覽器渲染無(wú)已有 HTML 頁(yè)面直接轉(zhuǎn)換第三方庫(kù)那條路比如用 JS 構(gòu)建 docx其實(shí)生成的是真正的.docxOOXML 格式質(zhì)量是最高的一檔。但它有個(gè)致命問(wèn)題你得把頁(yè)面上的每個(gè)元素、每種樣式都手動(dòng)翻譯成庫(kù)的 API 調(diào)用。一個(gè)表格嵌套合并單元格的報(bào)表翻譯代碼能寫(xiě)到你懷疑人生。而且它是重建不是轉(zhuǎn)換頁(yè)面上那些你已經(jīng)調(diào)好的 CSS 全部作廢。MHTML 這條路的哲學(xué)完全不同——它不重建它打包。你把瀏覽器已經(jīng)渲染好的 HTML 和它的樣式資源原封不動(dòng)裝進(jìn)一個(gè)容器交給 Word 的 HTML 解析引擎去盡力還原。省事還原度還高這就是我選它的根本原因。注意這里說(shuō)的高還原度是有前提的它依賴(lài) Word 對(duì) HTML/CSS 的支持程度不是瀏覽器級(jí)別的還原。下面講邊界的時(shí)候會(huì)具體展開(kāi)。2.2 MHTML 到底是個(gè)什么東西很多人對(duì) MHTML 陌生其實(shí)它是很老的規(guī)范了全稱(chēng) MIME Encapsulation of Aggregate HTML Documents。你可以把它理解成一個(gè)網(wǎng)頁(yè)壓縮包類(lèi)似把 HTML 和它的所有依賴(lài)資源塞進(jìn)一個(gè)大信封。它長(zhǎng)這樣From: Saved by Blink Subject: 報(bào)告 Date: ... MIME-Version: 1.0 Content-Type: multipart/related; boundary----_NextPart_01 ------_NextPart_01 Content-Location: file:///C:/report.html Content-Type: text/html; charsetutf-8 !DOCTYPE htmlhtml.../html ------_NextPart_01 Content-Location: file:///C:/style.css Content-Type: text/css .report-table { border-collapse: collapse; } ... ------_NextPart_01--關(guān)鍵點(diǎn)有三個(gè)multipart/related聲明這是復(fù)合文檔boundary是各部分之間的分隔符每個(gè)部分用Content-Location標(biāo)記自己的虛擬路徑。HTML 里引用資源時(shí)用相對(duì)路徑Word 解析時(shí)會(huì)在同一個(gè) MHTML 容器里按Content-Location找到對(duì)應(yīng)資源。這個(gè)機(jī)制的精髓在于資源不是外鏈?zhǔn)莾?nèi)嵌。Word 不需要聯(lián)網(wǎng)、不需要找文件所有東西在一個(gè)信封里解析起來(lái)穩(wěn)得很。2.3 為什么不能直接把 CSS 寫(xiě)進(jìn) style 標(biāo)簽這是個(gè)高頻誤區(qū)。有人想我把所有 CSS 內(nèi)聯(lián)到style標(biāo)簽里不就不用打包了我實(shí)測(cè)過(guò)直接丟 HTML 字符串給 Wordstyle塊里的規(guī)則大部分會(huì)被忽略尤其是復(fù)雜選擇器.a .b .c基本不認(rèn)media查詢(xún)完全不認(rèn)CSS 變量--primary-color不認(rèn)flex/grid 布局屬性不認(rèn)Word 的 HTML 解析器是個(gè)上古遺物它對(duì) CSS 的支持大概停留在 2005 年左右的水平。所以我的策略是在打包之前先把 CSS 計(jì)算成內(nèi)聯(lián)樣式也就是拿到了每個(gè)元素的最終計(jì)算樣式后直接寫(xiě)進(jìn)style屬性。這就是所謂的樣式固化步驟是整條鏈路里最關(guān)鍵的一環(huán)。3. 核心鏈路拆解從 DOM 到可下載文件3.1 整條鏈路的五個(gè)階段我把實(shí)現(xiàn)拆成五個(gè)階段邏輯上環(huán)環(huán)相扣克隆 DOM把要導(dǎo)出的目標(biāo)節(jié)點(diǎn)深拷貝一份絕不碰原頁(yè)面。樣式固化讀取計(jì)算樣式把關(guān)鍵屬性寫(xiě)成內(nèi)聯(lián)樣式。資源內(nèi)聯(lián)化圖片、背景圖、字體文件轉(zhuǎn) Base64塞進(jìn) CSS。MHTML 封裝按 MIME 規(guī)范拼裝多部分文檔。Blob 下載生成application/msword類(lèi)型的 Blob觸發(fā)下載。每一步都有坑我逐個(gè)說(shuō)。3.2 階段一克隆 DOM 的正確姿勢(shì)別偷懶用innerHTML序列化再解析那會(huì)丟掉一部分屬性狀態(tài)還會(huì)觸發(fā)不必要的重排。用cloneNode(true)function cloneTargetNode(target) { const clone target.cloneNode(true); clone.style.margin 0; return clone; }這里有兩個(gè)細(xì)節(jié)。第一克隆出來(lái)的節(jié)點(diǎn)不要掛到文檔流里掛上去又要清理麻煩。第二如果原節(jié)點(diǎn)依賴(lài)父級(jí)樣式比如繼承了font-family克隆后脫離了上下文會(huì)丟樣式所以后面做樣式固化時(shí)必須用原節(jié)點(diǎn)去拿計(jì)算樣式而不是克隆節(jié)點(diǎn)。這是個(gè)大坑我第一版就栽在這——克隆后getComputedStyle拿到一堆空值。3.3 階段二樣式固化整條鏈路的技術(shù)核心樣式固化說(shuō)白了就是遍歷每個(gè)元素用getComputedStyle拿到它所有最終生效的樣式挑出 Word 能認(rèn)的那部分寫(xiě)進(jìn)style屬性。為什么不能全寫(xiě)因?yàn)橛?jì)算樣式有 300 多個(gè)屬性全寫(xiě)進(jìn)去文件會(huì)大到離譜而且很多屬性 Word 根本不認(rèn)寫(xiě)了也是噪音。我需要維護(hù)一個(gè)白名單const STYLE_WHITELIST [ color, background-color, font-family, font-size, font-weight, font-style, text-decoration, text-align, vertical-align, line-height, border, border-collapse, padding, margin, width, height, letter-spacing ];遍歷邏輯function inlineStyles(sourceNode, cloneNode) { const sourceChildren [sourceNode, ...sourceNode.querySelectorAll(*)]; const cloneChildren [cloneNode, ...cloneNode.querySelectorAll(*)]; sourceChildren.forEach((source, index) { const target cloneChildren[index]; if (!target || !target.style) return; const computed window.getComputedStyle(source); STYLE_WHITELIST.forEach(prop { const value computed.getPropertyValue(prop); if (value value ! none value ! normal || prop text-align) { target.style.setProperty(prop, value); } }); // 背景圖單獨(dú)處理需要轉(zhuǎn) base64 const bgImage computed.getPropertyValue(background-image); if (bgImage bgImage ! none) { target.style.setProperty(background-image, bgImage); } }); }這里必須保證原節(jié)點(diǎn)和克隆節(jié)點(diǎn)的遍歷順序完全一致querySelectorAll(*)的返回順序是文檔序只要兩棵樹(shù)的層結(jié)構(gòu)一樣索引就對(duì)得上。這一點(diǎn)在開(kāi)始寫(xiě)之前就要保證克隆是完整的深拷貝。注意width和height從計(jì)算樣式里拿到的是像素值比如width: 200px。Word 對(duì) px 的支持還不錯(cuò)但線寬、邊框這類(lèi)建議用 pt。我在實(shí)踐里對(duì)邊框做了 px 到 pt 的換算px * 0.75 ptWord 里顯示更規(guī)整。3.4 階段三資源內(nèi)聯(lián)化別讓圖片變成紅叉頁(yè)面上只要有圖片就必須處理。兩種來(lái)源img src和 CSSbackground-image。img的處理async function inlineImages(node) { const images node.querySelectorAll(img); for (const img of images) { const src img.getAttribute(src); if (!src || src.startsWith(data:)) continue; const base64 await urlToBase64(src); img.setAttribute(src, base64); } } function urlToBase64(url) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(GET, url, true); xhr.responseType blob; xhr.onload () { const reader new FileReader(); reader.onloadend () resolve(reader.result); reader.readAsDataURL(xhr.response); }; xhr.onerror reject; xhr.send(); }); }用 XHR 而不是fetch是因?yàn)槟承├檄h(huán)境里fetch對(duì)同源的 blob 處理有兼容問(wèn)題。用 XHR 的responseType blob再轉(zhuǎn) DataURL 是最穩(wěn)的。注意跨域圖片會(huì)失敗這屬于瀏覽器同源策略無(wú)解只能讓后端代理或者提前轉(zhuǎn)好。CSS 背景圖同理要把url(...)里的地址替換成 Base64。這里容易漏掉background簡(jiǎn)寫(xiě)屬性Word 對(duì)background簡(jiǎn)寫(xiě)支持不好建議全部展開(kāi)成background-color和background-image。3.5 階段四與五MHTML 拼裝與下載拼裝邏輯其實(shí)就是字符串模板把邊界分隔符、頭部、各資源部分依次拼起來(lái)function buildMHTML(htmlContent, resources) { const boundary ----_NextPart_01_MHTML; let lines [ MIME-Version: 1.0, Content-Type: multipart/related; boundary boundary , , -- boundary, Content-Location: file:///C:/export/main.html, Content-Type: text/html; charsetutf-8, , htmlContent, ]; resources.forEach(res { lines.push(-- boundary); lines.push(Content-Location: res.location); lines.push(Content-Type: res.type); lines.push(); lines.push(res.content); lines.push(); }); lines.push(-- boundary --); return lines.join(\r\n); }行分隔符必須用\r\n這是 MIME 規(guī)范要求。用\n的話部分 Word 版本解析會(huì)出問(wèn)題導(dǎo)致整個(gè)文檔打開(kāi)是空白或者報(bào)錯(cuò)。這是我調(diào)試最久的一個(gè) bug因?yàn)樗粓?bào)錯(cuò)只是靜靜地不工作。下載部分function downloadAsWord(mhtmlContent, filename) { const blob new Blob([mhtmlContent], { type: application/msword }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename .doc; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }導(dǎo)出成.doc而不是.docx是刻意的。Word 打開(kāi).doc時(shí)會(huì)走 HTML 兼容解析路徑正好吃我們這套 MHTML如果命名成.docxWord 會(huì)按 OOXML 去解析直接報(bào)文件損壞。4. 實(shí)操落地一份可以抄的完整實(shí)現(xiàn)4.1 完整代碼組織我把上面各段拼成一個(gè)可用的模塊目錄結(jié)構(gòu)很簡(jiǎn)單export/ ├── index.js // 對(duì)外入口 ├── styleInliner.js // 樣式固化 ├── resourceInliner.js// 資源內(nèi)聯(lián) └── mhtmlBuilder.js // MHTML 拼裝對(duì)外入口export async function exportHtmlToWord(targetElement, filename export) { const clone targetElement.cloneNode(true); inlineStyles(targetElement, clone); await inlineImages(clone); await inlineBackgroundImages(clone); const html wrapHtmlDocument(clone.outerHTML, targetElement); const resources collectFontResources(); const mhtml buildMHTML(html, resources); downloadAsWord(mhtml, filename); }wrapHtmlDocument負(fù)責(zé)補(bǔ)上完整的!doctype htmlhtml langzh-cnheadmeta charsetutf-8這套外殼并且把頁(yè)面的style關(guān)鍵規(guī)則也帶進(jìn)去一份。為什么明明內(nèi)聯(lián)了還帶一份樣式因?yàn)?Word 有個(gè)怪脾氣它會(huì)優(yōu)先讀style塊里的page規(guī)則來(lái)做頁(yè)面設(shè)置比如頁(yè)邊距、紙張方向這部分內(nèi)聯(lián)樣式表達(dá)不了。4.2 頁(yè)面設(shè)置用 page 控制紙張想在導(dǎo)出的 Word 里控制頁(yè)邊距和紙張方向靠的是page規(guī)則page { size: A4 portrait; margin: 2cm 1.5cm 2cm 1.5cm; }size可以用A4、A3、letter這些預(yù)設(shè)值也可以寫(xiě)具體尺寸。margin的順序是上、右、下、左和 CSS 的簡(jiǎn)寫(xiě)一致。橫向打印就寫(xiě)size: A4 landscape;。這塊 Word 支持得還不錯(cuò)實(shí)測(cè)下來(lái) A4 和 margin 都能正確生效。提示page寫(xiě)在style里不要寫(xiě)在元素的 style 屬性上寫(xiě)在那上面無(wú)效。這是規(guī)范決定的page是頁(yè)面級(jí)規(guī)則不是元素級(jí)。4.3 表格還原重點(diǎn)攻堅(jiān)區(qū)表格是報(bào)表導(dǎo)出的重頭戲。HTML 表格在 Word 里能還原但有幾個(gè)關(guān)鍵屬性必須顯式帶上屬性作用不寫(xiě)的后果border-collapse: collapse邊框合并單元格之間出現(xiàn)雙線縫table-layout: fixed固定列寬列寬按內(nèi)容自適應(yīng)和頁(yè)面不一致width(在 col 或 th 上)顯式列寬列寬亂掉vertical-align垂直對(duì)齊內(nèi)容全擠在頂部我處理表格時(shí)會(huì)把colgroup里的列寬顯式轉(zhuǎn)成百分比或 px 寫(xiě)到每個(gè)單元格上因?yàn)?Word 對(duì)colgroup的支持不太穩(wěn)定。實(shí)測(cè)把手動(dòng)列寬寫(xiě)到三維單元格的width上還原度明顯提升。4.4 字體處理中文場(chǎng)景必須關(guān)注中文字體是導(dǎo)出后變化最明顯的地方。默認(rèn)情況下好多環(huán)境里會(huì)退化成宋體。解決辦法在font-family里做一個(gè)字體棧font-family: Microsoft YaHei, 微軟雅黑, PingFang SC, Hiragino Sans GB, sans-serif;Word 會(huì)按順序找找到系統(tǒng)里裝了的就用。微軟雅黑在 Windows 上基本都有蘋(píng)方在 Mac 上基本都有加上sans-serif兜底。需要 PPT 報(bào)告那種黑體效果的話把黑體SimHei放前面。如果你的業(yè)務(wù)需要字體絕對(duì)一致那得用font-face把字體文件 Base64 內(nèi)嵌。這個(gè)會(huì)讓文件體積暴漲一個(gè)中文字體動(dòng)輒十幾 MB我的建議是只對(duì)標(biāo)題這類(lèi)少量文字用內(nèi)嵌字體正文還是走系統(tǒng)字體棧性?xún)r(jià)比最高。5. 常見(jiàn)問(wèn)題與排查速查5.1 導(dǎo)出后打開(kāi)是空白或提示文件損壞這是最高頻的問(wèn)題我整理了排查順序現(xiàn)象最可能的原因解決打開(kāi)全白換行符用了\n而非\r\n全部替換為\r\n提示內(nèi)容有問(wèn)題文件擴(kuò)展名寫(xiě)成了.docx改回.doc內(nèi)容有一段亂碼charset 聲明缺失或不對(duì)補(bǔ)charsetutf-8部分版本能開(kāi)部分不能boundary 字符串含特殊字符boundary 只用字母數(shù)字和連字符注意boundary 的取值很講究必須以--結(jié)尾不出現(xiàn)在內(nèi)容里且不要用引號(hào)、空格這些字符。我統(tǒng)一用----_NextPart_01_MHTML這種形式穩(wěn)。5.2 樣式還原度不達(dá)預(yù)期我遇到過(guò)導(dǎo)出后所有文字都變宋體、背景色全丟的情況。排查下來(lái)是兩個(gè)原因一是樣式固化時(shí)白名單漏了background-color二是font-family取到的是-apple-system這類(lèi)系統(tǒng)關(guān)鍵字Word 認(rèn)不出來(lái)。改成顯式字體名后就好了。還有一個(gè)隱蔽的坑getComputedStyle返回的顏色是rgb(0, 0, 0)格式某些 Word 版本對(duì)rgb()支持不好只認(rèn)十六進(jìn)制。我在輸出前統(tǒng)一做了一次轉(zhuǎn)換function toHex(color) { const match color.match(/rgb\((\d),\s*(\d),\s*(\d)\)/); if (!match) return color; return # [1, 2, 3].map(i Number(match[i]).toString(16).padStart(2, 0) ).join(); }加上這個(gè)轉(zhuǎn)換之后顏色丟失的問(wèn)題基本絕跡。5.3 大文檔導(dǎo)出卡頓頁(yè)面元素一多getComputedStyle是個(gè)重操作幾百個(gè)元素逐個(gè)調(diào)用會(huì)明顯卡。我的優(yōu)化是把固化過(guò)程做成異步分批async function inlineStylesInBatch(sourceChildren, cloneChildren) { const BATCH 100; for (let i 0; i sourceChildren.length; i BATCH) { const slice sourceChildren.slice(i, i BATCH); slice.forEach((source, offset) { inlineSingle(source, cloneChildren[i offset]); }); await new Promise(r setTimeout(r, 0)); } }每批處理完setTimeout(0)讓出主線程UI 就不卡了。加個(gè)進(jìn)度提示體驗(yàn)更好。另外緩存getComputedStyle的結(jié)果也有用如果同一類(lèi)元素樣式相同可以只算一次。5.4 Word 打開(kāi)后表格列寬無(wú)法拖動(dòng)有人反饋導(dǎo)出的表格列寬被鎖死拖不動(dòng)。這是因?yàn)槲仪懊姘褀idth顯式寫(xiě)到了單元格上Word 把它當(dāng)成了固定寬度。如果業(yè)務(wù)希望導(dǎo)出的表格可編輯、列寬可調(diào)那就不要寫(xiě)死單元格寬度改成用table-layout: auto配合colgroup的百分比。這是個(gè)取舍要還原度就寫(xiě)死要可編輯性就放開(kāi)。我一般給兩個(gè)導(dǎo)出選項(xiàng)讓用戶選。6. 一些我踩坑后總結(jié)的心得先說(shuō)一個(gè)反直覺(jué)的點(diǎn)并不是所有樣式都要固化。我一開(kāi)始把計(jì)算樣式全部搬進(jìn)去結(jié)果文件 20MB 打開(kāi)巨慢。后來(lái)精簡(jiǎn)到白名單體積降到 200KB 左右還原度反而沒(méi)下降——因?yàn)?Word 不認(rèn)的那些屬性寫(xiě)了也是浪費(fèi)。第二個(gè)心得優(yōu)先用表格做布局還原。Word 的 HTML 解析器對(duì)divfloat/flex支持極差但對(duì)table的支持非常好。如果頁(yè)面上的多列布局導(dǎo)出后亂了一個(gè)有效的補(bǔ)救手段是在導(dǎo)出前把某些區(qū)塊的渲染結(jié)果轉(zhuǎn)成表格結(jié)構(gòu)。粗暴但有效。第三個(gè)是調(diào)試技巧先把生成的 MHTML 存成.mht文件用瀏覽器打開(kāi)看看。瀏覽器能正確渲染的 MHTMLWord 大概率也能正確解析如果瀏覽器打開(kāi)就是亂的那肯定是拼裝環(huán)節(jié)出了問(wèn)題不用去懷疑 Word。這個(gè)二分法能幫你快速定位問(wèn)題出在打包還是出在 Word 解析。最后關(guān)于字體那個(gè)老問(wèn)題——如果你在 Mac 上開(kāi)發(fā)、在 Windows 上給客戶用字體渲染差異是必然的。我的做法是在導(dǎo)出配置里加一個(gè)目標(biāo)平臺(tái)選項(xiàng)Mac 走蘋(píng)方字體棧Windows 走雅黑字體棧讓用戶自己選。這種細(xì)節(jié)看起來(lái)小但對(duì)交付質(zhì)量的感知影響很大。這套方案我已經(jīng)在幾個(gè)報(bào)表系統(tǒng)里跑了一年多覆蓋合同、報(bào)表、簡(jiǎn)歷、分析報(bào)告這幾類(lèi)場(chǎng)景穩(wěn)定性沒(méi)問(wèn)題。它的定位很清楚不是要做 100% 像素級(jí)復(fù)刻而是要在零后端依賴(lài)的前提下把還原度做到直接可交付的水平。如果你也在為前端導(dǎo)出 Word 頭疼從 MHTML 這條思路切入大概率能少走不少?gòu)澛贰?