原文参考我的公众号文章 前端生成海报新姿势 Puppeteer
以往都是怎么生成海报的?
在开发中经常会遇到「生成海报长图」的需求,一般都是这么做的:
- 后端生成:引入会图库进行绘制
- 前端生成:用原生canvas进行绘制、用一些js库(html-to-canvas)
这么用过来的体验就是:无论是谁生成,都会遇到海报上各个元素的定位困难、样式还原的困难、动态内容和动态海报尺寸不好把控等问题。
因此,经过一波探索,接触了puppeteer
这个「高级货!」,用完之后,简直有种相见恨晚的感觉!
所以现在要想生成一个复杂的海报或者长图的流程变成了这样:
- 编写海报承载web页面
- 调用puppeteer截图服务,对web页面进行截图,并返回图片或者图片地址给调用者
先看看官方是怎么介绍的?
Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。
你可以在浏览器中手动执行的绝大多数操作都可以使用 Puppeteer 来完成! 下面是一些示例:
- 生成页面截图或PDF。
- 抓取 SPA(单页应用)并生成预渲染内容(即“SSR”(服务器端渲染))。
- 自动提交表单,进行 UI 测试,键盘输入等。
- 创建一个时时更新的自动化测试环境。 使用最新的 JavaScript 和浏览器功能直接在最新版本的Chrome中执行测试。
- 捕获网站的 timeline trace,用来帮助分析性能问题。
- 测试浏览器扩展。
👍简直是「不明觉厉」!
主角登场
这篇文章的主角就是 page.screenshot
,通过它,我们可以实现对目标网页(其实也就是承载海报内容的网页)的截图,然后开一个node接口服务,形成高可用的业务接口,将截图后的图片或者图片地址返回给调用者。
直接参考 screenshot文档 就能快速实现一个截图服务,下面直接上代码,然后再说一些期间遇到的小问题及解决思路。
编写截图核心 screenshotPuppeteer.js
const path = require("path");
const puppeteer = require("puppeteer");
function sleep(delay = 1000) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(1);
}, delay);
});
}
function loginfo(debug, info) {
if (debug) {
console.log(info);
}
}
/**
* 基于「puppeteer」的服务端截图工具
* @param {*} options {...} 具体见下
@param {*} options.debug: false 是否开启调试
@param {*} options.pageUrl: "" 要截图的web地址
@param {*} options.defaultViewport: {
width: 390,
height: 844,
deviceScaleFactor: 3,
isMobile: true,
} 是否开启Viewport模拟器
@param {*} options.headless: "" 是否有浏览器界面
@param {*} options.fileName: "" 截图文件保存名称
@param {*} options.fileSavePath: "datasource/poster/" 默认服务器代码写死
@param {*} options.fileType: "jpeg" 截图保存格式 [jpeg, png, webm]
@param {*} options.quality: 100 压缩率[0,100],fileType=jpeg时有效
@param {*} options.fullPage: true 是否全屏,边滚动动边截图
@param {*} options.clip: null 指定裁剪区域,fullPage为true时不可用,默认null,允许参数:{ x: 0, y: 0, width: 390, height: 844 }
@param {Number} screenshotDelay: 500 页面load后,触发截图之前的【延迟时间】
@param {*} options.closePage: true 截图完关闭页面(默认true关闭)
@param {*} options.closeBrowser: true 截图完关闭浏览器(默认true关闭)
@param {*} options.cb: null,
};
*/
async function screenshotCore(config = {}) {
let options = {
debug: false,
headless: true, //默认无头
pageUrl: "", //要截图的web地址
defaultViewport: {
width: 390,
height: 667,
deviceScaleFactor: 3,
isMobile: true,
}, //是否开启Viewport模拟器
fileName: "", //截图文件保存名称
fileSavePath: "datasource/poster/", //默认服务器代码写死
fileType: "jpeg", //截图保存格式 [jpeg, png, webm]
quality: 100, //压缩率[0,100],fileType=jpeg时有效
fullPage: true, //是否全屏,边滚动动边截图
clip: null, //指定裁剪区域,fullPage为true时不可用,默认null,允许参数:{ x: 0, y: 0, width: 390, height: 844 }
screenshotDelay: 500, //页面load后,触发截图之前的【延迟时间】
closePage: true, //截图完关闭页面(默认true关闭)
closeBrowser: true, //截图完关闭浏览器(默认true关闭)
cb: null,
...config,
};
loginfo(
options.debug,
`screenshotCore options: ${JSON.stringify(options, " ", "\t")}`
);
let webpagePath = options.pageUrl || null;
let saveType = options.fileType || "jpeg";
let file = `${options.fileName}.${saveType}`;
let relativePath = `${options.fileSavePath}${file}`;
let savePath = path.join(__dirname, "..", relativePath);
let saveQuality = options.quality || 100;
let isFullPage = options.fullPage || true;
let clipArea = options.clip || null;
let browser = null;
let page = null;
const errHandler = (msg, err) => {
return {
error: 1,
msg,
errMsg: err.message || err.msg,
err,
};
};
const successHandler = (msg, data = {}) => {
return {
error: 0,
msg,
data,
};
};
const closeAll = async() => {
if (options.closePage) {
await page.close();
}
if (options.closeBrowser) {
await browser.close();
}
};
return new Promise(async(resolve, reject) => {
try {
// 启动浏览器
browser = await puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"], //如果报“No usable sandbox!”
// slowMo: 100, //放慢浏览器执行速度,方便测试观察
headless: options.headless, //是否为无界面访问浏览器
defaultViewport: options.defaultViewport || null,
});
} catch (err) {
closeAll();
return reject(errHandler("POSTER应用启动失败", err.msg || err));
}
try {
// 新建页面
page = await browser.newPage();
page.setDefaultNavigationTimeout(60000); //超时报错timeout时间,默认30s,0表示无限制
} catch (err) {
closeAll();
return reject(errHandler("puppeteer打开新页面失败", err));
}
// page.on("load", async () => {
// loginfo(options.debug, "Page loaded!");
// await sleep(options.screenshotDelay);
// do screenshot ...
// });
// let reqNum = 0;
// page.on("request", async (req) => {
// let method = req.method();
// let url = req.url();
// console.log("request:", ++reqNum);
// });
// let repNum = 0;
// page.on("response", async (rep) => {
// let url = rep.url();
// let status = rep.status();
// console.log("response:", ++repNum);
// });
try {
// 打开目标页面 { waitUntil: "networkidle0"} 表示当前页面500ms内无http请求,再返回
await page.goto(webpagePath, { waitUntil: "networkidle0" });
} catch (err) {
closeAll();
return reject(errHandler("puppeteer打开目标网页失败", err));
}
try {
// 开始截图
await sleep(options.screenshotDelay);
await page.screenshot({
path: savePath, //在服务器上的存储位置
type: saveType,
quality: saveQuality,
fullPage: isFullPage,
clip: clipArea,
});
loginfo(options.debug, `截图文件存储在了: ${savePath}`);
let retData = {
file,
type: saveType,
quality: saveQuality,
};
resolve(successHandler("海报生成成功", retData));
options.cb && options.cb(page);
} catch (err) {
reject(errHandler("puppeteer截图失败", err));
}
closeAll();
});
}
module.exports = {
screenshotCore,
};
调用示例
const { screenshotCore } = require("./screenshotPuppeteer");
screenshotCore({
pageUrl: "www.baidu.com",
fileName: `capture_${+new Date()}`
})
.then((res) => {
console.log("success:", res);
})
.catch((err) => {
console.log("failed:", err);
});
总结一些遇到的问题
puppeteer.launch启动报错
比如在本地开发没问题,部署到Linux服务器之后,遇到No usable sandbox!
或 ...setuid...
,需要在启动配置中增加 args: ["--no-sandbox","--disable-setuid-sandbox"]
// 启动浏览器
browser = await puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"],
// slowMo: 100, //放慢浏览器执行速度,方便测试观察
});
截图出现中文乱码?
这是由于服务器上没有中文字体的缘故,只需在Linux服务器的 usr/local/share/fonts
目录下添加中文字体包即可。
如果要完美还原目标页面的字体样式,还需要安装对应的字体文件。
截图不够清晰?
可以设置 page.setViewport(viewport)
,其中viewport
的deviceScaleFactor
代表着定义设备缩放, (类似于 dpr)。 默认 1。我们可以根据想要的效果设置为2或者3(这是根据iPhone的屏幕dpr来的),当然这个值越高,图片体积也就回越大,所以要做好权衡。一般最多设置为3。
截图内容空白或不完整?
有时候发现,虽然已经是在page
实例的page.on('load')
里才开始截图,但是依然会出现空白内容。这是因为大部分情况,我们的海报页面并不是纯静态的,也会有接口请求,然后再渲染一些动态内容。因此,需要做到「在接口请求结束,且短暂延迟后」再触发截图操作。
可以这么做:
// networkidle0表示当前页面500ms内无http请求,再返回
await page.goto(webpagePath, { waitUntil: "networkidle0" });
针对某个dom进行截图
//对页面某个元素截图
let element = await page.$x('#target_dom');
await element.screenshot({
...
});
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。