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,如果是簡單的繪制,原生的也是很好用的。如果用了某些類庫,碰到問題因為文檔少,翻譯更少,解決起來可能更麻煩。

跨域圖片如何解決

繪制這張海報的時候,大部分圖片都是自己的,設置允許跨域,只有用戶圖像這個圖片,是拿的其他部門獲取的實時用戶頭像,不讓跨域,這可把我整慘了,試了很多辦法都不行,最后使用服務器中轉解決了這個問題,步驟如下:

  1. 得到圖片鏈接。
  2. 將圖片鏈接通過接口傳遞給我們自己的服務器,服務器上獲取圖片base64,成功后返回給web。
  3. 將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軟鍵盤確定按鈕之后卡頓,需要滑動一下才能繼續觸摸的問題。

碰到這問題真是老虎吃天,沒處下爪。最后各種查資料、各種嘗試,解決方案如下:

  1. 彈起問題,我用的是vue,輸入框展示之后馬上聚焦有問題,需要用$nextTick()包一層,下個渲染回合在進行渲染。
  2. 卡頓問題,每當輸入框失去焦點的時候,將滾動條滾動到頂部document.body.scrollTop = 0即可。
  3. 彈起遮蓋問題,有些情況會出現鍵盤彈起會遮蓋輸入框,類似的,這種情況發生后執行document.body.scrollTop = 1000,將滾動條滾到底部即可。

碰到類似問題的可以沿着這個思路去解決,延時觸發了、下個周期執行了、滾動之類的。

總結

經過這次開發,對海報這種活動算是有了完整的了解,學習、鞏固了很多知識。相信讀着朋友們看完之后,也可以輕松實現海報制作了。

最后請大家玩兒玩兒這個活動,不妨關注下我的微博,哈哈哈。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM