Design System 101 - 設計模式 x Slots 插槽
- 文章發表於
什麼是 Slots ?
Slots, 也可以稱為插槽, 就是一個預設的區塊 (placeholder),其顧名思義,可以透過 Slots 在組件中插入任何預先定義的內容,包含其他組件、文字、圖片等等。
為什麼需要 Slots ?
在介紹 Slots 之前,我們先來探討現今 React 組件中最常見的兩種 API 設計方式:配置模式 (Configuration) 和組合模式 (Composition)。再深入瞭解 Slots 是如何為其提供解決方案的。
配置模式 (Configuration)
說到了配置模式,就讓筆者回想到當初在建 <TextInput />
或是其他一些比較複雜的組件,時常會遇到需要 客製化 的情況。
而那時候的解法都是透過傳入 props 大法,雖然這樣的方法在短期內是有效的,但隨著需求的增加,組件的 API 也會變得越來越繁鎖。
以下筆者將用 <TextInput />
舉例, 一起來看看其組件的成長史。
首先 v1 版本通常會是和組件的設計指南一樣,有基本的 label
、input
、helperText
,這時候的組件可能會長這樣
// v1const TextInput = ({ label, helperText, icon, ...props }) => {return (<div className="text-input"><label>{label}</label><input {...props} /><span className="helper-text">{helperText}</span>{icon}</div>)}
然而,隨著時間的進展,可能有同事提出需要改變 label
的顏色。於是我們引入 labelProps
以便開發者進行客製化:
// v2const TextInput = ({ label, helperText, icon, labelProps, ...props }) => {return (<div className="text-input"><label {...labelProps}>{label}</label><input {...props} /><span className="helper-text">{helperText}</span>{icon}</div>)}
不久之後,又有同事反應說需要 input
需要加入前綴與後綴,於是我們就加入 prefix
與 suffix
讓開發者可以自由的客製化 prefix
與 suffix
。
// v3const TextInput = ({label,helperText,icon,labelProps,prefix,prefixProps,suffix,suffixProps,...props}) => {return (<div className="text-input"><label {...labelProps}>{label}</label><div>{prefix && <span {...prefixProps}>{prefix}</span>}<input {...props} />{suffix && <span {...suffixProps}>{suffix}</span>}</div><span className="helper-text">{helperText}</span>{icon}</div>)}
隨著設計千奇百怪的需求迎面而來,為了讓組件能夠複用,我們就會需要越來越多的 props 來客製化組件,到最後 <TextInput />
API 可能會非常冗長,讓組件變得難以維護,且混合了各種邏輯。
而組合模式的出現,正是為了嘗試解決這樣的問題。接下來,我們來看看組合模式如何達到這個目的。
組合模式 (Composition)
React 是一個組件化的 UI 框架,而組件化的好處就是可以將一個大的組件拆分成更小的組件,並且可以複用。這就是組合模式的核心思想。
<TextField><TextField.Label /><TextField.Input /><TextField.HelperText /><TextField.Icon /></TextField>
這種方式提供開發者可以靈活運用組件,自由的定制組件的每一部分,但這引起了另一個問題:一致性,使用者必須按照預期的順序放入子組件,否則可能會導致 Accessibility 問題。
舉例來說 <TextField />
組件的順序應該是 Label
-> Input
-> HelperText
-> Icon
。
此時使用者將 Label
放在最後面,並透過 CSS 將 Label
移到 Input
的上方,這樣就會導致 Accessibility 問題,因為對於 Screen Reader 使用者來說,因為他們可能首先接觸到的是 Input,卻還不知道這個輸入框的具體用途。
Slots 概念
Slots - Web Components
在介紹如何設計 React Slots 之前我們可以透過 Web Components 的 API 來看看 Slots 是如何解決這個問題的。
舉例來說,現在要設計一個 Button
組件,有了 Slots 我們就可以 Slot 的位置,像是按鈕的文字,並且給予一個預設的內容,當有需要客製化的時候,就可以透過 slot
屬性直接來插入內容。
Slots - React
在 React 中,雖然沒有原生 Slots AP,但我們可以透過 children
來達到相同的效果,但是 children
並沒有提供名稱的概念,因此需要透過 props
來定義。
Default Slot
首先,先介紹當只需要定義一個 Slot, 讓我們來看看如何透過 children
來實現 Slots。
const Button = ({ children }) => {return <button>{children || 'Default Button'}</button>}
這樣的寫法,就可以讓開發者在使用 <Button />
組件時,可以自由的插入任何內容。
<Button><span>Click Me</span></Button>
Named Slots
當需要定義多個 Slots 時,例如 Button 與 Icon, 此時我們可以透過 props
來定義
const Button = ({ icon, content }) => {return (<button>{icon}{content || 'Default Button'}</button>)}
而這個做法有一個缺點,所有的 Slot 都需要透過 props
來定義。
<Button icon={<Icon />} content={<span>Click Me</span>} />
createSlots
Slots with 為了解決上面的問題,可以與組合模式的概念進行結合,並透過遍歷 React Children 的方式找到每個子組件的型別,並且將其放入對應的 Slot 中。
首先使用方式大概會像這樣
<Button><Icon>🖕</Icon><Content>Click Me!</Content></Button>
而 JSX 會被轉換成以下的結構
{type: function Button() {},props: {children: [{type: function Icon() {},props: {},},{type: function Content() {},props: {children: 'Click Me',},},],},}
再透過這個結構,遍歷 children
來找到每個子組件的型別,並且將其放入對應的 Slot 中。
import React from 'react' function ButtonContent(props) { return <>{props.children}</> } function Icon(props) { return <>{props.children}</> } const Button = (props) => { const { children } = props let icon = null let content = null React.Children.forEach(children, (child) => { if (!React.isValidElement(child)) return null if (child.type === Icon) { icon = child } else { content = child } }) console.log(icon, content) return ( <button> {icon} {content || 'Default Button'} </button> ) } export default () => { return ( <Button> <Icon>🖕</Icon> <ButtonContent>Click Me!</ButtonContent> </Button> ) }
而從上面的例子中,可以看到我們可以透過 slot
屬性來插入內容,就可以解決 Configuration 模式的痛點,不用再傳入過多的 props 來客製化組件。也可以解決 Composition 模式的痛點,因為開發者可以自由的插入內容,而不用擔心順序的問題。
Slots with Context API
上面的例子中,我們透過遍歷 React Children 的方式找到每個子組件的型別,並且將其放入對應的 Slot 中,但這樣的方式有一個缺點,就是當組件的層級越深,就需要越多的遍歷,這樣的效能會變得越來越差。
我們可以透過 React Context API 來解決這個問題,首先先建立一個 Context
const ButtonContext = React.createContext({slots: {icon: null,content: null,},})
再來就是透過 ButtonContext.Provider
來包住子組件,並且可以透過 slots
來定義每個 Slot 的內容
const ButtonContextProvider = ({ children, slots }) => {return <ButtonContext.Provider value={slots}>{children}</ButtonContext.Provider>}
最後建立一個 useButtonContext
來取得 Context 的值
const useButtonContext = (props, slotName) => {const context = React.useContext(ButtonContext)return { ...(props || {}), ...(context?.[slotName] || {}) }}
最後再建立 Button 與 Icon 組件時,加入 useButtonContext
來取得相對應 Slot 的內容
const ICONS = {play: '⏯️',thumbUp: '👍',};const Icon = (props: { type: string }) => {props = useButtonContext(props, 'icon');return (<span style={{ marginRight: '4px', display: 'inline-flex' }} {...props}>{ICONS[props.type]}</span>);};const Button = (props: { children: React.ReactNode }) => {props = useButtonContext(props, 'button');return (<button {...props}><Icon type="play" />{props.children}</button>);};
import React from 'react' const ButtonContext = React.createContext({ slots: { icon: null, button: null, }, }) const useButtonContext = (props, slotName) => { const context = React.useContext(ButtonContext) return { ...(props || {}), ...(context?.[slotName] || {}) } } const ButtonContextProvider = ({ children, slots }) => { return <ButtonContext.Provider value={slots}>{children}</ButtonContext.Provider> } const ICONS = { play: '⏯️', thumbUp: '👍', } const Icon = (props) => { props = useButtonContext(props, 'icon') return ( <span style={{ marginRight: '4px', display: 'inline-flex' }} {...props}> {ICONS[props.type]} </span> ) } const Button = (props) => { props = useButtonContext(props, 'button') return ( <button {...props}> <Icon type="play" /> {props.children} </button> ) } export default () => { return ( <ButtonContextProvider slots={{ icon: { type: 'thumbUp', style: { marginRight: '4px' }, }, }} > <Button>Play!!!</Button> </ButtonContextProvider> ) }
而這就是用 React Context API 建立 Slots 這個概念,也是目前 Adobe React-Spectrum Slot 的設計方式,透過 Context API 來定義 Slots 的內容,並且透過 useSlotProps
來取得相對應 Slot 的值。
接著來探讀 Adobe React-Spectrum Slot 是如何建立 Slots
組件,在這之前,我們不仿想一下如何讓上面的例子適用於不同的組件中,這時我們可能需要以下的 API
API
Name | Description | Params |
---|---|---|
SlotProvider | 建立 Slots 的 Context | slots |
useSlotProps | 使用對應 Slot 的內容 | props , defaultSlot |
mergeProps | 合併所有的 props | args |
Slot - SlotProvider
在正式進入實作之前,要先思考一下當開發者對同一個 slot 傳入重複的 props
我們要如何處理,例如以下的例子
<SlotProviderslots={{ icon: { type: 'play' }, className: 'mb-4', onClick={myClickLogic} }}><SlotProviderslots={{ icon: { type: 'pause' }, className: 'd-flex', onClick={defaultClickLogic} }}><Button /></SlotProvider></SlotProvider>
這時後我們要保留最外層的 type
(因為這是最後一個傳入的值),但是 className
則是要合併,最後 onClick
這種事件行函式則是要連續的呼叫,這時候我們就可以透過 mergeProps
來解決這個問題。
export const chain = (...fns) => {return (...args) => {fns.forEach((fn) => typeof fn === 'function' && fn?.(...args))}}export const mergeProps = (...args) => {const result = { ...args[0] }for (let i = 1; i < args.length; i++) {const props = args[i]for (const key in props) {const a = result[key]const b = props[key]if (typeof a === 'function' &&typeof b === 'function' &&key.startsWith('on') &&key.charCodeAt(2) <= 90 &&key.charCodeAt(2) >= 65) {result[key] = chain(a, b)continue}if (key === 'className') {result[key] = clsx(a, b)continue}result[key] = b === undefined ? a : b}}return result}
再來我們就可以實作 SlotProvider
了,因為 React Context 只會取最近的一層 Context,如果也要讓其他開發者放入 slot 參數都可以被取到,透過 useContext
先取得 parentSlots,
透過 reduce
將相同的 slot 組合起來, 並將 slots
與 parentSlots
透過 mergeProps
的方式進行合併,最後用 useMemo
將結果進行儲存,避免重複計算。
const SlotContext = React.createContext(null)const SlotProvider = (props) => {const parentSlots = useContext(SlotContext) || {}const { slots = {}, children } = propsconst value = useMemo(() => {return Object.keys(parentSlots).concat(Object.keys(slots)).reduce((acc, props) => ({...acc,[props]: mergeProps(parentSlots[props] || {}, slots[props] || {}),}),{})}, [parentSlots, slots])return <SlotContext.Provider value={value}>{children}</SlotContext.Provider>}
Slot - useSlotProps
useSlotProps
則相對簡單,我們只需要取得 slot
的值,並且回傳對應的 props
即可。
const useSlotProps = <T,>(props: T & { id?: string }, defaultSlot?: string) => {const slot = (props as SlotProps).slot || defaultSlot;const context = useContext(SlotContext) || {};return mergeProps(props, mergeProps(slot ? (context as any)[slot] : {}, { id: props.id }));};
Slot with Button
最後再將上面的 API 應用到 Button 組件中,首先我們先定義 Button, Icon 組件,並且有了 SlotProvider
之後,就可以改 Button 或是 Icon 的參數
import React, { useContext, useMemo } from 'react' const chain = (...fns) => { return (...args) => { fns.forEach((fn) => typeof fn === 'function' && fn?.(...args)) } } const mergeProps = (...args) => { const result = { ...args[0] } for (let i = 1; i < args.length; i++) { const props = args[i] for (const key in props) { const a = result[key] const b = props[key] if ( typeof a === 'function' && typeof b === 'function' && key.startsWith('on') && key.charCodeAt(2) <= 90 && key.charCodeAt(2) >= 65 ) { result[key] = chain(a, b) continue } if (key === 'className') { result[key] = clsx(a, b) continue } result[key] = b === undefined ? a : b } } return result } const SlotContext = React.createContext(null) const SlotProvider = (props) => { const parentSlots = useContext(SlotContext) || {} const { slots = {}, children } = props const value = useMemo(() => { return Object.keys(parentSlots) .concat(Object.keys(slots)) .reduce( (acc, props) => ({ ...acc, [props]: mergeProps(parentSlots[props] || {}, slots[props] || {}), }), {} ) }, [parentSlots, slots]) return <SlotContext.Provider value={value}>{children}</SlotContext.Provider> } const useSlotProps = (props, defaultSlot) => { const slot = props.slot || defaultSlot const context = useContext(SlotContext) || {} return mergeProps(props, mergeProps(slot ? context[slot] : {}, { id: props.id })) } const ICONS = { play: '⏯️', thumbUp: '👍', } const Icon = (props) => { props = useSlotProps(props, 'icon') return ( <span style={{ marginRight: '4px', display: 'inline-flex' }} {...props}> {ICONS[props.type]} </span> ) } const Button = (props) => { props = useSlotProps(props, 'button') return ( <button {...props}> <Icon type="play" /> {props.children} </button> ) } export default () => { return ( <SlotProvider slots={{ button: { style: { display: 'flex', alignItems: 'center' }, }, icon: { style: { marginRight: '8px', display: 'inline-flex' }, type: 'thumbUp', }, }} > <Button>Play!!</Button> </SlotProvider> ) }