let
和 const
是 JavaScript 中變數宣告的兩個相對較新的概念。 如前所述,let
在某些方面類似於 var
,但允許使用者避免 JavaScript 中常見的某些「陷阱」。
const
是 let
的擴充,它防止重新指派變數。
由於 TypeScript 是 JavaScript 的擴充,該語言自然支援 let
和 const
。在這裡,我們將進一步說明這些新的宣告,以及它們為何比 var
更受青睞。
如果您曾經隨意使用過 JavaScript,下一節可能是個復習的好方法。如果您非常熟悉 JavaScript 中 var
宣告的所有怪癖,您可能會發現跳過比較容易。
var
宣告
在 JavaScript 中宣告變數傳統上一直使用 var
關鍵字。
ts
var a = 10;
正如您可能已經發現的,我們剛剛宣告了一個名為 a
的變數,其值為 10
。
我們也可以在函式內宣告變數
ts
function f() {var message = "Hello, world!";return message;}
我們也可以在其他函式內存取相同的變數
ts
function f() {var a = 10;return function g() {var b = a + 1;return b;};}var g = f();g(); // returns '11'
在上面的範例中,g
擷取了在 f
中宣告的變數 a
。在任何 g
被呼叫的時刻,a
的值都會繫結到 f
中 a
的值。即使在 f
執行完畢後才呼叫 g
,它仍將能夠存取和修改 a
。
ts
function f() {var a = 1;a = 2;var b = g();a = 3;return b;function g() {return a;}}f(); // returns '2'
範圍規則
var
宣告對於習慣其他語言的人來說,有一些奇怪的範圍規則。以下為一個範例
ts
function f(shouldInitialize: boolean) {if (shouldInitialize) {var x = 10;}return x;}f(true); // returns '10'f(false); // returns 'undefined'
有些讀者可能會對這個範例感到驚訝。變數 x
是宣告在 if
區塊中的,但我們卻可以在區塊外存取它。這是因為 var
宣告可以在其包含函式、模組、命名空間或全域範圍內的任何地方存取,而我們稍後會探討所有這些內容,不論包含區塊為何。有些人稱此為 var
範圍 或 函式範圍。參數也是函式範圍的。
這些範圍規則可能會導致幾種類型的錯誤。它們會加劇的一個問題是,多次宣告同一個變數並不會產生錯誤
ts
function sumMatrix(matrix: number[][]) {var sum = 0;for (var i = 0; i < matrix.length; i++) {var currentRow = matrix[i];for (var i = 0; i < currentRow.length; i++) {sum += currentRow[i];}}return sum;}
對於一些有經驗的 JavaScript 開發人員來說,這可能很容易發現,但內層 for
迴圈會意外覆寫變數 i
,因為 i
參照同一個函式範圍變數。正如有經驗的開發人員現在所知,類似類型的錯誤會通過程式碼檢閱,並且會帶來無盡的挫折感。
變數擷取怪癖
花個幾秒鐘猜猜看以下程式片段的輸出結果是什麼
ts
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
ts
for (var i = 0; i < 10; i++) {// capture the current state of 'i'// by invoking a function with its current value(function (i) {setTimeout(function () {console.log(i);}, 100 * i);})(i);}
這種看起來很奇怪的模式其實很常見。參數清單中的 i
實際上隱藏了在 for
迴圈中宣告的 i
,但由於我們將它們命名為相同名稱,因此我們不必修改迴圈主體太多。
let
宣告
現在你已經發現 var
有些問題,這正是 let
陳述式被引進的原因。除了使用的關鍵字不同,let
陳述式與 var
陳述式的寫法相同。
ts
let hello = "Hello!";
關鍵的不同點不在於語法,而在於語意,我們現在將深入探討。
區塊作用域
當使用 let
宣告變數時,它會使用一些人稱為詞法作用域或區塊作用域。與作用域會外洩到其包含函式的 var
宣告的變數不同,區塊作用域變數在其最近的包含區塊或 for
迴圈之外不可見。
ts
function f(input: boolean) {let a = 100;if (input) {// Still okay to reference 'a'let b = a + 1;return b;}// Error: 'b' doesn't exist herereturn b;}
在此,我們有兩個區域變數 a
和 b
。a
的範圍僅限於 f
的主體,而 b
的範圍僅限於包含 if
陳述式的區塊。
在 catch
子句中宣告的變數也有類似的範圍規則。
ts
try {throw "oh no!";} catch (e) {console.log("Oh well.");}// Error: 'e' doesn't exist hereconsole.log(e);
區塊範圍變數的另一個屬性是,它們在實際宣告之前無法讀取或寫入。雖然這些變數在其整個範圍內「存在」,但直到宣告為止的所有點都是其時間死區的一部分。這只是一個複雜的說法,表示您無法在 let
陳述式之前存取它們,而且幸運的是,TypeScript 會讓您知道這一點。
ts
a++; // illegal to use 'a' before it's declared;let a;
需要注意的是,您仍然可以在宣告之前擷取區塊範圍變數。唯一的缺點是,在宣告之前呼叫該函式是非法的。如果鎖定 ES2015,現代執行時期會擲回錯誤;然而,目前 TypeScript 是允許的,不會將此報告為錯誤。
ts
function foo() {// okay to capture 'a'return a;}// illegal call 'foo' before 'a' is declared// runtimes should throw an error herefoo();let a;
如需有關時間死區的更多資訊,請參閱 Mozilla Developer Network 上的相關內容。
重新宣告和陰影化
使用 var
宣告時,我們提到過宣告變數的次數並不重要;您只會得到一個。
ts
function f(x) {var x;var x;if (true) {var x;}}
在上述範例中,x
的所有宣告實際上指的是相同的 x
,這是完全有效的。這通常會成為錯誤的來源。謝天謝地,let
宣告沒有那麼寬容。
ts
let x = 10;let x = 20; // error: can't re-declare 'x' in the same scope
TypeScript 不一定需要兩個變數都是區塊作用域,才能告訴我們有問題。
ts
function f(x) {let x = 100; // error: interferes with parameter declaration}function g() {let x = 100;var x = 100; // error: can't have both declarations of 'x'}
這並不是說區塊作用域變數永遠無法用函式作用域變數宣告。區塊作用域變數只需要在明顯不同的區塊中宣告即可。
ts
function f(condition, x) {if (condition) {let x = 100;return x;}return x;}f(false, 0); // returns '0'f(true, 0); // returns '100'
在更巢狀的範圍中引入新名稱的行為稱為遮蔽。這有點像一把雙面刃,它可能會在意外遮蔽的情況下自行引入某些錯誤,同時也會防止某些錯誤。例如,想像我們使用 let
變數撰寫了先前的 sumMatrix
函式。
ts
function sumMatrix(matrix: number[][]) {let sum = 0;for (let i = 0; i < matrix.length; i++) {var currentRow = matrix[i];for (let i = 0; i < currentRow.length; i++) {sum += currentRow[i];}}return sum;}
這個版本的迴圈實際上會正確執行總和,因為內部迴圈的 i
遮蔽了外部迴圈的 i
。
為了撰寫更清晰的程式碼,通常應該避免遮蔽。雖然在某些情況下可能適合利用它,但你應該運用最佳判斷力。
區塊作用域變數擷取
當我們第一次接觸使用 var
宣告的變數擷取概念時,我們簡要介紹了變數在擷取後的作用方式。為了更直觀地了解這一點,每次執行範圍時,它都會建立一個變數「環境」。即使範圍內的所有內容都已執行完畢,該環境及其擷取的變數仍可以存在。
ts
function theCityThatAlwaysSleeps() {let getCity;if (true) {let city = "Seattle";getCity = function () {return city;};}return getCity();}
由於我們從其環境中擷取了 city
,因此儘管 if
區塊已執行完畢,我們仍然可以存取它。
回想一下我們較早的 setTimeout
範例,我們最後需要使用 IIFE 來擷取變數的狀態,以進行 for
迴圈的每次反覆運算。實際上,我們所做的就是為我們擷取的變數建立新的變數環境。這有點麻煩,但幸運的是,在 TypeScript 中你再也不必這麼做了。
當宣告為迴圈的一部分時,let
宣告具有截然不同的行為。這些宣告不僅僅為迴圈本身引入新的環境,它們還會為每次反覆運算建立新的範圍。由於這正是我們使用 IIFE 所做的,因此我們可以將舊的 setTimeout
範例變更為僅使用 let
宣告。
ts
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
宣告是宣告變數的另一種方式。
ts
const numLivesForCat = 9;
它們就像 let
宣告,但正如其名稱所暗示的,它們的值一旦繫結就無法變更。換句話說,它們具有與 let
相同的範圍規則,但你無法重新指定它們。
這不應與它們所引用的值不可變的概念混淆。
ts
const numLivesForCat = 9;const kitty = {name: "Aurora",numLives: numLivesForCat,};// Errorkitty = {name: "Danielle",numLives: numLivesForCat,};// all "okay"kitty.name = "Rory";kitty.name = "Kitty";kitty.name = "Cat";kitty.numLives--;
除非您採取特定措施來避免,否則 const
變數的內部狀態仍然可以修改。幸運的是,TypeScript 允許您指定物件的成員為 readonly
。有關詳細資訊,請參閱 介面章節。
let
與 const
由於我們有兩種具有類似範圍語意的宣告,因此自然會想知道要使用哪一種。與大多數廣泛的問題一樣,答案是:視情況而定。
應用 最小權限原則,除了您計畫修改的宣告之外,所有宣告都應使用 const
。其理由是,如果變數不需要寫入,則其他處理相同程式碼庫的人員不應自動能夠寫入物件,並且需要考慮是否真的需要重新指派變數。在推論資料流時,使用 const
也會使程式碼更具可預測性。
根據您的最佳判斷,並在適用的情況下,與您的團隊其他成員諮詢此事。
本手冊的大部分內容使用 let
宣告。
解構
TypeScript 擁有的另一項 ECMAScript 2015 功能是解構。有關完整參考,請參閱 Mozilla Developer Network 上的文章。在本節中,我們將提供簡要概述。
陣列解構
解構最簡單的形式是陣列解構賦值
ts
let input = [1, 2];let [first, second] = input;console.log(first); // outputs 1console.log(second); // outputs 2
這會建立兩個新的變數,分別命名為 first
和 second
。這相當於使用索引,但更為方便
ts
first = input[0];second = input[1];
解構也適用於已宣告的變數
ts
// swap variables[first, second] = [second, first];
以及函式的參數
ts
function f([first, second]: [number, number]) {console.log(first);console.log(second);}f([1, 2]);
你可以使用語法 ...
為清單中剩下的項目建立一個變數
ts
let [first, ...rest] = [1, 2, 3, 4];console.log(first); // outputs 1console.log(rest); // outputs [ 2, 3, 4 ]
當然,由於這是 JavaScript,你可以忽略你不在乎的尾端元素
ts
let [first] = [1, 2, 3, 4];console.log(first); // outputs 1
或其他元素
ts
let [, second, , fourth] = [1, 2, 3, 4];console.log(second); // outputs 2console.log(fourth); // outputs 4
元組解構
元組可以像陣列一樣解構;解構變數取得對應元組元素的型別
ts
let tuple: [number, string, boolean] = [7, "hello", true];let [a, b, c] = tuple; // a: number, b: string, c: boolean
解構元組超出其元素範圍會產生錯誤
ts
let [a, b, c, d] = tuple; // Error, no element at index 3
與陣列一樣,你可以使用 ...
解構元組的其餘部分,以取得較短的元組
ts
let [a, ...bc] = tuple; // bc: [string, boolean]let [a, b, c, ...d] = tuple; // d: [], the empty tuple
或忽略尾隨元素或其他元素
ts
let [a] = tuple; // a: numberlet [, b] = tuple; // b: string
物件解構
你也可以解構物件
ts
let o = {a: "foo",b: 12,c: "bar",};let { a, b } = o;
這會從 o.a
和 o.b
建立新的變數 a
和 b
。請注意,如果你不需要 c
,你可以略過它。
與陣列解構一樣,你可以不宣告就進行指派
ts
({ a, b } = { a: "baz", b: 101 });
請注意,我們必須用括號將此陳述式包起來。JavaScript 通常會將 {
解析為區塊的開頭。
你可以使用語法 ...
為物件中其餘的項目建立一個變數
ts
let { a, ...passthrough } = o;let total = passthrough.b + passthrough.c.length;
屬性重新命名
您也可以為屬性指定不同的名稱
ts
let { a: newName1, b: newName2 } = o;
此處的語法開始令人困惑。您可以將 a: newName1
讀為「a
為 newName1
」。方向為從左至右,就像您寫下
ts
let newName1 = o.a;let newName2 = o.b;
令人困惑的是,此處的冒號並未表示類型。如果您指定類型,仍需要在整個解構後寫入
ts
let { a: newName1, b: newName2 }: { a: string; b: number } = o;
預設值
預設值讓您可以在屬性未定義時指定預設值
ts
function keepWholeObject(wholeObject: { a: string; b?: number }) {let { a, b = 1001 } = wholeObject;}
在此範例中,b?
表示 b
是選用的,因此可能是 undefined
。keepWholeObject
現在有一個變數 wholeObject
,以及屬性 a
和 b
,即使 b
是未定義的。
函式宣告
解構也在函式宣告中運作。對於簡單的案例,這是很直接的
ts
type C = { a: string; b?: number };function f({ a, b }: C): void {// ...}
但為參數指定預設值比較常見,而且使用解構來正確取得預設值可能會很棘手。首先,你需要記得在預設值之前放置樣式。
ts
function f({ a = "", b = 0 } = {}): void {// ...}f();
上面的程式碼片段是類型推論的範例,在手冊的前面有說明。
接著,你需要記得為解構屬性中的選用屬性提供預設值,而不是主初始化項。請記住 C
是使用選用的 b
定義的
ts
function f({ a, b = 0 } = { a: "" }): void {// ...}f({ a: "yes" }); // ok, default b = 0f(); // ok, default to { a: "" }, which then defaults b = 0f({}); // error, 'a' is required if you supply an argument
小心使用解構。正如前一個範例所示,除了最簡單的解構運算式之外,其他都令人困惑。對於深度巢狀的解構尤其如此,即使沒有堆疊重新命名、預設值和類型註解,也很難理解。請盡量保持解構運算式簡潔。你總是可以用自己產生的解構來撰寫指定。
散佈
展開運算子與解構相反。它允許你將陣列展開到另一個陣列,或將物件展開到另一個物件。例如
ts
let first = [1, 2];let second = [3, 4];let bothPlus = [0, ...first, ...second, 5];
這會讓 bothPlus 的值變成 [0, 1, 2, 3, 4, 5]
。展開會建立 first
和 second
的淺層拷貝。它們不會因為展開而改變。
你也可以展開物件
ts
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };let search = { ...defaults, food: "rich" };
現在 search
是 { food: "rich", price: "$$", ambiance: "noisy" }
。物件展開比陣列展開複雜。就像陣列展開,它從左到右進行,但結果仍然是一個物件。這表示在展開物件中較後出現的屬性會覆寫較早出現的屬性。所以如果我們修改前一個範例,在最後展開
ts
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };let search = { food: "rich", ...defaults };
那麼 defaults
中的 food
屬性會覆寫 food: "rich"
,這不是我們在這個情況下想要的。
物件展開也有其他幾個令人驚訝的限制。首先,它只包含物件的 自有可列舉屬性。基本上,這表示當你展開物件實例時,你會失去方法
ts
class C {p = 12;m() {}}let c = new C();let clone = { ...c };clone.p; // okclone.m(); // error!
其次,TypeScript 編譯器不允許從泛型函數展開類型參數。這項功能預計會在語言的未來版本中出現。
using
宣告
using
宣告是 JavaScript 中即將推出的功能,是 第 3 階段明確資源管理 提案的一部分。using
宣告很像 const
宣告,但它將繫結到宣告的值的生命週期與變數的作用域結合在一起。
當控制離開包含 using
宣告的區塊時,宣告值的 [Symbol.dispose]()
方法會被執行,這允許該值執行清理動作。
ts
function f() {using x = new C();doSomethingWith(x);} // `x[Symbol.dispose]()` is called
在執行階段,這會產生大致上等同於下列內容的效果
ts
function f() {const x = new C();try {doSomethingWith(x);}finally {x[Symbol.dispose]();}}
using
宣告在避免記憶體外洩時非常有用,特別是在處理持有原生參考(例如檔案句柄)的 JavaScript 物件時
ts
{using file = await openFile();file.write(text);doSomethingThatMayThrow();} // `file` is disposed, even if an error is thrown
或作用域運算(例如追蹤)時
ts
function f() {using activity = new TraceActivity("f"); // traces entry into function// ...} // traces exit of function
與 var
、let
和 const
不同,using
宣告不支援解構。
null
和 undefined
請務必注意,該值可以是 null
或 undefined
,在這種情況下,區塊結束時不會處置任何內容
ts
{using x = b ? new C() : null;// ...}
這大致上等同於
ts
{const x = b ? new C() : null;try {// ...}finally {x?.[Symbol.dispose]();}}
這允許你在宣告 using
宣告時有條件地取得資源,而不需要複雜的分支或重複。
定義一次性資源
您可以透過實作 Disposable
介面來指出您產生的類別或物件是一次性的
ts
// from the default lib:interface Disposable {[Symbol.dispose](): void;}// usage:class TraceActivity implements Disposable {readonly name: string;constructor(name: string) {this.name = name;console.log(`Entering: ${name}`);}[Symbol.dispose](): void {console.log(`Exiting: ${name}`);}}function f() {using _activity = new TraceActivity("f");console.log("Hello world!");}f();// prints:// Entering: f// Hello world!// Exiting: f
await using
宣告
有些資源或作業可能需要非同步執行的清除作業。為了配合這個需求,明確資源管理提案也引入了 await using
宣告
ts
async function f() {await using x = new C();} // `await x[Symbol.asyncDispose]()` is invoked
await using
宣告會呼叫並等待其值的 [Symbol.asyncDispose]()
方法,因為控制權會離開包含區塊。這允許非同步清除作業,例如資料庫交易執行回滾或提交,或檔案串流在關閉之前清除任何待處理的寫入作業到儲存體。
與 await
相同,await using
只能用於 async
函式或方法中,或模組的最上層。
定義非同步可處置資源
如同 using
依賴於 Disposable
的物件,await using
依賴於 AsyncDisposable
的物件
ts
// from the default lib:interface AsyncDisposable {[Symbol.asyncDispose]: PromiseLike<void>;}// usage:class DatabaseTransaction implements AsyncDisposable {public success = false;private db: Database | undefined;private constructor(db: Database) {this.db = db;}static async create(db: Database) {await db.execAsync("BEGIN TRANSACTION");return new DatabaseTransaction(db);}async [Symbol.asyncDispose]() {if (this.db) {const db = this.db:this.db = undefined;if (this.success) {await db.execAsync("COMMIT TRANSACTION");}else {await db.execAsync("ROLLBACK TRANSACTION");}}}}async function transfer(db: Database, account1: Account, account2: Account, amount: number) {using tx = await DatabaseTransaction.create(db);if (await debitAccount(db, account1, amount)) {await creditAccount(db, account2, amount);}// if an exception is thrown before this line, the transaction will roll backtx.success = true;// now the transaction will commit}
await using
與 await
await using
宣告中的一部分 await
關鍵字僅表示資源的 處置 是 await
的。它不會 await
值本身
ts
{await using x = getResourceSynchronously();} // performs `await x[Symbol.asyncDispose]()`{await using y = await getResourceAsynchronously();} // performs `await y[Symbol.asyncDispose]()`
await using
與 return
如果您在返回 Promise 的非同步函數中使用 await using
宣告,請務必注意此行為有一個小警告,即您沒有先對其進行 await
ts
function g() {return Promise.reject("error!");}async function f() {await using x = new C();return g(); // missing an `await`}
由於回傳的 Promise 沒有 await
,因此 JavaScript 執行階段可能會回報未處理的拒絕,因為執行會暫停,同時 await
非同步的 x
處置,而沒有訂閱回傳的 Promise。不過,這並非 await using
獨有的問題,因為這也可能發生在使用 try..finally
的非同步函數中
ts
async function f() {try {return g(); // also reports an unhandled rejection}finally {await somethingElse();}}
為避免這種情況,建議您 await
回傳值,如果它可能是 Promise
ts
async function f() {await using x = new C();return await g();}
using
和 await using
在 for
和 for..of
陳述式中
using
和 await using
都可以在 for
陳述式中使用
ts
for (using x = getReader(); !x.eof; x.next()) {// ...}
在這種情況下,x
的生命週期涵蓋整個 for
陳述式,並且僅在控制權因 break
、return
、throw
或迴圈條件為假而離開迴圈時才會處置。
除了 for
陳述式之外,這兩個宣告也可以在 for..of
陳述式中使用
ts
function * g() {yield createResource1();yield createResource2();}for (using x of g()) {// ...}
在此,x
在迴圈的每次迭代結束時處置,然後使用下一個值重新初始化。當使用產生器逐一產生資源時,這特別有用。
using
和 await using
在較舊的執行時期中
using
和 await using
宣告可用於目標較舊的 ECMAScript 版本,只要您使用相容的 Symbol.dispose
/Symbol.asyncDispose
polyfill,例如 NodeJS 近期版本中預設提供的 polyfill。