在設計可重用性的 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、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。