如何将一个大表格的数据转为图片

需求场景 - 一个巨大的数据报表

一个比较复杂的项目,通过网页呈现出一个项目状态的报表,报表中要展示项目的一些属性信息,以及最多3年的资金情况,一个项目的数据在1000行以内,数据量极限40列*1000行即约40000个单元格。

在网页中使用了ag-grid,不分页展示所有数据内容,直接使用ag-grid的表格优化,展示这些数据并无压力。

但是这个需求要求可以将数据表通过前端导出为一个图片,用来做公司流程的凭据或分享。

你可能会思考导出图片在这个场景的合理性,这里我隐去了其他复杂的背景信息,咱们将注意力放到图片的导出上。

难点在哪

40000个单元格是一个超大的图片,如果粗暴地按照每个单元格是120x30像素的大小,40000个单元格的大小就是4800*30000像素,按照这个规模,无论是生成的效率,导出的文件大小,还是上传下载的成功率,都是十分大的挑战。

在过去,遇到需要将网页部分内容导出为图片的需求,基本都会选择html2canvas来处理,很多工具包如jspdf在html场景也是基于html2canvas来实现的。

用html2canvas还会给我带来一些额外的焦虑:

  • 部分样式支持的不好,尤其是不支持overflow: hidden,也就是说要想让html2canvas将网页内容转图片,你就需要转换的内容真实地展示在网页上。
  • 转换性能不是很好,页面稍微有些篇幅,就会有明显的卡顿甚至阻塞(性能对比

不过今天我有了新的选择 SnapDOM,让我将网页内容转为图片做的更好。

SnapDOM

SnapDOM是一个高性能的将网页内容转换为图片的工具

实现的机制

html2canvas,是通过识别dom树再通过canvas的api进行绘制。

SnapDOM,是通过识别dom先转为svg,然后再将svg转为的canvas。

可以解决哪些问题

包括但不限于

  • 可以导出overflow:hidden子元素的内容,也就是可以导出下面b元素的内容。
css 复制代码
<div id="a" style="width:0; height:0; overflow:hidden;">
  <div id="b">内容</div>
</div>
  • 可以通过控制图片的质量,来调整导出的内容质量
    • 控制质量可以通过scale、dpr、quality来控制
      • scale:输出比例乘数
      • dpr:设备像素比,这个属性一般默认是2,也就是说从网页的像素换算到图片的像素会x2,有可能会导致图片大小溢出,后面会重点提到图片大小溢出的问题。
      • quality:JPG/WebP 质量(0 到 1)
  • 够快,同样的网页内容,SnapDOM远好于html2canvas,性能对比
  • 当前还没识别到样式的限制。

实现流程

graph TD A[触发导出按钮] --> B[生成整个数据表的DOM结构] B --> C["将DOM结构和样式
插入到新建的iframe中"] C --> D[SnapDOM导出] D --> E[上传文件服务器] E --> F[完成] classDef process fill:#e6f7ff,stroke:#1890ff,stroke-width:2px; class A,B,C,D,E,F process; linkStyle 0 stroke:#52c41a,stroke-width:2px; linkStyle 1 stroke:#52c41a,stroke-width:2px; linkStyle 2 stroke:#52c41a,stroke-width:2px; linkStyle 3 stroke:#52c41a,stroke-width:2px; linkStyle 4 stroke:#52c41a,stroke-width:2px;
  • 创建在iframe中是为了让导出的内容和页面解耦,防止样式污染,如果你想复用页面的样式,不需要隔离,可以去掉这一步。
  • 生成的iframe最好像我前面举得例子,插入到一个宽高都是0,overflow:hidden的容器中,做到对用户无感,后面就算生成过程会阻塞也可以通过其他方式优化,比如web worker。
  • 当前流程设计的是触发后生成导出内容,当然你也可以根据自己的需要提前生成好需要导出的DOM结构,以进一步加速生成流程。

那么通过这个流程需求完成了么?并没有。

生成图片的限制

前面我们计算出了当前需求的极限数据会产出4800*30000像素的图片。

那么这么大的图片能不能被生成出来呢?

答案是:有可能可以。

jhildenbiddle.github.io/canvas-size... 这个网站你可以看到各个浏览器对canvas生成的图片大小限制。

值得注意的是,限制氛围3个维度,最大宽、最大高、最大面积,三个维度都不能逾越,也就是说在 最大面积限制是268,435,456,最大宽度是65,535,最大高度是65,535 的限制下

  • 你不能生成700001或170000的图片
  • 你不能生成65535*65535的图片
  • 你可以生成4800*30000大小的图片

另一个值得注意的是,各个文档描述的这个大小说的是因硬件、操作系统的不同可能不同,也就是说不只是浏览器的限制,那你就没法保证根据上面的流程可以生成出需求这么大的图片。

如果你真的碰到要生成这么大图片的场景,我的建议是信自己,写个方法自己探测浏览器的限制。

探测当前环境的Canvas生成图片大小限制

下面这段JS获取当前浏览器的Canvas生成图片的限制

这里探测使用的是二分法,如果支持的数字较大可能会造成卡顿,比如6w多的场景下我的机器延迟接近500ms。鉴于硬件、系统、浏览器是相对静态的环境,因此将探测的结果存到了localstorage中,避免每次要重复获取,影响页面的响应速度。

typescript 复制代码
/**
 * Generates a simplified browser fingerprint based on User Agent and other browser features
 * @returns {string} A simplified identifier for the current browser
 */
function getBrowserFingerprint(): string {
  const ua = navigator.userAgent
  let browserId = ''

  // Extract browser name and version
  if (ua.includes('Firefox')) {
    const match = ua.match(/Firefox/(\d+)/)
    browserId = `Firefox_${match ? match[1] : 'unknown'}`
  } else if (ua.includes('Edg/')) {
    const match = ua.match(/Edg/(\d+)/)
    browserId = `Edge_${match ? match[1] : 'unknown'}`
  } else if (ua.includes('Chrome')) {
    const match = ua.match(/Chrome/(\d+)/)
    browserId = `Chrome_${match ? match[1] : 'unknown'}`
  } else if (ua.includes('Safari')) {
    const match = ua.match(/Version/(\d+)/)
    browserId = `Safari_${match ? match[1] : 'unknown'}`
  } else {
    // Fallback to a hash of the full UA if we can't identify the browser
    let hash = 0
    for (let i = 0; i < ua.length; i++) {
      const char = ua.charCodeAt(i)
      hash = (hash << 5) - hash + char
      hash = hash & hash // Convert to 32bit integer
    }
    browserId = `Unknown_${Math.abs(hash).toString(36)}`
  }

  return browserId
}

/**
 * Generates a storage key based on the browser fingerprint
 * @returns {string} The localStorage key for the current browser
 */
function getStorageKey(): string {
  return `MAX_CANVAS_SIZE_${getBrowserFingerprint()}`
}

/**
 * A cache for the max size of a canvas.
 * @type { {width: number, height: number, area: number} | null }
 */

let MAX_CANVAS_SIZE: { width: number; height: number; area: number } | null =
  null

/**
 * Dynamically determines the maximum canvas size supported by the current environment.
 * @returns {{width: number, height: number, area: number}} An object containing the max width, height, and area.
 */
function getMaxCanvasSize() {
  if (MAX_CANVAS_SIZE !== null) {
    return MAX_CANVAS_SIZE
  }

  // Check if we have a cached value in localStorage
  try {
    const storageKey = getStorageKey()
    const storedValue = localStorage.getItem(storageKey)
    if (storedValue) {
      const parsed = JSON.parse(storedValue)
      // Basic validation
      if (parsed.width && parsed.height && parsed.area) {
        MAX_CANVAS_SIZE = parsed
        console.log(
          'Loaded Max Canvas Size from localStorage:',
          MAX_CANVAS_SIZE,
        )
        return MAX_CANVAS_SIZE
      }
    }
  } catch (e) {
    console.warn('Failed to load canvas size from localStorage:', e)
  }

  // A helper function to test a specific size
  const testCanvasSize = (width: number, height: number) => {
    const canvas = document.createElement('canvas')
    canvas.width = width
    canvas.height = height
    const ctx = canvas.getContext('2d')
    if (!ctx) {
      return false
    }
    // Write a pixel and read it back
    ctx.fillRect(0, 0, 1, 1)
    const data = ctx.getImageData(0, 0, 1, 1).data
      // If the pixel is black/transparent, it likely failed
    return data[3] !== 0
  }

  // Binary search for the max width (with minimal height)
  let low = 1
  let high = 131072
  let maxWidth = 0
  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    if (testCanvasSize(mid, 1)) {
      maxWidth = mid
      low = mid + 1
    } else {
      high = mid - 1
    }
  }

  // Binary search for the max height (with minimal width)
  low = 1
  high = 65536
  let maxHeight = 0
  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    if (testCanvasSize(1, mid)) {
      maxHeight = mid
      low = mid + 1
    } else {
      high = mid - 1
    }
  }

  // Test 3: Square canvas to find balanced limits
  low = 1
  high = Math.min(maxWidth, maxHeight)
  let maxSquareArea = 0
  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    if (testCanvasSize(mid, mid)) {
      maxSquareArea = mid * mid
      low = mid + 1
    } else {
      high = mid - 1
    }
  }

  console.log('maxSquareArea', maxSquareArea)

  MAX_CANVAS_SIZE = {
    width: maxWidth,
    height: maxHeight,
    area: maxSquareArea,
  }

  // Save to localStorage
  try {
    const storageKey = getStorageKey()
    localStorage.setItem(storageKey, JSON.stringify(MAX_CANVAS_SIZE))
    console.log('Saved Max Canvas Size to localStorage:', MAX_CANVAS_SIZE)
  } catch (e) {
    console.warn('Failed to save canvas size to localStorage:', e)
  }

  console.log('Detected Max Canvas Size:', MAX_CANVAS_SIZE)
  return MAX_CANVAS_SIZE
}

export default getMaxCanvasSize

前置预防超出限制

如果用户点击生成但是因为超出限制失败了,体验是非常差的,因此我们可以争取前置做一些工作,避免这个错误发生。

让我们一起来回顾现在已知的信息

  1. 能获取到当前浏览器的限制
  2. 能知道表格单元格的大小
  3. 可以调整生成图片的比例和质量

根据这些信息,如果真的会溢出或者担心会溢出,开可以做以下的动作

  1. 根据数据内容计算生成内容的像素大小,比对当前浏览器的限制,按比例将图片缩到溢出范围内
  2. 根据得到的限制,计算出可以接受的数据量,并提示给用户,让用户将数据通过筛选等方式,将数据缩减到限制范围内。
  3. 生成多张图片。

如果你还有其他的方案也可以贴出来讨论。

回归问题本质

在看到需求场景时,聪明的你肯定会思考一个大数据表转换为图片的合理性,你可能会问

  • 为什么不是pdf,为什么不是excel,为什么不是后端生成,为什么不直接使用一个页面。
  • 图片这么大,人类要如何从图片中获取想要的信息。

这些质疑都是合理的,当你遇到这样的需求,也请你先思考并挑战提出需求的人,是否合理。

生成图片是在我这个工作环境下,综合安全、用户体验、开发成本的一个综合权衡。可能会有更好的选择,但是今天你看到了我的这个方案,后面你就可以有更多的选择。

相关推荐
掘了11 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅11 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅12 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅12 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment12 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅12 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端
爱敲代码的小鱼13 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte13 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc
NEXT0613 小时前
前端算法:从 O(n²) 到 O(n),列表转树的极致优化
前端·数据结构·算法