最近在读《你不知道的 JavaScript(上卷)》,做了一些学习笔记,分享出来。

作用域

JavaScript 中的作用域是什么?

作用域是一套规则,用来确定在何处以及如何查找变量(标识符)。

全局作用域、函数作用域和块作用域

JavaScript 中的作用域主要有全局作用域、函数作用域和块作用域。

全局作用域就是最外层的作用域,其余所有的作用域都嵌套在全局作用域中。

每个函数内部也会创建自己的作用域。

在两个花括号之间({…}),可以创建块作用域,后面会有介绍。

作用域嵌套

考虑以下代码:

1
2
3
4
5
6
var a = 1
function foo() {
console.log(a) // 1
}

foo()

执行这段代码时,会输出 1。调用 foo 时,在其中又 console.log,输入了变量 a 作为参数,于是引擎在 foo 函数的作用域中查找 a,foo 函数的作用域中没有 a 标识符,则引擎继续往上一级的作用域中查找 a 标识符,最终在全局作用域中找到了它。

这个过程就叫作用域查找。

作用域查找会在找到第一个匹配的标识符时停止,在多层的作用域嵌套中可以定义相同名字的标识符,也就是变量名。这样的设计有助于防止在开发时不小心定义了与外层作用域相同的变量名,对外层的变量进行了修改。

1
2
3
4
5
6
7
var a = 1
function foo() {
var a = 2
console.log(a) // 2
}

foo()

词法作用域和动态作用域

JavaScript 中使用的是词法作用域。与词法作用域相对的有动态作用域。

什么是词法作用域?简单地说就是,一个变量所处的作用域,在这个变量写下来的时候就确定好了;而动态作用域则是在调用函数的时候再去确定函数或者变量的作用域。

shell 脚本就是使用动态作用域的语言,如下:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
a=1
foo() {
local a=2; #定义局部变量 a 并赋值 2
bar #调用函数 bar
}

bar() {
echo $a #打印变量 a,输出 2
}

foo #调用函数 foo

相同的代码结构,用 JavaScript 写出来:

1
2
3
4
5
6
7
8
9
10
11
var a = 1
function foo() {
var a = 2
bar()
}

function bar() {
console.log(a) // 1
}

foo()

JavaScript 使用的是词法作用域模型,在 bar 函数的作用域在它被写下来的时候就决定好了,就是全局作用域。在调用 bar 函数时,会先在 bar 函数的作用域中查询 a 标识符,查询不到,所以接着往 bar 函数的上一级作用域查询,也就是全局作用域中查询 a,全局作用域中存在 a 标识符,a 变量的值就为 1,打印 1。

而 shell 脚本中的 bar 函数在调用时查找 a 变量,bar 函数的作用域中查询不到 a 标识符,则在 bar 函数执行时的 foo 函数的作用域中查找,在 foo 函数的作用域中查找到 a,值为 2,所以打印出 2。

当然也有例外。有两种方式可以改变作用域。

第一种:eval()

考虑如下代码:

1
2
3
4
5
6
7
8
var a = 1

function foo() {
eval('var a = 2')
console.log(a) // 2
}

foo()

运行上面的代码会输出 2,是因为 eval 函数修改了 foo 函数的作用域,在 foo 函数的作用域中声明了一个 a 变量,所以输出了 2。

如果把传入 eval 的字符串用一个变量代替,则可以在函数外部修改函数内部的作用域:

1
2
3
4
5
6
7
8
9
10
var a = 1

function foo(scopeStr) {
eval(scopeStr)
console.log(a) // 2
}

var scopeStr = 'var a = 2'

foo(scopeStr)

如果 scopeStr 的值由请求后端获得,而网络又被劫持了,则代码会存在安全风险,攻击者可以在你的网页中运行一些恶意代码。

微信小程序中就不支持 eval 函数。

第二种:with 语句

with 语句可以在当前作用域创建一个全新的作用域。

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
var obj = { a: 2 }

with (obj) {
a = 1
b = 2
}

console.log(obj.a) // 1
console.log(obj.b) // undefined
console.log(window.b) // 2

上面的代码中,把 obj 传入给 with 语句,再在 with 语句中给变量 a 赋值,此时引擎会在当前作用域寻找 a 标识符,在传入的 obj 对象中,找到了它有同名属性,于是修改了 obj 对象中的属性 a 的值。但是 obj 没有定义属性 b,引擎就继续往上层作用域查找 b 标识符,发现全局作用域中也没有 b 标识符,于是在全局作用域中创建一个变量 b,并赋值 2。所以可以使用全局对象 window 访问到属性 b。

在 with 语句的花括号中,不需要对变量做显式的赋值,如 obj.a = 1,也可以对 obj 的属性进行修改,就像是在当前作用域中又创建了一个 “局部的全局作用域”,此作用域的顶级对象就是传入的对象,在上面的代码中就是 obj。

不要使用 eval() 和 with

在所有 JavaScript 书籍中,都不建议开发者使用 eval 和 with。

JavaScript 引擎在编译阶段时会对代码进行优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。

但如果引擎在代码中发现了 eval() 或 with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval() 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。

最悲观的情况是如果出现了 eval() 或 with,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。

正如以上书中的解释所说,JavaScript 在编译的时候会对代码进行优化。

在严格模式中,with 将被禁止,eval 方法在运行时有自己的词法作用域。

所以我们在开发中应该尽量避免使用 eval() 和 with。

块作用域

块作用域通常是指花括号({…})包括的部分。

对于经常使用 JavaScript 开发的开发者来说,平时接触得最多的就是函数作用域以及全局作用域,块作用域可能是比较陌生的。

思考如下 C 语言代码:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(){
int a = 2;
if(1){
int a = 1;
}
printf("%d\n", a); // 2
return 0;
};

再思考相似结构的 javaScript 代码:

1
2
3
4
5
6
7
8
9
function main() {
var a = 2
if (true) {
var a = 1
}
console.log(a) // 1
}

main()

C 语言中有块作用域,当 int a = 1; 在 if 语句中声明时,该变量 a 的作用域就只在 if 语句的 {...} 块中有效,不会影响到 main 函数的作用域中的同名变量。

而在上面的 JavaScript 代码中,if 语句中声明的 a 变量对 main 函数作用域中的 a 变量进行了重新赋值,if 语句中声明的变量并不只在 if 语句中有效,甚至影响到了上层的作用域的同名变量。

ES6 中引入了 let 关键字,可以把变量绑定到任意的作用域中。

1
2
3
4
5
6
7
8
9
function main() {
var a = 2
if (true) {
let a = 1
}
console.log(a) // 2
}

main()

let 也可以用在 for 循环中:

1
2
3
4
5
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i)
}, 0)
}

运行以上代码会依次输出 0、1、2。

ES6 的 const 关键字也有类似的效果,不同的是 const 定义的变量是不可重新赋值的。

上文提到的 with 语句,也是块作用域的一个例子。

try/catch 分句中,也会创建一个块作用域。

1
2
3
4
5
6
7
try {
throw 1
} catch (err) {
console.log(err) // 1
}

console.log(err) // 报错

小结

作用域是一套规则,用来确定在何处以及如何查找变量(标识符);

作用域分为全局作用域、函数作用域和块作用域;

作用域可嵌套,寻找标识符时将会一层层往上寻找,直到全局作用域;

JavaScript 中使用的是词法作用域。变量所处的作用域,在这个变量写下来的时候就确定好了;

eval() 方法可修改作用域;

with 关键字可新增作用域;

JavaScript 中也含有块作用域(with、try/catch)。