忙了很长一段时间,需要浮出水面来总结一工作了,不然做过的东西就像翻过一页完全没有记住的书,难免徒劳。
0. 起源
最近在做一个在线图片制作的项目,面向网站运营和自媒体作者,为他们提供素材库和在线的简单图片处理、合成的方案。这类工作当然最累的是前端了,画布组件组合、拖拽、变形、调色,图片裁剪、拼接,每一个单拿出来都够填好一阵子的。但今天我要说的不是前端(虽然这个颇具挑战的项目一度让我萌生了重拾前端的想法),而是后端。
在我们的界面中,画布是这样呈现在我们面前的:
很简单,它是一系列DOM元素的组合。然而当用户选择下载时,他们希望得到的是这样一张图片:
我们需要考虑的是,怎么把这一堆DOM扔到一张图片里?之前我曾经用过的方案就是前端渲染,通过把DOM元素写入canvas,再调用浏览器渲染引擎截图,html2canvas.js
在这方面做得很好。然而省事的方法总伴随着一些麻烦:
- 浏览器分配到的资源有限。渲染速度慢,DOM转换canvas这个过程很费时间,特别是在DOM元素很多的情况下;
- 生成的图片要存储到文件系统,需要将图片转换为base64流走上传接口,而要保证图片质量的话,传输很占流量;
- 如果前端需要修改画布或素材样式,则需要将用户已保存的图片重新生成以完成同步,严重破坏数据一致性。
综上所述,一个神奇的解决方案——在后端渲染页面,就这么诞生了。
1. 敲定方案
在后端渲染页面,自己重新写个渲染引擎显然是不必要的,此时Headless Browser的概念开始进入我的视野。Headless Browser指的是一系列无界面的浏览器,一般用来配合爬虫生成网页的快照。它封装了某种浏览器内核,然后发起HTTP请求,对响应的内容进行渲染,输出图片。这跟我们的需求简直契合度100%。我考察了现在用的比较多的两种Headless Browser工具:
- wkhtmltopdf/wkhtmltoimage
- phantomjs
以上两个都是github上的开源项目,并且都是以Qt Webkit为内核的。经过一段时间的实际运用,也许是wkhtmltopdf的稳定版本Qt Webkit的引擎版本较低,对于一些web font的渲染支持并不是很好,与chrome等浏览器渲染效果有较大差异,于是我最终选择了phantomjs(中间省略数十万字踩坑血泪史)。
1.1. 抽象数据结构
有了Headless Browser后,我们需要得到页面的数据源来渲染页面,也就是为了得到和浏览器上显示一模一样的图片,后端必须拿到该页面所有的html、js、css代码。乍一看好像很麻烦,不过我们转念一想,我们需要渲染的也就只有画布这一个页面,那么我们参考前端的模板技术,定义好header、footer以及所有的js和css引用,把它们都放在服务器,到时候前端只需要把画布中的代码传过来不就好了吗?甚至我们还可以再进一步,把画布中的元素都抽象成数据结构,只需传输这些结构的实例,由服务器端根据预定义结构再拼装起来,岂不美哉?
以背景元素为例,它的类结构如下:
代码语言:javascript复制class Background extends Element {
// 标识属性
var name;
// 位置属性
var width;
var height;
var top;
var left;
// 形变属性
var rotate;
var scale;
var zIndex;
// 颜色属性
var color;
var opacity;
}
当用户在画布上新建一个背景元素时,根据用户定义的参数生成背景的一个实例:
代码语言:javascript复制{
"name": "Background1",
"width": 900,
"height": 500,
"top": 0,
"left": 0,
"color": "rgba(255, 255, 255, 1)",
"opacity": 100,
"rotate": 0,
"z-index": 0,
"scale": 1,
}
1.2. 构建渲染模板
定义好数据结构之后,后台需要根据这些定义以及前端传输过来的上述元素实例来重新拼装出画布。为了达到这个目的,我们首先需要在服务器端建立一个用来渲染页面的模板。模板完成数据拼装后需要输出html代码给phantomjs,因此我们就将模板存成一个html文件。
部分示例代码如下,在这里我们使用Vue.js渲染数据,也可以根据需要使用其他渲染组件。
代码语言:javascript复制<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<style>
.elem-node-bg {
z-index: 0;
}
</style>
<script src="./js/vue.js"></script>
</head>
<body>
<div id="app">
<div>
<template v-for="elem in elems">
<!-- background -->
<div class="elem-node elem-node-bg"
:style="{'opacity':elem.opacity/100,'width': elem.width 'px', 'height': elem.height 'px', 'background-size': 'cover', 'background-color': elem.color}"></div>
</template>
</div>
</div>
</div>
<script>
var rawData = {$data}
new Vue({
el: '#app',
data: {
elems: rawData
}
})
</script>
</body>
</html>
在后端可用字符串占位符替换的方法将数据实例塞进去,模板拼装这一步就完成了。得到的结果即将转入最后阶段:生成图片,
1.3. 生成图片
获取到拼装完成的html代码字符串后,我们可以开始使用phantomjs来渲染图片。在此之前,我选择先将这段代码写入到临时文件备用。随后,我们准备调用phantomjs的ScreenCapture方法,它的原理是在本地调起Webkit内核渲染指定页面,然后根据参数截取屏幕显示内容,生成图片。具体使用详见:http://phantomjs.org/screen-capture.html
新建文件render.js
:
var page = require('webpage').create();
// 在这里定义请求头,如访问目标对Referer、UserAgent有过滤机制的话可以加上
page.customHeaders = {
'Referer': 'http://www.xx.com',
};
// render.html即存储拼装数据的缓存文件
page.open('render.html', function() {
// 这里定义浏览器视窗宽高
page.viewportSize = { width: 900, height: 500 };
// 这里定义裁剪窗口坐标
page.clipRect = { top: 0, left: 0, width: 900, height: 500 };
page.evaluate(function() {
// 在这里可以复写dom元素,此处在最底层添加了一个背景层
var style = document.createElement('style'),
text = document.createTextNode('body { background: #fff }');
style.setAttribute('type', 'text/css');
style.appendChild(text);
document.head.insertBefore(style, document.head.firstChild);
});
// 指定输出文件名
page.render('render.jpg');
phantom.exit();
});
最后执行命令
代码语言:javascript复制phantomjs render.js
图片生成成功。
2. 一些不足
这个方案简单易操作,当然也还会存在很多问题。
- 与其他浏览器渲染细节上会有差异(具体需要看浏览器内核版本)。这个需要不断测试,尽量避免一些兼容性差的样式写法;
- 服务器如果非Windows,在字体的渲染上生成的图片会与Windows上浏览器显示的画布元素有差别。这涉及到Linux字体渲染引擎,需要深入研究,甚至自己对浏览器内核有一些改造;
- 渲染过程比较耗时,会对前端响应造成一定的影响。可以考虑后台用异步的方式生成图片,前端保存图片后不等待直接返回,减少用户交互上的不适。