简介:面向需要将网页表格数据导出为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:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"> <head><meta charset="utf-8"></head> <body><table>${table.innerHTML}</table></body> </html>`; const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); URL.revokeObjectURL(link.href); }这里charset=utf-8是为了让 Excel 正确识别中文,否则打开后表头会变成乱码。实际使用中你会发现,这个方案对简单表格还能应付,一旦涉及合并单元格的rowspan、colspan,或者列宽col width的精确控制,表现就很不稳定。它本质是让 Excel 去兼容 HTML,而 Excel 的 HTML 渲染引擎多年未更新,对 CSS 的支持停留在很老的版本上,padding、伪元素、box-shadow这些一概不认,甚至连border-collapse: collapse都可能出现双线边框。
另一个更大的坑是安全提示:用这种方式导出的.xls文件,Excel 打开时会弹“文件格式与扩展名不匹配”的警告。原因是文件内容实际是 HTML 文档,扩展名却是.xls,Excel 通过内容识别发现货不对板。用户每次都要点“是”才能打开,体验很差。所以这条路线我只建议用在“内部工具、能接受警告、无复杂样式”的场景,如果要交付给外部用户,别选它。
2.2 路线二:SheetJS(xlsx)社区版:数据导出强,样式支持弱
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 修复的情况可能要自己 patch;exceljs则因为浏览器端包的模块化问题,需要构建工具配合。
从本篇文章的标题出发,我会以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: bold | s.font.bold = true | 加粗,Excel 只有 true / false,没有数值粗细 |
font-style: italic | s.font.italic = true | 斜体 |
text-decoration: underline | s.font.underline = true | 下划线 |
font-size: 14px | s.font.size = 11 | Excel 字号单位是磅(pt),1 pt ≈ 1.333 px,常用经验是 14px 对应 11pt |
background-color | s.fill.fgColor.rgb | 需要去掉#,写 6 位十六进制 |
border/border-top | s.border.top.style、s.border.top.color | style 有thin、medium、thick、dashed等取值 |
text-align: center | s.alignment.horizontal = 'center' | 可选left、center、right、justify |
vertical-align: middle | s.alignment.vertical = 'center' | 可选top、center、bottom |
white-space: nowrap | s.alignment.wrapText = false | 默认 false,如果希望自动换行设为 true |
colspan/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.vertical,Excel 打开文件时可能忽略这个非法值,回到默认的bottom对齐,视觉上所有内容都贴在单元格底部,很难看。解决办法是:读取值时先判断,如果是baseline或middle,统一映射成center;sub、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 位,比如手机号 13800138000,Excel 默认会显示为科学计数法1.38E+10,身份证号 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 version="1.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 节的charset=utf-8中解决。
5.4 现象:合并单元格后内容错位
导出的文件里合并区域出现了,但合并区域里的数据串行,A 列的数据跑到 C 列去了,或者合并区域下方的数据整体错位。这个坑来自两个层面。
第一层是 DOM 解析时没有处理colspan/rowspan占位。HTML 表格里,一个colspan=2的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、合并边界丢线、手机号变科学计数法这几个坑轮流教育过。后来整理出样式映射表和验证清单后,基本一次就能过。希望这篇笔记能帮你在做这个需求时少走几步弯路,把有限的时间留给真正需要调的业务样式上。
本文还有配套的精品资源,点击获取