需求场景 - 一个巨大的数据报表
一个比较复杂的项目,通过网页呈现出一个项目状态的报表,报表中要展示项目的一些属性信息,以及最多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是一个高性能的将网页内容转换为图片的工具
- github: github.com/zumerlab/sn...
- 示例:snapdom.dev/
实现的机制
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,性能对比
- 当前还没识别到样式的限制。
实现流程
插入到新建的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
前置预防超出限制
如果用户点击生成但是因为超出限制失败了,体验是非常差的,因此我们可以争取前置做一些工作,避免这个错误发生。
让我们一起来回顾现在已知的信息
- 能获取到当前浏览器的限制
- 能知道表格单元格的大小
- 可以调整生成图片的比例和质量
根据这些信息,如果真的会溢出或者担心会溢出,开可以做以下的动作
- 根据数据内容计算生成内容的像素大小,比对当前浏览器的限制,按比例将图片缩到溢出范围内
- 根据得到的限制,计算出可以接受的数据量,并提示给用户,让用户将数据通过筛选等方式,将数据缩减到限制范围内。
- 生成多张图片。
如果你还有其他的方案也可以贴出来讨论。
回归问题本质
在看到需求场景时,聪明的你肯定会思考一个大数据表转换为图片的合理性,你可能会问
- 为什么不是pdf,为什么不是excel,为什么不是后端生成,为什么不直接使用一个页面。
- 图片这么大,人类要如何从图片中获取想要的信息。
这些质疑都是合理的,当你遇到这样的需求,也请你先思考并挑战提出需求的人,是否合理。
生成图片是在我这个工作环境下,综合安全、用户体验、开发成本的一个综合权衡。可能会有更好的选择,但是今天你看到了我的这个方案,后面你就可以有更多的选择。