重学JavaScript[4] 作用域
提起作用域,JavaScript 做题家会想起很多相关的八股文:let 和 var 的区别、函数作用域、块作用域、闭包等等,甚至还会扯上很多编译原理的知识(很可惜我没学过)
# 有关编译原理
像JavaScript
和Python
这类由解释器运行的脚本语言,似乎不需要编译的过程
但解释器(或引擎)在执行代码时,会即时地进行编译,这个过程大约会发生在执行前很短的时间内
传统编译语言编译时大致会有三个步骤:
- 词法分析(Tokenizing) 将代码字符串分解为词法单元(token)
- 语法分析(Parsing) 将词法单元数组转换成抽象语法树(AST)
- 代码生成 根据 AST 生成可执行代码(机器指令)
# LHS、RHS
熟悉C++
的选手可能会了解到两个名词:左值 和 右值
左值和右值分别表示 C/C++中赋值运算符两侧的两个值
类似地,JavaScript
在执行过程中也会进行两种查找:LHS 和 RHS
顾名思义,这两种操作也是为了查找(或是计算)赋值运算符两边的东西,比如,在这行代码里:
b = a
变量b
和a
分别会被进行 LHS 和 RHS 查询
但对于以下代码:
// RHS
console.log(a)
(a
并没有被赋值给谁,但需要查找并取到a
的值,所以是 RHS)
和
// LHS和RHS
function fn(a) {
console.log(a)
}
fn(2)
(函数体里的例子同上,fn
调用时需要查找并取到fn
的值,RHS。但其中传参过程中隐含了给a
赋值 2 的操作,所以也有对a
的 LHS)
# 作用域嵌套
对于嵌套的代码(通常是函数、块之间的嵌套),引擎会优先在当前作用域查找,如果查找不到,则逐级向上查找
比如:
var a = 2
function fn() {
console.log(a)
}
fn()
这段代码会顺利地打印出2
LHS 和 RHS 都会沿作用域链进行逐级查找,在查找到顶层(全局作用域)中停止
# 异常
如果某个变量查找不到(在任何作用域中),LHS 和 RHS 的表现是不同的
考虑:
var a = 2
a = b
在这段代码中,在对a
复制时,RHS 无法找到b
的定义,于是会报ReferenceError
错
考虑:
var a = 2
b = a
console.log(b)
在这段代码中,赋值时b
并未被声明,但代码也成功运行并输出了2
,因为LHS如果找不到b
,会“贴心地”帮你创建了变量b
(这绝不是什么好事,除非开启了严格模式)
# 函数作用域和块作用域
# 函数作用域
如果你看懂了LHS 和 RHS 的查找过程,那么函数作用域的概念不难理解:
function fn() {
var a = 1
function fn2() {
var a = 2
console.log(a)
}
}
任何包裹在函数体内的声明,不会影响到外部
函数作用域可以避免内部代码对外部(或全局)作用域的污染,但声明一个函数function fn() {...}
的过程,仍然会给外部暴露一个fn
的函数声明
于是就有了以下的操作:
(function() {
var a = 2
console.log(a)
})()
这是一个立即执行函数(IIFE),原理也很简单:声明一个匿名函数并立即执行它
# 块作用域
如果你习惯了Java
和C++
等语言,你可能会写出下面的JavaScript
代码:
for (var i = 0; i < 10; i++) {
// do something...
}
但这其实很危险,如果你在循环外部访问用于循环的i
,你会发现它依然存在,它会造成类似如下的异常:
function fn() {
function fn2() {
i = 2
}
for (var i = 0; i < 10; i++) {
// 死循环!
}
}
通过搜索引擎或其他人的了解,你可能会被告知一个遗憾的事实:“js 没有块作用域”
# try/catch
ES3 规范中规定了 try/catch 语句中,catch 块会创建一个块作用域:
try {
a = b
} catch (e) {}
console.log(e) // ReferenceError
catch 块传入的变量e
不会在外部暴露
# let
如果细心,你会发现和之前几篇不同,这篇文章到这里之前的全部代码中变量的声明都使用了var
,而非let
let
是 ES6 引入的关键字,可以解救上面循环中危险的var i
声明
使用let
会带来块作用域,来使不熟悉JavaScript
“特性”的程序员写出符合直觉上逻辑的代码(相比Java
和C++
等)
for (let i = 0; i < 10; i++) {
// do something...
}
console.log(i) //ReferenceError
现代的编辑器或ESLint
等规范会要求避免在代码中使用var
,而使用let
和const
# 提升
考虑以下代码:
console.log(a)
var a = 2
它并不会报错,但会输出undefined
(这并不符合Java
和C++
程序员们的“直觉”)
实际上,编译器在遇到var a = 2
时,会将声明与赋值分开处理,声明会被提升到作用域顶部,所以这段代码实际上可以理解为:
var a
console.log(a)
a = 2
同理,函数声明也会被提升:
fn()
function fn() {
console.log('hello')
}
需要注意的是:
- 提升在任何作用域都会发生
- 函数提升优先于变量
# 例外
let
声明的变量不会被提升 这也是let
带来的解救之一函数表达式不会被提升 函数表达式包括赋值给变量的匿名函数和箭头函数
fn() // TypeError fn2() // 同上,但因报错不会执行到这一步 var fn = function() {} var fn2 = () => {}
如果你细心观察,会发现这里的报错是
TypeError
,而不是ReferenceError
这是因为
var fn
在声明后,fn
被赋予了undefined
,对undefined
进行函数调用,则会报错TypeError
同样,如果你将一个具名函数(不是匿名的函数)复制给了变量,名称标识符依然无法被提升使用:
fn() // TypeError fn2() // ReferenceError var fn = function fn2() {}