Express+Nuxt+Puppeteer實(shí)現(xiàn)服務(wù)端截圖

需求背景

互聯(lián)網(wǎng)產(chǎn)品都少不了分享功能。在微信公眾號(hào)內(nèi),可以使用微信的轉(zhuǎn)發(fā)功能直接分享給朋友、分享到群聊和朋友圈,但是在小程序里卻并沒(méi)有提供直接分享到朋友圈的功能。想要分享小程序到朋友圈,比較通用的方法是提供一張帶有小程序二維碼的圖片,由用戶自主分享到朋友圈。

方案

目標(biāo)已經(jīng)明確——生成帶小程序二維碼的圖片。
如何生成?要根據(jù)圖片的內(nèi)容來(lái)選擇適合的技術(shù)方案。

如果圖片內(nèi)容簡(jiǎn)單,只包含一張底圖+二維碼+幾個(gè)動(dòng)態(tài)數(shù)據(jù),那么可以在小程序內(nèi)使用canvas繪制,將元素定位到計(jì)算好的坐標(biāo)上。具體可以查看canvas和微信小程序的相關(guān)API。

重點(diǎn)是另一種情況。圖片是一張網(wǎng)頁(yè)設(shè)計(jì)圖,包含比較復(fù)雜的布局和動(dòng)態(tài)信息,需要根據(jù)不同條件來(lái)展示不同的布局或樣式。簡(jiǎn)單來(lái)說(shuō)可理解為分享圖就是一張網(wǎng)頁(yè)截圖。作為Web前端工程師,寫網(wǎng)頁(yè)不是個(gè)事,重點(diǎn)在于生成圖片??梢允褂霉雀栝_(kāi)源的Puppeteer,配合其提供的截圖API來(lái)完成網(wǎng)頁(yè)截圖。

工具介紹

puppeteer

Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium.

上面是項(xiàng)目主頁(yè)的介紹,核心有三:

  1. Node庫(kù)
  2. 可以調(diào)用API控制Chrome瀏覽器(比如打開(kāi)瀏覽器,打開(kāi)頁(yè)面,發(fā)送/接收請(qǐng)求等等等等)
  3. 可啟無(wú)頭(headless)chrome瀏覽器,也能啟完整(帶界面)的

代表著:

  1. 運(yùn)行在Node服務(wù)端
  2. 可以在服務(wù)端使用chrome(headless)的功能,包括截圖

有人問(wèn),你怎么知道chrome能截圖?
很簡(jiǎn)單。
別人告訴我的。

先看一下chrome瀏覽器中的截圖步驟:

  1. 打開(kāi)開(kāi)發(fā)者工具
  2. 調(diào)出命令行(Windows: ctrl+shift+p; Mac: cmd+shift+p)
  3. 輸入關(guān)鍵字"screenshot",會(huì)列出三個(gè)命令:

然后任選其一就會(huì)執(zhí)行chrome的截圖命令,并且彈出下載窗口,讓用戶選擇保存位置。

那在Puppeteer中如何操作?來(lái)看一下官方例子

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.screenshot({path: 'example.png'});

  await browser.close();
})();

官方例子流程清晰、簡(jiǎn)單易懂:
啟動(dòng)瀏覽器 -> 開(kāi)頁(yè)面 -> 輸網(wǎng)址 -> 截圖保存
是不是跟我們手動(dòng)瀏覽網(wǎng)頁(yè)的操作差不多?
截圖工具準(zhǔn)備好了,下面就是要準(zhǔn)備頁(yè)面了

express+nuxt

能正確截圖的前提是:瀏覽器請(qǐng)求返回的HTML頁(yè)面就是最終需要截圖的頁(yè)面HTML。
瀏覽器可以等待所有資源加載完畢才去截圖,但不會(huì)等待JS執(zhí)行完。如果JS代碼里包含dom操作的,很可能還沒(méi)有執(zhí)行截圖流程就結(jié)束了。

簡(jiǎn)而言之,頁(yè)面要在服務(wù)器端渲染完成。

Node服務(wù)器端渲染有很多選擇:

  1. Express+后端模板引擎(Jade/EJS)等等
  2. Vue-ssr
  3. React-ssr

個(gè)人項(xiàng)目隨意選擇,公司項(xiàng)目一般跟隨著已有的技術(shù)?;蛑髁骷夹g(shù)棧。
建議使用React或者Vue框架,充分享受模塊管理帶來(lái)的開(kāi)發(fā)便利

本文使用Vue服務(wù)端渲染(Vue Server-Side Rendering)。為簡(jiǎn)化項(xiàng)目搭建,使用Vue官方推薦的SSR框架Nuxt

1. 使用官網(wǎng)指導(dǎo)初始化Nuxt項(xiàng)目
$ vue init nuxt-community/starter-template <project-name>

項(xiàng)目文件目錄結(jié)構(gòu)大致如下


在pages目錄下創(chuàng)建{name}.vue文件,將來(lái)訪問(wèn)http[s]://{host}/{name}就會(huì)自動(dòng)訪問(wèn)這個(gè)頁(yè)面。比如:

<template>
  <div>hello {{text}}!</div>
</template>

<script>
  export default {
    name: 'example',
    data () {
      return {
        text: 'world'
      }
    }
  }
</script>
測(cè)試頁(yè)面http://localhost:3000/example
2. 添加處理截圖請(qǐng)求的路由

Nuxt官方指導(dǎo)基于自動(dòng)(傻瓜)模式。不過(guò)我們需要在Node端使用Puppeteer進(jìn)行截圖,還需要以編程方式去處理截圖請(qǐng)求。Nuxt提供了兩種方式來(lái)讓開(kāi)發(fā)者自行處理請(qǐng)求:

(1)添加中間件
在nuxt.config.js中配置serverMiddleWare

// nuxt.config.js
module.exports = {
  ...,
  serverMiddleware: [
    {path: '/server', handler: '~/server/index.js'}
  ]
}

然后添加路由,處理截圖相關(guān)邏輯

// ~/server/index.js
const app = require('express')()

// /server/screenshot路由
app.use('/screenshot', (req, res) => {
  // 截圖操作
  // ...
})

module.exports = app

上面的配置{path: '/server', handler: '~/server/index.js'}指定了路徑為/server/*的請(qǐng)求由express先進(jìn)行處理。原因在官網(wǎng)中提到:
HEADS UP! If you don't want middleware to register for all routes you have to use Object form with specific path, otherwise nuxt default handler won't work!
不配置前綴路徑,所有請(qǐng)求會(huì)都先進(jìn)入middleWare中進(jìn)行匹配。
從性能角度和易維護(hù)角度,都是需要添加前綴以區(qū)分開(kāi)

(2)以編程方式啟動(dòng)nuxt(Using Nuxt.js Programmatically),樣例在API文檔

const { Nuxt, Builder } = require('nuxt')

const app = require('express')()
const isProd = (process.env.NODE_ENV === 'production')
const port = process.env.PORT || 3000

// We instantiate Nuxt.js with the options
const config = require('./nuxt.config.js')
config.dev = !isProd
const nuxt = new Nuxt(config)

// Render every route with Nuxt.js
app.use(nuxt.render)

// Build only in dev mode with hot-reloading
if (config.dev) {
  new Builder(nuxt).build()
  .then(listen)
  .catch((error) => {
    console.error(error)
    process.exit(1)
  })
}
else {
  listen()
}

function listen() {
  // Listen the server
  app.listen(port, '0.0.0.0')
  console.log('Server listening on `localhost:' + port + '`.')
}

其中關(guān)鍵代碼是:

const app = require('express')()
...
// Render every route with Nuxt.js
app.use(nuxt.render)
...
app.use('/screenshot', async (req, res) => {
  // 截圖相關(guān)代碼
})

serverMiddleWare配置方式不同的是,此路由訪問(wèn)路徑是/screenshot(而不是/server/screenshot

Puppeteer和Nuxt的基本使用已經(jīng)了解的現(xiàn)在,我們可以

添加截圖邏輯

假定需要截圖的網(wǎng)頁(yè)地址是http[s]://{hostname}/example。
我們可以約定將目標(biāo)網(wǎng)頁(yè)的url放在body中。然后在剛剛配置好的路由中進(jìn)行處理:

app.use('/screenshot', async (req, res) => {
  // url->http[s]://{hostname}/example
  const {url} = req.body
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url);
  await page.screenshot({path: 'example.png'});
  await browser.close();
})

這樣,一個(gè)截圖demo就完成了。接下來(lái)根據(jù)實(shí)際生產(chǎn)需求擴(kuò)展demo

數(shù)據(jù)

截圖頁(yè)面一般會(huì)顯示不同的數(shù)據(jù)。

獲取數(shù)據(jù)的方法

概括說(shuō)來(lái)只有兩種:

  1. 主動(dòng)請(qǐng)求:頁(yè)面發(fā)送請(qǐng)求獲取數(shù)據(jù)
  2. 被動(dòng)接收:帶上數(shù)據(jù)請(qǐng)求截圖服務(wù)

我將截圖服務(wù)定義為純切圖+截圖服務(wù),不希望與其他業(yè)務(wù)邏輯耦合,因此選擇方案2。
其他服務(wù)將帶截圖的頁(yè)面url和需要渲染的數(shù)據(jù)放入請(qǐng)求體(request body)中,請(qǐng)求/screenshot接口。然后在/screenshot路由中,通過(guò)Puppeteer訪問(wèn)url地址,并將數(shù)據(jù)放在訪問(wèn)url的請(qǐng)求中。Puppeteer訪問(wèn)頁(yè)面只有一個(gè)APIpage.goto(url),不能像使用axios一樣,提供請(qǐng)求方法、請(qǐng)求體的選項(xiàng)

如何將數(shù)據(jù)帶到Puppeteer請(qǐng)求中?

Puppeteer提供了攔截、修改請(qǐng)求的方法。

await page.setRequestInterception(true);
page.on('request', interceptedRequest => {
  // ...
});

回調(diào)函數(shù)的參數(shù)interceptedRequestRequest類的實(shí)例,擁有continue方法可以覆寫請(qǐng)求

開(kāi)啟請(qǐng)求監(jiān)聽(tīng)后,瀏覽頁(yè)面發(fā)出的所有網(wǎng)絡(luò)請(qǐng)求,包括頁(yè)面請(qǐng)求和靜態(tài)資源請(qǐng)求,都會(huì)被攔截。我們只需要將數(shù)據(jù)覆寫在頁(yè)面請(qǐng)求里:

app.use('/screenshot', async (req, res) => {
  ...
  // renderData -> 頁(yè)面數(shù)據(jù)
  const {url, renderData} = req.body
  
  // 開(kāi)啟請(qǐng)求監(jiān)聽(tīng)
  page.setRequestInterception(true)
  // 攔截請(qǐng)求
  page.on('request', (interceptReq) => {
    let opts = {}
    // 請(qǐng)求url為頁(yè)面url時(shí),覆寫請(qǐng)求,放入數(shù)據(jù)
    if(interceptReq.url() === url) {
      opts = {
        method: 'POST',
        postData: `renderData=JSON.stringify(renderData)`
    }
    interceptReq.continue(opts)
  })
  await page.goto(url)
  ...
})

將頁(yè)面數(shù)據(jù)放入請(qǐng)求之后

拿到請(qǐng)求中的數(shù)據(jù)并渲染到頁(yè)面上

為了保證頁(yè)面在服務(wù)端就渲染完成,我們需要將數(shù)據(jù)的放在服務(wù)器端
Nuxt默認(rèn)提供Vuex來(lái)管理狀態(tài),并提供了nuxtServerInit方法在服務(wù)端初始化狀態(tài)。nuxtServerInit方法的第二個(gè)參數(shù)是Nuxt的上下文(Context)對(duì)象,其中包含了一個(gè)我們需要的屬性:req,可以獲取請(qǐng)求體(request body),放到state中,供vue頁(yè)面使用

state: {
  renderData: {}
}
...
actions: {
  nuxtServerInit ({ commit }, { req }) {
    Vue.set(this.state, 'renderData', JSON.parse(req.body.renderData))
  }
}

所有數(shù)據(jù)的處理都要放到beforeCreatecreated生命周期鉤子(lifecycle hook)中完成。其他生命周期,如mounted,都會(huì)在客戶端執(zhí)行。我們使用Puppeteer瀏覽頁(yè)面無(wú)法得知什么時(shí)候js執(zhí)行完,會(huì)導(dǎo)致截圖出未渲染完成的頁(yè)面。

頁(yè)面渲染完,要在

適當(dāng)?shù)臅r(shí)機(jī)截圖

Puppeteer提供event:'load'鉤子來(lái)監(jiān)聽(tīng)window.onload事件。

MDN :The load event is fired when a resource and its dependent resources have finished loading

截圖操作放在回調(diào)函數(shù)中執(zhí)行,可以確保網(wǎng)頁(yè)的樣式、圖片等都加載完成,避免截出不完整的網(wǎng)頁(yè)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容