细读《你不知道的JavaScript·上卷》1-2 词法作用域

5,109 阅读6分钟

墨言妹带你细读《你不知道的 JavaScript 》系列的世界,深入 JavaScript 语言内部,弄清楚 JavaScript 每一个零部件的用途,知其然更要知其所以然。

导读

在第 1 章中,学习了 作用域,它是一套规则,用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。

作用域共有两种主要的工作模型,一是 词法作用域JavaScript 等);二是 动态作用域bash 脚本等)。

词法作用域

  • 什么是词法作用域
  • 为什么废弃欺骗词法作用域的两种机制

2.1 词法阶段

在第1章学习了,大部分标准语言编译器的 第一个工作阶段 是词法化( token 化 )。

词法作用域 就是在词法分析时定义的作用域,即在写代码时,由变量和块作用域的位置决定的。因此,在词法分析时也是固定不变的了(不考虑欺骗词法作用域情况)。

下面这段示例代码有三个嵌套作用域:

  • 圈1 包含了全局作用域,只有一个标识符号 foo
  • 圈2 包含 foo 作用域,有三个标识符 abarb
  • 圈3 包含 bar 作用域 ,有一个标识符 c

作用域的范围 是根据作用域代码块定义的位置决定的,在这里每个函数创建了一个作用域。

这里作用域嵌套是严格的,一个函数不能同时存在于两个外部函数中。

2.1.1 查找

  • 作用域查找会在找到第一个匹配的标识符时停止。

  • 遮蔽效应:在多层嵌套作用域中可以定义同名的标识符,内部的标识符会 遮蔽 外部的标识符。

  • 全局变量是全局对象的属性,被覆盖的非全局对象则无法被访问到。

    window.a
    
  • 词法作用域查找只会查找一级标识符, 比如 abc 。如果代码中引用了 foo.bar.baz ,词法作用域只会查找 foo 标识符,找到后,对象属性访问规则 会分别接管对 barbaz 属性的访问。

2.2 欺骗词法

欺骗词法作用域 会导致性能下降,以下两种方法都 不推荐使用

2.2.1 eval

  • eval(...) 函数可以接受一个字符串作为参数,并把字符串的内容当作代码运行,从而实现对词法作用域环境的修改。
  • 在执行 eval() 之后的那些代码,引擎不知道、也不去关心前面的代码是 动态编译 的,且 修改 了词法作用域环境。引擎只会一如既往地进行词法作用域查找。
非严格模式下:
function foo(a, str){
        console.log(str);           //2       // var b = 3;
	eval(str); // 欺骗!
	console.log(eval(str));     //2      //undefined
	console.log(a, b);          //0   2  // 1  3
	console.log(a, window.b);   //0   2  //1  2
}
var b = 2;
foo(0, b);
foo(1, " var b = 3 ;"); 
  • eval() 被调用时,字符串参数 “ var b = 3; ” 被当作真正的代码声明了变量 b ,并修改了 foo() 的词法作用域。在 foo() 内部创建了一个变量 b , 遮蔽了外部全局作用域中的同名变量 b
  • console.log() 被执行时,会在 foo() 的内部同时找到 ab , 但是永远也无法找到外部的 b 。因此会输出 1 , 3 ,而不是正常情况下会输出的 1 ,2

拓展 eval() 函数,理解值为 undefiend 的知识,请点击如下:

MDN 解析 eval() 案例

js中的eval方法详解(一)

eval()函数的使用

严格模式下:
function foo(a, str){
    "use strict";
     console.log(str);           //2       // var b = 3;
     eval(str); 
     console.log(eval(str));    //2       //undefined
     console.log(a, b);         //0  2    // 1  2
     console.log(a, window.b);  //0  2    //1  2
}
var b = 2;
foo(0, b);
foo(1, " var b = 3 ;");
  • eval() 在严格模式下,有自己的词法作用域,其中的声明无法修改作用域。
  • setTimeout(...)setInterval(...) 的第一个参数可以是字符串,字符串的内容会被解释为一段动态生成的函数代码。已废弃使用
  • 构造函数new Function()的最后一个参数可以接受代码字符串(前面的参数是新生成的函数的形参), 避免使用

2.2.2 with

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

var obj = {
	a:1,
	b:2,
	c:3
};

//单调乏味的重复“ obj ”
obj.a = 2;
obj.b = 3;
obj.c = 4;

//简单的快捷方式
with(obj){
	a = 3;
	b = 4;
	c = 5;
}

不仅仅是一个属性访问的 快捷方式 。如下:

function foo(obj){
	with(obj){
		a = 2;
	}
}

var o1 = {
	a : 3
};

var o2 = {
	b : 4
};

console.log(o1.a);//3
foo(o1);
console.log(o1.a);//2

foo(o2);
console.log(02.a);//undefined
console.log(a);//2 -> 不好,a 被泄露到全局作用域上了!
  • o1 传进后,with 声明的作用域是 o1a = 2 赋值操作找到 o1.a 并将 2 赋值给它。
  • o2 传进后,作用域 o2 中没有 a 属性,则进行 LHS 标识符查找,o2 的作用域、 foo() 的作用域 和全局作用域都没找到标识符 a ,因此当 a = 2 执行时,产生副作用,自动创建了一个全局变量(非严格模式)a ,并将 2 赋值给 a ,所以 o2.a 保持 undefined

在严格模式下,with 语句被完全禁用,eval() 则只保留核心功能,都不推荐使用。

2.2.3 性能

JavaScript 引擎在 编译阶段 进行各种性能优化,一些优化在词法分析阶段,静态分析了代码,预先确定了变量和函数声明的位置,所以在执行期间就可以快速解析标识符。

2.3小结

词法作用域只由函数被声明时所处的位置决定。

以下两个机制可以 欺骗 词法作用域:

  • eval(...) : 对一段包含一个或多个声明的 代码 字符串进行演算,借此来修改已经存在的词法作用域(运行时)。
  • with : 将一个对象的引用 当作 作用域,将对象的属性当作作用域的标识符,创建一个新的词法作用域(运行时)。

副作用 是引擎无法在编译时对作用域查找进行优化。因为引擎只能谨慎地认为这样的优化是无效的,使用任何一个机制都将导致代码运行变慢。废弃它们。

最后, 读书是由厚到薄,又由薄到厚的双向过程,注重领悟、实践,不断踩坑、提升,若有帮助,请点个赞,谢谢您的支持与指教。

参考文献:

木易杨博客

隙游尘博客

taopoppy 博客

历史文章:

细读《你不知道的JavaScript·上卷》1-1 作用域是什么

【译】30 Seconds of ES6 (一)