前端基础建设与架构 - 前百度资深前端开发工程师 - 拉勾教育

在前面第 28 讲 “设计性能守卫系统:完善 CI/CD 流程”中我们提到了 Puppeteer。事实上,以 Puppeteer 为代表的 Headless 浏览器在 Node.js 中的应用极为广泛,这一讲,就让我们对 Puppeteer 进行深入分析和应用。

Puppeteer 介绍和原理

我们先对 Puppeteer 进行一个基本介绍。(Puppeteer 官方地址

Puppeteer 是一个 Node 库,它提供了一整套高级 API,通过 DevTools 协议控制 Chromium 或 Chrome。正如其翻译为 “操纵木偶的人” 一样,你可以通过 Puppeteer 提供的 API 直接控制 Chrome,模拟大部分用户操作,进行 UI 测试或者作为爬虫访问页面来收集数据。

整个定义非常好理解,这里需要开发者注意的是,Puppeteer 在 1.7.0 版本之后,会同时给开发者提供:

  • Puppeteer
  • Puppeteer-core

两个版本。它们的区别在于载入安装 Puppeteer 时,是否会下载 Chromium。Puppeteer-core 默认不下载 Chromium,同时会忽略所有 puppeteer_ 环境变量。对于开发者来说,使用 Puppeteer-core 无疑更加轻便,但是*需要提前保证环境中已经具有可执行的 Chromium(具体说明可见puppeteer vs puppeteer-core)。

具体 Puppeteer 的应用场景有:

  • 为网页生成页面 PDF 或者截取图片;
  • 抓取 SPA(单页应用)并生成预渲染内容;
  • 自动提交表单,进行 UI 测试、键盘输入等;
  • 创建一个随时更新的自动化测试环境,使用最新的 JavaScript 和浏览器功能直接在最新版本的 Chrome 中执行测试;
  • 捕获网站的timeline trace,用来帮助分析性能问题;
  • 测试浏览器扩展。

下面我们就梳理一些 Puppeteer 应用的重点场景,并详细介绍如何使用 Puppeteer 实现一个高性能的海报 Node.js 服务。

Puppeteer 在 SSR 中的应用

区别于第 27 讲介绍的“同构渲染架构:实现一个 SSR 应用”,使用 Puppeteer 实现服务端预渲染出发点完全不同。这种方案最大的好处是不需要对项目代码进行任何调整,却能获取到 SSR 应用的收益。当然,相比同构渲染,基于 Puppeteer 技术的 SSR 在灵活性和扩展性上都有所局限。甚至在 Node.js 端渲染的性能成本也较高,不过该技术也逐渐落地,并在很多场景发挥了重要价值。

比如对于这样的一个页面,代码如下:

  1. <html>
  2. <body>
  3. <div id="container">
  4. </div>
  5. </body>
  6. <script>
  7. function renderPosts(posts, container) {
  8. const html = posts.reduce((html, post) => {
  9. return `${html}
  10. <li class="post">
  11. <h2>${post.title}</h2>
  12. <div class="summary">${post.summary}</div>
  13. <p>${post.content}</p>
  14. </li>`;
  15. }, '');
  16. container.innerHTML = `<ul id="posts">${html}</ul>`;
  17. }
  18. (async() => {
  19. const container = document.querySelector('#container');
  20. const posts = await fetch('/posts').then(resp => resp.json());
  21. renderPosts(posts, container);
  22. })();
  23. </script>
  24. </html>

该页面是一个典型的 CSR 页面,依靠 Ajax,实现了页面动态化渲染。

当在 Node.js 端使用 Puppeteer 渲染时,我们可以实现ssr.mjs,完成渲染任务,如下代码:

  1. import puppeteer from 'puppeteer';
  2. const RENDER_CACHE = new Map();
  3. async function ssr(url) {
  4. if (RENDER_CACHE.has(url)) {
  5. return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
  6. }
  7. const start = Date.now();
  8. const browser = await puppeteer.launch();
  9. const page = await browser.newPage();
  10. try {
  11. await page.goto(url, {waitUntil: 'networkidle0'});
  12. await page.waitForSelector('#posts');
  13. } catch (err) {
  14. console.error(err);
  15. throw new Error('page.goto/waitForSelector timed out.');
  16. }
  17. const html = await page.content();
  18. await browser.close();
  19. const ttRenderMs = Date.now() - start;
  20. console.info(`Headless rendered page in: ${ttRenderMs}ms`);
  21. RENDER_CACHE.set(url, html);
  22. return {html, ttRenderMs};
  23. }
  24. export {ssr as default};

对应server.mjs代码:

  1. import express from 'express';
  2. import ssr from './ssr.mjs';
  3. const app = express();
  4. app.get('/', async (req, res, next) => {
  5. const {html, ttRenderMs} = await ssr(`xxx/index.html`);
  6. res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
  7. return res.status(200).send(html);
  8. });
  9. app.listen(8080, () => console.log('Server started. Press Ctrl+C to quit'));

当然上述实现比较简陋,只是进行原理说明。如果更进一步,我们可以从以下几个角度进行优化:

  • 改造浏览器端代码,防止重复请求接口;
  • 在 Node.js 端,abort 掉不必要的请求,以得到更快的服务端渲染响应速度;
  • 将关键资源内连进 HTML;
  • 自动压缩静态资源;
  • 在 Node.js 端,渲染页面时,重复利用 Chrome 实例。

这里我们用简单代码进行说明:

  1. import express from 'express';
  2. import puppeteer from 'puppeteer';
  3. import ssr from './ssr.mjs';
  4. let browserWSEndpoint = null;
  5. const app = express();
  6. app.get('/', async (req, res, next) => {
  7. if (!browserWSEndpoint) {
  8. const browser = await puppeteer.launch();
  9. browserWSEndpoint = await browser.wsEndpoint();
  10. }
  11. const url = `${req.protocol}:
  12. const {html} = await ssr(url, browserWSEndpoint);
  13. return res.status(200).send(html);
  14. });

至此,我们从原理和代码层面分析了 Puppeteer 在 SSR 中的应用。接下来我们来了解更多的 Puppeteer 使用场景,请你继续阅读。

Puppeteer 在 UI 测试中的应用

Puppeteer 在 UI 测试(即端到端测试)中也可以大显身手,比如和 Jest 结合,通过断言能力实现一个完备的端到端测试系统。

比如下面代码:

  1. const puppeteer = require('puppeteer');
  2. test('baidu title is correct', async () => {
  3. const browser = await puppeteer.launch()
  4. const page = await browser.newPage()
  5. await page.goto('https://xxxxx')
  6. const title = await page.title()
  7. expect(title).toBe('xxxx')
  8. await browser.close()
  9. });

上面代码简单清晰地勾勒出了 Puppeteer 结合 Jest 实现端到端测试的场景。实际上,现在流行的主流端到端测试框架,比如 Cypress 原理都如上代码所示。

接下来,我们来分析 Puppeteer 结合 Lighthouse 应用场景。

Puppeteer 结合 Lighthouse 应用场景

第 28 讲 “设计性能守卫系统:完善 CI/CD 流程”中我们也提到了 Lighthouse,既然 Puppeteer 可以和 Jest 结合实现一个端到端测试框架,当然也可以和 Lighthouse 结合——这就是一个简单的性能守卫系统的雏形。

我们再通过代码来说明,如下代码:

  1. const chromeLauncher = require('chrome-launcher');
  2. const puppeteer = require('puppeteer');
  3. const lighthouse = require('lighthouse');
  4. const config = require('lighthouse/lighthouse-core/config/lr-desktop-config.js');
  5. const reportGenerator = require('lighthouse/lighthouse-core/report/report-generator');
  6. const request = require('request');
  7. const util = require('util');
  8. const fs = require('fs');
  9. (async() => {
  10. const opts = {
  11. logLevel: 'info',
  12. output: 'json',
  13. disableDeviceEmulation: true,
  14. defaultViewport: {
  15. width: 1200,
  16. height: 900
  17. },
  18. chromeFlags: ['--disable-mobile-emulation']
  19. };
  20. const chrome = await chromeLauncher.launch(opts);
  21. opts.port = chrome.port;
  22. const resp = await util.promisify(request)(`http:
  23. const {webSocketDebuggerUrl} = JSON.parse(resp.body);
  24. const browser = await puppeteer.connect({browserWSEndpoint: webSocketDebuggerUrl});
  25. page = (await browser.pages())[0];
  26. await page.setViewport({ width: 1200, height: 900});
  27. console.log(page.url());
  28. const report = await lighthouse(page.url(), opts, config).then(results => {
  29. return results;
  30. });
  31. const html = reportGenerator.generateReport(report.lhr, 'html');
  32. const json = reportGenerator.generateReport(report.lhr, 'json');
  33. await browser.disconnect();
  34. await chrome.kill();
  35. fs.writeFile('report.html', html, (err) => {
  36. if (err) {
  37. console.error(err);
  38. }
  39. });
  40. fs.writeFile('report.json', json, (err) => {
  41. if (err) {
  42. console.error(err);
  43. }
  44. });
  45. })();

整体流程非常清晰,是一个典型的 Puppeteer 与 Lighthouse 结合的案例。事实上,我们看到 Puppeteer 或 Headless 浏览器可以和多个领域能力相结合,在 Node.js 服务上实现平台化能力。接下来,我们再看最后一个案例,请读者继续阅读。

Puppeteer 实现海报 Node.js 服务

社区上我们常见生成海报的技术分享。应用场景很多,比如文稿中划线,进行 “金句分享”,如下图所示:

30 | 实现高可用:使用 Puppeteer 生成性能最优的海报系统 - 图1

一般来说,生成海报可以使用html2canvas这样的类库完成,这里面的技术难点主要有跨域处理、分页处理、页面截图时机处理等。整体来说,并不难实现,但是稳定性一般。另一种生成海报的方式就是使用 Puppeteer,构建一个 Node.js 服务来做页面截图。

下面我们来实现一个名叫 posterMan 的海报服务,整体技术链路如下图:

30 | 实现高可用:使用 Puppeteer 生成性能最优的海报系统 - 图2

核心技术无外乎使用 Puppeteer,访问页面并截图,这与前面几个场景是一样的,如下图所示:

30 | 实现高可用:使用 Puppeteer 生成性能最优的海报系统 - 图3

这里需要特别强调的是,为了实现最好的性能,我们设计了一个链接池来存储 Puppeteer 实例,以备所需,如下图所示:

30 | 实现高可用:使用 Puppeteer 生成性能最优的海报系统 - 图4

在实现上,我们依赖generic-pool库,这个库提供了 Promise 风格的通用池,可以用来对一些高消耗、高成本资源的调用实现防抖或拒绝服务能力,一个典型场景是对数据库的连接。这里我们把它用于 Puppeteer 实例的创建,如下代码所示:

  1. const puppeteer = require('puppeteer')
  2. const genericPool = require('generic-pool')
  3. const createPuppeteerPool = ({
  4. max = 10,
  5. min = 2,
  6. idleTimeoutMillis = 30000,
  7. maxUses = 50,
  8. testOnBorrow = true,
  9. puppeteerArgs = {},
  10. validator = () => Promise.resolve(true),
  11. ...otherConfig
  12. } = {}) => {
  13. const factory = {
  14. create: () =>
  15. puppeteer.launch(puppeteerArgs).then(instance => {
  16. instance.useCount = 0
  17. return instance
  18. }),
  19. destroy: instance => {
  20. instance.close()
  21. },
  22. validate: instance => {
  23. return validator(instance).then(valid =>
  24. Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses))
  25. )
  26. }
  27. }
  28. const config = {
  29. max,
  30. min,
  31. idleTimeoutMillis,
  32. testOnBorrow,
  33. ...otherConfig
  34. }
  35. const pool = genericPool.createPool(factory, config)
  36. const genericAcquire = pool.acquire.bind(pool)
  37. pool.acquire = () =>
  38. genericAcquire().then(instance => {
  39. instance.useCount += 1
  40. return instance
  41. })
  42. pool.use = fn => {
  43. let resource
  44. return pool
  45. .acquire()
  46. .then(r => {
  47. resource = r
  48. return r
  49. })
  50. .then(fn)
  51. .then(
  52. result => {
  53. pool.release(resource)
  54. return result
  55. },
  56. err => {
  57. pool.release(resource)
  58. throw err
  59. }
  60. )
  61. }
  62. return pool
  63. }
  64. module.exports = createPuppeteerPool

使用连接池的方式也很简单,如下代码,./pool.js

  1. const pool = createPuppeteerPool({
  2. puppeteerArgs: {
  3. args: config.browserArgs
  4. }
  5. })
  6. module.exports = pool

有了 “武器弹药”,我们来看看渲染一个页面为海报的具体逻辑。如下代码所示render方法,该方法支持接受一个 URL 也支持接受具体的 HTML 字符串去生成相应海报:

  1. const pool = require('./pool')
  2. const config = require('./config')
  3. const render = (ctx, handleFetchPicoImageError) =>
  4. pool.use(async browser => {
  5. const { body, query } = ctx.request
  6. const page = await browser.newPage()
  7. let html = body
  8. const {
  9. width = 300,
  10. height = 480,
  11. ratio: deviceScaleFactor = 2,
  12. type = 'png',
  13. filename = 'poster',
  14. waitUntil = 'domcontentloaded',
  15. quality = 100,
  16. omitBackground,
  17. fullPage,
  18. url,
  19. useCache = 'true',
  20. usePicoAutoJPG = 'true'
  21. } = query
  22. let image
  23. try {
  24. await page.setViewport({
  25. width: Number(width),
  26. height: Number(height),
  27. deviceScaleFactor: Number(deviceScaleFactor)
  28. })
  29. if (html.length > 1.25e6) {
  30. throw new Error('image size out of limits, at most 1 MB')
  31. }
  32. await page.goto(url || `data:text/html,${html}`, {
  33. waitUntil: waitUntil.split(',')
  34. })
  35. image = await page.screenshot({
  36. type: type === 'jpg' ? 'jpeg' : type,
  37. quality: type === 'png' ? undefined : Number(quality),
  38. omitBackground: omitBackground === 'true',
  39. fullPage: fullPage === 'true'
  40. })
  41. } catch (error) {
  42. throw error
  43. }
  44. ctx.set('Content-Type', `image/${type}`)
  45. ctx.set('Content-Disposition', `inline; filename=${filename}.${type}`)
  46. await page.close()
  47. return image
  48. })
  49. module.exports = render

至此,基于 Puppeteer 的海报系统就已经开发完成了。它是一个对外的 Node.js 服务。

我们也可以生成各种语言的 SDK 客户端,调用该海报服务。比如一个简单的 Python 版 SDK 客户端实现如下代码:

  1. import requests
  2. class PosterGenerator(object):
  3. def generate(self, **kwargs):
  4. """
  5. 生成海报图片,返回二进制海报数据
  6. :param kwargs: 渲染时需要传递的参数字典
  7. :return: 二进制图片数据
  8. """
  9. html_content = render(self._syntax, self._template_content, **kwargs)
  10. url = POSTER_MAN_HA_PROXIES[self._api_env.value]
  11. try:
  12. resp = requests.post(
  13. url,
  14. data=html_content.encode('utf8'),
  15. headers={
  16. 'Content-Type': 'text/plain'
  17. },
  18. timeout=60,
  19. params=self.config
  20. )
  21. except RequestException as err:
  22. raise GenerateFailed(err.message)
  23. else:
  24. if not resp:
  25. raise GenerateFailed(u"Failed to generate poster, got NOTHING from poster-man")
  26. try:
  27. resp.raise_for_status()
  28. except requests.HTTPError as err:
  29. raise GenerateFailed(err.message)
  30. else:
  31. return resp.content

总结

这一讲我们介绍了 Puppeteer 的各种应用场景,并重点介绍了一个基于 Puppeteer 设计实现的海报服务系统。

本讲内容总结如下:

30 | 实现高可用:使用 Puppeteer 生成性能最优的海报系统 - 图5

通过这几讲的学习,希望你能够从实践出发,对 Node.js 落地应用有一个更全面的认知。这里我也给大家留一个思考题,你平时开发中使用过 Puppeteer 吗?你还能基于 Puppeteer 想到哪些使用场景呢?欢迎在留言区和我分享你的经验。