在上一篇文章 深入理解JavaScript 执行上下文 中提到 只有理解了执行上下文,才能更好地理解 JavaScript 语言本身,比如变量提升,作用域,闭包等,本篇文章就来说一下 JavaScript 的作用域。
这篇文章称为笔记更为合适一些,内容来源于 《你不知道的JavaScript(上卷)》第一部分 作用域和闭包。讲的很不错,非常值得一看。
什么是作用域
作用域是根据名称查找变量的一套规则。
理解作用域
先来理解一些基础概念:
- 引擎:从头到尾负责整个JavaScript程序的编译及执行过程。
- 编译器:负责语法分析和代码生成。这部分也可以看 JavaScript代码是如何被执行的
- 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
接下来来看看下面代码的执行过程:
var a = 2;
- 遇见 var a,编译器 会问 作用域 变量
a
是否存在于同一个作用域集合中。如果存在,编译器会忽略声明,继续编译;否则,会要求作用域在当前作用域集合中声明一个新的变量,并命名为a
- 接下来 编译器 会为 引擎 生成运行时所需的代码,用来处理 a = 2 这个赋值操作。引擎运行时会先问作用域,当前作用域集中是否存在变量
a
。如果是,引擎就会使用该变量;如果不存在,引擎会继续查找该变量 - 如果 引擎 找到了a 变量,就会将 2 赋值给它,否则引擎就抛出一个错误。
总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量,然后在运行时引擎就会会作用域中查找该变量,如果能够找到就对它赋值。
编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a
来判断它是否已声明过。查找的过程中由作用域进行协助,但是引擎执行怎么样的查找,会影响最终的查找结果。
在我们的例子中,引擎会为变量 a 进行 LHS 查询,另外一个查找的类型叫做 RHS。 ”L“ 和 "R" 分别代表一个赋值操作左侧和右侧。当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。
LHS:试图找到变量的容器本身,从而可以对其赋值;RHS: 就是简单地查找某个变量的值。
console.log(a);
对 a 的引用是一个 RHS 引用,因为这里 a 并没有赋予任务值,相应地需要查找并取得 a 的值,这样才能将值传递给 console.log(...)
a = 2;
这里对 a 的引用是 LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为 = 2这个赋值操作找到目标。
funciton foo(a) {
console.log(a)
}
foo(2);
- 最后一行 foo 函数的调用需要对 foo 进行 RHS 引用,去找 foo的值,并把它给我
- 代码中隐式的 a = 2 操作可能很容易被你忽略掉,这操作发生在 2 被当做参数传递给
foo
函数时,2
会被分配给参数a
,为了给参数a
(隐式地) 分配值,需要进行一次LHS
查询。 - 这里还有对 a 进行的 RHS 引用,并且将得到的值传给了
console.log(...)
。console.log(...)
本身也需要一个引用才能执行,因此会对 console对象进行RHS
查询,并且检查得到的值中是否有一个叫做log
的方法。
RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。进行RHS查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行调用,后者引用null或 undefined 类型的值中的属性,那么引擎会抛出一个另外一种类型的异常 TypeError。
引擎执行 LHS 查询时如果找不到该变量,则会在全局作用域中创建一个。但是在严格模式下,并不是自动创建一个全局变量,而是会抛出 ReferenceError 异常
补充 JS几种常见的错误类型
简单总结如下:
作用域是一套规则,用于确定在哪里找,怎么找到某个变量。如果查找的目的是对变量进行赋值,那么就会使用 LHS查询; 如果目的是获取变量的值,就会使用 RHS 查询;
JavaScript 引擎执行代码前会对其进行编译,这个过程中,像 var a = 2 这样的声明会被分解成两个独立的步骤
- var a 在其作用域中声明变量,这会在最开始的阶段,也就是代码执行前进行
- 接下来,a = 2 会查询 (LHS查询)变量 a 并对其进行赋值。
词法作用域
词法作用域是你在写代码时将变量写在哪里来决定的。编译的词法分析阶段基本能够知道全局标识符在哪里以及是如何声明的,从而能够预测在执行过程中如果对他们查找。
有一些方法可以欺骗词法作用域,比如 eval, with, 这两种现在被禁止使用,1是严格模式和非严格模式下表现不同 2是有性能问题, JavaScript引擎在编译阶段会做很多性能优化,而其中很多优化手段都依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到识别符,eval, with会改变作用域,所以碰到它们,引擎将无法做优化处理。
全局作用域和函数作用域
全局作用域
- 在最外层函数和最外层函数外面定义的变量拥有全局作用域
var a = 1;
function foo() {
}
变量a 和函数声明 foo 都是在全局作用域中的。
- 所有未定义直接赋值的变量自动声明为拥有全局作用域
var a = 1;
function foo() {
b = 2;
}
foo();
console.log(b); // 2
- 所有 window 对象的属性拥有全局作用域
函数作用域
函数作用域是指在函数内声明的所有变量在函数体内始终是可见的。外部作用域无法访问函数内部的任何内容。
function foo() {
var a = 1;
console.log(a); // 1
}
foo();
console.log(a); // ReferenceError: a is not defined
只有函数的{}
构成作用域,对象的{}
以及if(){}
都不构成作用域;
变量提升
提升是指声明会被视为存在与其所出现的作用域的整个范围内。
JavaScript编译阶段是找到找到所有声明,并用合适的作用域将他们关联起来(词法作用域核心内容),所以就是包含变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
每个作用域都会进行提升操作。
function foo() {
var a;
console.log(a); // undefined
a = 2;
}
foo();
注意,函数声明会被提升,但是函数表达式不会被提升。
关于 块级作用域和变量提升的内容之前在 从JS底层理解var、let、const这边文章中详细介绍过,这里不再赘述。
块级作用域
我们来看下面这段代码
for(var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
})
}
console.log(`当前的i为${i}`); // 当前的i为5
上面这段代码我们希望是输出 0,1, 2, 3, 4 ,但是实际上输出的是 5,5, 5, 5, 5。我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使用 i,但是实际上 此时的 i 被绑定在外部作用域(函数或全局)中。
,块级作用域是指在指定的块级作用域外无法访问。在ES6之前是没有块级作用域的概念的,ES6引入了 let 和 const。我们可以改写上面的代码,使它按照我们想要的方式运行。
for(let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
})
}
// 0 1 2 3 4
console.log(`当前的i为${i}`); // ReferenceError: i is not defined
此时 for 循环头部的 let 不仅将 i 绑定到了 for 循环的迭代中,事实上将它重新绑定到了循环的每一个迭代中,确保使用上一次循环迭代结束的值重新进行赋值。
let声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域)。但是其行为是一样的,可以总结为:任何声明在某个作用域内的变量,都将附属于这个作用域。
const也是可以用来创建块级作用域变量,但是创建的是固定值。
作用域链
JavaScript是基于词法作用域的语言,通过变量定义的位置就能知道变量的作用域。全局变量在程序中始终都有都定义的。局部变量在声明它的函数体内以及其所嵌套的函数内始终是有定义的。
每一段 JavaScript 代码都有一个与之关联的作用域链(scope chain)。这个作用域链是一个对象列表或者链表。当 JavaScript 需要查找变量 x 的时候(这个过程称为变量解析),它会从链中的第一个变量开始查找,如果这个对象上依然没有一个名为 x 的属性,则会继续查找链上的下一个对象,如果第二个对象依然没有名为 x 的属性,javaScript会继续查找下一个对象,以此类推。如果作用域链上没有任何一个对象包含属性 x, 那么就认为这段代码的作用域链上不存在 x, 并最终抛出一个引用错误 (Reference Error) 异常。
下面作用域中有三个嵌套的作用域。
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(a, b, c)
}
bar( b * 3);
}
foo(2);
气泡1
包含着整个全局作用域,其中只有一个标识符:foo;气泡2
包含着foo所创建的作用域,其中有三个标识符:a、bar 和 b;气泡3
包含着 bar所创建的作用域,其中只有一个标识符:c
执行 console.log(...)
,并查找 a,b,c三个变量的引用。下面我们来看看查找这几个变量的过程.
它首先从最内部的作用域,也就是 bar(..) 函数的作用域气泡开始找,引擎在这里无法找到 a,因此就会去上一级到所嵌套的 foo(...)的作用域中继续查找。在这里找到了a,因此就使用了这个引用。对b来说也一样,而对 c 来说,引擎在 bar(..) 中就找到了它。
如果 a,c都存在于 bar(...) 内部,console.log(...)就可以直接使用 bar(...) 中的变量,而无需到外面的 foo(..)中查找。作用域会在查找都第一个匹配的标识符时就停止。
在多层的嵌套作用域中可以定义同名的标识符,这叫”遮蔽效应“。
var a = '外部的a';
function foo() {
var a = 'foo内部的a';
console.log(a); // foo内部的a
}
foo();
作用域与执行上下文
JavaScript的执行分为:解释和执行两个阶段
解释阶段
- 词法分析
- 语法分析
- 作用域规则确定
执行阶段
- 创建执行上下文
- 执行函数代码
- 垃圾回收
作用域在函数定义时就已经确定了,而不是在函数调用时确定,但执行上下文是函数执行之前创建的。
总结
- 作用域就是一套规则,用于确定在哪里找以及怎么找到某个变量。
- 词法作用域在你写代码的时候就确定了。JavaScript是基于词法作用域的语言,通过变量定义的位置就能知道变量的作用域。ES6引入的let和const声明的变量在块级作用域中。
- 声明提升是指声明会被视为存在与其所出现的作用域的整个范围内。
- 查找变量的时候会先从内部的作用域开始查找,如果没找到,就往上一级进行查找,依次类推。
- 作用域在函数定义时就已经确定了,执行上下文是函数执行之前创建的。
参考
- 深入理解JavaScript作用域和作用域链
- 深入理解javascript原型和闭包系列
- 作用域和词法作用域
- 《你不知道的JavaScript (上卷)》
其他
最近发起了一个100天前端进阶计划,主要是深挖每个知识点背后的原理,欢迎关注 微信公众号「牧码的星星」,我们一起学习,打卡100天。同时也会分享一些自己学习的一些心得和想法,欢迎大家一起交流。