Javascript
作用域
作用域是引擎执行中寻找变量的区域。
JS使用JIT的编译方式,如:var a = 2;
,编译器时只会处理声明var a;
,当执行时才会处理赋值a = 2;
。
引擎在执行代码时会不停地寻找左值(LHS)与右值(RHS),先在最近的作用域寻找,找不到再到外层作用域,直到找到。
function foo(a) {
console.log(a + b);
b = a;
}
foo(2);
这段代码中LHS有:a = ...
(调用函数时隐含的),b = ..
,RHS有:foo(..
,console
,
a + ..
,.. + b
,.. = a
。左值右值不只是在等号左右的值。函数的定义并没有进行赋值,
只是在调用时才会用到的代码块。
这段代码执行a + b
时,引擎在查找右值b时,首先在foo
函数作用域中找,没有找到,再到全局查找,
依然没有,这时会抛出ReferenceError
异常。而如果是b = a
的左值b时,当全局也没找到时,
会创建一个全局变量b。但是,如果在strict mode
下执行则也会产生ReferenceError
异常。
词法作用域
词法作用域是在编码时就确定下来的,其作用是为了在作用域内查找特定标识符。
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 );
首先假设函数定义确定一个作用域,那么上面的代码定义了3个词法作用域:全局作用域、foo
作用域和bar
作用域。
函数的参数也是属于该函数内作用域的,如在查找console.log(a, b, c)
的标识符a
时,首先在最内层bar
作用域
寻找,没找到再到外层作用域,直到在foo
中找到。这个过程是由内向外的一个冒泡过程,一旦找到就不会再往外层寻找。
但是,有方法可以直接访问到全局变量,window
是一个全局对象,通过window.a
就可以忽略冒泡过程,直接访问到a
。
有两种方法可以破坏这种编码定义下来的词法作用域,eval
和with
,with
不要使用,eval
不到万不得已不要使用。
eval
可以执行一段代码,代码中如果有变量的操作,可能会覆盖掉外层的变量。
with
可用来简化对象赋值,也有同样的效果。
function foo(obj) {
with (obj) {
a = 2;
b = 2;
}
}
var o = {
a: 3
};
foo( o );
console.log( o.a );
上面的代码with
覆盖掉了o.a
的值,他在寻找标识符时先从对象o
内部开始,没找到b
,再在全局寻找,
依然没有就会创建一个全局变量a
。
不应使用这两个方法的原因不止这些,关键是会降低程序执行的性能。引擎在运行代码前会先进行词法分析确定标识符的作用域, 而这两个方法导致无法确定其中标识符的作用域范围,还需要在运行时重新编译,因此导致代码执行性能下降。
函数作用域
一个函数就定义了一个气泡作用域,作用域中的变量只有在该作用域内部可以访问,外部无法访问会抛出ReferenceError
。
函数作用域可以用来隐藏私有变量,符合设计模式中的最小特权,将尽量少的接口暴漏给用户,有助于提高软件的结构。
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 );
像这个doSomethingElse
函数和变量b
都被隐藏在doSomething
函数中,外部无法访问,如果接口不变,
内部需要修改,可以只改动doSomething
函数内部,而不被用户知道,有利于软件维护。
如果在函数作用域中使用和外部相同的变量名,可能会导致冲突:
function foo() {
function bar(a) {
i = 3;
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 执行后会将i改为3
}
}
foo();
这段代码会导致死循环,可以通过更改变量名或者添加作用域解决。
为了避免命名冲突,可以通过命名空间达到隔离效果,只将一个标识符暴露在外面,其他内容以属性方式存在。
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
更加现代的方式,是使用模块管理机制,管理不同模块的依赖,避免冲突。
函数作用域可以用来屏蔽标识符,函数表达式(function(){..})
,可以让函数只在其内部被访问到,不会造成变量污染。
函数表达式可以是匿名的,但是最好为他定义个名字,有利于调试、递归和其功能的理解。
函数表达式直接调用的方式叫做IIFE(Immediately Invoked Function Expression),只需给函数表达式后加一个括号即可。 这种调用也可以传递参数,比如传递一个函数表达式:
var a = 2;
(function IIFE( def ){
def( window );
})(function def( global ){
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});
IIFE还可以用在错误重载undefined
时:
undefined = true; // 错误的赋值
(function IIFE( undefined ){
var a;
if (a === undefined) {
console.log( "Undefined is safe here!" );
}
})();
上面的IIFE不给他传参数,会导致参数undefined
的值为undefined
,刚好符合其含义,屏蔽了undefined的错误赋值。
with
、catch
、let
、const
这些关键字提供了块作用域。
变量提升
编译器运行时,变量声明会在编译过程进行处理,引擎执行代码是,才会去解释执行赋值等操作。
console.log( a );
var a = 2;
这段代码可以被理解为:
var a;
console.log( a );
a = 2;
a
的声明被提升。
每个变量声明在提升时都只会在其所在作用域生效,函数声明也会被提升,而且函数声明会提升在变量声明之前。 另外,函数表达式不会被提升。
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};
这段代码被解释为:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
foo
被调用时没有赋值,类型错误;bar
只在函数内部有效,所以在全局找不到他的定义。
闭包
闭包就是函数在她的词法作用域之外被调用时,仍然能够访问她的词法作用域中的变量。
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz();
baz
创建了bar
的引用,当foo
执行完后,执行baz
时仍然能够访问到变量a
。
无论用什么方式访问内部函数,他都会保留一个他声明时的作用域引用,用来访问作用域中的值。
function wait(message) {
setTimeout( function timer(){
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );
这段代码中timer
持有外部作用域wait
的引用,当调用wait
的时候,回调函数timer
会在1s之后执行,
执行时他仍然能够访问到message
变量。
var a = 2;
(function IIFE(){
console.log( a );
})();
IIFE自身并不是闭包,他没有在其词法作用域之外被调用。a
和IIFE
在同一个词法作用域,所以不属于闭包。
for (var i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
这段代码是循环与闭包的结合,执行结果是每隔1s输出一个6。理解的关键在于,timer
回调函数总是在循环执行完之后才执行,
无论超时时间设置的是多少。也就意味着循环结束之后i已经设置为6,因为5个timer
都被闭包在了同一个共享作用域,
共享了变量i
。
解决这个问题可以通过创建多个闭包作用域达到目的,可以使用IIFE。
for (var i=1; i<=5; i++) {
(function(){
var j = i;
setTimeout( function timer(){
console.log( j );
}, j*1000 );
})();
}
或者更简洁版本
for (var i=1; i<=5; i++) {
(function(j){
setTimeout( function timer(){
console.log( j );
}, j*1000 );
})( i );
}
还可以通过使用let
定义块作用域达到目标。
for (let i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
let
会在每一次循环时创建一个块作用域,而且每次循环还会声明一个i
。
闭包可以用来创建模块:
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
如果只实例化一次模块可以用IIFE格式:
var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
通过返回命名的对象,可以从内部改变模块的实现:
var foo = (function CoolModule(id) {
function change() {
// modifying the public API
publicAPI.identify = identify2;
}
function identify1() {
console.log( id );
}
function identify2() {
console.log( id.toUpperCase() );
}
var publicAPI = {
change: change,
identify: identify1
};
return publicAPI;
})( "foo module" );
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE
ES6提供了新的功能,可以定义文件模块,以文件为区分。这种模块是静态的,不能像publicAPI
那样改变。
// bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;
export
用于导出方法。
// foo.js
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}
export awesome;
import .. from ..
用来导入模块的方法。
module foo from "foo";
module bar from "bar";
console.log(
bar.hello( "rhino" )
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO
module .. from ..
用来导入整个模块。
动态作用域
JS是没有动态作用域的,都是静态的词法作用域,他关心的是声明的位置,而不是被调用的位置。
function foo() {
console.log( a ); // 2,在这个词法作用域中a只能是全局的a
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();