SpreadJS带格式复制整个Sheet到系统剪贴板的实现

📅 发布时间:2026/9/16 18:06:52
SpreadJS带格式复制整个Sheet到系统剪贴板的实现
这些年做在线表格类项目SpreadJS 基本是绕不开的选项。客户的需求往往一开始都很朴素比如“把页面上这个表格原样复制到 Excel 里”但真正落地时会发现难点根本不在“复制”这个动作而在“原样带格式”这五个字。直接 CtrlC 只能带走纯文本用 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})或者用户在页面上直接 CtrlC。这个命令复制的内容默认只存在于 SpreadJS 内部模拟的剪贴板中它的作用范围是“同一个 Spread 实例内部粘贴”。也就是说你在一个 Sheet 里 CtrlC然后到另一个 Sheet 里 CtrlV格式能保留因为 SpreadJS 内部有一套完整的对象模型在做序列化和反序列化。但一旦切到 Excel 里 CtrlV这套内部机制就不生效了。浏览器在用户按下 CtrlC 时如果当前焦点在 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 styleborder-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 charsetutf-8也要写在 HTML 片段前面而且要用meta http-equivContent-Type contenttext/html; charsetutf-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 带格式保留同时页面内 CtrlC / CtrlV 使用 SpreadJS 原生的内部复制粘贴。两个路径互不干扰体验最顺。最后一个实用小技巧如果你只需要把某个区域而不是整个 Sheet 复制出去把getUsedRange换成new GC.Spread.Sheets.Range(row, col, rowCount, colCount)就行其余逻辑一行都不用改。这个区域参数我会通过 SpreadJS 的SelectionChanged事件实时获取用户选中哪里按钮就复制哪里比固定复制整个 Sheet 更灵活。做在线表格产品时用户的操作习惯和 Excel 高度一致尽量顺应这种习惯比刻意设计按钮位置更有用。