Typescript開發學習總結(附大量代碼)

如果評定前端在最近五年的重大突破,Typescript肯定能名列其中,重大到各大技術論壇、大廠面試都認為Typescript應當是前端的一項必會技能。作為一名消息閉塞到被同事調侃成「新石器時代碼農」的我,也終於在2019年底上車了Typescript。使用的一年間整理了許多的筆記和代碼片段,花了一段時間整理成了下文。

本文不是教程,主要目的是分享我個人在使用Typescript開發1年期間的一些理解和代碼片段,因此文章內容主要圍繞對某些特性做的研究和理解。也希望能幫到一些同在學習使用Typescript的小夥伴,如有錯誤遺漏也希望能夠指出。

基礎數據類型

Javascript一共有6種基礎類型:String/Number/Boolean/Null/Undefined/Symbol,分別對應Typescript中6種類型聲明:string/number/boolean/null/undefined/symbol

基礎數據類型的類型聲明適用的幾條規則:

  1. Typescript在編譯時會對代碼做靜態類型檢查,多數情況下不支持隱式轉換,即let yep: boolean = 1會報錯
  2. Typescript中的基礎類型聲明的首字母不區分大小寫,即let num: number = 1等同於let num: Number = 1,但是推薦小寫形式
  3. Typescript允許變量有多種類型(即聯合類型),通過|連接即可,如let yep: number | boolean = 1,但是不建議這麼做
  4. 類型聲明不佔用變量,因此let boolean: boolean = true是允許的,但是不建議這麼用
  5. 默認情況下,除了neverTypescript可以把其他類型聲明(包括引用數據類型)的變量賦值為null/undefined/void 0而不報錯。但這肯定是錯誤的,建議在tsconfig.json中設置"strictNullChecks": true屏蔽掉這種情況
  6. 對於基礎類型而言,unknownany的最終結果是一致的
// 字符串類型聲明,單引號/雙引號不影響類型推斷
let str: string = 'Hello World';

// 數字類型聲明
let num: number = 120;
// 這些值也是合法的數字類型
let nan: number = NaN;
let max: number = Infinity;
let min: number = -Infinity;

// 布爾類型聲明
let not: boolean = false;
// Typescript只對結果進行檢查,!0最後得到true,因此不會報錯
let yep: boolean = !0;

// symbol類型聲明
let key: symbol = Symbol('key');

// never類型不能進行賦值
// 執行console.log(never === undefined),執行結果為true
let never: never;
// 但即使never === undefined,賦值邏輯仍然會報錯
never = undefined;

// 除了never,未開啟strictNullChecks時,其他類型變量賦值為null/undefined/void 0不報錯
let always: boolean = true;
let isNull: null =  null;
// 不會報錯
always = null;
isNull = undefined;

引用數據類型

Javascript的引用數據類型有很多,比如Array/Object/Function/Date/Regexp等,與基礎類型不一樣的地方是,Typescript有些地方並不能簡單地與Javascript直接對應,部分的執行結果讓人摸不着頭腦。

在書寫規則上,除了Object以外,Typescript其他的引用數據類型聲明的首字母必須大寫,如let list: array<number> = [1]會報錯,必須寫成let list: Array<number> = [1]。原因是這些引用數據類型在本質上都是構造函數,Typescript的底層會通過類似於list instanceof Array的邏輯進行類型比對。

其中比較有意思的一個點是:在所有的數據類型里,Array是唯一的泛型類型,也是唯一有兩種不同的寫法:Array<T>T[]

與數組相關的類型聲明還有元組Tuple,跟數組的差別主要體現在:元組的長度是固定已知的。因此使用場景也非常明確,適合用在有固定的標準/參數/配置的地方,比如經緯度坐標、屏幕分辨率等。

// 數組類型有Array<T>和T[]兩種寫法
let arr1: Array<number> = [1]
let arr2: number[] = [2]

// 未開啟strictNullChecks時,賦值為null/undefined/void 0不報錯
let arr3: number[] = null
// 編譯時不會報錯,運行時報錯
arr3.push(1)

// 元組類型
// 坐標表示
let coordiate: [ number, number ] = [114.256429,22.724147]

// 其他引用數據類型
let date: Date = new Date()
let pattern: Regexp = /\w/gi

// 類型聲明在函數中的簡單運用
// 函數表達式的寫法
function fullName(firstName: string, lastName: string): string {
  return firstName + ' ' + lastName
}
// 函數聲明式的寫法
const sayHello = (fullName: string): void => alert(`Hello, ${ fullName }`)

// 當你不知道函數的返回值,但又不想用any/unknown的時候可以試試這種類型聲明的寫法,不過不推薦
const sayHey: Function = (fullName: string) => alert(`Hey, ${ fullName }`)

Typescript中關於對象的類型聲明一共有三種形式:Object/object/{},我一開始以為Object會像Array也是泛型類型,然而經過測試發現不僅不是泛型,還有個首字母小寫形式的objectObject/object/{}三者之間的執行結果完全不同。

  1. Object作為類型聲明時,變量值可以是任意值,如字符串/數字/數組/函數等,但是如果變量值不是對象,則無法使用其變量值特有的方法,如let list: Object = []不會報錯,但執行list.push(1)會報錯。造成這種情況的原因是因為在Javascript中,在當前對象的原型鏈上找不到屬性/方法時,會向上一層對象進行查找,而Object.prototype是所有對象原型鏈查找的終點,也因此在Typescript中將類型聲明成Object不會報錯,但無法使用非對象的屬性/方法

  2. object作為類型聲明時,變量值只能是對象,其他值會報錯。值得注意的是,object聲明的對象無法訪問/添加對象上的任何屬性/方法,實際效果類似於通過Object.create(null)創建的空對象,暫時不知道這麼設計的原因

  3. {}其實就是匿名形式的type,因此支持通過&|操作符對類型聲明進行擴展(即交叉類型和聯合類型)

// 賦值給數字不會報錯
let one: Object = 1
// 也賦值給數組,但無法使用數組的push方法
let arr: Object = []
// 會報錯
arr.push(1)

// 賦值會報錯
let two: object = 2

// object作為類型聲明時,賦值給對象時不會報錯
let obj1: object = {}
let obj2: object = { name: '王五' } 
let Obj3: Object = {}

// 會報錯
obj1.name = '張三'
obj1.toString()
obj2.name

// 不會報錯
Obj3.name = '李四'
Obj3.toString()

// {} 等同於匿名形式的type
type UserType = { name: string; }

let user: UserType = { name: '李四' }
let data: { name: string; } = { name: '張三' }

交叉類型和聯合類型

上文提到,Typescript支持通過&|操作符對類型聲明進行擴展,用&相連的多個類型是交叉類型,用|相連的多個類型是聯合類型。

兩者之間的區別主要體現在聯合類型主要在做類型的合併,如Form4TypeForm6Type;而交叉類型則是求同排斥,如Form3TypeForm5Type。也可以用數學上的合集和並集來分別理解聯合類型和交叉類型。


type Form1Type = { name: string; } & { gender: number; }
// 等於 type Form1Type = { name: string; gender: number; }
type Form2Type = { name: string; } | { gender: number; }
// 等於 type Form2Type = { name?: string; gender?: number; }

let form1: Form1Type = { name: '王五' } // 提示缺少gender參數
let form2: Form2Type = { name: '劉六' } // 驗證通過


type Form3Type = { name: string; } & { name?: string; gender: number; }
// 等於 type Form3Type = { name: string; gender: number; }
type Form4Type = { name: string; } | { name?: string; gender: number; }
// 等於 type Form4Type = { name?: string; gender: number; }

let form3: Form3Type = { gender: 1 } // 提示缺少name參數
let form4: Form4Type = { gender: 1 } // 驗證通過


type Form5Type = { name: string; } & { name?: number; gender: number; }
// 等於 type Form5Type = { name: never; gender: number; }
type Form6Type = { name: string; } | { name?: number; gender: number; }
// 等於 type Form6Type = { name?: string | number; gender: number; }

let form5: Form5Type = { name: '張三', gender: 1 } // 提示name的類型為never,不能進行賦值
let form6: Form6Type = { name: '張三', gender: 1 } // 驗證通過

上述的代碼片段一般只會在面試題裏面出現,如果這種代碼出現在真實的項目代碼裏面,估計在代碼評審的時候就直接被點名批評了。

不過也不是沒有實用場景,以蘋果的教育優惠舉個例子:假設原價購買蘋果12需要5000元;如果通過教育優惠購買則可以享受一定折扣的優惠(比如打8折),但是需要提供學生證或者是教師證。經過產品經理的整理,轉變為需求文檔之後可能就變成了:原價購買無需其他材料,如需享受教育優惠,則需要提交個人資料以及學生證/教師證掃描件。

// 原價購買
type StandardPricing = {
  mode: 'standard';
}
// 教育優惠購買需要提供購買人姓名和相關證件
type EducationPricing = {
  mode: 'education';
  buyer_name: string;
  sic_or_tic: string;
}
// 通過&和|合併類型
type buyiPhone12 = { price: number; } & ( StandardPricing | EducationPricing )


let standard: buyiPhone12 = { mode: 'standard', price: 5000 }
let education: buyiPhone12 = { mode: 'education', price: 4000, buyer_name: '張三', sic_or_tic: '證件' }

Type和Interface

在一開始學習Typescript的時候看到interface,我第一時間想到的是JavaJavainterface是一種抽象類,把功能的定義和具體的實現進行分離,方便不同人員可以通過interface進行相互配合,類似於需求文檔在開發中的作用。

// 張三定義了用戶中心的功能有三個:登錄、註冊、找回密碼
interface UserCenterDao {
  void userLogin();
  void userRegister();
  void userResetPassword();
}

// 李四開發用戶中心的功能就會提示需要實現三個功能
class UserCenter implements UserCenterDao {
  public void userLogin() {};
  public void userRegister() {};
  public void userResetPassword() {};
}

Typescript對於interface的定義也是類似,都是聲明一系列的抽象變量/方法,然後通過具體的代碼去實現。

interface整體的效果與用type聲明的效果非常相似,即使是專屬於interface的繼承extendstype也可以通過&|操作符實現,兩者之間也不是獨立的,也可以互相進行調用。

因此在平時的實際開發中,不必太過糾結使用type還是interface進行類型的聲明,特別糾結的時候type一把梭。

// 用interface定義一個學生的基礎屬性為姓名、性別、學校、年級、班級
interface Student {
  name: string;
  gender: '男' | '女';
  school: string;
  grade: string | number;
  class: number;
}

// 用interface繼承學生的基礎屬性
// 並追加定義三好學生的標準為遵守校規、樂於助人,班級前三
interface MeritStudent extends Student {
  toeTheLine: boolean;
  helpingOther: boolean;
  topThreeInClass: boolean;
}

// 可以通過type將interface聲明的類型聲明到新聲明上
type StudentType = Student

// interface雖然不能直接使用type聲明的類型,但是可以通過繼承間接使用
interface CollageStudent extends StudentType {}

// 然後聲明相對應的邏輯去實現
let xiaoming: Student = {
  name: '小明',
  gender: '男',
  school: '清華幼兒園',
  grade: '大大班',
  class: 1
}

let xiaowang: MeritStudent = {
  name: '小王',
  gender: '男',
  school: '清華幼兒園',
  grade: '大大班',
  class: 1,
  toeTheLine: true,
  helpingOther: true,
  topThreeInClass: true
}

let xiaohong: StudentType = {
  name: '小紅',
  gender: '女',
  school: '朝陽小學',
  grade: 1,
  class: 1
}

說起typeinterface,有一道非常經典的Typescript面試題:typeinterface的區別在哪裡?

先說個人感受。我個人感覺typeinterface的區別主要是在語義上,type在官方文檔的定義是類型別名,而interface的定義是接口。

下面的代碼可以非常明顯體現其兩者在語義上的區別,其實兩者在語法方面的區別並不算大。

// type可以給類型定義別名
type StudentName = string

// interface可以像Java定義一個學生的抽象類
interface StudentInterface {
  addRecord: (subject: string, score: number, term: string) => void
}

// 等同於let name: string = '張三'
let name: StudentName = '張三'


// 構造函數CollageStudent獲得抽象類StudentInterface的聲明
class CollageStudent implements StudentInterface {

  public record = []

  addRecord(subject, score, term) {
    this.record.push({ subject, score, term })
  }
}

// type其實也定義類似的類型聲明結構,但是從語義上來說並不是抽象類
type TeacherType = {
  subject: Array<string>
}
// 構造函數也可以獲得type聲明的類型,語法上是可以實現的
// 但是從語義和規範的層面上來說不推薦這麼寫
class CollageTeacher implements TeacherType {

  subject: ['數學', '體育']
}

至於標準答案,官方文檔(點擊此處)中給出了兩者在語法上的具體區別。

type和interface的區別

泛型

什麼是泛型?簡單來說,泛型就是類型聲明裡的變量。舉個不相關但是很好理解的例子:

Javascript在執行let num = 1這段代碼的時候,Javascript的編譯器會從右向左執行代碼。代碼執行之前,編譯器並不知道變量num的數據類型是什麼,執行完之後編譯器便知道了變量num的數據類型為Number

這也正好是泛型的核心:編譯之前不知道是什麼類型,編譯之後就知道了

// 泛型的書寫形式是<T>,可以通過<T = ?>為泛型附默認值
// 函數表達式的寫法
function typeOf<T>(arg: T): string {
    return Object.prototype.toString.call(arg).replace(/\[object (\w+)\]/, '$1').toLowerCase()
}

// 等同於typeOf<string>('Hello World')
typeOf('Hello World')
// 等同於typeOf<number>(123456)
typeOf(123456)

// 函數聲明式的寫法
const size = <T>(args: Array<T>): number => args.length

// 等同於size<number>([ 1, 2, 3 ])
size([ 1, 2, 3 ])

上述代碼雖然比較簡單,但是足以看出泛型的靈活性,這能讓組件的復用性更高,不過可能還是不好理解泛型在實際項目中的用處。

下面是我在現實的項目工程中使用的代碼片段,代碼有點長但是邏輯不複雜。代碼主要是用於請求後端接口的hooks,定義了兩個泛型:RequestConfigAxiosResponse,分別用於定義請求參數和返回參數的結構,代碼中還運用了泛型嵌套Promise<AxiosResponse<T>>,方便對多層結構的復用。

import axios, { AxiosRequestConfig } from 'axios'

// 請求參數的結構
interface RequestConfig<P> {
  url: string;
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  data: P;
}

// 返回參數的結構
interface AxiosResponse<T> {
  code: number;
  message?: string;
  data: T;
}

const $axios = axios.create({ baseURL: '//demo.com' })

// 聲明了兩個泛型類型T和P
// T - 返回參數的泛型,默認值為void,在無返回參數的時候不需要傳類型聲明
// P - 請求參數的泛型,默認值為void,在無請求參數的時候不需要傳類型聲明
// 泛型支持嵌套,如Promise<AxiosResponse<T>>即表示AxiosResponse<T>的返回值在Promise中
const useRequest = async <T = void, P = void>(requestConfig: RequestConfig<P>): Promise<AxiosResponse<T>> => {
  
  const axiosConfig: AxiosRequestConfig = {

    url: requestConfig.url,
    data: requestConfig.data || {},
    method: requestConfig.method || 'GET'
  }

  try {
    // data中是預想中的返回參數
    const { data: response } = await $axios(axiosConfig)
    
    // 錯誤響應
    if( response.code !== 200 ) {

      return Promise.reject(response)
    }

    return Promise.resolve(response)
  } catch(e) {
    // 錯誤響應
    return Promise.reject(e)
  }
}

(async () => {

  interface RequestInterface {
    date: string;
  }
  interface ResponseInterface {
    weather: number;
  }

  // 無參數時使用,無需約束泛型
  await useRequest({ url: 'api/connect' })
  // 有參數時使用,通過泛型約束提升代碼質量
  const { weather } = await useRequest<RequestInterface, ResponseInterface>({
    url: 'api/weather',
    data: { date: '2021-02-31' }
  })
})()

另外,Typescript允許類型聲明調用自己,可以通過這個特性去實現類似於樹形結構的需求,比較常見的就是管理系統的導航菜單了。

// Typescript支持遞歸調用自身
type TreeType = {
  label: string;
  value: string | number;
  children?: Array<TreeType>
}
// 因此可以藉助這個特性實現樹形結構
let tree: Array<TreeType> = [
  
  { label: '首頁', value: 1, children: [
    { label: '儀錶盤', value: '1-1' },
    { label: '工作台', value: '1-2' },
  ] },
  { label: '進度管理', value: 2, children: [
    { label: '進度設置', value: '2-1' },
    { label: '操作記錄', value: '2-2' },
  ] },
]