前一阵子,我使用了TypeScript重构了一个JavaScript项目。但是在此之前,我一直是一个专精于Java的开发者。在应用领域,我主要研究SpringBoot、MyBatis、Tomcat、SpringCloud相关的微服务技术。一些大数据相关的技术,例如Hadoop、HBase、Hive,我也基本全面的学习过,并做过一整年的开发。对于其它的语言以及相关技术,无论是做开发用,还是用作写脚本,我也多多少少涉猎过一部分。如在Linux中编写bash脚本,windows下编写Powershell脚本,用Python搭建一个提供算法功能的简易服务器,用jQuery写一些简易的网页等。因为使用场景的不同、面对问题的不同,与之相关的开发模式、代码写法、组件生态甚至程序员思考问题的方法,都大相径庭。
在没有系统学习TypeScript之前,我主要以兼职的方式写一些TypeScript的代码。所谓兼职的方式就是把别人的代码拷贝过来,改一改调一调,满足一些不太复杂的需求。这些被拷贝的代码有一些是网友的,有一些是官网示例,也有一些是同事已经写好的代码。早些年的时候我使用WebStorm来编写前端代码,但是近年来渐渐改成了VSCode。相较于前端,后端开发主要使用的开发环境IDEA,对工具集成程度更高。如果项目和工具被正确的配置了,那么实际上开发者几乎不需要打开命令行,很多事情点点按钮就可以完成的很不错。例如导入依赖、运行代码、编译jar包等等。IDEA会代替开发者去执行相关的命令。相对来说还是比较方便快捷的。但是对于前端来说,想要做好一个项目的开发或者管理好一个项目,对工具链的认识,是要比后端要求高很多的。这是二者第一大差异所在。
在我决定用TypeScript系统性的去开发一些东西的时候,首先我要选择一个合适的项目创建工具。项目创建工具在前端的竞争要在比后端的竞争要更激烈一些,后端基本上就是maven了。尽管有一些其它的竞争者例如gradle,但是我周围的的开发者都会选择maven。在后端只需要在界面上点一点,就可以通过Spring Initializer创建出一个项目了。前端则是需要一些命令来做这些事情,对我来说,首先是npm,然后是pnpm,最后是vite。做好这些之后,我还需要在一些配置文件上花一些功夫,如tsconfig.json、vite.config.js、package.json等。确保由这些文件构建出来的项目,符合我的预期。
JavaScript的灵活性使得它不仅可以运行在浏览器当中,还可以运行在node中。一旦以node的形式运行,它又可以是一个http-server服务器,或者一个诸如bash那样的脚本。在我刚开始全面学习TypeScript的时候,对这一点还是颇为困惑的。同样是JavaScript代码,会因为运行的环境的不同而受到不同的约束。在浏览器中我能够使用window来获取全局变量,使用document来创建元素,但是在node环境下就不可以。同样的,在node环境下,我可以使用path来读写本地文件,但是在浏览器当中就不可以了。就连调试的方法,也差异较大。如果是node的代码,那么和Java的调试方式大差不差。但是如果是面向浏览器的,就相对来说比较麻烦了。需要先配置好浏览器打开的方式,必须是以debug模式打开,然后再通过远程的端口连接上浏览器的调试端口,最后还要确保执行代码到源代码的映射,要是正确的。
解决了项目创建和配置的问题之后,就要正式开始写代码了。Java和TypeScript在代码的写法上,相似度是挺高的。他们都同为C语言这个家族的后代,又都面向对象,所以这是一件自然而然的事。但是TypeScript身上的历史包袱,是要比Java重不少的。TypeScript要尽可能的兼容JavaScript,又要承担起面向对象的职责,面临的挑战是很大的。Java的历史包袱,我所知道的就是一个泛型擦除。JavaScript本身写起来比较灵活,在过去那个一个网站的若干网页都是独立存在的时代,还是相当实用的。但是现在我要开发更复杂的代码了,或者说我开发的就是一个包含十几万行代码的库。JavaScript就有点力不从心了。缺乏清晰地调用关系、参数类型不明了、不支持多态,这几点相当致命。遗憾的是,TypeScript作为一种前端语言,它最终还是要编译成JavaScript的,它仍旧受限于JavaScript的一些设计和缺陷。
当我试图用面向对象的方式去写代码的时候,我所受到的第一个限制就是TypeScript中的接口在编译后会被擦除。TypeScript中的接口与其说是一种类型,不如说更像是JSDoc的升级版。它深入的参与到了代码开发过程当中,给开发者提供重要的类型信息,同时也限制开发者胡乱编写,却又在编译后悄然消失。它的悄然消失,使得我无法在开发过程中使用instanceof来判断一个对象是否实现了这个接口。这是一种无奈。
接口在面向对象的代码开发过程中的地位,无需多言。很多规范都强调了接口的重要性,例如设计模式六大原则中的依赖倒置原则,就强调要面向接口编程而不是具体类来进行编程。通过阅读接口中的相关方法的定义,我们可以一目了然的了解到这个接口所具备的能力,而无需来回翻阅具体实现的代码。给变量声明的类型是接口而非具体类,那么将来想要替换或重写这个类,也是容易的。组件与组件的交互中,将一个组件所有的API都以接口的形式声明好,并抽取出一个独立的模块,调用方通过这个接口模块调接口,实现方在另一个模块中作出具体的实现。这是一种非常好的解耦方式。总体来说,TypeScript中的接口因为擦除的问题,确实有一些不足。但是也提供了接口所需要的大部分能力。TypeScript中接口支持多态,这倒让我眼前一亮,因为JavaScript不支持多态。但是TypeScript却可以通过声明名称相同相同、参数不同的方法。只不过在实现过程中,还是只能有一个函数来实现所有同名的接口。
TypeScript中的null和undefined则是我遇到的另一个比较困惑的问题。如果在考虑全等符号“===”,那就更令人困惑了。在Java中,空只有一种,那就是null。尽管有一些报告声称null造成了很多商业价值上的损失。在实际开发过程中,空指针确实也很常见。但是一直以来这个问题也没有太好的处理办法。我以前写过一些C#,C#中对类型要单独声明是否为空,当时让我挺诧异的。但是让我更诧异的是TypeScript中不仅有null,还有undefined,这几样东西混在一起,处理起来难度属实要高一些。尽管tsconfig中有一些相关的开关,能够关闭对null的检测,或者不区分null和undefined,但实际上为了更高的代码质量,我不能关闭它们。在声明一个变量的过程中,null和undefined都要体现出来。这就会显得代码很冗长。例如下面的例子:
class Person{
name: string | null | undefined
}
一行简单的name声明中,null和undefined占据了一半,但是有效的信息只提供了一点点。如果大量的出现在代码中,那么代码中具有业务含义的代码占比就会少不少了。而且混用还会带来一些问题。我可以举两个我实际遇到的例子。
第一个例子是用全等去判断的时候,null !== undefind。即null和undefined不全等。如果要清晰的判断person.name不为空,那么需要多写一些代码,多做一些工作。像下面这样编写:
if(person.name !== null && person.name !== undefined){
...
}
这其实是没必要的。在判空这件事上,多花一些时间和精力,在具体的功能开发上,就会少花一些时间和精力。工具为开发者服务,开发者需要了解工具,但是不应当总是去处理工具上的一些特殊问题。
第二个例子则是我遇到的一个bug,定位了很久才发现是 null >= 0和undefined >= 0的真假性不相同到导致的。null >= 0的值是true,而undefined >= 0的值却是false。如果一个number类型的变量x可以为null,也可以为undefined,那么x >= 0这种写法在一些特殊的场景下可能就会出现一些奇怪的问题。
在我负责的TypeScript的项目中,我选择广泛的使用null,谨慎地使用undefined,在有必要的时候,需要及时将值为undefined的变量设置为null。毕竟undefined和null的具体语义和作用还是不同的。不能一概而论,或者简单的只使用其中一个。
除了上面提到的两个问题,在对TypeScript进行系统学习和大量使用的过程中,我还发现了TypeScript为了弥补JavaScript在面向对象开发中的不足,做出了不少努力。作为一个Java开发者,其实我心里很清楚这些语法的作用。例如函数的返回类型是never、assert crond、arg is number这样。JavaScript一点都不面向对象。而TypeScript在面向对象这件事上想做的彻底一些。因此不得不使用一些类似于适配器一样的东西,来缓和两种语言之间的矛盾。
我在后续的博客中,还会详细的来分享这些问题。欢迎留言、私信。和我一起讨论。