雖然 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; }'.
複製程式碼
此時我們開始意識到,請求回來的json
的 data
列表是普通的 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
上時,可以看到如下型別提示:
所以在開發開始時,可以先直接用 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);
複製程式碼
即保證了型別安全,使用上又方便,程式碼也不失優雅。