先记住这个答案
默认参数有自己的作用域,和函数体是分开的。进入函数调用时,引擎先给所有形参创建绑定,再从左到右执行默认表达式来初始化。形参名一旦存在就会遮蔽外层同名变量,即使这个形参还没初始化完。所以 function f(x = x) 里右边的 x 找到的是形参 x,但它还在暂时性死区里,读取直接抛 ReferenceError。
x = x读的是未初始化的形参,不会回退到外层变量- 后面的参数可以引用前面的,反过来会触发 TDZ
- 默认表达式里读不到函数体内声明的变量
- 传了实参就不走默认表达式,也不会踩 TDZ
三种典型的报错场景
默认参数的 TDZ 问题有三种常见变体:同名遮蔽自己、前参引用后参、以及引用函数体内部变量。面试里随便拿一种都能考。
const value = 10
function ok(first = 2, second = first * 3) { return second }
function self(value = value) { return value }
function forward(first = second, second = 4) { return first }
console.log(ok())
for (const fn of [self, forward]) {
try { fn() } catch (e) { console.log(e.constructor.name) }
}查看输出与解释
6
ReferenceError
ReferenceErrorok 能正常返回 6,因为 second 引用的 first 已经初始化完了,从左到右没有问题。self 报错是因为形参 value 遮蔽了外层的 const value = 10,右边读到的是还没初始化的形参自己。forward 报错是因为 first 的默认表达式去读 second,而 second 排在后面,还没轮到它初始化。
这三个例子说明一条规则:默认表达式只能读已经初始化完的东西,也就是外层变量和排在左边的参数。
为什么形参会遮蔽外层变量
这个行为和块级作用域里的 let 一模一样。在一个块里用 let 声明了变量,即使声明写在后面,整个块从一开始就有了这个绑定,声明之前的区域就是 TDZ。
const x = 1
{
// let x 的绑定在这一刻就已存在,外层 x = 1 被遮蔽了
console.log(x) // ReferenceError
let x = 2
}函数默认参数的道理完全相同。进入调用时,引擎会先在参数作用域里给每个形参都创建一个绑定。绑定是一开始就全部创建的,初始化才是从左到右逐个进行的。所以 function f(value = value) 里,形参 value 的绑定在执行 = value 之前就已经存在了,名称查找先命中这个绑定,发现它还没初始化,直接报错。外层那个同名变量被完全遮蔽了,查找链根本走不到那里。
要想用外层变量做默认值,最简单的办法是让形参名和外层变量不同名。比如 function f(val = value) 就不会有遮蔽问题,val 和 value 是两个名字,value 会正常从外层找到。
参数作用域和函数体是分开的
当函数有默认参数时,参数列表和函数体各自拥有独立的作用域。默认表达式的查找链是「参数环境 → 外层环境」,不会向内深入到函数体。这样设计是为了避免函数体还没开始执行就被提前读取。
function test(a = inner) {
var inner = 'hello'
return a
}
try { test() } catch (e) { console.log(e.constructor.name) }查看输出与解释
ReferenceErrorinner 在函数体里用 var 声明,但默认表达式在参数作用域里执行,参数作用域建立在函数体之前,查找链不会向内看,所以找不到 inner。
反过来是可以的:函数体能正常读到参数。比如 function f(a = 1) { return a } 里 a 在函数体里完全可用,因为函数体的作用域链会经过参数环境。
没有默认参数的函数不存在这个问题。没有默认参数时,引擎会把参数环境和函数体环境合并成一个,var 声明和形参共享同一个作用域。只有出现了默认参数,两个作用域才会分开。
容易答错的地方
- 以为
x = x会读到外层的x - 很多人看到外层有个同名变量就以为默认表达式会用它。实际上形参绑定的优先级更高,同名形参一旦存在,外层变量就被遮蔽了。要引用外层变量,形参名不能和它重名。
- 把参数依赖关系写反了
(a = b, b = 1)会报错,(b = 1, a = b)才对。初始化从左到右,后面的参数在前面的默认表达式执行时还在 TDZ 里,不能被引用。
面试官还会怎么问?
传了实参还会触发 TDZ 吗?
不会。只要传的不是 undefined,默认表达式根本不执行。比如调 self(42) 完全正常,value 直接用实参 42 初始化,不走 = value 这个表达式,TDZ 根本不会被触碰。
默认表达式里能调用外层函数吗?
可以。参数作用域的外层就是函数定义所在的作用域,里面能访问到的函数和变量在默认表达式里都能用。但要注意调用发生在进入函数体之前,如果函数有副作用,执行时机可能和你预期的不同。比如默认表达式里调了一个会修改全局状态的函数,这个修改在函数体第一行之前就已经发生了。
解构参数也有 TDZ 问题吗?
有。解构参数的默认值走的是同一套机制。比如 function f({ a = b, b } = {}) 里 a 的默认值引用 b,但解构也是从左到右逐个初始化的,b 还没轮到,会报 ReferenceError。解构只是语法上看起来不同,底层的初始化顺序规则没变。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。