let 和 const 是 JavaScript 中兩個較新的變數宣告概念。正如我們之前提到的,let 在某些方面與 var 類似,但它允許使用者避免在 JavaScript 中經常遇到的一些常見“陷阱”。
const 是 let 的增強版,它能防止變數被重新賦值。
由於 TypeScript 是 JavaScript 的超集,該語言自然支援 let 和 const。在這裡,我們將詳細闡述這些新的宣告方式以及為什麼它們比 var 更可取。
如果你偶爾使用過 JavaScript,下一節可能是一個溫習記憶的好方法。如果你非常熟悉 JavaScript 中 var 宣告的所有怪癖,你可能覺得直接跳過會更容易。
var 宣告
在 JavaScript 中宣告變數一直以來都是使用 var 關鍵字。
tsvar a = 10;
正如你可能已經想到的,我們剛剛聲明瞭一個名為 a 的變數,其值為 10。
我們也可以在函式內部宣告變數
tsfunction f() {var message = "Hello, world!";return message;}
並且我們也可以在其他函式中訪問這些變數
tsfunction 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。
tsfunction f() {var a = 1;a = 2;var b = g();a = 3;return b;function g() {return a;}}f(); // returns '2'
作用域規則
對於習慣了其他語言的開發者來說,var 宣告有一些奇怪的作用域規則。請看下面的例子
tsfunction f(shouldInitialize: boolean) {if (shouldInitialize) {var x = 10;}return x;}f(true); // returns '10'f(false); // returns 'undefined'
一些讀者可能會在這個例子中感到困惑。變數 x 是在 if 塊內部 宣告的,但我們卻能夠從該塊外部訪問它。這是因為 var 宣告在它們所屬的函式、模組、名稱空間或全域性作用域內的任何位置都是可訪問的(我們稍後會詳細介紹這些),而不管它所在的塊如何。有些人稱之為 var-作用域 或 函式作用域。引數也是函式作用域的。
這些作用域規則可能會導致多種型別的錯誤。它們加劇的一個問題是:多次宣告同一個變數並不會報錯
tsfunction 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 指向的是同一個函式作用域內的變數。正如經驗豐富的開發者現在所知,這類錯誤很容易漏過程式碼審查,並可能成為令人沮喪的無盡源頭。
變數捕獲的怪癖
花一秒鐘猜猜下面這段程式碼的輸出是什麼
tsfor (var i = 0; i < 10; i++) {setTimeout(function () {console.log(i);}, 100 * i);}
對於不熟悉的人來說,setTimeout 會嘗試在給定的毫秒數後執行一個函式(儘管要等待其他正在執行的任務結束)。
準備好了嗎?看一看
10101010101010101010
許多 JavaScript 開發者非常熟悉這種行為,但如果你感到驚訝,你絕對不是一個人。大多數人期望的輸出是
0123456789
還記得我們之前提到的關於變數捕獲的內容嗎?我們傳遞給 setTimeout 的每個函式表示式實際上都指向同一個作用域內的同一個 i。
讓我們花點時間思考這意味著什麼。setTimeout 會在一定的毫秒數後執行一個函式,但僅在 for 迴圈停止執行之後;當 for 迴圈停止執行時,i 的值已經是 10。所以每次呼叫給定的函式時,它都會打印出 10!
一個常見的解決方法是使用 IIFE(立即執行函式表示式)來在每次迭代中捕獲 i
tsfor (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 語句相同。
tslet hello = "Hello!";
關鍵的區別不在於語法,而在於語義,我們現在就深入探討一下。
塊級作用域
當變數使用 let 宣告時,它使用所謂的 詞法作用域 或 塊級作用域。與使用 var 宣告的變數(其作用域會洩漏到包含它們的函式中)不同,塊級作用域變數在它們最接近的包含塊或 for 迴圈之外是不可見的。
tsfunction 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 子句中宣告的變數也具有類似的作用域規則。
tstry {throw "oh no!";} catch (e) {console.log("Oh well.");}// Error: 'e' doesn't exist hereconsole.log(e);
塊級作用域變數的另一個屬性是,它們在被實際宣告之前不能被讀取或寫入。雖然這些變數在整個作用域內都是“存在”的,但在宣告之前的所有位置都屬於它們的 暫時性死區(temporal dead zone)。這只是一種複雜的說法,即你不能在 let 語句之前訪問它們,幸運的是,TypeScript 會通知你這一點。
tsa++; // illegal to use 'a' before it's declared;let a;
需要注意的是,你仍然可以在塊級作用域變數宣告之前 捕獲 它。唯一的問題是,在宣告之前呼叫該函式是非法的。如果目標是 ES2015,現代執行時會丟擲錯誤;然而,目前的 TypeScript 是寬鬆的,不會將其報告為錯誤。
tsfunction foo() {// okay to capture 'a'return a;}// illegal call 'foo' before 'a' is declared// runtimes should throw an error herefoo();let a;
有關暫時性死區的更多資訊,請檢視 Mozilla 開發者網路 (MDN) 上的相關內容。
重新宣告與遮蔽
對於 var 宣告,我們提到過,無論你宣告變數多少次都沒有關係;你最終只會得到一個變數。
tsfunction f(x) {var x;var x;if (true) {var x;}}
在上面的例子中,所有 x 的宣告實際上都指向 同一個 x,這是完全合法的。這往往會導致 bug 的產生。幸運的是,let 宣告就沒有那麼寬鬆了。
tslet x = 10;let x = 20; // error: can't re-declare 'x' in the same scope
變數不一定要都是塊級作用域的,TypeScript 也會告訴我們存在問題。
tsfunction 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'}
這並不是說塊級作用域變數永遠不能與函式作用域變數一同宣告。塊級作用域變數只需要在明顯不同的塊中宣告即可。
tsfunction f(condition, x) {if (condition) {let x = 100;return x;}return x;}f(false, 0); // returns '0'f(true, 0); // returns '100'
在更巢狀的作用域中引入新名稱的行為稱為 遮蔽(shadowing)。它是一把雙刃劍,一方面,如果發生意外遮蔽,它可能會引入某些 bug;另一方面,它也能防止某些 bug。例如,想象一下如果我們使用 let 變數重寫了我們之前的 sumMatrix 函式。
tsfunction 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 宣告的變數捕獲概念時,我們簡要地討論了變數在被捕獲後的表現。為了更好地直觀理解這一點,每次執行一個作用域時,都會建立一個變數“環境”。該環境及其捕獲的變數甚至可以在其作用域內的所有內容執行完畢後繼續存在。
tsfunction theCityThatAlwaysSleeps() {let getCity;if (true) {let city = "Seattle";getCity = function () {return city;};}return getCity();}
因為我們捕獲了來自其環境內部的 city,所以儘管 if 塊執行完畢,我們仍然能夠訪問它。
回想一下我們之前關於 setTimeout 的例子,我們最終需要使用 IIFE 來捕獲 for 迴圈每次迭代中變數的狀態。實際上,我們所做的是為我們的捕獲變數建立了一個新的變數環境。這有點麻煩,但幸運的是,你在 TypeScript 中再也不需要那樣做了。
let 宣告在作為迴圈的一部分宣告時具有截然不同的行為。這些宣告並沒有僅僅引入一個迴圈本身的新環境,而是以 每次迭代 為單位建立了一個新的作用域。既然這正是我們使用 IIFE 所做的,我們就可以將舊的 setTimeout 例子修改為只使用 let 宣告。
tsfor (let i = 0; i < 10; i++) {setTimeout(function () {console.log(i);}, 100 * i);}
正如預期的那樣,這將打印出
0123456789
const 宣告
const 宣告是另一種宣告變數的方式。
tsconst numLivesForCat = 9;
它們類似於 let 宣告,但顧名思義,一旦繫結,它們的值就不能被改變。換句話說,它們與 let 具有相同的作用域規則,但你不能對它們重新賦值。
這不應與它們所指向的值是 不可變(immutable) 的概念相混淆。
tsconst 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 vs. const
既然我們有兩種具有相似作用域語義的宣告方式,自然會問該使用哪一種。像大多數廣泛的問題一樣,答案是:視情況而定。
應用 最小許可權原則,除了你計劃修改的宣告之外,所有宣告都應使用 const。其理由是,如果一個變數不需要被寫入,那麼在同一個程式碼庫上工作的其他人不應該自動擁有寫入該物件的許可權,並且需要考慮他們是否真的需要重新賦值該變數。使用 const 還能使資料流的推理更加可預測。
運用你的最佳判斷,如果適用,請與你的團隊其他成員協商。
本手冊的大部分內容使用 let 宣告。
解構
TypeScript 擁有的另一個 ECMAScript 2015 特性是解構。如需完整參考,請檢視 Mozilla 開發者網路 (MDN) 上的文章。在本節中,我們將簡要概述。
陣列解構
最簡單的解構形式是陣列解構賦值
tslet input = [1, 2];let [first, second] = input;console.log(first); // outputs 1console.log(second); // outputs 2
這建立了兩個名為 first 和 second 的新變數。這等同於使用索引,但更加方便
tsfirst = input[0];second = input[1];
解構也適用於已經宣告的變數
ts// swap variables[first, second] = [second, first];
以及函式引數
tsfunction f([first, second]: [number, number]) {console.log(first);console.log(second);}f([1, 2]);
你可以使用 ... 語法為列表中的剩餘項建立一個變數
tslet [first, ...rest] = [1, 2, 3, 4];console.log(first); // outputs 1console.log(rest); // outputs [ 2, 3, 4 ]
當然,由於這是 JavaScript,你可以直接忽略你不關心的末尾元素
tslet [first] = [1, 2, 3, 4];console.log(first); // outputs 1
或者其他元素
tslet [, second, , fourth] = [1, 2, 3, 4];console.log(second); // outputs 2console.log(fourth); // outputs 4
元組解構
元組可以像陣列一樣進行解構;解構變數會獲得相應元組元素的型別
tslet tuple: [number, string, boolean] = [7, "hello", true];let [a, b, c] = tuple; // a: number, b: string, c: boolean
解構超出元組元素範圍的元組會報錯
tslet [a, b, c, d] = tuple; // Error, no element at index 3
與陣列一樣,你可以用 ... 解構元組的剩餘部分,得到一個更短的元組
tslet [a, ...bc] = tuple; // bc: [string, boolean]let [a, b, c, ...d] = tuple; // d: [], the empty tuple
或者忽略末尾元素,或其他元素
tslet [a] = tuple; // a: numberlet [, b] = tuple; // b: string
物件解構
你也可以解構物件
tslet 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 通常將 { 解析為塊的開始。
你可以使用 ... 語法為物件中的剩餘項建立一個變數
tslet { a, ...passthrough } = o;let total = passthrough.b + passthrough.c.length;
屬性重新命名
你也可以給屬性起不同的名字
tslet { a: newName1, b: newName2 } = o;
這裡的語法開始變得令人困惑。你可以將 a: newName1 理解為“a 作為 newName1”。方向是從左到右,就好像你寫成了
tslet newName1 = o.a;let newName2 = o.b;
令人困惑的是,這裡的冒號 不 表示型別。如果你指定型別,它仍然需要寫在整個解構之後
tslet { a: newName1, b: newName2 }: { a: string; b: number } = o;
預設值
預設值允許你在屬性為 undefined 時指定一個預設值
tsfunction keepWholeObject(wholeObject: { a: string; b?: number }) {let { a, b = 1001 } = wholeObject;}
在此示例中,b? 表示 b 是可選的,因此它可能是 undefined。keepWholeObject 現在擁有一個用於 wholeObject 的變數,以及屬性 a 和 b(即使 b 為 undefined)。
函式宣告
解構也適用於函式宣告。對於簡單的情況,這很直接
tstype C = { a: string; b?: number };function f({ a, b }: C): void {// ...}
但指定預設值在引數中更為常見,而透過解構來處理好預設值可能很棘手。首先,你需要記住將模式放在預設值之前。
tsfunction f({ a = "", b = 0 } = {}): void {// ...}f();
上面的程式碼片段是型別推斷的一個例子,在本手冊前面已經解釋過。
然後,你需要記住為解構屬性上的可選屬性提供預設值,而不是在主要的初始化器中提供。記住 C 定義時 b 是可選的
tsfunction 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
謹慎使用解構。正如前面的例子所演示的,除了最簡單的解構表示式外,其他任何解構表示式都會令人困惑。在深度巢狀的解構中尤其如此,即便沒有加上重新命名、預設值和型別註解,也會變得 非常 難理解。儘量保持解構表示式小而簡單。你可以隨時自己編寫解構將會生成的賦值語句。
展開(Spread)
展開運算子是解構的對立面。它允許你將一個數組展開到另一個數組中,或者將一個物件展開到另一個物件中。例如
tslet first = [1, 2];let second = [3, 4];let bothPlus = [0, ...first, ...second, 5];
這使得 bothPlus 的值為 [0, 1, 2, 3, 4, 5]。展開會建立 first 和 second 的淺複製。它們不會被展開操作改變。
你也可以展開物件
tslet defaults = { food: "spicy", price: "$$", ambiance: "noisy" };let search = { ...defaults, food: "rich" };
現在 search 的值為 { food: "rich", price: "$$", ambiance: "noisy" }。物件展開比陣列展開更復雜。像陣列展開一樣,它從左到右執行,但結果仍然是一個物件。這意味著在展開物件中靠後的屬性會覆蓋靠前的屬性。所以如果我們修改之前的例子,在末尾進行展開
tslet defaults = { food: "spicy", price: "$$", ambiance: "noisy" };let search = { food: "rich", ...defaults };
那麼 defaults 中的 food 屬性就會覆蓋 food: "rich",這在當前情況下並不是我們想要的。
物件展開還有一些其他令人驚訝的限制。首先,它只包含物件 自身的可列舉屬性。基本上,這意味著當你展開一個物件的例項時,你會丟失方法
tsclass C {p = 12;m() {}}let c = new C();let clone = { ...c };clone.p; // okclone.m(); // error!
其次,TypeScript 編譯器不允許展開來自泛型函式的型別引數。該特性預計會在未來的語言版本中實現。
using 宣告
using 宣告是 JavaScript 中即將推出的一項特性,它是 第 3 階段顯式資源管理(Explicit Resource Management) 提案的一部分。using 宣告非常像 const 宣告,區別在於它將繫結到宣告的值的 生命週期 與變數的 作用域 耦合在一起。
當控制流離開包含 using 宣告的塊時,會執行所宣告值的 [Symbol.dispose]() 方法,這允許該值執行清理操作
tsfunction f() {using x = new C();doSomethingWith(x);} // `x[Symbol.dispose]()` is called
在執行時,這 大致 等同於以下操作
tsfunction 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
或者處理諸如追蹤(tracing)之類的作用域操作
tsfunction 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 宣告
tsasync function f() {await using x = new C();} // `await x[Symbol.asyncDispose]()` is invoked
當控制流離開包含塊時,await using 宣告會呼叫並 等待(await) 其值的 [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 的 async 函式中使用 await using 宣告,而沒有預先 await 它,那麼這種行為會有一個小小的注意事項
tsfunction g() {return Promise.reject("error!");}async function f() {await using x = new C();return g(); // missing an `await`}
因為返回的 promise 沒有被 await,JavaScript 執行時可能會報告一個未處理的拒絕(unhandled rejection),因為在 await x 的非同步銷燬時執行暫停,而沒有訂閱返回的 promise。然而,這並不是 await using 所特有的問題,因為在使用 try..finally 的 async 函式中也可能出現這種情況
tsasync function f() {try {return g(); // also reports an unhandled rejection}finally {await somethingElse();}}
為了避免這種情況,建議如果你的返回值可能是一個 Promise,請對其進行 await
tsasync function f() {await using x = new C();return await g();}
using 和 await using 在 for 和 for..of 語句中
using 和 await using 都可以用在 for 語句中
tsfor (using x = getReader(); !x.eof; x.next()) {// ...}
在這種情況下,x 的生命週期被限定在整個 for 語句中,並且僅在控制流由於 break、return、throw 離開迴圈,或者迴圈條件為 false 時才被銷燬。
除了 for 語句之外,這兩個宣告也可以用在 for..of 語句中
tsfunction * 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)。