前段时间,在公司做了一个 html
转 pdf
的项目,当时就用到了 Puppeteer,觉得它的能力蛮强大的,除此之外它还有,页面截屏生成图片、爬取页面内容、UI自动化测试、捕获页面加载时间线,分析页面性能等等能力。所以我好奇它是怎么控制浏览器获得这些能力的,因此结合源码和大佬们的解析,理解了它基本的工作原理,那废话少说我们一起看下吧。
什么是 Puppeteer
Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。
以上是官网上的回答,讲也很清楚,但是里面有几个东西我们需要了解的 DevTools 协议
和 headless 模式
。
DevTools 协议
什么是DevTools协议(Chrome DevTool Protocol)呢?
- CDP 基于 WebSocket,利用 WebSocket 实现与浏览器内核的快速数据通道
- CDP 分为多个域(DOM,Debugger,Network,Profiler,Console...),每个域中都定义了相关的命令和事件(Commands and Events)
- 我们可以基于 CDP 封装一些工具对 Chrome 浏览器进行调试及分析,比如我们常用的 “Chrome 开发者工具” 就是基于 CDP 实现的
- 很多有用的工具都是基于 CDP 实现的,比如 Chrome 开发者工具,chrome-remote-interface,Puppeteer 等。
headless 模式
- 在无界面的环境中运行 Chrome
- 通过命令行或者程序语言操作 Chrome
- 无需人的干预,运行更稳定
- 启动 Chrome 时添加参数 --headless,便可以 headless 模式启动 Chrome
启动 Browser 实例
Puppeteer 模块提供了两种启动 Chromium(Browser)实例的方法,一种是 puppeteer.launch
方法
const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.goto('https://www.google.com');
// 其他操作...
await browser.close();
});
另一种是 puppeteer.connect
方法
const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
// 存储节点以便能重新连接到 Chromium
const browserWSEndpoint = browser.wsEndpoint();
// 从 Chromium 断开和 puppeteer 的连接
browser.disconnect();
// 使用节点来重新建立连接
const browser2 = await puppeteer.connect({browserWSEndpoint});
// 关闭 Chromium
await browser2.close();
});
这两方法的的区别是:
puppeteer.launch
方法启动一个新的浏览器并建立连接。puppeteer.connect
方法与已启动的浏览器建立连接。
我们先了解一下 puppeteer.launch
方法背后的原理。
puppeteer.launch
先从源码中找到 Puppeteer
类。
// 源码路径 /puppeteer/node/Puppeteer.js
export class PuppeteerNode extends Puppeteer {
// ...
launch(options = {}) {
// launch方法中可以传需要启动的浏览器名,目前仅支持 chrome 和 firefox, 但是product参数在官方文档中没有给出来。
if (options.product)
this._productName = options.product;
return this._launcher.launch(options);
}
get _launcher() {
// ...
// 获取 browser 实例
this._lazyLauncher = Launcher(this._projectRoot, this._preferredRevision, this._isPuppeteerCore, this._productName);
return this._lazyLauncher;
}
}
浏览器类型选择和对应的类 ChromeLauncher 和 FirefoxLauncher
// 源码路径 /puppeteer/node/Launcher.js
import { Browser } from '../common/Browser.js';
import { BrowserRunner } from './BrowserRunner.js';
class ChromeLauncher {
// ...
// 其实 puppeteer.launch 调的就是这里的 launch 方法
async launch(options = {}) {
// 真正启动浏览器操作在 BrowserRunner 类的 start 方法里进行
const runner = new BrowserRunner(this.product, chromeExecutable, chromeArguments, userDataDir, isTempUserDataDir);
runner.start({
handleSIGHUP,
handleSIGTERM,
handleSIGINT,
dumpio,
env,
pipe: usePipe,
});
// 这里通过CDP的“ws://”开头的地址进行puppeteer和chrome的连接
const connection = await runner.setupConnection({
usePipe,
timeout,
slowMo,
preferredRevision: this._preferredRevision,
});
// 初始化 browser 实例方法(例如:newPage、wsEndpoint、target等方法)
let browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, (_a = runner.proc) !== null && _a !== void 0 ? _a : undefined, runner.close.bind(runner));
return browser;
}
}
export default function Launcher(projectRoot, preferredRevision, isPuppeteerCore, product) {
//...
// 如果没有传 product 字段的话,默认启动Chrome浏览器
switch (product) {
case 'firefox':
return new FirefoxLauncher(projectRoot, preferredRevision, isPuppeteerCore);
case 'chrome':
default:
if (typeof product !== 'undefined' && product !== 'chrome') {
console.warn(`Warning: unknown product name ${product}. Falling back to chrome.`);
}
// 初始化Chrome浏览器实例
return new ChromeLauncher(projectRoot, preferredRevision, isPuppeteerCore);
}
}
以上是 puppeteer.launch
方法入口到启动浏览器的部分,下面我们看下启动浏览器方法内具体做了什么。
BrowserRunner 通过子进程启动浏览器
nodejs的 child_process 模块提供了创建子进程能力,其中 child_process.spawn()
方法异步衍生子进程,不会阻塞 Node.js 事件循环。下面代码中 puppeteer 利用 child_process.spawn()
方法启动了浏览器。
// 源码路径 /puppeteer/node/BrowserRunner.js
import * as childProcess from 'child_process';
import * as readline from 'readline';
import { Connection } from '../common/Connection.js';
import { NodeWebSocketTransport as WebSocketTransport } from '../node/NodeWebSocketTransport.js';
export class BrowserRunner {
start(options) {
const { handleSIGINT, handleSIGTERM, handleSIGHUP, dumpio, env, pipe } = options;
// 通过node的child_process模块在子进程中启动浏览器
// _executablePath 浏览器可执行文件路径,在初始化是存到环境变量中
// 浏览器进程存到 this.proc 实例属性中
this.proc = childProcess.spawn(this._executablePath, this._processArguments, {
detached: process.platform !== 'win32',
env,
stdio,
});
// 之后初始化一些进程事件...
}
async setupConnection(options) {
const { usePipe, timeout, slowMo, preferredRevision } = options;
// 获取 this.proc 进程中启动的浏览器的websocket端点号
const browserWSEndpoint = await waitForWSEndpoint(this.proc, timeout, preferredRevision);
// 拿到端点号之后,通过 NodeWebSocket 建立运输通道
const transport = await WebSocketTransport.create(browserWSEndpoint);
// ws通道建立之后,给通道实例初始化事件函数(例如 send、_onMessage、_onClose等事件)
this.connection = new Connection(browserWSEndpoint, transport, slowMo);
return this.connection;
}
}
这里的 browserWSEndpoint
是建立通道的重要因素,waitForWSEndpoint
函数负责进程中获取 browserWSEndpoint
的任务。通过 readline
模块监听子进程的 stderr
日志信息,从日志信息中匹配到符合 /^DevTools listening on (ws:\/\/.*)$/
正则的信息,最后返回 ws://
开头的 CDP URL信息。
function waitForWSEndpoint(browserProcess, timeout, preferredRevision) {
return new Promise((resolve, reject) => {
const rl = readline.createInterface({ input: browserProcess.stderr });
let stderr = '';
function onLine(line) {
stderr += line + '\n';
const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
if (!match)
return;
cleanup();
resolve(match[1]);
}
});
}
创建websocket实例
其实 waitForWSEndpoint.create
中用 ws
这个npm包实例化了一个websocket通道,并初始化实例通信事件。下面我们看下具体代码。
// 源码路径 /puppeteer/node/NodeWebSocketTransport.js
import NodeWebSocket from 'ws';
export class NodeWebSocketTransport {
constructor(ws) {
this._ws = ws;
this._ws.addEventListener('message', (event) => {
if (this.onmessage)
this.onmessage.call(null, event.data);
});
this._ws.addEventListener('close', () => {
if (this.onclose)
this.onclose.call(null);
});
this._ws.addEventListener('error', () => { });
this.onmessage = null;
this.onclose = null;
}
static create(url) {
const pkg = require('../../../../package.json');
return new Promise((resolve, reject) => {
const ws = new NodeWebSocket(url, [], {
followRedirects: true,
perMessageDeflate: false,
maxPayload: 256 * 1024 * 1024,
headers: {
'User-Agent': `Puppeteer ${pkg.version}`,
},
});
ws.addEventListener('open', () => resolve(new NodeWebSocketTransport(ws)));
ws.addEventListener('error', reject);
});
}
send(message) {
this._ws.send(message);
}
close() {
this._ws.close();
}
}
CDP通信方式
Chrome DevTool Protocol 有内置的方法和事件,通过 websocket 通知浏览器你需要调的方法。我们以创建新页面 newPage
方法为例,看看它是怎么实现的。
// 源码路径 /puppeteer/common/Browser.js
import { EventEmitter } from './EventEmitter.js';
export class Browser extends EventEmitter {
async newPage() {
return this._browser._createPageInContext(this._id);
}
async _createPageInContext(contextId) {
const { targetId } = await this._connection.send('Target.createTarget', {
url: 'about:blank',
browserContextId: contextId || undefined,
});
const target = this._targets.get(targetId);
const page = await target.page();
return page;
}
}
this._connection
是我们已经绑定事件方法的 ws
实例,因此我们通过它给浏览器发送 Target.createTarget
方法名告诉他我们创建新页面。其他协议方法可以查看此地。
puppeteer.connect
puppeteer.connect
方法比较简单,因为它在浏览器已启动的前提下建立连接,因为拿到用户传进来的 browserWSEndpoint
之后直接创建连接。
// 源码路径 /puppeteer/common/connectToBrowser.js
export const connectToBrowser = async (options) => {
const { browserWSEndpoint, browserURL, transport } = options;
let connection = null;
if (transport) {
connection = new Connection('', transport, slowMo);
}
else if (browserWSEndpoint) {
// 常用的连接方式
const WebSocketClass = await getWebSocketTransportClass();
const connectionTransport = await WebSocketClass.create(browserWSEndpoint);
connection = new Connection(browserWSEndpoint, connectionTransport, slowMo);
}
else if (browserURL) {
const connectionURL = await getWSEndpoint(browserURL);
const WebSocketClass = await getWebSocketTransportClass();
const connectionTransport = await WebSocketClass.create(connectionURL);
connection = new Connection(connectionURL, connectionTransport, slowMo);
}
const { browserContextIds } = await connection.send('Target.getBrowserContexts');
// 与浏览器连接成功之后,创建新页面,并返回页面实例
return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError), targetFilter);
}
总结
本文讲述了 Puppeteer 与浏览器之间的通信原理,简单总结如下:
- 通过node子进程模块启动浏览器;
- 通过子进程实例,获取
ws://
开头的 CDP 地址; - 通过
ws
库创建websocket
实例,并且用上一步获取到的地址创建连接; - 通过协议定制的方法名进行通信。
如有不足,欢迎指出。
本文章基于puppeteer@13.5.1版本写的