重学 JavaScript[4] 作用域

提起作用域,JavaScript 做题家会想起很多相关的八股文:let 和 var 的区别、函数作用域、块作用域、闭包等等,甚至还会扯上很多编译原理的知识(很可惜我没学过)

有关编译原理

JavaScriptPython这类由解释器运行的脚本语言,似乎不需要编译的过程

但解释器(或引擎)在执行代码时,会即时地进行编译,这个过程大约会发生在执行前很短的时间内

传统编译语言编译时大致会有三个步骤:

  • 词法分析(Tokenizing)
    将代码字符串分解为词法单元(token)
  • 语法分析(Parsing)
    将词法单元数组转换成抽象语法树(AST)
  • 代码生成
    根据 AST 生成可执行代码(机器指令)

LHS、RHS

熟悉C++的选手可能会了解到两个名词:左值右值

左值和右值分别表示 C/C++中赋值运算符两侧的两个值

类似地,JavaScript在执行过程中也会进行两种查找:LHSRHS

顾名思义,这两种操作也是为了查找(或是计算)赋值运算符两边的东西,比如,在这行代码里:

b = a

变量ba分别会被进行 LHSRHS 查询

但对于以下代码:

// 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

LHSRHS 都会沿作用域链进行逐级查找,在查找到顶层(全局作用域)中停止

异常

如果某个变量查找不到(在任何作用域中),LHSRHS 的表现是不同的

考虑:

var a = 2
a = b

在这段代码中,在对a复制时,RHS 无法找到b的定义,于是会报ReferenceError

考虑:

var a = 2
b = a
console.log(b)

在这段代码中,赋值时b并未被声明,但代码也成功运行并输出了2,因为LHS如果找不到b,会“贴心地”帮你创建了变量b(这绝不是什么好事,除非开启了严格模式)

函数作用域和块作用域

函数作用域

如果你看懂了LHSRHS 的查找过程,那么函数作用域的概念不难理解:

function fn() {
    var a = 1
    function fn2() {
        var a = 2
        console.log(a)
    }
}

任何包裹在函数体内的声明,不会影响到外部

函数作用域可以避免内部代码对外部(或全局)作用域的污染,但声明一个函数function fn() {...}的过程,仍然会给外部暴露一个fn的函数声明

于是就有了以下的操作:

(function() {
    var a = 2
    console.loga)
})()

这是一个立即执行函数(IIFE),原理也很简单:声明一个匿名函数并立即执行它

块作用域

如果你习惯了JavaC++等语言,你可能会写出下面的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.loge) // ReferenceError

catch 块传入的变量e不会在外部暴露

let

如果细心,你会发现和之前几篇不同,这篇文章到这里之前的全部代码中变量的声明都使用了var,而非let

let是 ES6 引入的关键字,可以解救上面循环中危险的var i声明

使用let会带来块作用域,来使不熟悉JavaScript“特性”的程序员写出符合直觉上逻辑的代码(相比JavaC++等)

for (let i = 0; i < 10; i++) {
    // do something...
}

console.log(i) //ReferenceError

现代的编辑器或ESLint等规范会要求避免在代码中使用var,而使用letconst

提升

考虑以下代码:

console.log(a)
var a = 2

它并不会报错,但会输出undefined(这并不符合JavaC++程序员们的“直觉”)

实际上,编译器在遇到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() {}