[JS] You Don't Know JavaScript [Scope & Closures] - The (Not So) Secret Lifecycle of Variables

前言

经由前几篇文中应该对於全域作用域或嵌套全域作用域有一定的了解,但这仅仅只知道这麽变量是在哪一个作用域中宣告而已,若是我们在宣告这个变数之前就使用它会发生什麽事?又或是我们在同一个作用域中对同一个变数宣告两次会发生什麽事?

When Can I Use a Variable?

据我们所知,若我们要在这个作用域使用一个变量必须要在宣告他之後,但是这个观念是不一定的。

greeting(); //Hello!

function greeting(){
    console.log('Hello!');
}

上面的程序码中你可能会困惑,为什麽在greeting(...)被宣告之前就可以在第一行中使用?

回想What is Scope?中我们提到的,所有的标示在JS引擎编译的时候就已经注册到各自的作用域中了,此外每个标示都是在所属的作用域开头便被创建,因为这个现象所以就算变量被宣告在下方,但是上面还是可以使用这个变量,这个现象被称为hoisting

但是不能单纯使用hoisting来解释这个问题,我们在程序的一开始就看到了一个greeting的标示,但是为什麽我们可以在宣告他是function之前就呼叫greeting(...)呢?

这是因为function宣告的一个特别现象,称为function hoisting,当function的名称标示在作用域顶部被注册时,他会自动初始化对这个function的引用,换句话说greeting(...)这个function在这个作用域被注册的时候,便会去初始化所有使用到他的引用(第一行的greeting();)

另一个细节是function hoisting与变量的hoisting都是将自身的标示附加到最近的封闭函式作用域(若没有则是全域作用域)而不是块状作用域中。

Hoisting: Declaration vs. Expression

function hoisting只会发生在正式的function宣告而不会发生在function expression

greeting();  // TypeError

var greeting = function greeting() {
    console.log("Hello!");
};

在第一行中JS掷出了一个TypeError,一般来说TypeError代表着我们尝试使用一个不合法的值进行操作,在正常环境下JS会提供一些比较有用的错误讯息,比如说undefined in not a functiongreeting is not a function,但是这边只有显示TypeError。

这个Error并不是代表着greeting没有在这个作用域中被找到的ReferenceError,TypeError代表虽然有找到这个标示但是在这个时刻并不是函数的引用,因为他尚未被reference给function所以自然没办法被呼叫。

若是使用var宣告一个变量,那麽他会在作用域开头自动初始化这个标示将他初始化为undefined,一但初始化那麽他就可以在这个作用域中被使用,所以第一行的greeting他只被定义为undefined,要到第三行才被assigned为function,所以自然会出错。

Variable Hoisting

greeting = "Hello!";
console.log(greeting); // Hello!

var greeting = "Howdy!";

对於上面的程序码中可能会有疑问,明明greeting是在後面才宣告的但是为什麽在第一行就可以赋值?而且console出来的也不是宣告的值?
这边有两个解释:

  • 标示的hoisting
  • 自动初始化为undefined
    所以对於greeting来说,他在一进到作用域中就被自动初始化为undefined(hoisting),所以第一行便可以对greeting赋值(undefined -> Hello)。

Hoisting: Yet Another Metaphor

hoisting不是JS引擎执行之前的具体执行步骤,而是将JS在执行程序之前设置程序所做的动作可视化,简单来说可以把它想像为JS引擎在执行前会重写这段程序,因此会将上面的程序改写为

var greeting;           // hoisted declaration
greeting = "Hello!";    // the original line 1
console.log(greeting);  // Hello!
greeting = "Howdy!";    // `var` is gone!

hoisting建议JS对原始程序进行预先处理以便在执行之前将所有宣告都移动到各自的作用域顶部,当然函数的宣告也会被移动到最上层。

studentName = "Suzy";
greeting(); // Hello Suzy!

function greeting() {
    console.log(`Hello ${ studentName }!`);
}
var studentName;

对於hoisting的规则会要求JS将所有的function宣告移动到各自作用域顶部,等待所有function结束後才会轮到变量宣告

function greeting() {
    console.log(`Hello ${ studentName }!`);
}
var studentName;

studentName = "Suzy";
greeting(); // Hello Suzy!

hoisting将程序重新编排的机制是一个简单易懂的方法,但是实际上JS引擎并不是这麽做的,因为他不可能向前看并找到宣告,准确来说能够达到这个功能的唯一方法是完全解析程序


Re-declaration?

如果我们在同一个作用域中不只一次的宣告同一个变数会发生什麽事?

var studentName = "Frank";
console.log(studentName); // Frank

var studentName;
console.log(studentName);   // ???

对於上面的程序码可能会觉得第二个var studentName会重新宣告这个变数(reset),所以第二个console会变成undefined,但是实际上并不是这样的,从我们上面提到的hoisting来看,这段程序码会变成

var studentName;
var studentName; // second declared

studentName = "Frank";
console.log(studentName); // Frank 

console.log(studentName); // Frank

因为hoisting会将所有的宣告移动到作用域的上方,所以原本在中间的宣告会被hoisting到上方,所以一样会输出Frank,而第二次的宣告则是一个无意义的操作,但是如果使用var studentName = undefined,那麽结果会是完全不同的

var studentName = "Frank";
console.log(studentName); // Frank

var studentName; //the no-op
console.log(studentName); // Frank

// let's add the initialization explicitly
var studentName = undefined;
console.log(studentName); // undefined

将student显性的再次定义为undefined,那他的结果就会跟被动赋予undefined的结果不一样。

重复的使用var去宣告一个一样的变量是没意义的,实际上会是什麽都不做

var greeting;

function greeting() {
    console.log("Hello!");
}

var greeting; // basically, a no-op

typeof greeting; // "function"

var greeting = "Hello!"; //re declrate to string

typeof greeting; // "string"

第一行中宣告了一个greeting并自动初始化为undefined,由於这个标签已经被宣告了,所以function不需要对这个标签再次宣告一次只需要将function hoisting,他会自动初始化并覆盖原本这个标签的设定(undefined -> function),而第二个var的宣告并不会有任何操作,因为他已经被初始化过了;而实际上将Hello!赋予给greeting,使他的值从function变为string与var本身无关

那如果是使用letconst重复宣告呢?

let studentName = "Frank";

console.log(studentName);

let studentName = "Suzy";

这样的操作并不会被运行,因为他会掷出一个SyntaxError,而这个错误的意思代表studentName这个变量已经被宣告过了,换句话说重复宣告对使用let/const来说是不允许的。

var studentName = "Frank";

let studentName = "Suzy"; //SyntaxError
let studentName = "Frank";

var studentName = "Suzy"; //SyntaxError

对於上面这两种情况来说,都会在第二次宣告的时候掷出SyntaxError,这意味着如果要尝试使用re-declare则必须是全程使用var宣告才行。

Constants?

对於const的使用规范要比let来得严格,const不能在同一个作用域中重新宣告,但是他的这个规则与let不一样,const要求宣告的变量要有初始值,若没有则会掷出SyntaxError。

const empty; // SyntaxError

const也不能重新宣告

const studentName = "Frank";
console.log(studentName); // Frank

studentName = "Suzy";   // TypeError

上面的程序中掷出的错误是TypeError而不是SyntaxError,因为SyntaxError是代表语法错误导致程序无法执行,TypeError则是代表程序执行期间出现的错误,由於在程序中已经执行并将第一个宣告的studentName console出来,所以是属於执行中的错误。

Loops

由上面的介绍中可以发现,JS不希望我们对一个变数重复宣告,但是这个行为在回圈中也是吗?

var keepGoing = true;
while (keepGoing) {
    let value = Math.random();  
    if (value > 0.5) {
        keepGoing = false;
    }
}

上面的程序码中我们在while回圈中不断的使用let重新宣告value = Math.rendom();,这样的操作会造成错误吗?

答案是不会的,因为每个作用域都遵守作用域规则,换句话说在while在每次回圈执行的时候都会将整个作用域重置,所以每个迭代的while回圈都是自己的一个新作用域,对这些作用域来说value只有被宣告一次所以并不会造成错误,但是如果我们将value的宣告改为使用var会发生什麽事?

var keepGoing = true;
while (keepGoing) {
    var value = Math.random(); //change let to var
    if (value > 0.5) {
        keepGoing = false;
    }
}

会因为var可以允许而不断的重新宣告吗?答案是不会的,因为var不属於块状作用域宣告,所以他会将自身附加到全域作用域中,所以根本来说value是和keepGoing一样的全域作用域中,所以他只被宣告了一次,所以不会有重新宣告的问题。

那如果是for loop呢?

for (let i = 0; i < 3; i++) {
    let value = i * 10;
    console.log(`${ i }: ${ value }`);
}
/* 
  0: 0
  1: 10
  2: 20 
*/

我们已经了解对於value来说,因为每次回圈他都在新的作用域中所以不会有重复宣告的错误,但是对於i来说呢?

要解决这个问题我们需要先了解i是处於哪个作用域中,虽然他看起来像在全域作用域中但实际上他是处於for loop的作用域中

{
    // a fictional variable for illustration
    let $$i = 0;

    for ( /* nothing */; $$i < 3; $$i++) {
        // here's our actual loop `i`!
        let i = $$i;

        let value = i * 10;
        console.log(`${ i }: ${ value }`);
    }
    // 0: 0
    // 1: 10
    // 2: 20
}

这样可以清楚的了解,其实i与value一样都一直处於新的作用域中,所以不会有重复宣告的问题发生,那麽问题又来了,如果对於for loop使用const来宣告i结果还会一样吗?

for (const i = 0; i < 3; i++) {
  //...
}

我们将for loop中的i由let宣告改为使用const宣告原本预期会跟使用let一样,但是其实不一样,因为若是以观测i的作用域来说

{
    // a fictional variable for illustration
    const $$i = 0;

    for ( ; $$i < 3; $$i++) {
        // here's our actual loop `i`!
        const i = $$i;
        // ..
    }
}

虽然对於i来说,他是处於for loop作用域中所以不会有问题,但是有问题的是在for loop外的作用域使用const宣告的一个假的$$i变数,由於是使用const做的宣告,所以并不能在for loop中进行++的动作(re-assignment),所以这时候便会报错。


Uninitialized Variables (aka, TDZ)

当使用var去宣告一个变数的时候,会因为hoisting的作用将这个变数提升到作用域的顶层并自动初始化为undefined,因此让这个变数在整个作用域中都可以使用,但是letconst没有这个功能。

console.log(studentName); // ReferenceError

let studentName = "Suzy";

在第一行掷出了一个ReferenceError,它代表着你不能够在还没初始化这个变数之前就使用它。

但是若是错误讯息表示我们在还没初始化之前就使用这个变数,那麽我们将程序改写一下

studentName = "Suzy";   // let's try to initialize it!

console.log(studentName); // ReferenceError

let studentName;  //declarate variable

就算这样更改程序後依然发生错误,但是我们已经在一开始的地方对他初始化了,那麽是为什麽又发生错误?

对於let/const的初始化来说需要在宣告与句後面加上赋值,这样便能完成对於let/const宣告的变数初始化。

let studentName = "Suzy"; //intialized
console.log(studentName); // Suzy

除了这种方法之外也可以将宣告与赋值分成两段

let studentName; // let studentName = undefined;
 
studentName = "Suzy"; //assignment value

console.log(studentName); // Suzy

这边会有一个很特别的现象,对於var studentName来说他并不是等於var studentName = undefined,但是对於let来说他们是相同的,区别在於var studentName会在作用域顶部自动初始化而let studentName并不会这麽做。

当使用let/const宣告变量尚未被初始化之前的这段时间称为TDZ(Temporal Dead Zone),在这段期间内是不能对这个变量进行访问,只有编译器在原始声明中所留下的指令执行初始化之後才能自由地在所属的作用域中使用,以技术上来说var也是具有TDZ的,只是他不会被我们察觉。

对於TDZ中所提到的时间他确实是指时间而不是程序码中的位置

askQuestion();  // ReferenceError

let studentName = "Suzy";

function askQuestion() {
    console.log(`${ studentName }, do you know?`);
}

虽然askQuestion(...)中的console是放在let studentName宣告之後,但是以时间上来说askQuestion(...)被呼叫的时间早於studentName被宣告,所以会产生ReferenceError。

let/const don't hoist?

许多人会觉得let/const不会hoisting,但这其实是不对的,其实let/const他也会有hoist的现象,不过他与var的区别在於let/const的hoist不会在作用域顶部自动初始化,书中的作者认为自动注册变数到作用域顶部与自动初始化是不一样的操作,不应该将他们都归类於hoisting,我们可以举一个例子:

var studentName = "Kyle";
{
    console.log(studentName);  // ???

    let studentName = "Suzy";

    console.log(studentName);  // Suzy
}

如果以let/const不会hoisting的观念看这个程序码应该会觉得第一个console会打印出kyle,因为在这个时候只有外部作用域有一个studentName的宣告,但事实上这段程序码也会TDZ Error,这代表在{...}let studentName = "suzy";hoisting到这个block的最上方了只是还没初始化,所以第一个console才会掷出TDZ Error。

总结来说,会发生TDZ Error是因为let/const确实将宣告的参数移动到作用域得顶部但却不像var一样会自动为他们初始化,他将初始化的动作推迟到原始声明的出现,而在这段时间内对变数进行操作都会导致错误,所以要减少TDZ Error的方法最好是将所有的let/const宣告放在作用域的顶部,让你TDZ的时间几呼趋近於0。

参考文献:
You Don't Know JavaScript -2nd


<<:  【Day30】综合练习:台铁即时时刻表!

>>:  swift IQKeyboardManager - 铁人29

Day 27 Realm的练习-使用者注册系统(1/3)

上一篇是用userDefalt去做资料的新增跟删除,但之前说过,userDefalt有个问题是他只能...

【第四天 - HG 泄漏】

Q1. HG 是什麽? Mercurial 是一种轻量级分散式版本控制系统,由於 Mercuial ...

DAY23:优化器(下)

开始比较各种优化器 这边都采用变动学习率CosineAnnealing。示范我这边T_max只用6。...

Day26 - [丰收款] 永丰线上收款支付API功能实作总结(2)

在前半段的系列文章,我们把视野缩的很小,一篇一篇着重在每日赶进度的技术细节,看看怎麽使用Python...

(World N0-1)! To Pass LookML-Developer Exam Guide

50% Discount On Google Updated LookML-Developer Ex...