返回博客

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 选项,默认在 tsxbunvite-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。

坑:esbuild --keep-names 注入的 __name 只在 Node 模块作用域存在,序列化进浏览器后消失

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
正解:addInitScript 注入 noop __name shim,一行搞定

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.evaluateHandle
  • page.exposeFunction 包裹的回调(如果 callback 里有具名声明)
  • page.addScriptTag({ content: esbuildOutput })

page.gotopage.click 等不序列化 JS 闭包的 API 不受影响。

一句话外卖

tsx / bun / vite-node 用 esbuild --keep-names 给每个具名函数包一层 __name(),这个 helper 只在 Node 模块作用域存在——page.evaluate 把代码序列化进浏览器时它消失了;用 addInitScript 注入一个 fn => fn 的 noop shim 是最小成本的修法。