乐趣区

H5海报制作实践

引言
年后一直处于秣马厉兵的状态,上周接到了一个紧急需求,为 38 妇女节做一个活动页,主要功能是生成海报,第一次做这种需求,我也是个半桶水前端,这里将碰到的问题、踩的坑,如何解决的分享给大家,讲的不到位的地方还望斧正。
效果展示
目前活动还是在线状态,这里是最后生成海报的效果,扫描二维码就可以进入页面。

实现方案
起初实现的方案是展示的时候直接使用 canvas,计算手机屏幕大小,让 canvas 充满整个屏幕,用户编辑完之后直接用展示的 canvas 生成图片,最后发现这种形式很麻烦,碰到适配问题,canvas 计算起来比较麻烦。
最终方案,展示的时候使用 html、css,这样用户看到的展示、编辑页面适配起来容易。最后生成图片的时候使用 canvas,这个 canvas 是隐藏的,用户不可见,这样还有一个优点,最终生成的海报大小是固定的,跟手机屏幕大小无关。
方案看着很简单,实现的时候各种细节问题。
资源预加载
H5 海报活动,就像一个小型的 APP,体验一定要好,最主要的就是资源预加载了,整个应用大小有 30 个图片,还有字体文件,一个字体文件就有 3MB 多,如何做好资源预加载很大程度上影响了这次活动的体验。
图片预加载
图片预加载的原理就是使用 http 协议中的缓存,这里主要指的是强缓存(协商缓存还要去服务器,有网络交互)。在活动首页之前加个 loading 页面,将所有用到的图片加载一遍,等到后面加载的时候就只有几 ms。
图片预加载,使用 let image = new Image() 创建一个图片标签,在 image.src 中加入图片链接,加载成功调用 image.onload 事件。一张图片还好,大量图片的话如何优雅的做出进度条呢?
还好有 Promise 这个银弹,我们可以轻松的实现进度条效果。
class Preloadedr {
/**
*
* @param images array 要加载的图片,数组
* @param processCb function 回调函数,加载中进度有变化就调用
* @param completeCb function 回调函数,加载完成调用
*/
constructor(images, processCb, completeCb) {
this.imagesElement = []
this.loaded = 0
this.images = images
this.total = images.length
this.processCb = processCb
this.completeCb = completeCb
}

/**
* 开始预加载缓存图片
*
* @returns {Promise<any[]>} Promise 包含所有图片的 promise
*/
preloadImage() {
let me = this
let promises = []
me.loadedAction()
me.images.forEach((img) => {
let p = new Promise((resolve, reject) => {
let image = new Image()
image.src = img
this.imagesElement.push(image)
image.onload = () => {
me.loadedAction(img)
resolve(image)
}
image.onerror = () => {
resolve(“error”)
}
})
promises.push(p)
})

return Promise.all(promises)
}

/**
* 进度变化的时候回调,private
*
* @param key string 加载成功的图片地址
*/
loadedAction(key) {
if (key) {
this.loaded++
}
this.processCb(this.total, this.loaded)
if (this.total == this.loaded) {
this.completeCb()
}
}
}
每个要加载的图片都是一个 Promise,将所有图片 Promise 包装为一个大的 Promise,当这个大的 Promise 状态为 fulfilled 的时候,表明图片加载完成。要注意,包装图片 Promise 的时候 onerror 也是返回成功,这是因为 Promise.all 会包装出一个新 Promise,这个 Promise 只要出现一个失败,就直接返回报错了,所以失败了也返回成功(resolve),就算有少数图片未加载成功也影响不大。
用起来也很简单:
(async () => {
let imgLoader = new Preloadedr([
“//avatar-static.segmentfault.com/606/114/606114310-5c10a92c87f07_huge256”,
“//image-static.segmentfault.com/203/994/2039943300-5c515b79c91f1_articlex”,
], (total, loaded) => {
console.log(“process: 图片 ” + Math.floor(100 * loaded / total) + “%”)
}, () => {
console.log(“complete: 图片 ” + 100 + “%”)
})
await imgLoader.preloadImage()
console.log(“ 加载完成 ”)
})()
可以看到输出如下:
process: 图片 0%
Promise {<pending>}
process: 图片 50%
process: 图片 100%
complete: 图片 100%
加载完成
至此,图片预加载就实现了。接下来我们看看字体的预加载,字体也是一种 http 静态资源,也可以使用缓存,但在实现预加载上却远没有图片这么简单。
字体预加载
字体预加载,没有像 Image 那么方便的函数回调使用,查了下资料,有个 document.fonts 实验性的属性,试了下基本支持,但在 ios 上可能会出现一点儿小问题,加载过一次有缓存了,第二次加载时候 onloadingdone 事件可能不会触发,另外这个属性、事件还是一个实验性的属性,浏览器支持程度未知,可能很差。
查了很多资料,无意中看到有人说 webfontloader 这个项目通过一种比较 trick 的方法实现了,原理就是下面这两句话:
不同字体,在将 fontSize 设置到很大的时候(比如 300px),同一段文字,他展示的宽度是不一样的。给两个 div,同样的文字内容,第一段设置两种字体,待加载字体首选,默认字体备选,第二种只设置默认字体,定时器去扫描,当两段文字长度不同的时候就说明新字体加载成功可使用。

大概看了下 webfontloader,代码写的比较凌乱,命名奇怪,注释少、没翻译(????,可能是我能力还不够),但考虑的情况比较完善,实现字体实现除了 trick 的方法外,也用了上面提到的 document.fonts,有兴趣的可以详细阅读下。下面看看我实现的简易代码:
class Fontloader {
constructor(fontFamily) {
this.fontFamily = fontFamily
}

/**
* 返回 Promise,监测字体
*
* @returns {Promise<any>}
*/
watcher() {
if (“object” == typeof document.fonts) {
// 使用默认的 document.fonts,兼容性可能有问题,我做的过程中发现 ios 上可能会出现问题
return this.defaultWatcher()
} else {
// 使用 trick 法监测
return this.trickWatcher()
}
}

/**
* 返回 trick 法监测的 Promise
*
* @returns {Promise<any>}
*/
trickWatcher() {
let me = this
/**
* 生成一个获取字体展示宽度的 span 元素
* @param font
* @returns {HTMLSpanElement}
*/
let genSpanWithFont = (font) => {
let span = document.createElement(“span”)
span.style.cssText = `
display:block;
position:absolute;
top:-9999px;
left:-9999px;
font-size:500px;
width:auto;
height:auto;
line-height:normal;
margin:0;
padding:0;
font-variant:normal;
white-space:nowrap;
font-family:${font}
`
span.innerHTML = “BESbswy”
if (typeof document.body.append == “function”) {
document.body.append(span)
} else if (typeof document.body.appendChild == “function”) {
document.body.appendChild(span)
}
return span
}

/**
* 用来比较的字体
* @type {string[]}
*/
let fontDefault = [“serif”, “sans_serif”]
let defaultWidth = []
let fontWidth = []
fontDefault.forEach(font => {
let spanDefault = genSpanWithFont(font)
defaultWidth.push(spanDefault)
let spanFont = genSpanWithFont(me.fontFamily + `,${font}`)
fontWidth.push(spanFont)
})

let clearUp = () => {
defaultWidth.forEach(e => {
document.body.removeChild(e)
})
fontWidth.forEach(e => {
document.body.removeChild(e)
})
}

return new Promise((resolve, reject) => {
let check = () => {
for (let i = 0; i < fontDefault.length; i++) {
console.log(defaultWidth[i].offsetWidth, fontWidth[i].offsetWidth)
if (defaultWidth[i].offsetWidth !== fontWidth[i].offsetWidth) {
return true
}
}
return false
}

let times = 1
let maxTimes = 10000

let loop = () => {
if (times > maxTimes) {
clearUp()
reject(“load fonts error”)
}
times++
if (check()) {
clearUp()
resolve([me.fontFamily])
} else {
window.setTimeout(loop, 1000)
}
}

loop()
})
}

/**
* 支持原生方法的使用原生方法
* @returns {Promise<any>}
*/
defaultWatcher() {
return new Promise((resolve, reject) => {
let loadedFamily = []
document.fonts.onloadingdone = (e) => {
e.target.forEach((font) => {
if (font.status == “loaded”) {
loadedFamily.push(font.family)
}
})
resolve(loadedFamily)
}

document.fonts.onloadingerror = (e) => {
reject(“load fonts error”)
}
})
}
}
封装之后,两种形式都统一返回 Promise,在调用方通过异步函数 await watcher(),等待字体加在完成之后在继续流程。这里唯一有个缺点就是,字体可能要好几 MB,加载很慢,进度条很不均匀,这里我将加载分为 2 段,一段是图片,一段是字体,进度条分开展示,各位看官有更好的方法,不妨一起讨论。
canvas 绘制
绘制 canvas 的时候我是用了 pixi.js 类库,实际使用的时候并不一定方便很多 o(╯□╰)o,如果是简单的绘制,原生的也是很好用的。如果用了某些类库,碰到问题因为文档少,翻译更少,解决起来可能更麻烦。
跨域图片如何解决
绘制这张海报的时候,大部分图片都是自己的,设置允许跨域,只有用户图像这个图片,是拿的其他部门获取的实时用户头像,不让跨域,这可把我整惨了,试了很多办法都不行,最后使用服务器中转解决了这个问题,步骤如下:

得到图片链接。
将图片链接通过接口传递给我们自己的服务器,服务器上获取图片 base64,成功后返回给 web。
将 base64 绘制到 canvas。

这样就解决了来自别人服务器不让跨域图片的绘制

toDataURL 导出图片不全
海报由 10 个 sprite 组成,绘制完之后,马上调用 toDataURL,发现生成的图片没内容,或者图片缺失某些 sprite,这是因为绘制还没完成我就导出了,何以见得呢?当我延时几秒之后导出就没问题了。
为了保险起见,图片我一张张的绘制,每次绘制都是一个 Promise,等待状态为 fullfield 之后在进行下一张图片的绘制,最后一张绘制完之后,等待几百毫秒之后在进行导出,实际效果挺好,没再出现过导出图片不全或者空白的问题,下面是对绘图的封装:
async drawImage(sprite) {
return new Promise((resolve, reject) => {
let img = new Image()
img.setAttribute(“crossOrigin”,’Anonymous’)
img.onload = () => {
console.log(“yes”)
let item = new PIXI.Sprite.from(new PIXI.BaseTexture(img))
item.x = sprite.x
item.y = sprite.y
item.width = sprite.width
item.height = sprite.height
this.app.stage.addChild(item)
resolve(“0”)
}
img.src = sprite.image
})
}
我这里使用的是 pixi.js,sprite 表示一个精灵,里面包含了图片地址、坐标、宽高信息。onload 之后进行绘制,然后 resolve。
汉字折行问题
用的这个类库不支持汉字折行,汉字折行问题需要自己去计算,这里使用 canvas 的 measureText 方法,这个方法会根据字体大小样式计算字体正常渲染需要多少宽度,我只需要根据这个宽度一行行渲染汉字就行了,需要自己控计算控制绘制起点。
ios 键盘相关问题
作为一个后端,半桶水前端,每次碰到这种奇葩问题都很头疼,但作为后端又有一丝庆幸,不用经常面对这些问题,哈哈哈哈。
这次碰到的问题是 ios 上键盘弹起不正常、收起键盘卡顿的问题,具体就是用户点击按钮之后展示输入框,软键盘不弹起,和点击 ios 软键盘确定按钮之后卡顿,需要滑动一下才能继续触摸的问题。
碰到这问题真是老虎吃天,没处下爪。最后各种查资料、各种尝试,解决方案如下:

弹起问题,我用的是 vue,输入框展示之后马上聚焦有问题,需要用 $nextTick() 包一层,下个渲染回合在进行渲染。
卡顿问题,每当输入框失去焦点的时候,将滚动条滚动到顶部 document.body.scrollTop = 0 即可。
弹起遮盖问题,有些情况会出现键盘弹起会遮盖输入框,类似的,这种情况发生后执行 document.body.scrollTop = 1000,将滚动条滚到底部即可。

碰到类似问题的可以沿着这个思路去解决,延时触发了、下个周期执行了、滚动之类的。
总结
经过这次开发,对海报这种活动算是有了完整的了解,学习、巩固了很多知识。相信读着朋友们看完之后,也可以轻松实现海报制作了。
最后请大家玩儿玩儿这个活动,不妨关注下我的微博,哈哈哈。

退出移动版