[JS] You Don't Know JavaScript [Scope & Closures]

前言

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

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();  // TypeErrorvar 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 declarationgreeting = "Hello!";    // the original line 1console.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); // Frankvar studentName;console.log(studentName);   // ???

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

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

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

var studentName = "Frank";console.log(studentName); // Frankvar studentName; //the no-opconsole.log(studentName); // Frank// let's add the initialization explicitlyvar studentName = undefined;console.log(studentName); // undefined

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

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

var greeting;function greeting() {    console.log("Hello!");}var greeting; // basically, a no-optypeof greeting; // "function"var greeting = "Hello!"; //re declrate to stringtypeof 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); // FrankstudentName = "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); // ReferenceErrorlet studentName = "Suzy";

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

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

studentName = "Suzy";   // let's try to initialize it!console.log(studentName); // ReferenceErrorlet studentName;  //declarate variable

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

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

let studentName = "Suzy"; //intializedconsole.log(studentName); // Suzy

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

let studentName; // let studentName = undefined; studentName = "Suzy"; //assignment valueconsole.log(studentName); // Suzy

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

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

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

askQuestion();  // ReferenceErrorlet 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


关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章