替你读《你不知道的JavaScript》-(二)变量提升

您好,我是沧沧凉凉,是一名前端开发者,目前在掘金知乎以及个人博客上同步发表一些学习前端时遇到的趣事和知识,欢迎关注。


匿名与具名

没有名称表示符的通常就被称为匿名函数,匿名函数比较常见的地方是在回调函数上,比如声明一个定时器。

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

匿名函数表达式书写起来简单快捷, 很多库和工具也倾向鼓励使用这种风格的代码。 但是它也有几个缺点需要考虑。

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名, 当函数需要引用自身时只能使用已经过期的arguments.callee引用,比如在递归中。 另一个函数需要引用自身的例子, 是在事件触发后事件监听器需要解绑自身
  3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名。 一个描述性的名称可以让代码不言自明。

原书中推荐始终给函数表达式命名,比如上面的定时器代码应该写成:

setTimeout(function timeoutHandler() { // <-- 快看,我有名字了!
  console.log("I waited 1 second!");
}, 1000);

行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。

但是在我编写代码的过程中,很少会给回调函数命名,除非是直接引用另一个具名函数,比如下面这种:

function timeoutHandler() {
  console.log("I waited 1 second!");
}

setTimeout(timeoutHandler, 1000);

立即执行函数

因为直接在全局中声明的变量会污染全局作用域,所以我们经常会给它一个局部作用域,这个时候就不得不提到立即执行函数

(function(){
  console.log("我是立即执行函数");
})()

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

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

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

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

console.log(a); // 2

IIFE还有一种变化的用途是倒置代码的运行顺序, 将需要运行的函数放在第二位, 在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
});

其实就我个人的使用观感上来看,这种方式更加难以理解,在现在js文件被模块化引入的当下,这些写法我个人觉得都不常用到,所以了解一下这些写法就可以了,毕竟在ES6之前,ES5有着10来年的历史。

块作用域

在let和const出现之后,JavaScript也拥有了块作用域,以前使用var声明变量是没有块作用域这个概念的。

例如一个很普通的循环:

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

我们在for循环的头部直接定义了变量i,通常是因为只想在for循环内部的上下文中使用i,而忽略了i会被绑定在外部作用域(函数或全局)中的事实。

同时还有例如if语句:

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

其实在一般情况下,我们希望声明的变量bar仅仅只在if语句中能够访问,而不希望在能够在它的外部进行访问。

块作用域是一个用来对之前的最小授权原则进行扩展的工具, 将代码从在函数中隐藏信息扩展为在块中隐藏信息。

原书中有这句话:但可惜,表面上看JavaScript并没有块作用域的相关功能。除非你更加深入地研究。

在ES5时期,有两个with和try/catch就具有块作用域:

with

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

try/catch

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

let

到目前为止, 我们知道JavaScript在暴露块作用域的功能中有一些奇怪的行为。 如果仅仅是这样,那么JavaScript开发者多年来也就不会将块作用域当作非常有用的机制来使用了。幸好,ES6改变了现状,引入了新的let关键字,提供了除var以外的另一种变量声明方式。

除了上述的if语句和for循环,let关键字可以将变量绑定到所在的任意作用域中,

只要声明是有效的,在声明中的任意位置都可以使用{ .. }括号来为let创建一个用于绑定的块。

例如下面的代码:

{
  let a = 100;
}

console.log(a); // 报错

const

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

const a = 100;
a = 200; // 错误

但是对于复杂数据类型,例如:对象、数组,只要不修改变量a指向的地址而是修改数组或者对象则都不会报错:

例如:

const a = [100];
a[0] = 200;
const b = {
  c: 200
};

b.c = 300;

提升

在前文中讲解var的缺陷时,讲到了var声明的变量会发生变量提升的情况,导致本应该报错的代码可以被正确执行,其实在代码的运行中,会发生变量提升的不仅仅只有var声明的变量,甚至还有函数。

例如下面这段代码:

function run() {
  console.log("跑步");
}

run();
// 在函数声明之前就进行调用
eat();

function eat() {
  console.log("吃饭");
}

这段代码是能够正常运行的,那么为什么在函数eat声明之间就进行调用但是却不会报错,依然能成功运行呢?

在一般的情况下,我们预期JavaScript的代码执行都是从上到下依次执行,但是很明显上面的代码不符合预期,这就涉及到一个概念叫做变量提升。

其实真正的思路是:包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

优先级

正因为上面这些原因,使用var声明变量会出现变量提升导致很多意料之外的情况,所以在编程中谨慎使用var,能使用let或const就不要使用var来进行声明变量。

最后