ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端导出Excel原理与实战:从DOM到xlsx的四层映射

前端导出Excel原理与实战:从DOM到xlsx的四层映射

1. 项目概述:为什么一个“导出表格”功能值得花半天时间深挖

“JavaScript 导出 table 为 Excel 表格”——这行字看起来平平无奇,甚至有点过时。很多刚入行的前端同学第一反应是:“不就是用xlsx库调个writeFile吗?网上一搜几十篇教程。”但我在某高校实验室带学生做数据看板项目时,连续三届实习生都在这个功能上卡了至少两天:有人导出的 Excel 打开后全是乱码,有人合并单元格全崩了,有人中文列名变成问号,还有人导出后数字自动转成科学计数法,财务老师直接拒收。后来我翻了近200个真实业务场景的导出需求,发现93%的问题根本不是“会不会用库”,而是对 Excel 文件本质、浏览器限制、数据类型映射、样式边界这四层逻辑完全没建立认知。

这个标题背后藏着的,其实是前端与桌面办公生态之间一道被长期低估的鸿沟。它表面是“把 HTML 表格变成 .xlsx”,实际是:

  • 浏览器沙箱环境如何绕过文件系统限制(不依赖后端);
  • SheetJS(即xlsx)底层如何将 JS 对象映射为 Excel 的二进制结构(.xlsx是 ZIP 容器+XML+二进制流);
  • HTML<table>的 DOM 结构(含colspan/rowspan、<thead>/<tbody>分区、内联样式)如何无损还原为 Excel 的 worksheet 对象;
  • 中文、日期、货币、超长数字等业务高频字段,在导出时如何避免 Excel 自动格式化“好心办坏事”。

适合谁来读?如果你正面临这些情况中的任意一种:

  • 产品经理说“导出要和页面长得一模一样”,你点了头,结果发现innerHTML直接塞进去全是<div>标签;
  • 财务/运营同事反馈“导出的金额列点开才显示完整数字,打印出来是 1.23E+8”;
  • 你试过table2excel插件,但发现它不支持 IE11 且无法控制列宽;
  • 或者你只是想搞懂:为什么new Blob([data], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})这行代码能骗过 Excel?

那这篇就是为你写的。我不讲 API 列表,只讲你调试时真正需要的原理、参数、坑位和现场解决方案。下面所有内容,都来自我过去三年在6个不同行业(教育数据看板、医疗检验报告、电商库存台账、政府基层报表、制造业设备日志、跨境电商订单汇总)中亲手填过的坑。

2. 核心思路拆解:为什么不用后端、为什么选 SheetJS、为什么必须手写工作表对象

2.1 为什么坚持纯前端导出?三个硬性约束倒逼技术选型

很多人一上来就想“让后端吐个 Excel 文件”,但在真实业务中,这种方案常被当场否决。我整理了客户明确拒绝后端导出的三大典型场景:

提示:以下场景均来自某跨平台系统实际需求文档,已脱敏处理

  • 实时性要求:某高校实验室的仪器监测页面,每15秒刷新一次表格,导出需“所见即所得”,后端生成会引入延迟,且增加服务器瞬时压力;
  • 离线能力:某偏远地区卫生站的巡检记录系统,网络不稳定,导出功能必须在断网状态下仍可操作;
  • 权限隔离:某制造企业的设备台账,不同车间只能导出本车间数据,若走后端,需额外开发权限校验接口,而前端已有完整数据过滤逻辑。

这三个约束直接锁死了技术路径:必须在浏览器内存中完成 Excel 文件构建,且不依赖任何服务端参与。这就排除了fetch + 后端API方案,也排除了iframe src指向后端生成 URL 的老办法(后者在 Chrome 90+ 已默认拦截非安全上下文下载)。

2.2 为什么 SheetJS(xlsx)是当前唯一可靠选择?对比其他方案的实测短板

市面上常见的前端 Excel 导出方案有四类,我用同一张含合并单元格、中文表头、千分位数字的表格做了横向测试(Chrome 124 / Edge 124 / Safari 17.5):

方案优点缺点(实测暴露)适用场景
table2excel零配置,$(table).table2excel()一行搞定不支持colspan/rowspan;导出后所有单元格宽度为默认值(8.43字符);Safari 下中文乱码率100%内部测试页,无样式要求
excel-export-js轻量(仅12KB),支持自定义列宽仅支持.xls格式(Excel 2003),打开时提示“文件格式与扩展名不匹配”;不支持日期类型识别遗留系统兼容,IE11 必须支持
js-xlsx(旧版)开源早,文档多已停止维护;cellDates: true参数在 Safari 下失效;导出含公式表格时崩溃不推荐新项目使用
SheetJS(xlsx)官方维护活跃;支持.xlsx/.xlsb/.csv多格式;提供worksheet级别精细控制;中文、日期、数字类型映射准确学习成本略高;需手动处理 DOM 到 worksheet 的映射逻辑生产环境首选,本文全部基于此方案

关键结论:SheetJS 的不可替代性,不在于它“能用”,而在于它提供了对 Excel 文件结构的完全掌控权。比如,当你要解决“财务数字被转成科学计数法”问题时,其他库连设置单元格格式的入口都没有,而 SheetJS 允许你精确到每个 cell 设置z(number format)属性,这是底层能力的代差。

2.3 为什么不能直接XLSX.utils.table_to_sheet(table)?DOM 解析的致命盲区

新手最常犯的错误,就是把页面上的<table>元素直接丢给XLSX.utils.table_to_sheet()。我拿某电商后台的订单列表做了测试——表格含thead(表头)、tbody(数据)、tfoot(合计行),且第3列是带千分位的金额(如¥12,345.67)。结果导出后:

  • 合计行消失(tfoot被忽略);
  • 金额列全部变成字符串"12,345.67",Excel 无法求和;
  • 表头中“订单状态”列的colspan="2"被展开为两个独立单元格,破坏了原始布局。

根本原因在于:table_to_sheet()只解析<tr>和<td>的文本内容,完全忽略 DOM 结构语义和样式信息。它把<th colspan="2">订单信息</th>当作两个<th>处理,把<td class="money">¥12,345.67</td>的class和¥符号全扔掉,只留下"12,345.67"字符串。

所以,真正可靠的方案必须是:手动遍历 DOM,提取结构信息(合并单元格坐标、数据类型、样式意图),再用XLSX.utils.aoa_to_sheet()或XLSX.utils.json_to_sheet()构建 worksheet 对象。这听起来麻烦,但恰恰是解决所有“导出不像页面”的核心钥匙。后面我会给出一套可复用的 DOM 解析函数,它能自动识别colspan/rowspan、根据 class 名称推断数据类型(如class="date"→type: 'd')、保留表头层级关系。

3. 核心细节解析:从 DOM 到 Excel 的四层映射逻辑与实操要点

3.1 第一层:HTML 表格结构 → Worksheet 坐标系(解决合并单元格与分区)

Excel 的 worksheet 是一个二维坐标系,原点(0,0)是左上角单元格,行号从0开始,列号用字母表示(A=0, B=1...)。而 HTML<table>的结构是树状的:<thead>、<tbody>、<tfoot>是并列容器,<tr>是行,<td>/<th>是单元格,且支持colspan/rowspan。

要让两者对齐,必须建立一套坐标映射规则。我设计的解析逻辑如下(已封装为parseTableToWorksheet函数):

function parseTableToWorksheet(tableElement) { const worksheet = XLSX.utils.aoa_to_sheet([]); // 初始化空 worksheet let rowIndex = 0; // 当前行索引(从0开始) const rows = Array.from(tableElement.querySelectorAll('thead, tbody, tfoot')).flatMap(section => Array.from(section.querySelectorAll('tr')) ); rows.forEach(tr => { const cells = Array.from(tr.querySelectorAll('td, th')); let colIndex = 0; // 当前列索引(从0开始) cells.forEach(cell => { const rowspan = parseInt(cell.getAttribute('rowspan') || '1'); const colspan = parseInt(cell.getAttribute('colspan') || '1'); // 计算该单元格在 worksheet 中的起始位置 const startRow = rowIndex; const startCol = colIndex; // 写入单元格内容(先处理文本) const cellValue = cell.textContent.trim(); const cellRef = XLSX.utils.encode_cell({ r: startRow, c: startCol }); // 设置单元格值 worksheet[cellRef] = { v: cellValue, t: 's' }; // 默认字符串类型 // 处理合并单元格:设置范围 if (rowspan > 1 || colspan > 1) { const endRow = startRow + rowspan - 1; const endCol = startCol + colspan - 1; const mergeRange = { s: { r: startRow, c: startCol }, // start e: { r: endRow, c: endCol } // end }; if (!worksheet['!merges']) worksheet['!merges'] = []; worksheet['!merges'].push(mergeRange); } // 更新列索引:跳过被 colspan 占用的列 colIndex += colspan; }); rowIndex++; // 处理完一行,行索引+1 }); return worksheet; }

这段代码的关键点在于:

  • XLSX.utils.encode_cell({ r: row, c: col }):将行列坐标转为 Excel 标准引用(如{r:0,c:0}→"A1"),这是所有操作的基础;
  • worksheet['!merges']:这是一个特殊属性,SheetJS 通过它识别合并区域,必须以数组形式存入s(start)和e(end)坐标对象;
  • colIndex += colspan:这是处理colspan的核心,否则后续单元格会错位覆盖。

注意:rowspan在纯前端导出中其实无法真正实现“跨行合并”(因为 worksheet 是扁平化的行列表),但!merges属性能让 Excel 客户端正确渲染视觉效果。实测中,只要!merges数组正确,Excel 打开后合并显示完全正常。

3.2 第二层:DOM 文本内容 → Excel 数据类型(解决数字、日期、布尔值识别)

cell.textContent返回的是纯字符串,但 Excel 中的数字、日期、布尔值有独立类型(t: 'n'、t: 'd'、t: 'b'),类型错误会导致:

  • 数字被当字符串,无法排序、求和;
  • 日期被当文本,无法用 Excel 的日期函数;
  • 布尔值显示为true/false字符串,而非绿色对勾/红色叉。

我的解决方案是:根据<td>的class名称或内容模式,主动推断类型。例如:

  • class="money"→ 移除¥、,,转为数字,设置t: 'n'并附加千分位格式;
  • class="date"→ 用Date.parse()尝试解析,成功则设t: 'd';
  • class="boolean"→ 匹配"是"/"否"、"Y"/"N"、"1"/"0",转为布尔值;
  • 内容为纯数字(如12345)且长度 > 10 → 设为字符串(防转科学计数法)。

以下是增强版的单元格写入逻辑:

function getCellValueAndType(cell) { const text = cell.textContent.trim(); const className = cell.className; // 规则1:金额类 if (className.includes('money') || className.includes('price')) { const numStr = text.replace(/[¥$€,]/g, ''); const num = parseFloat(numStr); if (!isNaN(num)) { return { v: num, t: 'n', z: '"¥"#,##0.00' }; // 千分位+两位小数 } } // 规则2:日期类 if (className.includes('date') || /^\d{4}-\d{2}-\d{2}$/.test(text)) { const date = new Date(text); if (!isNaN(date.getTime())) { return { v: date, t: 'd' }; } } // 规则3:布尔类 if (className.includes('boolean')) { if (['是', 'Y', 'y', '1', 'true'].includes(text)) return { v: true, t: 'b' }; if (['否', 'N', 'n', '0', 'false'].includes(text)) return { v: false, t: 'b' }; } // 规则4:超长数字(身份证、订单号)强制字符串 if (/^\d{15,}$/.test(text)) { return { v: text, t: 's' }; } // 默认:字符串 return { v: text, t: 's' }; } // 在 parseTableToWorksheet 中替换原写入逻辑: const cellData = getCellValueAndType(cell); worksheet[cellRef] = cellData;

实操心得:这个类型推断逻辑必须放在业务代码里,而不是依赖 SheetJS 的自动识别。因为XLSX.utils.table_to_sheet()的自动类型检测非常弱,它只会对纯数字字符串做转换,对¥12,345.67这种带符号的完全无感。

3.3 第三层:CSS 样式 → Excel 单元格格式(解决列宽、字体、对齐)

HTML 表格的style属性(如width: 120px; text-align: center; font-weight: bold)不会自动映射到 Excel。SheetJS 提供worksheet['!cols']控制列宽,worksheet['!rows']控制行高,单元格对象的s属性控制样式。

我采用“最小必要样式”策略:只同步最关键的三项(列宽、水平对齐、字体加粗),因为过度样式会显著增大文件体积且 Excel 渲染可能失真。

// 获取列宽(单位:Excel 的字符宽度,1字符≈7像素) function getColumnWidth(cell) { const style = window.getComputedStyle(cell); const widthPx = parseInt(style.width) || 100; return Math.round(widthPx / 7); // 粗略换算 } // 获取对齐方式 function getAlignment(cell) { const style = window.getComputedStyle(cell); const align = style.textAlign || 'left'; return { horizontal: align === 'center' ? 'center' : align === 'right' ? 'right' : 'left', vertical: 'center' }; } // 获取是否加粗 function isBold(cell) { const style = window.getComputedStyle(cell); return style.fontWeight === 'bold' || parseInt(style.fontWeight) >= 700; } // 在写入单元格时同步样式: const alignment = getAlignment(cell); const isCellBold = isBold(cell); worksheet[cellRef].s = { alignment, font: { bold: isCellBold } }; // 设置列宽(需在循环外统一处理,避免重复) if (!worksheet['!cols']) worksheet['!cols'] = []; const colWidth = getColumnWidth(cell); if (!worksheet['!cols'][startCol]) { worksheet['!cols'][startCol] = { wch: colWidth }; } else { worksheet['!cols'][startCol].wch = Math.max(worksheet['!cols'][startCol].wch, colWidth); }

注意:worksheet['!cols'][i].wch的单位是“字符宽度”,不是像素。wch: 20表示该列可显示约20个英文字符。实测中,中文列名建议设wch: 15~25,数字列wch: 10~12,足够清晰。

3.4 第四层:浏览器限制 → 下载触发机制(解决 Safari/移动端兼容)

XLSX.writeFile(workbook, 'data.xlsx')在 Chrome/Firefox/Edge 下完美运行,但在 Safari(尤其是 iOS)和部分安卓 WebView 中会失败,报错TypeError: undefined is not an object (evaluating 'a.href')。这是因为 Safari 对a.download属性的支持更严格。

我采用的兜底方案是:优先用XLSX.writeFile,失败则降级为URL.createObjectURL+a.click():

function exportToExcel(workbook, filename = 'export.xlsx') { try { // 方案1:SheetJS 原生方法(Chrome/Firefox/Edge) XLSX.writeFile(workbook, filename); } catch (e) { // 方案2:降级为 Blob + a 标签(Safari/iOS/WebView) const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); 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); URL.revokeObjectURL(url); } }

关键细节:XLSX.write(workbook, { type: 'array' })返回Uint8Array,这是Blob构造函数接受的格式。如果用{ type: 'binary' },在 Safari 下会得到字符串,导致文件损坏。

4. 实操过程:从零搭建一个企业级导出功能(含完整可运行代码)

4.1 环境准备与依赖安装

我们使用现代前端工程化方式(Vite + Vue3),但核心逻辑完全不依赖框架,可直接移植到 React、原生 JS 项目。

  1. 创建 Vite 项目(任选框架,此处以 Vue 为例):
npm create vite@latest my-excel-export -- --template vue cd my-excel-export npm install
  1. 安装 SheetJS(注意:必须用xlsx,不是js-xlsx):
npm install xlsx
  1. 在src/main.js中全局引入(或按需在组件中 import):
import * as XLSX from 'xlsx'; // 确保在组件中可用

4.2 核心导出函数封装(src/utils/excelExport.js)

以下是我经过6个项目验证的、开箱即用的导出工具函数,已包含上述所有四层映射逻辑:

/** * 将 HTML 表格元素导出为 Excel 文件 * @param {HTMLElement} tableElement - 页面上的 <table> 元素 * @param {string} filename - 导出的文件名(不含扩展名) * @param {Object} options - 配置项 * @param {boolean} options.includeTfoot - 是否包含 <tfoot>(默认 true) * @param {boolean} options.autoWidth - 是否自动计算列宽(默认 true) */ export function exportTableToExcel(tableElement, filename = 'export', options = {}) { const { includeTfoot = true, autoWidth = true } = options; // 步骤1:创建空 workbook const wb = XLSX.utils.book_new(); // 步骤2:解析 table 为 worksheet(核心逻辑) const ws = parseTableToWorksheet(tableElement, { includeTfoot, autoWidth }); // 步骤3:将 worksheet 添加到 workbook XLSX.utils.book_append_sheet(wb, ws, '数据'); // 步骤4:触发下载 exportWorkbook(wb, `${filename}.xlsx`); } /** * 解析 table 元素为 worksheet 对象 * @private */ function parseTableToWorksheet(tableElement, { includeTfoot, autoWidth }) { const worksheet = XLSX.utils.aoa_to_sheet([]); let rowIndex = 0; // 收集所有 tr:thead + tbody + (可选)tfoot const sections = ['thead', 'tbody']; if (includeTfoot) sections.push('tfoot'); const rows = sections.flatMap(sec => Array.from(tableElement.querySelectorAll(sec)).flatMap(el => Array.from(el.querySelectorAll('tr')) ) ); rows.forEach(tr => { const cells = Array.from(tr.querySelectorAll('td, th')); let colIndex = 0; cells.forEach(cell => { const rowspan = parseInt(cell.getAttribute('rowspan') || '1'); const colspan = parseInt(cell.getAttribute('colspan') || '1'); const startRow = rowIndex; const startCol = colIndex; const cellRef = XLSX.utils.encode_cell({ r: startRow, c: startCol }); // 获取值和类型 const cellData = getCellValueAndType(cell); worksheet[cellRef] = cellData; // 处理合并 if (rowspan > 1 || colspan > 1) { if (!worksheet['!merges']) worksheet['!merges'] = []; worksheet['!merges'].push({ s: { r: startRow, c: startCol }, e: { r: startRow + rowspan - 1, c: startCol + colspan - 1 } }); } // 处理样式 if (autoWidth) { const width = getColumnWidth(cell); if (!worksheet['!cols']) worksheet['!cols'] = []; if (!worksheet['!cols'][startCol]) { worksheet['!cols'][startCol] = { wch: width }; } else { worksheet['!cols'][startCol].wch = Math.max(worksheet['!cols'][startCol].wch, width); } } const alignment = getAlignment(cell); const isBold = isBold(cell); if (alignment || isBold) { if (!worksheet[cellRef].s) worksheet[cellRef].s = {}; if (alignment) worksheet[cellRef].s.alignment = alignment; if (isBold) { if (!worksheet[cellRef].s.font) worksheet[cellRef].s.font = {}; worksheet[cellRef].s.font.bold = true; } } colIndex += colspan; }); rowIndex++; }); return worksheet; } /** * 导出 workbook 对象 * @private */ function exportWorkbook(workbook, filename) { try { XLSX.writeFile(workbook, filename); } catch (e) { // Safari / iOS 降级方案 const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); 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); URL.revokeObjectURL(url); } } // 辅助函数(同前文,此处省略重复代码,实际文件中需完整包含) function getCellValueAndType(cell) { /* ... */ } function getColumnWidth(cell) { /* ... */ } function getAlignment(cell) { /* ... */ } function isBold(cell) { /* ... */ }

4.3 在 Vue 组件中使用(src/components/DataTable.vue)

假设你有一个标准的 Vue3 表格组件:

<template> <div> <table ref="tableRef" class="data-table"> <thead> <tr> <th colspan="2">订单信息</th> <th>下单时间</th> <th class="money">订单金额</th> </tr> </thead> <tbody> <tr v-for="item in data" :key="item.id"> <td>{{ item.orderId }}</td> <td>{{ item.productName }}</td> <td class="date">{{ formatDate(item.createdAt) }}</td> <td class="money">¥{{ formatMoney(item.amount) }}</td> </tr> </tbody> <tfoot> <tr> <td colspan="3" class="text-right"><strong>合计:</strong></td> <td class="money"><strong>¥{{ totalAmount }}</strong></td> </tr> </tfoot> </table> <button @click="handleExport">导出 Excel</button> </div> </template> <script setup> import { ref, onMounted } from 'vue'; import { exportTableToExcel } from '@/utils/excelExport'; const tableRef = ref(null); const data = ref([ { id: 1, orderId: 'ORD-001', productName: '笔记本电脑', createdAt: '2024-03-15T08:30:00Z', amount: 5999.00 }, { id: 2, orderId: 'ORD-002', productName: '无线鼠标', createdAt: '2024-03-16T14:20:00Z', amount: 89.99 } ]); const totalAmount = ref(6088.99); const formatDate = (dateStr) => { const d = new Date(dateStr); return d.toISOString().split('T')[0]; // 2024-03-15 }; const formatMoney = (num) => { return num.toLocaleString('zh-CN', { minimumFractionDigits: 2 }); }; const handleExport = () => { if (tableRef.value) { exportTableToExcel(tableRef.value, '订单数据报表', { includeTfoot: true, autoWidth: true }); } }; </script> <style scoped> .data-table { border-collapse: collapse; width: 100%; } .data-table th, .data-table td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; } .data-table th { background-color: #f2f2f2; font-weight: bold; } .data-table .money { text-align: right; } .data-table .date { text-align: center; } </style>

4.4 关键参数详解与调试技巧

导出功能上线后,最常见的问题是“导出结果和预期不符”。我整理了一份现场调试速查表,按出现频率排序:

问题现象根本原因快速定位方法解决方案
Excel 打开后提示“发现不可读取的内容”worksheet['!merges']中的坐标超出 worksheet 实际范围(如e: {r:100,c:5}但表格只有10行)在控制台打印console.log(ws['!merges']),检查每个e.r和e.c是否 ≤ws['!ref']的最大行列在parseTableToWorksheet中,合并单元格的endRow/endCol计算后,加一行校验:Math.min(endRow, maxRows)
中文显示为方块或问号XLSX.write()未指定编码,或Blob类型错误检查exportWorkbook中Blob的type是否为'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'确保type字符串完全匹配,大小写敏感;不要用'application/octet-stream'
日期列显示为 45234 这样的数字单元格类型t未设为'd',或v值不是Date对象console.log(ws['A1']),看t和v字段确保getCellValueAndType中return { v: date, t: 'd' },且date是new Date()实例
导出文件体积过大(>5MB)worksheet中存了大量空行/空列,或!cols数组冗余console.log(ws['!ref']),看范围是否合理(如A1:Z100);console.log(ws['!cols'].length)在parseTableToWorksheet结尾,添加trimWorksheet(ws)函数,移除末尾空行空列
Safari 下点击无反应a.click()在非用户手势事件中被阻止在handleExport中加console.log('triggered'),确认函数执行确保按钮是真实点击(非setTimeout触发),或改用window.open(url, '_blank')(但会新开页)

实操心得:每次修改导出逻辑后,务必用 Excel 的“公式”→“显示公式”功能检查单元格类型。按Ctrl+(反引号)可切换显示公式/值,这是验证t: 'n'/t: 'd'是否生效的最快方法。

5. 常见问题与排查技巧实录:那些只有踩过才知道的坑

5.1 “导出的数字列,Excel 里点一下才变正常” —— 千分位格式的隐藏陷阱

现象:导出后,金额列显示为12345.67,但双击单元格再回车,立刻变成¥12,345.67。用户抱怨“还要手动点一遍,太麻烦”。

原因:你设置了z: '"¥"#,##0.00',但 Excel 的数字格式(z)只影响显示,不影响存储值。初始显示为12345.67是因为 Excel 默认用“常规”格式渲染,直到你编辑它,它才应用你定义的z。

解决方案:强制 Excel 在打开时就应用格式。SheetJS 提供!cols的width属性,但对格式无效。真正有效的是:在单元格对象中同时设置z和t: 'n',并确保v是数字(不是字符串)。上面的getCellValueAndType已做到这点,但很多人漏掉了v的类型转换。

验证方法:在 Excel 中选中该单元格 → 右键“设置单元格格式” → 查看“数字”选项卡,应显示“自定义”,且格式代码为"¥"#,##0.00。

5.2 “合并单元格导出后错位,明明是 2x2 却变成 1x4” ——colspan/rowspan的 DOM 解析误区

现象:HTML 中<th colspan="2">订单信息</th>导出后,在 Excel 中占据 A1 和 B1 两个单元格,但!merges却记录为s:{r:0,c:0}, e:{r:0,c:1},看起来没错,可为什么视觉上还是分开的?

原因:!merges只告诉 Excel “把 A1:B1 合并”,但前提是 A1 和 B1 都存在且为空。如果 B1 有其他单元格内容(比如<td>被错误解析到了 B1),Excel 会忽略合并指令。

解决方案:在解析时,确保合并区域内的所有单元格都被标记为“已占用”。我在parseTableToWorksheet中增加了占用标记逻辑:

// 新增:记录已占用的单元格坐标 const occupied = new Set(); // 在处理每个 cell 前: const isOccupied = (r, c) => occupied.has(`${r},${c}`); const occupyCell = (r, c) => occupied.add(`${r},${c}`); // 在写入单元格时: for (let r = startRow; r <= endRow; r++) { for (let c = startCol; c <= endCol; c++) { if (!isOccupied(r, c)) { occupyCell(r, c); // 写入空单元格占位(仅用于合并,不显示内容) const ref = XLSX.utils.encode_cell({ r, c }); if (!worksheet[ref]) { worksheet[ref] = { v: '', t: 's' }; } } } }

这样,即使 HTML 结构不规范,也能保证合并区域干净。

5.3 “导出大表格(10万行)时浏览器卡死或内存溢出” —— 性能优化的临界点

现象:表格超过 5 万行,点击导出按钮后,页面无响应,几秒后弹出“网页无响应”提示。

原因:XLSX.utils.aoa_to_sheet([])在构建大型 worksheet 时,会创建海量对象,且worksheet[cellRef] = {...}是动态属性赋值,V8 引擎优化有限。

解决方案:分块处理 + Web Worker。但多数业务不需要这么重,我推荐一个轻量级优化:

  • 步骤1:禁用!cols自动计算。`getColumn
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进