需求背景
互聯(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)目主頁的介紹,核心有三:
- Node庫
- 可以調(diào)用API控制Chrome瀏覽器(比如打開瀏覽器彤委,打開頁面鞭铆,發(fā)送/接收請(qǐng)求等等等等)
- 可啟無頭(headless)chrome瀏覽器,也能啟完整(帶界面)的
代表著:
- 運(yùn)行在Node服務(wù)端
- 可以在服務(wù)端使用chrome(headless)的功能焦影,包括截圖
有人問车遂,你怎么知道chrome能截圖?
很簡(jiǎn)單斯辰。
別人告訴我的舶担。
先看一下chrome瀏覽器中的截圖步驟:
- 打開開發(fā)者工具
- 調(diào)出命令行(Windows: ctrl+shift+p; Mac: cmd+shift+p)
- 輸入關(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ù)器端渲染有很多選擇:
- Express+后端模板引擎(Jade/EJS)等等
- Vue-ssr
- 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>
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ù)的方法
概括說來只有兩種:
- 主動(dòng)請(qǐng)求:頁面發(fā)送請(qǐng)求獲取數(shù)據(jù)
- 被動(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ù)interceptedRequest
是Request類的實(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ù)的處理都要放到
beforeCreate
和created
生命周期鉤子(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)頁牛欢。