iT邦幫忙

2023 iThome 鐵人賽

DAY 12
1
Modern Web

React 走出新手村 系列 第 12

React 走出新手村-自製高效 Context Provider

  • 分享至 

  • xImage
  •  

前導

還沒看過上一篇文章的朋友請先回顧喔!
接續上一篇的問題,大家有找到合適的解法了嗎?
如果沒有讓我們一起來思考除了useState以外還有什麼可以讓我們有效管理狀態呢?
https://ithelp.ithome.com.tw/upload/images/20230911/20129020NZ5MSaJCKW.png

繞過state

這時候就應該想到 useRef,相信大家應該很熟悉這個 hook function,在討論優化的問題通常的第一課就會學到的東西,簡單來說它就只是單純指向的概念,不清楚想弄懂的話可以看前面介紹過的useRef

重點是要怎麼讓原本的 useRef 能達到我們想要的 useState 的效果呢?

custom hook

我們可以利用 custom hook 的概念來處理,自己的 hook 自己做的概念,我曾經寫過不少 custom hook 的文章在 IT 邦(有興趣的話可以看這篇)。

接著我們就開始理解我們所需要的功能有哪些吧!

理解需求

首先,我們的 useState 會回傳一個陣列,陣列裡面會有兩個功能,getter & setter,這個基本的情境是我們的 hook 一定要回傳出來的。

接下來,我們的資料並不是採 state 方式去存的,也就是說我必須知道有多少組件有需要提供 ref.current 的資料,所以我們需要製作一個訂閱的功能來知道所需要的組件群(這部分的觀念可以參考這篇)。

完成大概會如下:

const useStoreData = () => {
  const store = useRef({
    count: 0,
    name: ""
  });
  // 這裡是getter提供給下面的useStore來使用,有點類似redux toolkit建置slice方式
  const get = useCallback(() => store.current, []);
  // 這裡用Set可以避免重複相同的值如果採用一般的陣列的話會有同值的問題要處理
  const subscribers = useRef(new Set());

  const set = useCallback((value) => {
    store.current = { ...store.current, ...value };
    subscribers.current.forEach((callback) => callback());
  }, []);
  // 這裡的callback接的是void function
  const subscribe = useCallback((callback) => {
    subscribers.current.add(callback);
    return () => subscribers.current.delete(callback);
  }, []);

  return {
    get,
    set,
    subscribe
  };
};

const StoreContext = createContext(null);

// 這個hook就比較像使用redux toolkit時的useSelector
const useStore = () => {
  const store = useContext(StoreContext);
  if (!store) {
    throw new Error("no store");
  }
  // store為context的value,下面source code要注意順序,但原則實務上使用會拆開的
  const [state, setState] = useState(store.get());
  // 這裡透過useEffect希望只拿一次,並處理cleanup function
  useEffect(() => {
    return store.subscribe(() => setState(store.get()));
  }, []);
  return [state, store.set];
};

然後我們將原本的 useContext 換成剛剛寫好的 useStore hook,如下:

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState
} from "react";

// 這裡的作法可以類比redux toolkit的slice
const useStoreData = () => {
  const store = useRef({
    count: 0,
    name: ""
  });
  const get = useCallback(() => store.current, []);

  const subscribers = useRef(new Set());

  const set = useCallback((value) => {
    store.current = { ...store.current, ...value };
    subscribers.current.forEach((callback) => callback());
  }, []);

  const subscribe = useCallback((callback) => {
    subscribers.current.add(callback);
    return () => subscribers.current.delete(callback);
  }, []);

  return {
    get,
    set,
    subscribe
  };
};

const StoreContext = createContext(null);

const useStore = () => {
  const store = useContext(StoreContext);
  if (!store) {
    throw new Error("no store");
  }
  const [state, setState] = useState(store.get());
  useEffect(() => {
    return store.subscribe(() => setState(store.get()));
  }, []);
  return [state, store.set];
};

const TextInput = ({ value }) => {
  const [store, setStore] = useStore(StoreContext);
  return (
    <fieldset className="field">
      <legend>{value}: </legend>
      <input
        value={store[value]}
        onChange={(e) => setStore({ ...store, [value]: e.target.value })}
      />
    </fieldset>
  );
};

const OrderBtns = ({ value }) => {
  const [store, setStore] = useStore(StoreContext);
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "space-around",
        alignItems: "center"
      }}
    >
      <button
        onClick={() => setStore({ ...store, [value]: (store[value] -= 1) })}
      >
        -
      </button>
      <button
        onClick={() => setStore({ ...store, [value]: (store[value] += 1) })}
      >
        +
      </button>
    </div>
  );
};

const InputContainer = () => {
  return (
    <div className="container">
      <h5>輸入面板</h5>
      <OrderBtns value="count" />
      <TextInput value="name" />
    </div>
  );
};

const Display = ({ value }) => {
  const [store] = useStore(StoreContext);
  return (
    <div className="value">
      {value}: {store[value]}
    </div>
  );
};

const DisplayContainer = () => {
  return (
    <div className="container">
      <h5>已成立訂單</h5>
      <Display value="count" />
      <Display value="name" />
    </div>
  );
};

const ShopContainer = () => {
  return (
    <div className="container">
      <h5>訂單系統</h5>
      <InputContainer />
      <DisplayContainer />
    </div>
  );
};

const ContextExapmle = () => {
  const store = useStoreData();

  return (
    <StoreContext.Provider value={store}>
      <h1>coffee shop</h1>
      <ShopContainer />
    </StoreContext.Provider>
  );
};

export default ContextExapmle;

完成後,透過devtool可以看見渲染的部分已經成功改善了:

  1. 點擊加號
    https://ithelp.ithome.com.tw/upload/images/20230908/20129020OEF86s54ru.png
  2. input輸入字串
    https://ithelp.ithome.com.tw/upload/images/20230908/20129020cKc1gyVK22.png

但是還不夠完美,在使用 redux toolkit 的時候並不會連動到其他不相干的 component 重新渲染,也就是說當按鈕按下時應該只有 count 會跟著渲染,而 name 的顯示欄位必須不被觸發才對。

追求完美

這部分我有參考了 Jack Herrington 的作法,不然原本的 useState & useEffect 還會牽扯到許多 Javascript 的底層觀念,所以這部分的處理就採用了 useSyncExternalStore 的 hook 來處理,想了解他的細節可以參考這篇

那處理完後會如下:

import {
  createContext,
  useCallback,
  useContext,
  useRef,
  useSyncExternalStore
} from "react";

// 這裡的作法可以類比redux toolkit的slice
const useStoreData = () => {
  const store = useRef({
    count: 0,
    name: ""
  });
  const get = useCallback(() => store.current, []);

  const subscribers = useRef(new Set());

  const set = useCallback((value) => {
    store.current = { ...store.current, ...value };
    subscribers.current.forEach((callback) => callback());
  }, []);

  const subscribe = useCallback((callback) => {
    subscribers.current.add(callback);
    return () => subscribers.current.delete(callback);
  }, []);

  return {
    get,
    set,
    subscribe
  };
};

const StoreContext = createContext(null);

// 這裡必須改變加入selector的參數來接要丟進來的callback
// 做完就會發現與redux toolkit的usSelector是相同的概念
const useStore = (selector) => {
  const store = useContext(StoreContext);
  if (!store) {
    throw new Error("no store");
  }
  // 這邊這個hook是react的新功能,詳細可以參考https://beta.reactjs.org/reference/react/useSyncExternalStore
  // 它能夠替代我們想要的快取,即原本的useState & useEffect裡面的處理。
  const state = useSyncExternalStore(store.subscribe, () =>
    selector(store.get())
  );
  return [state, store.set];
};

const TextInput = ({ value }) => {
  // 在處理了useStore的傳入之後,就可以嘗試修該為以下作法
  const [fieldValue, setStore] = useStore((store) => store[value]);
  return (
    <fieldset className="field">
      <legend>{value}: </legend>
      <input
        value={fieldValue}
        onChange={(e) => setStore({ [value]: e.target.value })}
      />
    </fieldset>
  );
};

const OrderBtns = ({ value }) => {
  const [fieldValue, setStore] = useStore((store) => store[value]);
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "space-around",
        alignItems: "center"
      }}
    >
      <button onClick={() => setStore({ [value]: parseInt(fieldValue - 1) })}>
        -
      </button>
      <button onClick={() => setStore({ [value]: parseInt(fieldValue + 1) })}>
        +
      </button>
    </div>
  );
};

const InputContainer = () => {
  return (
    <div className="container">
      <h5>輸入面板</h5>
      <OrderBtns value="count" />
      <TextInput value="name" />
    </div>
  );
};

const Display = ({ value }) => {
  const [fieldValue] = useStore((store) => store[value]);
  return (
    <div className="value">
      {value}: {fieldValue}
    </div>
  );
};

const DisplayContainer = () => {
  return (
    <div className="container">
      <h5>已成立訂單</h5>
      <Display value="count" />
      <Display value="name" />
    </div>
  );
};

const ShopContainer = () => {
  return (
    <div className="container">
      <h5>訂單系統</h5>
      <InputContainer />
      <DisplayContainer />
    </div>
  );
};

const ContextExapmle = () => {
  const store = useStoreData();

  return (
    <StoreContext.Provider value={store}>
      <h1>coffee shop</h1>
      <ShopContainer />
    </StoreContext.Provider>
  );
};

export default ContextExapmle;

做完修改如下圖:

  1. 點擊加號
    https://ithelp.ithome.com.tw/upload/images/20230908/20129020zjIpqlz0SK.png
  2. input輸入字串
    https://ithelp.ithome.com.tw/upload/images/20230908/20129020K7xZRGFwGI.png

這樣一來就達到我們的目的了,如此一番折騰就和 Redux toolkit 使用上基本是一致的,也可以參照 zustand, jotai, x-state… 這類的狀態管理工具,基本上也都差不多,但多了解一些底層的觀念對於日後的發展都是很好的,我也慢慢會欣賞那些新出來的工具,是如何解決痛點的,也或許能從中發展出一些靈感去創造新的工具。

總結

以上就是這一篇的所有內容了,有了以上的觀念再回去踩坑應該會很有概念了吧!

下一篇我們回到踩坑戰場,來聊聊剛開始的起步都犯哪些蠢。

給全新手的大禮包

React基本Hook教學

參考連結

react.dev
patterns observer pattern
Jack Herrington gitHub source code


上一篇
React 走出新手村-深入 Context Provider
下一篇
React 走出新手村-重新整理組件
系列文
React 走出新手村 31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言