作用域链和闭包:代码中出现相同的变量,JavaScript引擎如何选择|浏览器篇
# 作用域链和闭包:代码中出现相同的变量,JavaScript引擎如何选择
30 秒速记
- 同名变量按词法作用域链解析,不按函数调用时的调用栈顺序选择。
- 标识符查找从当前执行上下文开始;当前作用域不存在时,才沿外部作用域继续向上。
bar声明在全局作用域,因此其内部的myName最终命中全局值“极客时间”,不会命中调用者foo的局部值。- 调用栈描述函数的执行与返回顺序,作用域链决定变量解析路径,两者不能混用。
- 理解作用域链是继续分析闭包的前提;给定内容尚未展开闭包的形成与生命周期。
遇到同名变量时,JavaScript 会按词法作用域链由内向外查找,而不是根据函数调用顺序选择。 bar 在全局作用域中声明,所以它找不到自己的 myName 后,会继续查找全局变量,最终得到“极客时间”。虽然 bar 是由 foo 调用的,但 foo 中的局部变量不在它的查找路径上。调用栈只决定函数何时执行和返回,作用域链才决定变量取哪个值,这也是继续理解闭包的基础。
理解作用域链是理解闭包的基础,而闭包在 JavaScript 中几乎无处不在,同时作用域和作用域链还是所有编程语言的基础。所以,如果你想学透一门语言,作用域和作用域链一定是绕不开的
那今天我们就来聊聊什么是作用域链,并通过作用域链再来讲讲什么是闭包。
首先我们来看下面这段代码:
function bar() {
console.log(myName)
}
function foo() {
var myName = " 极客邦 "
bar()
}
var myName = " 极客时间 "
foo()
你觉得这段代码中的 bar 函数和 foo 函数打印出来的内容是什么?这就要分析下这两段代码的执行流程。
通过前面几篇文章的学习,想必你已经知道了如何通过执行上下文来分析代码的执行流程了。那么当这段代码执行到 bar 函数内部时,其调用栈的状态图如下所示:
从图中可以看出,全局执行上下文和 foo 函数的执行上下文中都包含变量 myName,那 bar 函数里面 myName 的值到底该选择哪个呢?
也许你的第一反应是按照调用栈的顺序来查找变量,查找方式如下:
- 先查找栈顶是否存在 myName 变量,但是这里没有,所以接着往下查找 foo 函数中的变量。
- 在 foo 函数中查找到了 myName 变量,这时候就使用 foo 函数中的 myName。
如果按照这种方式来查找变量,那么最终执行 bar 函数打印出来的结果就应该是“极客邦”。但实际情况并非如此,如果你试着执行上述代码,你会发现打印出来的结果是“极客时间”。为什么会是这种情况呢?要解释清楚这个问题,那么你就需要先搞清楚作用域链了
面试官追问
追问 1订单页里 foo() 声明了 var tenant = 'A',随后调用全局声明的 bar();bar() 却打印全局的 'default',同事认为引擎取错了变量,你怎么解释?
引擎没有取错,bar 的变量查找关系由它的声明位置决定,不会因为在 foo 内执行就读取 foo 的局部变量。bar 当前环境查找失败后会沿自身的外部引用继续查找;若业务要求使用租户值,应显式传参或调整函数的词法嵌套。
追问 2公共埋点函数分散在 30 个页面中,重构者想把调用语句移进各页面函数,让它自动读取页面内同名 pageId,这次改动能生效吗?
只移动调用语句不会改变公共函数的词法作用域,因此它仍不会自动读取调用者的 pageId。更稳妥的做法是把 pageId 作为参数传入,或在页面作用域内创建真正捕获该变量的函数;后者会形成外部变量引用,需要留意生命周期和维护成本。
追问 3一个回调原先在页面函数内部声明,重构后被提升为模块顶层函数,但调用位置和变量名都没变,线上配置为什么可能突然读成外层默认值?
声明位置变化会改变函数建立的外部作用域关系,所以同名变量的命中路径也可能随之改变。应比较重构前后的函数声明层级,并检查变量是在当前环境还是外部环境中被找到;仅保持调用位置不变,无法保证原有取值语义。
追问 4线上日志显示 bar() 读取了错误的 myName,调用栈中它正好位于 foo() 上方;值班同学准备从相邻栈帧继续找同名变量,你会怎样排查?
不要按调用栈相邻关系推断变量来源,应先定位 bar 的声明位置,再沿其词法外层逐级核对同名绑定。调用栈只反映执行上下文的调用关系,不能替代作用域链;若代码经过重构或包装,还需确认实际执行的是哪一个函数对象。
追问 5组件库需要让工具函数访问当前主题,一方建议读取外部变量,另一方坚持每次显式传入 theme,你如何做工程取舍?
若主题属于稳定的词法配置,内部函数捕获外部变量可以减少重复参数;若调用方可能同时处理不同主题,显式传参更容易看清数据来源。依赖闭包会把行为绑定到声明环境,依赖参数则增加调用成本;选择时应优先避免同名遮蔽和隐式状态造成的排查困难。
# 作用域链
30 秒速记
- 每个执行上下文都通过外部引用
outer关联其词法上的外层执行上下文。 - 读取标识符时,引擎先查当前执行上下文;未找到才沿
outer逐级向外查找。 - 当前作用域与各级外部作用域组成的标识符查找路径,就是作用域链。
foo与bar都直接声明在全局,因此二者的outer均指向全局上下文,而不是互相指向。- 作用域链负责变量解析,函数调用关系只影响执行上下文何时进入调用栈。
作用域链就是变量从当前执行上下文开始,再沿外部引用 outer 逐级向外查找的路径。 当前环境找不到标识符时,引擎才会进入 outer 指向的外层执行上下文,直到找到变量或走完整条链。示例里的 foo 和 bar 都直接声明在全局,因此它们的 outer 都指向全局,而不是因为互相调用就彼此关联。简单来说,调用关系影响执行上下文进出调用栈,声明位置才决定变量的解析路径。
关于作用域链,很多人会感觉费解,但如果你理解了调用栈、执行上下文、词法环境、变量环境等概念,那么你理解起来作用域链也会很容易。所以很是建议你结合前几篇文章将上面那几个概念学习透彻。
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。
当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量, 比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。为了直观理解,你可以看下面这张图:
从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链。
现在你知道变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?
要回答这个问题,你还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的
原理拆解: 标识符解析从当前词法环境开始:先检查函数参数、局部声明及块级绑定,未命中时再沿外部环境引用逐层上溯,直到全局环境;全链路仍未找到,读取操作会抛出 ReferenceError。这里的外部引用表达声明嵌套关系,不表达调用栈关系。调用栈只记录执行先后,因此函数甲调用函数乙,不会让甲自动成为乙的外层作用域。
最小验证: 输入为两个并列声明的函数,验证 bar 读取的是全局变量,而不是调用者 foo 的局部变量。
const label = "global";
function bar() {
console.log(label);
}
function foo() {
const label = "foo-local";
bar();
}
foo();
try {
console.log(missingName);
} catch (error) {
console.log(error.name);
}
关键行是 bar() 的声明位置:它与 foo 都位于全局,所以其外部环境指向全局环境。预期依次输出 global 和 ReferenceError,不会输出 foo-local。若把 bar 的声明整体移入 foo,查找链随声明嵌套改变,结果才会变为局部值。
边界与排查: let、const 在初始化前处于暂时性死区,此时即使外层存在同名变量,也不会越过当前绑定继续查找,而是直接抛出 ReferenceError;var 则有不同的创建和初始化时机。工程验证可在浏览器开发者工具中设置断点,查看 Scope 面板里的 Local、Closure、Script、Global 等层级,并逐层确认同名绑定首先在哪一级命中。
面试官追问
追问 1控制台中全局 token 为 'outer',request() 内也声明了 'inner',但它调用的顶层 sign() 仍读到 'outer';有人说调用者的局部变量优先级更高,错在哪里?
调用者的局部变量不在 sign 的作用域链上,因此不存在所谓更高优先级。引擎先查 sign 当前执行上下文,再沿其 outer 指向的声明外层查找;要使用 'inner',应传参或让 sign 在对应词法环境中声明。
追问 2一个模块嵌套了三层函数,每层都可能声明 config;代码评审要求明确最内层最终读取哪一个值,你会怎样判断?
从最内层当前执行上下文开始查找,命中后立即使用;只有当前层不存在 config,才沿 outer 进入下一层声明环境。判断依据是函数的词法嵌套而非运行时调用顺序;同名遮蔽会使更外层变量不可见,评审时应逐层标出绑定。
追问 3重构把 handler 从 createPage() 内部移到文件顶层,仍由 createPage() 返回并调用;产品要求它继续读取局部 locale,为什么仅保留返回方式不够?
函数移到文件顶层后,其 outer 不再指向 createPage 对应的外层环境,因此原来的局部 locale 不会被同样命中。可以把 locale 作为参数传入,或保留一个在 createPage 内声明的包装函数;两种方案分别承担调用显式性和闭包依赖的成本。
追问 4线上变量值异常,源码里最近的外层函数明明有同名 region,运行结果却走了全局值;你会优先核对哪些事实?
先确认当前执行函数的真实声明位置与函数对象来源,再核对各级执行上下文中是否确实存在该绑定。源码排版上的“靠得近”不等于 outer 指向它,动态调用也不会建立新的词法外层;若函数被抽离或替换,应以实际声明关系为准。
追问 5同事把作用域链描述成“当前函数查不到就扫描整个调用栈,最后查全局”,你会用什么代码现象反证?
可让全局声明的 bar() 读取 myName,再由局部声明同名变量的 foo() 调用它;结果仍会命中全局变量,而不是 foo 的局部值。该现象说明调用栈负责记录执行顺序,变量解析则沿 outer 形成的词法链,两者不能混用。
追问 6框架工具函数大量隐式读取模块外部配置,团队想统一改成参数传递;从作用域链角度看,这项取舍解决了什么,又付出什么?
显式传参能切断对特定声明环境的隐式依赖,使调用点直接展示配置来源,也更容易隔离同名变量。代价是函数签名和调用链会增加参数传递;若配置稳定且只服务于封闭模块,保留词法捕获也合理,但需要清楚记录其外部绑定。
