前言

笔者认为, 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. 一键Run带你体验扩散模型的魅力

    本文分享自华为云社区<爆圈Sora横空出世,AGI通用人工智能时代真的要来了吗?一键Run带你体验扩散模型的魅力!>,作者: 码上开花_Lancer. Sora这几天的爆炸性新闻,让所有人 ...

  2. 巧用SQL语句中的OR查询完成业务新需求-2022新项目

    一.业务场景 目前参与开发的项目,之前的一个已上线的版本中有一类查询是根据两张表进行LEFT JOIN查询用来取数据, 主表中有一个字段field用来区分不同的数据类型比如说A/B/C.前面的版本中只 ...

  3. 使用 PMML 实现模型融合及优化技巧

    在机器学习的生产环境中,我们经常需要将多个模型的预测结果进行融合,以便提高预测的准确性.这个过程通常涉及到多个模型子分的简单逻辑回归融合.虽然离线训练时我们可以直接使用sklearn的逻辑回归进行训练 ...

  4. SPEAK 510全向麦克风无线蓝牙拾音器产品体验及评测

    产品简介     大家开会的时候,很多人都直接使用手机app了,比如,zoom,腾讯会议等.既方便又快捷.由于手机设备拾音距离有限,也不是针对会议场景做的,所有,在多人会议的时候,问题就出来了.这个时 ...

  5. Rancher 2.5.x 证书过期报错 x509: certificate has expired or is not yet valid 解决方案

    Rancher 的证书过期会出现什么状况?不可以继续通过Rancher UI访问你的集群 查看Rancher Server日志报错:x509: certificate has expired or i ...

  6. 性能优化:编译器优化选项 -O2/-O3 究竟有多强大?

    之前的"性能优化的一般策略及方法"一文中介绍了多种性能优化的方法.根据以往的项目经验,开启编译器优化选项可能是立竿见影.成本最低.效果最好的方式了. 这么说可能还不够直观,举个真实 ...

  7. webpack 项目接入Vite的通用方案介绍(下)

    愿景 希望通过此系列文章,能给读者提供一个存/增量项目接入Vite的点子,起抛砖引玉的作用,减少这方面能力的建设成本 在阐述过程中同时也会逐渐完善webpack-vite-serve这个工具 读者可直 ...

  8. NJUPT自控第一次积分赛的小总结(一)题目感受

    快开学了事情真的好多啊 -_- 忙完积分赛就赶紧要去复习期末了...线代还是依托答辩啥都不懂 先看题目吧,RT,我们队(我)选择的是第一题(仅仅是因为很简单罢啦) 一开始看题目,心想不就调调pid吗, ...

  9. 【福利】JetBrains 全家桶永久免费使用

    Jetbrains系列的IDE公认是最好的集成开发工具,但是收费且挺贵.我们以PhpStorm为例,新用户第一年需要199$,注意是$,还不是人民币,这个价格一上来肯定筛选掉一大批用户.确实好用,所以 ...

  10. Scala 模式匹配拓展

    1 package chapter08 2 3 object Test03_MatchTupleExtend { 4 def main(args: Array[String]): Unit = { 5 ...