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的方法實現了,原理就是下面這兩句話:
給兩個div,同樣的文字內容,第一段設定兩種字型,待載入字型首選,預設字型備選,第二種只設置預設字型,定時器去掃描,當兩段文字長度不同的時候就說明新字型載入成功可使用。
大概看了下webfontloader,程式碼寫的比較凌亂,命名奇怪,註釋少、沒翻譯(:joy:,可能是我能力還不夠),但考慮的情況比較完善,實現字型實現除了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軟鍵盤確定按鈕之後卡頓,需要滑動一下才能繼續觸控的問題。
碰到這問題真是老虎吃天,沒處下爪。最後各種查資料、各種嘗試,解決方案如下:
$nextTick() document.body.scrollTop = 0 document.body.scrollTop = 1000
碰到類似問題的可以沿著這個思路去解決,延時觸發了、下個週期執行了、滾動之類的。
總結
經過這次開發,對海報這種活動算是有了完整的瞭解,學習、鞏固了很多知識。相信讀著朋友們看完之後,也可以輕鬆實現海報製作了。
最後請大家玩兒玩兒這個活動,不妨關注下我的微博,哈哈哈。