对于任何编程语言来说,都有一个很基础但也很重要的概念:变量的管理;它包括变量的声明,变量的赋值,变量的存储,变量的查找,变量的更改,变量的销毁等。而从另外一个角度来看这一系列问题就可以理解为:这个变量存在哪儿?存活多久?怎样才能找到它?在JS中,解决这些问题的基础就是作用域,同时了解作用域也是学习闭包的基础
1. 需要理解的概念
在阐述作用域的概念之前,首先需要了解的是,在面对一段程序的时候,JS内部是如何进行处理的,有一个流传很广的说法是JS是解释型语言,而非编译型语言,其实JS程序的执行也是需要编译的,只是其不是预编译的,而是在程序段执行之前进行的临时编译,其编译过程分为下面几步:
- 分词/语法分析,如
var a = 2;
就会被分为var
,a
,=
,2
等标记 - 解析,将上一步得出的所有标记转换为一个元素树,其实可以看做是该段程序的语法结构;这个元素树统称为"AST"(abstract syntax tree)
- 生成可执行码,即将上一步代码块对应的AST转换为机器可执行的指令
上面三部过程需要涉及到三个重要的角色:
- 引擎,负责JS代码的编译与执行
- 编译器,引擎的好朋友;主要为引擎做一些准备工作,如解析,生成可执行码
- 作用域,引擎的另一个好朋友;主要负责管理程序对应的元素(变量,方法等),同时定义一套规则,该规则约束当前程序可以访问哪些元素
当面对代码段var a = 2;
的时候,编译器会执行下列步骤:
- 编译器询问作用域是否已经存在一个叫a的变量,若存在,则进入下一步,若不存在,则通知作用域创建一个叫a的变量
- 编译器为引擎生成可执行码,然后引擎询问当前作用域是否在可以访问的a变量,若存在,则用之,否则,引擎将前往别处寻找(嵌套作用域)
- 如果引擎最终找到了变量a,则将2赋值给变量a,否则引擎将报错
2. 作用域中变量的声明与赋值
2.1 Hoisting
JS在面对一个变量的声明与赋值的时候,会首先在编译期对变量声明进行处理,然后在执行期对变量进行赋值;而在编译器进行代码编译的时候,会将变量或方法的声明由其代码申明处提至语义作用域(语义作用域将在后续章节中做详细解释)的顶部,这个过程就称为Hoisting或变量提升,Hoisting也是作用域中变量声明与赋值的核心和难点,首先看下面两段代码:
a = 2;
var a;
console.log( a );
console.log( a );
var a = 2;
经过编译器编译后,上面第一段代码会被转换为:
var a;
a = 2;
console.log( a );//2
而第二段代码将被转换为:
var a;
console.log( a );
a = 2;//undefined
可以发现,由于Hoisting的存在,在一个语义作用域内,只要存在变量声明,无论该声明语句处于什么位置,都会在执行前被提至语义作用域的顶部,需要注意的是只有声明会被Hoisting
,而赋值不会做任何处理,维持原顺序;同时Hoisting
只会在当前语义作用域中起效
方法的声明也一样会在编译期执行Hoisting
,如:
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}
将会在编译期是转换为:
function foo() {
var a;
console.log( a ); // undefined
a = 2;
}
foo();
又如:
foo();
var foo = function bar() {
// ...
};
将会在编译期是转换为:
var foo;
foo();
foo = function bar() {
// ...
};
又又如:
foo();
bar();
var foo = function bar() {
// ...
};
将会在编译期是转换为:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
初学JS的人经常会很奇怪为什么JS代码中,经常会出现对某个变量或方法的使用出现在其声明的前面,而JS引擎照样可以正常的执行,不会报错,这些要归功于Hoisting
2.2 变量Hoisting与方法Hoisting的优先级
如果在语义作用域中同时存在变量Hoisting和方法Hoisting,JS也规定了它们的优先级:
方法Hoisting优先级 > 变量Hoisting优先级
如:
foo();
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
将会在编译期是转换为:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
在这段代码中,有两个同名的声明,变量foo与方法foo,首先,方法foo将会被Hoisting,同时后续的变量foo的声明将会被忽略(因为JS引擎已经找到了变量foo,那么它就不会重新去声明一个同名变量)
在代码块里定义的方法也将被Hoisting
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log( "a" ); }
}
else {
function foo() { console.log( "b" ); }
}
将会在编译期是转换为:
function foo() { console.log( "a" ); }
function foo() { console.log( "b" ); }
foo(); // "b"
var a = true;
if (a) {
}
else {
}
3. 作用域中变量的找寻机制
JS引擎在编译包含有变量a
的代码时,会在作用域中找寻变量a
,总体来说有两种找寻方式,分别为:
- LHS:Left-hand Side
- RHS:Right-hand Side
这里的side指的是assignment operation,即通过赋值操作区分是LHS还是RHS,如果变量在赋值操作的左边,则是LHS;而RHS却不能简单定义为变量在assignment operation的右边,应该理解为非LHS的即为RHS,从变量找寻与赋值的角度来说,LHS指的是找寻变量本身,而RHS指的是获取变量的值,如:
-
var a = 1;
,变量在赋值操作符"="的左边,所以属于LHS -
console.log( a );
,变量不在赋值操作符的左边,而是直接获取变量的值,所以属于RHS
这里之所以要介绍着两种变量找寻方式,是因为这两种变量找寻方式在作用域中会有不同的表现,如:在RHS模式下,如果找到了对应变量,则返回该变量,反之未找到对应变量,会弹出ReferenceError
;而在LHS模式下,如果未找到对应变量,则根据不同情况作出不同反应,如果是“Strict Mode”下,则会弹出ReferenceError
,而非“Strict Mode”则在当前作用域下自动创建该变量
4. JS中的作用域(语义作用域)
说了这么多,如何识别JS中的作用域呢?首先从大的层面了解一下作用域的分类,一般来说作用域可分为两种:
- 语义作用域,即在"分词/语法分析"定义的作用域,或者说在代码编写阶段就已经决定了作用域的结构范围,JS使用的就是语义作用域
- 动态作用域,Bash脚本,Perl中依然使用的是动态作用域,本文不予讨论
而在JS中,根据代码形式,作用域也可以分为两种:
- 函数作用域,顾名思义,函数作用域就是通过定义一个JS的function而生成的作用域,在JS中所谓的语义作用域指的就是函数作用域,这一点一定要记清楚
- 块级作用域,而块级作用域则是通过定义一个JS的代码块生成的作用域,块级作用域的典型示例:
-
{}
,即单独的代码块 -
for(;;) {}
,即for循环代码块 -
if() {}
,即if判断代码块
-
块级作用域其实只是形式上的作用域,它并是严格意义上的语义作用域,所以会出现代码块里的变量声明直接被Hoisting其外部语义作用域(函数作用域)顶部的情况
那么除开写法上的不同,函数作用域和块级作用域主要有什么区别呢?其实它们最重要的区别在于函数作用域可以进行有效的变量隔离,即在函数作用域里定义的变量不会影响其嵌套作用域,这在模块化开发里尤其有用,它可以保证在A模块定义的变量不会影响与B模块的同名变量,更不会污染global作用域,典型的函数作用域示例:
- 方法定义与调用
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
- IIFE(Invoking Function Expressions Immediately)
var a = 2;
(function foo(){
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
需要注意的是IIFE的方法不能在外部语义scope里再次调用,如:
(function foo() {
a = 2;
console.log("a is " + a);
})();
foo();//ReferenceError
看下列示例,并思考这段代码中包含有几个函数(语义)作用域:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
这段代码有三个函数作用域:
- 作用域1:全局作用域,只定义了一个变量
foo
- 作用域2:
foo
方法内的作用域,定义了三个变量,b
,a
和bar
- 作用域3:方法
bar
内的作用域,定义了一个变量c
其中,作用域1是作用域2的嵌套作用域,而2又是3的嵌套作用域,如在作用域3中需要使用变量a
的值,但是此时在自己的作用域中并未找到变量a
,那么就会到其上一级嵌套作用域,也就是作用域2中找寻变量a
,以此类推;同时语义作用域只与方法的定义位置有关,与其调用位置毫无关系(所以也叫[语义]作用域) ;另外,在根据语义作用域进行变量找寻的时候,只适用于单独变量的情况,如a
,b
等,而对于通过对象属性找寻变量的情况,如foo.bar.baz
就不是根据语义作用域进行变量的找寻,而是通过对象属性访问规则找寻其对应变量
上面已经说过,块级作用域其实只是相当于形式上的作用域,没有任何变量隔离效果,如下面代码:
function foo() {
function bar(a) {
i = 3; // 就是for循环中创建的变量i
console.log( a + i );
}
for (var i=0; i<10; i++) {// i属于foo方法所创造的作用域
bar( i * 2 ); // 死循环
}
}
foo();
即在块级作用域中定义的变量实际上还是属于其对应的语义作用域内,或者说离它最近的函数作用域,这一点很容易造成错误
function foo() {
var i = 1;
for (var i=0; i<10; i++) {// 由于在foo方法创造的作用域中,变量i已经存在,所以此时for循环中的i其实就是
//上面的"var i = 1;"创建的i
//do something
}
console.log("now i is " + i);//10
}
foo();
到了ES6,可以通过let
与const
实现块级作用域的变量隔离,即通过let
在块级作用域中声明变量,该变量将只会存在于该块级作用域中
function foo() {
var i = 1;
for (let i=0; i<10; i++) {
//do something
}
console.log("now i is " + i);//1
}
foo();
虽然说块级作用域并没有变量隔离的效果,但是使用得当,块级作用域也能发挥意想不到的用处,如:加快垃圾清理,来看下面代码
function process(data) {
// do process
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){// 闭包的存在
console.log("button clicked");
}, /*capturingPhase=*/false );
可以发现在click事件对应的方法中,someReallyBigData
完全无用,可以将其回收掉,以减轻内存负担,但由于有闭包的存在,JS并不会马上对其进行回收,那么此时可以采用下列写法
function process(data) {
// do process
}
// block scope定义的任何数据都可以在scope结束后清理掉
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
这段代码里,将大量临时数据的处理放置于外部语义作用域的块级作用域中,它不会受到闭包的影响,在执行完成后会被JS的垃圾回收机制及时清理