一、简单介绍
个人理解,在javascript中查找变量时,分为词法作用域和原型链查找。这里暂时只介绍词法作用域,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此词法分析器处理代码时会保持作用域不变(大部分情况是这样的,也可以通过一些欺骗词法作用域的方法,比如evel)。
考虑以下代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(a,b,c);
}
bar(b * 3);
};
在这个例子中有三个逐级嵌套的作用域。
1、全局作用域,其中只有一个标识符:foo
2、foo所创建的作用域,其中有三个标识符:a、bar和b
3、bar所创建的作用域,其中有一个标识符:c
作用域气泡由其对应的作用域块代码在哪里决定,它们是逐级包含的。
foo被包含在全局作用域下,bar被包含在foo的作用域下,c被包含在bar的作用域下(隐式var c = b *3)
二、作用域查找介绍
我们把作用域看作气泡,气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识。.
作用域在查找时会逐级查找,作用域会查找第一个匹配的标识符时停止。在多层作用域中可以定义同名标识符,这叫“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或向上进行,直到遇见第一个匹配的标识符为止,如果遇见重名的也是一样,会找到最近的那个停止查找。
三、欺骗词法
Javascript中有两个机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能下降。
3.1 eval
Javascript中的eval(...)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那些位置的一样。
根据这个原理来理解eval(...),它是如何通过代码欺骗和假装书写时(也就是词法期)代码就在那,来实现修改词法作用域环境的,这个原理就变得清晰易懂了/
在执行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;" 这段代码会被当作本来就在那里一样来处理。由于那段代码声明了一个新变量b,因此它对已经存在的foo(..)的词法作用域进行了修改。事实上,和前面提到的原理一样,这段代码实际上在foo(..)内部创建了一个变量b,并遮蔽了外部(全局)作用域中的同名变量。
当console.log(...)被执行时,会在foo(..)的内部同时找到a和b,但是永远也无法找到外部的b。因此会输出"1,3"而不是正常情况下会输出的“1,2”。
注:在严格模式的程序中,eval(..)在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
function foo(str) {
"use strict"
eval(str);
console.log(a); // ReferenceError:a is not defined
}
foo("var = 2 ");
Javascript中还有其它一些功能效果和eval(..)很相似。setTimeout(..)和setInterval(..)的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码。这些功能已经过时且并不被提倡。不要使用它们!
在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失。
3.2 with
Javascript中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是with关键字。可以有很多方法来解释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: 3
};
foo(o1);
console.log(o1.a); // 2
foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2——不好,a被泄露到全局作用域上了!
在这个例子中创建了o1和o2两个对象。其中一个具有a属性,另外一个没有。foo(..)函数接收一个obj参数,该参数是一个对象引用,并对这个对象引用执行了with(obj){...}。在with块内部,我们写的代码看起来只是对变量a进行简单的词法引用,实际上就是一个LHS引用,并将2赋值给它。
当我们将o1传递进去,a=2赋值操作找到了o1.a并将2赋值给它,这在后面的console.log(o1.a)中可以体现。而当o2传递进去,o2并没有a属性,因此不会创建这个属性,o2.a保持undefined。
但是可以注意到一个奇怪的副作用,实际上a = 2赋值操作创建了一个全局的变量a。这是怎么回事?
with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域的词法标识符。
尽管with块可以将一个对象处理为词法作用域,但是这个块内部正常的var声明并不会限制在这个块的作用域中。
注:另外一个不推荐使用eval和with的原因是会被严格模式所影响(限制)。with被完全禁止,而在保留核心功能的前提下,间接或非安全地使用eval也被禁止了。
3.3 性能
eval和with会在运行时修改或创建新的作用域,以此来欺骗其它在书写时定义的词法作用域。
Javascript引擎会在编译阶段进行数项的性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。但如果引擎在代码中发现了eval或with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道eval会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给with用来创建新词法作用域的对象的内容到底是什么。
如果代码中大量使用eval或with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化(指对eval的副作用限制在最小范围),代码会运行的更慢这个事实。
四. 关于作用域的一些特性
接下来要讲的主要是一些关于作用域中容易忽略的问题
4.1 函数作用域
简单介绍一下函数作用域:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用,函数内也可以嵌套函数)
4.2 for循环中的变量污染
看一个例子:
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这个条件,无限循环。
在这里主要是为了接下来的块级作用域铺垫,从上面的例子我们也可以看见,在for中的变量是没有自己的作用域的,它是属于for所在作用域下。
4.3 块作用域
在ES6以后,引入了let、const关键字,提供了除var以外的另外一种变量声明方式。let、const关键字可以将变量绑定到所在的任意作用域中(通常是{...}内部)。换句话说,为其声明的变量隐式地劫持了所在的块作用域。
4.2中的代码将var i = 0 改成let i = 0;将不会存在变量污染的问题
如下转换:
for (let i=0; i < 10; i++) {
console.log(i);
}
console.log(i); // ReferenceError
for循环头部的let不仅将i绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
下面通过另一种方式来说明每次迭代时进行重新绑定的行为:
{
let j;
for(j=0; j<10;i++){
let i = j; // 每个迭代重新绑定!
console.log(i);
}
}
每个迭代进行重新绑定非常有趣,我们会在闭包时进行说明。
由于let声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域)!!!这里要着重注意,把块级作用域应当看作一个新的作用域,不能以函数、全局作用域看待,当代码中存在对于函数作用域var声明的隐式依赖时,就会有很多隐藏的陷阱,如果用let来替代var则需要在代码重构的过程中付出额外的精力。const也是一样的,只不过它无法被重新赋值。
4.4 先有鸡还是先有蛋(变量、函数)
4.4.1变量声明的提升
a = 2;
var a;
console.log(a); // 2
这个时候console会输出2,是因为声明会被提升。上面代码等价于如下:
var a;
a = 2;
console.log(a);
上面这只涉及到了变量的声明提升,往下还会介绍函数于变量的声明提升的优先级。
提示:只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变了代码的执行顺序,会造成非常严重的破坏。
下面我们再来看一段代码:
console.log(a); // undefined
var a = 2;
这段代码会打印undefined
下面通过编译器的编译阶段回顾以上代码,在解释Javascript代码之前,会先对代码进行编译:①首先将这段程序分解成词法单元(词法分析)②接着将词法单元解析成一个树结构(AST)③将AST转换为可执行代码的过程(在这个过程中编译器会为引擎生成运行时所需的代码,其中一部分工作就是找到所有的声明,并使用合适的作用域将它们关联起来),再提一点:var a = 2;这段程序,事实上,引擎这里有两个完全不同的声明,一个是编译器在编译时处理,另一个则由引擎在运行时处理。解释如下:<1>编译器遇到 var a,编译器会询问作用域是否已经由一个该名称的变量存在于同一个作用域集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域集合中声明一个新的变量,并命名为a。<2>接下来编译器会为引擎生成运行时所需代码,这些代码被用来处理a = 2这个赋值操作。引擎运行时首先先询问作用域,在当前作用域是否存在一个叫做a的变量。如果是,引擎就会使用这个变量;如果否,则会根据作用域链继续查找该变量。如果引擎最终找到了a变量,就会将2赋值给它。否则引擎就会抛出异常。
接着,我们从编译阶段和引擎阶段来解读下面这段代码:
var a = 2;
看到var a = 2;时,Javascript实际上会将其看成两个声明:var a;和a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在留在原地等待执行阶段。
4.4.2 函数声明的提升
直接看一段代码,按照上面变量的理解就行
foo();
function foo() {
console.log(a); // undefined
var a = 2;
}
以上这段代码能够正常运行,因为函数foo的声明(这个例子还包括实际函数的隐含值)被提升了,所以第一行代码foo能够执行。
最终代码会被转换为:
// 函数声明foo被提升了
function foo() {
var a;
console.log(a);
a = 2;
}
foo();
结论:函数声明会被提升,但是函数表达式却不会被提升。
我们看下一个例子,来解释函数表达式不会提升的意思
foo(); // TypeError!
var foo = function bar() {
..
}
这段代码会报错,这段程序中的变量标识符foo会被提升并分配给所在作用域(在这里是全局作用域),因此foo不会导致ReferenceError。但是foo此时并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值)。foo由于对undefined值进行函数调用而导致非法操作,所以抛出TypeError异常!
同时也要记住,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
..
}
上面这段代码实际等价于:
var foo; //此时是undefined,未进行赋值
foo(); // TypeError
bar(); // ReferceError
foo = function () {
var bar = ...self...
//...
}
由以上代码可以看见,使用变量接收一个具名函数表达式,此时bar也是不会出现在全局作用域下的。
我们可以试如下demo
var foo = function bar() {
console.log("aaaa");
}
foo(); // aaaa
bar(); // Uncaught ReferenceError: bar is not defined
4.4.3函数优先(变量与函数之间的声明优先级)
引入一个概念:函数声明和变量声明都会被提升。但是一个指的注意的细节(这个细节可以出现在有多个“重复声明的代码中”)是函数首先会被提升,然后才是变量,且不会被变量声明覆盖,但是会被变量赋值覆盖。再补充一句,变量声明不会覆盖函数声明,但是重复的函数声明前面的会被后面的覆盖掉。
看如下一段代码,然后判断其会等价于什么代码执行
foo(); // 1
var foo;
function foo() {
console.log(1);
}
foo = function() {
console.log(2);
}
foo(); // 2
根据变量、函数的声明会被提升且函数首先被提升,函数的声明不会被变量声明覆盖,但是会被变量赋值覆盖,等价结果为:
function foo() {
console.log(1);
}
// var foo; 等价于不存在, 变量声明无法覆盖已有的函数声明
foo(); // 1
// 对foo重新进行赋值,覆盖一开始声明的foo函数
foo = function() {
console.log(2);
}
foo(); // 2
再来验证一下,重复的函数声明会被后面的函数声明所覆盖掉。
foo(); // 3
function foo() {
console.log(1);
}
var foo = function() {
console.log(2);
}
function foo() {
console.log(3);
}
这段代码实际上转换之后结果是:
funcion foo() {
console.log(1);
}
function fo0() {
console.log(3);
}
foo(); // 3
foo = function() {
console.log(2);
}
需要再验证下,只需要在最后一个var foo 赋值后面再加一行foo();发现打印的会是2
4.4.4 条件判断下面的函数声明
结论:一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断语句所控制
看一个demo:
foo(); // TypeError: foo is not a function
var a = true;
if (a) {
function foo() {
console.log("a");
}
} else {
function foo() {
console.log("b");
}
}
4.4.5 通过try/catch在ES5中创建块级作用域
let和const是ES6中引入的,在ES5中,我们需要一个块级作用域可以通过try.. catch拿到,在Javascript的ES3规范中规定try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。
例如:
try {
undefined(); // 执行一个非法操作来强制制造一个异常
} catch(err) {
console.log(err); // 能够正常执行
}
console.log(err); // ReferenceError: err not found
通常不会这么使用,因为try/catch的性能非常差
五、小结
主要涉及的知识点:
①作用域简介(一笔带过,网上很多)
②欺骗语法:eval和with,eval和with会影响编译阶段预先确定的变量、函数的位置,导致性能降低,在严格模式下eval中注入的javascript代码的作用域不会污染到外部(但还是不建议使用),with在严格模式下直接禁止使用。
③ for循环中的通过var声明的变量会污染到for语句所在的作用域,因为var声明的变量不会绑定到块级作用域下
④ 通过let、const定义的变量会将变量绑定到块级作用域,这里需要注意:块级作用域并不属于全局作用域也不属于函数作用域,它是ES6中引入的一个新的作用域。
⑤变量、函数声明提升以及优先级,条件判断下面的函数声明无法被提升
⑥顺便提一句,以上涉及的作用域均理解成词法作用域,词法作用域是在编译阶段就已经确定好的,与代码的运行环境无关。Javascript中没有动态作用域这么一说,动态作用域与代码引擎在运行时的上下文环境有关,但是Javascript中的对象原型链的this指向类似动态作用域,this指向与当前调用的上下文环境有关,有关this中的一些调用在后续章节会提到。通过本章节我们需要知道,讲解的只是词法作用域。