you don't know js -- Scope and Closures学习笔记——第三章(函数VS块作用域)

以下内容为自己看原版尝试做的翻译,仅当一个自己的看书记录,书中内容绝大部分都翻译了,但由于个人能力有限,建议各位看客不要迷信翻译的质量,推荐购买其英文原版学习观看。

正如我们在第二章探索的,作用域由一系列“泡泡”构成,每个泡泡充当一个容器,在这里面定义了标识符(变量,函数)。这些泡泡整齐的嵌套,这些嵌套是在author time决定的。

函数作用域

JavaScript有基于函数的作用域。也即是说,每一个你声明的函数都为他自己创建了一个泡泡,但是没有其他结构创建他们自己的泡泡。但我们将会看到,这不完全对。

首先,我们来看看函数作用域及其实现。

考虑如下代码:

function foo(a){
	var b = 2;
	// some code

	function bar(){
		// ...
	}

	// more code
	var c = 3;
}

在这个代码段中,foo(..)的作用域泡泡包含标识符a,b,cbar。在这个作用域中相应的变量或者函数的声明出现在何处是无所谓的。在下章我们会探究这是如何实现的。

bar(..)拥有自己的作用域泡泡。全局作用域也一样,它拥有一个叫foo的标识符。

因为abcbar都属于foo(..)的作用域泡泡,他们在foo(..)的外部是无法被访问的。也就是说,下面的代码会导致ReferenceError错误,因为这些标志符对于全局作用域不可用。

bar(); // fails
console.log(a, b, c); // 都出错

然而,所有这些标识符(a,b,cbar)在foo(..)内部都可以访问,而且在bar(..)里面也可以(假设bar(..)里面没有shadow标识符的声明)。

函数作用域鼓励这一想法——所有变量都属于这个函数,而且可以在整个函数内部(事实上,甚至包括嵌套的作用域中)被使用和重用。这个设计思想相当有用,并且可以充分使用JavaScript变量的“动态”性质来呈现所需的不同类型。

另一方面,如果你不小心使用,在整个作用域存在的变量可能会带来一些意外的坑。

Hiding in Plain Scope

对于函数的传统思考方式是你声明一个函数并在里面添加代码。但对其的逆向思考是非常强大和有用的——将你任意的代码段用一个函数包裹起来,这实际上实现了“隐藏”代码。

上述方式带来的实际结果是创建了围绕这段代码的一个作用域泡泡,这意味着代码中的任何声明将与包裹其的函数的作用域相关联。而不是之前封闭的作用域。换句话说,你可以通过将变量和函数封闭到一个作用域中来“隐藏”他们。

为什么“隐藏”变量和函数是一个有用的技术手段?

有各种各样的原因鼓励这种基于作用域的隐藏。这种倾向源自于一个软件设计原则——Principle of Least Privilege,有时也被称为Least Authority or Least Exposure.这个原则声明在软件设计中,例如一个模块/对象的API,你应该尽可能的只暴露最小最必要的东西,然后“隐藏”剩下的所有东西。

这个原则也延伸到包含变量和函数的作用域的选择。如果所有变量和函数都在全局作用域,他们当然可以被任何嵌套的作用域访问。但是这将违反“Least...”原则。

例如:

function doSomething(a){
	b = a + doSomethingElse(a * 2);
	console.log(b * 3);
}

function doSomethingElse(a){
	return a - 1;
}

var b;

doSomething(2); // 15

在这个代码段中,变量bdoSomethingElse(..)函数在doSomething(..)工作时很可能是其“private” details。让bdoSomethingElse(..)暴露在全局不仅没有必要而且可能有“危险”,因为他们可能会有意或者无意的以意想不到的方式被使用,这也可能违背了doSomething(..)的初衷。一个更“恰当”的设计应该在doSomething(..)的作用域中隐藏这些私有的细节,比如:

function doSomething(a){
	function doSomethingElse(a){
		return a - 1;
	}

	var b;
	b = a + doSomethingElse(a * 2);
	console.log(b * 3);
}
doSomething(2); // 15

现在,bdoSomethingElse(..)将对外界不可见了,相反,仅由dSomething(..)控制。函数的功能和结果并没有受影响,这种设计使得私有细节私有。

冲突避免 Collision Avoidance

在一个作用域中“隐藏”变量和函数的另一个好处是避免两个同名的但意在用于不同场景的标识符的无意冲突。冲突常常发生在对值的意想不到的覆盖。

例如:

function foo(){
	function bar(a){
		i = 3; // 在下面的for循环中改变i
		console.log(a + i);
	}
	
	for(var i=0; i<10; i++){
		bar(i * 2);	// oh。。无限循环了
	}
}

foo();

bar(..)中的i=3的赋值意外的覆盖了foo(..)函数中用于for循环的i。这种情况下会导致无限循,因为i被设置为了一个定值3,而3恒小于10

bar(..)中的赋值需要声明一个局部变量,不管选择什么标识符名。var i = 3;将会修复这个问题(也会为i创建一个“shadowed variable”声明)。

全局命名空间

变量冲突的发生在全局作用域尤为明显。在你程序中加载的大量的库如果没有合理的隐藏他么内部/私有函数和变量的话将会非常容易互相冲突。

这些库一般都会在全局作用域床加一个唯一的变量,通常是一个拥有独特意义名字的对象。这个对象被用作库的一个命名空间,在里面特定的函数都被作为这个对象的属性暴露,而不是作为标识符暴露在顶级词法作用域。

例如:

var MyReallyCoolLibrary = {
	awesome: "stuff",
	doSomething: function(){
		// ...
	},
	doAnotherThing: function(){
		// ...
	}
};

模块管理

另一个避免冲突的选择就是更多的现代模块方法,使用任何一个各种各样的依赖管理器。使用这些工具,任何库都不会往全局作用域增加任何标识符,相反,需要使用依赖管理器提供的多种手段向需要使用这些库的作用域中显式的导入所需库的标识符。

应该观察到这些工具并不是具有“魔法”功能来跳出词法作用域规则。他们只是使用了作用域规则来使得没有标识符被注入了任何共享的作用域,相反,他们被保存于私有的,无冲突的作用域中。

因此,你也可以不用这些依赖管理工具,自己实现同样的功能。在第五章有更多关于模块模式的例子。

函数作用域

我们已经看到,我们可以将任意代码段包裹在一个函数中,并且这将有效的“隐藏”任何封闭在内的变量或函数。

例如:

var a = 2;

function foo(){ // <-- 插入这个
	
	var a = 3;
	console.log(a);	// 3	

} // <-- 这个
foo();	// <-- 还有这个

console.log(a); // 2

虽然这个技巧有用,但是他不一定非常理想。它也会带来一些问题。首先,我们不得不声明一个名为foo()的函数,这意味着叫foo的标识符它本身“污染”了包裹其的作用域(这里是全局)。我们也不得不显式的执行函数foo()来使得包裹在内的代码被执行。

更理想的情况是函数没有名字而且也能被自动执行。

幸运的是,JavaScript提供了一个解决上述两个问题的方案。

var a = 2;
(function foo()){ // <-- 插入这个

	var a = 3;
	console.log(a); // 3

})();	// <-- 这个

console.log(a); // 2

我们来看看发生了什么。

首先,注意这个包裹函数(function...语句以(开始,这个小细节带来了大改变。这种写法将函数看做函数表达式,而不是标准的函数定义。

The easiest way to distinguish declaration vs. expression is the
position of the word function in the statement (not just a line,
but a distinct statement). If function is the very first thing in
the statement, then it’s a function declaration. Otherwise, it’s a
function expression.

我们可以看到函数定义式和函数表达式的关键不同在于他们的名字是否被限制为一个标识符。

对比先前的两个代码段。前者,函数名foo被限制在封闭的作用域,并且我们直接用foo()来调用。在第二个代码段中,函数名foo并没有被限制在封闭的作用域中,相反仅被限制在他自己的函数内部。

换句话说,(function foo(){..})作为一个表达式意味着标识符foo能被..表明的作用域找到,并不在外层的作用域。将函数名foo隐藏在其内部意味着他没有污染其所处的作用域。

匿名VS具名

你可能非常熟悉callback参数的函数表达式,例如:

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

这被称作一个匿名函数表达式,因为function()没有名字。函数表达式可以匿名,但是函数定义式必须有名字。

匿名函数表达式的编写还是挺简单的,而且许多类库和工具都倾向于鼓励这种惯用的代码风格。但是,也有一些缺点需要考虑:

  1. 匿名函数在stack traces中没有有用的名字来显示,这将使得debug更加困难。

  2. 没有函数名的话,如果一个函数需要引用它自己,例如递归,等等,你需要使用不赞成使用的arguments.callee来实现引用。另一个需要自我引用的例子是当一个事件处理函数在触发之后想要解绑事件。

  3. 匿名函数省略了名字,而函数名在提供更可读/可理解的代码中非常有用。

内联函数表达式是非常强大和有用的。最佳的做法始终为你的函数表达式取个名字:

setTimeout(function timeoutHandler(){	// <-- look, I
	console.log("I waited 1 second!");	// have a name
}, 1000);

立即调用函数表达式

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

console.log(a); // 2

现在我们有一个使用( )包裹起来的函数表达式,我们可以执行这个函数通过在其末尾增加另一个(),如(function foo(){..})()。第一个封闭的括号使函数成为一个表达式,第二个括号执行了这个函数。

这个写作模式非常普通,几年前社区同意为它取一个术语:IIFE,他表示
立即调用的函数表达式(immediately invoked function expression)。

当然,IIFE并不必须需要一个名字——IIFE最常见的使用形式是匿名函数表达式。当然,命名的IIFE不是很常见,但是他有上面所提及的匿名函数没有的有点,因此采用这种做法是一个很好的实现方式。

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

console.log(a); // 2

现在对于传统的IIFE形式有一些轻微的变化,一些人更喜欢(function(){..}())。仔细看看两者的不同。在第一个形式中,函数表达式被包裹在()中,然后调用的()紧跟在它的外面。在第二个形式中,调用的()移动到了最外层()的里面。

这两种形式在函数功能上是完全一致的。纯粹是代码风格的不同

IIFE的另一个相当普通的变化是当做正常函数使用他们,调用,并传递参数。

例如:

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

console.log(a); // 2

我们命名了一个global参数,并传递了window对象的引用。当然,你可以任意命名参数,也可以为这个封闭的作用域传递任何你想传的参数。

这种模式的另一个应用是解决对undefined的担心。因为默认的undefined标识符的值可能会被错误的覆盖,从而带来未知的结果。通过命名一个参数为undefined,但是不传递任何值给这个参数,我们可以保证undefined标识符的值为undefined

如:

undefined = true; // 为其他代码埋下了地雷!要避免!

(function IIFE(undefined){
	var a;
	if(a === undefined){
		console.log("undefined is safe here!");
	}
})();

IIFE还有一种变种,此时要执行的函数放到了第二位,在第二个括号里面。这个模式在UMD(Universal Module Definition)项目中被使用。一些代码猴发现这样更加清楚和易于理解,尽管这有点冗长。

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

def函数表达式在上述代码段的第二部分中定义,然后作为一个参数(也叫做def)传递给代码段中第一部分定义的IIFE函数。最终,def参数(这个函数)被调用,将window作为global参数传递进去。

Blocks as Scopes

虽然函数是最常见的作用域单元,也是流行的JS中使用最广的设计方法。但还有其他实现作用域单元的方法。

不同于JavaScript,许多语言支持块级作用域。

尽管你可能从来没有写过一行基于块级作用域的代码,在JavaScript中你仍然可能熟悉这个及其普通的术语:

for(var i = 0; i < 10; i++){
	console.log(i);
}

我们在for循环的头部直接定义了变量i,多数情况下,我们可能仅仅想在for循环中使用这个i,而潜意识的忽略了这个i实际的作用域属于整个函数或全局。

这就是块级作用域的全部。根据他们可能要使用的地方,尽可能近,尽可能局部的声明变量。另一个例子:

var foo = true;
if(foo){
	var bar = foo*2;
	bar = something(bar);
	console.log(bar);
}

我们仅仅在if语句的上下文中使用了变量bar,因此我们感觉应该将他在if块中定义。然而,当我们使用var定义变量时,在何处定义并无意义。因为他们将总是属于封闭的作用域。这个代码段本质上来说,是一个假的块级作用域。

块级作用域是一个Principle of Least Privilege的扩展,从在函数中隐藏信息扩展到在我们的代码块中隐藏信息。

再次考虑for循环的例子:

for(var i = 0; i < 10; i++){
	console.log(i);
}

为什么要用一个仅会用于(或者,至少应该用于)for循环的i变量来污染整个函数的作用域呢?

针对i的块级作用域(如果可能的话)将会使i仅在for循环中可用,当它用在其他地方时会产生一个错误。这将确保变量不会以混乱的或者难以维护的方式重用。

但悲剧的现实是,表面上看来,JavaScript没有实现块级作用域的设备。

除非,你继续深挖一点。

with

。。。

try/catch

一个不为人知的事实是JavaScript在ES3中规定了在try/catch中的catch从句中定义的变量有属于catch块的块级作用域。

例如:

try{
	undefined();	// 非法的表达式产生一个异常
}
catch(err){
	console.log(err); // works!
}
console.log(err);	// ReferenceError: 'err' not found

正如你看到的,err仅在catch字句中存在,并且会在其他你尝试引用它的地方抛出错误。

While this behavior has been specified and true of practically
all standard JS environments (except perhaps old IE), many
linters seem to still complain if you have two or more catch
clauses in the same scope that each declare their error variable
with the same identifier name. This is not actually a redefinition,
since the variables are safely block-scoped, but the
linters still seem to, annoyingly, complain about this fact.
To avoid these unnecessary warnings, some devs will name
their catch variables err1, err2, etc. Other devs will simply
turn off the linting check for duplicate variable names.

let

目前为止,我们看到JavaScript仅有一些奇怪的小众行为来实现块级作用域。如果这就是我们所拥有的,那么块级作用域在JavaScript中对代码猴而言并不是非常有用。

幸运的是,ES6做出了改变,引入了一个新的关键字let,同var一样,它是另一种声明变量的方式。

let关键字使得定义的变量属于包含它的块(通常是一个{...})的作用域。换句话说,let为它定义的变量隐式的劫持了块级作用域。

var foo = true;
if(foo){
	let bar = foo*2;
	bar = something(bar);
	console.log(bar);
}

console.log(bar);	// ReferenceError

使用let来讲一个变量附加到一个已有的块是隐式的。

为块级作用域创建一个显式的块将使得变量属于的块更明显。通常,显式的代码比隐式的更好。显示的代码作用域风格非常容易实现也更自然的符合块级作用域在其他语言中的实现。

var foo = true;
if(foo){
	{	// <-- 显式块
		let bar = foo*2;
		bar = something(bar);
		console.log(bar);
	}
}
console.log(bar);	// ReferenceError

我们可以通过在一个合法的语句的任何地方包含一个{}来创建一个为let使用的代码块。在这种情况下,我们在if内部已经显式的创建了一个块,这个块可以作为一个整体移动,而不影响其在if语句中的位置和语义。

然而,使用let的定义将不会上升到整个块级作用域。这种定义在直到其定义语句处才可见。

{
	console.log(bar); // ReferenceError!
	let bar = 2;
}

垃圾回收

块级作用域有用的另一个原因与闭包和垃圾回收有关。这里会做一个简单的介绍,关于闭包技术的细节将在第五章讲解。

考虑如下例子:

function process(data){
	// do something interesting
}
var someReallyBigData = {..};
process(someReallyBigData);
var btn = document.getElementById("my_button");
btn.addEventListener("click", function click(evt){
	console.log("button clicked")
}, /*capturingPhase=*/false);

click函数的click回调并不需要someReallyBigData变量。这表示,理论上来讲,当执行完process(..),这个剧占内存的结构应该被垃圾回收掉。然而,很可能JS引擎会继续保留这个结构,因为click函数在整个作用域上有一个闭包。

块级作用域可以解决这个问题,通过清楚的告知引擎不需要保留someReallyBigData

function process(data){
	// do something interesting
}

// 在这个块里声明的任何东西随后都会被回收掉
{
	let someReallyBigData = {..};
	process(someReallyBigData);
}
var btn = document.getElementById("my_button");
btn.addEventListener("click", function click(evt){
	console.log("button clicked");
}, /*capturingPhase=*/false);

Declaring explicit blocks for variables to locally bind to is a powerful
tool that you can add to your code toolbox.

let loops

let的一个特别亮点是在for循环中使用。

for(let i = 0; i < 10; i++){
	console.log(i);
}
console.log(i);	// ReferenceError

let不仅在for循环的头部为循环体绑定变量i,实际上,每次循环都会重新绑定一次i,以确保在前一个循环结束后,能给i赋值。

另一个例子:

{
	let j;
	for(j = 0; j < 10; j++){
		let i = j; // re-bound for each iteration!
		console.log(i);
	}
}

const

除了let,ES6还引入了const,他也能创建一个属于块级作用域的常量。任何尝试对其的值进行修改将会导致错误。

var foo = true;
if(foo){
	var a = 2;
	const b = 3; // block-scoped to the containing 'if'

	a = 3; // just fine!
	b = 4; // error!定
}
console.log(a); // 3
console.log(b); // ReferenceError!

复习

在JavaScript中函数是最基本的作用域单元。声明在一个函数中的变量或者函数本质上对其他的封闭作用域是“不可见”的,这也是好软件的一个故意的设计原则。

但是函数并不是唯一的作用域单元。块级作用域指的是代码中的变量和函数可以属于任意(通常来讲,任意{...})的块,而不仅仅属于这个封闭的函数。

从ES3开始,try/catch结构的catch子句就拥有一个块级作用域。

在ES6中,let关键字(var关键字的兄弟)被引入来在任意的代码块中声明变量。if(..){let a = 2;}将会声明一个变量a,这个变量本质上只属于if{..}块。

posted on 2015-04-18 22:40  锟斤拷  阅读(177)  评论(0编辑  收藏  举报