前言
TS香,它3.8beta版本來了。
正文從這開始~~
TypeScript 3.8 將會帶來了許多特性,其中包含一些新的或即將到來的 ECMAScript 特性、僅僅導入/導出聲明語法等。
僅僅導入/導出聲明
為了能讓我們導入類型,TypeScript 重用了 JavaScript 導入語法。例如在下面的這個例子中,我們確保 JavaScript 的值 doThing 以及 TypeScript 類型 Options 一同被導入
// ./foo.ts interface Options { // ... } export function doThing(options: Options) { // ... } // ./bar.ts import { doThing, Options } from './foo.js'; function doThingBetter(options: Options) { // do something twice as good doThing(options); doThing(options); }
這很方便的,因為在大多數的情況下,我們不必擔心導入了什么 —— 僅僅是我們想導入的內容。
不幸的是,這僅是因為一個被稱之為「導入省略」的功能而起作用。當 TypeScript 輸出一個 JavaScript 文件時,TypeScript 會識別出 Options 僅僅是當作了一個類型來使用,它將會刪除 Options
// ./foo.js export function doThing(options: Options) { // ... } // ./bar.js import { doThing } from './foo.js'; function doThingBetter(options: Options) { // do something twice as good doThing(options); doThing(options); }
在通常情況下,這種行為都是比較好的。但是它會導致一些其他問題。
首先,在一些場景下,TypeScript 會混淆導出的究竟是一個類型還是一個值。比如在下面的例子中, MyThing 究竟是一個值還是一個類型?
import { MyThing } from './some-module.js'; export { MyThing };
如果單從這個文件來看,我們無從得知答案。如果 Mything 僅僅是一個類型,Babel 和 TypeScript 使用的 transpileModule API 編譯出的代碼將無法正確工作,並且 TypeScript 的 isolatedModules 編譯選項將會提示我們,這種寫法將會拋出錯誤。問題的關鍵在於,沒有一種方式能識別它僅僅是個類型,以及是否應該刪除它,因此「導入省略」並不夠好。
同時,這也存在另外一個問題,TypeScript 導入省略將會去除只包含用於類型聲明的導入語句。對於含有副作用的模塊,這造成了明顯的不同行為。於是,使用者將會不得不添加一條額外的聲明語句,來確保有副作用。
// This statement will get erased because of import elision. import { SomeTypeFoo, SomeOtherTypeBar } from './module-with-side-effects'; // This statement always sticks around. import './module-with-side-effects';
一個我們看到的具體例子是出現在 Angularjs(1.x)中, services 需要在全局在注冊(它是一個副作用),但是導入的 services 僅僅用於類型聲明中。
// ./service.ts export class Service { // ... } register('globalServiceId', Service); // ./consumer.ts import { Service } from './service.js'; inject('globalServiceId', function(service: Service) { // do stuff with Service });
結果 ./service.js 中的代碼不會被執行,導致在運行時會被中斷。
為了避免這類行為,我們意識到在什么該被導入/刪除方面,需要給使用者提供更細粒度的控制。
在 TypeScript 3.8 版本中,我們添加了一個僅僅導入/導出聲明語法來做為解決方式。
import type { SomeThing } from "./some-module.js"; export type { SomeThing };
import type 僅僅導入被用於類型注解或聲明的聲明語句,它總是會被完全刪除,因此在運行時將不會留下任何代碼。與此相似,export type 僅僅提供一個用於類型的導出,在 TypeScript 輸出文件中,它也將會被刪除。
值得注意的是,類在運行時具有值,在設計時具有類型。它的使用與上下文有關。當使用 import type 導入一個類時,你不能做類似於從它繼承的操作。
import type { Component } from "react"; interface ButtonProps { // ... } class Button extends Component<ButtonProps> { // ~~~~~~~~~ // error! 'Component' only refers to a type, but is being used as a value here. // ... }
如果在之前你使用過 Flow,它們的語法是相似的。一個不同的地方是我們添加了一個新的限制條件,來避免可能混淆的代碼。
// Is only 'Foo' a type? Or every declaration in the import? // We just give an error because it's not clear. import type Foo, { Bar, Baz } from "some-module"; // ~~~~~~~~~~~~~~~~~~~~~~ // error! A type-only import can specify a default import or named bindings, but not both.
與 import type 相關聯,我們提供來一個新的編譯選項:importsNotUsedAsValues,通過它可以來控制沒被使用的導入語句將會被如何處理,它的名字是暫定的,但是它提供來三個不同的選項。
-
remove,這是現在的行為 —— 丟棄這些導入語句。這仍然是默認行為,沒有破壞性的更改
-
preserve,它將會保留所有的語句,即使是從來沒有被使用。它可以保留副作用
-
error,它將會保留所有的導入(與 preserve 選項相同)語句,但是當一個值的導入僅僅用於類型時將會拋出錯誤。如果你想確保沒有意外導入任何值,這會是有用的,但是對於副作用,你仍然需要添加額外的導入語法。
對於該特性的更多信息,參考該 PR。
ECMAScript 私有字段
TypeScript 3.8 支持在 ECMAScript 中處於 stage-3 中的私有字段。
class Person { #name: string constructor(name: string) { this.#name = name; } greet() { console.log(`Hello, my name is ${this.#name}!`); } } let jeremy = new Person("Jeremy Bearimy"); jeremy.#name // ~~~~~ // Property '#name' is not accessible outside class 'Person' // because it has a private identifier.
不同於正常屬性(甚至是使用 private 修飾符聲明的屬性),私有字段有一些需要記住的規則:
-
私有字段使用
#
字符作為開始,通常,我們也把這些稱為私有名稱。 -
每個私有字段的名字,在被包含的類中,都是唯一的
-
在 TypeScript 中,像 public 和 private 修飾符不能用於私有字段
-
私有字段不能在所包含的類之外訪問 —— 即使是對於 JavaScript 使用者來說也是如此。通常,我們把這種稱為「hard privacy」。
除了「hard privacy」,私有字段的另外一個優點是我們先前提到的唯一性。
正常的屬性容易被子類所改寫
class C { foo = 10; cHelper() { return this.foo; } } class D extends C { foo = 20; dHelper() { return this.foo; } } let instance = new D(); // 'this.foo' refers to the same property on each instance. console.log(instance.cHelper()); // prints '20' console.log(instance.dHelper()); // prints '20'
使用私有字段時,你完全不必對此擔心,因為每個私有字段,在所包含的類中,都是唯一的
class C { #foo = 10; cHelper() { return this.#foo; } } class D extends C { #foo = 20; dHelper() { return this.#foo; } } let instance = new D(); // 'this.#foo' refers to a different field within each class. console.log(instance.cHelper()); // prints '10' console.log(instance.dHelper()); // prints '20'
另外有一個值得注意的地方,訪問一個有其他類型的私有字段,都將導致 TypeError。
class Square { #sideLength: number; constructor(sideLength: number) { this.#sideLength = sideLength; } equals(other: any) { return this.#sideLength === other.#sideLength; } } const a = new Square(100); const b = { sideLength: 100 }; // Boom! // TypeError: attempted to get private field on non-instance // This fails because 'b' is not an instance of 'Square'. console.log(a.equals(b));
對於類屬性來說,JavaScript 總是允許使用者訪問沒被聲明的屬性,而 TypeScript 需要使用者在訪問之前先定義聲明。使用私有字段時,無論是 .js 文件還是 .ts,都需要先聲明。
class C { /** @type {number} */ #foo; constructor(foo: number) { // This works. this.#foo = foo; } }
更多信息,請查看此 PR。
該使用哪個?
我們已經收到很多關於「我該使用 private 關鍵字,還是使用 ECMAScript 提供的私有字段 # 了?」這類的問題。
像所有其他好的問題一樣,答案總是令人遺憾的:它取決你。
在屬性方面,TypeScript private 修飾符在編譯后將會被刪除 —— 因此,盡管有數據存在,但是在輸出的 JavaScript 代碼中沒有關於該屬性聲明的任何編碼。在運行時,它的行為就像一個普通的屬性。當你使用 private 關鍵字時,私有屬性的有關行為只會出現在編譯階段/設計階段,而對於 JavaScript 消費者來說,則是完全無感知的。
class C { private foo = 10; } // This is an error at compile time, // but when TypeScript outputs .js files, // it'll run fine and print '10'. console.log(new C().foo); // prints '10' // ~~~ // error! Property 'foo' is private and only accessible within class 'C'. // TypeScript allows this at compile-time // as a "work-around" to avoid the error. console.log(new C()['foo']); // prints '10'
另一方面,ECMAScript 私有屬性無法在類之外訪問。
class C { #foo = 10; } console.log(new C().#foo); // SyntaxError // ~~~~ // TypeScript reports an error *and* // this won't work at runtime! console.log(new C()["#foo"]); // prints undefined // ~~~~~~~~~~~~~~~ // TypeScript reports an error under 'noImplicitAny', // and this prints 'undefined'.
「hard privacy」對於確保沒有人能使用你的任何內部變量是有用的,如果你是一個庫的作者,移除或者重命名一個私有字段不會造成任何重大變化。
正如上文所述,使用 ECMAScript 的私有字段,創建子類會更容易,因為它們是真私有。當使用 ECMAScript 私有字段時,子類無需擔心字段名字的沖突。當使用 TypeScript private 屬性聲明時,使用者仍然需要小心不要覆蓋父類中的相同字段。
最后,還有一些你需要考慮的事情,比如你打算讓你的代碼在哪運行?當前,TypeScript 只有在編譯目標為 ECMAScript 2015(ES6)及其以上時,才能支持該私有字段。因為我們在底層使用 WeakMaps 實現這種方法 —— WeakMaps 並不能以一種不會導致內存泄漏的方式 polyfill。對比而言,TypeScript 的 private 聲明屬性能在所有的編譯目標下正常工作 —— 甚至是 ECMAScript 3。
export * as ns 語法
以下方式很常見
import * as utilities from './utilities.js'; export { utilities };
在 ECMAScript 2020 中,添加了一種新的語法來支持該模式:
export * as utilities from "./utilities.js";
這是一次 JavaScript 代碼質量的改進,TypeScript 3.8 實現了此語法。
當你的編譯目標早於 es2020 時,TypeScript 將會按照第一個代碼片段輸出內容。
Top-Level await
大多數使用 JavaScript 提供 I/O(如 http 請求)的現代環境都是異步的,並且很多現代 API 都返回 Promise。盡管它在使操作無阻塞方面有諸多優點,但是它確實在一些如讀取文件或外部內容時,會讓人厭煩。
fetch('...') .then(response => response.text()) .then(greeting => { console.log(greeting); });
為了避免 Promise 中 .then 的鏈式操作符,JavaScript 使用者通常會引入 async 函數以使用 await,然后在定義該函數之后,立即調用該函數。
async function main() { const response = await fetch('...'); const greeting = await response.text(); console.log(greeting); } main().catch(e => console.error(e));
為了避免引入 async 函數,我們可以使用一個簡便的語法,它在即將到來的 ECMAScript feature 中被稱為 top-level await。
在當前的 JavaScript 中(以及其他具有相似功能的大多數其他語言),await 僅僅只能用於 async 函數內部。然而,使用 top-level await 時,我們可以在一個模塊的頂層使用 await。
const response = await fetch('...'); const greeting = await response.text(); console.log(greeting); // Make sure we're a module export {};
這里有一個細節:top-level await 僅僅只能在一個模塊的頂層工作 —— 僅當 TypeScript 發現文件代碼中含有 export 或者 import 時,才會認為該文件是一個模塊。在一些基礎的實踐中,你可能需要寫下 export {} 做為樣板,來確保這種行為。
top-level await 並不會在你可能期望的所有環境下工作。現在,只有在編譯目標選項是 es2017 及其以上,top-level await 才能被使用,並且 module 選項必須為 esnext 或者 system。更多相關信息,請查看該 PR。
JSDoc 屬性修飾符
TypeScript 3.8 通過打開 allJs 選項,能支持 JavaScript 文件,並且當使用 checkJs 選項或者在你的 .js 文件頂部中添加 // @ts-check 注釋時,TypeScript 能對這些 .js 文件進行類型檢查。由於 JavaScript 文件沒有專用的語法來進行類型檢查,因此 TypeScript 選擇利用 JSDoc。TypeScript 3.8 能理解一些新的 JSDoc 屬性標簽。
首先是所有的訪問修飾符:@public、@private、@protected。這些標簽的工作方式與 TypeScript 中 public、private、protected 相同。
// @ts-check class Foo { constructor() { /** @private */ this.stuff = 100; } printStuff() { console.log(this.stuff); } } new Foo().stuff; // ~~~~~ // error! Property 'stuff' is private and only accessible within class 'Foo'.
@public 是默認的,可以省略,它代表了一個屬性可以從任何地方訪問它
@private 表示一個屬性只能在包含的類中訪問
@protected 表示該屬性只能在所包含的類及子類中訪問,但不能在類的實例中訪問
下一步,我們計划添加 @readonly 修飾符,來確保一個屬性只能在初始化時被修改:
// @ts-check class Foo { constructor() { /** @readonly */ this.stuff = 100; } writeToStuff() { this.stuff = 200; // ~~~~~ // Cannot assign to 'stuff' because it is a read-only property. } } new Foo().stuff++; // ~~~~~ // Cannot assign to 'stuff' because it is a read-only property.
watchOptions
一直以來,TypeScript 致力於在 --watch 模式下和編輯器中提供可靠的文件監聽功能。盡管在大部分情況下,它都能很好的工作,但是在 Node.js 中,文件監控非常困難,這主要體現在我們的代碼邏輯中。在 Node.js 中內置的 API 中,要么占用大量的 CPU 資源,要么不准確(fs.watchFile),甚至它們在各個平台的行為不一致(fs.watch)。除此之外,我們幾乎不可能確定哪個 API 會更好的工作,因為它們不僅依賴於平台,還取決於文件所在的文件系統。
這一直是個難題,因為 TypeScript 需要在更多平台上運行,而不僅僅是 Node.js。並且需要考慮到避免依賴模塊完全獨立。這尤其適用於對 Node.js 原生模塊有依賴的模塊。
由於每個項目在不同的策略下都可能更好的工作,TypeScript 3.8 在 tsconfig.json 和 jsconfig.json 中添加了一個新的 watchOptions 字段,它可以讓使用者告訴編譯器/語言服務,應該使用哪種監聽策略來跟蹤文件或目錄。
{ // Some typical compiler options "compilerOptions": { "target": "es2020", "moduleResolution": "node", // ... }, // NEW: Options for file/directory watching "watchOptions": { // Use native file system events for files and directories "watchFile": "useFsEvents", "watchDirectory": "useFsEvents", // Poll files for updates more frequently // when they're updated a lot. "fallbackPolling": "dynamicPriority" } }
watchOptions 包含四種新的選項
watchFile:監聽單個文件的策略,它可以有以下值
-
fixedPollingInterval,以固定的時間間隔,檢查文件的更改
-
priorityPollingInterval,以固定的時間間隔,檢查文件的更改,但是使用「heuristics」檢查某些類型的文件的頻率比其他文件低(heuristics 怎么翻?)
-
dynamicPriorityPolling,使用動態隊列,在該隊列中,較少檢查不經常修改的文件
-
useFsEvents(默認),嘗試使用操作系統/文件系統原生事件來監聽文件更改
-
useFsEventsOnParentDirectory,嘗試使用操作系統/文件系統原生事件來監聽文件、目錄的更改,這樣可以使用較小的文件監聽程序,但是准確性可能較低
watchDirectory,在缺少遞歸文件監聽功能的系統中,使用哪種策略監聽整個目錄樹,它可以有以下值
-
fixedPollingInterval,以固定的時間間隔,檢查目錄樹的更改
-
dynamicPriorityPolling,使用動態隊列,在該隊列中,較少檢查不經常修改的目錄
-
useFsEvents(默認),嘗試使用操作系統/文件系統原生事件來監聽目錄更改
fallbackPolling,當使用文件系統的事件,該選項用來指定使用特定策略,它可以有以下值
-
fixedPollingInterval,同上
-
priorityPollingInterval,同上
-
dynamicPriorityPolling,同上
synchronousWatchDirectory,在目錄上禁用延遲監聽功能。在可能一次發生大量文件(如 node_modules)更改時,它非常有用,但是你可能需要一些不太常見的設置時,禁用它。
參考資料
-
TypeScript 3.8: https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/
-
PR: https://github.com/microsoft/TypeScript/pull/35200
-
stage-3: https://github.com/tc39/proposal-class-fields/
-
PR: https://github.com/Microsoft/TypeScript/pull/30829
-
PR: https://github.com/microsoft/TypeScript/pull/35813
-
fs.watchFile: https://nodejs.org/api/fs.html#fsfswatchfilefilenameoptions_listener
-
fs.watch: https://nodejs.org/api/fs.html#fsfswatchfilenameoptions_listener