十一、类型检查模式
为了让应用程序能够正常工作并忘记任何问题,您需要一种方法来确保应用程序的所有部分都相互匹配。构建在 JavaScript 或 ECMAScript 之上的语言,如 Flow 或 TypeScript,将类型系统带到应用程序中。由于这些,您将知道没有人向您的函数或组件发送错误的数据。我们已经将PropTypes
用于组件中的断言。现在我们将把这个概念应用于任何 JavaScript 变量。
在本章中,您将了解以下内容:
- 类型系统的基础
- 如何为函数和变量分配类型
- 合同测试是什么;例如,Pact 测试
- 泛型和联合类型
- 关于如何解决类型问题的提示
- 类型系统如何使用命名类型和结构类型
类型简介
在 ECMAScript 中,我们有七种隐式类型。其中六个是原语。
作为基本体的六种数据类型如下所示:
- 布尔型。
- 数字
- 一串
- 无效的
- 未定义。
- Symbol—ECMAScript 中引入的唯一标识符。其目的是保证独特性。这通常用作对象中的唯一键。
第七类是对象。
Functions and arrays are also objects. Generally, anything that is not a primitive type is an object.
无论何时为变量赋值,类型都会自动确定。根据类型,有一些规则适用。
基元函数参数按值传递。对象是通过引用传递的。
Every variable is stored in memory in the form of zeros and ones. Passing by value means that the called function parameter will be copied. This means the creation of a new object that has a new reference. Passing by reference means passing just the reference to the object—if somebody makes changes to the referenced memory, then it will affect everyone who uses this reference.
让我们看一下按值传递机制的示例:
// Passing by value
function increase(x) {
x = x + 1;
return x;
}
var num = 5;
increase(num);
console.log(num); // prints 5
num
变量尚未更改,因为在函数调用时复制了该值。x
变量引用了内存中的一个全新变量。现在让我们看一个类似的例子,但有一个对象:
// Passing by reference
function increase(obj) {
obj.x = obj.x + 1;
return obj;
}
var numObj = { x: 5 };
increase(numObj);
console.log(numObj); // prints { x: 6 }
这次,我们将numObj
对象传递给函数。它已通过引用传递,因此未被复制。当我们改变obj
变量时,它会从外部影响numObj
。
但是,当我们调用前面的函数时,我们不会检查类型。默认情况下,我们可以传递任何内容。如果我们的函数无法处理传递的变量,那么它将因某种错误而中断。
让我们来看看使用increase
函数时可能出现的隐藏和意外行为:
function increase(obj) {
obj.x = obj.x + 1;
return obj;
}
var numObj = { x: "5" };
increase(numObj);
console.log(numObj); // prints { x: "51" }
当我们添加"5"
和1
时,increase
函数计算51
。JavaScript 就是这样工作的,它通过隐式类型转换来执行操作。
我们是否有办法防止这种情况发生,并避免开发人员出现意外错误?是的,我们可以执行运行时检查以重新评估变量是否属于某一类型:
// Runtime checking if obj.x is a number
function increase(obj) {
if (typeof obj.x === 'number') {
obj.x = obj.x + 1;
return obj;
} else {
throw new Error("Obj.x must be a number");
}
}
var numObj = { x: "5" };
increase(numObj);
console.log(numObj); // do not print, an Error message is shown
// Uncaught Error: Obj.x must be a number
运行时检查是在计算代码时执行的检查。它是代码执行阶段的一部分,会影响应用程序的速度。在本章后面的“解决运行时验证中的问题”一节中,我们将更仔细地研究运行时检查。
当抛出Error
消息时,我们还需要使用错误边界来替换组件,或者使用一些try{}catch(){}
语法来处理异步代码错误。
If you did not read this book from the beginning, then you may find it handy to go back to Chapter 2, View Patterns, to learn more about error boundaries in React.
但是,我们没有检查obj
变量是否为Object
类型!可以添加这样的运行时检查,但让我们看看更方便的 TypeScript,它是一种构建在 JavaScript 之上的类型检查语言。
打字机简介
TypeScript 将类型带到我们的代码中。我们可以明确表示函数只接受特定变量类型的要求。让我们看看如何将上一节中的示例用于 TypeScript 中的类型:
type ObjXType = {
x: number
}
function increase(obj: ObjXType) {
obj.x = obj.x + 1;
return obj;
}
var numObj = { x: "5" };
increase(numObj);
console.log(numObj);
这段代码不会编译。静态检查将退出,并显示一个错误,说明代码库已损坏,因为类型不匹配。
将显示以下错误:
Argument of type '{ x: string; }' is not assignable to parameter of type 'ObjXType'.
Types of property 'x' are incompatible.
**Type 'string' is not assignable to type 'number'.**
打字稿把我们当场抓住了。我们需要修正这个错误。在开发人员修复错误之前,此类代码永远不会到达最终用户。
配置类型脚本
为方便起见,我在我们的存储库中配置了 TypeScript。您可以在代码文件的src/Chapter 11/Example 1
下进行检查。
有几件事我想让你明白
TypeScript 自带了自己的配置文件,名为tsconfig.json
。在这个文件中,您将发现多个配置属性,它们控制 TypeScript 编译器的严格程度。您可以在的官方文档中找到详细的属性列表和解释 https://www.typescriptlang.org/docs/handbook/compiler-options.html 。
在这些选项中,您可以找到outDir
。这将指定编译器输出应保存的位置。在我们的存储库中,它被设置为"outDir": "build/dist"
。从现在起,我们的应用程序将从build/dist
目录运行编译后的代码。因此,我将根App.js
文件更改如下:
// src/ Chapter_11/ Example_1_TypeScript_support/ App.js
import StandaloneApp from './build/dist/Root';
import StoryBookApp from './build/dist/storybook';
// ...
export default process.env['REACT_NATIVE_IS_STORY_BOOK'] ? StoryBookApp : StandaloneApp;
既然您了解了配置更改,我们现在可以继续学习基本类型。
学习基本类型
为了充分利用 TypeScript,您应该尽可能多地键入代码。但是,我们的应用程序以前没有类型。在大型应用程序的情况下,显然不能在任何地方突然添加类型。因此,我们将逐步增加应用程序类型的覆盖范围。
TypeScript's list of basic types is quite long—Boolean, number, string, array, tuple, enum, any, void, null, undefined, never, and object. If you are unfamiliar with any of the them, then kindly please check the following page: https://www.typescriptlang.org/docs/handbook/basic-types.html.
首先,让我们看看我们使用的其中一个组件:
import PropTypes from 'prop-types';
export const NavigateButton = ({
navigation, to, data, text
}) => (
// ...
);
NavigateButton.propTypes = {
// ...
};
现在我们将切换到 TypeScript。让我们从Prop
类型开始:
import {
NavigationParams, NavigationScreenProp, NavigationState
} from 'react-navigation';
type NavigateButtonProps = {
to: string,
data: any,
text: string,
navigation: NavigationScreenProp<NavigationState, NavigationParams>
};
在这些小例子中,我们定义了NavigationButton
道具的结构。data
道具属于any
类型,因为我们不控制传递的数据类型。
navigation
道具使用react-navigation
库定义的类型。这对于重用已经公开的类型至关重要。在项目文件中,我使用yarn add @types/react-navigation
命令安装了react-navigation
类型。
我们可以继续向NavigationButton
添加类型:
export const NavigateButton:React.SFC<NavigateButtonProps> = ({
navigation, to, data, text
}) => (
// ...
);
// Full example available at
// src/ Chapter_11/ Example_1/ src/ common/ NavigateButton/ view.tsx
SFC
类型由 React 库导出。它是一个泛型类型,可以接受任何可能的道具类型定义。因此,我们需要指定它是什么类型的道具:SFC<NavigateButtonProps>
。
就是这样,我们还需要删除底部的旧NavigateButton.propTypes
定义。从现在起,TypeScript 将验证传递给NavigateButton
函数的类型。
枚举和常量模式
在我所见过的任何代码库中,有一个概念长期以来都受到赞扬:常量。它们节省了太多的价值,以至于几乎所有人都同意必须定义包含特定常量值的变量。相反,如果我们将它复制到我们需要它们的每个地方,那么更新值就会困难得多。
有些常量需要灵活,因此,明智的开发人员会将它们提取到配置文件中。这些文件存储在代码库中,有时以多种不同的风格存储(例如,对于测试:dev
、质量保证和生产环境)。
在许多情况下,我们定义的常量只允许一组常量有效值。例如,如果要定义可用环境,则可以创建一个列表:
const ENV_TEST = 'environment_test';
// ...
const availableEnvironments = [ENV_TEST, ENV_DEV, ENV_QA, ENV_PROD]
在老式的 JavaScript 编程中,您只需switch-case
环境并将相关信息传播到应用程序中的特定对象。如果环境未被识别,那么 If 将落入默认子句,它通常只会抛出一个错误,称为“unrecogned environment”,并关闭应用程序。
如果您假设在 TypeScript 中,您不需要检查这些内容,那么您就错了。无论您从外部消费什么,都需要运行时验证。您不能允许 JavaScript 自己失败,并以不可预知的方式破坏应用程序。这是一个经常被忽视的巨大“陷阱”。
您可能遇到的最常见问题之一是 API 更改。如果您期望http://XYZ
端点返回带有tasks
键的 JSON,而您没有验证真正返回给您的内容,那么您就有麻烦了。例如,如果一个单独的团队决定将密钥更改为projectTasks
,并且没有意识到您对tasks
的依赖,那么肯定会导致问题。我们如何解决这个问题?
API 上的预期返回值很容易实现。很久以前,合同测试这个术语就被开发出来了。这意味着在前端和后端系统中创建一个契约。如果不确保两个代码库都已准备就绪,则无法更改合同。这通常是由一些自动化工具强制执行的,其中一个可能是 Pact 测试。
“契约(名词):
个人或当事人之间的正式协议。该国与美国谈判达成了一项贸易协定。
同义词:协议、协议、交易、合同”
-牛津字典(https://en.oxforddictionaries.com/definition/pact)。
如果您正在寻找一种以编程方式实施此功能的方法,请查看https://github.com/pact-foundation/pact-js 。这个主题很难,还需要后端语言的知识,因此不在本书的范围之内。
一旦我们 100%确定外部世界的数据得到验证,我们可能希望确保我们自己的计算不会导致变量的改变(例如,通过不变性,请参见第 9 章函数编程模式的元素),或者如果预期会发生改变,它将始终保留允许集的值。
这时 TypeScript 就派上了用场。您可以确保您的计算将始终导致一个允许的状态。您不需要任何运行时验证。TypeScript 将使您免于不必要的检查,大量检查可能会导致应用程序速度降低几毫秒。让我们看看如何做到这一点:
// src/ Chapter_11/
// Example_2/ src/ features/ tasks/ actions/ TasksActionTypes.ts
enum TasksActionType {
ADD_TASK = 'ADD_TASK',
TASKS_FETCH_START = 'TASKS_FETCH_START',
TASKS_FETCH_COMPLETE = 'TASKS_FETCH_COMPLETE',
TASKS_FETCH_ERROR = 'TASKS_FETCH_ERROR',
TASK_FETCH_START = 'TASK_FETCH_START',
TASK_FETCH_COMPLETE = 'TASK_FETCH_COMPLETE',
TASK_FETCH_ERROR = 'TASK_FETCH_ERROR'
}
我们定义了一个enum
类型。如果变量预期为TasksActionType
类型,则只能为其分配前面enum TasksActionType
中的值。
我们现在可以定义AddTaskActionType
:
export type TaskAddFormData = {
name: string,
description: string
}
export type AddTaskActionType = {
type: TasksActionType.ADD_TASK,
task: TaskAddFormData
};
它将在addTask
动作创建者中使用:
// src/ Chapter_11/
// Example_2/ src/ features/ tasks/ actions/ TaskActions.ts
const addTask = (task:TaskAddFormData): AddTaskActionType => ({
type: TasksActionType.ADD_TASK,
task
});
现在,我们的动作创建者的类型检查非常好。如果任何开发人员错误地将type
对象键更改为任何其他键,例如TasksActionType.TASK_FETCH_COMPLETE
,则 TypeScript 将检测到该错误并显示不兼容错误。
我们有AddTaskActionType
,但我们如何将其与减速器可能接受的其他动作类型相结合?我们可以使用联合类型。
创建并集类型和交点
联合类型描述的值可以是多种类型之一。这非常适合我们的Tasks
减速器类型:
export type TaskReduxActionType =
AddTaskActionType |
TasksFetchActionType |
TasksFetchCompleteActionType |
TasksFetchErrorActionType |
TaskFetchActionType |
TaskFetchCompleteActionType |
TaskFetchErrorActionType;
使用**|**
运算符创建联合类型。它的工作原理就像它是|
是or
。一种或另一种。
我们现在可以在Reducer
函数中使用前面的类型:
// src/ Chapter_11/
// Example_3/ src/ features/ tasks/ state/ reducers/ tasksReducer.ts
const tasksReducer = (
state = Immutable.Map<string, any>({
entities: Immutable.List<TaskType>([]),
isLoading: false,
hasError: false,
errorMsg: ''
}),
action:TaskReduxActionType
) => {
// ...
}
为了让 TypeScript 满意,我们需要将类型添加到所有参数中。因此,我添加了其余的类型。其中一个仍然不见了:TaskType
。
In the preceding code example, you may be surprised by the Immutable.List<TaskType>
notation, and especially the < >
signs. Those need to be used because List
is a generic type. We will talk about generic types in the next section.
要创建TaskType
,我们可以将其类型写为:
type TaskType = {
name: string,
description: string
likes: number,
id: number
}
但是,这并不是重用我们已经创建的类型:TaskAddFormData
。你是否想这样做是另一个讨论的话题。让我们假设我们想要。
要重用现有类型并以所需形状声明或创建TaskType
,我们需要使用交叉点:
export type TaskAddFormData = {
name: string,
description: string
}
export type TaskType = TaskAddFormData & {
likes: number,
id: number
}
在本例中,我们使用&
交叉操作符创建了一个新类型。创建的类型是&
运算符左侧和右侧类型的交叉点:
An intersection diagram, where the intersection is the space that is both in circle A and in circle B
A和B的交点同时具有A和B的性质。因此,由类型A和类型B的交集创建的类型必须同时具有类型A和类型B类型。总而言之,TaskType
现在必须具有以下形状:
{
name: string,
description: string
likes: number,
id: number
}
正如你所见,十字路口可能很方便。有时,当我们依赖外部库时,我们不想像前面的示例那样硬编码键类型。让我们再看一遍:
type NavigateButtonProps = {
to: string,
data: any,
text: string,
navigation: NavigationScreenProp<NavigationState, NavigationParams>
};
导航键按我们的型号硬编码。我们本可以使用一个交叉点来满足外部库形状未来可能发生的变化:
// src/ Chapter_11/
// Example_3/ src/ common/ NavigateButton/ view.tsx
import { NavigationInjectedProps, NavigationParams } from 'react-navigation';
type NavigateButtonProps = {
to: string,
data: any,
text: string,
} & NavigationInjectedProps<NavigationParams>;
在本例中,我们再次使用<>
符号。这些是必需的,因为NavigationInjectedProps
是泛型类型。让我们了解泛型类型是什么。
泛型
泛型允许您编写处理任何类型对象的代码。例如,您知道列表是泛型类型。你可以列出任何东西。因此,当我们使用Immutable.List
时,我们必须指定列表将包含哪些类型的对象:
Immutable.List<TaskType>
任务列表。现在让我们创建自己的泛型类型。
在我们的代码库中,我们有一个 util,可以用于任何类型。这是一个单子。
If you have jumped to this chapter, then you may find it handy to read about monad patterns in Chapter 9, Elements of Functional Programming Patterns.
当变量恰好是该类型的null
、undefined
或Something
时,Maybe
单子为Nothing
。这非常适合泛型类型:
export type MaybeType<T> = Something<T> | Nothing;
const Maybe = <T>(value: T):MaybeType<T> => {
// ...
};
棘手的部分是实现Something<T>
和Nothing
。让我们从Nothing
开始,因为它更容易。值检查时应返回null
并始终映射到自身:
export type Nothing = {
map: (args: any) => Nothing,
isNothing: () => true,
val: () => null
}
Something<T>
应映射到Something<MappingResult>
或Nothing
。值检查应返回T
:
export type Something<T> = {
map: <Z>(fn: ((a:T) => Z)) => MaybeType<Z>,
isNothing: () => false,
val: () => T
}
映射结果类型通过使用map
函数签名中引入的Z
泛型类型进行保存。
但是,如果我们尝试使用新定义的类型,它们将不起作用。不幸的是,TypeScript 并不总是正确地识别联合类型。当类型的联合导致每个特定密钥具有不同的调用签名时,就会出现此问题。在我们的例子中,map
函数会发生这种情况。其类型为(args: any) => Nothing
或<Z>(fn: ((a:T) => Z)) => MaybeType<Z>
。因此,map
没有兼容的呼叫签名。
这个问题的快速解决方法是定义一个独立的MaybeType
,它满足两个相互冲突的类型定义:
export type MaybeType<T> = {
map: <Z>(fn: ((a:T) => Z)) => (MaybeType<Z> | Nothing),
isNothing: () => boolean,
val: () => (T | null)
}
有了这样的类型定义,我们可以继续使用新的泛型类型:
// src/ Chapter_11/
// Example_4/ src/ features/ tasks/ state/ selectors/ tasks.ts
export const tasksSelector =
(state: TasksState):MaybeType<Immutable.Map<string, any>> =>
Maybe<TasksState>(state).map((x:TasksState) => x.tasks);
选择器函数以TasksState
为参数,并期望返回分配给状态内tasks
键的映射。它可能看起来有点难以理解,因此,我强烈建议您打开上一个文件并进行更长时间的查看。如果您有困难,在本章末尾的进一步阅读部分中,我提到了 GitHub 上的一个问题,详细讨论了这一点。
理解打字脚本
在上一节中,如果您从未使用过类型系统,那么我们遇到了一个很难理解的问题。让我们了解一下 TypeScript 本身,以便更好地理解这一点。
类型推断
我想让你们理解的第一件事是类型推断。您不需要键入所有内容。某些类型可以通过 TypeScript 推断
想象一下,我告诉你,“我只在你桌子上的盒子里放了巧克力甜甜圈。”因为在这个例子中,我假装是电脑,你可以相信我。因此,当您到达您的办公桌时,您 100%确定箱子是Box<ChocolateDonut[]>
类型的。你知道这一点,但没有打开盒子,或者上面有明确的标签,上面写着盒子装满了巧克力甜甜圈。
在实际代码中,它的工作原理非常相似。让我们看一下下面的最小示例:
const someVar = 123; // someVar type is number
这是微不足道的。我们现在可以看看我更喜欢的东西,ChocolateDonuts
,如下所示:
enum FLAVOURS {
CHOCOLATE = 'Chocolate',
VANILLA = 'Vanilla',
}
type ChocolateDonut = { flavour: FLAVOURS.CHOCOLATE }
const clone = <T>(sth:T):T => JSON.parse(JSON.stringify(sth));
const produceBox: <T>(recipe: T) => T[] = <T>(recipe: T) => [
clone(recipe), clone(recipe), clone(recipe)
];
// box type is inferred
const box = produceBox<ChocolateDonut>({ flavour: flavours.CHOCOLATE });
// inferred type correctly contains flavor key within donut object
for (const donut of box) {
console.log(donut.flavour);
} // compiles and when run prints "Chocolate" three times
在本例中,我们同时使用了enum
和泛型类型。clone
简单地将任何类型克隆成一个全新的类型,并委托给 JSON 函数:stringify
和parse
。ProduceBox
函数只需获取一个配方并基于该配方创建一个克隆数组。最后,我们制作了一盒巧克力甜甜圈。已正确推断该类型,因为我们已为produceBox
指定了泛型类型。
结构分型
TypeScript 使用结构类型。为了理解这意味着什么,让我们看一下以下示例:
interface Donut {
flavour: FLAVOURS;
}
class ChocolateDonut {
flavour: FLAVOURS.CHOCOLATE;
}
let p: Donut;
// OK, because of structural typing
p = new ChocolateDonut();
在本例中,我们首先声明p
变量,然后为其分配一个新的ChocolateDonut
实例。它是用打字机写的。它在 Java 中不起作用。为什么?
我们从未明确指出ChocolateDonut
实现了Donut
接口。如果 TypeScript 未使用结构类型,则需要将前面代码的一部分重构为以下内容:
class ChocolateDonut implements Donut {
flavour: FLAVOURS.CHOCOLATE;
}
使用结构类型背后的原因通常被称为 duck 类型:
If it walks like a duck and it quacks like a duck, then it must be a duck.
因此,TypeScript 中不需要implements Donut
,因为ChocolateDonut
的行为已经像甜甜圈一样,所以它必须是甜甜圈。万岁!
TypeScript 的不变性
在本节中,我想重申关于不变性的一点。这个主题在 JavaScript 中非常重要,在某些情况下,TypeScript 可能是一个比任何其他不变性途径更好的解决方案。
TypeScript 附带了一个特殊的readonly
关键字,强制某个变量是只读的。你不能改变这样一个变量。这是在编译时强制执行的。因此,您没有针对不变性的运行时检查。如果这对您来说是一个巨大的胜利,那么您甚至可能不需要任何 API,比如 Immutable.js。当需要克隆大型对象以避免突变时,Immutable.js 将大放异彩。如果您可以自行执行扩展操作,则意味着您的对象可能不够大,无法容纳 Immutable.js。
只读
由于我们的应用程序还不是超级大,作为练习,让我们用 TypeScript 中的readonly
替换 Immutable.js:
export type TasksReducerState = {
readonly entities: TaskType[],
readonly isLoading: boolean,
readonly hasError: boolean,
readonly errorMsg: string
}
这看起来像是重复了很多次。我们可以使用Readonly< T >
代替:
export type TasksReducerState = Readonly<{
entities: TaskType[],
isLoading: boolean,
hasError: boolean,
errorMsg: string
}>
这看起来干净多了。然而,这并不是一成不变的。你仍然可以变异entities
数组。为了防止这种情况,我们需要使用ReadonlyArray<TaskType>
:
export type TasksReducerState = Readonly<{
entities: ReadonlyArray<TaskType>,
// ...
}>
剩下的工作是在整个应用程序中用ReadonlyArray<TaskType>
替换每个TaskType[]
。然后需要将 Immutable.js 对象更改为标准 JavaScript 数组。这样的重构很长,不适合这些书页,但我已经在代码文件中完成了重构。如果您想查看已更改的内容,请转到位于src/Chapter_11/Example_5
的代码文件目录。
使用 linter 强制实现不变性
您可以使用 TypeScript linter 在 TypeScript 文件中强制使用readonly
关键字。允许您这样做的开源解决方案之一是tslint-immutable
。
在tslint.json
配置文件中增加了额外的规则:
"no-var-keyword": true,
"no-let": true,
"no-object-mutation": true,
"no-delete": true,
"no-parameter-reassignment": true,
"readonly-keyword": true,
"readonly-array": true,
从现在开始,当您运行 linter 时,如果您违反上述任何规则,您将看到错误。我已经重构了代码以符合它们。在src/Chapter_11/Example_6
的代码文件目录中查看完整示例。要运行 linter,可以在终端中使用以下命令:
yarn run lint
总结
在本章中,您了解了一个非常强大的工具:基于 JavaScript 构建的类型化语言。类型检查对于任何代码库都有无数的优点。它阻止您部署一个绝对不符合预期的突破性更改。您已经学会了如何告诉 TypeScript 什么是允许的。您知道什么是泛型类型,以及如何使用它们来减少类型化文件中的代码重复。
新工具带来了新知识,因此您还学习了类型推断和结构类型的基础知识。TypeScript 的这一部分肯定需要反复试验。练习它以更好地理解它。
这是本书的最后一章。我希望您已经学习了许多有趣的概念和模式。在这本书中,我一直在挑战你;现在是挑战代码库的时候了。看看什么适合你的应用程序,也许重新考虑一下你和你的团队以前所做的选择。
如果您不了解某些模式,请不要担心。并非所有这些都是必须的。有些是有经验的,有些只适用于大型代码库,有些是偏好问题。
选择保证应用程序正确性的模式,以及使您能够更快地增加客户价值的模式。祝你好运
进一步阅读
- 精通打字(第二版),内森·罗森塔尔斯:这是一本深入学习打字的好书。它演示了如何键入一些真正高级的用例。这是我个人的推荐,不是出版商的。
- TypeScript 的官方文件可在www.typescriptlang.org上找到。
- 本章前面提到的关于呼叫签名问题的讨论可以在的 TypeScript GitHub 存储库中找到 https://github.com/Microsoft/TypeScript/issues/7294 。
版权属于:月萌API www.moonapi.com,转载请注明出处