先记住这个答案
arguments.callee 指向当前正在执行的函数,早年是为了让匿名函数能递归调用自己。ES5 的严格模式禁用了它,一读就抛 TypeError。主要原因有两个:一是它要求引擎在运行时随时能从 arguments 对象拿到当前函数,妨碍了函数内联等优化;二是和它配套的 caller 会暴露调用栈,有安全隐患。替代方案很简单,给函数起个名字:用函数声明,或者用具名函数表达式,函数体里通过这个名字递归。
- 严格模式读
arguments.callee直接抛TypeError - 被禁的主要原因是妨碍引擎内联和优化
- 替代方案是函数声明或具名函数表达式
- 具名函数表达式的名字只在函数体内可见
先看严格模式下会发生什么
arguments.callee 在非严格模式下还能用,严格模式下读它会直接报错,不是返回 undefined。
function sloppy() {
return arguments.callee === sloppy // 非严格模式:callee 指向函数自己
}
function strict() {
'use strict'
return arguments.callee // 严格模式:一读就抛错
}
console.log(sloppy())
try {
strict()
} catch (e) {
console.log(e.name)
}查看输出与解释
true
TypeError严格模式下的 arguments 对象上,callee 被定义成一个访问器属性,读和写都会抛 TypeError。这样设计是故意的:如果只是返回 undefined,老代码可能悄悄走错分支,直接报错更容易发现问题。
要注意,ES 模块和 class 里的代码默认就是严格模式。在 Vite、Webpack 打包的现代项目里写 arguments.callee,即使文件里没有 'use strict' 也会报错。
它当初解决什么问题,又为什么被禁
在 ES3 时代,匿名函数想递归调用自己没有好办法。比如 setTimeout(function () { ... }, 1000) 里想再次调用这个匿名函数,只能写 arguments.callee。当时虽然也可以给函数表达式起名字,但老版本 IE 对具名函数表达式有 bug,会把名字泄漏到外层作用域,所以很多代码都用 callee。
问题出在性能上。引擎优化函数时,常见手段是内联:把被调用函数的代码直接展开到调用处,省掉一次函数调用。可一旦函数体里用到 arguments.callee,引擎就必须保证运行时能拿到「当前函数对象」这个引用,内联后这个引用就很难提供。为了支持这一种极少用到的写法,引擎只能放弃优化。
另一个问题是 fn.caller,以及早已从规范里移除的 arguments.caller。它们能拿到调用当前函数的那个函数,等于让任何函数都能往上翻调用栈,拿到不该拿到的引用。严格模式把这一组能力一起禁掉了。
递归的正确写法:给函数起名
函数声明本身就有名字,递归直接用。需要写成表达式的时候,比如作为回调、赋值给变量,就用具名函数表达式。
let countdown = function (n) {
return n === 0 ? 'done' : countdown(n - 1) // 通过外层变量递归
}
const saved = countdown
countdown = null // 外层变量被改掉了
try {
saved(3)
} catch (e) {
console.log(e.name) // 递归时找不到函数
}
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1) // fact 只在函数体内可见
}
const alias = factorial
console.log(alias(5)) // 换个名字调用也没问题
console.log(typeof fact) // 外面访问不到 fact查看输出与解释
TypeError
120
undefined第一种写法看起来也能递归,但它依赖外层变量 countdown。一旦这个变量被重新赋值,函数内部递归调用的就是 null,抛出 TypeError。
具名函数表达式里的 fact,是一个只在函数体内部存在的绑定,永远指向这个函数本身。外面的变量怎么改都不影响它,外面也访问不到它,不会污染作用域。这正是 arguments.callee 当年想提供的能力,现在用语法就能做到。
- 定时器里的匿名函数递归
- 轮询场景里经常要在回调中再次调用自己。以前写
setTimeout(arguments.callee, 1000),现在给回调起个名字就行:setTimeout(function tick() { poll(); setTimeout(tick, 1000) }, 1000)。tick这个名字还会出现在错误堆栈里,比anonymous好排查得多。
容易答错的地方
- 以为去掉
'use strict'就能继续用 - 现代项目里的代码大多是 ES 模块,或者在
class里,这些地方天然是严格模式,删掉'use strict'没有用。就算在普通脚本里能用,也只是把性能问题保留下来了。 - 以为箭头函数可以用
callee - 箭头函数没有自己的
arguments,写arguments.callee拿到的是外层普通函数的arguments,指向的也是外层函数,不是箭头函数本身。箭头函数要递归,只能通过外层变量,所以复杂递归更适合用普通函数写。 - 以为具名函数表达式的名字可以重新赋值
- 在函数体里给
fact赋值,非严格模式下会静默失败,fact还是原来的函数;严格模式下直接抛TypeError。这个名字是只读的,这也是它比外层变量可靠的原因。
面试官还会怎么问?
严格模式对 arguments 还有哪些改动?
除了禁用 callee,最重要的是 arguments 不再和形参联动。非严格模式下,简单参数列表的函数里改 arguments[0] 会同时改掉第一个形参,严格模式下两者互不影响。另外严格模式下不能给 arguments 这个名字重新赋值,也不能用它当变量名。
具名函数表达式的名字和 fn.name 是什么关系?
fn.name 会优先取具名函数表达式自己的名字。const factorial = function fact() {} 的 factorial.name 是 'fact'。如果是匿名函数表达式,引擎会根据赋值的变量名推断,const foo = function () {} 的 foo.name 是 'foo'。
现在还有什么场景会遇到 arguments.callee?
主要是在维护很老的脚本,或者读一些早期的库源码时。改造时直接给函数起名,把 arguments.callee 换成这个名字即可。如果是 ESLint 项目,no-caller 规则会直接报出这类用法。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。