需求背景
互联网产品都少不了分享功能。在微信公众号内,可以使用微信的转发功能直接分享给朋友、分享到群聊和朋友圈,但是在小程序里却并没有提供直接分享到朋友圈的功能。想要分享小程序到朋友圈,比较通用的方法是提供一张带有小程序二维码的图片,由用户自主分享到朋友圈。
方案
目标已经明确——生成带小程序二维码的图片。
如何生成?要根据图片的内容来选择适合的技术方案。
如果图片内容简单,只包含一张底图+二维码+几个动态数据,那么可以在小程序内使用canvas绘制,将元素定位到计算好的坐标上。具体可以查看canvas和微信小程序的相关API。
重点是另一种情况。图片是一张网页设计图,包含比较复杂的布局和动态信息,需要根据不同条件来展示不同的布局或样式。简单来说可理解为分享图就是一张网页截图。作为Web前端工程师,写网页不是个事,重点在于生成图片。可以使用谷歌开源的Puppeteer,配合其提供的截图API来完成网页截图。
工具介绍
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.
上面是项目主页的介绍,核心有三:
- Node库
- 可以调用API控制Chrome浏览器(比如打开浏览器,打开页面,发送/接收请求等等等等)
- 可启无头(headless)chrome浏览器,也能启完整(带界面)的
代表着:
- 运行在Node服务端
- 可以在服务端使用chrome(headless)的功能,包括截图
有人问,你怎么知道chrome能截图?
很简单。
别人告诉我的。
先看一下chrome浏览器中的截图步骤:
- 打开开发者工具
- 调出命令行(Windows: ctrl+shift+p; Mac: cmd+shift+p)
- 输入关键字"screenshot",会列出三个命令:
然后任选其一就会执行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();
})();
官方例子流程清晰、简单易懂:
启动浏览器 -> 开页面 -> 输网址 -> 截图保存
是不是跟我们手动浏览网页的操作差不多?
截图工具准备好了,下面就是要准备页面了
express+nuxt
能正确截图的前提是:浏览器请求返回的HTML页面就是最终需要截图的页面HTML。
浏览器可以等待所有资源加载完毕才去截图,但不会等待JS执行完。如果JS代码里包含dom操作的,很可能还没有执行截图流程就结束了。
简而言之,页面要在服务器端渲染完成。
Node服务器端渲染有很多选择:
- Express+后端模板引擎(Jade/EJS)等等
- Vue-ssr
- React-ssr
个人项目随意选择,公司项目一般跟随着已有的技术栈或主流技术栈。
建议使用React或者Vue框架,充分享受模块管理带来的开发便利
本文使用Vue服务端渲染(Vue Server-Side Rendering)。为简化项目搭建,使用Vue官方推荐的SSR框架Nuxt:
1. 使用官网指导初始化Nuxt项目
$ vue init nuxt-community/starter-template <project-name>
项目文件目录结构大致如下
在pages目录下创建{name}.vue
文件,将来访问http[s]://{host}/{name}
就会自动访问这个页面。比如:
<template>
<div>hello {{text}}!</div>
</template>
<script>
export default {
name: 'example',
data () {
return {
text: 'world'
}
}
}
</script>
2. 添加处理截图请求的路由
Nuxt官方指导基于自动(傻瓜)模式。不过我们需要在Node端使用Puppeteer进行截图,还需要以编程方式去处理截图请求。Nuxt提供了两种方式来让开发者自行处理请求:
(1)添加中间件
在nuxt.config.js中配置serverMiddleWare
// nuxt.config.js
module.exports = {
...,
serverMiddleware: [
{path: '/server', handler: '~/server/index.js'}
]
}
然后添加路由,处理截图相关逻辑
// ~/server/index.js
const app = require('express')()
// /server/screenshot路由
app.use('/screenshot', (req, res) => {
// 截图操作
// ...
})
module.exports = app
上面的配置
{path: '/server', handler: '~/server/index.js'}
指定了路径为/server/*
的请求由express先进行处理。原因在官网中提到:
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!
不配置前缀路径,所有请求会都先进入middleWare中进行匹配。
从性能角度和易维护角度,都是需要添加前缀以区分开
(2)以编程方式启动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 + '`.')
}
其中关键代码是:
const app = require('express')()
...
// Render every route with Nuxt.js
app.use(nuxt.render)
...
app.use('/screenshot', async (req, res) => {
// 截图相关代码
})
与serverMiddleWare
配置方式不同的是,此路由访问路径是/screenshot
(而不是/server/screenshot
)
Puppeteer和Nuxt的基本使用已经了解的现在,我们可以
添加截图逻辑
假定需要截图的网页地址是http[s]://{hostname}/example
。
我们可以约定将目标网页的url放在body中。然后在刚刚配置好的路由中进行处理:
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();
})
这样,一个截图demo就完成了。接下来根据实际生产需求扩展demo
数据
截图页面一般会显示不同的数据。
获取数据的方法
概括说来只有两种:
- 主动请求:页面发送请求获取数据
- 被动接收:带上数据请求截图服务
我将截图服务定义为纯切图+截图服务,不希望与其他业务逻辑耦合,因此选择方案2。
其他服务将带截图的页面url和需要渲染的数据放入请求体(request body)中,请求/screenshot
接口。然后在/screenshot
路由中,通过Puppeteer访问url地址,并将数据放在访问url的请求中。Puppeteer访问页面只有一个APIpage.goto(url)
,不能像使用axios一样,提供请求方法、请求体的选项
如何将数据带到Puppeteer请求中?
Puppeteer提供了拦截、修改请求的方法。
await page.setRequestInterception(true);
page.on('request', interceptedRequest => {
// ...
});
回调函数的参数interceptedRequest
是Request类的实例,拥有continue
方法可以覆写请求
开启请求监听后,浏览页面发出的所有网络请求,包括页面请求和静态资源请求,都会被拦截。我们只需要将数据覆写在页面请求里:
app.use('/screenshot', async (req, res) => {
...
// renderData -> 页面数据
const {url, renderData} = req.body
// 开启请求监听
page.setRequestInterception(true)
// 拦截请求
page.on('request', (interceptReq) => {
let opts = {}
// 请求url为页面url时,覆写请求,放入数据
if(interceptReq.url() === url) {
opts = {
method: 'POST',
postData: `renderData=JSON.stringify(renderData)`
}
interceptReq.continue(opts)
})
await page.goto(url)
...
})
将页面数据放入请求之后
拿到请求中的数据并渲染到页面上
为了保证页面在服务端就渲染完成,我们需要将数据的放在服务器端
Nuxt默认提供Vuex来管理状态,并提供了nuxtServerInit方法在服务端初始化状态。nuxtServerInit
方法的第二个参数是Nuxt的上下文(Context)对象,其中包含了一个我们需要的属性:req
,可以获取请求体(request body),放到state中,供vue页面使用
state: {
renderData: {}
}
...
actions: {
nuxtServerInit ({ commit }, { req }) {
Vue.set(this.state, 'renderData', JSON.parse(req.body.renderData))
}
}
所有数据的处理都要放到
beforeCreate
和created
生命周期钩子(lifecycle hook)中完成。其他生命周期,如mounted,都会在客户端执行。我们使用Puppeteer浏览页面无法得知什么时候js执行完,会导致截图出未渲染完成的页面。
页面渲染完,要在
适当的时机截图
Puppeteer提供event:'load'
钩子来监听window.onload
事件。
MDN :The load event is fired when a resource and its dependent resources have finished loading
截图操作放在回调函数中执行,可以确保网页的样式、图片等都加载完成,避免截出不完整的网页。