TypeScript 是一種由微軟開發的自由和開源的程式語言。它是 JavaScript 的一個超集,而且本質上向這個語言添加了可選的靜態類型和基於類的面向對象編程。
命名#
- 使用 PascalCase 為類型命名,包括接口 interface、類型別名 type、類 class。
- 不要使用
I
作為接口名前綴,接口成員使用 camelCase 方式命名。
// Bad
interface IFoo {
Bar: number
Baz(): number
}
// Good
interface Foo {
bar: number
baz(): number
}
為什麼不使用 I 前綴命名接口?
- I 前綴違反了封裝原則:在 TypeScript 中,類可以實現接口,接口可以繼承接口,接口可以繼承類。類和接口都是某種意義上的抽象和封裝,繼承時不需要關心它是一個接口還是一個類。如果用 I 前綴,當一個變量的類型更改了,比如由接口變成了類,那變量名稱就必須同步更改。
- 防止不恰當的命名:禁止使用 I 前綴可以迫使程式設計師為接口取一個合適的、帶有語義、便於和其他同類型變量區分的名字,而不僅是用前綴區分。
- 匈牙利命名的時代已經過時:匈牙利命名法由類型前綴加實際的變量名組成,用這種方法命名的變量,看到變量名,可以立即知道其類型。但它的缺點遠於它帶來的好處,比如使變量名變得冗長,使相同主體名但類型不同的變量有歧義。
示例:
interface IFoo {}
class Point {}
type Baz = IFoo & Point
其實我們關心的是這是否是一個「類型」,不論它是 interface 或 class 或 type,都作為「類型」,其它的都不加前綴,沒必要給 interface 加個前綴去獨立出來。
- 使用 PascalCase 為枚舉對象本身和枚舉成員命名。
// Bad
enum color {
red,
}
// Good
enum Color {
Red,
}
- 使用 camelCase 為函數命名。
- 使用 camelCase 為屬性或本地變量命名。
// Bad
const DiskInfo
function GetDiskInfo() {}
// Good
const diskInfo
function getDiskInfo() {}
- 使用 PascalCase 為類命名,類成員使用 camelCase 方式命名。
// Bad
class foo {}
// Good
class Foo {}
// Bad
class Foo {
Bar: number
Baz(): number {}
}
// Good
class Foo {
bar: number
baz(): number {}
}
- 導入模塊的命名空間時使用 camelCase 命名法,文件名則使用 snake_case 命名法。
import * as fooBar from './foo_bar'
- 不要為私有屬性名添加
_
前綴。 - 儘可能使用完整的單詞拼寫命名。
總結:
命名法 | 分類 |
---|---|
帕斯卡命名法(PascalCase) | 類、接口、類型、枚舉、枚舉值、類型參數 |
駝峰式命名法(camelCase) | 變量、參數、函數、方法、屬性、模塊別名 |
全大寫下劃線命名法(CONSTANT_CASE) | 全局常量 |
模塊#
導入#
TypeScript 代碼必須使用路徑進行導入。這裡的路徑既可以是相對路徑,以 .
或 ..
開頭,也可以是從項目根目錄開始的絕對路徑,如 root/path/to/file
。
在引用邏輯上屬於同一項目的文件時,應使用相對路徑 ./foo
,不要使用絕對路徑 path/to/foo
。
應儘可能地限制父層級的數量(避免出現諸如 ../../../
的路徑),過多的層級會導致模塊和路徑結構難以理解。
import { Symbol1 } from 'google3/path/from/root'
import { Symbol2 } from '../parent/file'
import { Symbol3 } from './sibling'
在 ES6 和 TypeScript 中,導入語句共有四種變體:
導入類型 | 示例 | 用途 |
---|---|---|
模塊 | import * as foo from '...' | TypeScript 導入方式 |
解構 | import { SomeThing } from '...' | TypeScript 導入方式 |
默認 | import SomeThing from '...' | 只用於外部代碼的特殊需求 |
副作用 | import '...' | 只用於加載某些庫的副作用(例如自定義元素) |
// 應當這樣做!從這兩種變體中選擇較合適的一種(見下文)。
import * as ng from '@angular/core'
import { Foo } from './foo'
// 只在有需要時使用默認導入。
import Button from 'Button'
// 有時導入某些庫是為了其代碼執行時的副作用。
import 'jasmine'
import '@polymer/paper-button'
根據使用場景的不同,模塊導入和解構導入分別有其各自的優勢。
模塊導入:
- 模塊導入語句為整個模塊提供了一個名稱,模塊中的所有符號都通過這個名稱進行訪問,這為代碼提供了更好的可讀性,同時令模塊中的所有符號可以進行自動補全。
- 模塊導入減少了導入語句的數量,降低了命名衝突的出現幾率,同時還允許為被導入的模塊提供一個簡潔的名稱。
解構導入語句則為每一個被導入的符號提供一個局部的名稱,這樣在使用被導入的符號時,代碼可以更簡潔。
在代碼中,可以使用重命名導入解決命名衝突:
import { SomeThing as SomeOtherThing } from './foo'
在以下幾種情況下,重命名導入可能較為有用:
- 避免與其它導入的符號產生命名衝突。
- 被導入符號的名稱是自動生成的。
- 被導入符號的名稱不能清晰地描述其自身,需要通過重命名提高代碼的可讀性,如將 RxJS 的 from 函數重命名為 observableFrom。
導出#
代碼中必須使用具名的導出聲明。不要使用默認導出,這樣能保證所有的導入語句都遵循統一的範式。
// Use named exports:
export class Foo {}
// X 不要這樣做!不要使用默認導出!
export default class Foo {}
為什麼?因為默認導出並不為被導出的符號提供一個標準的名稱,這增加了維護的難度和降低可讀性的風險,同時並未帶來明顯的益處。
// 默認導出會造成如下的弊端
import Foo from './bar' // 這個語句是合法的。
import Bar from './bar' // 這個語句也是合法的。
具名導出的優勢是,當代碼中試圖導入一個並未被導出的符號時,這段代碼會報錯。例如,假設在 foo.ts
中有如下的導出聲明:
// 不要這樣做!
const foo = 'blah'
export default foo
如果在 bar.ts
中有如下的導入語句:
// 編譯錯誤!
import { fizz } from './foo'
會導致編譯錯誤: error TS2614: Module '"./foo"' has no exported member 'fizz'
。反之,如果在 bar.ts
中的導入語句為:
// 不要這樣做!這定義了一個多餘的變量 fizz!
import fizz from './foo'
結果是 fizz === foo
,這往往不符合預期,且難以調試。
類型#
聲明規範#
- 除非類型 / 函數需要在多個組件中共享,否則不要導出
- 在文件中,類型定義應該放在最前面
自動類型推斷#
在進行類型聲明時應盡量依靠 TypeScript 的自動類型推斷功能,如果能夠推斷出正確類型盡量不要再手動聲明。
- 基礎類型變量不需要手動聲明類型。
let foo = 'foo'
let bar = 2
let baz = false
- 引用類型變量應該保證類型正確,不正確的需要手動聲明。
// 自動推斷
let foo = [1, 2] // number[]
// 顯示聲明
// Bad
let bar = [] // any[]
// Good
let bar: number[] = []
拆箱類型#
在任何情況下,都不應該使用這些裝箱類型。不要使用如下類型 Number,String,Boolean,Object
,這些類型指的是裝箱類型,該使用類型 number,string,boolean,object
,這些類型指的是拆箱類型。
// Bad
function reverse(s: String): String
// Good
function reverse(s: string): string
以 String
為例,它包括 undefined、null、void
,以及代表的拆箱類型 string
,但並不包括其他裝箱類型對應的拆箱類型,我們看以下的代碼:
// 以下代碼成立
const tmp1: String = undefined
const tmp2: String = null
const tmp3: String = void 0
const tmp4: String = 'linbudu'
// 以下代碼不成立,因為不是字符串類型的拆箱類型
const tmp5: String = 599
const tmp6: String = { name: 'linbudu' }
const tmp7: String = () => {}
const tmp8: String = []
null 還是 undefined?#
TypeScript 代碼中可以使用 undefined
或者 null
標記缺少的值,這裡並無通用的規則約定應當使用其中的某一種。許多 JavaScript API 使用 undefined
(例如 Map.get
),然而 DOM 則更多地使用 null
(例如 Element.getAttribute
),因此,對於 null
和 undefined
的選擇取決於當前的上下文。
- 可空 / 未定義類型別名
不允許為包括 |null
或 |undefined
的聯合類型創建類型別名。這種可空的別名通常意味著空值在應用中會被層層傳遞,並且它掩蓋了導致空值出現的源頭。另外,這種別名也讓類或接口中的某個值何時有可能為空變得不確定。
因此,代碼必須在使用別名時才允許添加 |null
或者 |undefined
。同時,代碼應當在空值出現位置的附近對其進行處理。
// 不要這樣做!不要在創建別名的時候包含 undefined !
type CoffeeResponse = Latte | Americano | undefined
class CoffeeService {
getLatte(): CoffeeResponse {}
}
正確的做法:
// 應當這樣做!在使用別名的時候聯合 undefined !
type CoffeeResponse = Latte | Americano
class CoffeeService {
// 代碼應當在空值出現位置的附近對其進行處理
getLatte(): CoffeeResponse | undefined {}
}
- 可選參數 / 可選字段優先
TypeScript 支持使用創建可選參數和可選字段,例如:
interface CoffeeOrder {
sugarCubes: number
milk?: Whole | LowFat | HalfHalf
}
function pourCoffee(volume?: Milliliter) {}
可選參數實際上隱式地向類型中聯合了 |undefined
。應當使用可選字段(對於類或者接口)和可選參數而非聯合 |undefined
類型。
interface 還是 type?#
- interface:接口是 TypeScript 設計出來用於定義對象類型的,可以對對象的形狀進行描述。
- type:類型別名用於給各種類型定義別名,它並不是一個類型,只是一個別名而已。
相同點:
- 都可以描述一個對象或者函數。
// interface
interface User {
name: string
age: number
}
interface SetUser {
(name: string, age: number): void
}
// type
type User = {
name: string
age: number
}
type SetUser = (name: string, age: number) => void
- 都允許繼承
interface 和 type 都可以繼承,並且兩者並不是相互獨立的,也就是說 interface 可以 extends type, type 也可以 extends interface。雖然效果差不多,但是兩者語法不同。
// interface extends interface
interface Name {
name: string
}
interface User extends Name {
age: number
}
// type extends type
type Name = {
name: string
}
type User = Name & { age: number }
// interface extends type
type Name = {
name: string
}
interface User extends Name {
age: number
}
// type extends interface
interface Name {
name: string
}
type User = Name & {
age: number
}
不同點:
- type 可以聲明基本類型別名、聯合類型、交叉類型、元組等類型,而 interface 不行。
// 基本類型別名
type Name = string
// 聯合類型
interface Dog {
wong()
}
interface Cat {
miao()
}
type Pet = Dog | Cat
// 元組類型,具體定義數組每個位置的類型
type PetList = [Dog, Pet]
- type 語句中還可以使用 typeof 獲取實例的 類型進行賦值。
// 當你想獲取一個變量的類型時,使用 typeof
const div = document.createElement('div')
type B = typeof div
- interface 能夠聲明合併,重複聲明 type 會報錯。
interface User {
name: string
age: number
}
interface User {
sex: string
}
/*
User 接口為 {
name: string
age: number
sex: string
}
*/
總結:
- 如果使用聯合類型、交叉類型、元組等類型的時候,用 type 類型別名
- 如果需要使用 extends 進行類型繼承時,使用 interface
- 其他類型定義能使用 interface,優先使用 interface
所以,當需要聲明用於對象的類型時,應當使用接口,而非對象字面量表達式的類型別名:
// 應當這樣做!
interface User {
firstName: string
lastName: string
}
// 不要這樣做!
type User = {
firstName: string
lastName: string
}
為什麼?這兩種形式是幾乎等價的,因此,基於從兩個形式中只選擇其中一種以避免項目中出現變種的原則,這裡選擇了更常見的接口形式。相關技術原因 TypeScript: Prefer Interfaces。
TypeScript 團隊負責人的話:“老實說,我個人的意見是對於任何可以建模的對象都應當使用接口。相比之下,使用類型別名沒有任何優勢,尤其是類型別名有許多的顯示和性能問題”。
繞過類型檢測#
- 鴨子類型
當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子。
鴨子類型放在 TypeScript 裡來說就是我們可以在鳥上構建走路、游泳、叫等方法,創建一隻像鴨子的鳥,來繞開對鴨子的類型檢測。
interface Param {
field1: string
}
const func = (param: Param) => param
func({ field1: '111', field2: 2 }) // Error
const param1 = { field1: '111', field2: 2 }
func(param1) // success
在這裡構造了一個函數 func 接受參數為 Param,當直接調用 func 傳參時,相當於是賦值給變量 param,此時會嚴格按照參數校驗進行,因此會報錯。
而如果使用一個臨時變量存儲,再將變量傳遞給 func,此時則會應用鴨子類型的特性,因為 param1 中 包含 field1,TypeScript 會認為 param1 已經完全實現了 Param,可以認為 param1 對應的類型是 Param 的子類,這個時候則可以繞開對多餘的 field2 的檢測。
- 類型斷言
interface Param {
field1: string
}
const func = (param: Param) => param
func({ field1: '111', field2: 2 } as Param) // success
any 類型#
TypeScript 的 any
類型是所有其它類型的超類,又是所有其它類型的子類,同時還允許解引用一切屬性。因此,使用 any
十分危險,它會掩蓋嚴重的程式錯誤,並且它從根本上破壞了對應的值 “具有靜態屬性” 的原則。
儘可能不要使用 any
。如果出現了需要使用 any
的場景,可以考慮下列的解決方案:
- 縮小 any 的影響範圍
function f1() {
const x: any = expressionReturningFoo() // 不建議,後續的 x 都是 any 了
processBar(x)
}
function f2() {
const x = expressionReturningFoo()
processBar(x as any) // 建議,只有這裡是 any
}
- 使用更細化的 any
const numArgsBad = (...args: any) => args.length // Return any 不推薦
const numArgs = (...args: any[]) => args.length // Return number 推薦
- any 的自動推斷
TypeScript 中的 any 並不是一成不變的,會隨著用戶的操作,TypeScript 會猜測更加合理的類型。
const output = [] // any[]
output.push(1) // number[]
output.push('2') // (number|string)[]
- 優先使用 unknown 而非 any
any
類型的值可以賦給其它任何類型,還可以對其解引用任意屬性。一般來說,這個行為不是必需的,也不符合期望,此時代碼試圖表達的內容其實是 “該類型是未知的”。在這種情況下,應當使用內建的 unknown
類型。它能夠表達相同的語義,並且,因為 unknown
不能解引用任意屬性,它較 any
而言更為安全。一個 unknown
類型的變量可以再次賦值為任意其它類型。
類型斷言#
- 謹慎使用類型斷言和非空斷言
類型斷言(x as SomeType)和非空斷言(y!)是不安全的。這兩種語法只能夠繞過編譯器,而並不添加任何運行時斷言檢查,因此有可能導致程式在運行時崩潰。因此,除非有明顯或確切的理由,否則不應使用類型斷言和非空斷言。
// 不要這樣做!
;(x as Foo).foo()
y!.bar()
如果希望對類型和非空條件進行斷言,最好的做法是顯式地編寫運行時檢查。
// 應當這樣做!
// 這裡假定 Foo 是一個類。
if (x instanceof Foo) {
x.foo()
}
if (y) {
y.bar()
}
有時根據代碼中的上下文可以確定某個斷言必然是安全的。在這種情況下,應當添加註釋詳細地解釋為什麼這一不安全的行為可以被接受,如果使用斷言的理由很明顯,註釋就不是必需的。
// 可以這樣做!
// x 是一個 Foo 類型的示例,因為……
;(x as Foo).foo()
// y 不可能是 null,因為……
y!.bar()
- 類型斷言必須使用
as
語法,不要使用尖括號語法,這樣能強制保證在斷言外必須使用括號。
// 不要這樣做!
const x = (<Foo>z).length
const y = <Foo>z.length
// 應當這樣做!
const x = (z as Foo).length
- 使用類型標記(
: Foo
)而非類型斷言(as Foo
)標明對象字面量的類型。在日後對接口的字段類型進行修改時,前者能夠幫助程式設計師發現 Bug。
interface Foo {
bar: number
baz?: string // 這個字段曾經的名稱是“bam”,後來改名為“baz”。
}
const a: Foo = {
bar: 123,
bam: 'abc', // 如果使用類型標記,改名之後這裡會報錯!
}
const b = {
bar: 123,
bam: 'abc', // 如果使用類型斷言,改名之後這裡並不會報錯!
} as Foo
枚舉#
使用枚舉代替對象設置常量集合。使用對象定義的普通的常量集合修改時不會提示錯誤,除非使用 as const
修飾符。
// Bad
const Status = {
Success: 'success',
}
// Good
enum Status {
Success = 'success',
}
還可以通過 const enum
聲明常量枚舉:
const enum Status {
Success = 'success',
}
常量枚舉和普通枚舉的差異主要在訪問性與編譯產物。對於常量枚舉,你只能通過枚舉成員訪問枚舉值(而不能通過值訪問成員)。同時,在編譯產物中並不存在一個額外的輔助對象,對枚舉成員的訪問會被直接內聯替換為枚舉的值。
對於枚舉類型,必須使用 enum
關鍵字,但不要使用 const enum
(常量枚舉)。TypeScript 的枚舉類型本身就是不可變的。
擴展:as const
修飾符用在變量聲明或表達式的類型上時,它會強制 TypeScript 將變量或表達式的類型視為不可變的(immutable)。這意味著,如果你嘗試對變量或表達式進行修改,TypeScript 會報錯。
const foo = ['a', 'b'] as const
foo.push('c') // 報錯,因為 foo 類型被聲明為不可變的
const bar = { x: 1, y: 2 } as const
bar.x = 3 // 報錯,因為 bar 類型被聲明為不可變的
陣列#
- 對於簡單類型,應當使用陣列的語法糖
T[]
- 對於其它複雜的類型,則應當使用較長的
Array<T>
這條規則也適用於 readonly T[]
和 ReadonlyArray<T>
。
// 應當這樣做!
const a: string[]
const b: readonly string[]
const c: ns.MyObj[]
const d: Array<string | number>
const e: ReadonlyArray<string | number>
// 不要這樣做!
const f: Array<string> // 語法糖寫法更短
const g: ReadonlyArray<string>
const h: { n: number; s: string }[] // 大括號和中括號讓這行代碼難以閱讀
const i: (string | number)[]
const j: readonly (string | number)[]
函數#
- 不要為返回值被忽略的回調函數設置一個
any
類型的返回值類型,可以使用void
:
// Bad
function fn(x: () => any) {
x()
}
// Good
function fn(x: () => void) {
x()
}
使用 void
相對安全,因為它防止了你不小心使用 x 的返回值:
function fn(x: () => void) {
const k = x() // oops! meant to do something else
k.doSomething() // error, but would be OK if the return type had been 'any'
}
- 函數重載應該排序,令具體的排在模糊的之前,因為 TypeScript 會選擇第一個匹配到的重載,當位於前面的重載比後面的更” 模糊 “,那麼後面的會被隱藏且不會被選用:
// Bad
declare function fn(x: any): any
declare function fn(x: HTMLElement): number
declare function fn(x: HTMLDivElement): string
let myElem: HTMLDivElement
let x = fn(myElem) // x: any, wat?
// Good
declare function fn(x: HTMLDivElement): string
declare function fn(x: HTMLElement): number
declare function fn(x: any): any
let myElem: HTMLDivElement
let x = fn(myElem) // x: string, :)
- 優先使用使用可選參數,而不是重載:
// Bad
interface Example {
diff(one: string): number
diff(one: string, two: string): number
diff(one: string, two: string, three: boolean): number
}
// Good
interface Example {
diff(one: string, two?: string, three?: boolean): number
}
- 使用聯合類型,不要為僅在某個位置上的參數類型不同的情況下定義重載:
// Bad
interface Moment {
utcOffset(): number
utcOffset(b: number): Moment
utcOffset(b: string): Moment
}
// Good
interface Moment {
utcOffset(): number
utcOffset(b: number | string): Moment
}
類#
- 不要 #private 語法
不要使用 #private
私有字段(又稱私有標識符)語法聲明私有成員。而應當使用 TypeScript 的訪問修飾符。
// 不要這樣做!
class Clazz {
#ident = 1
}
// 應當這樣做!
class Clazz {
private ident = 1
}
為什麼?因為私有字段語法會導致 TypeScript 在編譯為 JavaScript 時出現體積和性能問題。同時,ES2015 之前的標準都不支持私有字段語法,因此它限制了 TypeScript 最低只能被編譯至 ES2015。另外,在進行靜態類型和可見性檢查時,私有字段語法相比訪問修飾符並無明顯優勢。
- 用 readonly
對於不會在構造函數以外進行賦值的屬性,應使用 readonly
修飾符標記。這些屬性並不需要具有深層不可變性。
- 參數屬性
不要在構造函數中顯式地對類成員進行初始化。應當使用 TypeScript 的參數屬性語法。直接在構造函數的參數前面加上修飾符或 readonly 等同於在類中定義該屬性同時給該屬性賦值,使代碼更簡潔。
// 不要這樣做!重複的代碼太多了!
class Foo {
private readonly barService: BarService
constructor(barService: BarService) {
this.barService = barService
}
}
// 應當這樣做!簡潔明了!
class Foo {
constructor(private readonly barService: BarService) {}
}
- 字段初始化
如果某個成員並非參數屬性,應當在聲明時就對其進行初始化,這樣有時可以完全省略掉構造函數。
// 不要這樣做!沒有必要單獨把初始化語句放在構造函數裡!
class Foo {
private readonly userList: string[]
constructor() {
this.userList = []
}
}
// 應當這樣做!省略了構造函數!
class Foo {
private readonly userList: string[] = []
}
- 子類繼承父類時,如果需要重寫父類方法,需要加上
override
修辭符
class Animal {
eat() {
console.log('food')
}
}
// Bad
class Dog extends Animal {
eat() {
console.log('bone')
}
}
// Good
class Dog extends Animal {
override eat() {
console.log('bone')
}
}
風格#
- 使用箭頭函數代替匿名函數表達式。
// Good
bar(() => {
this.doSomething()
})
// Bad
bar(function () {})
- 只要需要的時候才把箭頭函數的參數括起來。 比如,
(x) => x + x
是錯誤的,下面是正確的做法: x => x + x
(x,y) => x + y
<T>(x: T, y: T) => x === y
- 總是使用
{}
把循環體和條件語句括起來。 - 小括號裡開始不要有空白。逗號,冒號,分號後要有一個空格。比如:
for (let i = 0, n = str.length; i < 10; i++) {}
if (x < 10) {}
function f(x: number, y: string): void {}
- 每個變量聲明語句只聲明一個變量 (比如使用
let x = 1; let y = 2;
而不是let x = 1, y = 2;
)。 - 如果函數沒有返回值,最好使用
void
。 - 相等性判斷必須使用三等號(===)和對應的不等號(!==)。兩等號會在比較的過程中進行類型轉換,這非常容易導致難以理解的錯誤。並且在 JavaScript 虛擬機上,兩等號的運行速度比三等號慢。JavaScript 相等表。
參考資料#
- Coding guidelines
- TypeScript 手冊
- TypeScript 中文手冊
- Google TypeScript 風格指南
- Prohibition against prefixing interfaces with "I"
- Confused about the Interface and Class coding guidelines for TypeScript
- Typescript 開發規範
- TypeScript 中 as const 是什麼
- TypeScript: Prefer Interfaces
- TypeScript 中 interface 和 type 究竟有什麼區別?
- Typescript 聲明文件 - 第三方類型擴展
- Effective Typescript:使用 Typescript 的 n 個技巧
- JavaScript 相等表