
简介面向需要将网页表格数据导出为Excel并保留表格样式的前端开发者这是一份简明的JS实现说明。资料针对谷歌浏览器环境系统梳理了两种保留样式的方法一是在table行内直接编写style样式二是在导出模板中添加样式规则并解释为何模板内样式可被Excel正确识别。文中提供完整可运行的HTML示例包含base64转码、数据替换、Blob下载链接生成等关键导出逻辑读者可据此快速复制改造到自己的项目中。资源包共1个文件为PDF文档大小62KB适合需要快速查阅代码思路的初中级前端工程师。该文档已有2077人学习下载内容篇幅精悍但覆盖表格导出核心痛点尤其对样式丢失、合并单元格和单元格背景色处理有直接参考价值。1. 为什么“导出Excel还保留样式”成了前端必踩的坑一个再常见不过的需求页面里有张 table用户点一下“导出 Excel”希望能把表格原样放进 xlsx 文件里——连带边框、底色、合并单元格、列宽行高一起打开文件就像把网页截图变成了可编辑表格。可实际做起来十个前端九个翻车要么导出的文件打开乱码要么样式全丢只剩干巴巴的数据要么合并单元格错位到没法看。问题不在导出这个动作而在“样式”这两个字——Excel 的单元格样式模型和 HTML 的 CSS 视觉模型根本不是一回事直接拿表格 DOM 去拼文件必然有边界要踩。这篇笔记我会从选型讲起给出一套能直接用在前端项目里的实现方案把样式映射、合并单元格、字符集、大数据量这些常见坑一次说清。适合正在做报表导出、管理后台 Excel 下载功能的前端开发者也适合想搞明白“为什么别人能导出带样式而我不行”的排查型读者。2. 导出方案的选型从 Table 转 Excel 的三种常见路线2.1 路线一纯前端表格转 HTML用 Blob 导出 .xls——最轻但样式有限先说最早也最常见的做法把 table 的 HTML 片段包成一个完整的 HTML 文档加上table标签和内联样式然后转成 Blob指定 MIME 类型为application/vnd.ms-excel下载成.xls文件。这种方案的核心逻辑很简单Excel 能打开 HTML 格式的 .xls 文件并把它当作表格渲染。所以理论上你在网页里的border、background-color、font-weight这些内联样式都能迁移过去。代码量极小不需要引入任何第三方库一个函数能写完。function exportTableToExcel(domId, filename export.xls) { const table document.getElementById(domId); const html html xmlns:ourn:schemas-microsoft-com:office:office xmlns:xurn:schemas-microsoft-com:office:excel headmeta charsetutf-8/head bodytable${table.innerHTML}/table/body /html; const blob new Blob([html], { type: application/vnd.ms-excel;charsetutf-8 }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download filename; link.click(); URL.revokeObjectURL(link.href); }这里charsetutf-8是为了让 Excel 正确识别中文否则打开后表头会变成乱码。实际使用中你会发现这个方案对简单表格还能应付一旦涉及合并单元格的rowspan、colspan或者列宽col width的精确控制表现就很不稳定。它本质是让 Excel 去兼容 HTML而 Excel 的 HTML 渲染引擎多年未更新对 CSS 的支持停留在很老的版本上padding、伪元素、box-shadow这些一概不认甚至连border-collapse: collapse都可能出现双线边框。另一个更大的坑是安全提示用这种方式导出的.xls文件Excel 打开时会弹“文件格式与扩展名不匹配”的警告。原因是文件内容实际是 HTML 文档扩展名却是.xlsExcel 通过内容识别发现货不对板。用户每次都要点“是”才能打开体验很差。所以这条路线我只建议用在“内部工具、能接受警告、无复杂样式”的场景如果要交付给外部用户别选它。2.2 路线二SheetJSxlsx社区版数据导出强样式支持弱SheetJS 是前端处理 Excel 事实上的标准库社区版xlsx包提供json_to_sheet、table_to_sheet、writeFile等 API能把数组、JSON、DOM 表格直接转成 xlsx 文件。但它有个长期被吐槽的短板社区版不支持样式写入。看一个最小例子import * as XLSX from xlsx; function exportBySheetJS(domId, filename) { const table document.getElementById(domId); const sheet XLSX.utils.table_to_sheet(table); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, sheet, Sheet1); XLSX.writeFile(workbook, filename); }这段代码能把表格里的数据导出来列顺序、单元格文本都对但你去打开生成的 xlsx会发现字体、颜色、边框、背景色全部丢失合并单元格虽然会被保留但样式依旧没有。社区版的cellStyles选项历史上存在过后来被移到了付费版里现在npm i xlsx装到的版本对样式写入基本是空操作。那为什么还要提它因为它的数据解析和写入效率很高sheet_to_json、aoa_to_sheet这些 API 处理数组数据非常顺手。常见的最佳实践是用 SheetJS 做数据转换和文件写入样式部分要么放弃要么自己往 sheet 的!cols、!merges等内部结构里补。但如果你需要完整的单元格样式还要支持边框、字体、填充、对齐我建议直接用下一条路线的库不要拿 xlsx 社区版硬凹。2.3 路线三ExcelJS / xlsx-js-style能写样式的正经路子真正能在前端生成带样式 xlsx 文件的方案有两个主流exceljs和xlsx-js-style。exceljs是比较完整的 Excel 文件读写库支持样式、合并单元格、条件格式、公式、图表API 设计也更贴近 Excel 对象模型。它的缺点是包体积大浏览器端使用需要引入exceljs/dist/exceljs.min.js而且它的样式 API 是逐个单元格设置的写起来比较啰嗦。xlsx-js-style是 SheetJS 社区版的一个 fork在保持原 API 的基础上给单元格对象增加了s属性用来描述样式。比如你可以写const cell { v: 你好, s: { font: { bold: true }, fill: { fgColor: { rgb: FFFF00 } } } };然后XLSX.utils.sheet_add_aoa(sheet, data, { origin: A1 })时二维数组里每个元素既可以是一个普通值也可以是{ v: 值, s: 样式 }这样的对象。这样既能复用 SheetJS 的表格生成逻辑又能在单元格级别写样式代码改动量相对小。两个库怎么选我的经验是如果项目里已经在用 SheetJS 处理数据或者只想导出一次、不想引入太多依赖用xlsx-js-style如果要做复杂报表、需要多次操作单元格、要控制行高列宽甚至插入图片用exceljs。但要注意xlsx-js-style社区维护频率一般遇到需要 bug 修复的情况可能要自己 patchexceljs则因为浏览器端包的模块化问题需要构建工具配合。从本篇文章的标题出发我会以xlsx-js-style为最终实现库原因有三第一它和 SheetJS API 兼容table_to_sheet可以直接用省掉手写行列解析第二样式描述是声明式的 JSON容易理解和维护第三已经有不少生产项目验证过它的稳定性。下面进入正题。3. 用 xlsx-js-style 把 Table 导出成带样式的 Excel最小可运行实现3.1 获取表格数据从 DOM 解析 thead/tbody还是从数据源直接构建在写导出函数之前先要明确一个问题你的数据从哪来这决定了实现路径。一种方式是直接读取页面上的 DOM 表格用table_to_sheet把整个 table 转成 sheet。这种方式适合“所见即所得”页面上表格已经渲染好了用户看到的和导出的应当一致。但它的样式信息是浏览器计算后的结果比如背景色可能来自 CSS class 而不是内联样式getComputedStyle才能拿到真正生效的值而table_to_sheet只读取内联样式和部分属性所以直接转换后样式往往是空的。另一种方式是从内存中的数据源构建比如 antd 的 Table 组件数据在dataSource数组里列配置在columns数组里。此时你完全可以从数据源构造工作表而不是依赖 DOM。这样做的好处是样式可控你清楚每一列是什么类型可以按业务规则统一设置表头字体、边框、对齐方式而不是去猜 DOM 上某个 class 对应什么颜色。缺点是要自己写一层列配置到 Excel 列样式的映射表头和单元格的数据组装也要自己来。我的建议是如果表格是静态的、结构简单直接table_to_sheet再用getComputedStyle补样式如果表格是动态渲染的来自 Vue、React尤其是 antd 的 table务必从数据源构建。后者更稳定也更容易应对列隐藏、列排序等变化。3.2 把行列样式映射成 Excel 单元格样式字体、边框、对齐、填充Excel 的单元格样式模型和 CSS 字段有对应关系但不是一一对应。我需要先把常见映射关系列出来后面代码会用到。CSS / DOM 概念Excel 样式属性说明font-weight: bolds.font.bold true加粗Excel 只有 true / false没有数值粗细font-style: italics.font.italic true斜体text-decoration: underlines.font.underline true下划线font-size: 14pxs.font.size 11Excel 字号单位是磅pt1 pt ≈ 1.333 px常用经验是 14px 对应 11ptbackground-colors.fill.fgColor.rgb需要去掉#写 6 位十六进制border/border-tops.border.top.style、s.border.top.colorstyle 有thin、medium、thick、dashed等取值text-align: centers.alignment.horizontal center可选left、center、right、justifyvertical-align: middles.alignment.vertical center可选top、center、bottomwhite-space: nowraps.alignment.wrapText false默认 false如果希望自动换行设为 truecolspan/rowspan!merges合并区间需在写入数据后追加到 sheet 的!merges数组这套映射表就是整个导出功能的核心。在实际编码前建议先建立一个小型“样式字典”把项目里常用的几种单元格样式定义为常量比如表头样式、正文样式、合计行样式。这样后面构造单元格时直接引用不会出现每个单元格都重新写一遍样式的情况也方便整体调整。3.3 完整代码一个 exportTableWithStyle 函数下面给出一份最小可运行实现。它从 DOM 读取表格的列配置thead 里的表头文本和行数据tbody 里的文本同时用getComputedStyle读取每个单元格的计算样式写入 Excel 单元格的s属性。import XLSX from xlsx-js-style; function exportTableWithStyle(tableId, filename table.xlsx) { const table document.getElementById(tableId); if (!table) { throw new Error(未找到 id 为 ${tableId} 的表格元素); } // 1. 获取列宽信息读取 thead 中每个 th 的 offsetWidth换算成 Excel 列宽 const thead table.querySelector(thead); const tbody table.querySelector(tbody); const thList Array.from(thead.querySelectorAll(th)); const colWidths thList.map(th { const pxWidth th.offsetWidth || 80; return Math.max(8, Math.round(pxWidth / 7)); // 粗略换算px 与 Excel 字符宽度比例约 7:1 }); // 2. 构造二维数据第一行为表头 const data []; const headerRow thList.map((th, colIndex) { return { v: th.innerText.trim(), s: getCellStyleFromDom(th, true) // 表头样式 }; }); data.push(headerRow); // 3. 遍历 tbody 的行 const trList Array.from(tbody.querySelectorAll(tr)); trList.forEach(tr { const tdList Array.from(tr.querySelectorAll(td)); const row tdList.map((td, colIndex) { return { v: td.innerText.trim(), s: getCellStyleFromDom(td, false) }; }); data.push(row); }); // 4. 生成 worksheet设置列宽 const ws XLSX.utils.aoa_to_sheet([]); // 先创建空 sheet XLSX.utils.sheet_add_aoa(ws, data, { origin: A1 }); ws[!cols] colWidths.map(width ({ wch: width })); // 5. 处理合并单元格读取 td 上的 rowspan / colspan const merges []; let currentRow 1; // 第 0 行是表头数据从第 1 行开始 trList.forEach((tr, rowIndex) { const tdList Array.from(tr.querySelectorAll(td)); let colOffset 0; tdList.forEach((td, tdIndex) { const rowspan parseInt(td.getAttribute(rowspan) || 1, 10); const colspan parseInt(td.getAttribute(colspan) || 1, 10); // 跳到实际列位置需要考虑此前合并单元格占掉的列 while (isInMergedRegion(merges, currentRow rowIndex, colOffset)) { colOffset; } if (rowspan 1 || colspan 1) { merges.push({ s: { r: currentRow rowIndex, c: colOffset }, e: { r: currentRow rowIndex rowspan - 1, c: colOffset colspan - 1 } }); } colOffset; }); }); if (merges.length 0) { ws[!merges] merges; } // 6. 生成 workbook 并触发下载 const wb XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, Sheet1); XLSX.writeFile(wb, filename); } function getCellStyleFromDom(el, isHeader) { const style window.getComputedStyle(el); const borderColor style.borderTopColor || #000000; const bgColor isHeader ? style.backgroundColor : style.backgroundColor; const s { font: { bold: isHeader || style.fontWeight bold, sz: parseFloat(style.fontSize) ? Math.round(parseFloat(style.fontSize) * 0.75) : 11, color: { rgb: hexToRgb(style.color) } }, fill: { fgColor: { rgb: hexToRgb(bgColor) } }, alignment: { horizontal: style.textAlign || left, vertical: style.verticalAlign || center, wrapText: style.whiteSpace normal }, border: { top: { style: thin, color: { rgb: hexToRgb(borderColor) } }, bottom: { style: thin, color: { rgb: hexToRgb(borderColor) } }, left: { style: thin, color: { rgb: hexToRgb(borderColor) } }, right: { style: thin, color: { rgb: hexToRgb(borderColor) } } } }; return s; } function hexToRgb(color) { // 将 rgb(255, 0, 0) 或 #ff0000 转换成 6 位 hex if (color.startsWith(#)) return color.replace(#, ).toUpperCase(); const m color.match(/rgba?\((\d),\s*(\d),\s*(\d)/); if (m) { return [m[1], m[2], m[3]].map(x Number(x).toString(16).padStart(2, 0)).join().toUpperCase(); } return FFFFFF; }这段代码的逻辑分六步。第 1 步读取th的offsetWidth估算列宽wch是 Excel 的字符宽度单位用像素值除以 7 得到一个近似值后面可以根据实际效果微调系数。第 2 步构造表头行每个单元格是一个对象v是显示文本s是样式对象。第 3 步遍历数据行同理。第 4 步是关键先用aoa_to_sheet([])建一个空 sheet再调用sheet_add_aoa把数据带样式写进去这样单元格对象里的s属性才会被保留如果直接aoa_to_sheet(data)二维数组里的对象也能被解析但有些版本对s属性的处理不一致先建空表再写入更稳妥。第 5 步解析rowspan和colspan需要特别注意的是列偏移量的计算如果前面的单元格有colspan后面兄弟单元格的实际列号要累加这里我写了一个isInMergedRegion辅助函数来判断偏移。第 6 步直接用XLSX.writeFile触发浏览器下载。这里有几个参数值得说明。style.font.sz的换算浏览器中的fontSize是像素Excel 字号单位是磅标准换算关系是 1pt 1.333px所以用px * 0.75得到 pt 值我取了四舍五入。wrapText的映射不是看white-space是否为nowrap而是看是否允许换行所以whiteSpace normal时设为true更合理。边框我这里统一用了thin实线颜色取了元素计算样式的边框色如果表格里不同区域边框粗细不同这个逻辑需要扩展成读取各方向边框宽度再做映射。isInMergedRegion的代码如下function isInMergedRegion(merges, row, col) { return merges.some(m row m.s.r row m.e.r col m.s.c col m.e.c); }它的作用是判断当前坐标是否已经被前面某个合并单元格覆盖。解析 DOM 时一个带colspan的td会在渲染层占据多个列但它后面的td在 DOM 里仍然是从第 2 个开始排如果不跳掉被占用的列数据就会整体错位。这段逻辑虽然不复杂但容易漏算是一个必须处理的细节。4. 样式映射的细节哪些样式能带过去哪些会翻车4.1 对齐与换行wrapText、horizontal、vertical 的坑在 Excel 里单元格对齐分水平和垂直两个方向对应alignment.horizontal和alignment.vertical。DOM 里text-align的取值相对简单left、center、right、justify能直接映射但justify在 Excel 里对应justify实际渲染效果和 HTML 的text-align: justify不完全一样表现为英文单词间距被拉伸、中文则无明显变化所以遇到justify我建议直接转成left避免打开 Excel 后排版异常。垂直对齐是最容易翻车的点。浏览器默认的vertical-align是baseline但getComputedStyle返回的往往是baseline而 Excel 的vertical只支持top、center、bottom、justify、distributed不认识baseline。如果不做处理把baseline直接写进s.alignment.verticalExcel 打开文件时可能忽略这个非法值回到默认的bottom对齐视觉上所有内容都贴在单元格底部很难看。解决办法是读取值时先判断如果是baseline或middle统一映射成centersub、super这类在表格场景很少见直接归为top。换行也是一个高频坑。HTML 表格里如果设置了white-space: nowrap那么单元格内容不会换行导出的 Excel 里应该保持wrapText: false。反过来如果页面里单元格内容很长且 CSS 允许换行那么需要把wrapText设为true否则 Excel 会把长文本溢出到相邻单元格打印或复制时内容显示不全。但注意wrapText只控制“自动换行”当单元格里有显式换行符\n时无论wrapText是否开启Excel 都会换行。所以如果你的表格数据里有\n比如地址字段要提前意识到导出的文件里这些换行会保留不会因为样式映射而丢失。4.2 边框与合并单元格mergeCells 的处理顺序先有数据再有合并这是我处理合并单元格时坚持的顺序。在xlsx-js-style里合并的信息写在ws[!merges]数组里每一项是{ s: { r: 起始行, c: 起始列 }, e: { r: 结束行, c: 结束列 } }。合并操作不会改变数据单元格的数量只是把多个单元格视觉上拼成一个。这意味着合并区间内除了左上角那个单元格需要保留值和样式其余被合并的单元格在数据数组中仍然占着位置你不能因为它们被合并了就把值删掉否则行列会错位。实际的坑在于当合并区间跨越多个单元格时Excel 只显示左上角单元格的边框其余单元格的边框会被忽略。如果你在 DOM 里看到的是一个带边框的合并单元格导出后可能只有左上角有边框右边界和下边界消失了。解决办法是对合并区域的四条边手动设置到对应的边界单元格上。例如一个A1:C3的合并区左边框设在A1右边框设在C1的right下边框设在A3、B3、C3的bottom。这要求你在构造样式时对每个单元格的位置做判断不能简单地把 DOM 里那个td的边框整个复制到左上角。我这里给一个处理函数的核心片段function applyMergedRegionBorders(ws, merge) { const { s, e } merge; for (let r s.r; r e.r; r) { for (let c s.c; c e.c; c) { const cell ws[XLSX.utils.encode_cell({ r, c })]; if (!cell) continue; cell.s cell.s || {}; cell.s.border cell.s.border || {}; // 上边界只有起始行有 if (r s.r) { cell.s.border.top { style: thin, color: { rgb: 000000 } }; } // 下边界只有结束行有 if (r e.r) { cell.s.border.bottom { style: thin, color: { rgb: 000000 } }; } // 左边界只有起始列有 if (c s.c) { cell.s.border.left { style: thin, color: { rgb: 000000 } }; } // 右边界只有结束列有 if (c e.c) { cell.s.border.right { style: thin, color: { rgb: 000000 } }; } } } }这个函数在合并区域生成后调用确保合并区域外围有一圈完整的边框。注意它只处理了thin黑色边框实际项目中边框颜色和粗细应作为参数传入。4.3 列宽行高Excel 单位与像素的换算HTML 表格的列宽取决于内容、CSSwidth、table-layout等多种因素而 Excel 的列宽单位是“字符宽度”character width大约表示该列能容纳多少个半角字符。xlsx-js-style里通过ws[!cols]数组设置列宽每一项可以是{ wch: 宽度 }或{ width: 像素值 }。实测下来wch更接近 Excel 原生存储方式建议优先使用。像素到wch的换算没有一个官方精确公式不同字体、字号下比例不同。经验公式是wch ≈ px / 7前提是默认字体等线或宋体和 11pt 字号。如果你的表格字体较大或列内内容多为中文可能需要把系数从 7 调整到 6 或 8。调整方法很简单导出后用 Excel 打开看实际列宽太窄就把数值调大太宽就调小系数通常一次就能定下来。行高同理ws[!rows]数组里用hpt指定行高单位是磅如果 DOM 行有明确高度可以用px * 0.75换算如果没有显式高度建议不要设置行高让 Excel 根据内容自动撑高否则可能出现文字被截断的观感。有一个细节HTML 表格里列宽是不同列不同值的但table_to_sheet转换时不会读取列宽信息只能自己算。我上面的代码里用th.offsetWidth获取表头实际渲染宽度但需要注意如果表格容器有横向滚动条部分列处于隐藏或部分可见状态offsetWidth可能为 0 或偏小。这时候更好的做法是从table.style.width或第一行td的实际渲染尺寸取平均或者在导出前强制把表格容器滚动到最左侧让所有列渲染完整后再读取尺寸。4.4 数字格式为什么导出的手机号变成科学计数法这个坑不解决导出功能等于白做。当 Excel 单元格里写入的数字超过 11 位比如手机号 13800138000Excel 默认会显示为科学计数法1.38E10身份证号 18 位更是直接变成科学计数法且后几位变成 0数据彻底损坏。原因在于 Excel 的数字精度默认只有 15 位有效数字超过部分会被四舍五入。解决办法有两个方向。方向一在数据构造阶段把这类字段统一转成字符串这样 Excel 会按文本处理不会触发数字格式。但注意如果数据源里是数字类型转成字符串时要用String(value)而非value 降低隐式转换的坑。方向二在样式里指定数字格式s.numFmt 表示文本格式s.numFmt 0表示整数s.numFmt 0.00表示保留两位小数。如果你既要显示为数字又要避免科学计数法应该用numFmt: 0配合数值类型。但身份证号这种既不是数字也不需要参与计算的直接转字符串最省心。我在实际项目里见过一个惨痛案例导出的 Excel 里用户 ID 列 18 位数字后三位全变成 0导致下游系统对不上数据。排查到最后发现数据源里 ID 是字符串但导出代码里parseInt(td.innerText)了一下int 转换后再用数字写入就踩了精度坑。所以这里有一条铁律凡是超过 11 位的纯数字字段一律当字符串处理凡是需要保留前导零的字段如工号00123也一律当字符串处理。对 Excel 而言数据正确性远比数据“看起来是数字”重要。5. 避坑指南导出 Excel 保留样式最常见的 5 个翻车现场5.1 现象用 Blob 导出 .xls打开提示文件格式与扩展名不匹配这是第 2.1 节提到的 HTML 方案最典型的翻车场景。用户点击导出文件下载成功文件名后缀是.xls但双击打开后 Excel 弹出“文件格式和扩展名不匹配。文件已损坏或是危险类型”的警告虽然点“是”还能打开但很多用户会直接怀疑文件有问题甚至以为导出功能做坏了。原因文件内容实际是一个 HTML 文档Excel 用内容嗅探识别出它并不是真正的 OLE 或 XLSX 格式因此报警告。要彻底解决最直接的办法是不再使用.xls扩展名而是把内容保存为.xls的 HTML 变体但扩展名与内容不一致的问题依旧存在。更好的做法是走真正的 Excel 文件格式用 SheetJS 或 ExcelJS 生成.xlsx内容格式标准不会触发警告。如果因为历史原因必须用.xls可以在 HTML 中添加?xml version1.0?声明和 Excel 的 XML 命名空间降低误判概率但实测效果不稳定不推荐。我的建议是新项目一律输出.xlsx老项目如果已经在用 HTML 方案尽快迁移到xlsx-js-style迁移成本通常在一个工作日内收益是彻底消除警告和样式丢失问题。5.2 现象样式全丢了明明设置了 border 和 fill代码里明明在s对象里写了边框和背景色生成的 xlsx 打开后样式全没有数据倒是都在。这种情况多半是单元格对象没有被正确解析。排查方法把生成的 sheet 里某个单元格对象打印出来看s属性是否存在。如果存在看fill的fgColor是不是 6 位十六进制注意不要带#号xlsx-js-style对rgb字段的格式敏感rgb: #FFFF00可能会被忽略正确写法是rgb: FFFF00。另外patternType字段也是必需项fill对象里至少要有patternType: solid否则填充色不生效。这是新手最容易漏的。// 错误的 fill s.fill { fgColor: { rgb: FFFF00 } }; // 正确的 fill s.fill { patternType: solid, fgColor: { rgb: FFFF00 } };还有一个容易忽略的问题如果你用的是XLSX.utils.sheet_add_aoa传入的单元格对象必须被正确识别为“单元格对象”而非普通对象。在sheet_add_aoa的实现里如果元素是普通对象且有v属性会当作单元格对象处理如果元素是数组会按数组递归展开。如果你不小心把一个带v和s的对象又包了一层{ v: { v: text, s: {...} } }就会导致样式丢失。检查方式很简单把ws[A1]打出来看如果A1的值是个对象说明数据结构多了层包装。5.3 现象中文文件名乱码文件名download(报表).xlsx下载后变成一堆乱码或文件名变成%E6%8A%A5%E8%A1%A8.xlsx这种 URL 编码形式。这是因为a标签的download属性在跨浏览器场景下对非 ASCII 文件名支持不一致比如老版本 Safari 会忽略download属性直接打开文件Chrome 在某些情况下会按 URL 编码解析。如果使用XLSX.writeFile(wb, filename)SheetJS 内部默认用a标签下载同样存在编码问题。解决办法是手动创建 Blob 并设置filename为 decodeURI 后的值或者用XLSX.write拿到 ArrayBuffer 后自己构造Blob并利用URL.createObjectURL触发下载同时在link.download中直接写中文文件名。实测现代 Chrome、Firefox、Edge 都支持中文download属性不加处理但为了兼容老版本建议对文件名做一次encodeURIComponent再在download属性里写原始中文具体做法因浏览器而异稳妥起见可以全部走Blob objectURL方案。如果你使用exceljs它的writeBuffer返回 Promise也需要自己处理下载。中文问题集中在文件名不在文件内容文件内容的中文乱码通常是编码问题已在第 3.3 节的charsetutf-8中解决。5.4 现象合并单元格后内容错位导出的文件里合并区域出现了但合并区域里的数据串行A 列的数据跑到 C 列去了或者合并区域下方的数据整体错位。这个坑来自两个层面。第一层是 DOM 解析时没有处理colspan/rowspan占位。HTML 表格里一个colspan2的td只占一个 DOM 节点但在渲染层它占了两个列。如果你遍历tr.querySelectorAll(td)时没有记录已占用的列数将每个td按顺序写入 Excel 行那么跨列单元格后面的所有单元格都会向左偏移。解决办法已经在 3.3 节的代码里用一个colOffset变量记录当前列位置遇到合并单元格时把后续列位置后移。第二层是!merges数组的坐标和实际数据行列对不上。常见错误是忘了表头行占第 0 行导致合并区间整体下移一行。Excel 的行列索引是从 0 开始的但用户习惯从 1 开始写代码时很容易把表头行当成第 1 行于是s.r从 1 开始结果表头上方出现一个空行或合并区域错位。建议在所有合并计算中统一使用 0 基索引最后在测试时用 Excel 打开逐个核对位置。5.5 现象大数据量卡死浏览器表格有几千行、几十列点击导出后浏览器卡顿数秒甚至崩溃。原因是每次写入一个单元格的样式都要创建对象、解析颜色、计算边框几千行乘几十列就是几万个对象再加上getComputedStyle的调用开销主线程就扛不住了。优化思路有三个。第一减少getComputedStyle调用次数。表头和同类型的数据行往往样式一致你可以只对第一个单元格调用getComputedStyle后续行直接复用同一个样式对象但要确保不同行确实样式一致比如隔行变色就需要区分奇偶行。第二用批量数据构造替代逐格写入。sheet_add_aoa接受二维数组性能比逐个ws[address] cell高得多优先使用。第三分片导出。如果数据实在太大把数据切分成每批 500 行用requestAnimationFrame或setTimeout分批写入 sheet每批之间让浏览器喘息一下避免长时间阻塞渲染。但注意最终writeFile时仍然会一次性编码整个文件这一段的耗时无法避免大数据量场景应给用户一个“正在导出”的遮罩提示避免重复点击。还要提醒一句不要用JSON.stringify深拷贝整个 worksheet 来做任何中间操作大数据量下这种操作会撑爆内存。需要调整数据时直接操作ws对象的单元格字段不要整表拷贝。6. 进阶玩法按需导出、模板导出与批量样式优化的实战技巧6.1 只导出用户勾选的列保留样式实际业务中表格可能有 20 列但用户只想导出其中 5 列。如果直接遍历 DOM没法跳过未勾选的列因为td的位置是固定的。常见做法是维护一份“可见列索引”列表在构造数据时只取这些索引。function exportSelectedColumns(tableId, columnIndexs, filename) { const table document.getElementById(tableId); const thead table.querySelector(thead); const tbody table.querySelector(tbody); const thList Array.from(thead.querySelectorAll(th)); const headerRow columnIndexs.map(idx { const th thList[idx]; if (!th) return { v: , s: defaultHeaderStyle }; return { v: th.innerText.trim(), s: getCellStyleFromDom(th, true) }; }); // 数据行同理tr.querySelectorAll(td) 后再按 columnIndexs 过滤 // 注意合并单元格的列索引在此场景下需要特殊处理因为合并单元格会占用多个索引 const rows Array.from(tbody.querySelectorAll(tr)).map(tr { const tdList Array.from(tr.querySelectorAll(td)); return columnIndexs.map(idx { const td tdList[idx]; return td ? { v: td.innerText.trim(), s: getCellStyleFromDom(td, false) } : null; }).filter(Boolean); }); // 后续生成 ws 的逻辑与 3.3 相同 }这里有一个关键点列索引是渲染层索引不是 DOM 索引。当存在colspan时一个td可能占据多个渲染列所以“用户勾选的列”在数值上对应的是渲染列的序号你需要先在 DOM 层把td映射到渲染列起始索引再做过滤。实现方式是在遍历td时累加colspan值构建一个“渲染列号 - td” 的映射表然后按可见列号取对应的td。否则勾选第 5 列实际拿到的却是 DOM 里第 5 个td在存在跨列时结果会错。6.2 用模板表头让导出更专业预置样式只填数据如果你不想每次导出都从 DOM 读样式而是希望导出的 Excel 有固定的品牌风格比如公司 Logo 区域、统一的标题行、特定的表头背景色可以做一个“模板工作簿”方案。做法是预先用 Excel 设计好一个.xlsx文件包含标题、表头、样式、列宽甚至公司 Logo 图片作为模板放在项目静态资源里。导出时用xlsx.js或exceljs读取模板往指定位置填充数据然后另存为新文件。import XLSX from xlsx-js-style; async function exportWithTemplate(templateUrl, dataRows, filename) { const response await fetch(templateUrl); const arrayBuffer await response.arrayBuffer(); const workbook XLSX.read(arrayBuffer, { type: array }); const sheet workbook.Sheets[workbook.SheetNames[0]]; // 假设模板中第 5 行开始是数据区A 到 D 列 dataRows.forEach((row, i) { const r 4 i; // 0 基行号 sheet[XLSX.utils.encode_cell({ r, c: 0 })] { v: row[0], s: { font: { sz: 10 } } }; sheet[XLSX.utils.encode_cell({ r, c: 1 })] { v: row[1], s: { font: { sz: 10 } } }; sheet[XLSX.utils.encode_cell({ r, c: 2 })] { v: row[2], s: { font: { sz: 10 } } }; sheet[XLSX.utils.encode_cell({ r, c: 3 })] { v: row[3], s: { font: { sz: 10 } } }; }); XLSX.writeFile(workbook, filename); }这个方案的优势是样式天然与设计稿一致不需要写任何样式映射代码模板里合并单元格、列宽、行高、页眉页脚全都保留。坑在于模板文件本身需要人工维护如果业务表头变了得重新设计模板另外XLSX.read对模板里的图片、图表支持有限如果模板里有复杂图片用xlsx-js-style读出来可能丢失。复杂模板建议直接用exceljs的load方法它对流式读写的支持更好。6.3 验证导出结果用 Excel 打开前先自查的关键点写完整套导出功能别急着点下载就完事。我在交付前会做一轮固定的验证清单这里分享给你能省掉来回沟通的麻烦。第一用文本编辑器打开生成的.xlsx文件.xlsx本质是 zip 包能解开看里面的xl/worksheets/sheet1.xml搜一下border、fill、mergeCells几个关键词确认样式 XML 里有内容。如果 XML 干净得像白纸说明样式根本没写进去不用打开 Excel 就已经知道失败了。第二用 Excel 打开后检查三个点合并单元格区域是否正确、最后一行的边框是否完整、长数字字段是否变成科学计数法。第三拿一个包含中文、空格、特殊字符如、、的表格做测试确保内容不会被转义成乱码。SheetJS 默认会把文本里的 HTML 特殊字符转义写入 XML 后能正确还原但如果数据里有转义错误会导致整个 XML 解析失败Excel 打开时报“文件已损坏”。一个我个人的习惯导出功能上线后留一个“导出内容预览”的日志接口把生成的 sheet 前 10 行 JSON 打到控制台或后台出了问题能快速定位是数据源错误还是样式映射错误。这比让用户截图反馈高效得多。最后说一句实在话导出 Excel 保留样式这件事难点从来不在 API 调用而在“你肯不肯把 DOM 样式和 Excel 样式模型之间的差异逐项搞清楚”。我第一次做时也被fill没写patternType、合并边界丢线、手机号变科学计数法这几个坑轮流教育过。后来整理出样式映射表和验证清单后基本一次就能过。希望这篇笔记能帮你在做这个需求时少走几步弯路把有限的时间留给真正需要调的业务样式上。本文还有配套的精品资源点击获取