Fork me on GitHub

JS学习系列 03 - 函数作用域和块作用域

function-scope

在 ES5 及之前版本,JavaScript 只拥有函数作用域,没有块作用域(with 和 try…catch 除外)。在 ES6 中,JS 引入了块作用域,{ } 内是单独的一个作用域。采用 let 或者 const 声明的变量会挟持所在块的作用域,也就是说,这声明关键字会将变量绑定到所在的任意作用域中(通常是 {…} 内部)。

今天,我们就来深入研究一下函数作用域块作用域

函数中的作用域

函数作用域的含义是指,属于这个函数的任何声明(变量或函数)都可以在这个函数的范围内使用及复用(包括这个函数嵌套内的作用域)。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo (a) {
var b = 2;

// something else

function bar () {
// something else
}

var c = 3;
}

bar(); // 报错,ReferenceError: bar is not defined
console.log(a, b, c); // 报错,原因同上

在这段代码中,函数 foo 的作用域包含了标识符a、b、c 和 bar ,函数 bar 的作用域中又包含别的标识符。

由于标识符 a、b、c 和 bar都属于函数 foo 的作用域,所以在全局作用域中访问会报错,因为它们都没有定义,但是在函数 foo 内部,这些标识符都是可以访问的,这就是函数作用域。

为什么要有这些作用域

当我们用作用域把代码包起来的时候,其实就是对它们进行了“隐藏”,让我们对其有控制权,想让谁访问就可以让谁访问,想禁止访问也很容易。

想像一下,如果所有的变量和函数都在全局作用域中,当然我们可以在内部的嵌套作用域中访问它们,但是因为暴露了太多的变量或函数,它们可能被有意或者无意的篡改,以非预期的方式使用,这就导致我们的程序会出现各种各样的问题,严重会导致数据泄露,造成无法挽回的后果。

例如:

1
2
3
4
5
6
7
8
9
var obj = {
a: 2,
getA: function () {
return this.a;
}
};

obj.a = 4;
obj.getA(); // 4

这个例子中,我们可以任意修改对象 obj 内部的值,在某种情况下这并不是我们所期望的,采用函数作用域就可以解决这个问题,私有化变量 a 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var obj = (function () {
var a = 2;
return {
getA: function () {
return a;
},
setA: function (val) {
a = val;
}
}
}());

obj.a = 4;
obj.getA(); // 2
obj.setA(8);
obj.getA(); // 8

这里通过立即执行函数(IIFE)返回一个对象,只能通过对象内的方法对变量 a 进行操作,其实这里有闭包的存在,这个我们在以后会深入讨论。

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,冲突会导致变量的值被意外覆盖。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
function foo () {
function bar (a) {
i = 3; // 修改了 for 循环所属作用域中的 i
console.log(a + i);
}

for (var i = 0; i < 10; i++) {
bar(i * 2); // 这里因为 i 总会被设置为 3 ,导致无限循环
}
}

foo();

bar(…) 内部的赋值表达式 i = 3 意外的覆盖了声明在 foo(…) 内部 for 循环中的 i ,在这个例子中因为 i 始终被设置为 3 ,永远满足小于 10 这个条件,导致无限循环。

bar(…) 内部的赋值操作需要声明一个本地变量来使用,采用任何名字都可以,var i = 3; 就可以满足这个要求。另外一种方法是采用一个完全不同的标识符名称,比如 var j = 3; 。但是软件设计在某种情况下可能自然而然的要求使用同样的标识符名称,因此在这种情况下使用作用域来“隐藏”内部声明是唯一的最佳选择。

总结来说,作用域可以起到两个作用:

  • 私有化变量或函数
  • 规避同名冲突

函数声明和函数表达式

如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

函数声明举个例子:

1
2
3
function foo () {
// something else
}

这就是一个函数声明。

函数表达式分为匿名函数表达式和具名函数表达式。

对于函数表达式来说,最熟悉的场景可能就是回调参数了,例如:

1
2
3
setTimeout(function () {
console.log("I wait for one second.")
}, 1000);

这个叫作匿名函数表达式,因为 function ()… 没有名称标识符。函数表达式可以是匿名的,但是函数声明不可以省略函数名,在 javascript 中这是非法的。

匿名函数表达式书写简便,但是它也有几个缺点需要注意:

  1. 匿名函数在浏览器栈追踪中不会显示出有意义的函数名,这会加大调试难度。
  2. 如果没有函数名,当函数需要引用自身的时候就只能使用已经不是标准的 arguments.callee 来引用,比如递归。在事件触发后的事件监听器中也有可能需要通过函数名来解绑自身。
  3. 匿名函数对代码的可读性和可理解性有一定的影响。一个有意义的函数名可以让代码不言自明。

具名函数表达式又叫行内函数表达式,例如:

1
2
3
setTimeout(function timerHandler () {
console.log("I wait for one second.")
}, 1000);

这样,在函数内部需要引用自身的时候就可以通过函数名来引用,当然要注意,这个函数名只能在这个函数内部使用,在函数外使用时未定义的。

立即执行函数表达式(IIFE)

IIFE 全写是 Immediately Invoked Function Expression,立即执行函数。

1
2
3
4
5
6
7
8
var a = 2;

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

console.log(a); // 2

由于函数被包含在一对 ( ) 括号内部,因此成为了一个函数表达式,通过在末尾加上另一对 ( ) 括号可以立即执行这个函数,比如 (function () {})() 。第一个 ( ) 将函数变成函数表达式,第二个 ( ) 执行了这个函数。

也有另外一种立即执行函数的写法,(function () {}()) 也可以立即执行这个函数。

1
2
3
4
5
6
7
8
var a = 2;

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

console.log(a); // 2

这两种写法功能是完全一样的,具体看大家使用。

IIFE 的另一种普遍的进阶用法是把它们当做函数调用并传递参数进去。

1
2
3
4
5
6
7
8
9
var a = 2;

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

console.log(a); // 2

我们将 window 对象的引用传递进去,但将参数命名为 global,因此在代码风格上对全局对象的引用变得比引用一个没有“全局”字样的变量更加清晰。当然可以从外部作用域传递你需要的任何东西,并将变量命名为任何你觉得合适的文字。这对于改进代码风格是非常有帮助的。

这个模式的另外一个应用场景是解决 undefined 标识符的默认值被错误覆盖的异常(这并不常见)。将一个参数命名为 undefined ,但是并不传入任何值,这样就可以保证在代码块中 undefined 的标识符的值就是 undefined 。

1
2
3
4
5
6
7
8
undefined = true;

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

块作用域

ES5 及以前 JavaScript 中具有块作用域的只有 with 和 try…catch 语句,在 ES6 及以后的版本添加了具有块作用域的变量标识符 let 和 const 。

with

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
a: 2,
b: 3
};

with (obj) {
console.log(a); // 2
console.log(b); // 3
}

console.log(a); // 报错,a is not defined
console.log(b); // 报错,a is not defined

用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

try…catch

1
2
3
4
5
6
7
try {
undefined(); // 非法操作
} catch (err) {
console.log(err); // 正常执行
}

console.log(err); // 报错,err is not defined

try/catch 中的 catch 分句会创建一个块作用域,其中的变量声明仅在 catch 内部有效。

let

let 关键字可以将变量绑定到任意作用域中(通常是 {…} 内部)。换句话说,let 为其声明的变量隐式的劫持了所在的块作用域。

1
2
3
4
5
6
7
8
9
10
11
var foo = true;

if (foo) {
let a = 2;
var b = 2;
console.log(a); // 2
console.log(b); // 2
}

console.log(b); // 2
console.log(a); // 报错,a is not defined

用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。在开发和修改代码的过程中,如果没有密切关注哪些代码块作用域中有绑定的变量,并且习惯性的移动这些块或者将其包含到其他块中,就会导致代码混乱。

为块作用域显示的创建块可以部分解决这个问题,使变量的附属关系变得更加清晰。

1
2
3
4
5
6
7
8
var foo = true;

if (foo) {
{
let a = 2;
console.log(a); // 2
}
}

在代码的任意位置都可以使用 {…} 括号来为 let 创建一个用于绑定的块。

还有一点要注意的是,在使用 var 进行变量声明的时候会存在变量提升,提升是指声明会被视为存在于其所出现的作用域的整个范围内。但是使用 let 进行的声明不会存在作用域提升,声明的变量在被运行之前,并不存在。

1
2
3
4
5
6
7
8
9
console.log(a);      // undefined
console.log(b); // 报错, b is not defined

// 在浏览器中运行这段代码时,因为前面报错了,所以不会看到接下来打印的结果,但是理论上就是这样的结果
var a = 2;
console.log(a); // 2

let b = 4;
console.log(b); // 4

2.3.1 垃圾收集
另一个块作用域非常有用的原因和闭包及垃圾内存的回收机制有关。
举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function processData (data) {
// do something
}

var bigData = {...};

processData(bigData);

var btn = document.getElementById('my_button');

btn.addEventListener('click', function () {
console.log('button clicked');
}, false);

这个按钮点击事件的回调函数中并不需要 bigData 这个非常占内存的数据,理论上来说,当 processData 函数处理完之后,这个占有大量空间的数据结构就可以被垃圾回收了。但是,由于这个事件回调函数形成了一个覆盖当前作用域的闭包,JavaScript 引擎极有可能依然保存着这个数据结构(取决于具体实现)。

使用块作用域可以解决这个问题,可以让引擎清楚的知道没有必要继续保存这个 bigData 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function processData (data) {
// do something
}

{
let bigData = {...};

processData(bigData);
}

var btn = document.getElementById('my_button');

btn.addEventListener('click', function () {
console.log('button clicked');
}, false);

2.3.2 let 循环
一个 let 可以发挥优势的典型例子就是 for 循环。

1
2
3
4
5
6
7
8
9
10
var lists = document.getElementsByTagName('li');

for (let i = 0, length = lists.length; i < length; i++) {
console.log(i);
lists[i].onclick = function () {
console.log(i); // 点击每个 li 元素的时候,都是相对应的 i 值,而不像用 var 声明 i 的时候,因为没有块作用域,所以在回调函数通过闭包查找 i 的时候找到的都是最后的 i 值
};
};

console.log(i); // 报错,i is not defined

for 循环头部的 let 不仅将 i 绑定到 fir 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保上一个循环迭代结束时的值重新进行赋值。

当然,我们在 for 循环中使用 var 时也可以通过立即执行函数形成一个新的闭包来解决这个问题。

1
2
3
4
5
6
7
8
9
var lists = document.getElementsByTagName('li');

for (var i = 0, length = lists.length; i < length; i++) {
lists[i].onclick = (function (j) {
return function () {
console.log(j);
}
}(i));
}

或者

1
2
3
4
5
6
7
8
9
var lists = document.getElementsByTagName('li');

for (var i = 0, length = lists.length; i < length; i++) {
(function (i) {
lists[i].onclick = function () {
console.log(i);
}
}(i));
}

其实原理无非就是,为每个迭代创建新的闭包,立即执行函数执行完后本来应该销毁变量,释放内存,但是因为这里有回调函数的存在,所以形成了闭包,然后通过形参进行同名变量覆盖,所以找到的 i 值就是每个迭代新闭包中的形参 i 。

const

除了 let 以外,ES6 还引入了 const ,同样可以用来创建作用域变量,但其值是固定的(常亮)。之后任何试图修改值的操作都会引起错误。

1
2
3
4
5
6
7
8
9
10
11
12
var foo = true;

if (foo) {
var a = 2;
const b = 3; // 包含在 if 中的块作用域常亮

a = 3; // 正常
b = 4; // 报错,TypeError: Assignment to constant variable
}

console.log(a); // 3
console.log(b); // 报错, b is not defined

和 let 一样,const 声明的变量也不存在“变量提升”。

总结

函数是 JavaScript 中最常见的作用域单元。块作用域指的是变量和函数不仅可以属于所处的函数作用域,也可以属于某个代码块。

本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。

有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开发者可以并且也应该根据需要选择使用哪种作用域,创造可读、可维护的优良代码。