reactjs - 通过类型强制互斥的 ARIA 标签属性,并修改 Props 导致其不兼容
设置
在关注组件的可访问性时,只应存在一个标签:应该只有 label、aria-label 或 aria-labelledby。(例外情况应很少;如果你确实需要它,则可以进行一些类型转换为“any”。)我正尝试通过一个附加类型来强制执行这一点,以便更容易顺利成功:
export type LabelWithAria<LabelType = string> =
| { label?: LabelType; ariaLabel?: never; ariaLabelledBy?: never }
| { label?: never; ariaLabel?: string; ariaLabelledBy?: never }
| { label?: never; ariaLabel?: never; ariaLabelledBy?: string };
然后我像这样将它添加到组件的 props 中:
export type DropdownProps<TValue = OptionValues> = LabelWithAria & {
value?: DropdownOption<TValue> | DropdownOptions<TValue> | null;
rawValue?: TValue;
...a dozen or so other properties...
}
当我在 JSX 中编写包含所有属性的 React 组件时,这个方法效果很好,虽然错误信息有点冗长(这个最小重现示例可以,不过通常会有十几个以上的属性,这会使得情况更加冗长,而且经常需要检查多个重载):
<Dropdown label="foo" ariaLabel="foo, only moreso" />
Type '{ label: string; ariaLabel: string; }' is not assignable to type 'IntrinsicAttributes & PropsWithChildren<DropdownProps<any>>'.
Type '{ label: string; ariaLabel: string; }' is not assignable to type '{ label?: ReactChild | undefined; ariaLabel?: undefined; ariaLabelledBy?: undefined; }'.
Types of property 'ariaLabel' are incompatible.
Type 'string' is not assignable to type 'undefined'.ts(2322)
问题
当更复杂的组件将其 props 作为 props 对象的一部分传递(用于包装组件)时,就会出现问题。下面的用法应该是合法的(“value” 本身就是可选的)。
export interface InputOrValueProps {
value: string | number | null | undefined | string[] | number[];
selectProps?: Omit<DropdownProps, 'value'>;
...and other properties...
}
public render() {
return <Dropdown {...(this.props.selectProps)} rawValue={this.props.value ?? ''} />;
}
这会产生关于 ariaLabel 的错误:
Type '{ rawValue: string | number | string[] | number[]; validationErrors?: ValidationError[] | undefined; label?: ReactChild | undefined; ariaLabel?: string | undefined; ... 32 more ...; }' is not assignable to type 'IntrinsicAttributes & PropsWithChildren<DropdownProps<any>>'.
Type '{ rawValue: string | number | string[] | number[]; validationErrors?: ValidationError[] | undefined; label?: ReactChild | undefined; ariaLabel?: string | undefined; ... 32 more ...; }' is not assignable to type '{ label?: ReactChild | undefined; ariaLabel?: undefined; ariaLabelledBy?: undefined; }'.
Types of property 'ariaLabel' are incompatible.
Type 'string | undefined' is not assignable to type 'undefined'.
Type 'string' is not assignable to type 'undefined'.ts(2322)
另外,当我混合使用展开运算符和 JSX 属性时,我会得到编译错误:
const props: DropdownProps = {
options: [...],
... and the rest of the props...
};
...and a bunch more logic...
<Dropdown label="I am sad" {...props} />
投诉是相同的“ariaLabel 错误”问题:
Type '{ label: string; shouldDisplayLabelInline: true; styleOverrides: { root: { gridTemplateColumns: string; }; }; validationErrors?: ValidationError[] | undefined; ariaLabel?: string | undefined; ... 32 more ...;} | { ...; } | { ...; }' is not assignable to type 'IntrinsicAttributes & PropsWithChildren<DropdownProps<any>>'.
Type '{ label: string; shouldDisplayLabelInline: true; styleOverrides: { root: { gridTemplateColumns: string; }; }; validationErrors?: ValidationError[] | undefined; ariaLabel?: string | undefined; ... 32 more ...;}' is not assignable to type 'IntrinsicAttributes & PropsWithChildren<DropdownMCProps<any>>'.
Type '{ label: string; shouldDisplayLabelInline: true; styleOverrides: { root: { gridTemplateColumns: string; }; }; validationErrors?: ValidationError[] | undefined; ariaLabel?: string | undefined; ... 32 more ...;}' is not assignable to type '{ label?: ReactChild | undefined; ariaLabel?: undefined; ariaLabelledBy?: undefined; }'.
Types of property 'ariaLabel' are incompatible.
Type 'string | undefined' is not assignable to type 'undefined'.
Type 'string' is not assignable to type 'undefined'.ts(2322)
如果 props 对象的类型是 Partial<DropdownProps>,这可以在一定程度上解决,但同样,如果第一次没有正确设置,它会给出一个晦涩的错误信息,对程序员不太友好。
有些问题出现在我稍微处理类型的时候,比如一个包装组件接收一个 Omit<DropdownProps, "only" | "optional" | "props" | "omitted">。
不可行的变通办法
是的,如果我做一堆类型转换,一切都能正常工作,但我是在为其他人制作这些组件。而且由于那些错误信息在指出解决方案方面非常糟糕(就是让你加一个无意义的类型转换!)。尽管把 ARIA 标签做到“正确”很不错,但我不想为了这个而让使用这些组件变得更困难。
<Dropdown {...(this.props.selectProps as DropdownProps)} rawValue={this.props.value ?? ''} />
一个不可行的解决方案
我想可以使用高度冗余的定义来定义 DropdownProps,但这是一场维护噩梦,并且意味着我必须对我们使用的大约 20 个具有 label 和 ariaLabel 的不同组件都这样做(而且我不确定这是否能解决上述传递/混合 props 的问题):
export type DropdownProps = {
label?: string;
...all the usual props...
} | {
ariaLabel?: string;
...same props again...
} | {
ariaLabelledBy?: string;
...yet a third copy of the same props...
}
目标
实际上,目标只是有一个简单的混入类型,用于 ARIA 属性,我可以将其附加到组件上,以便轻松正确地使用 ARIA 标签(在特定组件上不超过一个 ARIA 类型标签),同时不会让使用或修改组件变得更困难。
重印
游乐场链接
答案#1
你有两个不同的问题,尽管错误信息是相同的:
- 展开对象属性与内联属性的混合搭配
- 在使用
Omit修改后类型不兼容
1. 搭配组合
当我混合使用展开运算符和 JSX 属性时,会出现编译错误
问题是,通过在 <Dropdown> 组件上添加显式的 label 属性,其余的属性“绝不”应该包含 ariaLabel 或 ariaLabelledBy 属性(如 LabelWithAria 类型所指定)。
但是这些属性的“其余部分”是从一个完整的 DropdownProps 类型的对象中展开的,所以它很可能包含这些属性中的一个。
2. 一旦使用 Omit 修改,就不兼容的类型
有些问题出现在我稍微处理类型的时候,所以一个包装组件接收一个
Omit<DropdownProps, "only" | "optional" | "props" | "omitted">
正如你所发现的,一旦你修改了类型,新类型就会以某种方式与原始类型不兼容,即使它仍然可能是有效的(只是省略了可选属性等)。
看起来 TS 对用于互斥属性的联合类型的重构处理得不是很好,比如你的 LabelWithAria:
// Define mutually exclusive properties
type Xor = {
a: number
b?: never
} | {
a?: never
b: number
}
// @ts-expect-error
const a: Xor = { // Error: Types of property 'a' are incompatible. Type 'number' is not assignable to type 'undefined'.
a: 1,
b: 2
}
// Let's fiddle a bit with the type
type Modified = Omit<Xor, "whatever"> // It should theoretically be the same thing...
// Should have failed!
const m: Modified = { // For some reason, TS no longer enforces the mutual exclusion
a: 1,
b: 2
}
declare const m2: Modified;
// Should have been okay!
const b: Xor = m2; // Error: Type 'Modified' is not assignable to type 'Xor'.
游乐场链接
一个可能的解决方案(针对两个问题)是将 DropdownProps 拆分,以保持 Xor 部分的独立,并只处理“普通”属性。
不幸的是,这意味着使用组件必须处理这些单独的定义,并在之后重新合并它们。
也可以提供一个辅助类型:
// Helper type
type WithAriaLabel<Props> = LabelWithAria & Props;
type DropdownPropsNormal = {
options?: {label: string; value: string}[];
}
type DropdownProps = WithAriaLabel<DropdownPropsNormal>
type MixedProps = {
wrapped: DropdownPropsNormal; // label is always provided externally, so wrapped cannot contain any ariaLabel/ariaLabelledBy => DropdownPropsNormal only
}
const MixedRepro: React.FunctionComponent<MixedProps> = (props: MixedProps) => {
return <Dropdown {...props.wrapped} label="foo"/> // Okay
}
const PartialPropsRepro: React.FunctionComponent = (props) => {
const spread: WithAriaLabel<Omit<DropdownPropsNormal, "options">> = {
label: "whatTheFoo"
}
return <Dropdown {...spread}/> // Okay
}
游乐场链接
