iT邦幫忙

2022 iThome 鐵人賽

DAY 19
0
Modern Web

React Hook 不求人,建立自己的 Hook Libary系列 第 19

[DAY 19] 自己的Hook自己做!useTabs 想開幾個頁籤都可以?! (上)

  • 分享至 

  • xImage
  •  

情境

前陣子碰到一個需求,希望使用者能夠一次閱覽多筆的訂單(比對資料、快速查看、或複製等操作),但又不想離開頁面,彈出視窗也不適合,於是逛來逛去就發現 Tab 這個打造好的 component:

然後又看到瀏覽器上面一排的頁籤:

心想:「嗯?好像可以...不如做一個可新增刪除的 Tab 吧!」

需求及描述

類似瀏覽器頁籤的做法,實際上要:

  • 點擊訂單能夠新增一個 Tab
  • 點擊多筆訂單可以持續新增 Tab
  • 點擊 Tab 切換顯示內容
  • 點擊 Tab 旁的按鈕移除 Tab

(還有一些留到下篇 (。・`ω´・。))

VIEW

既然是瀏覽訂單,通常都會有List,而點擊 List 其中一個項目會新增一個 Tab 到 Tabs,Panel 則依照當下的選擇顯示對應的資料。

題外話

若是使用現有的元件要做出動態的 Tabs 時,要留意該元件有無相關的 prop 可以進行處理,例如 Chakra-UI 的 Tab 是複合型的(compound components)且設計上偏 static,因此在實現這個需求時 custom 就會比較麻煩一點,比對下來反而 MUI 的彈性就高很多,使用既有的元件要留意一下,或者也可以自己從頭建立就不用擔心這些問題 XD。

開始!

假資料

呈現 List 的部分就是一坨假資料,每一筆大致會有訂單編號、日期、客人、商品等資訊,格式大概這樣:

{
  id: "6e2e49b7-1c7e-4d29-b7d9-ab4e95fd1bd7",
  no: "OR20220619",
  date: "2022-06-19T09:58:52+08:00",
  customer: {
    name: "Julian",
    phone: "1-466-316-1793 x50734",
  },
  products: [
    {
      id: "869f01e6-faba-4eb8-99fe-05b0f491ee0a",
      no: "PR6146",
      name: "Fish",
      price: 145,
      qty: 24,
      subTotal: 1510,
    },
  ],
  total: 1510,
  createAt: "2022-06-19T09:58:52+08:00",
}

如何建立與儲存 Tabs

透過點擊,我們會新增一個Tab,這樣的動作無疑就是需要一個 array,因此會有一組專門記錄「開啟」的 Tabs:

const storedTabs = [{...}, {...}]

不過 {...} 實際上會儲存甚麼呢?

  • 整包訂單資料(X)
  • 部分資料(O)

依照當下需求,來決定 Tab 要用到什麼資料,而按照畫面來看就是訂單ID以及訂單編號,而我們也需要知道現在是哪一個 Tab 正在瀏覽,因此多一個 currentTab

const currentTab = id
const storedTabs = [{id, no}, ...]

訂單ID指的是獨一無二的ID,通常都長得跟亂碼一樣(UUID);訂單編號雖然也是獨一無二,但文字上容易閱讀,通常會有明顯的格式(或邏輯),以本篇的資料格式就是指 id 與 no 。

為何不直接用整包的資料?

雖然是可以,但實際上資料不一定是最新或是正確的,打API時,顯示列表你會用 GET /order (or GET /order/list),但若是單一筆資料你會用 GET /order/:id,有可能在 GET /order 情況下就獲取了 99% 的所有內容,但使用上,不同使用者之間有編輯有資料操作的情況,所以仍要分開處理,才能確保使用者瀏覽時才能獲取較正確的資訊。 (不過這一塊就要看後端怎麼給惹)

Hook

這樣大致上就知道需要怎麼建立這個 Hook 了!由於有不同的操作情境,我們來用 useReducer 來建立吧!

const initValue = {
  currentTab: null,
  storedTabs: [],
}

function useTabs() {
  const [state, dispatch] = useReducer(tabsReducer, initValue)

  const tabDispatch = useCallback(
    (type, payload) => dispatch({ type, payload }),
    []
  )

  return [state, tabDispatch]
}
  • initValue 定義了一開始狀態
  • dispatch 採用了 {type, payload} 格式,為避免手殘,另外包裝成 tabDispatch(type, payload)

而重點的 reudcer 在這裡:

const tabsReducer = (state, action) => {
  const type = action.type
  const payload = action.payload

  switch (type.toUpperCase()) {
    case "ADD": {
      if (state.storedTabs.some((tab) => tab.id === payload.id)) {
        return {
          ...state,
          currentTab: payload.id,
        }
      }

      return {
        ...state,
        currentTab: payload.id,
        storedTabs: [...state.storedTabs, payload],
      }
    }
    case "REMOVE": {
      const newStoredTabs = state.storedTabs.filter((tab) => tab.id !== payload)

      return {
        ...state,
        currentTab: newStoredTabs?.at(-1)?.id || null,
        storedTabs: newStoredTabs,
      }
    }
    case "NAVIGATE": {
      return {
        ...state,
        currentTab: payload,
      }
    }
    default: {
      throw new Error("[useTabs] dispatch has received non exist type")
    }
  }
}

很單純,就是針對 array 進行新增與刪除的動作,我們有:

  • ADD 新增一個 Tab 並同時進行閱讀,若 storedTabs 已經有了,則將 currentTab 改為剛剛點擊的 id
  • REMOVE 移除 Tab,並顯示最後一個 Tab,若已經是最後一個則為 null
  • NAVIGATE 點擊 Tab 時,切換到該顯示的頁面

Component

function Example() {
  const [tabs, tabDispatch] = useTabs()
  const isEmpty = tabs.storedTabs.length === 0

  return (
    <OrderBoard>
      {/* 列表在這邊~~~ */}
      <OrderList>
        {data.map((order) => (
          <OrderItem
            key={order.id}
            onClick={() => tabDispatch("ADD", { id: order.id, no: order.no })}
          >
            <Text>{order.no}</Text>
            <Text>$ {order.total}</Text>
          </OrderItem>
        ))}
      </OrderList>
      {/* tab 在這邊~~~ */}
      <OrderViewer>
        {isEmpty ? (
          <Alert>
            <AlertIcon />
            Select a order from list to view.
          </Alert>
        ) : (
          <>
            <Tabs >
              <TabList>
                {tabs.storedTabs.map((tab) => (
                  <OrderTab
                    key={tab.id}
                    isSelected={tab.id === tabs.currentTab}
                    onChoose={() => tabDispatch("NAVIGATE", tab.id)}
                    onClose={() => tabDispatch("REMOVE", tab.id)}
                  >
                    <Text>{tab.id}</Text>
                  </OrderTab>
                ))}
              </TabList>
            </Tabs>
          </>
        )}
      </OrderViewer>
    </OrderBoard>
  )
}

太多了,挑重點看XD

  • 左側的 List 點擊時是新增的行為,因此放上 ADD,一併存入 {id, no} 方便等等給 tab 顯示用:

    <OrderItem
      key={order.id}
      onClick={() => tabDispatch("ADD", { id: order.id, no: order.no })}
    >
      <Text>{order.no}</Text>
      <Text>$ {order.total}</Text>
    </OrderItem>
    
  • 上排的 Tabs 則有幾個內容:

    <OrderTab
      key={tab.id}
      isSelected={tab.id === tabs.currentTab}
      onChoose={() => tabDispatch("NAVIGATE", tab.id)}
      onClose={() => tabDispatch("REMOVE", tab.id)}
    >
      <Text>{tab.id}</Text>
    </OrderTab>
    
    • isSelected 給 Tab 切換不同 UI (藍色代表選中/黑色沒選)
    • onChoose 為點擊切換 Tab,於是放 NAVIAGTE
    • onClose 為移除 Tab,於是放 REMOVE

到目前為止會長這樣:

我們存了 id & no,用 id 不好閱讀,於是改用 no:

<OrderTab {...省略的props}>
  <Text>{tab.no}</Text> //id 改 no
</OrderTab>

請忽略有一點 UX 不友善的地方 XD

小結

這樣一來 tabs 部分就完成了!下一篇來弄弄 panel,看看實際如何把資料帶入 panel:


上一篇
# [DAY 18] 自己的Hook自己做!AuthProvider 不是 Hook 吧?(下)
下一篇
[DAY 20] 自己的Hook自己做!useTabs 想開幾個頁籤都可以?! (下)
系列文
React Hook 不求人,建立自己的 Hook Libary30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言