前言

笔者认为, TypeScript是服务于业务的, 核心就是提高代码的可维护性. TypeScript是把双刃剑, 如果类型系统使用的不好, 反而会阻碍开发, 甚至最后就变成了anyScript. 笔者最近在使用TypeScript的过程中, 有了一点点微不足道的思考, 想和大家分享、探讨.

本文比较适合有真实TypeScript使用经验的同学阅读, 对于没有太多经验的同学可能不太容易get到问题点

轻松! 业务初始时类型系统轻松应对

我们知道, 业务越清晰, 那么我们一开始的设计就越完善. 但是业务是不可能一次性给出的, 一定是随着时间的推移、市场的变化、用户的反馈而不停地变化. 这就要求我们有能力去设计一套支持业务快速变化的体系. 我们来看一个真实的业务迭代场景.

这是一个数据可视化平台(已简化业务), 假设这是第一期任务. 我们需要实现下图中的功能. 左边有一排字段, 通过拖拽的方式加入到右上方的维度、指标当中.

至于报表是如何生成, 这不是我们今天要讨论的内容. 我们要讨论的是类型定义, 不是可视化技术. 注意力聚焦在字段上即可.

请大家思考一个问题, 字段A和字段B的类型定义该如何设计? 为了回答这个问题, 我们需要整理一下思路.

  • 字段A和字段B, 在一开始时, 肯定是后端给我们的. 字段A是数据库的字段, 前端无法更改. 而字段B则是用户通过前端进行设置的.
  • 保存时, 我们需要把字段B的设置情况告知后端. 字段A则不用管, 因为字段A本身来自于数据库, 而非前端设置.

依据这个交互表现, 我们不难想到如下的接口请求.

// 获取字段A列表, 返回值是个数组, 类型先不写, 后文讨论
export function getFieldList(): any[] {
// 理论上应该有个获取依据, 比如是根据报表id获取 or 根据数据源id获取等, 这不在讨论范围内所以不深究.
return req.get('/chart/fieldList');
} // 获取用户所保存的维度、指标
export function getChartConfig(): {dimensionList: any[], metricList: any[]} {
return req.get('/chart/setting');
} // 保存用户所设置的维度、指标
export function saveChartConfig(dimensionList: any[], metricList: any[]): void {
return req.put('/chart/setting');
}

依据字段A的表现, 前后端协商确定了字段A的数据结构.

export interface Field {
id: string;
name: string;
type: 'string' | 'date' | 'number';
}

那么字段B呢? 经过和后端的沟通, 后端说传递和字段A一样的数据结构. 于是我们可以完善一开始的请求接口类型.

// 和一开始的区别只是字段类型的补充

export function getFieldList(): Field[] {
return req.get('/chart/fieldList');
} export function getChartConfig(): {dimensionList: Field[], metricList: Field[]} {
return req.get('/chart/setting');
} export function saveChartConfig(dimensionList: Field[], metricList: Field[]): void {
return req.put('/chart/setting');
}

到这里大家思考下, getFieldListsaveChart对各自字段的定义, 目前是引用了同一个数据结构. 所以此刻字段A===字段B, 就没有区分二者, 统一用Field. 这波操作有什么问题吗? 好像没有, 至少代码能跑, 没出现啥问题.

好险! 业务微变时类型系统勉强化解

第二期任务来了, 产品经理认为单纯的添加字段, 这个功能过于薄弱. 需要对字段进行编辑, 如下图所示.

这个需求合理吧? 非常合理. 从接口定义上来说, 我们的saveChart所要保存的字段就不能只是idnametype了. 所以我们很自然地对Field数据结构做出了如下修改.

export interface Field {
id: string;
name: string;
type: 'string' | 'date' | 'number';
// 补充新的类型, 不一一举例了, 就以'显示格式'配置为例吧
format: 'default' | 'thousands' | 'percent';
}

那么问题来了. getFieldList接口会返回format字段吗? 肯定不会, 前文强调了字段A是来自于数据库的. 那么就麻烦了, 如果按现在的接口定义, 获取到字段A时, 类型是可以读取到format的, 实际上是不存在的. 为了解决这个问题, 很多TypeScript初学者, 很容易出现添加可选的方式来解决这个问题.

export interface Field {
// 省略id、name、type
format?: 'default' | 'thousands' | 'percent';
}

按这个节奏下去, 很容易导致Field类型最终用在X个地方, 拥有Y个属性, 且大部分都是可选. 无法判断在哪个地方拥有哪个属性. 那么我们该怎么做呢? 思考一下, format属性是字段B独有的, 而字段A是没有的. 此时使用继承是更合适的方案.

export interface FieldA {
id: string;
name: string;
type: 'string' | 'date' | 'number';
} export interface FieldB extends FieldA {
format: 'default' | 'thousands' | 'percent';
}

起名叫FieldA、B是因为前文已经这么称呼了, 方便大家理解. 在实际业务中不可使用无语义的命名.

同时, 修改我们的接口请求.

export function getFieldList(): FieldA[] {
return req.get('/chart/fieldList');
} export function getChartConfig(): {dimensionList: FieldB[], metricList: FieldB[]} {
return req.get('/chart/setting');
} export function saveChartConfig(dimensionList: FieldB[], metricList: FieldB[]): void {
return req.put('/chart/setting');
}

看上去一片祥和, 站在业务的角度去审视字段A和字段B的类型, 感觉大家都有美好的未来.

糟糕! 业务巨变时类型系统极限抗压

很快, 第三期任务来了. 产品经理认为单纯从数据库拿字段还是不够给力. 这次是要新增公式字段, 让用户自由组合已有字段从而产生新的字段. 大概长下面这样.


点击保存后即可出现在左侧, 也就是原先的字段A那边

这个新增业务依旧非常的合理. 我们来思考下这个业务对类型系统带来的挑战. 其实这里的弹窗通常是考虑做成一个通用组件, 和这边的业务解耦, 因此不需要多考虑. 但是弹窗结束后, 会生成新的字段. 新字段的名字, 完全可以存储在之前的name属性中. 公式值呢? 貌似之前没有考虑过. 因此, 我们肯定要在某个类型中加入formula字段. 关于接口, 和后端讨论了下.

笔者: "后端怎么把新创建的公式字段给我?"

后端: "通过getFieldList吧, 本来这个接口就是用来拿到左侧字段列表的"

笔者: "欧克欧克. 那前端怎么保存新创建的公式字段呢?"

后端: "通过saveChartConfig吧, 之前是维度+指标, 现在把公式也放进来吧"

笔者: "那指标字段如果使用的是公式字段, 指标字段的值需要包含公式值吗?"

后端: "不用, 指标字段依然还是那几个属性. 关于公式值, 在保存接口中你已经把公式字段列表传过来了, 我会通过id查找的"

所以, 现在最大的区别是字段A有formula, 而字段B有format. 我们先回顾下在第二期任务中是怎么做类型定义的.

export interface FieldA {
id: string;
name: string;
type: 'string' | 'date' | 'number';
} export interface FieldB extends FieldA {
format: 'default' | 'thousands' | 'percent';
} // 请求
export function getFieldList(): FieldA[] {
return req.get('/chart/fieldList');
} export function getChartConfig(): {dimensionList: FieldB[], metricList: FieldB[]} {
return req.get('/chart/setting');
} export function saveChartConfig(dimensionList: FieldB[], metricList: FieldB[]): void {
return req.put('/chart/setting');
}

不得不叹息一口气. 现在的类型系统肯定是完全无法满足业务了. 都不知道该咋下手了. 万事开头难, 先挑个软柿子先. 根据后端的说法, FieldA部分会返回公式字段, 那么FieldA一定有公式属性. 因此我们尝试做出如下修改.

export interface FieldA {
id: string;
name: string;
type: 'string' | 'date' | 'number';
formula: string;
}

但是此刻就会发现, FieldB因为继承了FieldA, 那也就有了formula属性. 但实际上根据后端的说法, FieldB是不需要传这个属性的. 怎么办呢? 一个解决方案是利用内置类型Omit.

export interface FieldA {
id: string;
name: string;
type: 'string' | 'date' | 'number';
formula: string;
} export interface FieldB extends Omit<FieldA, 'formula'> {
format: string;
}

从一而终 or 半路翻车

此时类型系统其实已经开始变得有那么一点点复杂了. 但好在这3期业务变化以来, 都hold住了. 以上业务, 其实是根据笔者所接触的真实业务简化的. 在实际的案例中, 笔者选择了下面这个方案.

export interface FieldA {
id: string;
name: string;
type: 'string' | 'date' | 'number';
formula?: string;
} export interface FieldB extends FieldA {
format: string;
}

没错, 最后这次笔者选择了可选链. 可能有同学会问了, 那FieldB不就可以读到formula了吗? 实际业务不是没有这个属性吗? 是的, 非常正确. 但笔者还是选择了可选链.

因为以上业务都是简化的, 实际业务复杂的多. 在实际业务中, 因为开发者不是笔者一个人, 多人开发导致FieldA被用了在N个地方. 的确, 对于FieldB来说, 使用Omit就解决了. 但是其他地方呢? 继承来继承去的. 笔者在加入formula后, 导致几十个地方报红了. 那些地方其实都是用不到这个属性的. 但是他们的类型定义就是直接取的FieldA. 如果要解决这个问题, 就要梳理所有和FieldA相关的地方. 时间成本还是很大的. 换句话说, 当出现这个问题时, 说明类型系统已经被破坏了.

究竟是什么导致的类型系统屎山?

于是, 笔者最近一直在思考. 一开始好好的TypeScript类型定义, 为什么到最后稍微改一点类型, 就会全盘崩溃呢? 当然, 不排除有一种情况是正常崩溃. 也就是说+的这个属性的确是很多地方都要+, 所以很多地方报红了. 这是TypeScript起着正面作用呢, 需要我们对参数进行修改. 这也是重构的必要保障.

但是确实也遇到一丢丢的修改导致很多地方报错, 但是实际上是不影响业务运行的. 到底为什么会演变成今天的局面呢? 我认为有以下几个原因

菜是原罪

根据我面试的感受来说, 用过TypeScript的候选人中, 绝大部分都是知道extends的, 但是用过OmitPick等内置类型的, 却寥寥无几. 能够手动推导简单类型的人更是屈指可数. 毫不夸张地讲, 除了知道interface是干嘛的, 别的都不太知道了. 可见, 尽管TypeScript非常流行, 但大部分人都只是掌握了一点皮毛. 比如前文中我是通过Omit来解决不完全继承的问题. 还有keyofextends遍历等也是必须要掌握的东西. 但是如果不知道这些知识点, 就会步履维艰.

没有业务思考

类型系统是业务的体现. 很多人开发的时候, 过于聚焦功能而没有思考业务. 举个例子, 有下面这样的数据结构

export interface Student {
id: string;
name: string;
} export interface Teacher {
id: string;
name: string;
// 月薪
salary: string;
}

可能有同学看到这样的结构以后, 会想"这代码写的不行吧, 这idname不是重复的吗? 简单! 看我秀一波优化!"

export interface Student {
id: string;
name: string;
} export interface Teacher extends Student {
// 月薪
salary: string;
}

于是看起来好像通过extends减少了整整两行代码! 然后下一次业务发生了变化, Student需要添加score来表示学生分数. 这时候就麻烦了, 虽然可以通过Omit来解决这个问题. 但是其实已经在亡羊补牢了. 从业务上看, Teacher extends Student这样的关系本身就是不存在的. 万万不可将TypeScript玩成消消乐.

经验不足

其实前文中的数据可视化的项目中, 在真实业务中类型系统整体上还是很可以的. 只有极个别地方确实存在设计不合理的情况. 如果现在重新让我设计, 对于多个地方可能要用到相同、类似的数据结构时, 我会选择这么做.

interface BasicField {
id: string;
name: string;
type: 'string' | 'date' | 'number';
} export interface FieldA extends BasicField {} export interface FieldB extends BasicField {}

抽象出公共类型, 而不直接使用原始类型. 这样在业务变化后, 更方便扩展.

interface BasicField {
id: string;
name: string;
type: 'string' | 'date' | 'number';
} export interface FieldA extends BasicField {
formula: string;
} export interface FieldB extends BasicField {
format: 'default' | 'thousands' | 'percent';
}

但是这并不是一个一劳永逸的解决方案. 因为在未来有可能出现FieldC的场景, 这个字段有以下属性

interface FieldC {
id: string;
name: string;
}

如果此时采用继承BasicField的策略, 则会多了一个type属性. 那么问题来了, 又要用到Omit了吗? 我们一定要注意, 类型是业务的体现, 因此应该看业务需要. 如果type属性的确在绝大部分字段中都是存在的, 那么Omit是合理的. 如果只有极个别字段中存在type, 那么应该把type下沉到具体的类型中去.

时间不够

坦白说, 类型系统的建立其实蛮花时间的. 笔者曾经为了一个类型推导, 花了整整2天时间. 但其实如果any一下, 我只需要几秒钟. 这个就因人而异了, 如果公司的业务不允许你使用那么多时间, 那也没办法. 但是就我个人来说, 我会尽量争取为类型系统完善的时间. 从长远看, 还是值得的. 比如之前花了2天时间去搞的类型系统, 在之后的无数次迭代中都起到了非常强大的类型支撑. 如果没有这个类型支撑, 前面花的时间少了, 但是后面花的时间更多了, 而且犯错的可能性也大大增加.

总结

今天和大家分享了我对于TypeScript在业务中的思考. 通过一个简化的真实业务带着大家修改类型系统以适应业务变化. 并给出自己认为的几个可能导致类型屎山出现的原因. 每个人都有自己的局限性, 笔者也不例外. 文中也许有部分观点并不具备普适性, 欢迎交流与讨论.


我是前夕, 专注于前端和成长, 希望我的内容可以帮助到你. 公众号: 前夕小课堂

本文禁止转载!

浅谈TypeScript对业务可维护性的影响的更多相关文章

  1. 浅谈TypeScript

    TypeScript为JavaScript的超集(ECMAScript6), 这个语言添加了基于类的面向对象编程.TypeScript作为JavaScript很大的一个语法糖,本质上是类似于css的l ...

  2. 浅谈TypeScript,配置文件以及数据类型

    TypeScript在javaScript基础上多了一些拓展特性,多出来的是一些类型系统以及对ES6新特性的支持最终会编译成原始的javaScript, 文件名以.ts结尾,编译过后.js结尾,在an ...

  3. 浅谈IM软件业务知识——非对称加密,RSA算法,数字签名,公钥,私钥

    概述 首先了解一下相关概念:RSA算法:1977年由Ron Rivest.Adi Shamirh和LenAdleman发明的.RSA就是取自他们三个人的名字. 算法基于一个数论:将两个大素数相乘很ea ...

  4. c#Winform程序调用app.config文件配置数据库连接字符串 SQL Server文章目录 浅谈SQL Server中统计对于查询的影响 有关索引的DMV SQL Server中的执行引擎入门 【译】表变量和临时表的比较 对于表列数据类型选择的一点思考 SQL Server复制入门(一)----复制简介 操作系统中的进程与线程

    c#Winform程序调用app.config文件配置数据库连接字符串 你新建winform项目的时候,会有一个app.config的配置文件,写在里面的<connectionStrings n ...

  5. 浅谈微信小程序对于房地产行业的影响

    前几日,我们曾经整理过一篇文章是关于微信小程序对于在线旅游业的影响的一些反思(浅谈微信小程序对OTA在线旅游市场的影响),近日由于生活工作的需要走访了一些房地产的住宅商品房,突然想到微信小程序对于房地 ...

  6. 【转】.NET(C#):浅谈程序集清单资源和RESX资源 关于单元测试的思考--Asp.Net Core单元测试最佳实践 封装自己的dapper lambda扩展-设计篇 编写自己的dapper lambda扩展-使用篇 正确理解CAP定理 Quartz.NET的使用(附源码) 整理自己的.net工具库 GC的前世与今生 Visual Studio Package 插件开发之自动生

    [转].NET(C#):浅谈程序集清单资源和RESX资源   目录 程序集清单资源 RESX资源文件 使用ResourceReader和ResourceSet解析二进制资源文件 使用ResourceM ...

  7. 浅谈Hybrid技术的设计与实现第三弹——落地篇

    前言 接上文:(阅读本文前,建议阅读前两篇文章先) 浅谈Hybrid技术的设计与实现 浅谈Hybrid技术的设计与实现第二弹 根据之前的介绍,大家对前端与Native的交互应该有一些简单的认识了,很多 ...

  8. 浅谈Hybrid技术的设计与实现第二弹

    前言 浅谈Hybrid技术的设计与实现 浅谈Hybrid技术的设计与实现第二弹 浅谈Hybrid技术的设计与实现第三弹——落地篇 接上文:浅谈Hybrid技术的设计与实现(阅读本文前,建议阅读这个先) ...

  9. 浅谈Hybrid技术的设计与实现

    前言 浅谈Hybrid技术的设计与实现 浅谈Hybrid技术的设计与实现第二弹 浅谈Hybrid技术的设计与实现第三弹——落地篇 随着移动浪潮的兴起,各种APP层出不穷,极速的业务扩展提升了团队对开发 ...

  10. 浅谈Nginx负载均衡和F5的区别

    前言 笔者最近在负责某集团网站时,同时用到了Nginx与F5,如图所示,负载均衡器F5作为处理外界请求的第一道"墙",将请求分发到web服务器后,web服务器上的Nginx再进行处 ...

随机推荐

  1. 一文搞懂Vue的MVVM模式与双向绑定

    v-model 是 Vue.js 框架中用于实现双向数据绑定的指令.它充分体现了 MVVM(Model-View-ViewModel)模式中的双向数据绑定特性.下面我们将详细解释 v-model 如何 ...

  2. weekToDo - 一个本地todo软件 - 软件推荐 先用着试试

    https://weektodo.me/ https://github.com/Zuntek/WeekToDoWeb/releases/download/v1.7.0/WeekToDo-Setup-1 ...

  3. 音乐分层软件 spectralayers7 扒歌 简直就是黑科技

    音乐分层软件 spectralayers7 扒歌 简直就是黑科技

  4. 用python生成正玄波信号源码解析

    一 前记 项目需要生成不同频点的正玄波信号,没找到现成的软件,只能自己写一个了.顺便温习一下python. 二 源码解析: #!/usr/bin/python import numpy as np f ...

  5. Tornadofx学习笔记(3)——使用Maven编译成jar包

    之前我都是使用的IDEA自带的工具来编译jar包 但是增加了新的依赖,又得去修改project structure的依赖,过于麻烦 某天Android开发的时候,想到gradle可以一键打包,是不是m ...

  6. Welcome to YARP - 2.3 配置功能 - 配置过滤器(Configuration Filters)

    目录 Welcome to YARP - 1.认识YARP并搭建反向代理服务 Welcome to YARP - 2.配置功能 2.1 - 配置文件(Configuration Files) 2.2 ...

  7. 3D渲染慢,直接买显卡还是用云渲染更划算?

    3D渲染对建筑师和设计师来说并不陌生,3D渲染的过程中出现渲染卡顿.特殊材质难以渲染,或者本地配置不足.本地渲染资源不够时,常常会影响工作效率.本文比较了3D渲染时,为提高工作效率,买显卡还是用云渲染 ...

  8. 3DCAT荣获2021金陀螺“年度XR行业技术创新奖”“年度优秀VR行业应用奖”两项大奖

    作为年度行业影响力大奖,第六届金陀螺颁奖典礼与2021未来商业生态链接大会(简称"FBEC2021")同期举办.金陀螺奖金陀螺奖旨在对优质作品/项目及优秀企业做出嘉奖,鼓励创业者. ...

  9. Locust如何实现负载测试?

    一.场景要求 我们在使用locust时,有时候默认的场景无法满足我们的要求时,这时后我们需要自定义场景 比如我们要设置每一段时间启动10个用户运行,执行60s后再一次启动10个用户,总共运行10分钟, ...

  10. 基于 alientek rv1126 快速启动调试那的写坑

    基于 alientek rv1126 快速启动调试那的写坑 1. sdk 编制准备工作 1.1 编译配置修改 首先拿到 sdk 通过修改一下相关配置 1.1.1修改DDR 配置 cd /home/al ...