TypeScript Tutorial for JS Programmers Who Know How to Build a Todo App
原文链接: ts.chibicode.com/todo
作者的话: 我的教程是为了帮助初级程序员学习TypeScript,可能对有经验的程序员不那么有用。
为什么要针对初级程序员?随着 TypeScript 的普及,越来越多的初级程序员将学习和使用它。然而,我注意到许多现有的教程对初级程序员并不那么友好,因此,我尝试着写一个更友好的学习教程。
通过构建Todo应用来学习TypeScript
在2011年,Backbone.js是最流行的JavaScript库之一(React在2013年问世;Vue在2014年)。
当人们在学习Backbone.js时,许多人(包括我自己)是通过构建一个todo应用来学习的。官方文档包括一个用Backbone.js构建的todo应用实例,许多人通过阅读其精美的注释源代码来学习。
在当时,通过建立一个todo应用程序来学习一个JavaScript库是一个新的想法,并由Backbone.js(以及随后的其他库)推广开来。它启发了TodoMVC,它展示了使用不同JS库构建的todo应用。今天,许多教程,如Redux的官方教程,都涉及到构建一个todo应用程序。
但TypeScript教程呢?谈到TypeScript,涉及构建todo应用的教程并不多,我认为这错过了很多机会。构建一个todo应用是学习前端的一个好方法,许多JS程序员已经知道如何构建一个todo应用,因此应该有更多以todo应用为特色的TypeScript教程。
在本教程中,我将通过下图所示的一个示例todo应用来教授TypeScript的一些有趣部分。
在我们开始之前,这里有一些细节:
-
本教程不依赖于任何特定的前端库,所以你是否知道React、Vue或其他一些库并不重要。只要你有基本的JS知识,你就能跟上。没有必要事先了解TypeScript。
-
为了节省时间,我不打算谈论如何设置TypeScript项目--你应该阅读其他教程。对于React,请查看React+TypeScript Cheatsheets。
-
同时为了节省时间,我只介绍TypeScript中最基本的概念。我的目标不是要面面俱到,而是要让你想要去学习更多相关知识。
这篇文章总共有3个部分。以下是每一节所涵盖的主题。
第1节:类型、只读属性和映射类型
第2节:数组类型、字面量类型和交叉类型
第3节:联合类型和可选属性
让我们开始吧!
Section 1 类型、只读属性和映射类型
1.1 将数据转化为用户界面
让我们先来看看数据。像React或Vue这样的UI库本质上所做的事情是将数据转化为UI。例如,在React中,你把数据指定为道具或状态,它就会根据这些数据渲染出UI。
现在,让我们来看看下面这个todo应用程序。你能猜到与这个用户界面相关的数据是什么吗?
答:它是一个对象数组,每个对象都有id、text和done。
// Associated data. If we're using React, this
// would be the component’s props or state
[
{ id: 1, text: 'First todo', done: false },
{ id: 2, text: 'Second todo', done: false }
]
下面是每个todo对象里面的内容。
id是每个todo项目的ID。这通常是由后端数据库生成的。text包含每个todo项目的文本。- 最重要的是,完成的项目状态为真,否则为假。
让我们把这个应用程序和它的相关数据一起显示出来。试着勾选和不勾选每个复选框,并看看做了什么变化。(注:请进入原文进行操作)
正如你所看到的,当你check/uncheck一个复选框时,它会更新底层数据(done的属性),反过来,用户界面也会被更新。这就是React和Vue等UI库的工作方式。
接下来,让我们看看数据如何被更新。
1.2 让我们来实现toggleTodo()
为了实现check/uncheck的功能,我们需要编写代码来切换单个todo项目的已完成属性。
让我们把这个函数命名为toggleTodo()。下面是它应该如何工作。
- 当你在一个单一的todo对象上调用
toggleTodo()时 - 它需要返回一个新的todo对象,该对象的
done属性为相反的布尔值。
// Takes a single todo object and returns
// a new todo object containing the opposite
// boolean value for the "done" proprty.
function toggleTodo(todo) {
// ...
}
// Example usage:
toggleTodo({ id: …, text: '…', done: true })
// -> returns { id: …, text: '…', done: false }
toggleTodo({ id: …, text: '…', done: false })
// -> returns { id: …, text: '…', done: true }
现在,让我介绍我们的初级开发人员--小黄鸭。他将为我们实现toggleTodo()。
// 小黄鸭的实现
function toggleTodo(todo) {
return {
text: todo.text,
done: !todo.done
}
}
让我们检查一下小黄鸭的实现是否正确。看一下下面的测试案例。你认为输出会是什么?先试着猜一下,然后按下下面的运行按钮。(注:请进入原文进行操作)
done属性正确的变成了false,但它缺少id属性。所以小黄鸭的实现是不正确的。
正确的实现:
function toggleTodo(todo) {
return {
// This line was missing
id: todo.id,
text: todo.text,
done: !todo.done
}
}
现在,问题来了。我们怎样才能防止小黄鸭犯这样的错误?
这就是TypeScript的作用。
1.3 类型检查
使用TypeScript,我们可以通过做类型检查来防止犯小黄鸭刚刚的错误。
首先,为我们使用的数据创建一个类型。在我们的例子中,我们需要为一个todo项目创建一个类型。我们将这个类型称为Todo,并使用以下TypeScript语法来定义它。
type Todo = {
id: number
text: string
done: boolean
}
我们可以使用这个类型来检查一个变量是否确实是一个todo项目。做这种检查的TypeScript语法是:变量名: Todo。
它编译成功了,因为foo的类型与Todo的类型相符。
现在,下面这个例子会怎样呢?
答案是编译失败,因为缺乏id属性。
TypeScript能够让你根据指定的类型对变量进行类型检查,这有助于你尽早发现错误。
1.4 在toggleTodo()中使用TypeScript
现在,让我们用TypeScript来防止小黄鸭犯错误。回顾一下,这里是我们之前创建的Todo类型(id是必须的)。
type Todo = {
id: number
text: string
done: boolean
}
首先,我们指定toggleTodo()的输入必须是Todo。我们通过在参数todo旁边添加:Todo来做到这一点。
// Parameter "todo" must match the Todo type
function toggleTodo(todo: Todo) {
// ...
}
接下来,我们指定toggleTodo()的返回类型也必须是Todo。我们通过在参数列表后添加:Todo来做到这一点。
// The return value must match the Todo type
function toggleTodo(todo: Todo): Todo {
// ...
}
现在,让我们复制并粘贴小黄鸭写的代码--没有id属性的代码--看看会发生什么。(注:请进入原文进行操作)
它编译失败了,因为返回的对象缺少id属性,因此不符合Todo的类型。因此,TypeScript可以防止小黄鸭犯的错误!
为了确保这一点,让我们用正确的代码再试一次。我已经在返回的对象中添加了id属性。(注:请进入原文进行操作)
它编译成功了! 正如你所看到的,TypeScript在防止错误和让你知道一切都有正确的类型方面非常出色。
1.5 糟糕的重构
现在,代码已经正常运行了,小黄鸭决定重构toggleTodo()。
编译成功了,但它实际上是一个糟糕的重构。为什么呢?因为它改变了原来的todo对象。(注:请进入原文进行操作)
在对其运行toggleTodo()后,argument发生了变化。这不是好事,因为我们之前已经说过,toggleTodo()必须返回一个新的todo对象。它不应该修改参数(输入)的todo对象。
这就是为什么尽管它的编译正确,但小黄鸭的重构仍然是一个糟糕的重构。
1.6 只读属性
为了防止一个函数修改其输入,你可以在TypeScript中使用readonly关键字。在这里,readonly关键字被添加到Todo的所有属性中。
type Todo = {
readonly id: number
readonly text: string
readonly done: boolean
}
编译失败! 这是因为done被定义为一个只读属性,而TypeScript阻止你更新只读属性。
我们再次看到,TypeScript可以防止小黄鸭犯错误。
顺便说一下,我们先前使用的实现将继续工作,因为它没有修改输入的todo项目。
type Todo = {
readonly id: number
readonly text: string
readonly done: boolean
}
// Earlier implementation: it will continue to
// work because the input todo is not modified
function toggleTodo(todo: Todo): Todo {
return {
id: todo.id,
text: todo.text,
done: !todo.done
}
}
6. ReadOnly<...>映射类型
在TypeScript中,还有一种方法可以使一个对象类型的所有属性成为只读类型。
type Todo = {
readonly id: number
readonly text: string
readonly done: boolean
}
上面的代码等价于:
// Readonly<...> makes each property readonly
type Todo = Readonly<{
id: number
text: string
done: boolean
}>
在TypeScript中,如果你在一个对象类型上使用Readonly<...>关键字,它会使它的所有属性都是只读的。这通常比手动添加readonly到每个属性更容易。
下面是另一个例子。
type Foo = {
bar: number
}
type ReadonlyFoo = Readonly<Foo>
// ReadonlyFoo is { readonly bar: number }
在TypeScript中,你可以使用Readonly<...>这样的关键字来将一个类型转换为另一个类型。在这种情况下,Readonly<...>接收一个对象类型(如Todo)并创建一个具有只读属性的新对象类型。
而像Readonly<...>这样的关键词被称为映射类型。映射类型有点像函数,除了输入/输出是TypeScript类型。
有许多内置的映射类型(如Required<...>,Partial<...>等)。你也可以创建你自己的映射类型。我不会在本文详细介绍这些主题,你可以自己搜索。
1.7 section1 小结: 类型(Types)就像轻量级的自动单元测试
到目前为止,我们已经学到了以下内容:
- 我们可以定义一个类型以确保一个函数的输入和输出是正确的类型。
- 我们可以使用
readonly关键字来确保一个对象的属性不被修改。
在JavaScript中,你需要写单元测试来测试这些东西,但TypeScript可以自动检查它们。所以在某种意义上,TypeScript的类型就像轻量级的单元测试,在你每次保存(编译)代码的时候都会运行。(当然,这个比喻是简化的。你还是应该用TypeScript写测试!)
当你使用一个UI库并需要转换数据时,这一点特别有用。例如,如果你使用React,你将需要在状态更新中转换数据。当你把数据从一个父组件传递给它的子组件时,你也可能需要转换数据。TypeScript可以减少这些情况下产生的bug。
最后,我们了解到,我们可以使用像Readonly这样的映射类型,将一种类型转换为另一种类型。
Section 2 数组类型,字面量类型以及交叉类型(Array Types, Literal Types, and Intersection Types)
2.1 将所有项目标记为完成状态
让我们来谈谈我们的todo应用程序的一个新功能。"将所有项目标记为完成状态"。试着按下下面的 Mark all as completed。(注:请进入原文进行操作)
在按下 Mark all as completed 后,所有项目的属性 done 最后都变成了 true。
让我们用TypeScript来实现这个功能。我们将编写一个名为completeAll()的函数,它接收一个todo项目数组,并返回一个新的todos数组,其中属性done都是true。
// Takes an array of todo items and returns
// a new array where "done" is all true
function completeAll(todos) {
// ...
}
在实现它之前,让我们指定这个函数的输入/输出类型,以防止出现错误。
2.2 为completeAll()添加类型
对于completeAll(),我们将使用Todo类型的Readonly版本。
type Todo = Readonly<{
id: number
text: string
done: boolean
}>
首先,我们要指定 completeAll() 的参数类型,即 Todo 项目的数组。为了指定一个数组类型,我们在类型旁边加上[],如下所示。
// Input is an array of Todo items: Todo[]
function completeAll(todos: Todo[]) {
// ...
}
第二,让我们指定返回类型。它也将是一个Todo项目的数组,所以我们将使用和上面一样的语法。
// Output is an array of Todo items: Todo[]
function completeAll(todos: Todo[]): Todo[] {
// ...
}
第三,我们要确保completeAll()返回一个新的数组,而不是修改原始数组。
因为我们之前用Readonly<...>定义了Todo,所以数组中的每个todo项目都已经是只读的。然而,数组本身还不是只读的。
为了使数组本身成为只读的,我们需要给Todo[]添加readonly关键字,像下面这样。
// Make input todos as readonly array
function completeAll(
todos: readonly Todo[]
): Todo[] {
// ...
}
所以对于数组,我们使用readonly关键字,而不是Readonly<...>映射的类型?
是的,我们对数组使用readonly关键字。这样做,TypeScript将防止你意外地修改数组。
// After declaring todos as readonly Todo[],
// the following code WILL NOT compile:
// Compile error - modifies the array
todos[0] = { id: 1, text: '…', done: true }
// Compile error - push() modifies the array
todos.push({ id: 1, text: '…', done: true })
事实上。在实现completeAll()之前,我们还想做一件事。让我们来看看那是什么!
2.3 CompletedTodo类型
看一下下面的代码。除了Todo类型之外,我们还定义了一个新的类型,叫做CompletedTodo。
type Todo = Readonly<{
id: number
text: string
done: boolean
}>
type CompletedTodo = Readonly<{
id: number
text: string
done: true
}>
新的CompletedTodo几乎与Todo相同,只是它拥有done: true而不是done: boolean。
在TypeScript中,你可以在指定一个类型时使用精确的值(如true或false)。这被称为字面量类型。
让我们来看看一个例子。在下面的代码中,我们把CompletedTodo添加到一个done:false的todo项目上。让我们看看当你按下编译它时发生了什么。(注:请进入原文进行操作)
它编译失败了,因为done的值不是true。通过使用字面量类型,你可以准确地指定一个属性允许的值。
回到 completeAll(),我们可以指定 completeAll() 的返回类型是一个 CompletedTodo 的数组。
// Returns an array where "done" is all true
function completeAll(
todos: readonly Todo[]
): CompletedTodo[] {
// ...
}
通过这样做,TypeScript将迫使你返回一个done属性都是true的todo项目数组--如果不是,将导致编译错误。
2.4 交叉类型
问题:Todo和CompletedTodo之间似乎有一些重复的代码。我们能重构他们吗?
好问题!如果你仔细观察,会发现Todo和CompletedTodo有相同的id和text类型。
我们可以通过使用TypeScript的一个功能,即交叉类型,来重复代码。
在TypeScript中,你可以使用 & 符号来创建两种类型的交集类型。
交叉类型A&B是一个具有A和B的所有属性的类型,下面是一个例子。
type A = { a: number }
type B = { b: string }
// This intersection type…
type AandB = A & B
// …is equivalent to:
type AandB = {
a: number
b: string
}
此外,如果第二种类型比第一种类型更具体,那么第二种类型将覆盖第一种。这里有一个例子。
// They booth have a property foo,
// but B’s foo (true) is
// more specific than A’s foo (boolean)
type A = { foo: boolean }
type B = { foo: true }
// This intersection type…
type AandB = A & B
// …is equivalent to:
type AandB = { foo: true }
我们可以应用这个想法来更新CompletedTodo的定义。我们将用Todo来定义CompletedTodo,像这样:
type Todo = Readonly<{
id: number
text: string
done: boolean
}>
// Override the done property of Todo
type CompletedTodo = Todo & {
readonly done: true
}
通过上述操作,你可以定义CompletedTodo,使其具有与Todo除了done以外相同的属性,而不需要重复代码。
摘要:就像JavaScript有布尔运算符,如 &&,TypeScript有类型运算符,如 &,让你结合两种类型。
2.5 最终实现 completeAll()
我们终于准备好实现 completeAll()了。下面是代码--试着按下 【Compile】 按钮 ! (注:请进入原文进行操作)
它已经成功编译了! 让我们在一个示例的todo列表上运行这个函数。按下 【Run】 按钮 (注:请进入原文进行操作)
正如预期的那样,
done都变成了 true。
现在,让我们看看如果我们犯了一个错误,把done设置为false会怎么样。
它编译失败了,因为 CompletedTodo 必须拥有 done:true属性。再一次,TypeScript提前发现了一个错误。
这一节就到此为止吧 通过使用completeAll()和React这样的UI库,我们可以构建我们之前看到的“Mark all as completed”的功能。
2.6 section2 小结:TypeScript可以处理数组和精确值
在本节中,我们已经了解到TypeScript可以处理数组和精确值。
- 我们可以通过添加
[]来指定一个数组类型。我们也可以将一个数组设置为只读的。 - 我们可以使用字面量类型来准确地指定一个属性允许使用的值。
- 最后,我们了解到,我们可以使用交叉类型来覆盖一些属性,用来消除重复的代码。
在下一节(也是最后一节),我们将看一下TypeScript最强大的功能之一,联合类型Unions。
Section3 联合类型和可选属性(Union Types and Optional Properties)
让我们为我们的todo应用程序添加一个新功能位置标签。
每个待办事项现在都可以选择用以下预定义的标签之一来标记。
每个待办事项也可以用自定义的、用户定义的标签来标记。
用户可以创建任何他们想要的自定义地点标签。
用户可以使用这一功能来确定哪些任务需要在家里、在工作场所或其他地方完成。它是可选的,所以可以有一个没有地点标签的todo项目。
下面是一个示例:
让我们看一下相关的数据。每个todo现在可以有一个可选的place属性,它决定了place标签。
对于自定义地点,地点属性将是一个包含字符串自定义属性的对象。
如果一个项目没有地方标签,那么地方属性也可以没有。
下面是我们前面例子的相关数据。
[
{
id: 1,
text: 'Do laundry',
done: false,
place: 'home'
},
{
id: 2,
text: 'Email boss',
done: false,
place: 'work'
},
{
id: 3,
text: 'Go to gym',
done: false,
place: { custom: 'Gym' }
},
{
id: 4,
text: 'Buy milk',
done: false,
place: { custom: 'Supermarket' }
},
{ id: 5, text: 'Read a book', done: false }
]
为了在TypeScript中实现这一点,我们首先需要更新我们对Todo类型的定义。让我们接下来看看这个吧。
3.1 联合类型
为了实现位置标签,我们可以使用TypeScript的一个功能,即联合类型。
在TypeScript中,你可以使用语法A | B来创建一个联合类型,它表示一个类型是A或B。
例如,如果你创建一个等于 number|string 的类型,它可以是数字或字符串。
// Creates a union type of number and string
type Foo = number | string
// You can assign either a number or a string
// variable to Foo. So these will both compile:
const a: Foo = 1
const b: Foo = 'hello'
在我们的todo应用程序中,我们将首先创建一个新的Place类型,作为一个联合类型,如下所示。
下面是一个使用Place类型的例子。
type Place = 'home' | 'work' | { custom: string }
// They all compile
const place1: Place = 'home'
const place2: Place = 'work'
const place3: Place = { custom: 'Gym' }
const place4: Place = { custom: 'Supermarket' }
我们现在可以把Place类型分配给Todo的地点属性。
3.2 可选属性
我们前面简单地提到,像家或工作这样的地点标签是可选的--我们可以有没有地点标签的todo项目。
在我们之前的例子中,"读一本书 "没有任何地点标签,所以它没有任何地点属性。
TypeScript 能否描述这些可选属性?当然可以。在TypeScript中,你可以在属性名称后面添加一个问号(?)来使该属性成为可选项。
type Foo = {
// bar is an optional property because of "?"
bar?: number
}
// These will both compile:
// bar can be present or missing
const a: Foo = {}
const b: Foo = { bar: 1 }
在我们的例子中,不使用place: Place,我们可以使用place?: Place来使其成为可选项。
type Place = 'home' | 'work' | { custom: string }
type Todo = Readonly<{
id: number
text: string
done: boolean
// place is optional
place?: Place
}>
现在,我们准备在一个函数中使用这些类型。
3.3 实现placeToString()
正如上文所说,React或Vue等UI库将数据转化为UI。
对于地方标签,我们需要将每个地方的数据转化为地方标签用户界面。
要做到这一点,我们想实现一个叫做placeToString()的函数,它的输入和输出如下。
输入应该是一个地方。例如:'work'。
返回值应该是一个字符串(有一个表情符号),将用于标签用户界面。
下面是示例。
然后,我们可以使用它的返回值来渲染地方标签UI: 家庭、工作、健身房等等地方。例如,在React中,你可以定义一个功能组件并在其中调用placeToString()。
现在让我们来实现placeToString()。下面是启动代码--你能弄清楚里面的内容吗?
function placeToString(place: Place): string {
// Takes a Place and returns a string
// that can be used for the place label UI
}
3.4 小黄鸭的实现
(注:请进入原文进行操作)
编译失败了! TypeScript注意到,这里有一个逻辑错误。具体来说,在else里面,TypeScript知道place是'work'或者{ custom: string }。
情况是这样的。
在else里面,place不是'work'就是{ custom: string }。
而place.custom在place为'work'时是无效的。这就是为什么TypeScript给了你一个编译错误。
当然,修复方法是添加else if (place === 'work')。
不用担心,小黄鸭! TypeScript能够及早发现这个错误。
摘要:正如我们刚才所看到的,联合类型在与条件语句(如if/else)结合时很强大:
- 如果我们有一个联合类型的变量(比如说
place)... - 并在
if/else中检查其值... - 那么TypeScript就会对变量在
if/else的每个分支的可能值进行智能处理。
这就是全部! 让我们快速总结一下我们所学到的东西。
3.5 section3 小结
在本节中,我们已经了解了联合类型和可选属性。
1. 我们可以使用语法A | B来创建一个联合类型,它表示一个要么是A要么是B的类型。
2. 我们可以在属性名称后面添加一个问号(?),使该属性成为可选属性。
最后,当联合类型与条件语句(如if/else)相结合时,其功能非常强大。
如果我们有一个联合类型的变量(例如:place)...
并在if/else中检查其值...
那么TypeScript就会对变量在if/else的每个分支的可能值进行智能处理。
联合类型是TypeScript的最佳理念之一。你应该经常使用它们。
联合类型还有其他强大的功能(区分的联合,将它们与映射类型相结合,等等),我不会在这里介绍。
4. 结论和下一步
谢谢你的阅读! 现在你应该知道足够的TypeScript知识,可以开始做一个项目了。
- 如果你正在使用React,React+TypeScript Cheatsheets是一个很好的参考。
- 接下来要学习的内容:一旦你对TypeScript更加熟悉,接下来你应该学习泛型。我写了一篇文章,叫做 TypeScript Generics for People Who Gave Up on Understanding Generics。