Express+Nuxt+Puppeteer实现服务端截图

需求背景

互联网产品都少不了分享功能。在微信公众号内,可以使用微信的转发功能直接分享给朋友、分享到群聊和朋友圈,但是在小程序里却并没有提供直接分享到朋友圈的功能。想要分享小程序到朋友圈,比较通用的方法是提供一张带有小程序二维码的图片,由用户自主分享到朋友圈。

方案

目标已经明确——生成带小程序二维码的图片。
如何生成?要根据图片的内容来选择适合的技术方案。

如果图片内容简单,只包含一张底图+二维码+几个动态数据,那么可以在小程序内使用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.

上面是项目主页的介绍,核心有三:

  1. Node库
  2. 可以调用API控制Chrome浏览器(比如打开浏览器,打开页面,发送/接收请求等等等等)
  3. 可启无头(headless)chrome浏览器,也能启完整(带界面)的

代表着:

  1. 运行在Node服务端
  2. 可以在服务端使用chrome(headless)的功能,包括截图

有人问,你怎么知道chrome能截图?
很简单。
别人告诉我的。

先看一下chrome浏览器中的截图步骤:

  1. 打开开发者工具
  2. 调出命令行(Windows: ctrl+shift+p; Mac: cmd+shift+p)
  3. 输入关键字"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服务器端渲染有很多选择:

  1. Express+后端模板引擎(Jade/EJS)等等
  2. Vue-ssr
  3. 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>
测试页面http://localhost:3000/example
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

数据

截图页面一般会显示不同的数据。

获取数据的方法

概括说来只有两种:

  1. 主动请求:页面发送请求获取数据
  2. 被动接收:带上数据请求截图服务

我将截图服务定义为纯切图+截图服务,不希望与其他业务逻辑耦合,因此选择方案2。
其他服务将带截图的页面url和需要渲染的数据放入请求体(request body)中,请求/screenshot接口。然后在/screenshot路由中,通过Puppeteer访问url地址,并将数据放在访问url的请求中。Puppeteer访问页面只有一个APIpage.goto(url),不能像使用axios一样,提供请求方法、请求体的选项

如何将数据带到Puppeteer请求中?

Puppeteer提供了拦截、修改请求的方法

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

回调函数的参数interceptedRequestRequest类的实例,拥有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))
  }
}

所有数据的处理都要放到beforeCreatecreated生命周期钩子(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

截图操作放在回调函数中执行,可以确保网页的样式、图片等都加载完成,避免截出不完整的网页。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容