JavaScript-作用域
作用域
作用域是什么
收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
理解作用域
var a = 2;
编译器处理流程:
- 先处理
var a
;询问作用域是否存在该名称的变量在同一个作用域中。如果是,编译器就会忽略该声明,继续进行编译(进行第2步);否则会在当前作用域中声明一个新变量,并命名为 a 。 - 处理
a = 2
这个赋值操作。先询问作用域是否存在一个叫做 a 的变量,如果是,将使用该变量进行赋值操作;如果否,将继续查找该变量。 - 如果最终没找到 a 变量,将抛出一个异常。
LHS查询和RHS查询
-
LHS查询:查找变量的容器本身(赋值操作的“左侧”)
a = 2; // 此时对 a 的引用只是为 = 2 这个赋值操作找到一个目标(LHS)
-
RHS查询:查找某个变量的值(赋值操作的“非左侧”)
console.log(a); // 此时是一个RHS引用,只需要找到变量a的值并传递给console.log(..)
-
例
function foo(a){ // LHS,查找名为a的变量并进行赋值 console.log(a); /* 对console对象进行RHS查询,检查得到的值中是否存在名为log的方法 RSH,查询变量a的值 */ } foo(2); // RHS,查找名为foo的函数类型的值
作用域嵌套
在当前作用域中无法找到某个变量时,将会在外层签到的作用域中继续查找,直到找到该变量或抵达最外层的作用域(全局作用域)。
异常
“严格模式” | 非“严格模式” | |
---|---|---|
LHS查询未找到变量 | ReferenceError异常 | 在全局作用域中创建一个名为该名称的变量 |
RHS查询未找到变量 | ReferenceError异常 | 同左 |
RHS查询变量为空 (null或undefined) |
TypeError异常 | 同左 |
- ReferenceError与作用域判别失败相关
- TypeError表示对结果的操作是非法或不合理的
词法作用域
// 全局作用域
function foo(a) {
// foo创建的作用域
var b = a * 2;
function bar(c) {
// bar创建的作用域
console.log(a, b, c);
}
var(b * 3);
}
foo(2);
欺骗词法
在运行时“修改”(欺骗)词法作用域。欺骗词法作用域会导致性能下降
eval
eval(..)
函数可以接受一个字符串为参数,并将其视为存在程序这个位置的代码
function foo(str, a){
eval(str);
// 欺骗,此时foo(..)的词法作用域中创建了变量b,遮掩了外部作用域中的同名变量
console.log(a, b); // 1, 3
}
var b = 2;
foo("var b = 3;", 1);
严格模式下,eval(..)
在运行时有自己的词法作用域,即其发出的声明无法改变所在作用域
类似的还有setTimeout(..)
和setInterval(..)
的第一个参数可以是字符串,其可以被解释为一段动态生成的函数代码;new Funtion(..)
函数的最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是新生成函数的形参),但都不提倡使用
with
通常被当作重复引用同一个对象的多个属性的快捷方式
var obj = {
a: 1,
b: 2,
c: 3
};
// 重复“obj”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// with快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}
with(obj) {..}
不仅是为了方便访问对象属性
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
- 将
o1
传递进foo(..)
,通过一个LHS查询将2
赋值给了o1
的a
属性 o2
没有a
属性,因此不会创建这个属性,o2.a
保持undefined
,但with
会将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符,通过LHS查询作用域发现并没有名为a
的变量,则会在全局作用域中创建一个变量a
(非严格模式下)
性能
JavaScript引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能那个在执行过程中快速找到标识符
词法分析阶段无法明确知道eval(..)
会接收什么代码,这些代码会对作用域进行怎样的修改;也无法知道传递给with
用来创建新词法作用域的对象的内容到底是什么,这样就会导致程序运行缓慢
总结
eval(..)
会修改其所处的词法作用域with
会根据传递的对象创建一个新的词法作用域
函数作用域和块作用域
隐藏内部实现
对代码中的任意片段通过函数声明的方式对它进行包装,以达到“隐藏”代码的效果
为什么“隐藏”变量和函数是一个有用的技术?
最小特权原则:指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来
例:某个模块或对象的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(..)
的“访问权限”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(..)
都只能被diSomething(..)
所控制,功能性和最终效果并没有受影响,但设计上将具体内容私有化
规避冲突
“隐藏”作用域中的变量是可以避免同名标识符之间的冲突,而冲突会导致变量的值被覆盖
function foo() {
function bar(a) {
i = 3; // var i = 3;
console.log(a + 3);
}
for (var i = 0; i < 10; i++){
bar(i * 2);
}
}
foo();
bar(..)
内部的赋值表达式i = 3;
覆盖了声明在foo(..)
内部for
循环中的i
,导致无限循环- 使用
var i = 3;
会为i
声明一个“遮掩变量”,也可以采用一个完全不同的标识符名称 - 但软件设计在某种情况下可能自然而然的要求使用同样的标识符名称,因此使用作用域来“隐藏”内部声明是唯一的最佳选择
-
全局命名空间
当程序中加载了多个第三方库时,如果它没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突
这些库通常会在全局作用域中声明一个名字足够独特的变量(例如jQuery的
$
),通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中 -
模块管理
库无需将标识符加入到全局作用域中,而是依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中
利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中
函数作用域
声明一个具名函数foo()
,意味着foo
这个名称本身“污染”了所在作用域,而且必须显示的通过函数名(foo()
)才能调用这个函数
var a = 2;
// 标准函数声明
function foo() {
var a = 3;
console.log(a); // 3
}
// 包装函数
(function foo() { // <-- 添加这一行
var a = 3;
console.log(a); // 3
})();
console.log(a); // 2
- 包装函数声明的函数会被当作函数表达式而不是一个标准的函数声明来处理
- 通过对比可以发现,标准函数声明中
foo
被绑定在所在作用域;包装函数中的foo
被绑定在函数表达式自身的函数中而不是所在作用域中,即(function foo(){..})
中的foo
只能在..
所代表的位置中被访问,foo
变量名的隐藏意味着不会非必要地污染外部作用域
匿名和具名
-
匿名函数表达式(
funtion()..
)没有名称标识符
函数表达式可以匿名,函数声明不可以省略函数名(非法)
缺点
- 调试困难,匿名函数在栈追踪中不会显示有意义的函数名
- 无法引用自身,例如:递归,另一个函数需要引用自身的例子
- 省略了对于代码可读性/可理解性很重要的函数名,一个描述性的名称可以让代码不言自明
行内函数表达式——匿名和具名之间的区别并不会对函数表达式这点有任何影响
setTimeout(function timeoutHandler() { // 有名字 console.log("I waited 1 second!"); // 等待一秒后输出 }, 1000); setTimeout(console.log("I waited 1 second!"), 1000); // 立即输出
this
取决于函数运行时谁调用了这个函数setTimeout
第一个参数要是个函数- 执行
setTimeout
函数时,参数如果是表达式,会先计算表达式的结果
-
立即执行函数表达式 IIFE
var a = 2; (function foo() { var a = 3; console.log(a); // 3 })(); console.log(a); // 2
函数被包含在一对( )括号内部,因此成为了一个表达式,通过在末尾加上另外一个( )可以立即执行这个函数
函数名对IIFE不是必须的,IIFE最常见的用法是使用一个匿名函数表达式,其拥有匿名表达式的所有优势
-
将一个参数传递进函数里,是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
执行之后作为参数传递进去(被UMD项目广泛使用)var a = 2; (function IIFE(def) { def(window); })(function def(global) { var a = 3; console.log(a); // 3 console.log(global.a); // 2 });
-
块作用域
一个用来对最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息
with
with
从对象中创建的作用域尽在with
声明中而非外部作用域有效
try/catch
try/catch中的catch分句会创建一个块作用域,其中声明的变量尽在catch
内部有效
try {
undefined(); // 执行一个非法操作来强制制造一个异常
} catch (err) {
console.log(err); // 正常执行
}
console.log(err); // ReferenceError: err not found
err
尽在catch
分句中有效
let
let
关键字可以将变量绑定到所在的任意作用域(通常是{..}
内部),即let
为其声明的变量隐式地劫持了所在的块作用域
只要声明有效,在声明中的任何位置都可以使用{..}
括号来为let
创建一个用于绑定的块
使用let
进行的声明不会在块作用域中进行提升
-
垃圾收集
function process(data) { // do something } var someReallyBigData = {..}; process(someReallyBigData); var btn = document.getElementById("my_button"); btn.addEventListener("click", function click(evt) { console.log("button clicked"); }, /*capturingPhase*/false);
click
函数的点击回调并不需要someReallyBigData
变量。理论上当process(..)
执行后,内存中占用大量空间的数据结构就可以被垃圾回收了。但由于click
函数形成了一个覆盖整个作用域的闭包,JavaScript引擎极有可能保存这个结构块作用域可以让引擎知道没有必要保存
someReallyBigData
function process(data) { // do something } // 在这个块中定义的内容在结束后可以销毁! { let someReallyBigData = {..}; process(someReallyBigData); } var btn = document.getElementById("my_button"); btn.addEventListener("click", function click(evt) { console.log("button clicked"); }, /*capturingPhase*/false);
为变量显式声明块作用域,并对变量进行本地绑定
-
let循环
for
循环头部的let
不仅可以将i
绑定到循环的块中,实际上它将重新绑定到循环的每个迭代中
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