V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
banxi1988
V2EX  ›  前端开发

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

  •  
  •   banxi1988 ·
    banxi1988 · 2018-06-30 12:16:23 +08:00 · 2417 次点击
    这是一个创建于 2134 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

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

    原文链接

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

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

    你把你的需求和问题详细说明一下,看一下我是否可以帮到你。
    nl101531
        3
    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
        4
    banxi1988  
    OP
       2018-07-01 15:07:44 +08:00   ❤️ 1
    @nl101531 #3 这种写法肯定是支持的。 具体到 Angular,出现错误的时候,你将 item 输出调试看看。
    看这个对象是不是 FileItem 的对象. 有一种可能是 FileItem 对象被序列化之后,比如

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

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

    我现在都写成 static 方法,来避免这种意外情况。。。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2265 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 32ms · UTC 07:30 · PVG 15:30 · LAX 00:30 · JFK 03:30
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.