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

需求背景

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

方案

目標(biāo)已經(jīng)明確——生成帶小程序二維碼的圖片坤塞。
如何生成冯勉?要根據(jù)圖片的內(nè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)頁設(shè)計(jì)圖伐厌,包含比較復(fù)雜的布局和動(dòng)態(tài)信息,需要根據(jù)不同條件來展示不同的布局或樣式裸影。簡(jiǎn)單來說可理解為分享圖就是一張網(wǎng)頁截圖挣轨。作為Web前端工程師,寫網(wǎng)頁不是個(gè)事轩猩,重點(diǎn)在于生成圖片卷扮。可以使用谷歌開源的Puppeteer均践,配合其提供的截圖API來完成網(wǎng)頁截圖晤锹。

工具介紹

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)目主頁的介紹,核心有三:

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

代表著:

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

有人問车遂,你怎么知道chrome能截圖?
很簡(jiǎn)單斯辰。
別人告訴我的舶担。

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

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

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

那在Puppeteer中如何操作?來看一下官方例子

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)瀏覽器 -> 開頁面 -> 輸網(wǎng)址 -> 截圖保存
是不是跟我們手動(dòng)瀏覽網(wǎng)頁的操作差不多狱意?
截圖工具準(zhǔn)備好了,下面就是要準(zhǔn)備頁面了

express+nuxt

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

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

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

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

個(gè)人項(xiàng)目隨意選擇羔杨,公司項(xiàng)目一般跟隨著已有的技術(shù)棧或主流技術(shù)棧杨蛋。
建議使用React或者Vue框架兜材,充分享受模塊管理帶來的開發(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文件曙寡,將來訪問http[s]://{host}/{name}就會(huì)自動(dòng)訪問這個(gè)頁面。比如:

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

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

Nuxt官方指導(dǎo)基于自動(dòng)(傻瓜)模式寇荧。不過我們需要在Node端使用Puppeteer進(jìn)行截圖举庶,還需要以編程方式去處理截圖請(qǐng)求。Nuxt提供了兩種方式來讓開發(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ū)分開

(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配置方式不同的是寻仗,此路由訪問路徑是/screenshot(而不是/server/screenshot

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

添加截圖邏輯

假定需要截圖的網(wǎng)頁地址是http[s]://{hostname}/example
我們可以約定將目標(biāo)網(wǎng)頁的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就完成了。接下來根據(jù)實(shí)際生產(chǎn)需求擴(kuò)展demo

數(shù)據(jù)

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

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

概括說來只有兩種:

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

我將截圖服務(wù)定義為純切圖+截圖服務(wù)俗扇,不希望與其他業(yè)務(wù)邏輯耦合,因此選擇方案2箕别。
其他服務(wù)將帶截圖的頁面url和需要渲染的數(shù)據(jù)放入請(qǐng)求體(request body)中铜幽,請(qǐng)求/screenshot接口滞谢。然后在/screenshot路由中,通過Puppeteer訪問url地址除抛,并將數(shù)據(jù)放在訪問url的請(qǐng)求中狮杨。Puppeteer訪問頁面只有一個(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)求

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

app.use('/screenshot', async (req, res) => {
  ...
  // renderData -> 頁面數(shù)據(jù)
  const {url, renderData} = req.body
  
  // 開啟請(qǐng)求監(jiān)聽
  page.setRequestInterception(true)
  // 攔截請(qǐng)求
  page.on('request', (interceptReq) => {
    let opts = {}
    // 請(qǐng)求url為頁面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)
  ...
})

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

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

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

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瀏覽頁面無法得知什么時(shí)候js執(zhí)行完间狂,會(huì)導(dǎo)致截圖出未渲染完成的頁面攻泼。

頁面渲染完,要在

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

Puppeteer提供event:'load'鉤子來監(jiān)聽window.onload事件鉴象。

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

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市淆游,隨后出現(xiàn)的幾起案子傍睹,更是在濱河造成了極大的恐慌隔盛,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,884評(píng)論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件拾稳,死亡現(xiàn)場(chǎng)離奇詭異吮炕,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)访得,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,755評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門龙亲,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人震鹉,你說我怎么就攤上這事俱笛。” “怎么了传趾?”我有些...
    開封第一講書人閱讀 158,369評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵迎膜,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我浆兰,道長(zhǎng)磕仅,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,799評(píng)論 1 285
  • 正文 為了忘掉前任簸呈,我火速辦了婚禮榕订,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘蜕便。我一直安慰自己劫恒,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,910評(píng)論 6 386
  • 文/花漫 我一把揭開白布轿腺。 她就那樣靜靜地躺著两嘴,像睡著了一般。 火紅的嫁衣襯著肌膚如雪族壳。 梳的紋絲不亂的頭發(fā)上憔辫,一...
    開封第一講書人閱讀 50,096評(píng)論 1 291
  • 那天,我揣著相機(jī)與錄音仿荆,去河邊找鬼贰您。 笑死,一個(gè)胖子當(dāng)著我的面吹牛拢操,可吹牛的內(nèi)容都是我干的锦亦。 我是一名探鬼主播,決...
    沈念sama閱讀 39,159評(píng)論 3 411
  • 文/蒼蘭香墨 我猛地睜開眼庐冯,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼孽亲!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起展父,我...
    開封第一講書人閱讀 37,917評(píng)論 0 268
  • 序言:老撾萬榮一對(duì)情侶失蹤返劲,失蹤者是張志新(化名)和其女友劉穎玲昧,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體篮绿,經(jīng)...
    沈念sama閱讀 44,360評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡孵延,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,673評(píng)論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了亲配。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片尘应。...
    茶點(diǎn)故事閱讀 38,814評(píng)論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖吼虎,靈堂內(nèi)的尸體忽然破棺而出犬钢,到底是詐尸還是另有隱情,我是刑警寧澤思灰,帶...
    沈念sama閱讀 34,509評(píng)論 4 334
  • 正文 年R本政府宣布玷犹,位于F島的核電站,受9級(jí)特大地震影響洒疚,放射性物質(zhì)發(fā)生泄漏歹颓。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,156評(píng)論 3 317
  • 文/蒙蒙 一油湖、第九天 我趴在偏房一處隱蔽的房頂上張望巍扛。 院中可真熱鬧,春花似錦乏德、人聲如沸撤奸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,882評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽寂呛。三九已至,卻和暖如春瘾晃,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背幻妓。 一陣腳步聲響...
    開封第一講書人閱讀 32,123評(píng)論 1 267
  • 我被黑心中介騙來泰國(guó)打工蹦误, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人肉津。 一個(gè)月前我還...
    沈念sama閱讀 46,641評(píng)論 2 362
  • 正文 我出身青樓强胰,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親妹沙。 傳聞我的和親對(duì)象是個(gè)殘疾皇子偶洋,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,728評(píng)論 2 351

推薦閱讀更多精彩內(nèi)容