精读|《你不知道的 JavaScript》上卷:第二章 词法作用域

0 阅读7分钟

精读|《你不知道的 JavaScript》上卷:第二章 词法作用域

摘要

本章详解词法作用域原理,讲解遮蔽效应、evalwith对词法作用域的影响,及其性能危害,夯实 JS 作用域核心基础。

标签

#JavaScript #你不知道的JavaScript #前端基础 #精读笔记

目录

  1. 2.1 词法阶段
  2. 2.2 欺骗词法
  3. 2.2.1 eval
  4. 2.2.2 with
  5. 2.2.3 性能

2.1 词法阶段

之前我们说过大部分标准语言编译器的第一个工作阶段就是词法阶段(就是将代码单词化),词法作用域就是在这个阶段的作用域,作用域由代码写在哪里决定。

javascript

运行

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。

这和其他语言也很相似,函数会创建作用域。引擎查找变量就是根据这种作用域嵌套的规则,作用域查找会在找到的第一个匹配的标识符时停止。在这种多层嵌套的作用域中,可以定义同名的标识符,而较内层的标识符会遮蔽掉外层的标识符,因为引擎查找时是先从本层作用域找的,这就导致在内层作用域找到标识符后就不管外层是否有这个标识符了,这就 “遮蔽效应”。

这里不得不说说全局作用域的标识符了,全局变量会自动成为全局对象的属性(比如浏览器中的 window 对象),这就导致,内层可以间接地访问被遮蔽的全局变量(例如 window.a,即使全局作用域中的 a 被遮蔽依然可以访问),但是如果不是全局变量,又被遮蔽了,那么就没办法访问了。

词法作用域查找只会查找一级标识符,也就是说查找 foo.bar.baz 时词法作用域查找只会查找 foo 这个标识符,而 bar 和 baz 的访问会交给对象属性访问规则。

2.2 欺骗词法

前面我们说到,词法作用域在写代码时就已经确定,但是实际上有两种方法可以在运行时修改(也可以说是欺骗了)词法作用域。但是普遍认为这不是一个好的做法,因为欺骗词法作用域会导致性能下降。

2.2.1 eval

eval 函数可以接受一个字符串作为参数,并在运行时把这个字符串视为一段代码来执行,就好像书写代码时这段代码并不存在一样。在执行 eval 之后,引擎不会在意前面执行的代码是不是动态插入进来的,也就是说这段本来不存在的代码可以改变词法作用域,例子如下:

javascript

运行

function foo(str,a) {
    eval( str );
    console.log( a, b );
}
var b = 2;
foo("var b = 3;", 1 );

我们来分析一下这段代码,这里 eval 函数执行的代码是 b 变量的声明和赋值,那么显然 foo 函数作用域的 b 标识符会遮蔽全局作用域的 b 标识符,这就导致词法作用域被修改,导致这段代码最终输出在控制台的结果为:1,3

在这个例子中 str 是一个确定的字符串,而实际情况中这个字符串的内容是动态的,可能会更加复杂,技术上还可以通过一些技巧直接调用 eval 来使其作用在全局作用域中,并对全局作用域进行修改(作者在这里并没用具体说明这些技巧)。

JavaScript 中还有其他一些动态插入代码的功能,比如 setTimeout () 和 setInterval () 的第一个参数可以是字符串(作者强调这些功能已经过时且并不被提倡,不要使用它们!),new Function () 函数的最后一个参数可以接受字符串,并将其转化为动态生成的函数,这种构建函数的语法比 eval 更安全一些,但是也要尽量避免使用。

eval 函数:eval (str) 该函数的功能是在代码运行时检查 str 字符串是不是可以执行的代码,如果是,就执行这段代码,这也就导致了 eval 函数可以在代码运行时修改词法作用域。

2.2.2 with

JavaScript 中另一个难以掌握的用来欺骗词法作用域的功能是 with 关键字。可以有很多方法来解释 with,作者在这里选择从它如何与被它所影响的词法作用域进行交互这个角度来解释。

with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。比如:

javascript

运行

var obj = {
    a:1,
    b:2,
    c:3
};
//单调乏味的重复
obj.a = 2;
obj.b = 3;
obj.c = 4;
//简单的快捷方式
with( obj ) {
    a = 3;
    b = 4;
    c = 5;
}

这段代码展示了快捷方式的功能,但是使用 with 关键字还可能会带来一些奇怪的问题,我们再看下面的代码:

javascript

运行

function foo(obj){
    with (obj) {
        a = 2;
    }
}
var o1 = {
    a:3
};
var o2 = {
    b:3
};
foo( o1 );
console.log( o1.a );
foo( o2 );
console.log( o2.a );
console.log( a );

这段代码输出的结果:

plaintext

2
undefined
2

从输出的结果我们可以发现一个奇怪的现象,全局作用域中本来应该没有 a 这个变量的,但是为什么第 17 行代码执行后全局作用域中的 a 变量是存在的呢。首先我们排除掉自动创建变量,因为这一行对 a 变量的查询是 RHS,而 RHS 是不可能会自动创建变量的,那么答案显然就是有什么导致了 foo 函数中对 a 的赋值泄露到了全局作用域中,而问题就出在 with 关键字上。

with 可以将一个对象处理为词法作用域,因此这个对象的属性会被定义在这个作用域中的词法标识符。但是这个块内部的正常 var 声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作用域中。也就是说,在 o1 对象中是有 a 标识符的,那么执行 a=2; 时在 o1 的作用域中找到了 a,但是在 o2 对象中没有 a 标识符,我们想想会发生什么?是的,这时引擎会开始在外层作用域查询 a 标识符,显然一直到全局作用域都不会找到 a 标识符,而执行 a=2; 时是 LHS,这会导致在全局作用域中自动创建 a 变量并执行这行代码。

与 eval 函数中只有接受声明代码才会修改词法作用域不同,with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

之所以不推荐使用 eval 和 with,也有它们会被严格模式影响的原因,with 被严格模式完全禁用,eval 在严格模式中,使用声明会将作用域限制在 eval 函数内部。

2.2.3 性能

eval 函数和 with 关键字的使用会导致性能下降,这也是不建议使用的一个原因,一旦引擎在代码中发现了 eval () 或者 with,它只能假设关于标识符位置的判断都是无效的,因为它们可能会修改词法作用域,最坏情况下,这可能会导致所有的优化都变得没有意义,引擎只能放弃优化。


学习小结

本章核心讲解词法作用域,它在编译阶段由代码书写位置确定,存在遮蔽效应;同时介绍了evalwith两种 “欺骗词法” 的方式,以及它们修改作用、带来的性能危害,日常开发需避免使用。下一章将继续讲解函数作用域与块作用域

互动交流

  • 你对本章词法作用域、遮蔽效应还有疑问吗?
  • 平时写代码时,遇到过变量遮蔽、eval相关问题吗?

欢迎评论区一起交流,一起系统啃完《你不知道的 JavaScript》!