作用域
编程语言的基础功能:储存变量当中的值, 并且能在之后对这个值进行访问或修改, 将变量引入程序会引起几个问题, 这些变量储存在哪里? 程序需要时如何找到它们?
一、编译原理
1.编译语言分类
计算机不能理解高级语言,更不能直接执行高级语言,它只能直接理解机器语言(二进制代码),所以使用任何高级语言编写的程序若想被计算机运行,都必须将其转换成计算机语言,也就是机器码。而这种转换的方式有两种:编译、解释 . 由此高级语言也分为编译型语言和解释型语言。
主要区别在于,编译语言源程序编译后即可在该平台运行,解释语言是在运行期间才编译。所以前者运行速度快,后者跨平台性好。
1.1编译型语言
使用专门的编译器,针对特定的平台,将高级语言源代码一次性的编译成可被该平台硬件执行的机器码,并包装成该平台所能识别的可执行性程序的格式。
优点:
运行速度快,代码效率高,编译后程序不可以修改,保密性好。
缺点:
- 代码需要经过编译方可运行,可移植性差,只能在兼容的操作系统上运行;
- 安全性不如解释性语言,一个编译型的程序可以访问内存的任何区域,并且可以对你的PC做它想做的任何事情(大部分病毒是使用编译型语言编写的)。
编译流程
在传统编译语言的流程中, 程序中的一段源代码在执行之前会经历三个步骤, 统称为“ 编译” .
- 词法分析: 将字符串分解成( 对编程语言来说)有意义的代码块, 这些代码块被称为词法单元(token)
- 语法分析: 将词法单元流( 数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST).
- 代码生成: 将AST转换为可执行代码的过程称被称为代码生成 .
1.2解释型语言
使用专门的解释器对源程序逐行解释成特定平台的机器码并立即执行。是代码在执行时才被解释器一行行动态翻译和执行,而不是在执行之前就完成翻译。
优点:
- 解释型语言提供了极佳的调试支持。
- 解释器比编译器容易实现。
- 中间语言代码的大小比编译型可执行代码小很多。例如,C/C++的.exe文件要比同样功能的Java的.class文件大很多。
- 可移植性好,只要有解释环境,可以在不同的操作系统上运行。比如在解释执行时可以动态改变变量的类型、对程序进行修改以及在程序中插入良好的调试诊断信息等,而将解释器移植到不同的系统上,则程序不用改动就可以在移植了解释器系统上运行。
- 解释型语言也可以保证高度的安全性—这是互联网应用迫切需要的
缺点:
- 运行需要解释环境,程序严重依赖平台。
- 运行起来比编译的要慢,占用的资源也要多一些,代码效率低。因为不仅要给用户程序分配空间,解释器本身也占用了宝贵的系统资源。
- 由于解释型应用的decode-fetch-execute(解码-抓取-执行)的周期,它们比编译型程序慢很多。
2.javascript
尽管通常将JavaScript归类为“ 动态”或“ 解释执行”语言, 但事实上它是一门编译语言. 但与传统的编译语言不同, 它不是提前编译的, 编译结果也不能在分布式系统中进行移植。
二、理解作用域
简单理解: 作用域是根据名称查找变量的一套规则.
1.涉及作用域的几个角色
- 引 擎 从头到尾负责整个JavaScript程序的编译及执行过程
- 编译器 负责语法分析及代码生成等
- 作用域 负责收集并维护由所有声明的标识符( 变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限
变量的赋值操作会执行两个动作, 首先编译器会在当前作用域中声明一个变量( 如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量, 如果能够找到就会对它赋值。
2.编译器的LHS与RHS
LHS与RHS, “L”和“R”分别代表一个赋值操作的左侧和右侧。换句话说,当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。讲得更准确一点,RHS查询与简单地查找某个变量的值别无二致, 而LHS查询则是试图找到变量的容器本身, 从而可以对其赋值。 从这个角度说,RHS并不是真正意义上的“ 赋值操作的右侧”,更准确地说是“非左侧”。
2.1 LHS查询
LHS查询指的是找到变量的容器本身,从而可以对其进行赋值。也就是找到赋值操作的目标。
需要知道的是:LHS查询的时候会沿着作用域链进行查询,找到的话就会将值赋值给这个变量,如果到达作用域顶端仍然找不到,就会在作用域链顶端创建这个变量。
例如: var a = 2
, 相当于先声明 var a 在作用域中, 然后再执行 a = 2 的赋值操作. 这里的a就是一个LHS引用,我们只是想要为2找到一个赋值的目标,而不会去关心这个目标(a)的值是多少什么的. 因为 var a ; 也就是已经把a添加到当前作用域了,所以LHS查询a的时候,找到了a,即找到了赋值操作的目标。 如果没有 var a ;那么LHS查询a的时候,就会找不到a,就会在作用域中创建这个变量a。
2.2 RHS查询
RHS查询就是普通的查询变量的值,即获取变量的值。
RHS查询的时候会沿着作用域链进行查询,找到的话就会取得这个值并返回,如果到达作用域顶端仍然找不到,就会抛出错误(比如TypeError)。
例如: console.log(a)
,a就是一个RHS引用,因为 console.log 需要获取到a的值才能输出a的值. 同时这里 console.log 也是一个RHS引用,这里对 console 对象进行RHS 查询,并且检查得到的值中是否有一个叫作 log 的方法.
2.3 LHS 与 RHS 区别
区分 LHS 和 RHS 的重要意义在于, 在变量还没有声明( 在任何作用域中都无法找到该变量)以及接下来的操作中, 这两种查询的行为是不一样的.
- 当引擎执行LHS查询时, 如果在顶层( 全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量, 并将其返还给引擎, 前提是程序运行在非 “严格模式” 下。
- 当引擎执行RHS查询时, 如果在所有嵌套的作用域中遍寻不到所需的变量, 就会抛出ReferenceError异常。接下来, 如果RHS查询找到了一个变量, 但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用, 或着引用null或undefined类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作TypeError.
: ReferenceError同作用域判别失败相关, 而TypeError则代表作用域判别成功了, 但是对结果的操作是非法或不合理的。 严格模式: ES5中引入了“ 严格模式.。同正常模式, 或者说宽松/懒惰模式相比,严格模式在行为上有很多不同。 其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量.
2.4 综合例子
一般程序的运行既有LHS也有RHS引用.
(function test() {
a=2;
});
console.log(a);
上面例子中 test方法的a=2会进行LHS查询,发现沿着作用域链找不到a,这里的作用域链顶端就只是括着test的()内的作用域,而不是全局作用域。 因此,找不到变量a,就会在作用链顶端也就是括着test的()内创建一个变量a。
在console.log(a)语句中对a进行RHS查询时,沿着作用域链查找,找不到a,所以会抛出错误。
(function test() {
a=2;
})();
console.log(a);
执行上面代码之后, 会发现并不会抛出错误, 这是因为 test 函数运行之后 , LHS沿着作用域查询变量 a , 最终在作用链顶端全局作用域创建了变量 a. 因此当 console.log(a)中对a进行RHS查询时,沿着作用域链查找就可以在全局作用域中找到a了,也就可以取得a的值了。
综上所诉,可以考虑如下代码结果
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
// ReferenceError
function foo(a) {
b = a;
console.log( a + b );
}
foo( 2 );
// 4
3.作用域嵌套
在实际的程序运行中, 时常会一个块或函数嵌套在另一个块或函数中, 这就发生了作用域的嵌套。
如下代码:
function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4
作用域查找可视化如下:
遍历嵌套作用域链的规则很简单: 引擎从当前的执行作用域开始查找变量, 如果找不到,就向上一级继续查找。 当抵达最外层的全局作用域时, 无论找到还是没找到, 查找过程都会停止。
三、词法作用域
3.1 词法
词法作用域就是定义在词法阶段 ( 编译器的第一个工作阶段 ) 的作用域。 换句话说, 词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的, 因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
function foo (a) {
var b = a * 2;
function bar ( c ) {
console.log(a, b, c);
}
bar(b * 3);
}
foo(2)
如上图, 每个变量的作用域气泡由其对应的作用域块代码写在哪里决定, 它们是逐级包含的. 代码中bar的气泡被完全包含在foo所创建的气泡中, 唯一的原因是那里就是我们希望定义函数bar的位置。
遮蔽
作用域查找始终从运行时所处的最内部作用域开始, 逐级向外或者说向上进行, 作用域的逐级向上查找会在找到第一个匹配的标识符时停止。 在多层的嵌套作用域中可以定义同名的标识符, 这叫作“ 遮蔽效应”(内部的标识符“ 遮蔽”了外部的标识).
全局变量
全局变量会自动成为全局对象( 比如浏览器中的window对象)的属性, 因此可以不直接通过全局对象的词法名称, 而是间接地通过对全局对象属性的引用来对其进行访问.
var a = 2 ;
window.a ;
window.a通过这种技术可以访问那些被同名变量所遮蔽的全局变量。 但非全局的变量如果被遮蔽了,无论如何都无法被访问到。
只查找一级标识符
词法作用域查找只会查找一级标识符, 比如a、b和c。如果代码中引用了foo.bar.baz,词法作用域查找只会试图查找foo标识符, 找到这个变量后, 对象属性访问规则会分别接管对bar和baz属性的访问。
3.2 欺骗词法
虽然词法作用域完全由写代码期间函数所声明的位置来定义, 但是我们也可以在运行时来“ 修改” (也可以说欺骗)词法作用域. JavaScript中有两种机制来实现这个目的。
3.2.1 eval
JavaScript中的eval(..)函数可以接受一个字符串为参数, 并将其中的内容视为好像在书写时就存在于程序中这个位置的代码.
function foo (str, a) {
eval(str);
console.log( a, b );
}
var b = 2;
foo(" var b = 3", 1) // 1 3
在上面的代码示例中, eval(..)调用中的"var b = 3;"这段代码会被当作本来就在那里一样来处理, 即这段代码实际上是在foo(..)内部创建了一个变量b,并遮蔽了外部(全局)作用域中的同名变量, 由于代码声明了一个新的变量b,因此它对已经存在的foo(..)的词法作用域进行了修改.
严格模式下的eval
在严格模式的程序中,eval(..)在运行时有其自己的词法作用域, 意味着其中的声明无法修改所在的作用域。
function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
3.2.2 with
with通常被当作重复引用同一个对象中的多个属性的快捷方式, 可以不需要重复引用对象本身。
var obj = {
a: 1,
b: 2,
c: 3,
}
// 重复引用obj
obj.a = 4;
obj.b = 5;
obj.c = 6;
with (obj) {
a = 7;
b = 8;
c = 9;
}
with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域, 因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。尽管with块可以将一个对象处理为词法作用域, 但是这个块内部正常的var声明并不会被限制在这个块的作用域中, 而是被添加到with所处的函数作用域中。
function foo(obj) {
with (obj) {
a = 2;
}
}
let o1 = {
a: 3
}
let o2 = {
b: 3
}
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a被泄漏到全局作用域上了!
let o3 = {
c: 4
}
with (o3) {
d = 4
}
console.log(o3.d) // undefined
console.log(d) // 4
在上面示例代码中, 。foo(..)函数接受一个obj参数,该参数是一个对象引用,并对这个对象引用执行了with(obj) {..}。 当我们将o1传递进去,a=2赋值操作找到了o1.a并将2赋值给它, 这在后面的console.log(o1.a)中可以体现。 而当o2传递进去,o2并没有a属性, 因此不会创建这个属性,o2.a保持undefined。但是这样却产生了一个副作用, 就是创建了一个一个全局的变量a。
可以这样理解, 当我们传递o1给with时,with所声明的作用域是o1,而这个作用域中含有一个同o1.a属性相符的标识符。 但当我们将o2作为作用域时, 其中并没有a标识符,因此进行了正常的LHS标识符查找。o2的作用域、foo(..)的作用域和全局作用域中都没有找到标识符a,因此当a=2执行时,自动创建了一个全局变量(因为是非严格模式)。
3.2.3 性能
eval(..)和with会在运行时修改或创建新的作用域, 以此来欺骗其他在书写时定义的词法作用域。前者可以对一段包含一个或多个声明的“ 代码”字符串进行演算, 并借此来修改已经存在的词法作用域( 在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理, 将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。
JavaScript引擎会在编译阶段进行数项的性能优化。 其中有些优化依赖于能够根据代码的词法进行静态分析, 并预先确定所有变量和函数的定义位置, 才能在执行过程中快速找到标识符。 但如果引擎在代码中发现了eval(..)或with,它只能简单地假设关于标识符位置的判断都是无效的, 因为无法在词法分析阶段明确知道eval(..)会接收到什么代码, 这些代码会如何对作用域进行修改,也无法知道传递给with用来创建新词法作用域的对象的内容到底是什么。
四、函数作用域和块级作用域
作用域包含了一系列的“ 气泡” 容器, 其中包含了标识符( 变量、 函数)的定义。 这些气泡互相嵌套并且整齐地排列成蜂窝型,排列的结构是在写代码时定义的。在Javascript中能生成气泡的结构有哪些呢?
4.1 函数作用域
函数作用域的含义是指, 属于这个函数的全部变量都可以在整个函数的范围内使用及复用( 事实上在嵌套的作用域中也可以使用 ). JavaScript具有基于函数的作用域, 意味着每声明一个函数都会为其自身创建一个作用域的气泡. 如下代码:
function foo (a){
var b = 1;
function bar () {
}
var c = 3;
}
bar() // ReferenceError
console.log(a, b, c) // ReferenceError
foo(..)的作用域气泡中包含了标识符a、b、c和bar。bar(..)拥有自己的作用域气泡。全局作用域也有自己的作用域气泡, 它只包含了一个标识符:foo。 由于标识符a、b、c和bar都附属于foo(..)的作用域气泡, 因此无法从foo(..)的外部对它们进行访问。 也就是说, 这些标识符全都无法从全局作用域中进行访问,但是在在foo(..)的内部都是可以被访问的, 同样在bar(..)内部也可以被访问(假设bar(..)内部没有同名的标识符声明.
4.1.1 作用
对函数的传统认知就是先声明一个函数, 然后再向里面添加代码。 但反过来想也可以带来一些启示: 从所写的代码中挑选出一个任意的片段, 然后用函数声明对它进行包装, 实际上就是把这些代码“隐藏”起来了。隐藏”变量和函数是一个有用的技术?
代码安全
从最小安全原则上来讲, 在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的API设计。
function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15
在上面代码片段中, 变量b和函数doSomethingElse(..)应该是doSomething(..)内部具体实现的“ 私有”内容。 给予外部作用域对b和doSomethingElse(..)的“ 访问权限”不仅没有必要, 而且可能是“ 危险”的, 因为它们可能被有意或无意地以非预期的方式使用,从而导致超出了doSomething(..)的适用条件。 更“ 合理”的设计会将这些私有的具体内容隐藏在doSomething(..)内部。
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15
修改后的代码 b 和 doSomethingElse(..) 都无法从外部被访问, 而只能被doSomething(..)所控制。功能性和最终效果都没有受影响, 但是设计上将具体内容私有化了, 设计良好的软件都会依此进行实现。
规避冲突
隐藏”作用域中的变量和函数所带来的另一个好处, 是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样, 无意间可能造成命名冲突。 冲突会导致变量的值被意外覆盖。
function foo() {
function bar(a) {
i = 3; // 修改for循环所属作用域中的i
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 糟糕,无限循环了!
}
}
foo();
上面代码示例中, bar(..)内部的赋值表达式i = 3意外地覆盖了声明在foo(..)内部for循环中的i, 因为i被固定设置为3,永远满足小于10这个条件, 导致无限循环。解决办法有两种: 一是在bar(..)内部的赋值操作声明一个本地变量来使用,采用任何名字都可以,例如 var i = 3; 另一种是采用一个完全不同的标识符名称,比如var j = 3.
4.1.2 立即执行函数
在任意代码片段外部添加包装函数, 可以将内部的变量和函数定义“ 隐藏”起来,外部作用域无法访问包装函数内部的任何内容。但是这样会带来两个问题:第一 需要声明一个这样作用的具名函数如下面例子的 foo(), 这样造成了foo对所在作用域的污染; 第二 必须显式地通过函数名(foo())调用这个函数才能运行其中的代码。
var a = 2;
function foo() {
// <-- 添加这一行
var a = 3;
console.log( a ); // 3
}
// <-- 以及这一行
foo();
// <-- 以及这一行
console.log( a ); // 2
JavaScript提供了能够同时解决这两个问题的方案: 函数表达式.
var a = 2;
(function foo(){ // <-- 添加这一行
var a = 3;
console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2
比较一下上面两个两个代码片段。第一个片段中foo被绑定在所在作用域中,可以直接通过foo()来调用它。第二个片段中foo被绑定在函数表达式自身的函数中而不是所在作用域中。换句话说,(function foo(){ .. })作为函数表达式意味着foo只能在..所代表的位置中被访问, 外部作用域则不行。foo变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
区分函数声明和表达式最简单的方法是看function关键字出现在声明中的位置( 不仅仅是一行代码, 而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
函数与函数表达式
函数表达式可以是匿名的,而函数声明则不可以省略函数名——在JavaScript的语法中这是非法的。最常见的匿名函数表达式莫过于回调参数了;
setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );
匿名函数的缺点:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难
- 如果没有函数名, 当函数需要引用自身时只能使用已经过期的arguments.callee引用,比如在递归中。 另一个函数需要引用自身的例子, 是在事件触发后事件监听器需要解绑自身
- 匿名函数省略了对于代码可读性/可理解性很重要的函数名。 一个描述性的名称可以让代码不言自明。
始终给函数表达式命名是一个最佳实践:
setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
console.log( "I waited 1 second!" );
}, 1000 );
立即执行函数表达式(Immediately Invoked Function Expression)
函数被包含在一对( )括号内部,因此成为了一个表达式,通过在末尾加上另外一个( )可以立即执行这个函数, 代表立即执行函数表达式.
var a = 2;
(function IIFE() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
相较于传统的IIFE形式, 很多人都更喜欢另一个改进的形式:(function(){ .. }()) 这两种形式在功能上是一致的。选择哪个全凭个人喜好。
var a = 2;
(function IIFE() {
var a = 3;
console.log( a ); // 3
}());
console.log( a ); // 2
IIFE的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。
var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
IIFE还有一种变化的用途是倒置代码的运行顺序, 将需要运行的函数放在第二位, 在IIFE执行之后当作参数传递进去。
var a = 2;
(function IIFE( def ) {
def( window );
})(
function def( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
}
);
函数表达式def定义在片段的第二部分, 然后当作参数( 这个参数也叫作def)被传递进IIFE函数定义的第一部分中。 最后, 参数def(也就是传递进去的函数)被调用, 并将window传入当作global参数的值。
4.2 块级作用域
JavaScript除了函数作用域外还存在块作用域.块作用域是一个用来对之前的最小授权原则进行扩展的工具, 将代码从在函数中隐藏信息扩展为在块中隐藏信息。
for循环中的块级作用域
for (var i=0; i<10; i++) {
console.log( i );
}
with中的块级作用域
用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。
try/catch创建的块级作用域
try {
undefined(); // 执行一个非法操作来强制制造一个异常
} catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found
let关键字定义声明的变量
let关键字可以将变量绑定到所在的任意作用域中(通常是{ .. }内部) 。换句话说,let为其声明的变量隐式地附加在了所在的块作用域。 let与var的区别, 如下代码:
var flag = true;
if(flag){
var flaga = 11;
}
console.log(flaga) // 11
if(flag){
let flagb = 22;
}
console.log(flagb) // flagb is not defined
const关键字
const,同样可以用来创建块作用域变量, 但其值是固定的(常量) 。之后任何试图修改值的操作都会引起会引起.
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在if中的块作用域常量
a = 3; // 正常!
b = 4; // 错误!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
笔记总结来源《你不知道的Javascript》
如有问题,欢迎探讨,如果满意,请手动点赞,谢谢!?
及时获取更多姿势,请您关注!!
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!