需求背景
互聯(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è)的介紹,核心有三:
- Node庫(kù)
- 可以調(diào)用API控制Chrome瀏覽器(比如打開(kāi)瀏覽器,打開(kāi)頁(yè)面,發(fā)送/接收請(qǐng)求等等等等)
- 可啟無(wú)頭(headless)chrome瀏覽器,也能啟完整(帶界面)的
代表著:
- 運(yùn)行在Node服務(wù)端
- 可以在服務(wù)端使用chrome(headless)的功能,包括截圖
有人問(wèn),你怎么知道chrome能截圖?
很簡(jiǎn)單。
別人告訴我的。

先看一下chrome瀏覽器中的截圖步驟:
- 打開(kāi)開(kāi)發(fā)者工具
- 調(diào)出命令行(Windows: ctrl+shift+p; Mac: cmd+shift+p)
- 輸入關(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ù)器端渲染有很多選擇:
- Express+后端模板引擎(Jade/EJS)等等
- Vue-ssr
- 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>

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)只有兩種:
- 主動(dòng)請(qǐng)求:頁(yè)面發(fā)送請(qǐng)求獲取數(shù)據(jù)
- 被動(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ù)interceptedRequest是Request類的實(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ù)的處理都要放到
beforeCreate和created生命周期鉤子(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è)。