作用域
- 首先知道,作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,哪么就会使用LHS查询,如果目的是获取变量的值,就会使用RHS查询。赋值操作符会导致LHS查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的复制操作。
编译原理
- JS它是一门编译语言。在传统的编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
- 分词/词法分析。这个过程会由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如:var a = 2;会被分成var,a,=,2,;。
- 解析/语法分析。这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树被称为“抽象语法树”。
- 代码生成。将AST转换为可执行代码的过程被称为代码生成。
理解作用域
- 引擎可以根据需要创建并储存变量,它从头到尾负责整个JavaScript程序的编译及执行过程。
- 编译器,负责语法分析及代码生成等脏活累活(编译原理)
- 作用域,负责收集并维护有所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标示符的访问权限。
- 变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
编译器
- 编译器在编译过程的第二步中生成了代码,引擎执行它时,会用过查找变量a来判断是否已经声明过了。查找的过程由作用域进行协,但是引擎执行这样的查找,会影响最终得查找结果。
- 引擎会为变量a进行LHS查询。另一个查找的类型RHS。
- LHS查找是试图找到变量的容器本身,从而可以对其赋值,理解成“赋值操作的目标是谁”。RHS查询与简单地查找,某个变量的值别无二致,理解成“谁是赋值操作的源头”。
- JavaScript引擎首先会在代码执行前对其进行编译,在这个过程中,像var a = 2这样的声明会被分解成两个独立的步骤:
- 首先,var a 在其作用域中声明新变量。这回在最开始的阶段,也就是代码执行前进行。
- 接下来,a = 2会查询(LHS查询)变量a并对其进行赋值。
- 编译器可以在代码生成的同时处理声明和值得定义。
作用域嵌套
- 当一个快或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。
- 遍历嵌套作用域链的规则:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。
异常
- 如果RHS查询在所有嵌套的作用域中寻不到所需的变量,引擎就会抛出ReferenceErrer异常。
- 当引擎执行LHS查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非“严格模式”中。在“严格模式”下引擎就会抛出ReferenceErrer异常。
- 如果RHS查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个费函数类型的值进行函数调用,或者引用null或undefined类型的值中的属性,哪么引擎会抛出TypeError异常。
- ReferenceErrer同作用域判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
词法作用域
“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
词法阶段
- 大部分标准语言编译器的第一个工作阶段叫作词法话(也叫作单词化)。词法化的过程会对源代码中的字符进行检查,如果是有状态额解析过程,还会赋予单词语义。
- 词法作用域就是定义在词法阶段的作用域。词法作用域是由你在写代码时将变量和块作用域写在哪里决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况是这样)。
查找
- 作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”。抛开遮蔽效应,作用域吓着始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直接遇见第一个匹配的标识符为止。
- 无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
欺骗词法
-
javascript中有两种机制来实现在运行时来“修改”(也可以说欺骗)词法作用域目的。但是欺骗词法作用域会导致性能下降,引擎无法在编译时最作用于查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。
-
javascript中的eval(…)函数可以接受一盒字符串作为参数,并将其中的内容是为好像在书写时就存在于程序中这个位置的代码。在执行eval(…)之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改。引擎只会如往常地进行词法作用域查找。
function foo(str,a){ eval(str);//欺骗console.log(a,b); } val b = 2; foo("var b = 3;",1);//1,3
-
eval(…)通常被用来执行动态创建的代码。
with
- with 通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象。
- with可以讲一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
- eval(…)函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
- eval(…)和with会被严格模式所影响(限制)。with被完全禁止,而保留核心功能的前提下,简介或非安全地使用eval(…)也被禁止了。
函数作用域和快作用域
函数作用域
函数作用域的含义是指,属于这个函数的全部连梁都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。这种设计方案非常有用,能充分利用javascript变量可以根据需要改变值类型的“动态”特性。
隐藏内部实现
- 所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来了。
- 实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量,函数)豆浆绑定在这个新创新的包装函数的作用域中,而不是先前所在的作用域中。换句话说们可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们。
- “隐藏”变量和函数大都是从最小特权原则中引用申请出来的,也叫最小授权或最小暴露原则。
规避冲突
“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。
函数作用域
- 区分函数声明和表达式最简单方法是看function关键字出现在声明中的位置(不仅是一行代码,而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
- 函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑在何处。(function foo(){…})函数表达式意味着foo只能在…所代表的位置中被访问,外部作用域则不行。foo变量名称被隐藏在自身中以为着不会非必要地污染外部作用域。
匿名和具名
setTimeout(function(){
console.log('I waited');
},100);
- 这叫作匿名函数表达式,因为function()…没有名称标识符。
- 匿名函数缺点:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.calles引用。
- 匿名函数省略了对代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
- 始终给函数表达式命名:function后加上 名字()
立即执行函数表达式
-
由于函数被包含在一对()括号内部,因此成为了一个表达式,通过在末尾加上另一个()可以立即执行这个函数,比如(function foo(){…})()。第一个函数变成表达式,第二个()执行了这个函数。
-
IIFE:代表立即执行函数表达式。
-
相对于传统IIFE,还有另一个改进形式:(function(){…}())。这两个形式功能是一致的。
-
IIFE的另一个非常普遍的进阶用法是把它们当作函数调用并传参数进去。
var a = 2; (function IIFE(global){ var a = 3; console.log(a);//3 console.log(global.a);//2 })(window); console.log(a);
这个模式的另一个应用场景是解决undefined标识符的默认值被错误覆盖导致的异常(虽然不常见)。讲一个参数命名为undefined,但是在对应的位置不传入任何值,这样就保证在代码块中undefined标识符的值真的是undefined。
5. IIFE还有一种变化的用途是倒置代码的运行顺序,将需要的函数放在第二位,在IIFE执行之后当作参数传递进去。
块作用域
-
width 也是块作用域的一个例子(块作用域的一种形式),用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。
-
javascript的ES3规范中规定try/catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。
try{ undifined();//执行一个非法操作来强制制造一个异常 } catch(err){ console.log(err);//能够正常执行! } console.log(err);//ReferenceError
-
为了避免当同一个作用域中的两个或多个catch分句用同样的标识符名称错误变量时,静态检查工会报错的不必要警告,很多开发者会将catch的参数命名为err1,err2等。也有开发者干脆关闭了静态检查工具对重复变量名的检查。
let
-
let 关键字可以将变量绑定到所在的任意作用域中(通常是{…}内部)。换句话说,let为其声明的变量隐式地劫持了所在的块作用域。
-
用let将变量附加在一个已经存在的块作用域上的行为是隐式的。显示的代码由于隐式或一些精巧但不清晰的代码。以下为显示代码:
var foo = true; if(foo){ {//显示代码块 let bar = foo * 2;bar = something(bar); console.log(bar); } }
-
只要声明是有效的,在声明中的任意位置都可以使用{…}括号来为let创建一个用于绑定的块。在这个例子中,在if声明内部显示地创建了一个块,如果需要对其进行重构,整个块都可以被方便地移动而不会对外部if声明的位置和语义产生任何影响。
const
ES6还引进了const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值得操作都会引起错误。
提升
编译器再度来袭
- 编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。
a = 2; var a; console.log(a); //会输出2 - var a = 2;实际上会被看成,var a;和a = 2;第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。也就是先声明,后赋值。
- 这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程称为提升。
4.注意,每个作用域都会进行提升。
5.声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
函数优先
1.函数声明和变量声明都会被提升。但是要注意是一个细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。
foo();//1 var foo;
function foo(){
console.log(1);
}
foo = function(){
console.log(2);
};//会输出1而不是2
- 尽管重复的var声明会被忽视掉,但出现在后面的函数声明还是会覆盖前面的函数声明。
本文定义以及例子参考自:
- 《你不知道的JavaScript》