笔者最近负责一款大屏在线编辑器。可以通过拖拽进行大屏搭建。
该编辑器可以在每个大屏或模版保存时候,对该大屏或模版的展现进行截图,后续在列表展示的时候,用户就能对该大屏或模版的大致展现有初步的了解。
同时,我们还可以在编辑大屏时候,将大屏的截图以JPG的形式导出来。
随着项目日渐复杂,我们的截图功能有时会出现截图失败的情况。为此我们需要分析下截图失败的原因,并解决之。
经过分析我们发现,我们的截图方案,最初是使用脚本库 dom-to-image 实现的。
我们大致过一下源码,总结一下其实现的原理:svg本质时一段 xml 代码。我们将待截图的 Dom 及其子节点,通过 svg 的标签 foreignObject 递归地放入 svg 标签。这样我们就能把这个 svg 做成图片,之后再借助 Canvas 的 API toDataURL
把 svg 转化成对应的图片格式。
由于 svg 不能渲染跨域资源。我们需要把跨域的字体、图片、css 等资源内连到 svg 内部。而对于页面中的 canvas 元素,我们则直接将其用 toDataURL
渲染成图片。我们截图程序原来的失败点就在这里:
由于 canvas 也受同源策略限制。我们的出问题的组件从 mapbox 加载资源,因此触发同源策略限制。此时 canvas toDataURL
失效。
目前,市面上前端截图的方案,主要就是在 svg 和 canvas 上重新绘制 dom ,并借助 canvas API生成图片。比较著名的库包括:html2canvas、dom-to-image 和 rasterizehtml 等等。但这种重建,不是浏览器自身的机制,所以都有或多或少的问题。比如,无法截图 iframe、对跨域资源不友好、对脚本不友好等等。
其实前端还有一种方案,使用原生的 navigator.mediaDevices.getDisplayMedia()
方法可以提炼出视频流,将它传递给 video 元素。并利用 video 和 canvas 配合,完成截图。这里是一个例子。由于安全限制,请在弹出的新页面里进行操作。
我们在调研时候,几乎就选定这种方法了。不过在最初的交互需要弹出用户交互的许可。这对于静默保存几乎是不可容忍的。因此我们放弃了这种做法。
接下来,我们的目光就转向了服务端截图。碰巧,NodeJS 服务端有一个比较完美的解决方案 puppeteer。它的原理就是在服务端启动一个浏览器,然后通过 devtools 协议指挥这个浏览器访问对应页面。最终调用自带的截图 API 进行截图,并返回 base64 图片内容。
我们的设想是,启动一个服务器。需要截图时候,向该服务发出请求。服务器接到请求,则启动截图。
我们需要解决几大问题:
- 用户身份问题。
- 非法身份不能干扰正常的截图。
- 因为是 Linux 系统,需要解决字体问题。
- 尽量快返回结果。
由于我们的系统是采用 cookie 进行身份验证,所以我们这里取了个巧。我们把截图请求在nginx 做一个代理,这时候,我们就相当于访问了当前域名下的服务,这时候,我们可以无缝地把 cookie 传过去:
location /workspaceshot {
proxy_pass http://xyzbalabala.com:3000;
include segments/proxy.conf;
}
我们的截图服务端使用 NodeJS 完成,我们使用框架 fastify 搭建服务。接受到的 cookie 可以传给 puppeteer。我们在 puppeteer 中主动访问验证身份接口,如果验证失败,则直接返回错误,避免错误截图被保存。
为了防止截图中文乱码,我们把中文的字体,苹方和雅黑放置在源代码文件,并在 Dockerfile 中设置了复制这些文件到字体文件夹。
COPY ./fonts/* /usr/share/fonts/
为了截图程序尽快运行,我们在启动时,使用最小的启动参数。
const browser = await puppeteer.launch({
headless: true,
timeout: 0,
executablePath: process.env.CHROME_BIN || null,
args: [
'--disable-gpu', // GPU硬件加速
'--disable-dev-shm-usage', // 创建临时文件共享内存
'--disable-setuid-sandbox', // uid沙盒
'--no-first-run', // 没有设置首页。在启动的时候,就会打开一个空白页面。
'--no-sandbox', // 沙盒模式
'--no-zygote', // 调试进程
'--single-process', // 单进程运行
'--incognito', // 无痕模式,
'--autoplay-policy=user-gesture-required',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-breakpad',
'--disable-client-side-phishing-detection',
'--disable-component-update',
'--disable-default-apps',
'--disable-domain-reliability',
'--disable-extensions',
'--disable-features=AudioServiceOutOfProcess',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-notifications',
'--disable-offer-store-unmasked-wallet-cards',
'--disable-popup-blocking',
'--disable-print-preview',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--disable-speech-api',
'--disable-sync',
'--hide-scrollbars',
'--ignore-gpu-blacklist',
'--metrics-recording-only',
'--mute-audio',
'--no-default-browser-check',
'--no-pings',
'--password-store=basic',
'--use-gl=swiftshader',
'--use-mock-keychain'
]
})
同时,我们在截图时,使用 JPEG 格式,使得返回更加迅速。
我们调用 puppeteer 基本就完成了服务端截图。平心而论,服务端截图质量较高,但是对服务器资源占用较大。而前端截图则更加轻量,但经常有截图失败的情况。
那么我们究竟选择哪种截图方案呢?
小孩子才做选择,我们都选择。我们的策略是,优先选择前端截图,如果前端失败,我们再用服务端截图作为兜底方案。
当然,这个方案还有优化的空间,比如在服务端截图时,把截图任务放入消息队列,服务请求只做队列的生产者,另起服务消费队列,进一步提高吞吐量。
Comments