优秀的编程知识分享平台

网站首页 > 技术文章 正文

从 React 迁移 TypeScript忍受了15 年的JavaScript 错误从此走远

nanyue 2024-10-24 11:46:57 技术文章 3 ℃


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 是因为:

  1. TypeScript 是 JavaScript 的超集,因此移植起来很容易。移植回来也更容易些:只需删除类型定义即可,然后我们又回到了 JavaScript。
  2. TypeScript 编译器使用 TypeScript 编写,并作为已编译的 JavaScript 代码分发,因此我们可以在自己的 Web 应用中运行它。我们的 TypeScript 课程正是这样做的:在浏览器中评估用户的 TypeScript 代码,以避免网络延迟。
  3. 这一条是我们的业务特有的: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”和“确保我们的图标确实存在”之类的简单事情。

我们在整个系统中都做了这种事情。其他的一些示例:

  1. 我们在整个系统中使用了一个 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 个月内对其进行了扩展和维护。

Tags:

最近发表
标签列表