浅谈TypeScript对业务可维护性的影响

前言

笔者认为, TypeScript是服务于业务的, 核心就是提高代码的可维护性. TypeScript是把双刃剑, 如果类型系统使用的不好, 反而会阻碍开发, 甚至最后就变成了anyScript. 笔者最近在使用TypeScript的过程中, 有了一点点微不足道的思考, 想和大家分享、探讨.
本文比较适合有真实TypeScript使用经验的同学阅读, 对于没有太多经验的同学可能不太容易get到问题点

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

我们知道, 业务越清晰, 那么我们一开始的设计就越完善. 但是业务是不可能一次性给出的, 一定是随着时间的推移、市场的变化、用户的反馈而不停地变化. 这就要求我们有能力去设计一套支持业务快速变化的体系. 我们来看一个真实的业务迭代场景. 这是一个数据可视化平台(已简化业务), 假设这是第一期任务. 我们需要实现下图中的功能. 左边有一排字段, 通过拖拽的方式加入到右上方的维度、指标当中.
至于报表是如何生成, 这不是我们今天要讨论的内容. 我们要讨论的是类型定义, 不是可视化技术. 注意力聚焦在字段上即可.
image-20221217150327061
请大家思考一个问题, 字段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[]): {
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[]): {
return req.put('/chart/setting')
}
到这里大家思考下, getFieldListsaveChart对各自字段的定义, 目前是引用了同一个数据结构. 所以此刻字段A===字段B, 就没有区分二者, 统一用Field. 这波操作有什么问题吗? 好像没有, 至少代码能跑, 没出现啥问题.

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

第二期任务来了, 产品经理认为单纯的添加字段, 这个功能过于薄弱. 需要对字段进行编辑, 如下图所示.
image-20221217153339874
这个需求合理吧? 非常合理. 从接口定义上来说, 我们的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[]): {
return req.put('/chart/setting')
}
看上去一片祥和, 站在业务的角度去审视字段A和字段B的类型, 感觉大家都有美好的未来.

糟糕! 业务巨变时类型系统极限翻盘

很快, 第三期任务来了. 产品经理认为单纯从数据库拿字段还是不够给力. 这次是要新增公式字段, 让用户自由组合已有字段从而产生新的字段. 大概长下面这样.
image-20221217165518810
image-20221217164511675
点击保存后即可出现在左侧, 也就是原先的字段A那边
image-20221217164835880
这个新增业务依旧非常的合理. 我们来思考下这个业务对类型系统带来的挑战. 其实这里的弹窗通常是考虑做成一个通用组件, 和这边的业务解耦, 因此不需要多考虑. 但是弹窗结束后, 会生成新的字段. 新字段的名字, 完全可以存储在之前的name属性中. 公式值呢? 貌似之前没有考虑过. 因此, 我们肯定要在某个类型中加入formula字段. 关于接口, 和后端讨论了下. 笔者: “后端怎么把新创建的公式字段给我?” 后端: “通过getFieldList吧, 本来这个接口就是用来拿到左侧字段列表的” 笔者: “欧克欧克. 那前端怎么保存新创建的公式字段呢?” 后端: “通过saveChartConfig吧, 之前是维度+指标, 现在把公式也放进来吧” 笔者: “那指标字段如果使用的是公式字段, 指标字段的值需要包含公式值吗?” 后端: “不用, 指标字段依然还是那几个属性. 关于公式值, 在保存接口中你已经把公式字段列表传过来了, 我会通过id查找的”
image-20221217173343903
所以, 现在最大的区别是字段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[]): {
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';
}

时间不够

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

总结

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

感谢您的来访,获取更多精彩文章请收藏本站。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容