利用Node Puppeteer 來搭建多端統一的海報渲染服務

緣起

在朋友圈,你可能會見過有不少帶着我的信息或者二維碼的絢麗的海報圖片,看起來很高大上的樣子。在很早以前,我有過了解的是,這些海報圖片都是由 UI 設計師,進行人肉設計出來的,很是考驗設計師的忍耐力。再到後來,隨着 web 技術的發展,出現了由前端來生成一張又一張的海報圖片,這樣 UI 設計師只須要設計一張模版圖片,剩餘的交給前端來完成便可,大大的減輕了 UI 小姐姐們苦力活(滑稽笑)。html

雖然,UI 小姐姐的活減輕了,可是前端(包含移動端)小哥哥的活就加劇了,還面臨着不少頭疼的問題,好比:生成的圖片清晰度不夠、手機兼容問題致使生成失敗、資源跨域問題致使生成失敗、web 端和移動端生成的圖片不同等等問題。前端

咱們瞭解下前端是如何生成海報圖片的呢?

其實前端是經過 HTML5 新增長的 canvas API 來繪製的,可是 canvas 繪製會有不少的痛點:上手門檻比較高,須要掌握 canvas API;代碼可讀性比較差、調試複雜;代碼可複用低,每一個端須要從新編碼;無緩存、同一張相同圖片會屢次繪製,用戶體驗差;若是有遠程圖片,可能會引發跨域問題,致使繪製失敗等等問題。node

針對這些問題,社區出現了一個開源庫(html2canvas),經過編寫 HTML 頁面來生成圖片,有效解決了大部分問題,可是圖片跨域、緩存、代碼複用問題,仍是沒法解決。針對這些問題,社區提出了使用服務端來完成海報圖片渲染,就有可能完全解決這些問題。git

咱們該如何經過服務端來渲染這個海報呢?

在小程序社區,有贊商城前端團隊成員提出基於 Puppeteer 來實現公共海報渲染服務,使用方只需傳入海報圖片的html,海報渲染服務繪製一張對應的圖片做爲返回結果,解決了canvas繪製的各類痛點問題。那麼,我今天就來體驗下快感吧。在這以前,咱們先來了解下!github

Puppeteer 是什麼

puppeteer 是一個Chrome官方出品的headless Chrome node庫。它提供了一系列的API, 能夠在無UI的狀況下調用Chrome的功能, 適用於爬蟲、自動化處理等各類場景等。web

Puppeteer 能作什麼

根據官網上描述,Puppeteer幾乎能實現你能在瀏覽器上作的任何事情,好比:npm

一、生成頁面截圖和 PDF;canvas

二、自動化表單提交、UI 測試、鍵盤輸入等;小程序

三、建立一個最新的自動化測試環境。使用最新的 JavaScript ;跨域

四、和瀏覽器功能,能夠直接在最新版本的 Chrome 中運行測試;

五、捕獲站點的時間線跟蹤,以幫助診斷性能問題;

六、爬取 SPA 頁面並進行預渲染(即'Prerender');

七、開發我的微信接口,實現我的微信機器人(wechaty)。

本文的渲染服務將會使用它的截圖功能,來實現圖片生成。

代碼實現

接下來,就讓咱們簡單的看看如何代碼實現吧!

首先,須要先初始化一個 npm 項目,而且安裝響應的模塊,這裏就不一一說明了。

npm init
npm install puppeteer koa crypto --save
複製代碼

這裏,咱們安裝了三個模塊:puppeteer 咱們今天的關鍵性模塊、koa 快速搭建一個 web 服務、crypto 經過加密內容字符串來生成惟一標識符。

另外,個人 node 版本是 10.11 的,因此使用了不少新語法。 app.js

/* * @Descripttion: 入口文件 * @version: 1.0.0 * @Author: falost * @Date: 2019-08-27 10:54:32 * @LastEditors: falost * @LastEditTime: 2019-09-08 18:20:41 */
const SnapshotController = require('./libs/SnapshotController')

const Koa = require('koa')

const controller = new SnapshotController()

const app = new Koa()

app.use(async ctx => {
  return await controller.postSnapshotJson(ctx)
})
app.listen(3000)
複製代碼

/libs/SnapshotController.js

/* * @Descripttion: 調取 puppenter 來生成接收到的html 數據生成圖片 * @version: 1.0.0 * @Author: falost * @Date: 2019-08-27 09:55:52 * @LastEditors: falost * @LastEditTime: 2019-09-08 18:20:56 */
const crypto = require('crypto');
const PuppenteerHelper = require('./PuppenteerHelper');

const oneDay = 24 * 60 * 60;

class SnapshotController {
  /** * 截圖接口 * @param {Object} ctx 上下文 */
  async postSnapshotJson(ctx) {
    const result = await this.handleSnapshot()

    ctx.body = {code: 10000, message: 'ok', result}
  }

  async handleSnapshot() {
    const { ctx } = this
    const { html } = ctx.request.body  // html 是咱們將要生成的海報圖片的 HTML 實現代碼字符串
    // 根據 html 作 sha256 的哈希做爲 Redis Key
    const htmlRedisKey = crypto.createHash('sha256').update(html).digest('hex');

    try {
      // 首先看海報是否有繪製過的
      let result = await this.findImageFromCache(htmlRedisKey);

      // 獲取緩存失敗
      if (!result) {
        result = await this.generateSnapshot(htmlRedisKey);
      }

      return result;
    } catch (error) {
      ctx.status = 500;
      return ctx.throw(500, error.message);
    }

  }

  /** * 判斷kv中是否有緩存 * @param {String} htmlRedisKey kv存儲的key */
  async findImageFromCache(htmlRedisKey) {
    return false
  }

  /** * 生成截圖 * @param {String} htmlRedisKey kv存儲的key */
  async generateSnapshot(htmlRedisKey) {
    const { ctx } = this
    const {
      html,
      width = 375,
      height = 667,
      quality = 80,
      ratio = 2,
      type: imageType = 'jpeg',
    } = ctx.request.body;

    if (!html) {
        return 'html 不能爲空'
    }

    let imgBuffer;
    try {
      imgBuffer = await PuppenteerHelper.createImg({
        html,
        width,
        height,
        quality,
        ratio,
        imageType,
        fileType: 'path',
        htmlRedisKey
      });
    } catch (err) {
      // logger
      console.log(err)
    }
    let imgUrl;

    try {
      imgUrl = await this.uploadImage(imgBuffer);
      // 將海報圖片路徑存在 Redis 裏
      await ctx.kvdsClient.setex(htmlRedisKey, oneDay, imgUrl);
    } catch (err) {
    }

    return {
      img: imgUrl || ''
    }
  }

  /** * 上傳圖片到 CDN 服務 * @param {Buffer} imgBuffer 圖片buffer */
  async uploadImage(imgBuffer) {
    // upload image to cdn and return cdn url
  }
}

module.exports = SnapshotController  
複製代碼

./libs/PuppenteerHelper.js

/* * @Descripttion: 建立生成圖片類 * @version: 1.0.0 * @Author: falost * @Date: 2019-08-27 11:43:41 * @LastEditors: falost * @LastEditTime: 2019-09-08 18:20:51 */
const puppeteer = require('puppeteer')
const { mkdirsSync, formatNumber } = require('../utils/utils')

class PuppenteerHelper {
  async createImg(params) {
    const browser = await puppeteer.launch({
      headless: false, // 默認爲 true 不打開瀏覽器,設置 false 打開
    })
    const date = new Date()
    const path = `static/upload/${date.getFullYear()}/${formatNumber(date.getMonth() + 1)}`
    mkdirsSync(path)
    // 經過建立瀏覽器標籤來打開
    const page = await browser.newPage()
    // 設置視窗大小
    await page.setViewport({
      width: params.width,
      height: params.height,
      deviceScaleFactor: params.ratio
    })
    // 設置須要截圖的html內容
    await page.setContent(params.html)
    await this.waitForNetworkIdle(page, 50)
    let filePath
    // 根據 type 返回不一樣的類型 一種圖片路徑、一種 base64
    if (params.fileType === 'path') {
      filePath = `${path}/${params.htmlRedisKey}.${params.imageType}`
      await page.screenshot({
        path: filePath,
        fullPage: false,
        omitBackground: true
      })
    } else {
      filePath = await page.screenshot({
        fullPage: false,
        omitBackground: true,
        encoding: 'base64'
      })
    }
    browser.close()
    return filePath
  }
  // 等待HTML 頁面資源加載完成
  waitForNetworkIdle(page, timeout, maxInflightRequests = 0) {  
    page.on('request', onRequestStarted);
    page.on('requestfinished', onRequestFinished);
    page.on('requestfailed', onRequestFinished);
  
    let inflight = 0;
    let fulfill;
    let promise = new Promise(x => fulfill = x);
    let timeoutId = setTimeout(onTimeoutDone, timeout);
    return promise;
  
    function onTimeoutDone() {
      page.removeListener('request', onRequestStarted);
      page.removeListener('requestfinished', onRequestFinished);
      page.removeListener('requestfailed', onRequestFinished);
      fulfill();
    }
  
    function onRequestStarted() {
      ++inflight;
      if (inflight > maxInflightRequests)
        clearTimeout(timeoutId);
    }
  
    function onRequestFinished() {
      if (inflight === 0)
        return;
      --inflight;
      if (inflight === maxInflightRequests)
        timeoutId = setTimeout(onTimeoutDone, timeout);
    }
  }
}
module.exports = new PuppenteerHelper()
複製代碼

../utils/utils.js

/* * @Descripttion: 工具類庫 * @version: * @Author: falost * @Date: 2019-08-27 14:10:16 * @LastEditors: falost * @LastEditTime: 2019-08-27 14:15:52 */
const fs = require('fs')
const path = require('path')

const mkdirsSync = (dirname) => {
  if (fs.existsSync(dirname)) {
    return true;
  } else {
    if (mkdirsSync(path.dirname(dirname))) {
      fs.mkdirSync(dirname);
      return true;
    }
  }
}
const formatNumber = function (n) {
  n = n.toString()
  return n[1] ? n : '0' + n
}

module.exports = {
  mkdirsSync,
  formatNumber
}

複製代碼

效果

到這裏,簡單的代碼實現步驟,現已完成,接下來咱們看看最後生成的效果圖吧!

image

結語

若是你想要用於生產環境,那麼你還須要作一些其餘個工做,這樣才能保證他能更好的產出。固然這裏面也有些坑,還須要咱們去完成填坑的。

git 倉庫地址:github.com/falost/node…

若是你想了解更多關於 puppeteer 的使用, 能夠查詢官方倉庫說明文檔:github.com/GoogleChrom…

很是感謝您的耐心閱讀!

文中若有不對之處,歡迎留言指教!

做者:falost
原文地址:https://falost.cc/article/5d74e54f8457894be1bc3b45複製代碼
相關文章
相關標籤/搜索