tsx 下 page.evaluate 报 __name is not defined——esbuild --keep-names 的序列化陷阱
写了一个 Playwright 爬虫,在 Node 里跑得好好的。改用 tsx 直接执行 TypeScript 之后,page.evaluate 一调就炸,报错信息莫名其妙:
page.evaluate: ReferenceError: __name is not defined
at eval (eval at evaluate (:302:30), <anonymous>:1:21)
at UtilityScript.evaluate (<anonymous>:304:16)
代码里根本没有 __name,全局搜索也找不到,但就是在浏览器上下文里 ReferenceError。
现象
- 用
tsc编译后node dist/scraper.js跑——没问题,evaluate 正常返回数据 - 用
tsx src/scraper.ts直接跑——进入page.evaluate立即抛__name is not defined - evaluate 的闭包里只有最普通的具名 const:
const data = await page.evaluate(() => {
const pickText = (el: Element | null) => el?.textContent?.trim() ?? ''
return pickText(document.querySelector('h1'))
})改成 bun run 同样复现;改成 vite-node 同样复现——只要是 esbuild 系的运行器都中招。
根因
esbuild 有一个 --keep-names 选项,默认在 tsx、bun、vite-node 里开启,目的是让压缩后的函数仍保留 .name 属性,以便 Error 堆栈可读。
esbuild 实现这个特性的方式是:把每个具名函数/箭头声明都用内置 helper __name() 包裹:
// 你写的:
const pickText = (el) => el?.textContent?.trim() ?? ''
// esbuild 输出的:
const pickText = __name((el) => el?.textContent?.trim() ?? '', 'pickText')在 Node 里,__name helper 被注入到当前模块的包装作用域,没有问题。
但 page.evaluate 会把闭包序列化成字符串再发给浏览器执行。序列化后的代码里带着 __name(...) 调用,而浏览器页面上下文里根本没有这个 helper——直接 ReferenceError。
page.evaluate 把你的闭包 fn.toString() 后发到浏览器执行。esbuild 为了保留 .name 注入的 __name() 包裹不会跟着序列化过去——浏览器没有 __name,于是崩了。你不会在自己代码里看到这个符号,因为它是构建工具插入的;grep 代码库永远找不到,让问题极其难定位。
正解
在创建 context 之后、newPage() 之前,用 addInitScript 向浏览器注入一个无操作 shim:
const ctx = await browser.newContext({ /* storageState, userAgent, viewport ... */ })
// esbuild --keep-names 会把具名 const/function 包成 __name(fn, 'fn')
// page.evaluate 序列化进浏览器后 __name 不存在 → ReferenceError
// 注入 noop shim 消除该报错,语义不变(shim 直接返回 fn 本身)
await ctx.addInitScript(() => {
if (!(globalThis as any).__name) {
(globalThis as any).__name = (fn: any) => fn
}
})
const page = await ctx.newPage()
// 之后 page.evaluate / page.evaluateHandle 里可以自由使用具名 const要点:
addInitScript在每个 frame 所有脚本之前执行,保证 shim 先到位- shim 的语义是
fn => fn,直接返回原函数,运行时行为与没有 --keep-names 完全一致 - 加了
if (!globalThis.__name)防御,不会覆盖其他来源的合法注入 - shim 是 per context 的,如果你创建了多个 context,每个都要 addInitScript
await ctx.addInitScript(() => { if (!globalThis.__name) globalThis.__name = fn => fn }),放在 newPage() 之前。shim 返回函数本身,esbuild 注入的 __name(fn, 'fn') 在浏览器里变成无害的身份函数,evaluate 正常执行。
不推荐的替代方案
| 方案 | 问题 |
|------|------|
| 把 evaluate 闭包里所有具名 const 改成匿名 | 脆弱,一旦有人重构提取 helper 立刻复发 |
| 禁用 --keep-names(自定义 esbuild 配置) | 丢失全局堆栈可读性,tsx 不直接暴露该选项 |
| page.evaluate(stringOfCode) 传字符串 | 丢失 TypeScript 类型检查和 IDE 补全 |
影响范围
同一根因还会在这些路径触发:
page.evaluateHandlepage.exposeFunction包裹的回调(如果 callback 里有具名声明)page.addScriptTag({ content: esbuildOutput })
page.goto、page.click 等不序列化 JS 闭包的 API 不受影响。
一句话外卖
tsx / bun / vite-node 用 esbuild
--keep-names给每个具名函数包一层__name(),这个 helper 只在 Node 模块作用域存在——page.evaluate把代码序列化进浏览器时它消失了;用addInitScript注入一个fn => fn的 noop shim 是最小成本的修法。