简介:在后台管理系统与数据报表场景中,将页面表格导出为带样式的Excel文件是一个常见诉求。虽然CSV与纯XLSX导出简单,但往往丢失边框、背景色、表头样式等关键信息。理解XLSX文件内部结构与样式存储原理(styles.xml中的cellXfs)后,可实现真正保留格式的导出方案。工程实践中,基于Excel内置HTML解析能力的HTML .xls方案,配合getComputedStyle动态内联化页面样式,能快速输出与页面同款的报表;而xlsx-js-style则提供标准XLSX的逐格样式控制,适合对文件格式有严格要求的下游数据交换场景。两种方案覆盖“给人看”和“给程序读”的典型应用,本文围绕这两条路径介绍具体实现、参数边界与样式映射方法。
1. 把页面 table 带样式导成 Excel,先想清楚要哪一种导出
后台管理系统里最常见的需求不是导出数据,是导出一份给领导看的 Excel 表。页面上 Table 表头蓝底白字加粗,状态列带彩色标签,交付时长出现最多的一句话是:样式丢了。多数人第一版用 SheetJS,但官方社区版没有样式写入参数,aoa_to_sheet只把值写进单元格,导出的文件白底黑字、列宽挤在一起、长单号变成科学计数法。
保留样式有两条落地路径:一条利用 Excel 内置的 HTML 解析能力,把页面 table 连同内联 CSS 包成 .xls;另一条用社区维护的 xlsx-js-style,在标准 XLSX 里逐格写样式对象。前者适合给人看的报表,后者适合给程序读的文件,下面分别给完整代码、参数说明和边界条件。
2. JS 导出 Excel 的三条路线与样式保真度对比
动手写代码之前,先花十分钟确认三个问题:文件最终用什么软件打开(Excel 桌面版、WPS 还是下游程序解析)、样式要保到哪一层(字体颜色、边框、合并单元格还是列宽)、浏览器兼容范围到哪。这三个答案决定了走哪条路线。这一章把 CSV、HTML 壳 .xls、xlsx-js-style 三条路线放一起对比,说清楚各自能保什么、不能保什么,以及为什么完整的样式信息在 XLSX 文件里是需要专门写入的。
2.1 为什么 CSV 和普通 xlsx 导出会丢样式
CSV 本质是逗号分隔的纯文本,Excel 打开时按默认格式渲染,字体、边框、背景色这些信息在格式里根本无法表达,中文编码不对还会乱码。它适合做数据备份和系统间回传,不适合做人看的报表。
普通 xlsx 丢样式的原因在文件结构里。XLSX 是一个 zip 包,单元格值写在xl/worksheets/sheet1.xml的<c>节点里,每个<c>有可选的s属性,指向xl/styles.xml中cellXfs数组的索引:
<!-- 无样式:s 属性缺省,所有单元格用第 0 个默认样式 --> <c r="A1" t="s"><v>订单号</v></c> <!-- 有样式:s="1" 指向 styles.xml 里定义的加粗字体与边框组合 --> <c r="A1" t="s" s="1"><v>订单号</v></c>SheetJS 官方社区版在 write 阶段只写值和!cols列宽,不会生成带格式的s索引,所以打开后全是默认样式。这是社区版的能力边界,不是写法问题;理解这一点,再遇到"为什么文档里没有 style 参数"这类疑问就不会兜圈子。
2.2 HTML 壳 .xls:利用 Excel 内置的表格解析能力
Excel 从 97 到现在的桌面版都保留了打开 HTML 文件的能力:只要文件是带 table 结构的 HTML 文档,即使扩展名是 .xls,Excel 也会按表格解析,并保留内联 CSS 里的字体、边框、背景色、文本对齐和行高列宽。这套做法在前端领域用了十几年,很多老系统导出的"Excel"本质就是这种 HTML 文件。
它的限制同样明显:只认内联 style 和有限的一套 CSS 属性,class 里定义的样式、Vue 组件里写的 scoped 样式都不认;不支持渐变背景和阴影;文件本质是 HTML,WPS 或个别版本 Excel 的解析会有细微差异。所以它最适合"页面有现成 table、样式不复杂"的管理系统报表,不适合对文件格式有严格校验、必须切到真实单元格类型的下游场景。
2.3 xlsx-js-style:社区分支补上标准 XLSX 的样式通道
如果对方明确要求标准 .xlsx 文件,比如下游系统要读取单元格内容或做数据校验,HTML 壳就不合适了。常见做法是换用 xlsx-js-style,这是 SheetJS 的社区分支,保持writeFile、aoa_to_sheet等 API 兼容的同时,在 write 阶段把每个单元格上的s样式对象序列化进 styles.xml 的 fonts、fills、borders 和 cellXfs。用起来和 SheetJS 几乎一样,只是 npm 包名从xlsx换成xlsx-js-style,并且单元格可以挂样式对象。
注意它和官方版是两个发布源,同一项目混装会出问题;如果代码里已经引了官方 SheetJS,要整体替换而不是两个都保留。三条路线的取舍对比如下:
| 路线 | 输出格式 | 样式保真度 | 中文处理 | 文件体积 | 典型场景 |
|---|---|---|---|---|---|
| CSV | .csv 纯文本 | 无 | 需手动加 BOM | 最小 | 数据备份、系统间回传 |
| HTML 壳 .xls | .xls(内容为 HTML) | 中:字体、边框、背景、列宽 | 需手动加 BOM | 中 | 管理系统报表、与页面同款 |
| xlsx-js-style | .xlsx 标准格式 | 高:字体、填充、边框、对齐、合并、行高列宽 | 直接 UTF-8 | 较大 | 下游解析、交付文件 |
选型标准其实就一条:文件给人看还是给程序读。给人看,HTML 壳最快,当天能上线;给程序读,或要求真实 xlsx 后缀和纯数字单元格,直接上 xlsx-js-style。后面两章分别给这两条路线的完整实现和参数坑位。
3. 用 HTML 壳导出带样式的 .xls:从 DOM 拿样式到输出文件的完整代码
这一章给一个能直接粘进项目的exportTableAsExcel函数,并说明每一步在干什么。核心思路三步:克隆页面 table 并给单元格补内联样式;套上 Excel 能识别的 HTML 外壳;用 Blob 触发下载并处理中文乱码。
3.1 直接 table.outerHTML 导出的样式是丢的
很多人的第一版是这样:const html = table.outerHTML,包上 Blob 就下载。结果导出的文件连边框都没有。原因是页面样式绝大多数挂在 class 上,CSS 规则不会出现在 outerHTML 里,Vue 的 scoped CSS 还会给选择器加 data 属性,而 Excel 的 HTML 解析器不加载任何外部样式表,只认元素上的内联 style。所以第一步先做"内联化":遍历克隆节点里的 td/th,用getComputedStyle把渲染后的样式写回 style 属性。
function inlineTableStyles(clone) { const cells = clone.querySelectorAll('td, th'); cells.forEach(function (cell) { const cs = getComputedStyle(cell); // border 拆成三个值拼,比直接读简写属性兼容性好 cell.style.border = cs.borderTopWidth + ' ' + cs.borderTopStyle + ' ' + cs.borderTopColor; cell.style.backgroundColor = cs.backgroundColor; cell.style.color = cs.color; cell.style.fontWeight = cs.fontWeight; cell.style.textAlign = cs.textAlign; cell.style.padding = cs.padding; }); return clone; }说明:getComputedStyle返回的 border 在浏览器里拆成 width/style/color 三个值,拼回去是和原页面一致的字符串;原单元格没设边框时 borderTopStyle 是 none,拼出来也不会多出线条。不必把所有样式都塞进去,Excel 对超出它理解范围的属性反而会解析不稳定,下表是实际验证过能被识别的字段范围:
| 内联样式字段 | Excel 是否识别 | 备注 |
|---|---|---|
| border、border-top/bottom/left/right | 识别 | 颜色不支持 transparent 或 rgba |
| background-color | 识别 | 只认纯色,渐变和图片忽略 |
| color、font-weight、font-style | 识别 | 与 CSS 语义一致 |
| text-align、vertical-align | 识别 | 分别对应水平、垂直对齐 |
| padding | 部分识别 | 能撑开行高,但像素换算不精确 |
| line-height、letter-spacing | 忽略 | Excel 里无对应概念 |
3.2 Excel 外壳、工作表名与 UTF-8 BOM
Excel 对 HTML 文件的识别依赖几个约定:最外层必须是完整 html 文档;head 里的 mso 条件注释用来声明工作表名,<x:Name>标签内容就是 Excel 左下角的 sheet 名;<meta charset="UTF-8">负责字符集声明。中文表名可以直接写,但两个 sheet 重名时 Excel 会提示修复,所以表名要保证唯一。
中文乱码是另一个高频问题:Blob 构造时把 UTF-8 BOM(\ufeff)放在 HTML 字符串最前面,绝大多数 Excel 和 WPS 能按 UTF-8 解码,否则某些系统会用本地编码打开导致整表乱码。BOM 只影响文件头一个字节,对 HTML 内容本身无副作用。
3.3 完整函数与参数说明
function exportTableAsExcel(selector, options) { const opts = options || {}; const filename = opts.filename || '导出表格.xls'; const sheetName = opts.sheetName || 'Sheet1'; const table = document.querySelector(selector); if (!table) { console.warn('exportTableAsExcel: 未找到节点', selector); return; } const clone = table.cloneNode(true); inlineTableStyles(clone); const html = '<html xmlns:o="urn:schemas-microsoft-com:office:office" ' + 'xmlns:x="urn:schemas-microsoft-com:office:excel">' + '<head><meta charset="UTF-8" />' + '<!--[if gte mso 9]>' + '<xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet>' + '<x:Name>' + sheetName + '</x:Name>' + '<x:WorksheetOptions><x:DisplayGridlines /></x:WorksheetOptions>' + '</x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml>' + '<![endif]--></head><body>' + clone.outerHTML + '</body></html>'; const blob = new Blob(['\ufeff', html], { type: 'application/vnd.ms-excel;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); }参数说明:selector支持任意 querySelector 表达式,'#tableId'最直接;如果页面有多个小表格想合成一个 sheet,先把它们拼到一个隐藏 table 里再传。filename必须以.xls结尾,内容虽是 HTML,但 Excel 会按兼容模式打开;起成.xlsx会被按严格格式解析,直接报"文件损坏"。
提示:
cloneNode(true)只复制结构和内联 style,复制不了虚拟滚动场景下尚未渲染的行。table 开启 LazyRender 时,先让数据源全量渲染再导出。
使用示例:
// 导出 antd Table 渲染出来的真实 DOM,边框和表头背景由 getComputedStyle 内联化后带走 exportTableAsExcel('#report-table', { filename: '月度报表.xls', sheetName: '月报' });3.4 单元格内容类型:避免公式识别和超长数字变形
HTML 壳方案有个高频坑:td 内容以=开头时,Excel 会当成公式执行,导出的表可能出现错误值;超过 11 位的数字会被转成科学计数法。常见做法是在数据列上标记类型,内联化之后单独处理。给 td 加>// 只处理页面渲染时标记过的数值列 clone.querySelectorAll('td[data-type="number"]').forEach(function (cell) { const old = cell.getAttribute('style') || ''; cell.setAttribute('style', old + ';mso-number-format:0.00;'); });
mso-number-format是 Excel 私有属性,浏览器不认但会原样留在 style 字符串里,Excel 解析 HTML 时读取它来控制显示格式。0.00表示固定两位小数;想保留长单号就写成@,表示按文本显示。此方案只影响显示,单元格在 Excel 里仍是文本或常规类型,需要真实数字类型时应在数据源层面处理。
4. 用 xlsx-js-style 逐格写样式:表头、边框、合并单元格与列宽参数
HTML 壳在数据量大、样式规则多、或必须交付真实 .xlsx 时会碰到天花板。xlsx-js-style 是更可控的路线:值、样式、合并信息都写成结构化对象,由库在 write 阶段生成合法的 XLSX 包。这一章先讲样式对象的字段结构,再给一个带表头样式、数据行边框、列宽和合并单元格的完整函数,最后说大表性能怎么取舍。
4.1 样式对象结构:font / fill / alignment / border 字段对照
xlsx-js-style 里,每个单元格的s属性是一个普通对象,直接对应 styles.xml 里的组件。常用字段和取值:
| 分组 | 字段 | 可选值与说明 |
|---|---|---|
| font | bold | true / false |
| font | sz | 字号,数字,如 11、12 |
| font | color.rgb | 8 位 ARGB 字符串,如FF333333 |
| fill | fgColor.rgb | 背景色,同样写 8 位,如FFF2F2F2 |
| alignment | horizontal | left / center / right |
| alignment | vertical | top / center / bottom |
| alignment | wrapText | true / false,长文本自动换行 |
| border | top / bottom / left / right | { style: 'thin', color: { rgb: 'FFCCCCCC' } },style 取 thin / medium / thick / dashed / dotted |
| z(单元格属性) | 数字格式 | '0.00'、'yyyy-mm-dd'等,写在单元格对象上而不是 s 里 |
两个容易翻车的地方:fill 必须写fgColor,patternType 可省,库默认按 solid 处理;border 的 style 写错大小写或颜色不带 8 位会被整条忽略。颜色统一用带FF前缀的 ARGB 是兼容性最好的写法。
4.2 表头和数据行分开上样式,避免全表逐格构造
单元格样式是逐格设置的,一万行的表如果每格都新建独立样式对象,文件膨胀且打开变慢。常见做法是定义两个共享样式对象:表头一个、数据行一个。表头只循环一行,数据行遍历!ref范围内非空单元格赋值同一个对象引用,内存只多一份样式:
import XLSX from 'xlsx-js-style'; function exportRowsToXlsx({ columns, rows, filename, sheetName }) { const ws = XLSX.utils.aoa_to_sheet([]); const header = columns.map(function (col) { return col.title; }); XLSX.utils.sheet_add_aoa(ws, [header], { origin: 'A1' }); XLSX.utils.sheet_add_aoa(ws, rows, { origin: 'A2' }); const headerStyle = { font: { bold: true, sz: 12, color: { rgb: 'FFFFFFFF' } }, fill: { fgColor: { rgb: 'FF2F54EB' } }, alignment: { horizontal: 'center', vertical: 'center' }, border: { top: { style: 'thin', color: { rgb: 'FFD9D9D9' } }, bottom: { style: 'thin', color: { rgb: 'FFD9D9D9' } }, left: { style: 'thin', color: { rgb: 'FFD9D9D9' } }, right: { style: 'thin', color: { rgb: 'FFD9D9D9' } } } }; const cellStyle = { alignment: { vertical: 'center' }, border: { top: { style: 'thin', color: { rgb: 'FFE8E8E8' } }, bottom: { style: 'thin', color: { rgb: 'FFE8E8E8' } }, left: { style: 'thin', color: { rgb: 'FFE8E8E8' } }, right: { style: 'thin', color: { rgb: 'FFE8E8E8' } } } }; header.forEach(function (_, c) { const addr = XLSX.utils.encode_cell({ r: 0, c: c }); ws[addr].s = headerStyle; }); const range = XLSX.utils.decode_range(ws['!ref']); for (let r = 1; r <= range.e.r; r++) { for (let c = 0; c <= range.e.c; c++) { const addr = XLSX.utils.encode_cell({ r: r, c: c }); if (!ws[addr]) { ws[addr] = { t: 's', v: '' }; } ws[addr].s = cellStyle; } } ws['!cols'] = columns.map(function (col) { return { wch: col.width || 16 }; }); ws['!rows'] = [{ hpt: 26 }].concat( rows.map(function () { return { hpt: 22 }; }) ); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, sheetName || 'Sheet1'); XLSX.writeFile(wb, filename || '导出.xlsx'); }这个循环里容易写错三处。sheet_add_aoa第二次调用不传 origin 会把数据从 A1 重新写、覆盖表头,所以必须写{ origin: 'A2' }。encode_cell的行列从 0 开始,和第一行的直观行号差一位,循环边界要看ws['!ref']译出来的范围。!rows是按行号排列的完整数组,长度不够时后面的行高不生效,所以这里用 concat 保证和数据行数等长。
4.3 合并单元格、列宽与不转科学计数法的处理
合并单元格通过工作表!merges声明,每一项是{ s: {r, c}, e: {r, c} },即左上角和右下角坐标,从 0 开始。标题行跨列合并是典型场景:把 A1 到最后一列合并,值保留在左上角:
ws['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 0, c: columns.length - 1 } } ]; ws['A1'].s = { font: { bold: true, sz: 14 }, alignment: { horizontal: 'center', vertical: 'center' } };注意合并后除了左上角,范围内其他单元格对象的引用要清理掉,否则部分 Excel 打开会报"文件已损坏";清理方式是把这些地址的ws[addr]设为undefined。这里的合并和页面 Table 的 rowSpan/colSpan 是两个体系,从 DOM 反向推导合并范围时要用表头的 colspan 累加列偏移,不能直接拿列索引用。
超长数字在 Excel 里默认转科学计数法。aoa_to_sheet会把纯数字自动写作数字类型,想按原样显示整数,给对应列单元格设置z属性:
// 第 2 列(B 列)按整数原样显示 for (let r = 1; r <= range.e.r; r++) { const addr = XLSX.utils.encode_cell({ r: r, c: 1 }); if (ws[addr]) { ws[addr].z = '0'; } }z是单元格上的数字格式属性,和样式对象分开写。'0'表示整数原样显示;日期列换成'yyyy-mm-dd hh:mm:ss',百分比列用'0.0%'。若想保留前导零(单号、证件号),需要在数据源里把值处理成字符串再交给aoa_to_sheet,它遇到字符串会写文本类型,不会自动转数字。
5. 样式自动映射与导出自检:把 getComputedStyle 变成 Excel 样式
最后落一个能直接收工的技巧:从页面真实渲染样式自动生成 xlsx-js-style 样式对象,并在导出后校验样式确实写进了文件。
5.1 用 getComputedStyle 生成表头样式对象
页面表头的颜色和字号由设计稿定死在 CSS 里,与其在导出代码里另抄一份颜色,不如直接从 DOM 读。表头只有一行,逐格读开销可忽略:
function cssColorToExcel(cssColor, fallback) { if (!cssColor || cssColor === 'transparent') return fallback || 'FFFFFF'; const parts = cssColor.match(/[\d.]+/g); if (!parts || parts[3] === '0') return fallback || 'FFFFFF'; // alpha 为 0 return parts.slice(0, 3).map(function (n) { return Number(n).toString(16).padStart(2, '0'); }).join('').toUpperCase(); } function headerStyleFromDom(th) { const cs = getComputedStyle(th); return { font: { bold: cs.fontWeight === 'bold' || parseInt(cs.fontWeight, 10) >= 600, sz: parseInt(cs.fontSize, 10), color: { rgb: 'FF' + cssColorToExcel(cs.color, '333333') } }, fill: { fgColor: { rgb: 'FF' + cssColorToExcel(cs.backgroundColor, 'FFFFFF') } }, alignment: { horizontal: cs.textAlign, vertical: 'center' }, border: { bottom: { style: 'thin', color: { rgb: 'FFCCCCCC' } } } }; }说明:cs.color在 Chrome 里返回rgb(r, g, b)或rgba(r, g, b, a),不能直接当十六进制用,cssColorToExcel负责把三通道转成 6 位 hex 并处理透明底色;数据行样式通常统一,逐行跑getComputedStyle上万次会有可见卡顿,所以这个函数只用于表头,数据行沿用 4.2 里的共享cellStyle。
5.2 导出后自检:直接读 styles.xml 验证样式落盘
xlsx-js-style 生成的文件是 zip 包,样式落在xl/styles.xml的 fills 和 cellXfs 里。导出完成后不需要打开 Excel 肉眼核对,用 Node 直接解包查颜色即可:
const fs = require('fs'); const JSZip = require('jszip'); async function verifyStyle(path, color) { const buf = fs.readFileSync(path); const zip = await JSZip.loadAsync(buf); const styles = await zip.file('xl/styles.xml').async('string'); console.log(styles.includes(color) ? '样式已写入: ' + color : '样式丢失: ' + color); } // 校验导出.xlsx 里是否存在表头背景色 FF2F54EB verifyStyle('导出.xlsx', 'FF2F54EB');命令行的快速替代是unzip -p 导出.xlsx xl/styles.xml | grep FF2F54EB,适合交付前手动抽查。如果查不到颜色字符串,优先检查三点:样式对象的 rgb 是否带FF前缀、fill 用的字段是不是fgColor、目标单元格是否真的存在——只设置!cols不会触发 styles.xml 生成样式块。把这套自检放进发版用例里,样式回归问题能在交付前被拦住。
本文还有配套的精品资源,点击获取