當設計模式趕上 Hooks

前言

「設計模式」是一個老生常談的話題,但更可能是集中在面嚮對象語言領域,如 C++,Java 等。前端領域對於設計模式的探討熱度並非很高,不少人以爲對於 JavaScript 這種典型的面向過程的語言來講,設計模式的價值很難體現。以前我持有相似的觀點,對於設計模式的理解僅停留在概念層面,沒有深刻去了解其在前端工程中的實踐。近期閱讀了《 JavaScript 設計模式與開發實踐》一書,書中介紹了 15 種常見設計模式和基本的設計原則,以及如何使用 JavaScript 優雅實現並應用於實際工程中。碰巧前不久團隊舉行了一場關於 Hooks 的辯論賽,而 Hooks 的核心思想在於函數式編程,因而決定探究一下「設計模式是否有助於咱們寫出更優雅的 Hooks 」這一話題。前端

爲何是設計模式

在逆襲武俠劇中,主人公向第一位師父請教武藝時,最開始老師父只會讓主人公挑水、扎馬步等基本功,主人公這時老是會諸般抱怨,但又因爲某些客觀緣由又不得不堅持,以後開始學習真正的武藝時才頓悟以前老師父的一番苦心,夯實基礎後學習武藝日新月異,最終成爲一代大俠。對於咱們開發者來講,「數據結構」和「設計模式」就是老師父所教的基本功,它不必定可以讓咱們走得更快,但必定可讓咱們走得更穩、更遠,有助於咱們寫出高可靠且易於維護的代碼,避免往後被 「挖墳」。react

在 Hooks 發佈以來,飽受詬病的一點就是維護成本激增,特別是對於成員能力水平差距較大的團隊來講。即使一開始由經驗老到的同窗搭建整個項目框架,一旦交由新人維護一段時間後,大機率也會變得面目全非,更不用說讓新人使用 Hooks 開發從零到一的工程。我理解這是因爲 Hooks 的高度靈活性所致使的,Class Component 尚有一系列生命週期方法來約束,而 Hooks 除了 API 參數上的約束,也僅有 「只在最頂層使用 Hook」 「只在 React 函數中調用 Hook」 兩條強制規則。另外一方面自定義 Hook 提升組件邏輯複用率的同時,也致使經驗不足的開發者在抽象時缺乏設計。Class Component 中對於邏輯的抽象一般會抽象爲純函數,而 Hooks 的封裝則可能攜帶各類反作用(useEffect),出現 bug 時排查成本較大。編程

那麼既然「設計模式」是一種基本功,而「Hooks」是一種新招式,那咱們就嘗試從設計模式出發,攻克新招式。redux

有哪些經典的設計模式

在正式進入話題以前,咱們先簡單回顧一下那些快被咱們遺忘的經典設計模式和設計原則。平常中,咱們提到設計原則都會將其簡化爲「SOLID」,對應於單一職責原則(Single Responsibility Principle)、開放/封閉原則(Open Closed Principle)、里氏替代原則(Liskov Substitution Principle)、最小知道原則(Law of Demeter)、接口隔離原則(Interface Segregation Principle)和依賴倒置原則(Dependence Inversion Principle)。設計模式又包括了單例模式、策略模式、代理模式、迭代器模式、發佈-訂閱模式、命令模式、組合模式、模版方法模式、亨元模式、職責鏈模式、中介者模式、裝飾者模式、狀態模式、適配器模式等。segmentfault

關於設計原則和設計模式社區有不少優秀的文章講解,這裏就不過多贅述了,僅僅只是爲了喚起一下你們的記憶而已。設計模式

1 + 1 > 2

image.png

非得用 useContext 嗎

在 React Hook 工程中,一旦涉及到全局狀態管理,咱們的直覺會是使用 useContext。舉個例子,假設工程中須要根據灰度接口返回的信息,決定某些組件是否進行渲染。因爲整個工程共享一份灰度配置,咱們很容易就想到將其做爲一個全局狀態,在工程初始化時調用異步接口獲取並進行初始化,而後在組件內部使用 useContext 來獲取。性能優化

// context.js
const GrayContext = React.createContext();
export default GrayContext;

// App.js
import GrayContext from './context';
function App() {
  console.log('App rerender');
    const [globalStatus, setGlobalStatus] = useState({});
    useEffect(() => {
    console.log('Get GrayState');
        setTimeout(() => {
            setGlobalStatus({
                gray: true
            });
        }, 1000);
    }, []);
    
    return (
        <GrayContext.Provider value={globalStatus}>
        <GrayComponent />
      <OtherChild />
    </GrayContext.Provider>
    );
}

// GrayComponent/index.js
function GrayComponent() {
  console.log('GrayComponent rerender');
  const grayState = useContext(GrayContext);

  return (
    <div>
      子節點
      {grayState.gray && <div>灰度字段</div>}
    </div>
  );
}

// OtherChild/index.js
function OtherChild() {
  console.log('OtherChild rerender');
  return (
    <div>其它子節點</div>
  );
}

可是 createContext 的使用會形成一旦全局狀態發生變動,Provider 下的全部組件都會進行從新渲染,哪怕它沒有消費 context 下的任何信息。數據結構

Kapture 2021-05-20 at 10.38.04.gif

仔細想一想,這種場景和設計模式中的「發佈-訂閱模式」有着殊途同歸之處,咱們能夠本身定義一個全局狀態實例 GrayState,在 App 組件中初始化值,在子組件中訂閱該實例的變化,也可以達到相同的效果,而且僅訂閱了 GrayState 變化的組件會進行從新渲染。框架

// GrayState.js
class GrayState {
  constructor() {
    this.observers = [];
    this.status = {};
  }
  
  attach(func) {
    if (!this.observers.includes(func)) {
      this.observers.push(func);
    }
  }

  detach(func) {
    this.observers = this.observers.filter(observer => observer !== func);
  }

  updateStatus(val) {
    this.status = val;
    this.trigger();
  }

  trigger() {
    for (let i = 0; i < this.observers.length; i++) {
      this.observers[i](this.status);
    }
  }
}

export default new GrayState();

// App.js
import GrayState from './GrayState.js';
function App() {
  console.log('App rerender');
    useEffect(() => {
    console.log('Get GrayState');
    setTimeout(() => {
      const nextStatus = {
        gray: true,
      };
      GrayState.updateStatus(nextStatus);
    }, 200);
  }, []);
    
    return (
        <div>
        <GrayComponent />
      <OtherChild />
    </div>
    );
}

// GrayComponent/index.js
import GrayState from './GrayState.js'
function GrayComponent() {
  console.log('GrayComponent rerender');
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const changeVisible = (status) => {
      setVisible(status.gray);
    };
    GrayState.attach(changeVisible);
    return () => {
      GrayState.detach(changeVisible);
    };
  }, []);

  return (
    <div>
      子節點
      {visible && <div>灰度字段</div>}
    </div>
  );
}

最終實現的效果是一致的,不一樣的是獲取灰度狀態後,僅僅依賴灰度配置信息的 GrayComponent 進行了從新渲染。dom

Kapture 2021-05-20 at 10.47.54.gif

考慮更好複用的話咱們還能夠將對 Status 監聽的部分抽象爲一個自定義 Hook:

// useStatus.js
import { useState, useEffect } from 'react';
import GrayState from './GrayState';

function useGray(key) {
  const [hit, setHit] = useState(false);
  
  useEffect(() => {
    const changeLocalStatus = (status) => {
      setHit(status[key]);
    };
    GrayState.attach(changeLocalStatus);
    return () => {
      GrayState.detach(changeLocalStatus);
    };
  }, []);

  return hit;
}

export default useGray;

// GrayComponent/index.js
import useStatus from './useGray.js'
function GrayComponent() {
  console.log('GrayComponent rerender');
  const [visible, setVisible] = useGray('gray');

  return (
    <div>
      子節點
      {visible && <div>灰度字段</div>}
    </div>
  );
}

固然,藉助 redux 也是可以作到按需從新渲染,但若是項目中並無大量全局狀態的狀況下,使用 redux 就顯得有點殺雞用牛刀了。

useState 仍是 useReducer

Hooks 初學者經常會感慨 「我開發中只用到 useState useEffect,其它鉤子彷佛不怎麼須要的樣子」。這種感慨源於對 Hooks 的理解還不夠透徹。 useCallback useMemo 是一種在必要時刻才使用的性能優化鉤子,日常接觸較少也是有可能的,但 useReducer 卻值得咱們重視。在官方的解釋中,useReduceruseState 的替代方案,什麼狀況下值得替代呢,這裏一樣以一個例子來分析。

舉個狀態模式中最爲常見的例子 —— 音樂播放器的順序切換器。

function Mode() {
  /* 普通書寫模式 */
  const [mode, setMode] = useState('order');    // 定義模式狀態

  const changeHandle = useCallback((mode) => {    // 模式切換行爲
    if (mode === 'order') {
      console.log('切換到隨機模式');
      setMode('random');
    } else if (mode === 'random') {
      console.log('切換到循環模式');
      setMode('loop');
    } else if (mode === 'loop') {
      console.log('切換到順序模式');
      setMode('order');
    }
  }, []);

  return (
    <div>
      <Button onClick={() => changeHandle(mode)}>切換模式</Button>
      <div>{mode.text}</div>
    </div>
  );
}

在上面的實現中,能夠看到模式的切換依賴於上一個狀態,在「順序播放-隨機播放-循環播放」三個模式中依次切換。目前只有三種模式,可使用簡單的 if...else 方式實現,但一旦模式多了便會十分難以維護和擴展,所以,針對這種行爲依賴於狀態的場景,當分支增加到必定程度時,便須要考慮使用「狀態模式」從新設計。

function Mode() {
  /* 普通的狀態模式實現 */
  const [mode, setMode] = useState({});

  useEffect(() => {
    const MODE_MAP = {
      order: {
        text: 'order',
        press: () => {
          console.log('切換到隨機模式');
          setMode(MODE_MAP.random);
        },
      },
      random: {
        text: 'random',
        press: () => {
          console.log('切換到循環模式');
          setMode(MODE_MAP.loop);
        },
      },
      loop: {
        text: 'loop',
        press: () => {
          console.log('切換到順序模式');
          setMode(MODE_MAP.order);
        },
      }
    };
    setMode(MODE_MAP.order);
  }, []);

  return (
    <div>
      <Button onClick={() => mode.press()}>切換模式</Button>
      <div>{mode.text}</div>
    </div>
  );
}

React 官網中對 useReducer 的解釋中提到「在某些場景下,useReducer 會比 useState 更適用,例如 state 邏輯較複雜且包含多個子值,或者下一個 state 依賴於以前的 state 等」。這裏着重看一下後一個場景,「類的行爲是基於它的狀態改變」的「狀態模式」就是一種典型的依賴上一個狀態的場景,這使 useReducer 自然的適用於多狀態切換的業務場景。

/* 藉助 reducer 更便捷實現狀態模式 */
const reducer = (state) => {
  switch(state) {
    case 'order':
      console.log('切換到隨機模式');
      return 'random';
    case 'random':
      console.log('切換到循環模式');
      return 'loop';
    case 'loop':
      console.log('切換到順序模式');
      return 'order';
  }
};

function Mode() {
  const [mode, dispatch] = useReducer(reducer, 'order');

  return (
    <div>
      <Button onClick={dispatch}>切換模式</Button>
      <div>{mode.text}</div>
    </div>
  );
}

自定義 Hook 封裝原則

自定義 Hook 是 React Hook 廣受歡迎的重要緣由,然而一個抽象不佳的自定義 Hook 卻可能極大增長了維護成本。在《The Ugly Side of React Hooks 》「The hidden side effect」 章節中就列舉了一個層層嵌套的反作用帶來的異常排查成本。咱們常常說設計原則和設計模式有助於提升代碼的可維護性和可擴展性,那麼有哪些原則/模式可以幫助咱們優雅地封裝自定義 Hooks 呢?

OS:「如何優雅地封裝自定義 Hooks」 是一個很大的話題,這裏僅僅拋轉引玉講述幾個觀點。

第零要義:存在數據驅動

在比較 Hooks 和類組件開發模式時,經常提到的一點就是 Hooks 有助於咱們在組件間實現更普遍的功能複用。因而,剛開始學習 Hooks 時,對於任何可能有複用價值的功能邏輯,我常常矯枉過正地封裝成奇奇怪怪的 Hooks,好比針對「在用戶關閉通知且當天第一次打開時,進行二次提醒打開」這麼一個功能,我抽象了一個 useTodayFirstOpen

// 🔴 Bad case
function useTodayFirstOpen() {
  const [status, setStatus] = useState();
  const [isTodayFirstOpen, setIsTodayFirstOpen] = useState(false);

  useEffect(() => {
    // 獲取用戶狀態
    const fetchStatus = async () => {
      const res = await getUserStatus();
      setStatus(res);
    };
    fetchStatus();
    // 判斷今天是否首次打開
    const value = window.localStorage.getItem('isTodayFirstOpen');
    if (!value) {
      setIsTodayFirstOpen(true);
    } else {
      const curr = getNowDate();
      setIsTodayFirstOpen(curr !== value);
    }
  }, []);

  useEffect(() => {
    if (status <= 0) {
      // 未打開時進行二次提醒
      setTimeout(() => {
        tryToPopConfirm({
          onConfirm: () => {
              setStatus(1);
            updateUserStatus(1);
          },
        });
      }, 300);
      
      window.localStorage.setItem('isTodayFirstOpen', Date.now())
    }
  }, [status, isTodayFirstOpen]);
}

事實上,它並無返回任何東西,在組件內調用時也僅僅是 useTodayFirstOpen() 。回過頭來,這塊功能並無任何的外部數據流入,也沒有數據流出,徹底能夠將其抽象爲一個高階函數,而不是一個自定義 Hooks。所以具備複用價值且與外部存在數據驅動關係的功能模塊纔有必要抽象爲自定義 Hooks。

第一要義:單一職責原則

單一職責原則(SRP)建議一個方法僅有一個引發變動的緣由。自定義 Hooks 本質上就是一個抽象方法,方便實現組件內邏輯功能的複用。可是若是一個 Hooks 承擔了太多職責,則可能形成某一個職責的變更會影響到另外一個職責的執行,形成意想不到的後果,也增長了後續功能迭代過程當中出錯的機率。至於何時應該拆分,參照 SRP 中推薦的職責分離原則,在 Hooks 中更適合解釋爲「若是引發兩個數據變動的緣由不是同一個時,則應該將二者分離」。

useTodayFirstOpen 爲例,假設外界還有 Switch 控件須要根據 status 作展現與交互:

function useTodayFirstOpen() {
  const [status, setStatus] = useState();
  const [isTodayFirstOpen, setIsTodayFirstOpen] = useState(false);

  // ...
  
  const updateStatus = async (val) => {
    const res = await updateUserStatus(val);
    // dosomething...
  }
  
  return [status, updateStatus];
}

假設 getUserStatus 的返回格式發生了改變,須要修改該 Hook。

function useTodayFirstOpen() {
  const [status, setStatus] = useState();
  const [isTodayFirstOpen, setIsTodayFirstOpen] = useState(false);

  useEffect(() => {
    // 獲取用戶狀態
    const fetchStatus = async () => {
      const res = await getUserStatus();
      setStatus(res.notice);
    };
    fetchStatus();
    // ...
  }, []);

  // ...
}

假設再有一天,監管反饋天天二次提醒頻率過高了,要求改成每週「二次提醒」,又須要再次重構該 Hook。

function useThisWeekFirstOpen() {
  const [status, setStatus] = useState();
  const [isThisWeekFirstOpen, setIsThisWeekFirstOpen] = useState(false);

  useEffect(() => {
    // 獲取用戶狀態
       // ...
    // 判斷今天是否首次打開
    const value = window.locaStorage.getItem('isThisWeekFirstOpen');
    if (!value) {
      setIsTodayFirstOpen(true);
    } else {
      const curr = getNowDate();
      setIsThisWeekFirstOpen(diffDay(curr, value) >= 7);
    }
  }, []);

  // ...
}

這顯然違背了單一職責原則,此時須要考慮分離 status...FirstOpen 邏輯,使其更加通用,再以組合的形式抽象爲業務 Hook。

// 用戶狀態管理
function useUserStatus() {
  const [status, setStatus] = useState();

  const fetchStatus = async () => {
    const res = await getUserStatus();
    setStatus(res);
  };

  useEffect(() => {
    fetchStatus();
  }, []);

  const updateStatus = useCallback(async (type, val) => {
    const res = await updateUserStatus(type, val);
    if (res) {
      console.log('設置成功');
      fetchStatus();
    } else {
      console.log('設置失敗');
    }
  }, []);

  return [status, updateStatus];
}

// 二次提醒
function useSecondConfirm(key, gapDay, confirmOptions = {}) {
  const [isConfirm, setIsConfirm] = useState(false);
  
  const showConfirm = useCallback(() => {
    const curr = Date.now();
    const lastDay = window.localStorage.getItem(`${key}_lastCheckDay`);
    if (!lastDay || diffDay(curr, lastDay) > gapDay) {
      setTimeout(async () => {
        tryToPopConfirm({
          title: confirmOptions.title,
          content: confirmOptions.content,
          onConfirm: () => setIsConfirm(true),
        });
      }, 300);
      window.localStorage.setItem(`${key}_lastCheckDay`, curr);
    }
  }, [gapDay]);

  return [isConfirm, showConfirm];
}

function useStatusWithSecondConfirm(type, gapDay, confirmOptions) {
  const [userStatus, setUserStatus] = useUserStatus();  
  const [isConfirm, showConfirm] = useSecondConfirm(type, gapDay, confirmOptions);
  // 關閉狀態二次提醒用戶是否打開
  useEffect(() => {
    console.log(userStatus);
    if (userStatus && userStatus[type] <= 0) {
      showConfirm();
    }
  }, [userStatus]);

  // 確認後修改用戶狀態
  useEffect(() => {
    if (isConfirm) {
      setUserStatus(type, 1);
    }
  }, [isConfirm]);

  return [userStatus ? userStatus[type] : null, setUserStatus];
}

// 使用時
function Component() {
  const [status, setStatus] = useStatusWithSecondConfirm(
    'notice', 
    1,
    {
        title: '是否打開提醒',
        content: '打開通知以免錯太重要信息'
    }
  );

  return (
    <>
      <label>打開消息提醒</label>
      <Switch
        checked={status}
        onChange={setStatus}
      />
    </>
  );
}

改造以後,若是獲取/設置用戶狀態的接口發生變更,則修改 useUserStatus;若是二次提醒的效果須要改動(如上報日誌),則修改 useSecondConfirm;若是業務上調整了二次提醒邏輯(會員不二次提醒),則僅需修改 useStatusWithSecondConfirm ,各自定義 Hooks 各司其職。

image-20210718161038859.png

第 n + 1 要義努力探索中……,留個坑,之後有新的想法再繼續分享

總結

說實話,本文的確有蹭 「React Hooks」 熱點的嫌疑(手動狗頭),但不得不說數據結構與設計模式是 yyds,它可以指導咱們開發複雜系統中尋得一條清晰的道路,那既然都說 Hooks 難以維護,那就嘗試讓「神」來拯救這混亂的局面。對於「設計模式是否有助於咱們寫出更優雅的 Hooks 」這個問題,看完前面的章節,相信你心中也有本身的答案,固然,本文並非爲了辯論「 Hooks 是否強於類開發」這一話題,若是有興趣的話,歡迎加入 ES2049,說不定還能遇上下一場辯論(~ ̄▽ ̄)~

做者:ES2049 / 林木木
文章可隨意轉載,但請保留此原文連接。
很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj@alibaba-inc.com
相關文章
相關標籤/搜索