iT邦幫忙

2023 iThome 鐵人賽

DAY 10
0
Modern Web

設計系統 - Design System系列 第 10

[Day 10] Design System - React Slots (插槽) - 實作

  • 分享至 

  • xImage
  •  

本系列文章會在筆者的部落格繼續連載!Design System 101 感謝大家的閱讀!

本文同步上傳到筆者的個人部落格,裡面透過 Sandpack 直接編輯程式碼!

前言

在前一篇文章中,我們透過遍歷 React Children 的方式找到每個子組件的型別,並且將其放入對應的 Slot 中,但這樣的方式有一個缺點,就是當組件的層級越深,就需要越多的遍歷,這樣的效能會變得越來越差。

Slots with Context API

而今天我們將會介紹如何透過 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>
  );
};

CodeSandbox Playground

而這就是用 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 我們要如何處理,例如以下的例子

<SlotProvider
  slots={{ icon: { type: 'play' }, className: 'mb-4', onClick={myClickLogic} }}
>
  <SlotProvider
    slots={{ 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 組合起來, 並將 slotsparentSlots 透過 mergeProps 的方式進行合併,最後用 useMemo 將結果進行儲存,避免重複計算。

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>;
};

Slot - useSlotProps

useSlotProps 則相對簡單,我們只需要取得 slot 的值,並且回傳對應的 props 即可。

const useSlotProps = (props, defaultSlot) => {
  const slot = props.slot || defaultSlot;
  const context = useContext(SlotContext) || {};

  return mergeProps(props, mergeProps(slot ? context[slot] : {}, { id: props.id }));
};

Slot with Button

最後再將上面的 API 應用到 Button 組件中,首先我們先定義 Button, Icon 組件

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>
  );
};

有了 SlotProvider 之後,就可以改 Button 或是 Icon 的參數

const App = () => {
  return (
    <SlotProvider
      slots={{
        button: {
          style: { display: 'flex', alignItems: 'center' },
        },
        icon: {
          style: { marginRight: '8px', display: 'inline-flex' },
          type: 'thumbUp',
        },
      }}
    >
      <Button>Play!!</Button>
    </SlotProvider>
  );
};

CodeSandbox Playground

小結

明天將介紹 controllState 與 unControllState!

Reference

  1. react-spectrum slots

上一篇
[Day 9] Design System - React Slots (插槽)
下一篇
[Day 11] Design System - Common Hook (一)
系列文
設計系統 - Design System30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言