iT邦幫忙

2023 iThome 鐵人賽

DAY 20
0
Modern Web

React 走出新手村 系列 第 20

React 走出新手村 — 創造合邏輯的組件

  • 分享至 

  • xImage
  •  

Compund Pattern

這次要分享的是 Compound Pattern,如果你常常使用一些 component library 的話,這樣的使用你應該不陌生,就好像 React 本身的 Context 一樣,必須先有 Context 才能使用 Provider,讓他透過先後順序可以推斷哪裡做錯了?
https://ithelp.ithome.com.tw/upload/images/20230918/20129020NECyG58448.png

概念

當然,這只是一中複合組件的切分方法,你可以想像成折衣服的方式一樣,只能說這種折法比較好讓人找得到,那我們看看範例吧!

這裡我舉 Tab 組件邏輯為例,假設我們想實現一個單純切換顯示段落的功能,通常會把按鈕區和顯示區分成兩個子組件,另外在外層組建傳入 state & onChange function 來控制段落的切換。

import { useState } from "react";
// 外層
const Wrapper = () => {
  const [tabActived, setTabActived] = useState('a');
  const tabs = ['a', 'b', 'c'];
  
  return (
    <>
      <div className="flex">
        {tabs.map((tab) => (
          <Tab 
            key={tab} 
            label={tab} 
            tabActived={tabActived}
            setTabActived={setTabActived}
          />
        ))}
      </div>
      <Panel label="a" tabActived={tabActived}>
        {/* 這裡擺 tab a 的內容... */}
      </Panel>
      <Panel label="b" tabActived={tabActived}>
        {/* 這裡擺 tab b 的內容... */}
      </Panel>
      <Panel label="c" tabActived={tabActived}>
        {/* 這裡擺 tab c 的內容... */}
      </Panel>
    </>
  );
}

// 按鈕
const Tab = ({ label, tabActived, setTabActived}) => {
  return (
    <button
      className={label===tabActived ? `active` : ``}
      onClick={() => setTabActived(label)}
    >
      {label}
    </button>
  );
}

// 顯示層
const Panel = ({tabActived, label, children}) => {
  return tabActived === label ? <div>{children}</div> : null;
}
  
export default Wrapper;

但這樣處理的話又要再做一次 stateTabs 的管理,如果我們很常修改這裡的段落,除了要改頁面以外還要重新整理新增或刪除 state & Tabs 的內容,那我們看看透過 compound pattern 是如何整理的。

換個思維

首先,我們將所有的組件與引用的 Wrapper 做一個簡單的拆分,在 component 的資料夾下面新增 Tabs 的資料夾,然後我們新增 Tabs.tsx 的檔案,這次我示範 typescripts 的作法,如果是使用 js 的朋友可以忽略掉 typescript 的部分:

// Tabs/Tabs.tsx
import React from 'react';
import { Tab, ITabProps } from './Tab';
import { Panel, IPanelProps } from './Panel';

interface ITabsContext {
  activeTab: string;
  setActiveTab: (label: string) => void;
}
// 這裡我們需要用到children,那下面是使用ts的方法
interface ITabsComposition {
  Tab: React.FC<React.PropsWithChildren<ITabProps>>;
  Panel: React.FC<React.PropsWithChildren<IPanelProps>>;
}
// 這裡我們先createContext之後再透過Provider的方式讓整個群體
// 透過useContext去提取資料
const TabsContext = React.createContext<ITabsContext | undefined>(undefined);

const Tabs: React.FC<React.PropsWithChildren> & ITabsComposition = (props) => {
  const [activeTab, setActiveTab] = React.useState('');

  // 這裡應該都不陌生了吧,我前面介紹Memo的時候已經非常詳細為什麼要使用useMemo了
  // 還不懂的可以參考前面的文章
  const memoizedContextValue = React.useMemo(
    () => ({
      activeTab,
      setActiveTab,
    }),
    [activeTab, setActiveTab],
  );

  return (
    <TabsContext.Provider value={memoizedContextValue}>
      {props.children}
    </TabsContext.Provider>
  );
};

// 做個簡單的提醒,避免直接使用的error,這通常會在後面階段才回來補。
export const useTabs = (): ITabsContext => {
  const context = React.useContext(TabsContext);
  if (!context) {
    throw new Error('該組件必須包在 <Tabs> 裡面');
  }
  return context;
};

Tabs.Tab = Tab;
Tabs.Panel = Panel;

export { Tabs };

接下來我們來新增 Tab.tsx的 component :

// Tabs/Tab.tsx
import React from "react";
import { useTabs } from "./Tabs";

export interface ITabProps {
  label: string;
}
// 我這裡有掛tailwindcss的code,style的部分可以自行調整
export const Tab: React.FC<React.PropsWithChildren<ITabProps>> = props => {
  // 如果沒用custom hook的朋友可以透過useContext的方式再去取一次
  const { activeTab, setActiveTab } = useTabs();

  return (
    <div 
      className={activeTab === props.label ? `border-b-2 border-red-400` : ``}
    >
      <button onClick={() => setActiveTab(props.label)}>
        {props.children}
      </button>
    </div>
  );
};

然後是 Panel.tsx 的component:

// Tabs/Panel.tsx
import * as React from "react";
import { useTabs } from "./Tabs";

export interface IPanelProps {
  label: string;
}

export const Panel: React.FC<React.PropsWithChildren<IPanelProps>> = props => {
  const { activeTab } = useTabs();
  return activeTab === props.label ? <div>{props.children}</div> : null;
};

最後我們在另外立一個 index.ts 讓 import 的時候不那麼奇怪(純粹個人喜好問題,也可以直接改Tabs.tsx的檔名為 index.tsx),如下:

// Tabs/index.ts
export * from "./Tabs";

那麼,接下來引用的部分就可以像你在使 Headless UI 那樣直接寫對應的 Tab & Panel 就能夠正常運行了:

import { FC } from "react"
import { Tabs } from "../../components/Tabs";

const Wrapper:FC = () => {
  
  return (
    <div className="container mx-auto">
      <Tabs>
        <div className="flex">
          <Tabs.Tab label="a">a區</Tabs.Tab>
          <Tabs.Tab label="b">b區</Tabs.Tab>
          <Tabs.Tab label="c">c區</Tabs.Tab>
        </div>
        <Tabs.Panel label="a">
          這裡是a
        </Tabs.Panel>
        <Tabs.Panel label="b">
          這裡是b
        </Tabs.Panel>
        <Tabs.Panel label="c">
          這裡是c
        </Tabs.Panel>
      </Tabs>
    </div>
  )
}

export default Wrapper;

總結

這次的分享也是提供大家一點思路,當你的組件 props 的變數過多過雜的時候也可以考慮這樣的方式重組你的組件,詳細可以參考一些主流的 component library,比如像 Material UI, mantine, HeadlessUI 這些UI框架或多或少都有用這樣的方式來賦予原本的組件更加富有不同重組的特性。

另外,這樣的拆分方式也很考驗命名的問題,比如這個組件以下會長出哪些東西,你當然可以用 ComponentA, ComponentB, ComponentC… 來做命名,但很難看得懂你每個組件到底涵蓋了哪些東西?對應哪些情況?所以說寫程式除了邏輯以外,你也需要懂得表達能力,符合語意往往要比簡短來的重要得多

給全新手的大禮包

React基本Hook教學


上一篇
React 走出新手村 — 路由基礎
下一篇
React 走出新手村 — 資料夾的分類
系列文
React 走出新手村 31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言