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