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

2018-06-30 12:16:23 +08:00
 banxi1988

虽然 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 上时,可以看到如下类型提示:

所以在开发开始时,可以先直接用 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);

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

原文链接

2500 次点击
所在节点    前端开发
5 条回复
nl101531
2018-06-30 17:24:11 +08:00
看了一些代码,现在貌似都这样写,但是有个问题,在服务端有 domain 层概念,我想在这个实体类中加属于他的方法该怎么做? ts 的 class 加了这个会出问题,interface 又不能加,有什么好的解决办法吗?
banxi1988
2018-06-30 20:06:32 +08:00
@nl101531 #1
> ts 的 class 加了这个会出问题.

这个会出什么问题?可否举个例子?

你把你的需求和问题详细说明一下,看一下我是否可以帮到你。
nl101531
2018-07-01 08:38:18 +08:00
@banxi1988

比如有下面一个类,里面定义了一个 getFilePath()方法。

export class FileItem {

fileName: string;

md5Name: string;

frontRoute: string;

passwd: string;

fileType: string;

fileContent: string;


getFilePath(): string {
switch (this.fileType) {
case 'md':
return `${this.frontRoute}/${this.md5Name}/${this.passwd}/`;
}
}

}

我用的是 Angular,有时候使用对象调用这个 item.getFilePath(),会得到 getFilePath is not funcition 这个错误,不知道为什么,还是说不支持这种写法。
banxi1988
2018-07-01 15:07:44 +08:00
@nl101531 #3 这种写法肯定是支持的。 具体到 Angular,出现错误的时候,你将 item 输出调试看看。
看这个对象是不是 FileItem 的对象. 有一种可能是 FileItem 对象被序列化之后,比如

```js
const fileItem = new FileItem(略);
const jsonStr = JSON.stringify(fileItem);
const fileItem2 = JSON.parse(jsonStr); // 此时 fileItem2 反序列化之后 `getFilePath` 是不会再存在的。
或者.

```
nl101531
2018-07-01 16:44:15 +08:00
@banxi1988 我先在怀疑 Angular 在组件信息传输过程中可能使用了类似序列化方式吧,导致子组件接收到的时候这个属性丢失了。

我现在都写成 static 方法,来避免这种意外情况。。。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/467025

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX