这些年做在线表格类项目,SpreadJS 基本是绕不开的选项。客户的需求往往一开始都很朴素,比如“把页面上这个表格原样复制到 Excel 里”,但真正落地时会发现,难点根本不在“复制”这个动作,而在“原样带格式”这五个字。直接 Ctrl+C 只能带走纯文本,用 SpreadJS 自带复制命令又进不了系统剪贴板,折腾一圈下来,很多前端都被卡在“如何把整个 Sheet 的样式、合并单元格、列宽行高一起带出去”。这篇文章就把我自己的实现思路、踩过的坑和最终封装方案完整写出来,希望能给你省点时间。
先说清楚这篇文章适合谁:正在用 SpreadJS 做在线表格、报表设计器、数据录入系统,并且需要把整个工作表复制到系统剪贴板以便粘贴到 Excel、WPS 或邮件里的前端开发者。下面所有代码基于 SpreadJS 的 JavaScript API 编写,思路同样适用于类似的前端表格控件。
1. 先搞清楚:把 Sheet“带格式”复制到底要复制什么
1.1 从“复制一个格子”到“复制整个工作表”
很多人第一反应是遍历所有单元格,把每个格子里的 value 取出来,拼成一个二维数组或 CSV,然后扔进剪贴板。这个方案在数据简单时没问题,但一旦涉及合并单元格、背景色、边框、数字格式、列宽行高,纯文本方案就完全失效了。带格式复制的本质,是让目标程序(Excel 也好,WPS 也好)拿到一份“能还原样式”的结构化数据,而浏览器剪贴板里真正能承担这个角色的,就是 HTML 表格。
所以“复制整个 Sheet”这件事,拆开来看其实是三件事:读数据、生成 HTML、写剪贴板。读数据不能只读显示文本,还要拿到每个单元格的样式对象;生成 HTML 时要把样式转成内联 CSS,而不是靠外部样式表;写剪贴板时则要同时写入 text/html 和 text/plain 两个 MIME 类型,这样 Excel 会优先识别 HTML,而记事本等纯文本场景还有兜底内容。
1.2 SpreadJS 内部剪贴板与系统剪贴板的差别
SpreadJS 自身提供了复制命令,比如spread.commandManager().execute({cmd: "copy", sheet: sheet}),或者用户在页面上直接 Ctrl+C。这个命令复制的内容,默认只存在于 SpreadJS 内部模拟的剪贴板中,它的作用范围是“同一个 Spread 实例内部粘贴”。也就是说,你在一个 Sheet 里 Ctrl+C,然后到另一个 Sheet 里 Ctrl+V,格式能保留,因为 SpreadJS 内部有一套完整的对象模型在做序列化和反序列化。
但一旦切到 Excel 里 Ctrl+V,这套内部机制就不生效了。浏览器在用户按下 Ctrl+C 时,如果当前焦点在 SpreadJS 画布上,SpreadJS 可以通过拦截 copy 事件往系统剪贴板写入数据;如果是我们自己用按钮触发复制,就必须主动调用浏览器剪贴板 API,自己构造数据。明白了这一点,你就能理解为什么很多人封装“一键复制”时会发现:代码明明执行了,Excel 粘出来却只有一行文本,因为只写了text/plain,没写text/html。
1.3 你要交付给 Excel 的是“HTML”,不是“数据”
Excel 对 HTML 表格的支持相当成熟,它打开剪贴板里的 HTML 内容后,会解析<table>结构,识别<td>里的内联样式,包括背景色、字体、边框、合并单元格(通过colspan、rowspan)、列宽(通过<col width="...">或td的宽度)。这就是为什么带格式复制的最佳载体是 HTML 而不是 Word 的 RTF,也不是 JSON。
不过这里有个容易忽视的细节:Excel 对 HTML 的解析有自己的“标准”,它并不会完整支持所有 CSS 属性。比如background-color能识别但linear-gradient基本无效,border: 1px solid #000没问题但border-radius会被忽略。所以生成 HTML 时,我们不用追求把所有 SpreadJS 样式都塞进去,关键是优先还原 Excel 能理解的那部分:字体、字号、加粗、斜体、颜色、背景色、边框、对齐方式、合并单元格、列宽。
2. 实现前的准备:读取 Sheet 数据和样式
2.1 遍历行列:确定数据范围和合并单元格
要生成完整的 HTML 表格,第一步是确定 Sheet 的“有效范围”。我的做法是先拿到sheet.getUsedRange(),也就是有数据或者有样式的区域,然后遍历这个区域内的所有单元格。如果直接遍历整个 Sheet 的所有行列,性能会很差,尤其当用户模板行列数很多但实际只用了左上角一小块时,生成一个几千乘几千的空表格既没意义也浪费时间。
拿到 usedRange 之后,需要单独处理合并单元格。SpreadJS 里可以调用sheet.getSpans()拿到所有合并区域,也可以逐个单元格判断sheet.getSpan(row, col)。生成 HTML 时,合并区域的第一格写内容,并加上rowspan和colspan,后续被合并的单元格直接跳过。这个逻辑如果放在遍历主循环里判断,建议先建一个 Set 或二维标记数组,把合并过的格子记住,避免重复处理。
function getUsedMatrix(sheet) { const range = sheet.getUsedRange(GC.Spread.Sheets.UsedRangeType.Data); if (!range) return null; const rowCount = range.rowCount; const colCount = range.colCount; const spans = sheet.getSpans(); const spanMap = new Set(); const spanInfo = {}; spans.forEach(span => { const key = `${span.row}_${span.col}`; spanMap.add(key); spanInfo[key] = { rowCount: span.rowCount, colCount: span.colCount }; }); return { rowCount, colCount, spanMap, spanInfo, startRow: range.row, startCol: range.col }; }这里我特意记录startRow和startCol,因为 usedRange 不一定是 A1 开头,尤其是用户可能从第 5 行开始做表头。HTML 表格天然是二维矩阵,没有“空行跳过”的概念,所以生成时要从整个 usedRange 的第一行开始铺。
2.2 提取单元格值与公式
值这块要区分三种情况:普通值、公式和富文本。SpreadJS 中sheet.getValue(row, col)拿到的是存储值,sheet.getFormula(row, col)拿到的是公式字符串。如果某格有公式,Excel 粘贴后通常希望保留公式本身,但这里有个取舍问题:跨应用粘贴公式很容易因为引用相对位置变化而出错。我自己的习惯是提供一个开关,默认复制“值”,需要时再通过参数开启“公式”。
富文本处理更麻烦。SpreadJS 的富文本单元格返回的是 segments 数组,每段有自己的字体、颜色、上下标等信息。要完整还原到 HTML,需要把每段的文本包进 span 并设置相应样式。如果你的项目对富文本要求不高,可以直接用sheet.getText(row, col)拿纯文本,简单省事。
function getCellContent(sheet, row, col, keepFormula = false) { if (keepFormula) { const formula = sheet.getFormula(row, col); if (formula) { return { text: `=${formula}`, isFormula: true }; } } const text = sheet.getText(row, col); const richText = sheet.getRichText(row, col); if (richText && richText.segments && richText.segments.length > 1) { return { text: buildRichHtml(richText.segments), isRich: true }; } return { text: text?.toString() ?? '', isRich: false }; }这里有个小经验:取显示文本用getText而不是getValue,因为getText已经应用了数字格式,比如日期会显示成2024/05/16,百分比会显示成56.2%,这正是用户肉眼看到的“内容”。而getValue拿到的是原始存储值,直接放进 HTML 往往不直观,Excel 粘贴后也不会自动套用数字格式。
2.3 样式映射:把 SpreadJS 样式转成内联 CSS
样式是“带格式”的核心。SpreadJS 的样式对象和 CSS 不是一一对应的,需要手动映射。基础映射包括字体、字号、加粗、斜体、下划线、删除线、前景色、背景色、水平垂直对齐、边框。下面是常用的映射函数:
function styleToCss(style) { if (!style) return ''; const css = []; const font = style.font || '10.5pt 微软雅黑'; const fontArr = font.split(' '); const fontSize = fontArr.find(item => item.includes('pt')) || '10.5pt'; css.push(`font-size: ${fontSize}`); css.push(`font-family: ${fontArr[fontArr.length - 1] || '微软雅黑'}`); if (font.includes('bold') || style.fontWeight === 'bold') css.push('font-weight: bold'); if (font.includes('italic') || style.fontStyle === 'italic') css.push('font-style: italic'); if (style.foreColor) css.push(`color: ${style.foreColor}`); if (style.backColor && style.backColor !== 'rgb(255, 255, 255)') css.push(`background-color: ${style.backColor}`); css.push(`text-align: ${mapAlign(style.hAlign)}`); css.push(`vertical-align: ${mapVAlign(style.vAlign)}`); if (style.borderLeft) css.push(`border-left: ${borderToCss(style.borderLeft)}`); if (style.borderTop) css.push(`border-top: ${borderToCss(style.borderTop)}`); if (style.borderRight) css.push(`border-right: ${borderToCss(style.borderRight)}`); if (style.borderBottom) css.push(`border-bottom: ${borderToCss(style.borderBottom)}`); return css.join('; '); }需要注意sheet.getStyle(row, col)取到的是“本单元格独有样式”,不是“最终生效样式”。如果单元格没有单独设置样式,getStyle 返回 null,但表格里可能套用了主题样式、整列样式或条件格式。更稳妥的做法是用sheet.getActualStyle(row, col),它会合并默认样式、行列样式和单元格样式,拿到的才是真正渲染出来的效果。这一点非常容易踩坑,我第一次封装时就是用 getStyle,结果大片单元格没有背景色和边框,排查了半天才发现是这个问题。
边框映射还有一个坑:SpreadJS 的边框对象里,borderLeft的 color 可能是类似#000000的字符串,也可能带透明度,需要原样输出。另外相邻两个格子都有边框时,Excel 的显示效果是“后画的覆盖先画的”,HTML 表格在某些浏览器上可能出现双边框。简单的处理方案是只在单元格的右下两侧画边框,左上靠前一个格子顶上去,但这样会漏掉 usedRange 第一行和第一列的顶部和左边框。稳妥起见,我选择把四个方向的边框都配上,然后给table设置border-collapse: collapse,让浏览器自动合并。
3. 核心实现:构造剪贴板数据并完成写入
3.1 HTML 表格生成与 Excel 粘贴适配
生成 HTML 时,我会从<table>开始,带上border-collapse: collapse边界风格,然后依次输出<col>定义列宽,再逐行输出<tr>和<td>。每个<td>都写上style属性,内容用innerText的安全替换方式做 HTML 转义,防止特殊字符破坏结构。
列宽可以通过sheet.getColumnWidth(col)获取,单位是像素,在 HTML 里对应width属性。行高用sheet.getRowHeight(row),如果不设置,Excel 默认按内容高度撑开,观感会差很多。列宽行高对 Excel 粘贴还原非常重要,尤其是制作打印模板类需求时,缺了这两个参数,整个版式会散掉。
function sheetToHtml(sheet, { keepFormula = false } = {}) { const matrix = getUsedMatrix(sheet); if (!matrix) return '<table></table>'; const { startRow, startCol, rowCount, colCount, spanMap, spanInfo } = matrix; let html = '<table style="border-collapse: collapse;">'; html += '<colgroup>'; for (let c = 0; c < colCount; c++) { const width = sheet.getColumnWidth(startCol + c); html += `<col width="${width}" />`; } html += '</colgroup>'; for (let r = 0; r < rowCount; r++) { const rowHeight = sheet.getRowHeight(startRow + r); html += `<tr style="${rowHeight ? 'height: ' + rowHeight + 'px;' : ''}">`; for (let c = 0; c < colCount; c++) { const key = `${startRow + r}_${startCol + c}`; if (spanMap.has(key)) continue; const span = spanInfo[key]; const style = sheet.getActualStyle(startRow + r, startCol + c); const css = styleToCss(style); let td = `<td style="${css}"`; if (span) { td += ` rowspan="${span.rowCount}" colspan="${span.colCount}"`; } td += '>'; const content = getCellContent(sheet, startRow + r, startCol + c, keepFormula); td += content.isRich ? content.text : escapeHtml(content.text); td += '</td>'; html += td; } html += '</tr>'; } html += '</table>'; return html; }这里要特别强调escapeHtml的必要性。单元格里的内容可能是<、>、&、换行符,尤其用户是复制一段代码或 XML 内容时,如果不做转义,生成的 HTML 结构会被破坏,粘贴到 Excel 里的内容就是乱的。换行符也要处理成<br>或者让 Excel 识别,我在转换时会把\n替换成<br>,因为 Excel 对 HTML 里文本换行的识别比较依赖这个标签。
3.2 用 Clipboard API 写入 text/html 与 text/plain
现代浏览器推荐用异步 Clipboard API,核心是构造一个ClipboardItem对象,把多个 MIME 类型的数据放进去,然后调用navigator.clipboard.write()。完整的代码如下:
async function copySheetToClipboard(sheet, options = {}) { const html = sheetToHtml(sheet, options); const plainText = sheetToPlainText(sheet, options); const clipboardItem = new ClipboardItem({ 'text/html': new Blob([html], { type: 'text/html' }), 'text/plain': new Blob([plainText], { type: 'text/plain' }) }); await navigator.clipboard.write([clipboardItem]); }text/plain部分我单独写了sheetToPlainText函数,它把每个单元格的文本用\t连接成一行,行与行之间用\n分隔,本质上就是 TSV 格式。这个 fallback 很重要,因为有些场景(比如粘贴到聊天窗口、文本编辑器)不支持 HTML,这时 TSV 至少能保证数据不错位。
还有一个细节:构造ClipboardItem时,MIME 类型必须和 Blob 的 type 严格一致,否则 Chrome 会抛NotAllowedError或TypeMismatchError。另外,navigator.clipboard.write必须在用户手势触发的异步流程里调用,比如点击事件回调中直接调用,不能在几秒之后的 setTimeout 里执行,否则会被浏览器判定为非用户主动操作而拒绝。
3.3 老旧浏览器与 execCommand 兼容方案
虽然 Clipboard API 已经普及,但总有一些内嵌浏览器、老版本 Electron 或偏保守的企业浏览器环境不支持ClipboardItem。我在项目里保留了一套基于document.execCommand('copy')的降级方案,思路是创建一个隐藏的textarea或div,把 HTML 放进去,选中,再执行 copy 命令。
function fallbackCopyHtml(html, plainText) { const container = document.createElement('div'); container.setAttribute('contenteditable', 'true'); container.style.position = 'fixed'; container.style.left = '-9999px'; container.innerHTML = html; document.body.appendChild(container); const range = document.createRange(); range.selectNodeContents(container); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); const done = document.execCommand('copy'); selection.removeAllRanges(); document.body.removeChild(container); return done; }execCommand方案有个老毛病:它只能把选区内容放进去,而选区内容的“格式”取决于浏览器如何序列化所选 DOM。在 Chrome 里选中一个带内联样式的 div 再复制,clipboard 里通常会有 text/html 和 text/plain 两份数据,基本够用。但在 Firefox 和老版本 Safari 上,HTML 序列化有时会丢失部分样式,这是浏览器行为,前端很难完全控制。所以我会在检测到不支持ClipboardItem时,先给出一个明确的提示文案,让用户知道在确认粘贴格式前最好先粘贴到 Excel 里检查一遍。
3.4 用户手势、权限与 Safari 注意点
Safari 对剪贴板 API 的支持一直比较保守。navigator.clipboard.write在 Safari 里需要满足两个条件:页面是 HTTPS 环境(或 localhost),且调用发生在用户手势事件内部。这两个条件缺一个,Safari 都会静默失败或报NotAllowedError。实测下来,Safari 16+ 对ClipboardItem的支持还可以,但 text/html 的粘贴到 Excel 时,部分样式(尤其是列宽)会被忽略,所以如果主要用户群用 Safari,建议在复制完成后增加一个“复制成功,若样式缺失请使用 Chrome/Edge”的提示。
Chrome 在权限方面相对宽松,clipboard-write在用户激活的页面上默认放行,不需要额外申请。但如果你把代码写在 iframe 里,需要检查 iframe 是否允许clipboard-write权限策略,否则调用了也会被拦截。这块排查起来有点隐蔽,因为浏览器控制台并不总是打印错误,可能只是一句静默失败。
4. 踩坑记录:从剪贴板到 Excel 的细节问题
4.1 样式丢失:为什么必须内联样式
我最早封装时为了代码整洁,把样式写成了<style>标签里的 class,结果复制到 Excel 后所有样式全部丢失。原因很简单:Excel 解析剪贴板 HTML 时,基本不加载内嵌<style>标签,它只认元素上的style属性。所以生成 HTML 必须把所有样式内联到<table>、<tr>、<td>上,哪怕重复很多,也不能偷懒用 class。这条规则对 HTML 邮件同样适用,做过邮件前端的朋友应该秒懂。
还有个容易忽略的点:<meta charset="utf-8">也要写在 HTML 片段前面,而且要用meta http-equiv="Content-Type" content="text/html; charset=utf-8"这种带 charset 的写法。如果缺失,Excel 在解析中文时可能出现乱码。我的sheetToHtml函数里在开头拼上了这段 meta。
4.2 Excel 里多出空行空列
问题表现为:复制后粘贴到 Excel,明明只选了 3 行 4 列的数据,粘出来却多了很多空白行列。常见原因有两个:一是 usedRange 的范围比实际数据大,比如某些单元格设置过样式但内容为空,SpreadJS 会认为这个区域“被使用了”,于是 usedRange 扩大到整块区域;二是表格里有跨行列合并,在遍历到合并区域内部时,如果清理逻辑没做干净,会出现空 td 计数错位。
我的处理方式是增加一个“有效内容判断”:在生成td前,如果当前单元格无文本、无样式、无边框、且不是合并区域起点,就把它输出成空 td。同时提供trimTrailingEmptyRow选项,遍历时记录每一行是否有实际内容,末尾连续的空行去掉,这样粘贴到 Excel 不会带出一大片空白。
4.3 公式粘贴成文本
这个问题取决于需求。如果你想保留公式,需要在生成 td 时把=开头的公式字符串放进去,Excel 粘贴 HTML 时会自动把单元格内容当作文本还是公式?实测下来,Excel 对 HTML 表格里的=开头的文本,有时会当成公式执行,有时会当成字符串粘贴,行为并不完全可预测。更稳妥的做法是生成时给公式单元格的 td 加上>class SpreadSheetClipboard { static async copySheet(sheet, options = {}) { const html = this.buildHtml(sheet, options); const plainText = this.buildPlainText(sheet, options); const isSupport = typeof ClipboardItem !== 'undefined' && navigator.clipboard && window.isSecureContext; if (isSupport) { const item = new ClipboardItem({ 'text/html': new Blob([html], { type: 'text/html' }), 'text/plain': new Blob([plainText], { type: 'text/plain' }) }); await navigator.clipboard.write([item]); } else { const ok = this.fallbackCopy(html, plainText); if (!ok) throw new Error('当前浏览器不支持直接复制,建议使用 Chrome 最新版'); } } }
使用方只需要关心一行代码:
await SpreadSheetClipboard.copySheet(spread.getActiveSheet(), { keepFormula: false });这里我额外做了window.isSecureContext判断,用于过滤非 HTTPS 页面,避免调用剪贴板 API 后出现不可控的报错。同时,整个方法在按钮点击事件里同步调用,确保在用户手势的有效窗口期内完成写入。
5.3 与 SpreadJS 内置粘贴选项的配合
如果用户复制到同一个 SpreadJS 实例内粘贴,我们自制的 HTML 剪贴板方案反而不如 SpreadJS 内置粘贴体验好。因此实际项目中,我会监听 SpreadJS 的ClipboardPasted事件,判断粘贴来源:如果来自外部(比如 Excel),走系统剪贴板解析;如果来自内部复制命令,走 SpreadJS 原生处理。这部分的监听逻辑不复杂,网上能查到很多配置示例,关键是别忘了在销毁组件时移除监听,避免内存泄露。
我自己的项目中,最终给用户的交互是:按钮“复制表格”执行上面封装的方法,粘贴到 Excel 带格式保留;同时页面内 Ctrl+C / Ctrl+V 使用 SpreadJS 原生的内部复制粘贴。两个路径互不干扰,体验最顺。
最后一个实用小技巧
如果你只需要把某个区域而不是整个 Sheet 复制出去,把getUsedRange换成new GC.Spread.Sheets.Range(row, col, rowCount, colCount)就行,其余逻辑一行都不用改。这个区域参数我会通过 SpreadJS 的SelectionChanged事件实时获取,用户选中哪里,按钮就复制哪里,比固定复制整个 Sheet 更灵活。做在线表格产品时,用户的操作习惯和 Excel 高度一致,尽量顺应这种习惯,比刻意设计按钮位置更有用。