javascript - Hur skapar man ett gränssnitt för en funktion som skapar en nästlad elementstruktur i typescript?
Jag är ny på TypeScript. Jag implementerar en tidigare skapad funktion med JavaScript. Funktionen tar emot ett objekt som har följande egenskaper
tag: vilket kommer att vara enstring.children: vilket är en array av samma gränssnitt (dvscePropssom visas nedan);style: vilket kommer att vara ett objekt som innehåller stilar som (color,fontSizeosv.)- Alla andra nycklar kan läggas till i detta objekt. (Som
innerHTML,srcetc.)
Här är min kod.
interface Style {
[key : string ]: string;
}
interface ceProps {
tag: string;
style?: Style;
children?: ceProps[];
[key : string]: string;
}
const ce = ({tag, children, style, ...rest } : ceProps) => {
const element = document.createElement(tag);
//Adding properties
for(let prop in rest){
element[prop] = rest[prop];
}
//Adding children
if(children){
for(let child of children){
element.appendChild(ce(child))
}
}
//Adding styles
if(style){
for(let prop in style){
element.style[prop] = style[prop];
}
}
return element;
}
Det visar fel på style och children
Egenskap
'style'av typen'Style | undefined'kan inte tilldelas till strängindextypen'string'.ts(2411)Egenskapen
'children'av typen'ceProps[] | undefined'kan inte tilldelas till strängindex-typen'string'.ts(2411)
Det finns ett fel till på raden element[prop]=rest[prop]; och samma fel på element.style[prop]=style[prop];
Element har implicit typen 'any' eftersom ett uttryck av typen 'string' inte kan användas för att indexera typen 'HTMLElement'. Ingen indexsignatur med en parameter av typen 'string' hittades på typen 'HTMLElement'
Var vänlig förklara varje problem och dess lösning.
Svar #1
Svara på dina frågor
Tilldelbarhet med indexegenskaper
Ja, gränssnitt låter dig inte definiera både en strängindexerad egenskap och egenskaper som använder specifika strängar som definieras på olika sätt. Du kan kringgå detta genom att använda en intersektionstyp:
type ceProps =
& {
tag: string;
style?: Style;
children?: ceProps[];
}
& {
[key: string]: string;
};
Detta berättar för Typescript att tag alltid kommer att finnas och alltid vara en sträng, style kanske finns eller inte, men kommer att vara en Style när den finns, och children kanske finns eller inte, men kommer att vara en ceProps[] när den finns. Alla andra egenskaper kan också finnas och kommer alltid att vara en sträng.
Indexering av HTMLElement
Problemet är att du angav att ceProps kunde innehålla vilken sträng som helst som en egenskap, men HTMLElement har aldrig någon sträng som en egenskap, det har särskilda egenskaper definierade för sig.
Du kan undkomma Typescripts kontroll här genom att casta element som any, eller element.style som any, på följande sätt:
//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];
}
}
Men, detta är inte typ-säkert. Ingenting kontrollerar att egenskaperna i din ceProps faktiskt är en egenskap som elementet du skapade kan ha eller använda. HTML är ganska förlåtande—de flesta gånger kommer egenskapen bara att ignoreras tyst—men det kan vara ännu mer frustrerande än en krasch, eftersom du inte får någon indikation på vad som är fel.
Generellt sett bör du vara extremt försiktig med att använda any. Ibland måste du, men det bör alltid göra dig obekväm.
Förbättra typsäkerheten
Detta låter dig kompilera din befintliga kod som Typescript, och det kommer att ge åtminstone lite typ-säkerhet. Typescript kan dock göra mycket, mycket bättre.
CSSStyleDeclaration
Filen lib.dom.d.ts som följer med Typescript har massor av definitioner för alla möjliga saker i HTML och inbyggd Javascript. En av dem är CSSStyleDeclaration, en typ som används för att styla HTML-element. Använd denna istället för din egen Style-definition:
type ceProps =
& {
tag: string;
style?: CSSStyleDeclaration;
children?: ceProps[];
}
& {
[key: string]: string;
};
När du gör detta behöver du inte längre casta element.style med (element.style as any)—du kan bara använda detta:
//Adding styles
if (style) {
for (const prop in style) {
element.style[prop] = style[prop];
}
}
Detta fungerar eftersom TypeScript nu vet att din style är samma typ av objekt som element.style, så detta kommer att fungera korrekt. Som en bonus, när du nu skapar din ceProps från början, får du ett felmeddelande om du använder en ogiltig egenskap—win-win.
Generiska typer
Definitionen av ceProps kommer att tillåta dig att definiera en struktur som fungerar med ce för att skapa vilken element som helst. Men en potentiellt bättre lösning här är att göra detta generiskt. På så sätt kan vi spåra vilken tagg som är associerad med en viss instans av ceProps.
type CeProps<Tag extends string = string> =
& {
tag: Tag;
style?: CSSStyleDeclaration;
children?: CeProps[];
}
& {
[key: string]: string;
};
(Jag bytte namn på ceProps till CeProps för att vara mer i linje med typisk Typescript-namnkonvention, även om ditt projekt naturligtvis är fritt att använda sin egen stil.)
Vinkelparenteserna anger generiska typparametrar, här Tag. Att ha Tag extends string betyder att Tag är begränsad till att vara en sträng—något som CeProps<number> kommer att vara ett fel. = string-delen är en standardparameter—om vi skriver CeProps utan vinkelparenteser, menar vi CeProps<string>, det vill säga vilken sträng som helst.
Fördelen med detta är att Typescript stödjer strängliteraltyper, vilket utvidgar sträng. Så du kan använda CeProps<"a">, och då skulle vi veta att tag inte bara är vilken sträng som helst, utan "a" specifikt.
Så har vi då möjligheten att ange vilken tagg vi pratar om. Till exempel:
const props: CeProps<"a"> = { tag: "a", href: "test" };
Om du skulle skriva tag: "b" här, skulle du få ett fel—Typescript kommer att kräva att detta är ett "a". Du skulle kunna skriva en funktion som tar endast en specifik CeProps kanske, och så vidare.
Typescript kan också härleda detta korrekt om du använder nyckelordet as const:
const props = { tag: "a" } as const;
Typescript kommer att förstå att denna props-variabel är ett CeProps<"a">-värde. (Tekniskt sett kommer den faktiskt att förstå det som en { tag: "a"; }-typ, men det är kompatibelt med CeProps<"a"> och kan, till exempel, skickas till en funktion som förväntar sig det.)
Slutligen, om du är intresserad av att skriva en funktion som endast kan ta CeProps för vissa taggar, men inte bara en tagg, kan du använda en unionstyp, vilket indikeras med ett |:
function takesBoldOrItalics(props: CeProps<"b" | "i">): void {
Du kan kalla denna funktion med const aBold: CeProps<"b">={ tag: "b" };, eller med const anItalic={ tag: "i" } as const;, eller bara kalla den direkt som takesBoldOrItalics({ tag: "b" });. Men om du försöker kalla den med { tag: "a" } kommer du få ett fel.
Begränsa saker till keyof HTMLElementTagNameMap
Ett annat kraftfullt verktyg i lib.dom.d.ts är HTMLElementTagNameMap, som ger det specifika HTMLElement för varje möjlig HTML-tag-sträng. Det ser ut så här:
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"applet": HTMLAppletElement;
"area": HTMLAreaElement;
// ...
}
(Kopierad från lib.dom.d.ts)
Detta används av lib.dom.d.ts för att typa createElement själv, till exempel:
createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
options?: ElementCreationOptions,
): HTMLElementTagNameMap[K];
(Jag kopierade detta från lib.dom.d.ts och lade till några radbrytningar för läsbarhet.)
Notera delen <K extends keyof HTMLElementTagNameMap> här. Precis som med vårt <Tag extends string> på CeProps, indikerar detta en typparameter K med en begränsning. Så K måste vara någon sorts keyof HTMLElementTagNameMap. Om du inte är bekant med det, indikerar keyof "nycklarna av" någon typ—egenskapsnamnen. Så keyof { foo: number; bar: number; } är "foo" | "bar". Och keyof HTMLElementTagNameMap är "a" | "abbr" | "address" | "applet" | "area" | ...—en union av alla möjliga HTML-taggnamn (åtminstone enligt den senaste uppdateringen av lib.dom.d.ts). Det betyder att createElement kräver att tag är en av dessa strängar (det finns andra överlagringar för den som hanterar andra strängar och bara returnerar ett HTMLElement).
Vi kan utnyttja samma funktionalitet i vår CeProps:
type CeProps<Tag extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap> =
& {
tag: Tag;
style?: CSSStyleDeclaration;
children?: CeProps[];
}
& {
[key: string]: string;
};
Nu, om vi skrev ce({ tag: "image" }) istället för ce({ tag: "img" }) skulle vi få ett fel istället för att det accepteras tyst och sedan inte fungerar korrekt.
Skriver resten korrekt
Om vi använder Tag extends keyof HTMLElementTagNameMap kan vi typa de "övriga" egenskaperna mer precist, vilket skyddar dig från att göra misstag samt begränsar mängden typomvandlingar du behöver göra inuti ce.
För att använda det har jag uppdaterat CeProps så här:
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>>>;
Jag delade upp det i två delar, MinimalCeProps för de delar du alltid vill ska visas, och sedan hela CeProps som producerar snittet av den typen med Partial<Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>>. Det är ett långt uttryck, men vi kommer att bryta ner det om ett ögonblick.
Nu då, vi har det där med Partial och Omit. För att bryta ner det,
HTMLElementTagNameMap[Tag]är HTML-elementet som motsvararTag. Du kommer att märka att detta är samma typ som används som returtyp förcreateElement.Omitindikerar att vi utelämnar vissa egenskaper av typen vi skickar in som första parameter, som anges av unionen av strängliteraler i den andra. Till exempel kommerOmit<{ foo: string; bar: number; baz: 42[]; }, "foo" | "bar">att resultera i{ bar: 42[]; }.I vårt fall,
Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>, utelämnar vi egenskaper frånHTMLElementTagNameMap[Tag]som redan är egenskaper iMinimalCeProps<Tag>—nämligentag,styleochchildren. Detta är viktigt eftersomHTMLElementTagNameMap[Tag]kommer att ha någonchildren-egenskap—och den kommer inte att varaCeProps[]. Vi skulle kunna användaOmit<HTMLElementTagNameMap[Tag], "children">, men jag tyckte det var bäst att vara grundlig—vi vill attMinimalCePropsska "vinna" för alla dessa taggar.Partialindikerar att alla egenskaper i den passade typen ska göras valfria. SåPartial<{ foo: number; bar: string; baz: 42[]; }>kommer att vara{ foo?: number; bar?: string; baz?: 42[]; }.I vårt fall är detta bara för att indikera att vi inte kommer att passera in varje egenskap hos vilken HTML-element som helst här—bara de som vi är intresserade av att åsidosätta.
Det finns två fördelar med att göra saker på detta sätt. För det första förhindrar detta att felstavade eller felaktigt inmatade egenskaper läggs till i CeProps. För det andra kan det användas av ce själv för att minska beroendet av typomvandling:
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;
}
Här får element automatiskt rätt typ, HTMLElementTagNameMap[T] tack vare createElement’s typdeklaration. Sedan måste vi skapa otherProps "dummyvariabeln", och tyvärr kräver det viss typomvandling - men vi kan vara säkrare än att omvandla till any. Vi behöver också använda ! på otherProps[prop] - ! talar om för Typescript att värdet inte är undefined. Detta beror på att du skulle kunna skapa ett CeProps med ett uttryckligen undefined värde, som { class: undefined }. Eftersom det skulle vara ett konstigt misstag att göra verkar det inte värt att kontrollera mot det. Egenskaper du bara utelämnar kommer inte vara ett problem, eftersom de inte kommer att dyka upp i for (const props in otherProps).
Och viktigare, returtypen av ce är korrekt typad—precis på samma sätt som createElement är typad. Detta betyder att om du gör ce({ tag: "a" }), kommer Typescript att veta att du får ett HTMLAnchorElement.
Slutsats: Några exempel/testfall
// 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" | ...
