JS 的函数作用域和块作用域

1,765 阅读6分钟

JS 的函数作用域


首先我们要了解 JS 的词法作用域。
词法作用域: 简单的说就是定义在词法阶段的作用域。换句话说是由你在写代码时将变量和块作用域写在哪决定的(有一些欺骗词法作用域的方法不在此处介绍)。我们可以将作用域看成一个气泡,示例如下

气泡1: 包含整个全局作用域,其中只有一个标识符: foo
气泡2: 包含 foo 所创建的作用域,其中有三个标识符: a, bar 和 b
气泡3: 包含 bar 所创建的作用域,其中只有一个标识符: c

注意:这里所说的气泡是严格包含的,并不是文氏图这种可以跨越边界的。也就是说没有一个气泡可以同时出现在两个外部作用域中


究竟是什么生成了新的气泡? 只有函数会生成新的气泡吗? JS 中其他结构能生成作用域气泡吗?继续看下去吧,或许会有不同的感受。

函数作用域 :属于这个函数的全部变量都可以在整个函数范围内使用及复用(嵌套的作用域中也可以使用)。

示例代码:标识符 a, b, c , bar 都附属于 foo(...) 的作用域气泡,以此无法在 foo(...) 的外部访问。

function foo(a){
	var b = 2;
    	// ...
       	function bar(){
        	// ...
        }
    	// ...
        
        var c = 3;
 }
 
 bar()  // ReferenceError
 console.log( a, b, c )	// ReferenceError
    

我们可以联想到模块或者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(...) 内部实现的私有内容。

改进代码:

function doSomething(a){
        function doSomethingElse(a){
            return a - 1 ;
        }
        var b;
	b = a + doSomethingElse( a * 2 );
    	console.log( b * 3 );
}
doSomething(2)	// 15

现在我们总结一下隐藏作用域的好处:

  • 规避同名标识符之间的冲突

    例如:

    function foo() {
    	function(a) {
        	i = 3; // 修改 for 循环所属作用域中的 i 
            console.log( a + 1 );
        }
        
        for ( var i=0; i<10; i++ ) {
        	bar( i * 2 );	// emmm, 无限循环了
        }
    }
    
    
  • 规避第三方库变量命名冲突 在全局作用域中,当程序加载第三方库时,如果他们没有妥善处理内部变量和函数,很容易引发冲突。

现在我们已经知道在任意代码外部添加包装函数,可以将内部变量和函数“隐藏”起来,外部作用域无法访问内部任何内容。

例如:

var a = 2;
function foo() { // <--添加这行
	var a = 3;
    	console.log(a);  // 3
}
foo();  // <--添加这行
console.log(a);	// 2;

虽然这种技术可以解决一些问题,但是它并不理想,会导致一些额外问题。

  • 必须声明一个具名函数 foo(),意味着 foo 这个名称本身就“污染”了所在的作用域
  • 必须显示地通过函数名 (foo()) 调用这行函数才能运行其中的代码

JS 提供了能够同时解决上述两个问题的方案:

var a = 2;
(function foo(){
	var a = 3;
    	console.log(a);	// 3;
})();
console.log(a);	// 2

包装函数的声明以 (function... 而不仅仅是以 function... 开始。函数会被当做函数表达式而不是以一个标准的函数声明来处理。 函数声明和函数表达式之间的重要区别是它们的名称标识符会被绑定在何处。 换句话说就是 (function foo(){ ... } 作为函数表达式意味着 foo 只能在 (...) 所代表的位置中被访问,外部则不行。 foo 变量名将被隐藏在自身中而不污染外部作用域。

由此我们可以想到匿名函数

匿名函数

对于函数表达式我们最熟悉的场景可能是回调参数了,比如:

setTimeout( function() {
	console.log("I waited 1 second!");
}, 1000);

这个叫做匿名函数表达式。因为 function().. 没有名称标识符。函数表达式可以匿名,而函数声明不可以省略函数名(JS中非法)。

匿名函数书写简单快捷,但是也有几个缺点需要考虑:

  • 匿名函数在栈追踪不会显示有意义的函数名,使得调试困难。
  • 没有函数名,当函数需要应用自身时只能使用已经过期的 arguments.callee 引用。
  • 省略了代码的可读性。一个描述性的名称可以让打码不言自明。

所以始终给函数表达式一个明明是一个最佳实践

立即执行函数表达式

var a = 2;
(function foo(){
	var a = 3;
    	console.log(a);	// 3;
})();
console.log(a);	// 2

由于函数被包含在 () 中,因此形成了一个表达式,通过在末尾增加一个 () 可以立即执行这个函数。

这种模式很常见,社区给它规定了一个术语: IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression)

相较于传统的 IIFE 形式,很多人更喜欢另一个改进模式: (function(){...}())。仔细观察其中的区别,第二种用来调用的 () 被移进用来包装的 () 中。这两种形式功能上一致,全凭个人喜好使用

IIFE 有一种用途可以倒置代码执行顺序,例如:

var a = 2;
(function IIFE( def ) {
	def( window );
})(function def( global ) {
	var a = 3;
    console.log( a );	//3
    console.log( global.a );	//2
});

块作用域

大家都知道 es6 引入 let 和 const 关键字,那在 es6 之前 js 有块作用域吗?答案是有的!

with

with 关键字是用来欺骗词法作用域的(现在并不推荐使用,可以作为了解知识点)。通常被当做重复引用同一个对象中的多个属性的快捷方式。

例如:

var obj = {
	a : 1,
    	b : 2,
    	c : 3
};
// 单调乏味的重复 obj
obj.a = 2;
obj.b = 3;
obj.c = 4;

// 简单的快捷方式
with (obj){
	a = 3;
    	b = 4;
    	c = 5;
}

这看起来好像并没有明显的块作用域,别急,看下面的代码:

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 ??? 看到这里很疑惑吧,看下面的解释

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理定义在这个词法标识符中。对上述代码我们可以这样理解:o1 传给 with 时,该作用域包含一个同 o1.a 属性相符的标识符。但是将 o2 作为作用域时,并没有 a 标识符,因此进行正常的 LHS 查找。o2 的作用域、foo(...) 的作用域和全局作用域都没有标识符 a , 因此当 a = 2 执行时自动创建一个全局变量(非严格模式下)。

try/catch

很少有人注意到 JS 的 es3 规范中规定的 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效

例如

try {
	undefined();	// 执行一个非法操作来强制制造一个异常
}
catch( err ) {
	console.log( err );	// 能够正常执行!
}

console.log( err );	// ReferenceError: err not found

正如你所看到的, err 仅存在 catch 内部, 当试图从别处引用它时会抛出错误。

或许 catch 分句创建的块作用域看起来如同鸡肋没有什么用处,但仔细研究还是会发现一些有用的信息(后续更新)