回答您的问题
索引属性的可分配性
是的,接口不允许您同时定义字符串索引属性和使用不同定义的特定字符串的属性。您可以使用intersection type 解决此问题:
type ceProps =
& {
tag: string;
style?: Style;
children?: ceProps[];
}
& {
[key: string]: string;
};
这告诉 Typescript tag 将永远存在并且永远是一个字符串,style 可能存在也可能不存在,但当它存在时将是 Style,children 可能存在也可能不存在在那里,但当它在那里时将是一个ceProps[]。任何其他属性也可能存在,并且始终是字符串。
索引HTMLElement
问题是您指定ceProps 可以包含任何字符串作为属性,但HTMLElement 没有任何字符串作为属性,它具有为其定义的特定属性。
您可以通过将element 转换为any 或将element.style 转换为any 来逃避Typescript 的检查,如下所示:
//Adding properties
for (const prop in rest) {
(element as any)[prop] = rest[prop];
}
if (style) {
for (const prop in style) {
(element.style as any)[prop] = style[prop];
}
}
但是,这不是类型安全的。没有什么可以检查您的 ceProps 中的属性实际上是您创建的元素可以拥有或使用的属性。 HTML 是相当宽容的——大多数时候该属性会被默默地忽略——但这可能比崩溃更令人毛骨悚然,因为你不会知道哪里出了问题。
一般来说,您应该在使用any时非常谨慎。有时您必须这样做,但它总是会让您感到不舒服。
提高类型安全性
这将使您可以将现有代码编译为 Typescript,并且它将提供至少一点类型安全性。不过,Typescript 可以做得更好。
CSSStyleDeclaration
Typescript 附带的lib.dom.d.ts 文件包含大量 HTML 和原生 Javascript 中的各种定义。其中之一是CSSStyleDeclaration,一种用于样式化 HTML 元素的类型。使用它而不是您自己的 Style 声明:
type ceProps =
& {
tag: string;
style?: CSSStyleDeclaration;
children?: ceProps[];
}
& {
[key: string]: string;
};
当你这样做时,你不再需要使用 (element.style as any) 来投射 element.style——你可以使用这个:
//Adding styles
if (style) {
for (const prop in style) {
element.style[prop] = style[prop];
}
}
这是可行的,因为现在 Typescript 知道您的 style 与 element.style 是同一种对象,所以这将正确运行。作为奖励,现在当您首先创建 ceProps 时,如果您使用错误的属性(双赢),则会收到错误消息。
通用类型
ceProps 的定义将允许您定义一个结构,该结构将与ce 一起创建任何元素。但这里一个可能更好的解决方案是使其通用。这样我们就可以跟踪哪个标签与ceProps 的特定实例相关联。
type CeProps<Tag extends string = string> =
& {
tag: Tag;
style?: CSSStyleDeclaration;
children?: CeProps[];
}
& {
[key: string]: string;
};
(我将 ceProps 重命名为 CeProps 以更符合典型的 Typescript 命名风格,当然欢迎您的项目使用自己的风格。)
尖括号表示泛型类型参数,这里是Tag。拥有Tag extends string 意味着Tag 被约束 成为一个字符串——类似CeProps<number> 的东西将是一个错误。 = string 部分是默认参数——如果我们写成不带尖括号的CeProps,我们的意思是CeProps<string>,即任何字符串。
这样做的好处是 Typescript 支持string literal types,它扩展了字符串。所以你可以使用CeProps<"a">,然后我们就会知道tag不仅仅是任何字符串,而是"a"。
那么我们就有能力指出我们正在谈论的标签。例如:
const props: CeProps<"a"> = { tag: "a", href: "test" };
如果你要在这里写tag: "b",你会得到一个错误——Typescript 要求这是一个"a"。你可以编写一个函数,它可能只接受一个特定的CeProps,等等。
如果您使用 as const 关键字,Typescript 也可以正确推断:
const props = { tag: "a" } as const;
Typescript 会理解这个 props 变量是一个 CeProps<"a"> 值。 (实际上,从技术上讲,它会将其理解为 { tag: "a"; } 类型,但它与 CeProps<"a"> 兼容,并且可以传递给期望的函数,例如。)
最后,如果您有兴趣编写一个函数,该函数只能将CeProps 用于特定标签,而不仅仅是one 标签,您可以使用union type,它用a 表示|:
function takesBoldOrItalics(props: CeProps<"b" | "i">): void {
您可以使用const aBold: CeProps<"b"> = { tag: "b" }; 或const anItalic = { tag: "i" } as const; 调用此函数,或者像takesBoldOrItalics({ tag: "b" }); 一样直接调用它。但是如果你尝试用{ tag: "a" } 调用它,你会得到一个错误。
将事物限制为keyof HTMLElementTagNameMap
lib.dom.d.ts 中的另一个强大工具是HTMLElementTagNameMap,它为每个可能的 HTML 标记字符串提供特定的 HTMLElement。它看起来像这样:
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"applet": HTMLAppletElement;
"area": HTMLAreaElement;
// ...
}
(复制自lib.dom.d.ts)
lib.dom.d.ts 使用它来输入 createElement 本身,例如:
createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
options?: ElementCreationOptions,
): HTMLElementTagNameMap[K];
(我从lib.dom.d.ts 复制了这个并添加了一些换行符以提高可读性。)
请注意此处的<K extends keyof HTMLElementTagNameMap> 部分。与CeProps 上的<Tag extends string> 一样,这表示带有约束的类型参数K。所以K 一定是某种keyof HTMLElementTagNameMap。如果您不熟悉,keyof 表示某种类型的“键”——属性名称。所以keyof { foo: number; bar: number; } 是"foo" | "bar"。而keyof HTMLElementTagNameMap 是"a" | "abbr" | "address" | "applet" | "area" | ...——所有潜在 HTML 标记名称的联合(至少在上次更新到 lib.dom.d.ts 时)。这意味着createElement 要求tag 成为这些字符串之一(它还有其他重载可以处理其他字符串并只返回HTMLElement)。
我们可以在 CeProps 中利用相同的功能:
type CeProps<Tag extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap> =
& {
tag: Tag;
style?: CSSStyleDeclaration;
children?: CeProps[];
}
& {
[key: string]: string;
};
现在如果我们写 ce({ tag: "image" }) 而不是 ce({ tag: "img" }) 我们会得到一个错误而不是它被静默接受然后不能正常工作。
正确输入其余部分
如果我们使用Tag extends keyof HTMLElementTagNameMap,我们可以更精确地键入“rest”属性,这样可以防止您出错并限制您需要在ce 中执行的转换量。
为了使用它,我更新了CeProps,如下所示:
interface MinimalCeProps<Tag extends keyof HTMLElementTagNameMap> {
tag: Tag;
style?: CSSStyleDeclaration;
children?: CeProps[];
}
type CeProps<Tag extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap> =
& MinimalCeProps<Tag>
& Partial<Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>>;
我将它分成两部分,MinimalCeProps 用于您希望始终出现的部分,然后是完整的CeProps,它会产生该类型与Partial<Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>> 的交集。这是一口,但我们稍后会分解它。
那么,我们与Partial 和Omit 有业务往来。要分解它,
-
HTMLElementTagNameMap[Tag] 是与Tag 对应的 HTML 元素。您会注意到这与 createElement 上的返回类型使用的类型相同。
-
Omit 表示我们省略了作为第一个参数传入的类型的一些属性,如第二个字符串文字的并集所示。例如,Omit<{ foo: string; bar: number; baz: 42[]; }, "foo" | "bar"> 将导致 { bar: 42[]; }。
在我们的例子中,Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>,我们忽略了HTMLElementTagNameMap[Tag] 中已经是MinimalCeProps<Tag> 中的属性的属性,即tag、style 和children。这很重要,因为HTMLElementTagNameMap[Tag] 将有一些children 属性——它不会是CeProps[]。我们可以只使用Omit<HTMLElementTagNameMap[Tag], "children">,但我认为最好彻底——我们希望MinimalCeProps 能够“赢得”所有这些标签。
-
Partial 表示所有传递的类型的属性都应该是可选的。所以Partial<{ foo: number; bar: string; baz: 42[]; }> 将是{ foo?: number; bar?: string; baz?: 42[]; }。
在我们的例子中,这只是为了表明我们不会在这里传递任何 HTML 元素的 每个 属性——只是我们有兴趣覆盖的那些。
以这种方式做事有两个好处。首先,这可以防止将拼写错误或输入错误的属性添加到CeProps。其次,ce 本身可以利用它来减少对强制转换的依赖:
function ce<T extends keyof HTMLElementTagNameMap>(
{ tag, children, style, ...rest }: CeProps<T>,
): HTMLElementTagNameMap[T] {
const element = window.document.createElement(tag);
//Adding properties
const otherProps = rest as unknown as Partial<HTMLElementTagNameMap[T]>;
for (const prop in otherProps) {
element[prop] = otherProps[prop]!;
}
//Adding children
if (children) {
for (const child of children) {
element.appendChild(ce(child));
}
}
//Adding styles
if (style) {
for (const prop in style) {
element.style[prop] = style[prop];
}
}
return element;
}
这里,element 自动获取正确的类型,HTMLElementTagNameMap[T] 感谢createElement 的类型声明。然后我们必须创建otherProps“虚拟变量”,遗憾的是这需要一些转换——但我们可以比转换为any更安全。我们还需要在otherProps[prop] 上使用!——! 告诉 Typescript 该值不是undefined。这是因为您可以使用明确的undefined 值创建CeProps,例如{ class: undefined }。由于这将是一个奇怪的错误,因此似乎不值得对其进行检查。您忽略的属性不会有问题,因为它们不会出现在 for (const props in otherProps) 中。
更重要的是,ce 的返回类型是正确类型的——就像 createElement 的类型一样。这意味着如果您执行ce({ tag: "a" }),Typescript 会知道您收到的是HTMLAnchorElement。
结论:一些例子/测试用例
// Literal
ce({
tag: "a",
href: "test",
}); // HTMLAnchorElement
// Assigned to a variable without as const
const variable = {
tag: "a",
href: "test",
};
ce(variable); // Argument of type '{ tag: string; href: string; }' is not assignable to parameter of type 'CeProps<...
// Assigned to a variable using as const
const asConst = {
tag: "a",
href: "test",
} as const;
ce(asConst); // HTMLAnchorElement
// Giving invalid href property
ce({
tag: "a",
href: 42,
}); // 'number' is not assignable to 'string | undefined'
// Giving invalid property
ce({
tag: "a",
invalid: "foo",
}); // Argument of type '{ tag: "a"; invalid: string; }' is not assignable to parameter of type 'CeProps<"a">'.
// Object literal may only specify known properties, but 'invalid' does not exist in type 'CeProps<"a">'.
// Did you mean to write 'oninvalid'?
// Giving invalid tag
ce({ tag: "foo" }); // Type '"foo"' is not assignable to type '"object" | "link" | "small" | ...