作用域
编程语言最基本的功能之一:将值储存到变量当中,并且能在之后通过变量对这个值进行访问或修改。
在要对存放在变量中的值进行操作(访问、修改或删除)时,就需要知道存放该值的变量存放在了哪里并且该如何找到代表该值的变量。
为此编程语言设计了一套良好的规则,这套规则就叫作用域。
作用域定义:
- 狭义上说作用域就是一个对象(更确切的来说应该是集合);
- 广义上来说作用域是一套用来存储变量,并且之后可以方便的找到这些变量的规则;
作用域的作用:
- 作用域负责收集并维护由所有声明的标识符组成的一系列集合,并实施一套的规则,确定当前执行的代码对这些标识符的访问权限。(这套规则用来管理引擎如何在当前作用域以及嵌套的上层作用域中根据标识符名称进行变量查找。)
编译语言的原理:
一段代码在编译型语言中执行前的三个步骤:
- 分词|词法分析
- 解析|语法分析
- 代码生成(代码转为机器语言)
JavaScript的编译发生在代码执行前的瞬间。在JavaScript处理一段代码时,会有以下基本分(不仅仅只有它们):
- js引擎:负责整个js程序的编译及执行
- 编译器:在代码被js引擎执行前,负责语法解析和编译为可执行代码
- 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查 询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
对于一段代码,会先后经过编译器进行编译处理和js引擎进行运行处理,期间都有作用域参与。
其中编译器做的事:1. 把代码分解为词法单元;2.将词法单元分解为语法单元结构树;3.遇到变量或函数声明,去查询同一个作用域中是否已经存在同名标识符,有则继续编译,没有则创建声明一个;4.生成运行时所需代码。
js引起在其中做的事:1.遇到变量就在作用域(及上层作用域)中进行左查询或右查询,找到便继续执行;没有,则根据是左查询或右查询给出不同反馈(自动创建或报错)。
上下文
代码(全局代码,函数体,eval代码)执行前的准备工作:
- 对于函数,参数赋值,arguments 赋值;
- .提升(变量 函数 函数表达式);
- 确定this指向;
- 与对应作用域关联;
一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过); 有可能有过,现在函数被调用完毕后,上下文环境被销毁了;
左查询与右查询(都由js引擎去查询):
左右的区分一般是一个赋值操作的左侧和右侧。当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。RHS 并不是真正意义上的“赋 值操作的右侧”,更准确地说是“非左侧”。对于右查询,本质是希望找到变量中对应的值;对于左查询,本质是希望找到变量这个容器,至于它内部是否有值并不重要。
在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行 为是不一样的。
如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。值得注意的是,ReferenceError 是非常重要的异常类型。如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作, 比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的 属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。
当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量, 全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非 “严格模式”下。在 严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询 失败时类似的 ReferenceError 异常。
标识符的查找机制:
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的 标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应, 作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见 第一个匹配的标识符为止。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处 的位置决定。
var a = 'asd'
function bar() {
console.log(a);
}
function foo() {
var a = 2;
bar()
}
foo()
词法作用域查找只会查找一级标识符,比如 a、b 和 c。如果代码中引用了 foo.bar.baz, 词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会分别接 管对 bar 和 baz 属性的访问。
扩展作用的情况:(扩展词法作用域会导致性能下降)
- eval (“可执行的js代码字符串”) ,写在eval()内的字符串代码就等价与写源码时直接在eval的位置加上和字符串一样的js代码(非严格模式下);在严格模式下,eval()有自己作用域,它内部可以访问外部的标识符,而外部不能访问它内部的,同时,它内部的标识符也不能作用到外部。
- with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象 本身。
函数与块作用域
产生新作用域的情况:
- 每声明 一个函数都会为其自身创建一个作用域
function foo() {
var a = 2
var b = 8
function bar() {
console.log(c, b);
}
bar()
var c = 9
}
foo() //输出 undefined 8
在编写代码时,可以将所有代码(如变量声明赋值、函数定义、对象的定义)都写在一个作用域(往往是全局作用域)下。但是更好的做法是,将一些声明与定义放在某些函数内部,让其只在函数作用域内有效,以实现对代码的内部隐藏,同时也有效的避免了命名冲突(最小暴露原则)。因为同一作用域下,如果两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致 变量的值被意外覆盖。
第三方库为了避免命名冲突,通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属 性,而不是将自己的标识符暴漏在顶级的词法作用域中。
用函数声明式的方式去封装一组代码存在的不足:
-
函数名本身就污染了全局作用域;
-
必须主动调用,该函数才能运行其中的代码
为此,可以使用函数自调用的函数去弥补。
自调用函数(IIFE)可以匿名也可以具名:
匿名:
(function() {
console.log("I waited 1 second!");
})()
匿名的不足:
1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,
比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑
自身。
3. 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让
代码不言自明。
具名(建议使用):
(function timeoutHandler() {
console.log( "I waited 1 second!" );
})()
IIFE 的另一个非常普遍的用法是把它们当作函数调用并传递参数进去。
var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
另外一个应用场景是解决 undefined 标识符的默认值被错误覆盖导致的异常(虽 然不常见)。将一个参数命名为 undefined,但是在对应的位置不传入任何值,这样就可以 保证在代码块中 undefined 标识符的值真的是 undefined:(jQuery中就做了这种处理)
undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做!
(function IIFE( undefined ) {
var a;
if (a === undefined) {
console.log( "Undefined is safe here!" );
}
})();
-
块级作用域
出现块级作用的地方:
- with语句
- try/catch的catch 分句会创建一个块作 用域,其中声明的变量仅在 catch 内部有效
- let、const 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)
块级作用的优势:
- 防止变量泄露污染全局
- 有利于垃圾回收
提升
js引擎会在运行 JavaScript 代码之前,首先通过编译器对其进行编译。编译阶段中的一部分工作就是找到所有的 声明,并用合适的作用域将它们关联起来。所以,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
当看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个 声明:var a; 和 a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在 原地等待执行阶段。这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动” 到了最上面。这个过程就叫作提升。只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
每个作用域都会进行提升操作。
函数优先
同一个作用域下面,函数会首先被提升,然后才是变量。
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
等价于:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
注意,var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明(因此被忽
略了),因为函数声明会被提升到普通变量之前。
尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。
foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}
一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代
码暗示的那样可以被条件判断所控制:
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}
闭包
闭包是基于词法作用域书写代码时所产生的自然结果。
闭包:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
this
-
每个函数作用域内都会自动定义一个 this 以指向某个对象引用
-
this 不指向函数自身
函数在自身内部调用自己,可能的目的是----递归或者事件处理函数自解绑
函数(具名或匿名)内,argument.callee属性指向函数自身
-
this 不一定是指向函数的作用域
-
this只用在函数被调用时才能确定指向的对象引用,与函数声明的位置没有任何关系
-
确定 this 指向的关键在于确定函数调用的方式
确定this指向的四种情况:
-
函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
-
函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。
-
函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。
-
如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到 全局对象。
-
如果把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值 在调用时会被忽略,实际应用的是默认绑定规则。
-
传入 null 常见的做法是使用 apply(..) 来“展开”一个数组,并当作参数传入一个函数。
-
bind(..) 的功能之一就是可以把除了第一个 参数(第一个参数用于绑定 this)之外的其他参数都传给下层的函数(这种技术称为“部 分应用”,是“柯里化”的一种)。
在 JavaScript 中创建一个空对象最简单的方法都是 Object.create(null) 。Object.create(null) 和 {} 很 像, 但 是 并 不 会 创 建 Object. prototype 这个委托,所以它比 {}更空.