MDx Blog
MDx Blog

生成分享图功能开发踩坑记录

生成分享图功能开发踩坑记录

生成分享图是 MDx 的“元老级”功能之一,在 MDx 公开发布不久之后就被加入了 MDx(初版完成于 2018.1.1,初次实装的具体版本是 1.7.1)。经过这么多次的更新,生成分享图的功能正在不断变得更加美观完善,当然,这期间踩的坑也不少。接下来我会按时间顺序整理一下那些踩过的坑和相关解决方案。

开发灵感

开发这个功能的灵感来自于我试图将我的文章分享到 QQ 空间和微信朋友圈这类的半封闭社交网络的时候。由于我的博客没有接入也没有资质接入微信开放平台等相关平台,我的这些文章被分享到这些平台之后无法显示一些文章的基本信息,基本只能剩下一个简陋的链接——不仅丑,还让其他用户点击链接的欲望大大降低。于是,我希望主题中可以有一个简单而美观的分享文章的方式让我更好地分享文章。经过一些思考,我决定采用生成一张包含文章基本信息和文章链接二维码的“分享图”的形式实现这个需求。对于一篇文章,我只要将这张“分享图”分享出去就可以简单美观地分享文章。我本来以为这会变成又一个“MDx 独家功能”(我之前真的没有看到过其他主题有类似功能),然而在我搜索信息思考实现方式的时候,我看到有一款付费主题已经实现了这个功能(具体是哪款找不到了orz),而且生成结果还挺好看的。行吧,那我就不自称“独家功能”了。

难得碰到个完全符合我需求的实现,我仔细研究了这个主题的实现,结果...什么都没研究出来,唯一的信息是那款主题的实现思路是图片由后端生成的(所以我也挖不到更多信息了)。不过我思考了下,由后端,在 WordPress 的情况中就是 PHP,生成图片显然会占用 CPU 资源,如果请求量大了,对于我这种 1C 1G 小服务器的人来说非常不友好,何况后端生成图片样式更难细致控制,加上前后端数据传输也可能要花费时间和流量,这么一合计我就决定采取前端生成图片的方式实现这个功能,这样还能将计算压力分摊到客户端上,减轻服务器压力。正好我了解到有一个 JS 库 html2canvas 可以将 HTML 元素按样式绘制到 Canvas 上,大大降低了开发难度,可以让我更专注于控制生成图片的样式。于是,我就一脚踏进了前端生成图片这个无敌大坑里...

此外,我在为写这篇文章查一些资料时,发现网上已经有了大把的通过 PHP 实现这一功能的教程文章,也有不少主题已经加入了相关功能。这些教程文章大多发布于 2019 年初,我最初开发这些功能的时候并没有这些教程,也没有这么多主题有这个功能。很难说是 MDx,还是我看到的那一款主题,或者是其他我不知道的主题带动了这股风潮,不过我还是比较自豪 MDx 在早期就已经实现了这一功能——虽然实现方式不太一样。

初次见面

说回开发来,有着现成库的帮助,我很快完成了一版简单的实现。整体思路很简单,在页面上排好版,使用 qrcodejs 这个库生成页面二维码,然后使用 html2canvas 将整个 div 绘制到虚拟 canvas 上,最后将 canvas 转为 Data URL 的 png 塞进 img 里展示出来。虽然这个功能后来一直在更新,但这个基本思路却没有变化过,到最新版本仍然是这样的。

在第一版实现之后,我也意识到了前端生成分享图方案的一些劣势,比如字体选择少、字体渲染易受浏览器和系统差异的影响、生成速度慢、难以一键分享到社交网络(由于不在线上必须先保存再手动上传分享)等。在之后的版本中我优化了生成速度,不过那是后来的故事了。至于字体,由于 MDx 本身就偏向于“系统默认”风格,没有不必要的修饰,大部分系统中的默认无衬线字体就能很好地契合 MDx 的风格,字体选择少其实不是个很大的问题。但字体渲染在平台间的差异就很难解决了:加载完整字符集的 Web 字体是不现实的,调用系统字体又会受到系统影响,尤其是某些支持自定义字体的 Android 系统(请自行脑补中学女同学手机里的可爱字体),渲染出来的图片极其违和。不过后来转念一想,别的用户自己装的字体,页面变得很丑就是他们自己的锅,我瞎操心个啥,于是也不再纠结。只剩下最后一点实在不太好解决,有一些思路,不过也是后面的事了。

2X 屏呀 2X 屏

第一阶段的开发似乎已经结束了,我心满意足地提交了 commit。但我还是太单纯了,虽然开发时这个功能在电脑端测试没有任何问题,但 commit 之后我在手机上预览时发现了问题:图片宽高不对,还糊了。通过图片变模糊这一点我推测应该是手机上高 DPI 屏幕的问题,然而这类问题只能从 html2canvas 库的角度来解决。让情况变得更复杂的是,当时 html2canvas 这个库正处于重构之后的 alpha 阶段,而这次重构使得老版本中需要手动配置的高 DPI 屏幕选项变得自动化了,也正是因为这一更改,不同的 alpha 版本对于高 DPI 屏幕的处理有很大的差异。部分版本渲染结果会变模糊,部分版本则会出现奇怪的白边。我在多次实验之后,才确定一个问题相对较少的非最新版本 alpha-3。一直到半年后,我才将其更新到了 alpha-11。

至此,生成分享图功能终于在 MDx 1.7.1 初次亮相。

Speed Up!

虽然生成分享图的功能已经发布了,但我还有不满意的地方,那就是上文提到的生成速度不够快。点击生成按钮,图片通常要花费 2-4 秒才能生成。看起来这个问题没有优化的空间了,因为整个渲染过程都是由第三方库控制的,我难以进行进一步的优化。不过好在我仍然找到了可以优化的地方,那就是在客户端缓存已生成的图像,这样在同一个客户端上第二次获取同一篇文章的时候就可以直接调取缓存而不是再次生成了。

受另一个 MDx “元老级”功能“实时搜索”的启发,我决定将生成图片的 Data URL 缓存在浏览器的 sessionStorge这不仅拥有比 cookie 大得多的存储空间,还有简单的原生 Javascript 接口,非常适合用来存分享图这种内容。

而我选择 sessionStorge 而不是 localStorge 是出于文章更新的考虑。一方面,由于文章可能会更新,直接使用 localStorge 无脑永久缓存肯定是不现实的,而要实现有效期还得自己实现一个过期-更新系统,代码量很快就会上去。综合考虑之后,我觉得还是每次浏览结束之后都会清除数据的 sessionStorge 更适合适。

于是,我实现了一个简单的 sessionStorge 缓存系统。将页面 url 作为 key(早期版本是 url 的 MD5),生成的图片的 Data URL 作为 value,不用管缓存过期,完事!

var canvasData = canvas.toDataURL("image/png");
sessionStorage.setItem('si_imgdata_'+window.location.href, canvasData);
image.src = canvasData;

折腾完了之后,分享图的生成速度终于可以在部分情况下快一点了🌚。

旋转,错位,我卡页面

在 1.7.2 更新了 sessionStorge 之后,我很久都没有再更新这一功能,直到 1.8.12 突然就连着有 2 个 issue 提醒我这个功能 broken 了,具体情况是生成分享图时浏览器报错,关闭分享图浮层后页面冻结。

https://mdxblog.img.flyhigher.top/wp-content/uploads/2020/01/1.jpg

灾难现场

于是检查了一下,这个问题可以追溯到 1.8.9 我更新 ImgBox 的时候。当时我重构了 ImgBox 这个功能,但忘了和 ImgBox 共享一部分 DOM 的分享图功能,导致这个功能一路 broken 了数个版本但我没有发现...正好之前分享图显示时的尺寸计算一直有点小问题,有的时候会导致图片显示错位,加上显示效果也算不上美观,我就干脆重构了整个显示部分的代码。

不过,在这里我遇到了一个奇怪的问题。以下代码看起来没有问题:

var canvasData = canvas.toDataURL("image/png");
image.src = canvasData;

//插入元素
document.getElementById('mdx-share-img-loaded-container').appendChild(image);

//更新容器高度
shareDialog.handleUpdate();

但是很奇怪地,第 5 行的 .appendChild() 表现得像异步一样,第 8 行更新容器高度时获取到的总是 appendChild(img) 之前的高度。然而事实上,.appendChild() 应该是同步的,理应不会有问题。

其实,这里有一个问题,那就是浏览器的 JS 线程和绘图线程并不是同步的。虽然 .appendChild() 时 DOM 的确被同步地插入了页面,但这时绘图线程并没有绘制插入的这个 img,这就导致第 8 行更新高度时并没有获取到 img 的高度。要改变线程的执行顺序,我只能通过加一个 5ms 的 setTimeout 这种一点都不优雅的方式来使高度更新正常工作。

var canvasData = canvas.toDataURL("image/png");
image.src = canvasData;

document.getElementById('mdx-share-img-loaded-container').appendChild(image);

setTimeout(() => {
    shareDialog.handleUpdate();
}, 5);

似乎 0ms 的 setTimeout 也可以改变执行顺序,不过由于 5ms 的方案可以工作,我也没有修改的想法了。

重写之后,分享图展示界面终于精致了很多,不再会卡住页面,尺寸也不会计算错误了。

https://mdxblog.img.flyhigher.top/wp-content/uploads/2020/01/2.jpg

重写之后

CORS 虽迟但到

想不到,躲不过,跨域问题终于出现在了我的面前。问题是我自己发现的:不久之前,我偶然发现我博客的生成分享图功能出了问题——图片正常生成了,但文章的头图没有显示在分享图中。经过检查,我发现在生成分享图时,浏览器控制台中出现了一条我没见过的奇怪警告:

The FetchEvent for "https://img.flyhigher.top/foobar.jpg" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors.

随后相关资源就会报出 net::ERR_FAILED 错误。

一开始,我以为问题在于服务器响应里没有合适的 Access-Control-Allow-Origin 头,但检查之后发现没有问题。搜索之后我发现问题在于 Service Worker 规则里相关资源的域名被设为了缓存优先,导致 html2canvas 在获取图片时抛出这个错误,而解决方案就是为需要走网络优先规则的资源另开一个域名。

然而等我折腾完,分享图依然不能成功生成,不过错误变成了另一个:

Unable to get image data from canvas because the canvas has been tainted by cross-origin data.

好嘛,这个错误我认识,是由于图像跨域导致 canvas 被污染无法导出为 Data URL。直到这个时候我才发现在调用 html2canvas 库时,传入的配置里并没有解决 CORS 问题的 useCORS: true,反而设置了 allowTaint: true。这就导致在对图片设置了与页面不同的域名的情况下画布就会被污染无法读取。

html2canvas(document.getElementById("mdx-share-img"),{allowTaint: true})
.then(function(canvas){
    convertCanvasToImage(canvas);
});

解决方案也很简单,去掉 allowTaint: true 加上 useCORS: true 就好了。

html2canvas(document.getElementById("mdx-share-img"),{useCORS: true})
.then(function(canvas){
    convertCanvasToImage(canvas);
});

在回溯问题的时候我才发现,这个问题从这个功能的一开始就存在,allowTaint: true 的错误配置存在了整整两年,直到最新的 1.9.5 中才解决。

所以是生成分享图这个功能使用的人不多的原因还是使用 CDN 的人不多的原因呢...

至于为什么以前没有这个问题,经过我的检查,是由于我的 CDN 插件更新了,一些原本域名没有被替换的图片的在更新后被替换了,其中就包括分享图相关 DOM 中的图片,于是原来没有跨域的图片跨域了,分享图自然就不能正常生成了。

未来计划

虽然目前生成分享图功能已经比较完善了,但的确还有一些小问题,也有可以改进的地方。

比如,在部分情况下生成的图像的一侧会有 1 像素的透明边框,目前猜测还是高 DPI 屏幕下的问题,有机会的话我会尽可能地修复这个问题。

再比如之前提到的由于图片在本地生成,无法一键分享到社交网络。对于这个问题,我有一些思路,比如可以将图片上传到 sm.ms 等公共图床再分享,但这也有一些诸如第三方服务不一定可靠、上传图片需要花费时间和流量等问题。我暂时还没有好的思路,希望以后可以解决吧。

赞赏

推荐文章

yrc进行回复 取消回复

textsms
account_circle
email

MDx Blog

生成分享图功能开发踩坑记录
生成分享图是 MDx 的“元老级”功能之一,在 MDx 公开发布不久之后就被加入了 MDx(初版完成于 2018.1.1,初次实装的具体版本是 1.7.1)。经过这么多次的更新,生成分享图的功能正在不断变…
扫描二维码继续阅读
2020-01-22