TypeScript 的未来
TypeScript 的未来
TypeScript 的未来绝不仅仅是大多数人使用的“AnyScript”,更不仅仅是一个加入类型推导能力的 JavaScript。TypeScript 未来将成为一切 DSL 事实上的类型系统。
很早以前,当我接触到 TypeScript 时,我知道它是对 JavaScript 缺乏类型系统的一种补充,是一种最开始甚至不被认为是一门真正的“编程语言”的“转译器语言”。TypeScript 不是完备的,它依赖于 JavaScript 自身才能真正被称为一门编程语言。甚至 TypeScript 引以为傲的类型系统也不是所谓“Sound”的,这有时甚至被一些 Dart 支持者攻击。当然,我也不认为这些狂热的 Dart 支持者很聪明。
后来我意识到,随着 JavaScript 成为事实上形成垄断的前端标准,成为世界上使用人数最多的编程语言,渐渐成为几乎所有终端的唯一编程语言,甚至侵入后端领域与嵌入式编程领域,TypeScript 也不再仅仅是“JavaScript 的类型系统”。
TS 与前端框架
React 刚推出时,JSX 尚缺乏 TypeScript 支持。后来随着 React 流行起来,TS 团队几乎不得不为 JSX 添加了支持,这才有了我们如今如此无缝的 TSX 开发体验。
相比之下,Vue 团队则要艰辛地多,由于 Vue 使用的模板语法侵入式太强,并且在背后大量使用了 Proxy,导致 TS 这么一个几十人的团队不太可能专门为 Vue 添加支持,因此 Vue 团队不得不付出极大的努力才为 Vue 和 TS 之间搭起了桥梁。即使是 Vue 2 中直接把 data 中的属性变成 TS keyword 都是极其困难的事情,除了堆人力外没有任何取巧的解决方案。后来 Vue 甚至因为 Vue 2 实在是太难添加对 TS 的完善支持而直接开发了 Vue 3.
同时,为了使 Vue 开发者能够获得更好的 TS 支持,Vue 团队还不惜血本专门为 VSCode 开发了官方的 Vue 支持插件——在其它语言里这听起来可不要太疯狂,一个框架的开发团队专门为使用某个编辑器的用户投入血本开发适配插件,而不是让编辑器/IDE 来适配自己,这是只有前端领域才能见到的奇景。
不过,前端新秀 Svelte 看起来就要幸福得多。Svelte 最开始也缺乏足够的 TS 支持,但不知道什么时候 Svelte 团队中混进来个人和某个 TS 团队成员很熟,于是一段时间之后,Svelte 就神奇地有了 TS 支持,甚至 Svelte 团队中很多人都没意识到这是怎么发生的。
显而易见,这些前端框架都并非完全使用“JavaScript”的框架。为了更流畅的开发体验,它们都在某种程度上“加强”了 JavaScript 的语法,而这也意味着在这些框架刚诞生的那段时间里,必然是缺乏或是只有不完善的 TS 支持的。为了获得完善的 TS 支持,它们要么能足够流行到等 TS 团队适配,要么开发者团队不惜血本专门给 TS 搭桥,要么这框架本来就没做太多侵入式的语法加强,TS 团队看着你这框架流行度还可以,而且适配不困难,就顺手适配了。
为什么要聊这些前端框架?因为它们对 TS 的支持已经不仅局限于“为 JavaScript 添加类型系统”,而是已经变成了“为某种基于 JavaScript 的 DSL 添上类型系统”了。实际上在这些框架中,TS 已经不仅仅是 JS 的类型系统了,它已经成为了某种 DSL 的类型系统。
TS 与生成器
这里聊的“生成器”可不是 JavaScript 中那个从 Python 里“借鉴”而来的 generator,而是真正的代码生成器。在这方面,一个比较典型的例子就是 GraphQL.
GraphQL 本身作为一种查询语言,要接入 TS 的类型系统可谓是相当困难了。事实上,至今也没有一个被广泛采用的解决方案能够根据 GraphQL 查询字符串精准推导出返回值的类型,我怀疑可能也从没有人做过。但是,为了让 GraphQL 前后端开发能够与 TS 集成,社区还是做了不少努力。
其中一个努力是 type-graphql,它使用 TS 装饰器根据你使用 TS 编写的 class 自动生成 GraphQL Schema,例如类型与 Query/Mutation 定义。当然,也有反过来的方案,比如 graphql-code-generator 就提供了将 GraphQL Schema 反过来生成 TS interface 的方案,这通常被用于前端,而 type-graphql 通常被用于后端。
通过 type-graphql 与 graphql-code-generator 的结合,你可以在后端直接编写 TS class,使用 type-graphql 生成 GraphQL Schema,然后前端使用 graphql-code-generator 根据后端生成的 GraphQL Schema 再生成 TS interface 供前端使用。这一过程甚至可以是同步的,如果后端已经部署到了服务器上,前端的 graphql-code-generator 只需要配置 GraphQL 后端的网址就可以自动获取 Schema 并自动更新生成的 interface,达到近乎无缝的体验。当然,除了单纯生成简单的 TS interface 外,graphql-code-generator 还支持各种 GraphQL 客户端和服务端框架,可以直接生成专用于各框架的 TS 代码,甚至是 Java/C#代码,框架开发者也可以编写 graphql-code-generator 的插件,供用户使用。
可以看到,在这一过程中,TS 成了无论前端还是后端的,事实上的“终端”语言。而在其中,GraphQL 几乎仅仅是作为一个给代码生成器使用的“中间语言”在充当作用。当然,目前来说,前端还是不可避免地需要编写 GraphQL 查询字符串,这方面不太容易由代码生成器将 TS 代码转译成 GraphQL 字符串,那样就有些太傻了,反而降低了 GraphQL 的灵活性。但是,谁说未来不会出现能够纯粹使用 TS 编写 GraphQL 查询字符串的前端库呢?这个库的任务也很简单,只需要让用户通过某种方式用 TS 代码生成 AST,然后将 AST 处理为 GraphQL 查询字符串就行——其实现在就可以这么做,你可以直接从 graphql 库中导入那些 AST 相关的类然后手动构建一个 AST,但大家都嫌麻烦因此使用 graphql-tag 等方案直接写字符串。我相信未来会有比写字符串更优雅的解决方式。
未来 TS 有没有可能成为 SQL 的类型系统?后端使用 Spring Data JPA/MyBatis Plus 甚至 Node/Go/PHP/Ruby 那边的 ORM 框架生成对应的 SQL 语句,然后由某个代码生成器根据这些 SQL 语句和数据库定义生成 TS 类型,供前端使用。中间我们完全不关心 SQL 语句长什么样的,它只是作为一种中间语言为我们的 TS 提供完善且准确的类型支持而已。事实上,这方面早有相关尝试,PgTyped就是其中之一。
上面这番关于 SQL 的讨论看起来有点奇怪——我们现在前后端分离式的开发已经不怎么关注 SQL 了。如果我们使用 Spring Data JPA/MyBatis Plus 这样的 ORM 框架(严格来说,MP 不算 ORM 框架,但这里就随意点了),本来 SQL 就几乎只是作为后端框架生成的查询语言存在,前端本来就不用关心这些 SQL,为什么上面还要讨论通过 SQL 生成 TS 类型定义?
事实上,在上面描述的这个流程中,SQL 作为中间语言的关键在于提供精准的类型定义。目前来说,我们大多数的前后端分离开发还需依赖前端开发者手动根据后端提供的接口封装相应的 API 并且编写类型,而如果后端能够直接静态生成 SQL,然后使用代码生成器直接编译成对应的前端 API,就免去了前端手动适配后端 API 的过程。
如今有很多“前后端一体化”的“快速 CRUD”框架,其中很多采用 Node.js 作为后端,背后也采用了类似的思路。这些框架通常带有一个专用的代码生成器,后端编写了相应的 API 后,能够直接生成可供前端调用的封装好的接口函数。但是,这些框架存在的显著问题是不具备足够的泛用性,这样的代码生成器显然只能在该框架内部使用。而如果未来我们能够广泛地使用 SQL 作为中间语言进行传递,就能获得更好的泛用性,该代码生成器不需要依赖于某个特定的后端框架也能使用,只要那个后端框架做了生成静态 SQL 语句的适配,或者说有哪个人为这个后端框架做了适配也行。
TS 与 DSL
在 DSL(领域特定语言)方面,Ruby 一直以来都呈现近乎垄断的地位。当然,也不是说其他编程语言不能做 DSL,Chisel 就是基于 Scala 的嵌入式领域的 DSL,甚至要广义一点说,Python 那边 Pandas 的那个语法也可以说是一种 DSL,像 Spark 和 Tensorflow 这种,背后生成计算图的框架,其实也可以说是把宿主语言当作 DSL 在用。
但是近年来,Ruby 首先由于 Rails 渐渐不适应当前的主流开发模式而没落,其后由于 Python 的流行,甚至在 DSL 上也慢慢没了饭碗。不过,要我说这些方式大都存在一个问题,那就是缺乏足够的类型提示。
DSL 要什么类型提示?为了一个几十行顶多几百行的配置文件塞一个类型系统岂不是吃饱了撑的?在过去是这样。但随着静态类型 DSL,像是 kts、Chisel 这些方案的推广,我们意识到在静态类型语言的基础上构建 DSL 是完全有可能的。只是过去静态类型语言的灵活度还不够,导致我们下意识地认为只有独立开发的 DSL 或基于动态类型语言的 DSL 才能胜任我们的需求。但现在发现,我们似乎只是陷入了一个思维定势。
但是其实现在有更简单的选择:先搓一个 Parser 把你的 DSL 变成 TS,于是 TS 就能为你的 DSL 提供 type checking,然后你用 tsc 将 DSL 变成 JS,再在你的框架里内嵌个 JS 解释器(如果你的框架本来就是 JS 写的就更简单了),读取 JS 代码进行配置就行了。
好吧,我承认这其实是个非常逆天的方案。在这个过程中,你的 DSL 首先要经过你自己的 Parser 变成 TS,然后经过 TS 的 Parser 变成 JS,然后你的框架里还要内嵌个 JS 解释器来解释生成的 JS。而现在很多方案都在极力避免这种套一堆 Parser 的情况,比如很多 DSL 之所以选择 Ruby 是因为你可以直接用 Ruby 的元编程能力定义一个看起来就像外部 DSL 的内部 DSL,不用写 Parser,直接就能用。同理,Chisel、kts 等也是没经过 Parser 或是只用写个很小的 Parser,几乎就是宿主语言。
但是,谁说这就不行呢?使用 TS 作为 DSL 中间语言的最大好处是只需要编辑器有插件做简单适配就可以获得完备的类型提示,而不需要你额外塞个类型系统。而且得益于 JS 本身的灵活性和(相对较高的)速度,你的 DSL 也可以获得足够的灵活性和速度。
比如我们考虑 Pandas,我们经常编写这样的代码:
1 | |
这里有一个问题,那就是缺乏类型提示——我们不知道 foo 和 bar 这两个列在当前的 df 上是否存在。当然,我们可以用 Jupyter 根据变量表获得动态的类型提示,但我们终归不是总能使用 Jupyter,并且 Jupyter 本身也存在一些限制。
为什么不考虑写一个 TS 版本的 Pandas 呢?
1 | |
现在,如果 foo 或 bar 不存在,TS 就会报错。
但是这看起来很麻烦,不是吗?我们需要写这么长一串 TS 代码,才等价于上面那样简洁的 Pandas.
不要着急,假设我们实现了一个运算符重载版本的 JS,就叫它 TSOO 吧(TS Operator Overloading),现在我们的 TS 代码就可以长成这样:
1 | |
完全一致,不是吗?现在我们的 Parser 能够识别重载的运算符,将这段代码编译成对应的 TS。在此过程中,我们获得了 TS 的类型安全。
再来看看 R 的 ggplot2:
1 | |
我们考虑这样构建我们的 TS 版本:
1 | |
这里的方法和参数都泛型化了,因此 TS 能够给出足够强大的类型提示并保证类型安全。
当然,这样写实在是太繁琐了,我们可以在此之上构建一个 DSL.
1 | |
这里的工作其实也很简单,就是把#{ foo: #bar }变成(_) => ({ foo: _.bar })而已,可以看到现在可读性高了不少,并且我们获得了 TS 提供的类型安全与智能提示。
当然,你可以觉得上面这么写不太好看,甚至可以改成这样,随便你怎么做!
1 | |
在上面的代码中,我们假设如果一个函数以$结尾,比如fn$(...),就把它变成fn((_) => ...),然后在这个特殊的函数中,如果出现以:开头的名字,比如:foo,就把它变成_.foo。然后我们还将原来传入一个 JS 对象的语法处理成了关键字参数的形式,这也很简单。
得益于前端工具链的完善,只需要我们给 Babel 和 ts-loader 写一些很简单的配置,一切就完成了!我们获得了简洁、类型安全且包含大量智能提示的新 Pandas 和 ggplot。当然,还需要编辑器/IDE 来点插件适配,这是没有办法避免的。
未来
目前来说,TS 是我们最容易能够直接利用的类型系统。而 JS 又是足够灵活且流行的编程语言。我相信随着前端的发展,JS 会不可避免地逐渐侵入一切可能的领域,而 TS 作为 JS 的类型系统,只需要充足的代码生成器和转译器支持(考虑到目前前端生态链的极端发达,这相当轻松),便足以成为大多数框架甚至 DSL 事实上的类型系统。
我想这一天不会太远。