H5海報製做實踐

引言

年後一直處於秣馬厲兵的狀態,上週接到了一個緊急需求,爲38婦女節作一個活動頁,主要功能是生成海報,第一次作這種需求,我也是個半桶水前端,這裏將碰到的問題、踩的坑,如何解決的分享給你們,講的不到位的地方還望斧正。javascript

效果展現

目前活動仍是在線狀態,這裏是最後生成海報的效果,掃描二維碼就能夠進入頁面。css

clipboard.png

實現方案

起初實現的方案是展現的時候直接使用canvas,計算手機屏幕大小,讓canvas充滿整個屏幕,用戶編輯完以後直接用展現的canvas生成圖片,最後發現這種形式很麻煩,碰到適配問題,canvas計算起來比較麻煩。html

最終方案,展現的時候使用html、css,這樣用戶看到的展現、編輯頁面適配起來容易。最後生成圖片的時候使用canvas,這個canvas是隱藏的,用戶不可見,這樣還有一個優勢,最終生成的海報大小是固定的,跟手機屏幕大小無關。前端

方案看着很簡單,實現的時候各類細節問題。vue

資源預加載

H5海報活動,就像一個小型的APP,體驗必定要好,最主要的就是資源預加載了,整個應用大小有30個圖片,還有字體文件,一個字體文件就有3MB多,如何作好資源預加載很大程度上影響了此次活動的體驗。java

圖片預加載

圖片預加載的原理就是使用http協議中的緩存,這裏主要指的是強緩存(協商緩存還要去服務器,有網絡交互)。在活動首頁以前加個loading頁面,將全部用到的圖片加載一遍,等到後面加載的時候就只有幾ms。ios

圖片預加載,使用let image = new Image()建立一個圖片標籤,在image.src中加入圖片連接,加載成功調用image.onload事件。一張圖片還好,大量圖片的話如何優雅的作出進度條呢?git

還好有Promise這個銀彈,咱們能夠輕鬆的實現進度條效果。github

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),就算有少數圖片未加載成功也影響不大。web

用起來也很簡單:

(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,將滾動條滾到底部便可。

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

總結

通過此次開發,對海報這種活動算是有了完整的瞭解,學習、鞏固了不少知識。相信讀着朋友們看完以後,也能夠輕鬆實現海報製做了。

最後請你們玩兒玩兒這個活動,不妨關注下個人微博,哈哈哈。

相關文章
相關標籤/搜索