在设计可重用性的 React 组件时,一般须要组件支持在不一样状况下传入不一样的 DOM 属性。假设你正在构建一个 <Button />
组件。首先,你只须要容许将自定义的 className
合并进去,但之后,你须要支持与该组件无关,可是和组件使用的上下文有关的各类属性和事件处理方法。例如:须要传入 Tooltip 组件的 aria-describedby
属性,或是在组件内写 tableIndex
和 onKeyDown
属性触发的焦点事件。html
Button 组件不可能预测和处理每个可能使用的特殊的上下文,所以有一个合理的理由能够容许任意额外的 props 给 Button 组件,并让它传递没法理解的额外的 props。前端
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
color?: ColorName;
icon?: IconName;
}
function Button({ color, icon, className, children, ...props }: ButtonProps) {
return (
<button
{...props}
className={getClassName(color, className)}
>
<FlexContainer>
{icon && <Icon name={icon} />}
<div>{children}</div>
</FlexContainer>
</button>
);
}
复制代码
举个例子,咱们能够将额外的 props 传递给 <button>
元素,它支持参数的类型检查。因为 props 类型继承自 React.ButtonHTMLAttributes
,咱们只能经过 props 传递一些参数来完善 <button>
:react
<Button onKeyDown={({ currentTarget }) => { /* do something */ }} />
<Button foo="bar" /> // Correctly errors 👍
复制代码
在你将 Button v1 版本发给产品研发团队半小时以后,他们会来问你一个问题:怎么使用 Button 来作 react-router 的 Link?怎样作一个连接到外部站点的 HTMLAnchorElement?你发给他们的组件仅仅是渲染成 HTMLButtonElement。android
若是咱们不关心类型安全,咱们能够很轻松的使用普通 JavaScript 来写这个:ios
function Button({
color,
icon,
className,
children,
tagName: TagName,
...props
}) {
return (
<TagName
{...props}
className={getClassName(color, className)}
>
<FlexContainer>
{icon && <Icon name={icon} />}
<div>{children}</div>
</FlexContainer>
</TagName>
);
}
Button.defaultProps = { tagName: 'button' };
复制代码
这使得使用者能够轻松的使用他们喜欢的任意标签或组件来做为容器:git
<Button tagName="a" href="https://github.com">GitHub</Button>
<Button tagName={Link} to="/about">About</Button>
复制代码
可是?咱们如何使用使类型正确呢?Button 的 props 不能再无条件的继承自 React.ButtonHTMLAttributes
,由于多余的 props 不能传递给 <button>
。github
适当的警告:深刻到彻底未知的领域,来解释为何不能很好地工做的几个缘由。若是你更愿意相信个人话,你能够跳到一个更好的解决方案。typescript
咱们先从一个简单的例子开始,只容许 tagName
为 'a'
或 'button'
。(我还会删除一些影响简洁性的 props 和属性。)这是一次合理的尝试:后端
interface ButtonProps {
tagName: 'a' | 'button';
}
function Button<P extends ButtonProps>({ tagName: TagName, ...props }: P & JSX.IntrinsicElements[P['tagName']]) {
return <TagName {...props} />;
}
<Button tagName="a" href="/" />
复制代码
注意:要理解这一点,要具有 JSX.IntrinsicElements 的基础知识。这是 React 类型定义的维护者之一对 TypeScript 中的 JSX 的深刻研究。安全
出现的两个直接观察的结果是
props.ref
的类型不适合 TagName
的类型。tagName
被推断为字符串文字类型时,它确实会产生咱们想要的结果。咱们甚至能够从 AnchorHTMLAttributes
那里获得完整的信息:然而,更多的实验代表,咱们也有效地禁用了多余的属性检查:
<button href="/" fakeProp={1} /> // correct errors 👍
<Button tagName="button" href="/" fakeProp={1} /> // no errors 👎
复制代码
Button 上的每一个 prop 都将被推断为类型参数 P
的属性,而类型参数 P
又成为被容许的 prop 的一部分。换句话说,容许的 props 老是包括你传递的全部 props。当你添加一个 prop 时,它就成为了 Button 的 props 的一部分。(实际上,你能够经过在上面的示例中悬停在 Button
的内容来看到这一点。)这显然与你打算如何定义 React 组件相反。
ref
有什么问题?若是你尚未被说服放弃使用这种方法,或者你只是好奇为何上面的代码片断编译得很差,更深刻一些。在你使用 Omit<typeof props, 'ref'>
实现一个比较清晰的解决方案时,会被警告:ref
并非惟一的问题,这只是第一个问题。其他的问题是每一个事件处理程序的 prop。[1]
那么 ref
和 onCopy
有什么共同点呢?他们都有共同的形式:(param: T) => void
,其中 T
指的是渲染的 DOM 元素的实例类型:例如 HTMLButtonElement
用于按钮, HTMLAnchorElement
用于锚点。若是要调用被调用参数类型的并集,则必须传递它们的参数类型的交集,以确保不管在运行时调用哪一个函数,该函数都将接收对其参数指望的子类型。[2] 简单的例子以下:
function addOneToA(obj: { a: number }) {
obj.a++;
}
function addOneToB(obj: { b: number }) {
obj.b++;
}
// 假设咱们有一个函数
// 它能够是上面声明的函数类型
declare var fn: typeof addOneToA | typeof addOneToB;
// 函数可能会访问咱们传递的任何一个属性 'a' 或 'b'
// 所以直观地说
// 对象须要定义这两个属性
fn({ a: 0 });
fn({ b: 0 });
fn({ a: 0, b: 0 });
复制代码
在这个例子中,能够很容易看出来咱们必须向 fn
传递一个类型为 { a: number, b: number }
的对象,它是 { a: number }
和 { b: number }
的交集。一样这也会发生在 ref
和全部的事件处理程序上面:
type Props1 = JSX.IntrinsicElements['a' | 'button'];
// 简化为:
type Props2 =
| JSX.IntrinsicElements['a']
| JSX.IntrinsicElements['button'];
// 这意味着 ref 是...
type Ref =
| JSX.IntrinsicElements['a']['ref']
| JSX.IntrinsicElements['button']['ref'];
// 这是函数的并集!
declare var ref: Ref;
// 忽略掉字符串的引用
if (typeof ref === 'function') {
// 所以,它须要 `HTMLButtonElement & HTMLAnchorElement`
ref(new HTMLButtonElement());
ref(new HTMLAnchorElement());
}
复制代码
如今咱们能够看到,为何 ref
不要参数类型是 HTMLAnchorElement | HTMLButtonElement
的并集,而是须要它们的交集:HTMLAnchorElement & HTMLButtonElement
—— 理论上可行的类型,但不是在 DOM 中出现的类型。并且咱们直观地知道,若是咱们有一个 React 元素,要么是锚,要么是 Button,传递给 ref
的值要么是 HTMLAnchorElement
,要么是 HTMLButtonElement
,因此咱们提供给 ref
的函数应该是可以接受 HTMLAnchorElement | HTMLButtonElement
的。所以,回到原来的组件,咱们能够看到当 P['tagName']
是一个并集的时候,JSX.IntrinsicElements[P['tagName']]
可以合理的容许使用不安全的回调类型,而这正是编译器所不接受的。经过忽略此类型错误可能出现的不安全操做的例子:
<Button
tagName={'button' as 'a' | 'button'}
ref={(x: HTMLAnchorElement) => x.href.toLowerCase()}
/>
复制代码
props
类型我认为使这个问题不直观的缘由是你老是但愿将 tagName
实例化为一个字符串文本类型,而不是一个联合类型。在这种状况下,JSX.IntrinsicElements[P['tagName']]
是合理。然而在组件函数内部,TagName
看起来是联合类型,所以 props 输入的时候要为交集。事实证实,这是可能的,可是这有点老套。所以在这咱们甚至不会把 UnionToIntersection
写下来。私底下不要这么作:
interface ButtonProps {
tagName: 'a' | 'button';
}
function Button<P extends ButtonProps>({
tagName: TagName,
...props
}: P & UnionToIntersection<JSX.IntrinsicElements[P['tagName']]>) {
return <TagName {...props} />;
}
<Button tagName="button" type="foo" /> // Correct error! 🎉
复制代码
当 tagName
是一个联合类型的时候又会怎么样呢?
<Button
tagName={'button' as 'a' | 'button'}
ref={(x: HTMLAnchorElement) => x.href.toLowerCase()} // 🎉
/>
复制代码
不过,咱们不要过早地庆祝:咱们尚未有效的解决缺少过多的属性检查,这是一个不可接受的折衷。
正如咱们以前所发现的,过量属性检查来带问题是,咱们全部的props都会成为类型参数 P
的一部分。咱们须要一个类型参数,以便将 tagName
推断为字符串文字单位类型,而不是一个联合类型,可能其余属性根本不须要是泛型的:
interface ButtonProps<T extends 'a' | 'button'> {
tagName: T;
}
function Button<T extends 'a' | 'button'>({
tagName: TagName,
...props
}: ButtonProps<T> & UnionToIntersection<JSX.IntrinsicElements[T]>) {
return <TagName {...props} />;
}
复制代码
这是什么新的和不寻常的错误?
它来自 TagName
泛型 和 React 对 JSX.LibraryManagedAttributes 的定义做为一种分配性条件类型的组合。TypeScript 目前不容许将任何东西赋值给条件类型,条件类型的检查类型(在 ?
以前)是通用的:
type AlwaysNumber<T> = T extends unknown ? number : number;
function fn<T>() {
let x: AlwaysNumber<T> = 3;
}
复制代码
显然,声明的 x
类型老是 number
,但 3
不能赋值给它。你看到的是一个保守的简化,能够防止分布可能更改结果类型的状况:
// 这些类型看起来相同,由于全部的 `T` 都拓展了 `unknown`
type Keys<T> = keyof T;
type KeysConditional<T> = T extends unknown ? keyof T : never;
// 这里是同样的
type X1 = Keys<{ x: any, y: any }>;
type X2 = KeysConditional<{ x: any, y: any }>;
// 但这里不相同
type Y1 = Keys<{ x: any } | { y: any }>;
type Y2 = KeysConditional<{ x: any } | { y: any }>;
复制代码
因为这里演示的分布式特性,在实例化泛型条件类型以前假设它的任何内容一般都是不安全的。
假设你解决了这个可分配性错误,并准备将全部的 'a' | 'button'
替换为 keyof JSX.IntrinsicElements
。
interface ButtonProps<T extends keyof JSX.IntrinsicElements> {
tagName: T;
}
function Button<T extends keyof JSX.IntrinsicElements>({
tagName: TagName,
...props
}: ButtonProps<T> & UnionToIntersection<JSX.IntrinsicElements[T]>) {
// @ts-ignore YOLO
return <TagName {...props} />;
}
<Button tagName="a" href="/" />
复制代码
那么,恭喜你成功弄崩了 TypeScript 3.4!约束类型 keyof JSX.IntrinsicElements
173 个键的联合类型,类型检查器将用它们的约束实例化泛型,来确保全部可能的实例化都是安全的。这意味着 ButtonProps<T>
是 173 个对象类型的并集,而且能够说 UnionToIntersection<...>
是一个包裹在另外一个对象类型中的条件类型,其中一个条件类型分布到另外一个 173 个类型的并集上,并在此类型推断上进行调用。简而言之,你刚刚发明了一个没法在节点的默认堆大小内进行推理的 Button。并且咱们甚至历来没有考虑过支持 <Button tagName={Link} />
!
TypeScript 3.5 能够经过推迟大量简化条件类型的工做来处理这个问题,而不会崩溃,可是你真的想编写只等待合适时机爆发处理操做的组件吗?
若是你认真看到了这里,我真的很感动。我花了几个星期才到这里,但只花了你十分钟!
当咱们回到画板,刷新一下咱们真正想要完成的东西。咱们的按钮组件是这样的:
onKeyDown
和 aria-describedby
button
, 带有 href
属性的 a
标签, 或者带有 to
属性的 Link
组件事实证实,咱们可使用渲染 prop 来完成这些工做。我建议命名为 renderContainer
并给它一个合理的默认值:
interface ButtonInjectedProps {
className: string;
children: JSX.Element;
}
interface ButtonProps {
color?: ColorName;
icon?: IconName;
className?: string;
renderContainer: (props: ButtonInjectedProps) => JSX.Element;
children?: React.ReactChildren;
}
function Button({ color, icon, children, className, renderContainer }: ButtonProps) {
return renderContainer({
className: getClassName(color, className),
children: (
<FlexContainer>
{icon && <Icon name={icon} />}
<div>{children}</div>
</FlexContainer>
),
});
}
const defaultProps: Pick<ButtonProps, 'renderContainer'> = {
renderContainer: props => <button {...props} />
};
Button.defaultProps = defaultProps;
复制代码
让咱们尝试一下:
// 简单的默认设置
<Button />
// 渲染为 Link,强制设置 `to` 属性
<Button
renderContainer={props => <Link {...props} to="/" />}
/>
// 渲染为锚点,接收 `href` 属性
<Button
renderContainer={props => <a {...props} href="/" />}
/>
// 渲染为带有 `aria-describedby` 属性的 button
<Button
renderContainer={props =>
<button {...props} aria-describedby="tooltip-1" />}
/>
复制代码
咱们彻底消除了 keyof JSX.IntrinsicElements
的 173 个组成联合键类型形成的类型错误,同时容许更大的灵活性,它是完美的,类型安全的。任务也完成了 🎉
这样的 API 设计成本很小。犯这样的错误很容易:
<Button
color={ColorName.Blue}
renderContainer={props =>
<button {...props} className="my-custom-button" />}
/>
复制代码
{...props}
已经包含了 className
,它使 Button 看起来更漂亮而且呈蓝色,而且这里咱们用 my-custom-button
彻底覆盖了类 className
。
一方面,这提供了最高程度的可定制性 —— 用户能够彻底控制哪些内容能够放到容器中,哪些不能够,容许进行之前不可能进行的细粒度定制。可是另外一方面,你可能在 99% 的状况下都但愿合并这些类,由于它视觉上看起来是零碎的,并非明显的。
根据组件的复杂性、用户的身份以及文档的可靠性,这些多是严重的问题,也可能不是。当我开始在本身的工做中使用这样的模式时,我写了一个 小的实用程序来帮忙实现附加 props 的合并:
<Button
color={ColorName.Blue}
renderContainer={props =>
<button {...mergeProps(props, {
className: 'my-custom-button',
onKeyDown: () => console.log('keydown'),
})} />}
/>
复制代码
这样能够确保正确合并类名,若是 ButtonInjectedProps
扩展其定义来注入本身的 onKeyDown
,则将运行此处提供的注入的类名和控制台日志记录的类名。
onCopy
替换为前面所说的 ref
。我试图直观地解释这种关系,但这是由于参数是函数签名中的逆变位置。关于这个话题有几个很好的解释。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。