淺談TypeScript對業務可維護性的影響

前夕Sama發表於2024-04-03

前言

筆者認為, TypeScript是服務於業務的, 核心就是提高程式碼的可維護性. TypeScript是把雙刃劍, 如果型別系統使用的不好, 反而會阻礙開發, 甚至最後就變成了anyScript. 筆者最近在使用TypeScript的過程中, 有了一點點微不足道的思考, 想和大家分享、探討.

本文比較適合有真實TypeScript使用經驗的同學閱讀, 對於沒有太多經驗的同學可能不太容易get到問題點

輕鬆! 業務初始時型別系統輕鬆應對

我們知道, 業務越清晰, 那麼我們一開始的設計就越完善. 但是業務是不可能一次性給出的, 一定是隨著時間的推移、市場的變化、使用者的反饋而不停地變化. 這就要求我們有能力去設計一套支援業務快速變化的體系. 我們來看一個真實的業務迭代場景.

這是一個資料視覺化平臺(已簡化業務), 假設這是第一期任務. 我們需要實現下圖中的功能. 左邊有一排欄位, 透過拖拽的方式加入到右上方的維度、指標當中.

至於報表是如何生成, 這不是我們今天要討論的內容. 我們要討論的是型別定義, 不是視覺化技術. 注意力聚焦在欄位上即可.

image-20221217150327061

請大家思考一個問題, 欄位A和欄位B的型別定義該如何設計? 為了回答這個問題, 我們需要整理一下思路.

  • 欄位A和欄位B, 在一開始時, 肯定是後端給我們的. 欄位A是資料庫的欄位, 前端無法更改. 而欄位B則是使用者透過前端進行設定的.
  • 儲存時, 我們需要把欄位B的設定情況告知後端. 欄位A則不用管, 因為欄位A本身來自於資料庫, 而非前端設定.

依據這個互動表現, 我們不難想到如下的介面請求.

// 獲取欄位A列表, 返回值是個陣列, 型別先不寫, 後文討論
export function getFieldList(): any[] {
  // 理論上應該有個獲取依據, 比如是根據報表id獲取 or 根據資料來源id獲取等, 這不在討論範圍內所以不深究.
  return req.get('/chart/fieldList');
}

// 獲取使用者所儲存的維度、指標
export function getChartConfig(): {dimensionList: any[], metricList: any[]} {
  return req.get('/chart/setting');
}

// 儲存使用者所設定的維度、指標
export function saveChartConfig(dimensionList: any[], metricList: any[]): void {
  return req.put('/chart/setting');
}

依據欄位A的表現, 前後端協商確定了欄位A的資料結構.

export interface Field {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
}

那麼欄位B呢? 經過和後端的溝通, 後端說傳遞和欄位A一樣的資料結構. 於是我們可以完善一開始的請求介面型別.

// 和一開始的區別只是欄位型別的補充

export function getFieldList(): Field[] {
  return req.get('/chart/fieldList');
}

export function getChartConfig(): {dimensionList: Field[], metricList: Field[]} {
  return req.get('/chart/setting');
}

export function saveChartConfig(dimensionList: Field[], metricList: Field[]): void {
  return req.put('/chart/setting');
}

到這裡大家思考下, getFieldListsaveChart對各自欄位的定義, 目前是引用了同一個資料結構. 所以此刻欄位A===欄位B, 就沒有區分二者, 統一用Field. 這波操作有什麼問題嗎? 好像沒有, 至少程式碼能跑, 沒出現啥問題.

好險! 業務微變時型別系統勉強化解

第二期任務來了, 產品經理認為單純的新增欄位, 這個功能過於薄弱. 需要對欄位進行編輯, 如下圖所示.

image-20221217153339874

這個需求合理吧? 非常合理. 從介面定義上來說, 我們的saveChart所要儲存的欄位就不能只是idnametype了. 所以我們很自然地對Field資料結構做出瞭如下修改.

export interface Field {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
  // 補充新的型別, 不一一舉例了, 就以'顯示格式'配置為例吧
  format: 'default' | 'thousands' | 'percent';
}

那麼問題來了. getFieldList介面會返回format欄位嗎? 肯定不會, 前文強調了欄位A是來自於資料庫的. 那麼就麻煩了, 如果按現在的介面定義, 獲取到欄位A時, 型別是可以讀取到format的, 實際上是不存在的. 為了解決這個問題, 很多TypeScript初學者, 很容易出現新增可選的方式來解決這個問題.

export interface Field {
  // 省略id、name、type
  format?: 'default' | 'thousands' | 'percent';
}

按這個節奏下去, 很容易導致Field型別最終用在X個地方, 擁有Y個屬性, 且大部分都是可選. 無法判斷在哪個地方擁有哪個屬性. 那麼我們該怎麼做呢? 思考一下, format屬性是欄位B獨有的, 而欄位A是沒有的. 此時使用繼承是更合適的方案.

export interface FieldA {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
}

export interface FieldB extends FieldA {
  format: 'default' | 'thousands' | 'percent';
}

起名叫FieldA、B是因為前文已經這麼稱呼了, 方便大家理解. 在實際業務中不可使用無語義的命名.

同時, 修改我們的介面請求.

export function getFieldList(): FieldA[] {
  return req.get('/chart/fieldList');
}

export function getChartConfig(): {dimensionList: FieldB[], metricList: FieldB[]} {
  return req.get('/chart/setting');
}

export function saveChartConfig(dimensionList: FieldB[], metricList: FieldB[]): void {
  return req.put('/chart/setting');
}

看上去一片祥和, 站在業務的角度去審視欄位A和欄位B的型別, 感覺大家都有美好的未來.

糟糕! 業務鉅變時型別系統極限抗壓

很快, 第三期任務來了. 產品經理認為單純從資料庫拿欄位還是不夠給力. 這次是要新增公式欄位, 讓使用者自由組合已有欄位從而產生新的欄位. 大概長下面這樣.

image-20221217165518810 image-20221217164511675

點選儲存後即可出現在左側, 也就是原先的欄位A那邊

image-20221217164835880

這個新增業務依舊非常的合理. 我們來思考下這個業務對型別系統帶來的挑戰. 其實這裡的彈窗通常是考慮做成一個通用元件, 和這邊的業務解耦, 因此不需要多考慮. 但是彈窗結束後, 會生成新的欄位. 新欄位的名字, 完全可以儲存在之前的name屬性中. 公式值呢? 貌似之前沒有考慮過. 因此, 我們肯定要在某個型別中加入formula欄位. 關於介面, 和後端討論了下.

筆者: "後端怎麼把新建立的公式欄位給我?"

後端: "透過getFieldList吧, 本來這個介面就是用來拿到左側欄位列表的"

筆者: "歐克歐克. 那前端怎麼儲存新建立的公式欄位呢?"

後端: "透過saveChartConfig吧, 之前是維度+指標, 現在把公式也放進來吧"

筆者: "那指標欄位如果使用的是公式欄位, 指標欄位的值需要包含公式值嗎?"

後端: "不用, 指標欄位依然還是那幾個屬性. 關於公式值, 在儲存介面中你已經把公式欄位列表傳過來了, 我會透過id查詢的"

image-20221217173343903

所以, 現在最大的區別是欄位A有formula, 而欄位B有format. 我們先回顧下在第二期任務中是怎麼做型別定義的.

export interface FieldA {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
}

export interface FieldB extends FieldA {
  format: 'default' | 'thousands' | 'percent';
}

// 請求
export function getFieldList(): FieldA[] {
  return req.get('/chart/fieldList');
}

export function getChartConfig(): {dimensionList: FieldB[], metricList: FieldB[]} {
  return req.get('/chart/setting');
}

export function saveChartConfig(dimensionList: FieldB[], metricList: FieldB[]): void {
  return req.put('/chart/setting');
}

不得不嘆息一口氣. 現在的型別系統肯定是完全無法滿足業務了. 都不知道該咋下手了. 萬事開頭難, 先挑個軟柿子先. 根據後端的說法, FieldA部分會返回公式欄位, 那麼FieldA一定有公式屬性. 因此我們嘗試做出如下修改.

export interface FieldA {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
  formula: string;
}

但是此刻就會發現, FieldB因為繼承了FieldA, 那也就有了formula屬性. 但實際上根據後端的說法, FieldB是不需要傳這個屬性的. 怎麼辦呢? 一個解決方案是利用內建型別Omit.

export interface FieldA {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
  formula: string;
}

export interface FieldB extends Omit<FieldA, 'formula'> {
  format: string;
}

從一而終 or 半路翻車

此時型別系統其實已經開始變得有那麼一點點複雜了. 但好在這3期業務變化以來, 都hold住了. 以上業務, 其實是根據筆者所接觸的真實業務簡化的. 在實際的案例中, 筆者選擇了下面這個方案.

export interface FieldA {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
  formula?: string;
}

export interface FieldB extends FieldA {
  format: string;
}

沒錯, 最後這次筆者選擇了可選鏈. 可能有同學會問了, 那FieldB不就可以讀到formula了嗎? 實際業務不是沒有這個屬性嗎? 是的, 非常正確. 但筆者還是選擇了可選鏈.

因為以上業務都是簡化的, 實際業務複雜的多. 在實際業務中, 因為開發者不是筆者一個人, 多人開發導致FieldA被用了在N個地方. 的確, 對於FieldB來說, 使用Omit就解決了. 但是其他地方呢? 繼承來繼承去的. 筆者在加入formula後, 導致幾十個地方報紅了. 那些地方其實都是用不到這個屬性的. 但是他們的型別定義就是直接取的FieldA. 如果要解決這個問題, 就要梳理所有和FieldA相關的地方. 時間成本還是很大的. 換句話說, 當出現這個問題時, 說明型別系統已經被破壞了.

究竟是什麼導致的型別系統屎山?

於是, 筆者最近一直在思考. 一開始好好的TypeScript型別定義, 為什麼到最後稍微改一點型別, 就會全盤崩潰呢? 當然, 不排除有一種情況是正常崩潰. 也就是說+的這個屬性的確是很多地方都要+, 所以很多地方報紅了. 這是TypeScript起著正面作用呢, 需要我們對引數進行修改. 這也是重構的必要保障.

但是確實也遇到一丟丟的修改導致很多地方報錯, 但是實際上是不影響業務執行的. 到底為什麼會演變成今天的局面呢? 我認為有以下幾個原因

菜是原罪

根據我面試的感受來說, 用過TypeScript的候選人中, 絕大部分都是知道extends的, 但是用過OmitPick等內建型別的, 卻寥寥無幾. 能夠手動推導簡單型別的人更是屈指可數. 毫不誇張地講, 除了知道interface是幹嘛的, 別的都不太知道了. 可見, 儘管TypeScript非常流行, 但大部分人都只是掌握了一點皮毛. 比如前文中我是透過Omit來解決不完全繼承的問題. 還有keyofextends遍歷等也是必須要掌握的東西. 但是如果不知道這些知識點, 就會步履維艱.

沒有業務思考

型別系統是業務的體現. 很多人開發的時候, 過於聚焦功能而沒有思考業務. 舉個例子, 有下面這樣的資料結構

export interface Student {
  id: string;
  name: string;
}

export interface Teacher {
  id: string;
  name: string;
  // 月薪
  salary: string;
}

可能有同學看到這樣的結構以後, 會想"這程式碼寫的不行吧, 這idname不是重複的嗎? 簡單! 看我秀一波最佳化!"

export interface Student {
  id: string;
  name: string;
}

export interface Teacher extends Student {
  // 月薪
  salary: string;
}

於是看起來好像透過extends減少了整整兩行程式碼! 然後下一次業務發生了變化, Student需要新增score來表示學生分數. 這時候就麻煩了, 雖然可以透過Omit來解決這個問題. 但是其實已經在亡羊補牢了. 從業務上看, Teacher extends Student這樣的關係本身就是不存在的. 萬萬不可將TypeScript玩成消消樂.

經驗不足

其實前文中的資料視覺化的專案中, 在真實業務中型別系統整體上還是很可以的. 只有極個別地方確實存在設計不合理的情況. 如果現在重新讓我設計, 對於多個地方可能要用到相同、類似的資料結構時, 我會選擇這麼做.

interface BasicField {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
}

export interface FieldA extends BasicField {}

export interface FieldB extends BasicField {}

抽象出公共型別, 而不直接使用原始型別. 這樣在業務變化後, 更方便擴充套件.

interface BasicField {
  id: string;
  name: string;
  type: 'string' | 'date' | 'number';
}

export interface FieldA extends BasicField {
  formula: string;
}

export interface FieldB extends BasicField {
  format: 'default' | 'thousands' | 'percent';
}

但是這並不是一個一勞永逸的解決方案. 因為在未來有可能出現FieldC的場景, 這個欄位有以下屬性

interface FieldC {
  id: string;
  name: string;
}

如果此時採用繼承BasicField的策略, 則會多了一個type屬性. 那麼問題來了, 又要用到Omit了嗎? 我們一定要注意, 型別是業務的體現, 因此應該看業務需要. 如果type屬性的確在絕大部分欄位中都是存在的, 那麼Omit是合理的. 如果只有極個別欄位中存在type, 那麼應該把type下沉到具體的型別中去.

時間不夠

坦白說, 型別系統的建立其實蠻花時間的. 筆者曾經為了一個型別推導, 花了整整2天時間. 但其實如果any一下, 我只需要幾秒鐘. 這個就因人而異了, 如果公司的業務不允許你使用那麼多時間, 那也沒辦法. 但是就我個人來說, 我會盡量爭取為型別系統完善的時間. 從長遠看, 還是值得的. 比如之前花了2天時間去搞的型別系統, 在之後的無數次迭代中都起到了非常強大的型別支撐. 如果沒有這個型別支撐, 前面花的時間少了, 但是後面花的時間更多了, 而且犯錯的可能性也大大增加.

總結

今天和大家分享了我對於TypeScript在業務中的思考. 透過一個簡化的真實業務帶著大家修改型別系統以適應業務變化. 並給出自己認為的幾個可能導致型別屎山出現的原因. 每個人都有自己的侷限性, 筆者也不例外. 文中也許有部分觀點並不具備普適性, 歡迎交流與討論.


我是前夕, 專注於前端和成長, 希望我的內容可以幫助到你. 公眾號: 前夕小課堂

image-20240403101717261

本文禁止轉載!

相關文章