关于我们为什么从 React 迁移到 TypeScript这件事
Beta 版的 Execute Program 是用 Ruby 和 JavaScript 编写的。之后,我们分几步将整个应用完全移植到了 TypeScript 上。本文介绍的是移植的第一步,也就是前端的部分。
在 Execute Program 的原始 JavaScript 前端中,我经常会犯一些小错误。例如,我会将错误的 prop 名称传递给 React 组件,或者遗漏某个 prop,抑或传递错误的数据类型。(Prop 是作为参数发送到 React 组件的数据。组件将一些 props 传递给自己的某个子组件,以此类推,这是很常见的。)
对于像 JavaScript 和 Ruby 这样的动态语言来说,这不是个小问题。过去 15 年来我一直在学习该如何应对这种错误问题。我在之前谈论的是 2011 年代的情况,其中讨论的缓解措施确实有些用途,但它们无法随着系统的发展顺利地扩展下去,而且我们忘掉它们时也没有安全网可用。
我觉得 15 年时间已经够长了。我想回到静态类型系统的怀抱,毕竟这种系统中根本不会出现这类错误。彼时我们有几个选项:Elm、Reason、Flow、TypeScript 和 PureScript。(这里举了一部分例子。)最后我决定使用 TypeScript 是因为:
- TypeScript 是 JavaScript 的超集,因此移植起来很容易。移植回来也更容易些:只需删除类型定义即可,然后我们又回到了 JavaScript。
- TypeScript 编译器使用 TypeScript 编写,并作为已编译的 JavaScript 代码分发,因此我们可以在自己的 Web 应用中运行它。我们的 TypeScript 课程正是这样做的:在浏览器中评估用户的 TypeScript 代码,以避免网络延迟。
- 这一条是我们的业务特有的:TypeScript 比其他选项更受欢迎。这意味着更多的人希望从像我们这样的课程中学习 TypeScript。用 TypeScript 编写 Execute Program,让我们可以制作出更好的 TypeScript 课程。
在 2018 年 10 月,我们用了大约两天时间将前端 JavaScript 代码移植到了 TypeScript。下面的图表显示了在移植前和移植后每种语言拥有的代码量。
当时我们还是 pre-beta 版本,所以系统还很小,只有大约 6,000 行。这张图上没有涉及移植后的情况;我们将在以后的文章中具体介绍相关内容。
在这次移植之后,React prop 问题消失了。下面我们会看几个示例,首先是一个简单的例子。以下是渲染“Continue”按钮的代码,这个按钮出现在我们课程的每个文本段落之后:
<Button
autofocus={true}
icon="arrowRight"
onClick={continue}
primary
>
Continue
</Button>
这个 Button 组件的 props 的类型如下所示。当读取诸如 autofocus?: boolean 之类的属性类型时:“autofocus”是属性的名称;“?”表示它是可选的;“:”将属性名称与其类型分开;而“boolean”是类型。最后一个属性类型 onClick 表示“一个不带参数且不返回任何内容的函数”。如果你不熟悉 TypeScript 的函数类型语法,可以在我们的课程中全面了解 TypeScript 的函数类型。
type ButtonProps = {
autofocus?: boolean
icon?: IconName
primary?: boolean
onClick: () => void
}
如果将“autofocus”prop 从 true 更改为 1,会发生什么?现在,我们在类型系统期望一个布尔值的地方传递了一个数字值。不到一秒钟后,编译器将在下面显示错误。(这里删除了一些不相关的细节;本系列文章中所有涉及到错误的地方都会这样处理。)
src/client/components/explanation.tsx(13,27):
error: Type 'number' is not assignable to type 'boolean | undefined'.
有害代码在 vim 中也变成了红色。修好它后,红色消失了。解决错误只需要几秒钟。在 Ruby 或 JavaScript 中,我可能会花几分钟的时间手动测试应用程序,并反复浏览它的状态才能知道到底发生了什么事情。我也可以依靠自动化测试,但是我们在另一篇文章中介绍了测试 vs 类型的问题。
这个整数到布尔的更改是对类型系统的一次简单而低风险的测试。Button 的 icon 属性显示了更高级的用法。下面还是 Button 调用:
<Button
autofocus={true}
icon="arrowRight"
onClick={continue}
primary
>
Continue
</Button>
看起来 icon prop 只是一个字符串:“arrowRight”。在运行时,在已编译的 JavaScript 代码中,它将是一个字符串。但是在上面显示的 ButtonProps 类型中,我们将其定义为 IconName,后者是在其他地方定义的。在查看其定义之前,让我们先看看这个类型的作用。假设我们将“icon”prop 更改为“banana”。我们实际上没有名为“banana”的图标。
<Button
autofocus={true}
icon="banana"
onClick={continue}
primary
>
Continue
</Button>
不到一秒钟后,TypeScript 编译器拒绝了这一更改:
src/client/components/explanation.tsx(13,44):
error: Type '"banana"' is not assignable to type
'"menu" | "arrowDown" | "arrowLeft" | ... 21 more ... | undefined'.
编译器说“icon”不能是任意字符串。它必须是我们定义为 icon 名称的 24 个字符串之一。编译器将拒绝任何使我们引用不存在图标的更改;这不是有效的程序,甚至无法开始执行。
有多种方法可以实现 IconName 类型。一种是编写一种类型,该类型显式列出所有可能的 icon 名称。然后,我们必须使 icon 名称与其在磁盘上的图像文件保持同步。这种类型可能是这样的:
type IconName =
"menu" |
"arrowDown" |
"arrowLeft" |
"arrowRight" |
...
翻译成中文:“这里会静态地保证 IconName 类型的一个值是此处指定的字符串之一,但不能是其他任何字符串。”(这个类型是我们两堂课程涵盖的两个主题的组合:字面量类型和类型联合)
我们的 IconName 未被定义为字面量类型的简单联合。让图标名称列表与文件列表保持同步是很无聊的工作,我们可以让计算机来完成它!相反,我们的 icon.tsx 文件如下所示:
export const icons = {
arrowDown: {
label: "Down Arrow",
data() {
return <path ... />
}
},
arrowLeft: {
label: "Left Arrow",
data() {
return <path ... />
}
},
...
}
实际的 SVG < path/> 标签就在源代码中,在以 icon 名称为键的对象中。(也可以在不将 SVG 内联到源文件中的情况下执行此操作。例如,我们可以使用一些 Webpack 技巧将图像保存在它们自己的文件中,但仍然可以确保列表中的每个图标也都存在于磁盘上。到目前为止,这种简单的解决方案对我们来说是很好用的。)
通过这种方式定义 icon 后,我们可以使用一行代码自动提取其名称的联合类型(union type):
export type IconName = keyof typeof icons
(这里的意思是,你可以认为该类型表示“每当某物的类型为”IconName”时,它必须是与 icons 对象的键之一匹配的字符串。)
这样就搞定了;并不需要其他类型层面的工作。剩下的代码只是一个简单的 Icon React 组件,它在列表中查找图标并返回其 SVG 路径。这个函数中没有明确的 TypeScript 类型。它看起来像是纯粹的 JavaScript 代码,但它也经过了类型检查。这是一个最小版本,其中删除了所有无关的细节:
export function Icon(props: {
name: IconName
}) {
return <svg>
{icons[props.name].data()}
</svg>
}
现在,我们可以将 SVG 标签放入这个源文件中,并将新 icon 拖放到“icons”列表中。当我们这样做时,这个 icon 就可以在 Button 组件,以及系统内接受 icon 名称的其他任何部分中使用。如果我们从列表中删除一个 icon,则系统中引用该 icon 的所有部分都将立即无法编译,从而确保没有过时的 icon 引用在运行时导致错误。
这些示例按照静态类型标准来说是很简单的,但我认为它们证明了 Web 应用程序中有多少可以轻松实现的改进之处。一个应用程序中的大多数代码都不涉及高级类型系统功能;多数需求仅仅是“确保我们传递正确的 props”和“确保我们的图标确实存在”之类的简单事情。
我们在整个系统中都做了这种事情。其他的一些示例:
- 我们在整个系统中使用了一个 Note 组件。它具有一个 tone prop 来确定提示的样式:“info”“warning”“error”等。如果我们不再使用其中某个 tone 选项,则我们将从联合类型中将其删除,并且所有引用这个 tone 的 Note 将出错,直到我们更新它们。
2.我们链接到的每个 URL 都将静态保证存在。当我们重命名或删除 URL 时,链接到它的每个组件都无法编译,直到我们对其进行更新以匹配为止。
3.当我们链接到这些 URL 时,类型系统可确保我们填充 URL 中的所有空缺。例如,路径“/courses/:courseId/lessons/:lessonId”具有两个 hole,“courseId”和“lessonId”。如果我们尝试链接到该路径,但忘记提供“courseId”,则代码将无法编译。
4.我们在客户端上发出的每个 API 请求都会被静态确保与相应服务端 API 端点的负载结构匹配。如果我们在端点中重命名一个属性,哪怕属性在嵌套的 API 对象的内部深处,引用该端点属性的任何代码也都将无法编译,直到我们对其更新以匹配为止。我们在另一篇文章中介绍了细节。
诸如此类的问题经常会出现在编程工作中,尤其是在动态语言中非常常见;但我们无需编写任何自动测试,也用不着什么手动测试,就可以从静态上避免这些问题。有些问题解决起来需要费些功夫。我们的 API 路由器验证写起来很麻烦。但是写多了就顺手了。上面的单行“IconName”类型实际上是问题的完整解决方案。如果将其复制到 TypeScript 文件中,它就能起作用。
将我们的前端代码移植到 TypeScript 仅仅是个开始。那之后,我们又将后端从 Ruby 移植到了 TypeScript,然后在移植后的 9 个月内对其进行了扩展和维护。
英文原文
Porting a React Frontend to TypeScript
作者:Gary Bernhardt
译者:王强
来源:InfoQ