var、let、const、解構、展開、new、this、class、函式

前端瓶子君發表於2019-08-19

引言

JS系列暫定 27 篇,從基礎,到原型,到非同步,到設計模式,到架構模式等,此為第一篇:是對 var、let、const、解構、展開、函式 的總結。

let在很多方面與 var 是相似的,但是 let 可以幫助大家避免在 JavaScript 裡常見一些問題。const 是對 let 的一個增強,它能阻止對一個變數再次賦值。

一、var 宣告

一直以來我們都是通過 var 關鍵字定義 JavaScript 變數。

var num = 1;
複製程式碼

定義了一個名為 num 值為 1 的變數。

我們也可以在函式內部定義變數:

function f() {
    var message = "Hello, An!";

    return message;
}
複製程式碼

並且我們也可以在其它函式內部訪問相同的變數。

function f() {
    var num = 10;
    return function g() {
        var b = num + 1;
        return b;
    }
}

var g = f();
g(); // 11;
複製程式碼

上面的例子裡,g 可以獲取到 f 函式裡定義的 num 變數。 每當 g 被呼叫時,它都可以訪問到 f 裡的 num 變數。 即使當 gf 已經執行完後才被呼叫,它仍然可以訪問及修改 num

function f() {
    var num = 1;

    num = 2;
    var b = g();
    num = 3;

    return b;

    function g() {
        return num;
    }
}

f(); // 2
複製程式碼

作用域規則

對於熟悉其它語言的人來說,var 宣告有些奇怪的作用域規則。 看下面的例子:

function f(init) {
    if (init) {
        var x = 10;
    }

    return x;
}

f(true);  // 10
f(false); // undefined
複製程式碼

在這個例子中,變數 x 是定義在 if 語句裡面,但是我們卻可以在語句的外面訪問它。

這是因為 var 宣告可以在包含它的函式,模組,名稱空間或全域性作用域內部任何位置被訪問,包含它的程式碼塊對此沒有什麼影響。 有些人稱此為 var 作用域或函式作用域 。 函式引數也使用函式作用域。

這些作用域規則可能會引發一些錯誤。 其中之一就是,多次宣告同一個變數並不會報錯:

function sumArr(arrList) {
    var sum = 0;
    for (var i = 0; i < arrList.length; i++) {
        var arr = arrList[i];
        for (var i = 0; i < arr.length; i++) {
            sum += arr[i];
        }
    }

    return sum;
}
複製程式碼

這裡很容易看出一些問題,裡層的 for 迴圈會覆蓋變數 i,因為所有 i 都引用相同的函式作用域內的變數。 有經驗的開發者們很清楚,這些問題可能在程式碼審查時漏掉,引發無窮的麻煩。

捕獲變數怪異之處

快速的思考一下下面的程式碼會返回什麼:

for (var i = 0; i < 10; i++) {
    setTimeout(function() { console.log(i); }, 100 * i);
}
複製程式碼

介紹一下,setTimeout會在若干毫秒的延時後執行一個函式(等待其它程式碼執行完畢)。

好吧,看一下結果:

10
10
10
10
10
10
10
10
10
10
複製程式碼

很多 JavaScript 程式設計師對這種行為已經很熟悉了,但如果你很不解,你並不是一個人。 大多數人期望輸出結果是這樣:

0
1
2
3
4
5
6
7
8
9
複製程式碼

還記得我們上面提到的捕獲變數嗎?

我們傳給 setTimeout 的每一個函式表示式實際上都引用了相同作用域裡的同一個 i

讓我們花點時間思考一下這是為什麼。 setTimeout 在若干毫秒後執行一個函式,並且是在 for 迴圈結束後。for 迴圈結束後,i 的值為 10。 所以當函式被呼叫的時候,它會列印出 10

一個通常的解決方法是使用立即執行的函式表示式(IIFE)來捕獲每次迭代時i的值:

for (var i = 0; i < 10; i++) {
    (function(i) {
        setTimeout(function() { console.log(i); }, 100 * i);
    })(i);
}
複製程式碼

這種奇怪的形式我們已經司空見慣了。 引數 i 會覆蓋 for 迴圈裡的 i ,但是因為我們起了同樣的名字,所以我們不用怎麼改 for 迴圈體裡的程式碼。

二、let 宣告

現在你已經知道了 var 存在一些問題,這恰好說明了為什麼用 let 語句來宣告變數。 除了名字不同外, letvar 的寫法一致。

let hello = "Hello,An!";
複製程式碼

主要的區別不在語法上,而是語義,我們接下來會深入研究。

塊作用域

當用 let 宣告一個變數,它使用的是詞法作用域或塊作用域。 不同於使用 var 宣告的變數那樣可以在包含它們的函式外訪問,塊作用域變數在包含它們的塊或 for 迴圈之外是不能訪問的。

function f(input) {
    let a = 100;

    if (input) {
        // a 被正常引用
        let b = a + 1;
        return b;
    }

    return b;
}
複製程式碼

這裡我們定義了2個變數 aba 的作用域是 f 函式體內,而 b 的作用域是 if 語句塊裡。

catch 語句裡宣告的變數也具有同樣的作用域規則。

try {
    throw "oh no!";
}
catch (e) {
    console.log("Oh well.");
}

// Error: 'e' doesn't exist here
console.log(e);
複製程式碼

擁有塊級作用域的變數的另一個特點是,它們不能在被宣告之前讀或寫。 雖然這些變數始終“存在”於它們的作用域裡,但在直到宣告它的程式碼之前的區域都屬於 暫時性死區。 它只是用來說明我們不能在 let 語句之前訪問它們:

a++; 
// Uncaught ReferenceError: Cannot access 'a' before initialization
let a;
複製程式碼

注意一點,我們仍然可以在一個擁有塊作用域變數被宣告前獲取它。 只是我們不能在變數宣告前去呼叫那個函式。

function foo() {
    return a;
}

// 不能在'a'被宣告前呼叫'foo'
// 執行時應該丟擲錯誤
foo();
// Uncaught ReferenceError: Cannot access 'a' before initialization

let a;
複製程式碼

關於暫時性死區的更多資訊,檢視這裡Mozilla Developer Network.

重定義及遮蔽

我們提過使用 var 宣告時,它不在乎你宣告多少次;你只會得到1個。

function f(x) {
    var x;
    var x;

    if (true) {
        var x;
    }
}
複製程式碼

在上面的例子裡,所有 x 的宣告實際上都引用一個相同的 x,並且這是完全有效的程式碼。 這經常會成為 bug 的來源。 好的是, let 宣告就不會這麼寬鬆了。

let x = 10;
let x = 20; 
// Uncaught SyntaxError: Identifier 'x' has already been declared
複製程式碼

並不是要求兩個均是塊級作用域的宣告才會給出一個錯誤的警告。

function f(x) {
    let x = 100; 
    // Uncaught SyntaxError: Identifier 'x' has already been declared
}

function g() {
    let x = 100;
    var x = 100; 
    // Uncaught SyntaxError: Identifier 'x' has already been declared
}
複製程式碼

並不是說塊級作用域變數不能用函式作用域變數來宣告。 而是塊級作用域變數需要在明顯不同的塊裡宣告。

function f(condition, x) {
    if (condition) {
        let x = 100;
        return x;
    }

    return x;
}

f(false, 0); // 0
f(true, 0);  // 100
複製程式碼

在一個巢狀作用域裡引入一個新名字的行為稱做 遮蔽 。 它是一把雙刃劍,它可能會不小心地引入新問題,同時也可能會解決一些錯誤。 例如,假設我們現在用 let 重寫之前的 sumArr 函式。

function sumArr(arrList) {
    let sum = 0;
    for (let i = 0; i < arrList.length; i++) {
        var arr = arrList[i];
        for (let i = 0; i < arr.length; i++) {
            sum += arr[i];
        }
    }

    return sum;
}
複製程式碼

此時將得到正確的結果,因為內層迴圈的 i 可以遮蔽掉外層迴圈的 i

通常來講應該避免使用遮蔽,因為我們需要寫出清晰的程式碼。 同時也有些場景適合利用它,你需要好好打算一下。

塊級作用域變數的獲取

在我們最初談及獲取用 var 宣告的變數時,我們簡略地探究了一下在獲取到了變數之後它的行為是怎樣的。 直觀地講,每次進入一個作用域時,它建立了一個變數的環境。 就算作用域內程式碼已經執行完畢,這個環境與其捕獲的變數依然存在。

function theCityThatAlwaysSleeps() {
    let getCity;

    if (true) {
        let city = "Seattle";
        getCity = function() {
            return city;
        }
    }

    return getCity();
}
複製程式碼

因為我們已經在 city 的環境裡獲取到了 city ,所以就算 if 語句執行結束後我們仍然可以訪問它。

回想一下前面 setTimeout 的例子,我們最後需要使用立即執行的函式表示式來獲取每次 for 迴圈迭代裡的狀態。 實際上,我們做的是為獲取到的變數建立了一個新的變數環境。

let 宣告出現在迴圈體裡時擁有完全不同的行為。 不僅是在迴圈裡引入了一個新的變數環境,而是針對每次迭代都會建立這樣一個新作用域。 這就是我們在使用立即執行的函式表示式時做的事,所以在 setTimeout例子裡我們僅使用 let 宣告就可以了。

for (let i = 0; i < 10 ; i++) {
    setTimeout(function() {console.log(i); }, 100 * i);
}
複製程式碼

會輸出與預料一致的結果:

0
1
2
3
4
5
6
7
8
9
複製程式碼

三、const 宣告

const 宣告是宣告變數的另一種方式。

const numLivesForCat = 9;
複製程式碼

它們與 let 宣告相似,但是就像它的名字所表達的,它們被賦值後不能再改變。 換句話說,它們擁有與 let 相同的作用域規則,但是不能對它們重新賦值。

這很好理解,它們引用的值是不可變的

const numLivesForCat = 9;
const kitty = {
    name: "Aurora",
    numLives: numLivesForCat,
}

// Error
kitty = {
    name: "Danielle",
    numLives: numLivesForCat
};

// all "okay"
kitty.name = "Rory";
kitty.name = "Kitty";
kitty.name = "Cat";
kitty.numLives--;
複製程式碼

除非你使用特殊的方法去避免,實際上 const 變數的內部狀態是可修改的。

四、let vs. const

現在我們有兩種作用域相似的宣告方式,我們自然會問到底應該使用哪個。 與大多數泛泛的問題一樣,答案是:依情況而定。

使用最小特權原則,所有變數除了你計劃去修改的都應該使用const。 基本原則就是如果一個變數不需要對它寫入,那麼其它使用這些程式碼的人也不能夠寫入它們,並且要思考為什麼會需要對這些變數重新賦值。 使用 const也可以讓我們更容易的推測資料的流動。

跟據你的自己判斷,如果合適的話,與團隊成員商議一下。

五、解構

解構陣列

最簡單的解構莫過於陣列的解構賦值了:

let input = [1, 2];
let [first, second] = input;
console.log(first); // 1
console.log(second); // 2
複製程式碼

這建立了2個命名變數 firstsecond。 相當於使用了索引,但更為方便:

first = input[0];
second = input[1];
複製程式碼

解構作用於已宣告的變數會更好:

[first, second] = [second, first];
複製程式碼

作用於函式引數:

function f([first, second]) {
    console.log(first);
    console.log(second);
}
f(input);
複製程式碼

你可以在陣列裡使用 ... 語法建立剩餘變數:

let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 1
console.log(rest); // [ 2, 3, 4 ]
複製程式碼

當然,由於是JavaScript, 你可以忽略你不關心的尾隨元素:

let [first] = [1, 2, 3, 4];
console.log(first); // 1
複製程式碼

或其它元素:

let [, second, , fourth] = [1, 2, 3, 4];
複製程式碼

物件解構

你也可以解構物件:

let o = {
    a: "foo",
    b: 12,
    c: "bar"
};
let { a, b } = o;
複製程式碼

這通過 o.a and o.b 建立了 ab 。 注意,如果你不需要 c 你可以忽略它。

就像陣列解構,你可以用沒有宣告的賦值:

({ a, b } = { a: "baz", b: 101 });
複製程式碼

注意,我們需要用括號將它括起來,因為 Javascript 通常會將以 { 起始的語句解析為一個塊。

你可以在物件裡使用 ... 語法建立剩餘變數:

let { a, ...passthrough } = o;
let total = passthrough.b + passthrough.c.length;
複製程式碼

六、展開

展開操作符正與解構相反。 它允許你將一個陣列展開為另一個陣列,或將一個物件展開為另一個物件。 例如:

let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];
複製程式碼

這會令 bothPlus 的值為 [0, 1, 2, 3, 4, 5] 。 展開操作建立了 firstsecond 的一份淺拷貝。 它們不會被展開操作所改變。

你還可以展開物件:

let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };
複製程式碼

search 的值為 { food: "rich", price: "$$", ambiance: "noisy" } 。 物件的展開比陣列的展開要複雜的多。 像陣列展開一樣,它是從左至右進行處理,但結果仍為物件。 這就意味著出現在展開物件後面的屬性會覆蓋前面的屬性。 因此,如果我們修改上面的例子,在結尾處進行展開的話:

let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { food: "rich", ...defaults };
複製程式碼

那麼,defaults 裡的 food 屬性會重寫 food: "rich" ,在這裡這並不是我們想要的結果。

物件展開還有其它一些意想不到的限制。 首先,它僅包含物件 自身的可列舉屬性。 大體上是說當你展開一個物件例項時,你會丟失其方法:

class C {
  p = 12;
  m() {
  }
}
let c = new C();
let clone = { ...c };
clone.p; // ok
clone.m(); // error!
複製程式碼

七、new、this、class、函式

this 與 new

new 關鍵字建立的物件實際上是對新物件 this 的不斷賦值,並將 __proto__ 指向類的 prototype 所指向的物件

var SuperType = function (name) {
    var nose = 'nose' // 私有屬性
    function say () {} // 私有方法
    
    // 特權方法
    this.getName = function () {} 
    this.setName = function () {}
    
    this.mouse = 'mouse' // 物件公有屬性
    this.listen = function () {} // 物件公有方法
    
    // 構造器
    this.setName(name)
}

SuperType.age = 10 // 類靜態公有屬性(物件不能訪問)
SuperType.read = function () {} // 類靜態公有方法(物件無法訪問)

SuperType.prototype = { // 物件賦值(也可以一一賦值)
    isMan: 'true', // 公有屬性
    write: function () {} // 公有方法
}

var instance = new SuperType()
複製程式碼

new

在函式呼叫前增加 new,相當於把 SuperType 當成一個建構函式(雖然它僅僅只是個函式),然後建立一個 {} 物件並把 SuperType 中的 this 指向那個物件,以便可以通過類似 this.mouse 的形式去設定一些東西,然後把這個物件返回。

具體來講,只要在函式呼叫前加上 new 操作符,你就可以把任何函式當做一個類的建構函式來用。

加 new

在上例中,我們可以看到:在建構函式內定義的 私有變數或方法 ,以及類定義的 靜態公有屬性及方法 ,在 new 的例項物件中都將 無法訪問

不加 new

如果你呼叫 SuperType() 時沒有加 new,其中的 this 會指向某個全域性且無用的東西(比如,window 或者 undefined),因此我們的程式碼會崩潰,或者做一些像設定 window.mouse 之類的傻事。

let instance1 = SuperType(); 

console.log(instance1.mouse); 
// Uncaught TypeError: Cannot read property 'mouse' of undefined

console.log(window.mouse); 
// mouse
複製程式碼

函式、類

函式
function Bottle(name) {
  this.name = name;
}

// + new
let bottle = new Bottle('bottle'); // ✅ 有效: Bottle {name: "bottle"}
console.log(bottle.name) // bottle

// 不加 new
let bottle1 = Bottle('bottle');   // ? 這種呼叫方法讓人很難理解
console.log(bottle1.name); // Uncaught TypeError: Cannot read property 'name' of undefined
console.log(window.name); // bottle
複製程式碼
class Bottle {
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    console.log('Hello, ' + this.name);
  }
}

// + new
let bottle = new Bottle('bottle');
bottle.sayHello(); // ✅ 依然有效,列印:Hello, bottle

// 不加 new
let bottle1 = Bottle('bottle'); // ? 立即失敗
// Uncaught TypeError: Class constructor Bottle cannot be invoked without 'new'
複製程式碼
對比使用
let fun = new Fun();
// ✅ 如果 Fun 是個函式:有效
// ✅ 如果 Fun 是個類:依然有效

let fun1 = Fun(); // 我們忘記使用 `new`
// ? 如果 Fun 是個長得像建構函式的方法:令人困惑的行為
// ? 如果 Fun 是個類:立即失敗
複製程式碼

new Fun() Fun
class ✅ this 是一個 Fun 例項 TypeError
function ✅ this 是一個 Fun 例項 this 是 window 或 undefined

使用 new 的怪異之處

return 無效
function Bottle() {
  return 'Hello, AnGe';
}

Bottle(); // ✅ 'Hello, AnGe'
new Bottle(); // ? Bottle {}
複製程式碼
箭頭函式

對於箭頭函式,使用 new 會報錯?

const Bottle = () => {console.log('Hello, AnGe')};
new Bottle(); // Uncaught TypeError: Bottle is not a constructor
複製程式碼

這個行為是遵循箭頭函式的設計而刻意為之的。箭頭函式的一個附帶作用是它沒有自己的 this 值 —— this 解析自離得最近的常規函式:

function AnGe() {
    this.name = 'AnGe'
    return () => {console.log('Hello, ' + this.name)};
}
let anGe = new AnGe();
console.log(anGe()); // Hello, AnGe
複製程式碼

所以**箭頭函式沒有自己的 this。**但這意味著它作為建構函式是完全無用的!

總結:箭頭函式

  • this 指向定義時的環境。
  • 不可 new 例項化
  • this 不可變。
  • 沒有 arguments 物件
允許一個使用 new 呼叫的函式返回另一個物件以 覆蓋 new 的返回值

先看一個例子:

function Vector(x, y) {
  this.x = x;
  this.y = y;
}

var v1 = new Vector(0, 0);
var v2 = new Vector(0, 0); 

console.log(v1 === v2); // false
v1.x = 1;
console.log(v2); // Vector {x: 0, y: 0}
複製程式碼

對於這個例子,一目瞭然,沒什麼可說的。

那麼再看下面一個例子,思考一下為什麼 b === ctrue 喃?:

let zeroVector = null;
// 建立了一個懶變數 zeroVector = null;
function Vector(x, y) {
  if (zeroVector !== null) {
    // 複用同一個例項
    return zeroVector;
  }
  zeroVector = this;
  this.x = x;
  this.y = y;
}

var v1 = new Vector(0, 0);
var v2 = new Vector(0, 0); 

console.log(v1 === v2); // true
v1.x = 1;
console.log(v2); // Vector {x: 1, y: 0}
複製程式碼

這是因為,JavaScript 允許一個使用 new 呼叫的函式返回另一個物件以 覆蓋 new 的返回值。這在我們利用諸如「物件池模式」來對元件進行復用時可能是有用的。

參考:

TypeScript Variable Declarations

系列文章

想看更過系列文章,點選前往 github 部落格主頁

走在最後,歡迎關注:前端瓶子君,每日更新

前端瓶子君

相關文章