TypeScript 数据模型层编程的最佳实践

4,318 阅读5分钟

虽然 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);

即保证了类型安全,使用上又方便,代码也不失优雅。