TypeScript 中给 JSX 元素传递泛型
Typescript 2.9 支持给 JSX 元素设置泛型类型参数。这意味着我们可以像下面这样在 TSX 文件中写组件:
function Form() {
// ...
return (
<Select<string> options={targets} value={target} onChange={setTarget} />
)
}
为了理解为什么泛型 JSX 元素是有用的(以及为什么我们通常并不需要显式地把类型参数写出来),我们先来写一个 Select
组件,然后围绕这个组件迭代升级它的静态类型。
步骤一:在 Javascript/JSX 中实现 Select
让我们使用 React 实现一个可复用的 Select 组件。我们的组件应该渲染出原生的 <select>
元素,并且有一组 <option>
子元素:
我们需要给 Select
组件传递 options
属性,以及当前选中的 value
和 onChange
回调函数。上面截图组件的代码如下:
function Form() {
const targets = [
{ value: 'es3', label: 'ECMAScript 3' },
{ value: 'es5', label: 'ECMAScript 5' },
{ value: 'es2015', label: 'ECMAScript 2015' },
{ value: 'es2016', label: 'ECMAScript 2016' },
{ value: 'es2017', label: 'ECMAScript 2017' },
{ value: 'es2018', label: 'ECMAScript 2018' },
{ value: 'es2019', label: 'ECMAScript 2019' },
]
const [target, setTarget] = useState('es2019')
return <Select options={targets} value={target} onChange={setTarget} />
}
我们该如何在普通的 Javascript/JSX 中实现 Select
组件?以下是第一次尝试:
function Select(props) {
function handleOnChange(e) {
props.onChange(e.currentTarget.value)
}
return (
<select value={props.value} onChange={handleOnChange}>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
我们的组件接收一些属性,然后返回包含了通过 options
属性设置的所有选项的 <select>
元素。我们还定义了一个 handleOnChange
函数,它会在每次选中值改变时被触发,然后调用 onChange
回调并传入选中的值。
组件能和预期一样工作。现在我们尝试给它添加静态类型。
步骤二:在 TypeScript/TSX 中实现 Select
我们先来创建一个类型,用来表示单个选项的类型。我们把它命名为 Option
,并且定义了两个属性,一个是选项的值,另一个是我们需要展示的 label:
type Option = {
value: string
label: string
}
这很简单。接下去,我们来给 Select
组件的属性定义一个类型。我们需要一个 options
属性,它会使用我们刚刚定义的 Option
类型;一个 value
属性代表当前被选中的值;以及一个 onChange
回调,每当选中值修改时被调用:
type Props = {
options: Option[]
value: string
onChange: (value: string) => void
}
最后,我们将 Props
付诸使用,并且给我们的 handleOnChange
函数的参数 e
添加类型标注:
function Select(props: Props) {
function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
props.onChange(e.currentTarget.value)
}
return (
<select value={props.value} onChange={handleOnChange}>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
我们现在有了一个完全实现静态类型的 React 组件。它要求所有的选项都需要设置 string
类型的值,这个约束在现实的应用中限制可能过于严格(或者完全没有,如果是这种场景,我们可以就此打住不继续展开了)。
步骤三:支持数字类型的选项值
虽然字符串类型的值是常见的使用场景,但它并不是唯一可能的选项值类型。我们很可能需要 Select
组件接收数字类型的选项值:
function Form() {
const targets = [
{ value: 3, label: 'ECMAScript 3' },
{ value: 5, label: 'ECMAScript 5' },
{ value: 2015, label: 'ECMAScript 2015' },
{ value: 2016, label: 'ECMAScript 2016' },
{ value: 2017, label: 'ECMAScript 2017' },
{ value: 2018, label: 'ECMAScript 2018' },
{ value: 2019, label: 'ECMAScript 2019' },
]
const [target, setTarget] = useState(2019)
return <Select options={targets} value={target} onChange={setTarget} />
}
注意我已经用数字替换了字符串类型的值,包括传给 useState
Hook 的初始值。
在我们更新 Select
组件的类型之前,让我们先给 handleOnChange
函数添加非字符串选项值的支持。当前,它只能接受字符串类型的值。e.currentTarget.value
永远是一个字符串,即便我们给我们的选项设置了数字类型的值。
好消息是,修改并不难,且相当简单。我们可以不用读取 e.currentTarget.value
然后把它直接传给 onChange
回调,相反的,我们可以通过 e.currentTarget.selectedIndex
属性来获取选中选项的索引,然后通过 options
数组来获取到对应索引中的选项值,并传递给 onChange
:
function Select(props: Props) {
function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
const { selectedIndex } = e.currentTarget
const selectedOption = props.options[selectedIndex]
props.onChange(selectedOption.value)
}
return (
<select value={props.value} onChange={handleOnChange}>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
这种方法之所以能生效,是因为我们将 options
数组中的每一项都按顺序渲染出一个 <option>
元素,并且没有额外添加 <option>
元素。
我们已经修复了 Select
组件的实现,现在我们来修复它的类型。我们现在会得到一个类型错误,因为我们传递给 value
属性(期望是 string
类型)的 target
是数字类型。
让我们将 value
属性的类型从 string
修改为 string | number
类型来支持数字类型的值:
type OptionValue = string | number
type Option = {
value: OptionValue
label: string
}
type Props = {
options: Option[]
value: OptionValue
onChange: (value: OptionValue) => void
}
注意我增加了一个类型别名叫 OptionValue
,这样我们不用在很多地方重复写联合类型 string | number
。
不幸的是,我们的 Props
类型现在并不太合适。我们的选项值现在被定义为 string | number
类型,这意味着我们的 onChange
回调也会接收 string | number
类型的值。但这个类型并没有很好地反映 Select
组件的行为:
- 如果我们传的选项值类型是
string
,onChange
回调应该接收string
类型的值。 - 如果我们传的选项值类型是
number
,onChange
回调应该接收number
类型的值。
换句话说,我们在这个过程中丢失了类型信息。这会在我们使用这个参数的时候产生问题,比如当们调用 useState
Hook 返回的 setTarget
函数的时候:
- 当我们使用
"es2019"
作为初始值调用userState
的时候,它是一个字符串,Typescript 推断target
应该是类型string
。 - 当我们使用
2019
作为初始值调用userState
的时候,它是一个数字,Typescript 推断target
应该是类型number
。
不管哪种情况,string | number
类型的值都不能赋值给 string
或者 number
。Typescript 因此会给我们 Select
组件的 TypeScript
属性报一个类型错误:
Type 'number' is not assignable to type 'SetStateAction'.
所以我们究竟该如何恰当地给 React 组件添加类型?答案是泛型。
步骤四:使用泛型确定精确的属性类型
我们来使用泛型 T
来替代到处使用的 string | number
类型。我们通过给 Option
添加类型参数使得它变为泛型。然后我们可以使用类型 T
作为属性 value
的类型:
type OptionValue = string | number
type Option<T extends OptionValue> = {
value: T
label: string
}
注意我们限制类型参数 T
需要继承于 OptionValue
类型。换句话说,我们可以给泛型 T
设置任何类型,只要它能赋值给 string | number
,这包括:
string
类型number
类型- 字符串字面量类型
- 数字字面量类型
never
类型,以及- 以上类型的任意联合类型
现在 Option
类型已支持泛型,当我们在 Props
类型的 options 属性中使用它的时候,我们需要给它设置一个类型参数。因此,我们也应该需要让 Props
支持泛型。再一次,我们引入了泛型类型参数 T
,并且也将它用于 value
和 onChange
属性:
type Props<T extends OptionValue> = {
options: Option<T>[]
value: T
onChange: (value: T) => void
}
现在 Props
是泛型类型了,所以我们需要在 Select
组件使用 Props
类型的时候提供类型参数 T
。同时,我们需要添加 extends OptionValue
限制,这样我们可以将 T
传递给 Props<T>
——以下是完整代码:
function Select<T extends OptionValue>(props: Props<T>) {
function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
const { selectedIndex } = e.currentTarget
const selectedOption = props.options[selectedIndex]
props.onChange(selectedOption.value)
}
return (
<select value={props.value} onChange={handleOnChange}>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
我们已经成功地将 Select
变成了一个泛型函数式组件。现在 TypeScript 2.9 终于要登场了。当我们创建一个 <Select>
元素的时候,我们可以设置一个泛型类型:
function Form() {
const targets = [
{ value: 'es3', label: 'ECMAScript 3' },
{ value: 'es5', label: 'ECMAScript 5' },
{ value: 'es2015', label: 'ECMAScript 2015' },
{ value: 'es2016', label: 'ECMAScript 2016' },
{ value: 'es2017', label: 'ECMAScript 2017' },
{ value: 'es2018', label: 'ECMAScript 2018' },
{ value: 'es2019', label: 'ECMAScript 2019' },
]
const [target, setTarget] = useState('es2019')
return (
<Select<string> options={targets} value={target} onChange={setTarget} />
)
}
这个语法刚接触确实有点奇怪。然而,再仔细一想,它和我们在 Typescript 其他地方设置泛型参数的语法是一致的。
现在我们将 Select
组件以及 Props
和 Option
类型都变成了泛型,我们的程序能通过类型检查,不再有任何类型报错,无论我们使用字符串、数字,或者两者都使用。
注意在这里我们并不需要在 JSX 元素上显式地设置泛型类型参数。Typescript 可以帮我们推断出来。通过检查 targets
数组中每个对象的 value
属性类型,Typescript 会理解我们正在使用 string
作为值的类型。
因为 Typescript 可以根据上下文为我们推断出类型 string
,我们可以将 <Select<string>
修改回 <Select
。下面是完整可运行的例子:
type OptionValue = string | number
type Option<T extends OptionValue> = {
value: T
label: string
}
type Props<T extends OptionValue> = {
options: Option<T>[]
value: T
onChange: (value: T) => void
}
function Select<T extends OptionValue>(props: Props<T>) {
function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
const { selectedIndex } = e.currentTarget
const selectedOption = props.options[selectedIndex]
props.onChange(selectedOption.value)
}
return (
<select value={props.value} onChange={handleOnChange}>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
function Form() {
const targets = [
{ value: 'es3', label: 'ECMAScript 3' },
{ value: 'es5', label: 'ECMAScript 5' },
{ value: 'es2015', label: 'ECMAScript 2015' },
{ value: 'es2016', label: 'ECMAScript 2016' },
{ value: 'es2017', label: 'ECMAScript 2017' },
{ value: 'es2018', label: 'ECMAScript 2018' },
{ value: 'es2019', label: 'ECMAScript 2019' },
]
const [target, setTarget] = useState('es2019')
return <Select options={targets} value={target} onChange={setTarget} />
}
好了,我们最终完成了这个支持静态类型的 Select
组件,可以给 JSX 元素添加泛型的类型参数。