Puppeteer 提供了一组在页面上下文中执行 JavaScript 的方法,其中 .$eval() 和 .$$eval() 是处理 DOM 元素最常用的两个接口。它们都允许你把一个函数注入到浏览器页面中运行,但一个针对单个元素,另一个针对元素集合。很多脚本错误都源于对这两个方法参数结构的误解,所以把它们的机制彻底理清对编写可靠爬虫或自动化测试很有帮助。

从本质上说,.$eval(selector, pageFunction, ...args) 会先在页面中查找第一个匹配 selector 的元素,然后把这个元素作为第一个参数传给 pageFunction。如果页面中没有匹配元素,pageFunction 的第一个参数会是 null,而不是直接抛出异常。这一点非常关键,因为很多开发者会假设元素一定存在,结果在函数内部访问属性时得到 TypeError。相比之下,.$$eval(selector, pageFunction, ...args) 会查找所有匹配的元素,并把一个元素数组作为第一个参数传给 pageFunction。这个数组始终存在,即使没有元素匹配,也是一个空数组。
另一个重要的细节是,这两个方法中的 pageFunction 是在浏览器上下文里执行的,而不是在 Node.js 进程中执行。因此,函数内部可以访问 window、document 等浏览器 API,但不能直接引用 Node.js 环境中的变量,除非你通过 ...args 把值传递进去。传参时,参数会被序列化后传递给页面函数,基础类型和可 JSON 序列化的对象都可以正常使用。
$eval 与 $$eval 的核心区别
.$eval() 的设计目标是从单个元素中提取数据。调用它时,Puppeteer 内部会先执行 document.querySelector(selector),把得到的元素引用交给页面函数。例如,你想获取页面中第一个段落元素的文本内容,可以这样写:
const text = await page.$eval('p', el => el.textContent);
console.log(text);
这里 el 就是第一个 <p> 元素。如果页面上没有 <p> 元素,el 会是 null,此时访问 el.textContent 会抛出错误。更稳妥的做法是在函数内部先做判空处理:
const text = await page.$eval('p', el => el ? el.textContent : '');
而 .$$eval() 内部执行的是 document.querySelectorAll(selector),它把整个 NodeList 转换成数组后传给页面函数。因此你可以直接使用数组方法,比如 map、filter、forEach。例如,提取所有商品标题:
const titles = await page.$$eval('.product-title', items =>
items.map(item => item.textContent.trim())
);
console.log(titles);
从返回结果来看,.$eval() 返回的是页面函数返回的值,这个值会被序列化后传回 Node.js 上下文。如果页面函数返回的是 DOM 元素或无法序列化的对象,Puppeteer 会尝试处理,但通常你应当返回基础类型、普通对象或数组。两者在序列化规则上没有区别,区别只在于传入页面函数的第一个参数类型不同。
$eval 的详细用法与参数传递
.$eval() 的完整签名是 page.$eval(selector, pageFunction, ...args)。selector 是 CSS 选择器字符串,pageFunction 是要在页面中执行的函数,后面的 ...args 会作为额外参数按顺序传给 pageFunction。注意,第一个参数固定是元素,后续参数才对应 ...args。比如你想从 Node.js 传递一个属性名给页面函数,用来获取元素对应的属性值:
const attributeName = 'data-id'; const value = await page.$eval( '.card', (el, attr) => el.getAttribute(attr), attributeName ); console.log(value);
在上面的代码中,el 是第一个匹配 .card 的元素,attr 接收 attributeName 的值。这种传参方式可以避免在页面函数中硬编码数据,提高了代码复用性。需要留意的是,...args 会被 JSON 序列化后传给页面函数,所以不能传函数、DOM 节点或包含循环引用的对象。
另一个常见场景是通过 .$eval() 获取表单输入框的值。例如,读取搜索框中的关键词:
const keyword = await page.$eval('#search-input', input => input.value);
console.log(keyword); // 输出输入框当前的值
如果输入框不存在,input 为 null,访问 input.value 会报错。这时可以在函数内部判断,或者确保选择器可靠。有些开发者习惯使用可选链操作符 input?.value,但需要注意浏览器版本是否支持。稳妥写法仍然是显式判空。
$$eval 处理多个元素与复杂数据收集
.$$eval() 更适合批量数据处理。当你需要从一组元素中提取结构化信息时,页面函数通常会返回一个对象数组。例如,从新闻列表中提取标题和链接:
const news = await page.$$eval('.news-item', items =>
items.map(item => ({
title: item.querySelector('h3')?.textContent.trim(),
link: item.querySelector('a')?.href,
time: item.querySelector('.time')?.textContent.trim()
}))
);
console.log(JSON.stringify(news, null, 2));
这里 items 是所有 .news-item 元素的数组,map 会遍历每个元素并返回普通对象。由于返回的是可序列化的对象数组,Puppeteer 会把它完整地传回 Node.js 上下文。需要提醒的是,页面函数中不能使用 console.log 把数据输出到 Node 终端,因为 console.log 在页面上下文中对应的是浏览器的控制台。如果你在页面函数里写了 console.log,需要开启 page.on('console') 才能看到输出。
有时候你只需要统计元素数量,不必把所有元素传给页面函数。虽然可以调用 page.$$eval('.item', items => items.length),但 Puppeteer 还提供了更直接的 page.$$eval 的兄弟方法 page.$$(selector) 用于获取元素句柄数组,不过 .$$eval 适合需要在页面中直接对元素做计算的情况。例如,计算所有商品价格的总和:
const total = await page.$$eval('.price', prices =>
prices.reduce((sum, priceEl) => {
const value = parseFloat(priceEl.textContent.replace(/[^0-9.]/g, ''));
return sum + (isNaN(value) ? 0 : value);
}, 0)
);
console.log(total);
这个例子展示了如何利用数组中可用的方法在页面上下文中完成数据清洗和计算,减少数据在 Node 和浏览器之间的传输量。对于大型页面,尽量在页面函数内完成过滤和聚合,只返回最终结果,能显著提升脚本性能。
常见错误与调试建议
第一个常见错误是混淆参数顺序。误以为 .$eval(selector, ...args, pageFunction) 的形式也能工作,实际上 pageFunction 必须是第二个参数,...args 从第三个参数开始。如果写错顺序,可能会收到 “pageFunction is not a function” 之类的错误。第二个常见错误是在 pageFunction 中直接引用外部变量。比如在 Node.js 中定义了一个变量 const prefix = 'item-',然后在页面函数里直接使用 prefix 会得到 ReferenceError,因为页面函数运行在浏览器全局作用域,看不到 Node 变量。正确做法是把变量作为参数传入。
另一个值得注意的坑是选择器匹配不到元素时 .$eval() 传入 null,而很多开发者会在页面函数中直接读取属性导致脚本中断。调试时可以在 Node 侧先用 page.$(selector) 检查元素是否存在,或者使用 page.waitForSelector 等待元素出现后再调用 .$eval()。如果元素是动态加载的,这一点尤其重要。
还有一点是关于返回值序列化。页面函数如果返回 undefined,Puppeteer 会把它转换成 undefined 传回 Node 侧,这没问题。但如果返回了 Map、Set 或自定义类实例,则可能无法原样保留,因为序列化会调用 JSON.stringify。建议始终返回 JSON 兼容的数据结构。最后,当你在 .$eval() 或 .$$eval() 中执行异步操作时,页面函数可以是 async 函数,Puppeteer 会等待 Promise 完成后再返回结果。不过要注意浏览器上下文中可用的异步 API 与 Node 不同,不要混用。