什么是作用域
作用域是一组定义在何处储存变量以及如何访问变量的规则。
编译器
javascript 是编译型语言。但是与传统编译型语言不同,它是边编译边执行的。编译型语言一般从源码到执行会经历三个步骤:
- 分词/词法分析
将一连串字符串打断成有意义的片段,成为 token(记号)。
- 解析
将一个 token 流(数组)转化为一个嵌套元素的树,即抽象语法树(AST)。
- 代码生成
将抽象语法树转化为可执行的代码。其实是转化成机器指令。
比如var a = 1
的编译过程:
- 分词/词法分析:
var a = 1
这段程序可能会被打断成如下 token:var
、a
、=
、1
,空格保留与否得看其是否具有意义。 - 解析:将第一步的 token 形成抽象树:大致如下:
变量声明: { 标识符: a 赋值表达式: { 数字字面量: 1 } }
- 代码生成: 转化成机器命令:创建一个称为 a 的变量,并分配内存,存入一个值为数字 1。
理解作用域
作用域就是通过标识符名称查询变量的一组规则。
代码解析运行中的角色:
- 引擎
负责代码的编译和程序的执行。
- 编译器
协助引擎,主要负责解析和代码生成。
- 作用域
协助引擎,收集并维护一张所有被声明的标识符(变量)的列表,并对当前执行的代码如何访问这些变量强制实施一组严格的规则。
比如var a = 1
的运行:
- 编译器遇到
var a
,会首先让作用域去查询 a 是否已经存在,存在则忽略,不存在,则让作用域创建它; - 编译器遇到
a = 1
,会编译成引擎稍后需要运行的代码; - 引擎执行编译后的代码,会让当前查看是否存在变量
a
可以访问,存在则引用这个变量,不存在则查看其他其他。
上面过程中,引擎会对变量进行查询,而查询分为 RHS(right-hand Side)查询 和 LHS(left-hand Side)查询,它们根据变量出现在赋值操作的左手边还是右手边来判断查询方式。
- RHS
变量在赋值的右手边时采用这种方式查询,查不到会抛出错误 referenceError
- LHS
变量在赋值的左手边时采用这种方式查询,在非严格模式下,查不到会再顶层作用域创建这个变量
嵌套的作用域
实际工作中,通常会有多于一个的作用域需要考虑,会存在作用域嵌套在其他作用域中的情况。
嵌套作用域的规则:
从当前作用域开始查找,如果没有,则向上走一级继续查找,以此类推,直至到了最外层全局作用域,无论找到与否,都会停止。
词法作用域
作用域的工作方式一般有俩种模型:词法作用域和动态作用域。javascript 所采用的是词法作用域。
词法分析时
词法作用域是在词法分析时被定义的作用域。
上述定义的潜在含义即:词法作用域是基于写程序时变量和作用域的块儿在何处被编写所决定的。公认的最佳实践是将词法作用域看作是仅仅依靠词法的。
查询变量:
引擎查找标识符时会在当前作用域开始一直向最外层作用域查找,一旦匹配到第一个,作用域查询便停止。
相同名称的标识符可以在嵌套作用域的多个层中被指定,这成为“遮蔽”。
不管函数是从哪里被调用、如何调用,它的词法作用域是由这个函数被声明的位置唯一定义的。
欺骗词法作用域
javascript 提供了在运行时修改词法作用域的机制——with 和 eval,它们会欺骗词法作用域。实际工作中,这种做法并不被推荐,应当尽量避免使用。
欺骗词法作用域会导致更低下的性能。
引擎在编译阶段会对代码做许多优化工作,比如静态地分析代码。但如果代码存在 eval 和 with,导致词法作用域的不固定行为,这一切的优化都有可能毫无意义,所以引擎就会简单地不做任何优化。
- eval
eval函数
接收一个字符串作为参数,并在运行时将该字符串的内容在当前位置运行。
function foo(str, a) {
eval(str); // 作弊!
console.log(a, b);
}
var b = 2;
foo("var b = 3", 1); //1,3
上面的代码,var b = 3
会再 eval 位置运行,从而在 foo 作用域内创建了变量b
。当console.log(a,b)
调用发生时,引擎会直接访问 foo 作用域内的b
,而不会再访问外部的b
变量。
注意:使用严格模式,在 eval 中作出的声明不会实际上修改包围他的作用域
- with
我们通常使用 with 来引用一个对象的多个属性。
var obj = {
a: 1,
b: 2,
c: 3
};
with (obj) {
a = 3;
b = 4;
c = 5;
}
console.log(obj); //{a: 3, b: 4, c: 5}
但是,with 会做的事,比这要多得多。
var o1 = { a: 3 };
var o2 = { b: 3 };
function foo(obj) {
with (obj) {
a = 2;
}
}
foo(o1);
console.log(o1.a); //2
foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2 全局作用域泄漏
with 语句接受一个对象,并将这个对象视为一个完全隔离的词法作用域。
但是 with 块内部的一个普通的var
声明并不会归于这个with
块儿的作用域,而是归于包含它的函数作用域。
所以,上面代码执行foo(o2)
时,在执行到 a = 2
时,引擎会进行 LHS查找
,但是一直到最外层都没有找到 a 变量,所以会在最外层创建这个变量,这里就造成了作用域泄漏。
函数与块作用域
javascript 中是不是只能通过函数创建新的作用域,有没有其他方式/结构创建作用域?
函数中的作用域
javascript 拥有基于函数的作用域
函数作用域支持着这样的想法:所有变量都属于函数,而去贯穿整个函数都可以使用或重用(包括嵌套的作用域中)。
这样以来,一个声明出现在作用域何处是无关紧要的。
隐藏标识符于普通作用域
我们可以通过将变量和函数围在一个函数的作用域中来“隐藏”它们。
为什么需要“隐藏”变量和函数?
如果允许外围的作用域访问一个工作的私有细节,不仅没必要,而且可能是危险的。所以软件设计中有一个最低权限原则原则:
最低权限原则:也称“最低授权”/“最少曝光”,在软件设计中,比如一个模块/对象的 API,你应当只暴露所需要的最低限度的东西,而隐藏其他一切。
将变量和函数隐藏可以避免多个同名但用处不同的标识符之间发生无意的冲突,从而导致值被意外的覆盖。
实际可操作的方式:
- 全局命名空间
在引用多个库时,如果他们没有隐藏内部/私有函数和变量,那么它们十分容易出现相互冲突。所以,这些库通常会在全局作用域中使用一个特殊的名称来创建一个单读的变量声明。它经常是一个对象,然后这个对象被用作这个库一个命名空间
,所有要暴露出来的功能都会作为属性挂载在这个对象上。
比如,Jquery 的对象就是 jquery/$;
- 模块管理
实现命名冲突的另一种方式是模块管理。
函数作为作用域
声明一个函数,可以拿来隐藏函数和变量,但这种方式同时也存在着问题:
- 不得不声明一个命名函数,这个函数的标识符名称本身就污染了外围作用域
- 不得不通过名称明确地调用这个函数
不需要名称,又能自动执行的,js 恰好提供了这样一种方式。
(function(){
...
})()
上面的代码使用了匿名函数和立即调用函数表达式:
- 匿名函数
函数表达式可以匿名,函数声明不能匿名。
匿名函数的缺点:
- 在栈中没有有用的名称可以表示,调试困难;
- 想要递归自己(arguments.callee)或者解绑事件处理器变得麻烦
- 更不易代码阅读
最佳的方式总是命名你的函数表达式。
- 立即调用函数表达式
通过一个()
,我们可以将函数作为表达式。末尾再加一个括号可以执行这个函数表达式。这种模式被成为 IIFE(立即调用函数表达式;Immediately Invoked Function Expression)
块作为作用域
大部门语言都支持块级作用域,从而将信息隐藏到我们的代码块中,块级作用域是一种扩展了最低权限原则
的工具。
但是,表面上看来 javascript 没有块级作用域。
for (var i = 0; i < 10; i++) {
console.log(i);
}
console.log(i); // 10 变量i被划入了外围作用域中
if (true) {
var bar = 9;
console.log(bar); //9
}
console.log(bar); //9 // 变量bar被划入了外围作用域中
但也有特殊情况:
- with
它从对象中创建的作用域仅存在于这个 with 语句的生命周期中。
- try/catch
ES3 明确指出 try/catch 中的 cathc 子语句中声明的变量,是属于 catch 块的块级作用域。
js try { var a = 1; } catch (e) { var c = 2; } console.log(a); //1 console.log(c); //undefined
- let/const
let 将变量声明依附在它所在的块儿(通常是{...})作用域中。
- 隐含使用现存得块儿
js if (true) { let bar = 1; console.log(bar); //1 } console.log(bar); // ReferenceError
- 创建明确块儿
js if (true) { { // 明确的块儿 let bar = 1; console.log(bar); //1 } } console.log(bar); // ReferenceError
const 也创建一个块级作用域,但是它的值是固定的(常量)。
注意: let/const 声明不进行变量提升。
块级作用域的用处:
- 垃圾回收
可以处理闭包和释放内存的垃圾回收。
js function process() { // do something } var bigData = {...}; // 大体量数据 process(bigData); var btn = document.getElementById(‘btn‘); btn.addEventListener("click",function(e){ console.log(‘btn click‘); })
点击事件的回调函数根本不需要 bigData 这个大体量数据。理论上讲,在执行完 process 函数后,这个消耗巨大内存的数据结构应该被作为垃圾而回收。然而因为 click 函数在整个函数作用域上拥有一个闭包,bigData 将会仍然保持一段事件。
块级作用域可以解决这个问题:
js function process() { // do something } { let bigData = {...}; // 大体量数据 process(bigData); } var btn = document.getElementById(‘btn‘); btn.addEventListener("click",function(e){ console.log(‘btn click‘); })
- 循环
对每一次循环的迭代重新绑定。
js for (let i = 0; i < 10; i++) { console.log(i); } console.log(i); // ReferenceError
也可以这样:
js { let j; for (j = 0; i < 10; i++) { let i = j; // 每次迭代重新绑定 console.log(i); } }
提升
函数作用域还是块级作用域的行为都依赖于一个相同的规则: 在一个作用域中声明的任何变量都附着在这个作用域上。
但是出现一个作用域内各种位置的声明如何依附作用域?
先有鸡还是先有蛋?
我们倾向于认为代码是自上而下地被解释执行的。这大致上是对的,但也有一部分并非如此。
a = 2;
var a;
console.log(a); // 2
如果代码自上而下的解释运行,预期应该输出 undefined
,因为 var a
在 a = 2
之后,应该重新定义了变量 a。显然,结果并不是如此。
console.log(a); // undefined
var a = 2;
从上面的例子上,你也许会猜测这里会输出 2,或者认为这里会导致一个 ReferenceError 被抛出。不幸的是,结果却是 undefined。
代码究竟如何执行,是先有声明还是赋值?
编译器再次袭来
我们知道,引擎在 javascript 执行代码之前会先对代码进行编译,编译的其中一个工作就是找到所有的声明,并将它关联在合适的作用域上。
所以,在我们的代码被执行前,所有的声明,包括变量和函数,都会被首先处理。
对于var a = 2
,我们认为是一个语句,但 javascript 实际上认为这是俩个语句:var a
和 a = 2
。第一句(声明)会在编译阶段处理,第二句(赋值)会在执行阶段处理。
知道了这些,我想对于上一节的疑惑也就迎刃而解了:先有声明,后有赋值。
注意:提升是以作用域为单位的
函数声明会被提升,但是表达式不会。
foo(); // 1
goo(); // TypeError
function foo() {
console.log(1);
}
var goo = function() {
console.log(2);
};
变量 goo 被提升了,但表达式没有,所以调用 goo 时,goo 的值为 undefined。所以会报 TypeError。
函数优先
函数声明和变量都会提升。但是函数享有更高的优先级。
console.log(typeof foo); // function
var foo = 2;
function foo() {
console.log(1);
}
从上面代码可以看出,结果输出 function 而不是 undefined 。说明函数声明优先于变量。
重复声明,后面的会覆盖前面的。
作用域闭包
必须要对作用域有健全和坚实的理解才能理解闭包。
启蒙
在 javascript 中闭包无处不在,你只是必须认出它并接纳它。它是依赖于词法作用域编写代码而产生的结果。
事实真相
闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在他的词法作用域之外执行时
function foo() {
var a = 2;
function bar() {
console.log(2);
}
bar();
}
这种形式算闭包吗?技术上算,它实现了闭包,函数 bar 在函数 foo 的作用域上有一个闭包,即 bar 闭住了 foo 的作用域。但是在上面代码中并不是可以严格地观察到。
function foo() {
var a = 2;
function bar() {
console.log(2);
}
return bar;
}
var baz = foo();
baz(); //2 这样使用才算真正意义上的闭包
bar 对于 foo 内的作用域拥有此法作用域访问权,当我们调用 foo 之后返回 bar 的引用。按理来说,foo 执行过后,我们一般会期望 foo 的整个内部作用域消失,因为垃圾回收机制会自动回收不再使用的内存。但 bar 拥有一个词法作用域的闭包,覆盖着 foo 的内部作用域,闭包为了能使 bar 在以后的任意时刻可以引用这个作用域而保持的它的存在。
所以,bar 在词法作用域之外依然拥有对那个作用域的引用,这个引用称为闭包。
闭包使一个函数可以继续访问它在编写时被定义的词法作用域。
var a = 2;
function bar() {
console.log(a);
}
function foo(fn) {
fn(); // 发现闭包!
}
foo(bar);
上面的代码,函数作为参数被传递,实际上这也是一种观察/使用闭包的例子。
无论我们使用什么方法将一个函数传送到它的词法作用域之外,它都将维护一个指向它被声明时的作用域的引用。
循环 + 闭包
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i); // 5
}, i * 1000);
}
这段代码的预期是每隔一秒分别打印数字:1,2,3,4,5。但是我们执行后发现结果一共输出了 5 次 6。
为什么达不到预期的效果?
定时器的回调函数会在循环完成之后执行(详见事件循环机制)。而 for 不是块级作用域,所以每次执行 timer 函数的时候,它们的闭包都在全局作用域上。而此时全局作用域环境中的变量 i 的值为 6。
我们的代码缺少了什么?
因为每一个 timer 函数执行的时候都是使用全局作用域,所以访问的变量必然是一致的,所以想要达到预期的结果,我们必须为每一个 timer 函数创建一个私有作用域,并在这个私有作用域内存在一个可供回调函数访问的变量。现在我们来改写一下:
for (var i = 1; i <= 5; i++) {
(function() {
let j = i;
setTimeout(function() {
console.log(j); // 1,2,3,4,5
}, i * 1000);
})();
}
我们使用 IIFE 为每次迭代创建新的作用域,并且保存每次迭代需要的值。
其实这里主要用到的原理是使用块级作用域,所以,理论上还有其他方式可以实现,比如:with,try/catch,let/const,大家都可以尝试下哦。
模块
模块也利用了闭包的力量。
function coolModule() {
var something = "cool";
function doSomething() {
console.log(something);
}
return {
doSomething: doSomething
};
}
var foo = coolModule()
foo.doSomething() // cool