如果你已经使用 React 一段时间,你会察觉 JavaScript 一些自由野性的天性让你难以驾驭(这当然不是 JS 的问题 ),当你和团队协作的时候,这个特点尤为显著。
或许你不知道,你需要 TypeScript,至少试一下。
我先声明一下,我喜欢 JavaScript 的自由,甚至有相当长的时间,我“反对”使用 TypeScript。
我想和你一起探索 TypeScript 是否值得使用,还只是适合那些不怎么会写代码的人(这是我们团队内部玩笑)。
本文旨在介绍 TS 的基础以便你了解它的优势,决定是否使用它。本文的第二部分会介绍在 React 中的 TS。
参考资料
为什么使用 ESLint、Prettier 和 Husky
何为 TypeScript
为什么要使用 TS
如何设置 TypeScript
购物清单项目示例
TypeScript 中的可选参数
TypeScript 中的类型推论
TypeScript 中的any
和 unknown
TypeScript 中的数组
TypeScript 中的对象
TypeScript 中的类型别名
TypeScript 模块
TypeScript 类型
TypeScript 中的函数
TypeScript 枚举
TypeScript 泛型
TypeScript 中的元组
TypeScript 中的类
TypeScript 中的接口
TypeScript 中的 DOM 操作
如何结合 React + TypeScript
useState hook
useReducer hook
useContext
useRef hook
React 内置类型
React 组件返回类型
结合模板字面量
如何使用Exclude
自定义 HTML 组件
设置
设置组件 Props 类型
定义 hook 的类型
传递 ref
如何在 React 中使用 TypeScript 泛型
定义自定义 useFetch Hook 类型
总结
你可以从以下样板着手:
如果你喜欢游戏编程,可以尝试 PhaserJS。你可以在浏览器通过创建游戏边玩边学 TypeScript 。
确保你也阅读了 TS 官方文档。里面包含大量有用的文档和案例。
另外还有两个示例项目,这样你就可以看到代码是如何实现的:
这是一个简单的体验 TypeScript 开发的项目,不需要 Webpack、React 以及任何其他组件,仅需要把 TypeScript 转换成 JavaScript。
借助 JikanAPI 我搭建了一个简单的结合 React 和 TypeScript 的应用,该应用提供一系列动画和基本信息,你可以观看你最喜欢的动画的最新预告。
在样板中我使用了 Airbnb 的 ESlint 规则、Prettier 建议规则以及 Husky 的提前提交(pre-commit)行为。团队协作的时候,这样可以促使大家遵循同样的代码规则,即便你是单人作业或者学习开发,这样操作也会对你的项目有所助益。
有些 Airbnb 的规则可能会有些奇怪,但是规则都有注解和示例,你可以以此来决定采不采用,如果想要关闭某个规则,可以放在.eslintrc
文件中。
这些规则对入门开发和刚刚开始使用 JS 或者 TS 的人来说非常有用。所以我建议你将它们纳入你的项目,尝试一下。
TypeScript 或者 TS 是由微软开发并且维护的开源语言,它具有以下特性:
浏览器不能解读 TS 代码,必须 转译 为 JS。JS 为动态类型映射值,而 TS 是静态类型,所以不易出错。
React 中已经是通过 Babel 转译 JS 了,所以转译代码并不是 TS 额外的优势。
问题就在这儿:为什么要使用 TS,JS 不好用吗?你用得不开心吗?你不怕麻烦吗?正如上文所述,过去我们团队内部会取笑像 TS 这样带类型的语言(当时我还在使用 Java)。我们团队会说如果你需要类型,证明你不会正确地写代码。
TypeScript、Java 以及其他一些语言具备静态类型,也就是会定义变量的类型。一旦你将变量定义为 string 或者 boolean ,你就不能改变它的类型。
而 JavaScript 拥有动态类型。也就是说,变量一开始是字符串,之后可以变为布尔值、数字或者任意你想要的值。变量类型会在运行时动态分配。
当你浏览网络上的 TS 代码,你会看到……(语法糖)。
回到我们团队的玩笑,当然这个说法没错:如果你知道自己在做什么,你不需要别人不断提醒你这是字符串,也只能是字符串,在某一刻它变成了布尔值或者其他类型……我知道自己在做什么!
真相是人非完人,总有这样的事情发生:
出于同样的原因,我们使用 IDE、IDE 插件、代码高亮、linter 而不是记事本应用。TypeScript 和这些辅助工具一样。
让我们看一看使用和不使用 TS 的一些对比示例:
// App.js
import { MemoryRouter as Router } from 'react-router-dom';
import Routes from './routes';
export default function App() {
return (
<Router basename="/my-fancy-app">
<Routes />
</Router>
);
}
你知道上面代码块的问题出在哪儿吗?如果知道的话,请给自己一朵大红花!
这个文件在我的样板中存在了很长时间,这并不是一个 bug,但是…… MemoryRouter
并不需要任何 basename
。它出现的原因是我之前使用了BrowserRouter
,所以需要basename
属性。
如果使用 TS 你会被提示 No overload matches this call
告诉你有这个属性的组件并没有被签名。
TypeScript 不仅可以使用静态类型,也可以帮助你决定是否需要其他的库。这里的库可以是第三方或者同事提供的组件和函数。
肯定会有一些声音——“了解正在使用的库不是必须么”,是的,你是对的。但是让参与项目的每个人都知道每个“外部”库以及版本的细微差别,可是艰巨的任务!
let isVerified = false;
verifyAmount();
// isVerified = "false"
if (isVerified) proceedPayment();
我被这个问题困扰了很多次。虽然每次不是一模一样的代码,一些细微的差别,但是你可以从这个示例中体会我的用意:你设置一个布尔值变量来决定一些代码运不运行,但很有可能其他人(或者你自己)后来将布尔值变成了字符串,而非空字符串为真值。
如果使用 TypeScript,会出现报错: The type 'string' is not assignable to the type 'boolean'
。代码在编译时就会出现这个报错,不需要等到运行时,那么在生产阶段出现这样的报错的机率非常低。
当然,和前文的规则一样,如果你正确编写代码,这个问题不会发生,如果你采用简洁代码的策略并且在编码的时候非常小心也可以避免这样的错误。TypeScript 并不是为了让我们偷懒,而是我们的好帮手,正如代码高亮可以帮助我们避免错误,找出不正常的变量。
const MONTH_SELECT_OPTIONS = MONTHS.map((month) => ({
label: getMonthName(month),
value: month
}));
export default function PaymentDisplayer() {
const [currentMonthFilter, setCurrentMonthFilter] = useState(
MONTH_SELECT_OPTIONS[0]
);
const onChangeHandler = (option) => {
setCurrentMonthFilter(option.value);
};
return (
<select onChange={onChangeHandler}>
{MONTH_SELECT_OPTIONS.map(({ label, value }) => (
<option key="value" value={value}>
{label}
</option>
))}
</select>
);
}
改变一个 state 的类型非常常见(虽然不建议这么做),有些时候会设置一个isError
标志变量,突然从布尔假值变成表示错误信息的字符串(也不建议这么做!)。还有一些时候是无意为之。
编写这段代码的人一开始认为 currentMonthFilter
会存储实际的选项,一个包含 label 和 value 的HTMLOptionElement
。之后,同样的开发在另一个天(或者另一个开发)创建了 changeHandler
函数并只设置了value
而不是整个选项。
上述代码可以运行,为了方便学习我也做了简化,但是假设项目规模更大,特别是组件的行为是由 props 传递的时候,问题就复杂得多。
使用 TypeScript 可以从两个方面解决这个问题:
currentMonthFilter
的{label: string, value: number}
改成number
时,静态类型会报错。所以使用 TypeScript,可以从 IDE 检查第三方库提供的函数、参数以及文档或者是同事编写的组件。
从上文例子中(可能不那么典型),我们可以得出,TypeScript 在 React 的环境中,可以帮助我们:
在本文中我们将使用全局安装。因为我认为第一次探索 TypeScript 应该不受到 Webpack、React 等其他变量的干扰,这样才能更加了解 TypeScript 是如何运行和处理问题的。
npm install -g typescript
#或
yarn install --global typescript
在系统中安装好 TypeScript 之后,就可以使用 TypeScript 的编译器,使用 tsc
命令行。
让我们通过简单配置编译器测试一下:
index.html
文件,文件内容是基础的 HTML5 结构。index.html
同一层,创建一个空的index.ts
文件。tsc --init
(假设你是全局安装 TypeScript),便会创建一个 tsconfig.json
文件。(我们将在下一章详细探讨这个文件)你的文件夹结构如下:
- index.html
- index.ts
- tsconfig.json
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
</html>
你需要在 HTML 中添加 TS 文件,但是浏览器并不理解 TypeScript,只认识 JavaScript,所以你可以将index.html
修改为:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
<script src="./index.js"></script>
</html>
打开一个新的终端,并输入tsc
,你的 index.ts
文件将转换为 index.js
,浏览器就可以理解。
为了不在每一次将 TS 文件转换为 JS 文件的时候都输入tsc
,你可以将 TypeScript 设置为监控模式,使用tsc -w
。
现在我建议你同时打开 TS 文件和 JS 文件,在index.ts
文件中输入普通的 JS,测试输出是什么。(我们将在接下来的章节大量使用这样的方法)。
tsconfig.json
里是什么跟着文章一边看一边实践的话,通过tsc --init
命令,你将创建 tsconfig.json
并包含默认配置和初始化的注解。
让我们看一看一些关键的属性:
target
是 TS 代码将要转换成的 JS 的版本。版本主要取决于支持的浏览器,你可能需要使用比较早期版本的 JS。这也是很好的学习资源,你可以修改不同版本来看生成什么样的 JS 代码。module
设置模块的语法。commonjs
默认使用require/module.exports
,现代 JS (ES6+)使用import/export
。如果你希望使用 import/export
,你需要将target
设置为 ES6 或更高。本文中的示例项目将使用这个语法。lib
你需要指定你在项目中额外使用的库,检查额外的类型,如 DOM 相关。jsx
如果使用 React,我们需要把这一项设置为preserve
,也就是由另一个工具(即 Babel)来编译这 JSX,TSC 仅用于检查类型。你也可以设置为react
或者react-native
。这个配置决定是否使用 TSC 将你的 JSX 代码转换为常规的 JS 代码。大多数情况,我们将这个属性设置为preserve
,将文件设置为常规的 JSX 并由 Babel 或者 Webpack 来处理编译工作。outDir
是编译后文件存储的地方,例如大部分 React 项目会被存放在build
文件。rootDir
是需要被编译的文件的位置,大部分 React 项目的位置为./src
。strict
开启一系列检查类型的规则,这些规则对"正确"的要求更为严格。我建议在学习阶段将它设置为 false,当你掌握得还不错了之后再开启。记住开启这个选项就是开启了 TS 的所有潜能,其中包含的一些选项你可以单独关闭。include
你想要编译的文件夹,如src
文件夹。exclude
你不想要编译的文件夹,如node_modules
文件夹。在示例中,我们将 rootDir
设置为 ./src
, outDir
设置为 public
文件夹。
项目示例很简单:你可以在购物清单中添加不同的物品、修改数量、删除物品以及查看需要买什么物品。
示例项目是为了让你适应 TypeScript 的工作流。一旦使用 React 环境,Webpack 和其他一些打包器就完成很多神奇的事情,所以我认为了解基础之后再接触 React 的打包器比较重要。
让我们来看看如何使用 TS 来获得一个更优并不容易出错的代码库。
你如果想使用 ES6 import/export
模块,你必须设置 tsconfig
:
并且在index.html
文件中增加模块类型:
<script type="module" src="app.js"></script>
注意使用模块有两个弊端:
在 JavaScript 中,类型在运行时分配。当编译器遇到变量和值时候再决定它们的类型是什么。这就意味着我们可以这样做:
let job = 'Warrior'; // 字符串
let level = 75; // 数字
let isExpansionJob = false; // 布尔值
level = 'iLevel' + 75;
// 现在是一个字符串
在 TypeScript 中,类型在编译时分配,一旦一个类型被定义就受到该签名的保护:
let job: string = 'Samurai';
let level: number = 75;
let isExpansionJob: boolean = true;
level = 'iLevel' + 75;
// Error, Type string cannot
// be assign to type number!
实际上不需要明确指定变量类型,TS 可以自行推断:
let job = 'Samurai';
let level = 75;
let isExpansionJob = true;
level = 'iLevel' + 75;
// Error, Type string cannot
// be assign to type number!
在接下来的 React 项目中,我们也会看到类似的推论,如在使用useState
的时候:
const [currentMonthFilter, setCurrentMonthFilter] = useState('January');
useEffect(() => {
setCurrentMonthFilter(1);
// Error, Type number cannot
// be assign to type string!
}, []);
我一直说 TS 有静态类型,但有一个细微的点需要说明:
let level: any = 10;
level = 'iLevel' + 125;
// OK, still type any
level = false;
// OK, still type any
欢迎回到 JavaScript!any
是一种动态类型,当你不知道这个变量在未来的值为何时可以使用,当然这也就放弃掉了 TS 提供的所有优势。
let level: any = 10;
level = 'iLevel' + 125;
level = false;
let stringLevel: string = level;
console.log(typeof stringLevel);
stringLevel.replace('false', 'true');
当你将 level
分配给stringLevel
时,变量类型并没有变成string
,而是保持布尔值。所以replace
函数并不存在,代码在运行时失效,你会收到报错:Uncaught TypeError: stringLevel.replace is not a function
。
在这种情况下我们可以使用比any
更安全的替换方案:
let level: unknown = 10;
level = 'iLevel' + 125;
level = false;
let stringLevel: string = level;
// Error
unknown
和any
一样,可以分配任何类型,当但你想要将它分配给另外一个变量时,编译器会报错。所以当你不知道变量未来是什么类型的值时,使用 unknown
而不是 any
。
let job = 'Red Mage';
let level = 75;
let isExpansionJob = false;
let jobAbilities = ['Chainspell', 'Convert'];
jobAbilities.push('Composure'); // OK
jobAbilities.push(2); // Error
jobAbilities[0] = 2; // Error
在上面例子中,我们定义了一个由字符串组成的数组:jobAbilities
,我们可以在这个数组中添加其他的字符串,但是不能添加其他类型的值,也不能将当前值转换成其他类型。因为在声明数组时,我们将类型推论设置为了 string[]
。
let job = 'Red Mage';
let level = 75;
let isExpansionJob = false;
let jobAbilities = ['Chainspell', 'Convert'];
let swordSkill = ['B', 5, 144, 398];
swordSkill.push('B+'); // OK
swordSkill.push(230); // OK
swordSkill[1] = 'C';
// OK, the type is not position related
swordSkill.push(true); // Error
和之前的例子一样,声明时类型推论就形成。所以我们声明了一个由字符串和数组组成的数组swordSkill
。
如果你希望指定声明数组的类型,可以:
let jobAbilities: string[] = ['Chainspell', 'Convert'];
let swordSkill: (string | number)[] = ['B', 5, 144, 398];
|
是 union
(联合声明)不同的类型。
让我们回到例子,不过这一次是以对象的形式:
let job = {
name: 'Summoner',
level: 75,
isExpansion: true,
jobAbilities: ['Astral Flow', 'Elemental Siphon']
};
job.name = 'Blue Mage'; // OK
job.level = 'Four'; // Error
job.avatars = ['Carbuncle']; // Error
job.level = "Four"
不可以实现,因为我们不可以修改属性的类型。对象的属性也是静态类型。job.avatars = ["Carbuncle"]
– 我们不能增加新的属性,因为 job
对象已经拥有一个类型和定义好的结构。let job = {
name: 'Summoner',
level: 75,
isExpansion: true,
jobAbilities: ['Astral Flow', 'Elemental Siphon']
};
job = {
name: 'Blue Mage',
level: 4,
isExpansion: true,
jobAbilities: ['Azure Lore', 'Burst Affinity']
}; // OK
job = {
name: 'Corsair',
level: 25,
isExpansion: true
}; // Error
我们可以分配另一个对象,因为对象是由 let
声明的,但必须是一模一样的形式。
停下来思考一下:有多少次你在前端的工作中,在没有检查的情况下,像这样重复对象结构?有多少次你犯过如data.descrption
这样的拼写错误,几天之后你发现问题?如果没有发生过,我保证这种问题迟早会发生。
让我们看看如何指定具体类型:
let job: {
name: string;
level: number;
isExpansion: boolean;
jobAbilities: string[];
} = {
name: 'Summoner',
level: 75,
isExpansion: true,
jobAbilities: ['Astral Flow', 'Elemental Siphon']
};
对于一个简单的对象来说,这样可能有一点复杂了,所以我们可以使用类型别名
(Type Aliases)。
type Job = {
name: string;
level: number;
isExpansion: boolean;
jobAbilities: string[];
};
let Summoner: Job = {
name: 'Summoner',
level: 75,
isExpansion: true,
jobAbilities: ['Astral Flow', 'Elemental Siphon']
};
let BlueMage: Job = {
name: 'Blue Mage',
level: 4,
isExpansion: true,
jobAbilities: ['Azure Lore', 'Burst Affinity']
};
使用类型别名可以定义可复用的常见类型。在 React、DOM 和其他的库中有很多这种即用型定义类型。
TS 中的函数语法和 JS 类似,但是你可以指定参数类型和返回类型。
type Enemy = {
name: string;
hp: number;
level: number;
exp: number;
};
let attack = (target: Enemy) => {
console.log(`Attacking to ${target.name}`);
};
attack = 'Hello Enemy'; // Error
在示例中我使用了箭头函数,你也可以使用普通的函数声明。JS 和 TS 函数的两大不同是:
target: Enemy
。attack
变量已经设定了返回类型,之后就不能修改。函数的类型可以这样声明:
let attack = (target: Enemy): void => {
console.log(`Attacking to ${target.name}`);
};
当返回值为空的时候可以用void
类型,也不需要指定特定类型。
// let attack = (target: Enemy): number => {
let attack = (target: Enemy) => {
return target.hp - 2;
};
any
和void
类型有一些不同:
let attack = (target: Enemy): void => {
console.log(`Attacking to ${target.name}`);
};
attack = (target: Enemy): number => {
return target.hp - 2;
};
// lizard has 200hp
console.log(attack(lizard)); // 198
上面的示例没有报错:即便你认为将 attack
从(target: Enemy) => void
变成了 (target: Enemy) => number
,类型实际上还是 void
。
尝试一下如果首先使用 number
来定义函数会怎么样:
let attack = (target: Enemy) => {
return target.hp - 2;
};
attack = (target: Enemy) => {
console.log(`Attacking to ${target.name}`);
}; // Error
let attackResult = attack(lizard);
Type '(target: Enemy) => void' is not assignable to the type '(target: Enemy) => number'
. Type 'void' is not assignable to the type 'number'
,所以在这个情况下 void
和 any
类似。
attackResult
的类型为 number
,没有必要重新指定,TS 会通过函数的返回类型完成推论。
使用 ?
来定义 TS 函数中的可选参数:
let heal = (target: Player | Enemy, spell: Spell, message?: string) => {
if (message) console.log(message);
return target.hp + spell.power;
};
heal(player1); // Error
heal(player1, cure, 'Healing player1'); // OK
heal(skeleton, cure); // OK
第一个函数调用不成功是因为我们必须至少传入两个参数,后面两次调用成功。message
是一个可选参数,如果没有传入的话,就被定义为undefined
。
这个示例转换为 JS 为:
let heal = (target, spell, message) => {
if (message) console.log(message);
return target.hp + spell.power;
};
heal(player1); // Error
heal(player1, cure, 'Healing player1'); // OK
heal(skeleton, cure); // OK
两个函数的基本行为一致,只是 JS 的问题会在运行时显示出来,第一个调用出错的原因是不可以从一个没有定义的值获取 power
。
从示例中我们可以发现,TS 的函数更安全,因为你不需要依赖外部环境,也清楚知道需要传入什么参数。
这对于其他使用这个函数的开发者说也是一样的,他们知道需要使用什么参数、形式以及会返回什么参数。
使用枚举,我们可以定义一个常量集合。
enum BattleMenu {
ATTACK,
MAGIC,
ABILITIES,
ITEMS,
DISENGAGE
}
enum Equipment {
WEAPON = 0,
HEAD = 1,
BODY = 2,
HANDS = 3,
LEGS = 4
}
console.log(BattleMenu.ATTACK, Equipment.WEAPON);
// 0 0
枚举默认是自动序列化的,示例中的两种声明方式对等。
也可以使用枚举来存储字符串,我常在 React 中使用枚举来存储路径:
enum Routes {
HOME = '/',
ABOUT = '/about',
BLOG = '/blog'
}
const getPartyLeader = (memberList: Player[]) => {
return memberList[0];
};
const partyLeader = getPartyLeader(partyA);
上面代码想要实现一个 getPartyLeader
函数,返回数组的第一位。
如果我们想要函数支持除 Player
以外的类型呢?根据我们所知的信息,可以采取这样的做法:
const getPartyLeader = (memberList: Player[] | Enemy[]) => {
return memberList[0];
};
const partyLeader = getPartyLeader(partyA);
// Player[] | Enemy[]
现在我们可以传入 Player
组也可以传入 Enemy
组,但是 PartyLeader
常数可以为两组中任意一种类型,所以使用 Player[] | Enemy[]
。
如果我们想要使用指定类型的话,也可以使用泛型:
const getPartyLeader = <T>(memberList: T[]) => {
return memberList[0];
};
const partyLeader = getPartyLeader(partyA); // Player
partyA
数组内是Player
类型, partyLeader
就为 Player
类型。让我们查看语法:
T
是通常定义泛型的方法,但是你可以采用任意你喜欢的方式。和使用 any
一样, T 接收任意类型。所以我们可以修改传入的参数类型:
type Player = {
name: string;
hp: number;
};
type Enemy = {
name: string;
hp: number;
};
type Spell = {
name: string;
power: number;
};
const getPartyLeader = <T extends { hp: number }>(memberList: T[]) => {
return memberList[0];
};
const playerPartyLeader = getPartyLeader(partyOfPlayers); // Ok
const enemyPartyLeader = getPartyLeader(partyOfEnemies); // Ok
const whatAreYouTrying = getPartyLeader(spellList); // Error
我们只能传入包含hp
属性的对象。
正如我们之前看到的,数组可以包含不同的类型但不受位置限制。元组可以补充这一点。
type Weapon = {
name: string;
damage: number;
};
type Shield = {
name: string;
def: number;
};
const sword: Weapon = {
name: 'Onion Sword',
damage: 10
};
const shield: Shield = {
name: 'Rusty Shield',
def: 5
};
let equipment: [Weapon, Shield, boolean];
equipment = [sword, shield, true]; // OK
equipment[2] = false; // OK
equipment = [shield, sword, false]; // Error
equipment[1] = true; // Error
这样我们就拥有了类数组的类型,它关心类型的放置位置。
由于从 ES6 开始 JS 中添加了类,TS 和 JS 的类大同小异:
class Job {
public name: string;
private level: number;
readonly isExpansion: boolean;
constructor(name: string, level: number, isExpansion: boolean) {
this.name = name;
this.level = level;
this.isExpansion = isExpansion;
}
}
const whiteMage = new Job('White Mage', 75, false);
console.log(whiteMage.name); // "White Mage"
console.log(whiteMage.level); // Error
console.log(whiteMage.isExpansion); // false
whiteMage.name = 'Blue Mage'; // Ok
whiteMage.level = 50; // Error
whiteMage.isExpansion = true; // Error
在 TS 类中,你可以访问类属性的修饰符(modifiers):
和 类型别名
(type aliases)相同,我们可以使用接口
(interfaces)来定义类:
interface Enemy {
name: string;
hp: number;
}
let attack = (target: Enemy): void => {
console.log(`Attacking to ${target.name}`);
};
是不是看上去和类型别名
很像?那应该使用哪一个呢?两种方法都可以控制不同类型的 TS,并且区别非常小:
你可以遵循以下规则来做取舍:
因此,我在样板中加入了 ESLint 自动将类型别名转换为接口。
如果想要深入了解两者的区别,可以阅读 TS 手册中的这篇文章 ,但现在很多使用接口的功能都可以使用类型别名,反之亦然。
虽然在 React 中直接操作 DOM 的机会不多,但是我觉得还是有必要知道 DOM 的相关知识。
// HTMLFormElement | null
const form = document.querySelector('form');
// HTMLElement | null
const otherForm = document.getElementById('myFancyForm');
// HTMLSelectElement
const select = document.createElement('select');
执行document.querySelector ("form")
时, 常量form
被类型推论为HTMLFormElement
或 null
。但在第二个例子中,我们通过 ID 来获取 dom,TS 并不知道是什么 HTML 元素,所以推论为泛型 HTMLElement
。
const form = document.querySelector('form');
form.addEventListener('submit', (e: Event) => {
e.preventDefault();
console.log(e);
}); // Error
TS 不知道是否能够通过查询选择器在 HTML 找到元素,所以不能对一个可能为 null 的类型添加 addEventListener
函数,你可以这样修改:
我确认会找到元素:
// HTMLFormElement
const form = document.querySelector('form')!;
使用 !
告诉 TS 放心,一定不会是null
。
如果不为 null 运行:
const form = document.querySelector('form');
form?.addEventListener('submit', (e: Event) => {
e.preventDefault();
console.log(e);
});
你可能在 JS 可选链式运算符中见过 “?”。
是铸造类型的时候了:
const otherForm = document.getElementById('myFancyForm') as HTMLFormElement;
otherForm.addEventListener('submit', (e: Event) => {
e.preventDefault();
console.log(e);
});
通过 HTMLFormElement
告诉 TS 会找到什么类型的元素,而不是 null
。
让我们进入文章的第二个部分,记住,在第一个部分我们探讨了为什么使用 TypeScript,如何使用,以及这个语言的概览。
在这个部分,我们将学习如何在 React 中使用 TypeScript,如何解决相应的难题,以及如何使用 React 和 TypeScript 共同创建一个应用。
对于 CRA 用户来说,你们只需要设定模板:
npx create-react-app my-awesome-project --template typescript
使用 Vite 创建 TypeScript 项目和使用 CLI 一样简单,只需要选择 TypeScript 模板:
npm create vite@latest my-awesome-project
如果你想要对已经存在的 JavaScript 项目添加 TypeScript,只需要添加对应开发依赖项:
npm install -D typescript
需要提醒你的事,如果是首次使用 TypeScript,不建议你从现有的项目着手。因为这样的话,你会不断地认为你已经有一个可以运行的项目了,而使用 TypeScript 不过是做些无所谓的工作。你没办法从中体会 TypeScript 的优势。
在 React 项目中使用 TypeScript 最常用的场景是编写组件 props。
想要正确地编写组件 props,必须定义清楚组件接受什么样的 props、props 类型以及是否是必要的。
// src/components/AnimeDetail/Cover/index.tsx
type CoverProps = {
url: string;
};
export default function Cover({ url }: CoverProps) {
// ...
}
我们只使用 url
prop ,类型为 string
并且是强制的。
另一个有多个 props 和可选项的例子:
// src/components/AnimeDetail/StreamingList/PlatformLink/index.tsx
type PlatformLinkProps = {
name: string;
url?: string;
};
export default function PlatformLink({ name, url }: PlatformLinkProps) {
// ...
}
使用 ?
来定义可选参数, TypeScript 知道在这个例子中url
的类型是 string
,默认值为undefined
,即便未传入url
,消费组件也不会报错。
让我们看一个更复杂的例子:
// src/components/AnimeDetail/Detail/index.tsx
type AnimeType = 'TV' | 'Movie';
type DetailProps = {
liked: boolean;
toggleFav: () => void;
title: string;
type: AnimeType;
episodeCount: number;
score: number;
status: string;
year: number;
votes: number;
};
export default function Detail({
liked,
toggleFav,
title,
type,
episodeCount,
score,
status,
year,
votes
}: DetailProps) {
// ...
}
这次包含众多类型,包括 function
和一个自定义类型 AnimeType
。
所以总结一下,使用 TS 来编写 props:
对于消费组件来说的 props 验证
不需要猜测组件需要什么
不需要打开组件源码来检查需要什么数据
自动填充和文档记录
直接从消费组件端知道自动填充的 prop 和 value,不需要提前浏览
如果是使用复杂的组件,或是从第三方库使用组件的,这一定可以派上用场。
在 React 和很多其他的库中,你会发现大量预置的类型,可以减轻开发者的编写负担。如以下示例:
// src/components/AnimeDetail/Detail/index.tsx
type AnimeType = 'TV' | 'Movie';
type DetailProps = {
liked: boolean;
toggleFav: () => void;
title: string;
type: AnimeType;
episodeCount: number;
score: number;
status: string;
year: number;
votes: number;
};
export default function Detail({
liked,
toggleFav,
title,
type,
episodeCount,
score,
status,
year,
votes
}: DetailProps) {
// ...
}
一个自定义的 React 组件,接受其他元素作为子元素,在这种情况下 children
被定义为ReactNode
类型。
你可以能会遇到这样定义组件 props 的语法:
type PlatformLinkProps = {
name: string;
url?: string;
};
const PlatformLink: React.FC<PlatformLinkProps> = ({ name, url }) => {
// ...
};
使用 React.FC
或者使用 React.FunctionComponent
时,上面的代码生效,但是你需要知道这样使用的弊端,也就是为什么在本文中我们不这样用:
children
属性,即便该组件并不使用这个属性。直到 React18 之后,这个问题才改善。最后一块拼图,组件返回什么?可以使用内置的类型:React.ReactElement
、 React.ReactNode
和 JSX.Element
:
export default function Favorites(): JSX.Element {
// ...
}
总结一下:让 TypeScript 自行推论返回类型。如果你需要这一部分的详细介绍,我推荐阅读这个来自 stack overflow 的帖子。
在动漫预告片项目中,我引入的一个自定义 UI 就是很好的示例。你可以查看src/components/UI
中的组件,其中大部分内容都会在本文讨论:
让我们看一下自定义组件Position
:
// src/components/UI/Position/index.tsx
import React from 'react';
import { StyledPosition } from './StyledPosition';
type VPosition = 'top' | 'bottom';
type HPositon = 'left' | 'right';
export type PositionValues = `${VPosition}-${HPositon}`;
type PositionProps = {
children: React.ReactNode;
position?: PositionValues;
};
export default function Position({
children,
position = 'top-right'
}: PositionProps) {
return <StyledPosition position={position}>{children}</StyledPosition>;
}
Position 是一个简单的组件,可以与任何其他具有绝对位置的组件一起使用,可以通过 top-left
, top-right
, bottom-left
和 bottom-right
将组件放置在四个边上。
使用字面量模板来创建新的类型并不新鲜,在这里有趣的地方是结合 ${VPosition}-${HPositon}
和联合类型 top
| bottom
,TypeScript 会自动生成所有组合,就可以创建我们需要的四种类型。
让我们给上面的例子添加更多值:
type VPosition = 'top' | 'middle' | 'bottom';
type HPositon = 'left' | 'center' | 'right';
export type PositionValues = `${VPosition}-${HPositon}`;
模板会创建所有可能的组合,所以我们将拥有 "top-left" | "top-center" | "top-right" | "top-left" | "center-left" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"
。
有一条比较奇怪:middle-center
,我们只需要center
,这时就可以使用 Exclude
:
type PositionValues =
| Exclude<`${VPosition}-${HPositon}`, 'middle-center'>
| 'center';
这时 PositionValues
会生成"center" | "top-left" | "top-center" | "top-right" | "middle-left" | "middle-right" | "bottom-left" | "bottom-center" | "bottom-right"
。
使用 exclude 删除middle-center
之后再添加center
。
如果你想创建一个行为类似input
的组件,但是你又不想编写 input HTML 的所有属性和方法,你可以这样:
// src/components/UI/Input/index.tsx
import React from 'react';
import styles from './StyledInput.module.css';
type InputProps = React.ComponentProps<'input'>;
const Input = React.forwardRef(
(props: InputProps, ref: React.Ref<HTMLInputElement>) => {
return <input {...props} className={styles.StyledInput} ref={ref} />;
}
);
export default Input;
使用React.ComponentProps
,你可以指定你需要基于什么类型创建一个组件,获取一个真正的 HTML input 的功能来自定义 UI 组件。但如果你想覆盖掉一些属性甚至禁用怎么办?
让我们以 UI 组件中的 Tag
为例:
// src/components/UI/Tag/index.tsx
import React from 'react';
import { StyledTag } from './StyledTag'; // aka a styled span
type TagProps = {
variant?: 'solid' | 'outlined';
text: string;
} & Omit<React.ComponentProps<'span'>, 'children'>;
export default function Tag({ text, variant = 'solid' }: TagProps) {
return <StyledTag variant={variant}>{text}</StyledTag>;
}
在这个例子中,我们显式地传递了一个text
展示组件的 children
。你或许不希望消费组件使用原始的children
,你可以忽略掉 React.ComponentProps
中的这个属性。
现在让我们看一下如何编写 React 中一些常用的 hook。
大多数情况下,useState
不需要额外的操作,TypeScript 会自行推论。但是如果初始值和未来值不同则需要特别声明。
// src/pages/Search.tsx
export default function Search() {
const [animeList, setAnimeList] = useState<Anime[] | null>(null);
const [page, setPage] = useState(1);
// const [page, setPage] = useState<number>(1)
// ...
}
page
的状态可以根据初始值推论为数字,它的行为和注解里的代码一模一样。state 的 setter 也会定义为 React.Dispatch<React.SetStateAction<number>>
, number
来替换推论的类型。
如果animeList
没有任何显式类型的话就为 null
。在组件获取必要的数据之前这都是正确的,但是最终会是包含 Anime
类型集合,所以必须将这个类型显式地设置为这两个可能类型的组合。
除了给 useState 的初始 state 设置为 null 以外,还可以:
export default function Search() {
// const [animeList, setAnimeList] = useState<Anime[] | null>(null)
const [animeList, setAnimeList] = useState<Anime[]>([]);
const [anime, setAnime] = useState<Anime>({} as Anime);
// ...
}
请仔细观察 anime, setAnime
代码行,它之所以生效是因为这不是一个集合,而是单个元素。这里的区别在于你对编译器没有完全诚实,你预设会得到这个对象形状(shape)的值,有一定风险。
export default function Search() {
const [anime, setAnime] = useState<Anime>({} as Anime);
// ...
return <div>{anime.coverURL}</div>;
}
如果没有在可选项中提供正确的值,会在运行时报错。
多数情况下,是由上至下传递 state,并且等 state 完成或者设置好了才能传递到子组件,所以需要在编写 props 的时候想好状态的类型。
type FancyComponentProps = {
anime: Anime;
setAnime: React.Dispatch<React.SetStateAction<Anime>>;
};
const FancyComponent = ({ anime, setAnime }: FancyComponentProps) => {
// ...
};
最好清楚自己传递的是什么类型。如果你觉得困难的话,可以使用 IDE 的提示。
你已经具备正确定义 useReducer
所需的所有知识。
下面的例子中我简化了代码,正式代码会在泛型部分讲解:
// src/hooks/useFetch.ts
const enum ACTIONS {
LOADING,
FETCHED,
ERROR
}
type State = {
data?: Anime[];
loading: boolean;
error?: Error;
};
type Action =
| { type: ACTIONS.LOADING }
| { type: ACTIONS.FETCHED; payload: Anime }
| { type: ACTIONS.ERROR; payload: Error };
const initialState: State = {
loading: true,
error: undefined,
data: undefined
};
const fetchReducer = (state: State, action: Action): State => {
switch (action.type) {
case ACTIONS.LOADING:
return { ...initialState };
case ACTIONS.FETCHED:
return { ...initialState, data: action.payload, loading: false };
case ACTIONS.ERROR:
return { ...initialState, error: action.payload, loading: false };
default:
return state;
}
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
和往常一样, status
和 dispatch
来自于使用 useReducer
时的 reducer function
以及一个 initial state
。你不需要额外编写其他内容,但是你必须编写state
和 actions
,因为 state 和 dispatch 的行为是根据它们来的。
我们可以简化initial state
这个部分。不是创建一个State
类型,而是使用 typeof initialState
来定义。
const initialState: State = {
loading: true,
error: undefined,
data: undefined
};
const fetchReducer = (state: typeof initialState, action: Action) => {
// ...
};
这样写的弊端是无法控制未来的 data
和error
的值,如果 state 的类型始终保持一致的话,没有问题,除此之外可以使用自定义 State
类型。
你可以使用组合(union)来处理 reducer 的 action 部分,你也可以选择使用枚举(Emun),这样写的话比在多个地方写字符串要更不容易出错。
你只需要指定传入函数的参数的类型,这个已经在上一部分完成。
另外,如果你需要从 useReducer 传出 prop,你必须编写对应的消费组件 props。
state
必须是你定义在 initialState
的类型,或者是例子中的自定义 State
类型。dispatch
是 React.Dispatch<Action>
中自定义 Action
类型。示例项目中的上下文用于管理你喜欢动漫的列表,你可以在应用不同地方切换它们的 state。useContext
并不是一个难点,使用它的方法就是上述内容的结合——让我们一起看代码:
// src/context/FavContext.tsx
type FavContextType = {
favList: Favorite[];
// setFavList: React.Dispatch<React.SetStateAction<Favorite[]>>
toggleFav: (id: number, favorite: Favorite) => void;
};
export const FavContext = createContext({} as FavContextType);
export const FavContextProvider = ({ children }: FavContextProviderProps) => {
const [favList, setFavList] = useState<Favorite[]>([]);
const toggleFav = (id: number, favorite: Favorite) => {
/* ... */
};
// ...
return (
<FavContext.Provider value={{ favList, toggleFav }}>
{children}
</FavContext.Provider>
);
};
useContext
和 useState
定义类型的规则相同。在我们的示例中,初始值应该为 null,但是我们使用了一个小技巧,在createContext
添加as
,并且定义了一个对象,包含一个favourite animes
数组,和负责切换的函数。
注解部分是你根据场景需要的特定 setter。
接下来的代码都是你在 useState
中学过的内容。在 Favorite
类型中,useState 会推断必要的类型,这些类型可以直接在消费组件中访问。
// src/components/AnimeDetail/index.tsx
const { favList, toggleFav } = useContext(FavContext);
可以通过两种方式来使用 useRef
,我们将分别讲解。
其中一个方式是使用useRef
保持一个 DOM 元素的引用 。
在示例项目中,通过持有对动画列表中最后一项的可观察对象的引用,你会发现它可以无限滚动。这让你知道用户何时在视口中查看该项目并触发新的获取。
让我们查看使用 useRef 来引用 DOM 一个更简短的示例,你也可以查看 useRef + observer 的完整版本:
const myDomReference = useRef<HTMLInputElement>(null);
useEffect(() => {
if (myDomReference.current) myDomReference.current.focus();
}, []);
一个典型的情况是当页面加载的时候,你希望自动聚焦在输入框,只需要指定好引用的 DOM 元素,在这个示例中就是HTMLInputElement
。
使用上面代码需要注意的是:
current
属性。current
,React 会通过React.RefObject<HTMLInputElement>
处理。null!
以避开 if 检查。useRef
的第二个使用场景是在渲染之间保持可变值。例如,在你需要为组件的每个实例提供一个唯一变量的情况下,该变量在渲染之间存在并且不会触发重新渲染。
const isFirstRun = useRef(true);
useEffect(() => {
if (isFirstRun) {
// ...
isFirstRun.current = false;
}
}, []);
使用上面代码,你需要注意的是:
current
的值。React.MutableRefObject<boolean>
现在是RefObject
内部的MutableRefObject
(可变引用对象)。如果在某些时候你需要像 useRef 部分那样传递对 HTML 元素的引用,那么为该组件编写 props 会略有不同:
// src/components/AnimeGrid/Card/index.tsx
const Card = React.forwardRef(
(
{ id, coverURL, title, status, score, type, year }: CardProps,
ref: React.Ref<HTMLImageElement>
) => {
// ...
}
);
要传递引用,需要用React.forwardRef
打包组件,这将与组件的常规 props 一起注入 ref
(是包装在 React.Ref
类型中的任何 HTML 元素)。
这样我们就知道我们传递的 HTML 元素的类型,如果你的使用场景不是这样,可以使用泛型。
假设我们想通过包装现有的 HTML 元素来创建自定义 UI 组件,但像大多数组件库一样为它提供一组自定义属性。
这些组件库也提供一些灵活性,如哪一个 HTML 元素被渲染由 as
属性控制 – Text
UI 组件的示例就是这样。
Text UI 组件用来显示一组尺寸和颜色的任意文本,同时我们也希望使用者选择他们需要的 HTML 元素,而不是只限定于 p
或 span
。
在这个示例中,你不能提前知道用户会选择什么元素传入组件,所以你需要使用泛型来推断它们使用了什么类型。
所以组件的 prop 如下:
// src/components/UI/Text/index.tsx
type TextOwnProps<T extends React.ElementType> = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
variant?: 'base' | 'primary' | 'secondary';
as?: T | 'div';
};
type TextProps<T extends React.ElementType> = TextOwnProps<T> &
React.ComponentPropsWithoutRef<T>;
export default function Text<T extends React.ElementType = 'div'>({
size = 'md',
variant = 'base',
children,
as = 'div'
}: TextProps<T>) {
// ...
}
让我们仔细查看上面代码:
T
来命名泛型,你也可以使用任意字母。React.ElementType
,即 HTML 元素的最通用的类型。所以我们知道传递给组件的任何东西都是基于 HTML 元素,而不是所有可能的 HTML 元素的手动类型的组合。TextProps
的第二个类型有两个用途:label
时,我们希望检查并建议与为span
时不同的属性。为此,我们需要使用 React.ComponentProps
。在这种情况下,我们不需要引用,因此我们显式使用ComponentPropsWithoutRef
类型。React.ComponentProps
同时也提供 children
prop,所以不需要在 TextOwnProps
引入。Omit
或者其他排除技术,因为 children
并没有被 TextOwnProps
props 修改或者覆盖。通过这个例子,我们有一个非常灵活的组件,它的类型正确并且提供了良好的开发者体验。
在示例项目中,你可以测试不同的自定义 UI 组件,来检查上述模式的实现。
在示例项目中,我编写了一个简单的钩子来获取数据,并且利用localStorage
来暂时缓存数据,这样就不会超出 API 的限制。这没什么大不了的,但我认为它是本文中解释的所有内容的完整示例。
让我们一起看看这个钩子——但是我更推荐你查看实际文件,以及理解这本文章不同部分的讲解。
// src/hooks/useFetch.ts
type State<T> = {
data?: T;
loading: boolean;
error?: Error;
};
function useFetch<T = unknown>(
url?: string,
{ initialFetch, delayFetch }: Options = { initialFetch: true, delayFetch: 0 }
): State<T> {
// ...
}
url
来确定从哪里获取资源,以及一个选项来决定是否需要初始获取,以及两次获取之间有没有延迟。options
拥有默认值。State
,由消费组件通过泛型指定。让我们看一下消费组件的使用情况:
// src/pages/AnimeDetail.tsx
const { data, loading, error } = useFetch<JikanAPIResponse<RawAnimeData>>(
getAnimeFullById(Number(id))
);
getAnimeFullById
返回终端的 URL。useFetch
会返回 JikanAPIResponse
类型的data
,根据情况不同,返回的数据也不同。在我们的示例中为 RawAnimeData
。本文探索了 TypeScript 能够解决的一些常见痛点。特别是当你和团队一起工作,并且完全理解每一个组件的输入和输出、钩子以及上下文,TypeScript 非常有用。
使用 TypeScript 意味着代码更加可靠、记录更完善以及可读性更强。同时也更不容易出错并且更好管理。
编写代码不仅仅是创建一个有效的算法。你也和别人一起工作(即便你是独立开发者,你也要发表你的作品,寻求他人的帮助和协作),在这些场景中,组员之间的沟通是关键。
我喜欢将 TypeScript 类比为人类的 Babel:我们通过 Babel 来优化 CPU 的使用,同时也需要一个类似的 Babel 来指导和扩展团队合作。
还剩下一个问题,什么时候需要使用 TypeScript?
所有大项目都是由小项目组成,所以注意这里“大型”的定义。
这篇文章相当长,如果你读到这里,我对你的付出和热情表示感谢。我的初衷并不是文章的曝光,而是解释清楚为什么。
希望你喜欢这篇文章,如果你已经从 JS 转换成 TS,或者两个都在使用,或者思考过是否使用但是暂时不考虑以及其他任何情况——我非常期待听到你的分享。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8