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

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

一个比较复杂的项目,通过网页呈现出一个项目状态的报表,报表中要展示项目的一些属性信息,以及最多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,为什么不是后端生成,为什么不直接使用一个页面。
  • 图片这么大,人类要如何从图片中获取想要的信息。

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

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

相关推荐
Mintopia3 小时前
🌌 AIGC与AR/VR结合:Web端沉浸式内容生成的技术难点
前端·javascript·aigc
拖拉斯旋风3 小时前
前端学习之弹性布局(上):弹性布局的基本知识
前端
疯狂踩坑人3 小时前
【面试系列】万字长文,速通TCP、HTTP(s)、DNS、CDN、websocket、SSE
前端·面试
小时前端3 小时前
前端稳定性:你的应用经得起一场“混沌演练”吗?
前端·面试
一枚前端小能手3 小时前
🗂️ Blob对象深度解析 - 从文件处理到内存优化的完整实战指南
前端·javascript
杰克尼3 小时前
vue-day02
前端·javascript·vue.js
一只小阿乐3 小时前
vue3 中实现父子组件v-model双向绑定 总结
前端·javascript·vue.js·vue3·组件·v-model语法糖
qq_338032923 小时前
Vue 3 的<script setup> 和 Vue 2 的 Options API的关系
前端·javascript·vue.js
lumi.3 小时前
Vue Router页面跳转指南:告别a标签,拥抱组件化无刷新跳转
前端·javascript·vue.js