TypeScript:從新發明一次 JavaScript

做者:LeanCloud 工程師 王子亭html

做爲一個 Node.js 開發者,我很早便了解到了 TypeScript,但又由於我對 CoffeeScript 的喜好,直到 2016 年才試用了一下 TypeScript,但當時對它的學習並不深刻,直到最近又在工做中用 TypeScript 開發了兩個後端項目,對 TypeScript 有了一些新的理解。前端

爲 JavaScript 添加類型

你們總會把 TypeScript 和其餘語言去作對比,說它是在模仿 Java 或 C#,我也曾一度相信了這種說法。但其實並不是如此,TypeScript 的類型系統和工做機制是如此的獨特,沒法簡單地描述成是在模仿哪個語言,更像是在 JavaScript 的基礎上從新發明了 JavaScriptgit

究其根本,TypeScript 並非一個全新的語言,它是在一個已有的語言 —— 仍是一個很是靈活的動態類型語言上添加靜態約束。在官方 Wiki 上的 TypeScript Design Goals 中有提到,TypeScript 並非要從 JavaScript 中抽取出一個具備靜態化語義的子集,而是要儘量去支持以前社區中已有的編程範式,避免與常見的用法產生不兼容。github

這意味着 TypeScript 試圖爲 JavaScript 已有的大量十分「動態」的特性去提供靜態語義。通常認爲「靜態類型」的標誌是在編譯時爲變量肯定類型,但 TypeScript 很特殊,由於 JavaScript 自己的動態性,TypeScript 中的類型更像是一種「約束」,它尊重已有的 JavaScript 設計範式,同時儘量添加一點靜態約束 —— 這種約束不會影響到代碼的表達能力。或者說,TypeScript 會以 JavaScript 的表達能力爲先、以 JavaScript 的運行時行爲爲先,而靜態約束則次之。typescript

這樣聽起來 TypeScript 是否是很無聊呢,畢竟 Python 也有 Type Checking,JavaScript 以前也有 Flow。的確如此,但 TypeScript 的類型系統的表達能力和工具鏈的支持實在太強了,並不像其餘一些靜態類型標註僅能覆蓋一些簡單的狀況,而是可以深入地參與到整個開發過程當中,提升開發效率編程

前面提到 TypeScript 並不想發明新的範式,而是要儘量支持 JavaScript 已有的用法。所以雖然 TypeScript 有着強大的類型系統、大量的特性,但對於 JavaScript 開發者來講學習成本並不高,由於幾乎每一個特性均可以對應 JavaScript 社區中一種常見的範式。後端

基於屬性的類型系統

在 JavaScript 中,對象(Object)是最經常使用的類型之一,咱們會使用大量的對象字面量來組織數據,咱們常常將不少不一樣的參數塞進一個對象,或者從一個函數中返回一個對象,對象中還能夠再嵌套對象。能夠說對象是 JavaScript 中最經常使用的數據容器,但並無類型去約束它。數組

例如 request 這個庫會要求使用者將發起請求的全部參數一股腦地以一個對象的形式做爲參數傳入。這就是很是典型的 JavaScript 風格。再好比 JavaScript 中一個 Promise 對象只需有 then 和 catch 這兩個實例方法就能夠,而並不真的須要真的來自標準庫中的 Promise 構造器,實際上也有不少第三方的 Promise 的實現,或一些返回類 Promise 對象的庫(例如一些 ORM)。安全

在 JavaScript 中咱們一般只關注一個對象是否有咱們須要的屬性和方法,這種範式被稱爲「鴨子類型(Duck typing)」,就是說「當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就能夠被稱爲鴨子」。數據結構

因此 TypeScript 選擇了一種基於屬性的類型系統(Structural type system),這種類型系統再也不關注一個變量被標稱的類型(由哪個構造器構造),而是 在進行類型檢查時,將對象拆開,抽絲剝繭,逐個去比較組成這個對象的每個不可細分的成員。若是一個對象有着一個類型所要求的全部屬性或方法,那麼就能夠看成這個類型來使用

這就是 TypeScript 類型系統的核心 —— Interface(接口):

interface LabeledValue {
  label: string
}

TypeScript 並不關心 Interface 自己的名字,與其說是「類型」,它更像是一種約束。一個對象只要有一個字符串類型的 label 屬性,就能夠說它知足了 LabeledValue 的約束。它能夠是一個其餘類的實例、能夠是字面量、能夠有額外的屬性;只要它知足 LabeledValue 所要求的屬性,就能夠被賦值給這個類型的變量、傳遞給這個類型的參數。

前面提到 Interface 其實是一組屬性或一組約束的集合,說到集合,固然就能夠進行交集、並集之類的運算。例如 type C = A & B 表示 C 須要同時知足類型 A 和類型 B 的約束,能夠簡單地實現類型的組合;而 type C = A | B 則表示 C 只需知足 A 和 B 任一類型的約束,能夠實現聯合類型(Union Type)。

接下來我會挑選一些 TypeScript 具備表明性的一些特性進行介紹,它們之間環環相扣,十分精妙。

字符串魔法:字面量

在 TypeScript 中,字面量也是一種類型:

type Name = 'ziting'
const myName: Name = 'ziting'

在上面的代碼中,Name 類型惟一合法的值就是 ziting 這個字符串 —— 這看起來毫無心義,但若是咱們引入前面提到的集合運算(聯合類型)呢?

type Method = 'GET' | 'PUT' | 'DELETE'

interface Request {
  method: Method
  url: string
}

上面的代碼中咱們約束了 Request 的 method 只能是 GET、PUT 和 DELETE 之一,這比單純地約束它是一個字符串類型要更加準確。這是 JavaScript 開發者常用的一種模式 —— 用字符串來表示枚舉類型,字符串更靈活也更具備可讀性。

在 lodash 之類的庫中,JavaScript 開發者還很是喜歡使用字符串來傳遞屬性名,在 JavaScript 中這很容易出錯。而 TypeScript 則提供了專門的語法和內建的工具類型來實現對這些字符串字面量的計算,提供靜態的類型檢查:

interface Todo {
  title: string
  description: string
  completed: boolean
}

// keyof 將 interface 的全部屬性名提取成一個新的聯合類型
type KeyOfTodo = keyof Todo // 'title' | 'description' | 'completed'
// Pick 能夠從一個 interface 中提取一組屬性,生成新的類型
type TodoPreview = Pick<Todo, 'title' | 'completed'> // {title: string, completed: boolean}
// Extract 能夠找到兩個並集類型的交集,生成新的類型
type Inter = Extract<keyof Todo, 'title' | 'author'> // 'title'

藉助這些語法和後面提到的泛型能力,JavaScript 中各類以字符串的形式傳遞屬性名、魔法般的對象處理,也均可以獲得準確的類型檢查。

類型元編程:泛型

泛型提供了一種將類型參數化的能力,在其餘語言中最基本的用途是定義容器類型,使得工具函數能夠沒必要知道被操做的變量的具體類型。JavaScript 中的數組或 Promise 在 TypeScript 中都會被表述爲這樣的泛型類型,例如 Promise.all 的類型定義能夠寫成:

function all<T>(values: Array<T | Promise<T>>): Promise<Array<T>>

能夠看到類型參數能夠被用來構造更復雜的類型,進行集合運算或嵌套。

默認狀況下,由於類型參數能夠是任意的類型,因此不能假定它有某些屬性或方法,也就不能訪問它的任何屬性,只有添加了約束才能遵循這個約束去使用它,同時 TypeScript 會依照這個約束限制傳入的類型:

interface Lengthwise {
  length: number
}

function logLength<T extends Lengthwise>(arg: T) {
  console.log(arg.length)
}

約束中也能夠用到其餘的類型參數或使用多個類型參數,在下面的代碼中咱們限制類型參數 K 必須是 obj 的一個屬性名:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

除了在函數上使用泛型以外,咱們還能夠定義泛型類型:

type Partial<T> = {
  [P in keyof T]?: T[P];
}

當定義泛型類型時咱們其實是在定義一種處理類型的「函數」,使用泛型參數去生成新的類型,這也被稱做「元編程」。例如 Partial 會遍歷傳入類型 T 的每個屬性,返回一個全部屬性均可空的新類型:

interface Person {
  name: string
}

const a: Person = {} // 報錯 Property 'name' is missing in type '{}' but required in type 'Person'.
const b: Partial<Person> = {}

前面咱們提到的 Pick 和 Extract 都是這樣的泛型類型。

在此以外 TypeScript 甚至能夠在定義泛型類型時進行條件判斷和遞歸,這使得 TypeScript 的類型系統變成了 圖靈完備的,能夠在編譯階段進行任何計算。

你可能會懷疑這樣複雜的類型真的有用麼?其實這些特性更多地是提供給庫開發者使用的,對於 JavaScript 社區中的 ORM、數據結構,或者是 lodash 這樣的庫來講,如此強大的類型系統是很是必要的,lodash 的 類型定義 行數甚至是它自己代碼的幾十倍。

類型方程式:自動推導

但其實咱們並不必定要掌握這麼複雜的類型系統,實際上前面介紹的高級特性在業務代碼中都極少被用到。TypeScript 並不但願標註類型給開發者形成太大的負擔,所以 TypeScript 會盡量地進行類型推導,讓開發者在大多數狀況下沒必要手動標註類型。

const bool = true // bool 是 true(字面量類型)
let num = 1 // num 是 number
let arr = [0, 1, 'str'] // arr 是 (number | string)[]

let body = await fs.readFile() // body 是 Buffer

// cpuModels 是 string[]
let cpuModels = os.cpus().map( cpu => {
  // cpu 是 os.CpuInfo
  return cpu.model
})

類型推導一樣能夠用在泛型中,例如前面提到的 Promise.all 和 getProperty,咱們在使用時都沒必要去管泛型參數:

// 調用 Promise.all<Buffer>,files 的類型是 Promise<Buffer[]>
const files = Promise.all(paths.map( path => fs.readFile(path)))
// 調用 Promise.all<number[]>,numbers 的類型是 Promise<number[]>
const numbers = Promise.all([1, 2, 3, 4])

// 調用 getProperty<{a: number}, 'a'>,a 的類型是 number
const a = getProperty({a: 2}, 'a')

前面提到泛型是在將類型參數化,引入一個未知數來代替實際的類型,因此說泛型對於 TypeScript 就像是一個方程式同樣,只要你提供了可以解開這個方程的其餘未知數,TypeScript 就能夠推導出剩餘的泛型類型。

價值十億美金的錯誤

在不少語言中訪問空指針都會報出異常(在 JavaScript 中是從 null 或 undefined 上讀取屬性時),空指針異常被稱爲「價值十億美圓的錯誤」。TypeScript 則爲空值檢查也提供了支持(需開啓 strictNullChecks),雖然這依賴於類型定義的正確性,並無運行時的保證,但依然能夠提早在編譯期發現大部分的錯誤,提升開發效率。

TypeScript 中的類型是不可爲空(undefined 或 null)的,對於可空的類型必須表示成和 undefined 或 null 的並集類型,這樣當你試圖從一個可能爲 undefined 的變量上讀取屬性時,TypeScript 就會報錯了。

function logDateValue1(date: Date) { // 參數不可空
  console.log(date.valueOf())
}

logDateValue1(new Date)
logDateValue1() // 報錯 An argument for 'date' was not provided.

function logDateValue2(date: Date | undefined) { // 參數可空
  console.log(date.valueOf()) // 報錯 Object is possibly 'undefined'.
}

logDateValue2(new Date)
logDateValue2()

在這種狀況下 TypeScript 會要求你先對這個值進行判斷,排除其爲 undefined 可能性。這就要說到 TypeScript 的另一項特性 —— 其基於控制流的類型分析。例如在你使用 if 對變量進行非空判斷後,在 if 以後的花括號中這個變量就會變成非空類型:

function print(str: string | null) {
  // str 在這裏的類型是 string | null
  console.log(str.trim()) // 報錯 Object is possibly 'null'.
  if (str !== null) {
    // str 在這裏的類型是 string
    console.log(str.trim())
  }
}

一樣的類型分析也發生在使用 if、switch 等語句對並集類型進行判斷時:

interface Rectangle {
  kind: 'rectangle'
  width: number
  height: number
}

interface Circle {
  kind: 'circle'
  radius: number
}

function area(s: Rectangle | Circle) {
  // s 在這裏的類型是 Rectangle | Circle
  switch (s.kind) {
    case 'rectangle':
      // s 在這裏的類型是 Rectangle
      return s.height * s.width
    case 'circle':
      // s 在這裏的類型是 Circle
      return Math.PI * s.radius ** 2;
  }
}

僅僅工做在編譯階段

TypeScript 最終仍然會編譯到 JavaScript,再被 JavaScript 引擎(如 V8)執行,在生成出的代碼中不會包含任何類型信息,TypeScript 也不會添加任何與運行時行爲有關的功能。

TypeScript 僅僅提供了類型檢查,但它並無去保證經過檢查的代碼必定是能夠正確運行的。可能一個變量在 TypeScript 的類型聲明中是一個數字,但並不能阻止它在運行時變成一個字符串 —— 多是使用了強制類型轉換或使用了其餘非 TypeScript 的庫且類型定義文件有誤。

在 TypeScript 中你能夠將類型設置爲 any 來繞過幾乎全部檢查,或者用 as 來強制「轉換」類型,固然就像前面提到的那樣,這裏轉換的僅僅是 TypeScript 在編譯階段的類型標註,並不會改變運行時的類型。雖然 TypeScript 設計上要去支持 JavaScript 的全部範式,但不免有一些極端的用例沒法覆蓋到,這時如何使用 any 就很是考驗開發者的經驗了。

編程語言的類型系統老是須要在靈活和複雜、簡單和死板之間作出權衡,TypeScript 則給出了一個徹底不一樣的答案 —— 將編譯期的檢查和運行時的行爲分別看待。這是 TypeScript 飽受爭議的一點,有人認爲這樣很是沒有安全感,即便經過了編譯期檢查在運行時依然有可能獲得錯誤的類型,也有人認爲 這是一個很是切合工程實際的選擇 —— 你能夠用 any 來跳過類型檢查,添加一些過於複雜或沒法實現的代碼,雖然這破壞了類型安全,但確實又解決了問題

那麼這種僅僅工做在編譯階段類型檢查有意義麼?我認爲固然是有的,畢竟 JavaScript 已經提供了足夠使用的運行時行爲,並且要保持與 JavaScript 的互操做性。你們須要的只是 TypeScript 的類型檢查來提升開發效率,除了編譯階段的檢查來儘早發現錯誤之外,TypeScript 的類型信息也能夠給編輯器(IDE)很是準確的補全建議。

與 JavaScript 代碼一塊兒工做

任何基於 JavaScript 的技術都要去解決和標準 JavaScript 代碼的互操做性 —— TypeScript 不可能創造出一個平行與 JavaScript 的世界,它必須依賴社區中已有的數十萬的 JavaScript 包。

所以 TypeScript 引入了一種類型描述文件,容許社區爲 JavaScript 編寫類型描述文件,來讓用到它們的代碼能夠獲得 TypeScript 的類型檢查。

描述文件的確是 TypeScript 開發中最大的痛點,畢竟只有當找全了定義文件以後,纔會有流暢的開發體驗。在開發的過程當中不可避免地會用到一些特定領域的、小衆的庫,這時就必需要去考慮這個庫是否有定義文件、定義文件的質量如何、是否須要本身爲其編寫定義文件。對於不涉及複雜泛型的庫來講,寫定義文件並不會花太多時間,你也只須要給本身用到的接口寫定義,但終究是一個分心的點。

小結

TypeScript 有着先進的類型系統,並且這個先進並非「學術」意義上的先進,而是「工程」意義上的先進,可以切實地提升開發效率,減輕動態類型的心理負擔,提早發現錯誤。因此在此建議全部的 JavaScript 開發者都瞭解和嘗試一下 TypeScript,對於 JavaScript 的開發者來講,TypeScript 的入門成本很是低。

在 LeanCloud,控制檯在最近的一次的重構中切換到了 TypeScript,提升了前端項目的工程化水平,讓代碼能夠被長時間地維護下去。同時咱們一部分既有的基於 Node.js 的後端項目也在切換到 TypeScript。

LeanCloud 的一些內部工具和邊緣服務也會優先考慮 TypeScript,較低的學習成本(誰沒寫過幾行 JavaScript 呀!)、靜態類型檢查和優秀的 IDE 支持,極大地下降了新同事參與不熟悉或長時間無人維護的項目的門檻,提升你們改進內部工具的積極性。

LeanCloud 的 JavaScript SDK、Node SDK 和 Play SDK 都添加了 TypeScript 的定義文件(而且打算在以後的版本中使用 TypeScript 改寫),讓使用 LeanCloud 的開發者能夠在 TypeScript 中使用 SDK,即便不用 TypeScript,定義文件也能夠幫助編輯器來改進代碼補全和類型提示。

若是你也但願一塊兒來完善這些項目,能夠了解一下在 LeanCloud 的 工做機會

參考資料:

相關文章
相關標籤/搜索