TypeScript 資料模型層程式設計的最佳實踐

banxi發表於2018-06-30

雖然 TypeScript 主要用於客戶端,而資料模型的設計主要是服務端來做的。 但是要寫出優雅的程式碼,也還是有不少講究的。

讓我們從一個簡單的我的文章列表 api 返回的資料開始,返回的文章列表的資訊如下:

 {
    "id": 2018,
    "title" : "TypeScript 資料模型層的程式設計最佳實踐",
    "created" : 1530321232,
    "last_modified" : 1530320620,
    "status": 1
}
複製程式碼

同時服務端告訴我們說:

status 各值的意思 0/未釋出, 1/已釋出, 2/已撤回

最佳實踐一: 善用列舉,No Magic constant

對於 status 這種可列舉的值,為了避免寫出 status === 1 這種跟一個魔法常量的比較的程式碼,最佳的做法是寫一個列舉,並配套一個格式化為字串表示的函式,如下:

/**
 * 文章狀態
 */
const enum PostStatus {
  /**
   * 草稿
   */
  draft = 0,
  /**
   * 已釋出
   */
  published = 1,

  /**
   * 已撤回
   */
  revoked = 2
}

function formatPostStatus(status: PostStatus) {
  switch (status) {
    case PostStatus.draft:
      return "草稿";
    case PostStatus.published:
      return "已釋出";
    case PostStatus.revoked:
      return "已撤回";
  }
}

複製程式碼

如果 PostStatus 狀態比較多的話,根據喜好可以寫成下面的這樣。

function formatPostStatus(status: PostStatus) {
  const statusTextMap = {
    [PostStatus.draft]: "草稿",
    [PostStatus.published]: "已釋出",
    [PostStatus.revoked]: "已撤回"
  };
  return statusTextMap[status];
}

複製程式碼

考慮到返回的 created 是時間戳值,我們還需要新增一個格式化時間戳的函式:


const enum TimestampFormatterStyle {
  date,
  time,
  datetime
}

function formatTimestamp(
  timestamp: number,
  style: TimestampFormatterStyle = TimestampFormatterStyle.date
): string {
  const millis = timestamp * 1000;
  const date = new Date(millis);
  switch (style) {
    case TimestampFormatterStyle.date:
      return date.toLocaleDateString();
    case TimestampFormatterStyle.time:
      return date.toLocaleTimeString();
    case TimestampFormatterStyle.datetime:
      return date.toLocaleString();
  }
}
複製程式碼

最佳實踐二:如非必要,不要使用類

上來就搞個資料類

一開始的時候,由於之前的程式設計經驗的影響,我一上來就搞一個資料類。如下:

class Post {
  id: number;
  title: string;
  created: number;
  last_modified: number;
  status: number;

  constructor(
    id: number,
    title: string,
    created: number,
    last_modified: number,
    status: number
  ) {
    this.id = id;
    this.title = title;
    this.created = created;
    this.last_modified = last_modified;
    this.status = status;
  }
}
複製程式碼

這可謂分分鐘就寫了 20 行程式碼。 然後如果你想到了 TS 提供了簡寫的方式的話,可以將上面的程式碼簡寫如下。

class Post {
  constructor(
    readonly id: number,
    readonly title: string,
    readonly created: number,
    readonly last_modified: number,
    readonly status: number
  ) {}
}
複製程式碼

也就是說在建構函式中的引數前面新增如 readonly,public,private 等可見性修飾符的話,即可自動建立對應欄位。 因為我們是資料模型,所以我們選擇使用 readonly

一般再在 Post 新增幾個 Getter ,用於返回格式化好的要顯示的屬性值。 如下:

class Post{
 // 建構函式同上
 
 get createdDateString(): string {
    return formatTimestamp(this.created, TimestampFormatterStyle.date);
  }
  
  get lastModifiedDateString(): string {
    return formatTimestamp(this.last_modified, TimestampFormatterStyle.date);
  }

  get statusText(): string {
    return formatPostStatus(this.status);
  }
}
複製程式碼

麻煩的開始

好了現在資料類寫好,準備請求資料,繫結資料了。 一開始我們寫出如下程式碼:

const posts:Post[] = resp.data
複製程式碼

然後 TS 報如下錯誤:

[ts]
Type '{ id: number; title: string; created: number; last_modifistatic fromJson(json: JsonObject): Post {
    return new Post(
      json.id,
      json.title,
      json.created,
      json.last_modified,
      json.status
    );
  }ed: number; status: number; }[]' is not assignable to type 'Post[]'.
  Type '{ id: number; title: string; created: number; last_modified: number; status: number; }' is not assignable to type 'Post'.
    Property 'createdDateString' is missing in type '{ id: number; title: string; created: number; last_modified: number; status: number; }'.
複製程式碼

此時我們開始意識到,請求回來的jsondata 列表是普通的 object 不能直接給 Post 賦值。 由於一些程式設計慣性,我們開始想著,是不是反序列化一下,將json 物件反序列化成 Post. 於是我們在 Post 類中新增如下的反序列化方法。

type JsonObject = { [key: string]: any };
class Post{
   // 其他程式碼同上 
   
  static fromJson(json: JsonObject): Post {
    return new Post(
      json.id,
      json.title,
      json.created,
      json.last_modified,
      json.status
    );
  }
}
複製程式碼

然後在請求結果處理上增加一過 map 用於反序列化的轉換。如下:

const posts: Post[] = resp.data.map(Post.fromJson);
複製程式碼

程式碼寫到這裡,思考一下,原來 json 就是一個原生的 JavaScript 物件了。但是我們又再一步又用來構造出 Post 類。這一步顯得多餘。 另外雖然一般我們的模型程式碼比如 Post 其實可以根據 api 文件自動生成, 但是也還是增加不少程式碼。

開始改進

怎麼改進呢? 既然我們的 json 已經是 JavaScrit 物件了,我們只是缺少型別宣告。 那我們直接加上型別宣告的,而且 TS 中的型別宣告,編譯成 js 程式碼之後會自動清除的,這樣可以減少程式碼量。這對於小程式開發來說還是很有意義的。

自然我們寫出如下程式碼。

interface Post {
  id: number;
  title: string;
  created: number;
  last_modified: number;
  status: number;
}
複製程式碼

此時,為了 UI 模板資料上的繫結。 我們雙增加了一個叫 PostInfo 的介面。然後將程式碼修改如下:

interface PostInfo {
  statusText: string;
  createdDateString: string;
  post: Post;
}

function getPostInfoFromPost(post: Post): PostInfo {
  const statusText = formatPostStatus(post.status);
  const createdDateString = formatTimestamp(post.created);
  return { statusText, createdDateString, post };
}

const postInfos: PostInfo[] = (resp.data as Post[]).map(getPostInfoFromPost);

複製程式碼

其實你已知知道貓的樣子

其實我想說的是,我們上面的程式碼中 Post 介面是多餘的。 直接看程式碼:

const postDemo = {
  id: 2018,
  title: "TypeScript 資料模型層的程式設計最佳實踐",
  created: 1530321232,
  last_modified: 1530320620,
  status: 1
};

type Post = typeof postDemo;
複製程式碼

當把滑鼠放到 Post 上時,可以看到如下型別提示:

Easy Post interface from

所以在開發開始時,可以先直接用 API 返回的資料結構當作一個資料模型例項。然後使用 typeof 來得到對應的型別。

把套去掉

PostInfo 這樣包裝其實挺醜陋的, 因為在我們心裡這裡其實應該是一個 Post 列表,但是為了格式化一些資料顯示,我們弄一個 PostInfo 的包裝,這樣在使用上帶來很多不方便。因為當你要使用 Post 的其他的值時,你總需要多一次間接訪問比如這樣 postInfo.post.id。 這就PostInfo 是我們在使用 Post 例項時的一個枷鎖,一個套, 現在我們來將這個套去掉。而去掉這個套的方法使用了兩項技術。 一個是 TS 中介面的繼承,一個是 Object.assign 這個方法。 直接用程式碼說話:

interface PostEx extends Post {
  statusText: string;
  createdDateString: string;
}

function getPostExFromPost(post: Post): PostEx {
  const statusText = formatPostStatus(post.status);
  const createdDateString = formatTimestamp(post.created);
  return Object.assign(post, { statusText, createdDateString });
}

const posts: PostEx[] = (resp.data as Post[]).map(getPostExFromPost);

複製程式碼

即保證了型別安全,使用上又方便,程式碼也不失優雅。

相關文章