先记住这个答案
JavaScript 用的是词法作用域,也叫静态作用域:一个函数能访问哪些变量,在写代码的时候就定了,取决于它写在哪个函数里面,和它运行时在哪里被调用没关系。函数里读一个变量时,先在自己的作用域里找,找不到就去外层函数找,再往外,直到全局作用域,这一串就是作用域链。找到就停下,全局也没有就抛 ReferenceError。
- 变量查找看函数写在哪,不看在哪调用
- 从内往外逐层查找,找到就停
- 内层同名变量会遮蔽外层变量
- 闭包就是函数带着它的作用域链离开了原地
调用位置不会改变查找结果
下面这段代码是这道题最直接的证明:三个地方都有一个叫 label 的变量,看看函数最后读到的是哪个。
const label = 'global'
function makeReader() {
const label = 'outer'
return function read() {
return label // read 写在 makeReader 里面
}
}
function run(callback) {
const label = 'caller'
return callback() // read 在这里被调用
}
console.log(run(makeReader()))查看输出与解释
outerread 在 run 里面被调用,run 里有一个 label 等于 'caller',但 read 读到的是 'outer'。因为 read 是写在 makeReader 里面的,它的外层就是 makeReader,查找时根本不会经过 run。
如果 JavaScript 是动态作用域,按调用关系查找,这里就会打印 'caller'。Bash 脚本的变量就是这种规则,所以面试官有时会拿两种作用域做对比。
作用域链是怎么搭起来的
可以把每个作用域想象成一个记录变量的小本子,每个本子上都写着「找不到就去看哪个本子」。函数创建时会记住自己定义所在的那个本子,每次调用时,引擎新建一个本子放这次调用的局部变量,并让它指向函数记住的那个外层本子。这样一层套一层,就是作用域链。
因为「外层是谁」在函数创建时就记下了,所以不管函数后来被传到哪里、被谁调用,查找路径都不变。闭包也是这么来的:makeReader 执行完了,但 read 还记着它的本子,所以 'outer' 一直能读到。
- 遮蔽:内层同名变量挡住外层
- 查找是「找到就停」,所以内层有同名变量时,外层的就被挡住了,这叫遮蔽。要注意的是,只要内层作用域里声明了同名的
let或const,哪怕声明写在后面,从进入这个作用域开始外层变量就读不到了。在声明之前读取会抛ReferenceError,而不是退回去读外层的值。 - 块也是一层作用域
if、for和单独的一对花括号,里面用let、const、class声明的变量只在块内可见,块也是作用域链上的一环。var不认块,只认函数,所以用var声明的变量会直接落到外层函数的作用域里。
容易答错的地方
- 以为函数在谁里面调用,就能读谁的变量
- 调用关系组成的是调用栈,不是作用域链。回调函数被传进别的函数执行,也读不到那个函数的局部变量,要用就只能通过参数传进去。
- 把作用域链和原型链混为一谈
- 作用域链用来查变量,比如直接写
count。原型链用来查对象属性,比如obj.count。一个找不到抛ReferenceError,一个找不到返回undefined,是两套独立的机制。
面试官还会怎么问?
JavaScript 里有没有能破坏词法作用域的东西?
有两个:with 和直接调用的 eval。with (obj) 会把一个对象临时插到作用域链上,eval 可以在运行时往当前作用域里加变量,两者都让查找结果没法在写代码时确定,引擎也没法优化。严格模式下 with 直接禁用,eval 声明的变量也不再泄漏到外层。
this 也是按词法作用域查找的吗?
普通函数不是,它的 this 由调用方式决定,这正好是 this 让人困惑的地方。箭头函数没有自己的 this,它会像普通变量一样沿作用域链往外找,所以箭头函数的 this 是词法的。
作用域链越长,性能越差吗?
理论上查找层数越多越慢,但现代引擎在编译时就能确定大多数变量在哪一层,访问开销很小,日常开发不用为此刻意减少嵌套。真正该注意的是闭包长时间持有大对象带来的内存占用。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。