基於 Electron + React 的超高顏值喜馬拉雅客戶端 - Mob 誕生記

前言

最近一個月沉迷喜馬拉雅沒法自拔,聽相聲、段子、每日新聞,還有英語聽力,摸魚學習兩不誤。上班時候苦於沒有桌面端,用網頁版有些 bug,官方也不搞一個,只好本身動手了。樣式參考了一下 Moon FM /t/555343,顏值還過得去,自我感受挺好 😜😜😜javascript

簡介

Mob(モブ), 異能超能 100的男一號。java

GitHub: zenghongtu/Mobreact

基於 Electron, Umi, Dva, Antd 構建ios

功能及 UI

目前實現的功能有這些:git

  • 一個基本的音樂播放器
  • 每日必聽
  • 推薦
  • 排行榜
  • 分類
  • 訂閱
  • 聽過
  • 下載聲音
  • 搜索專輯

技術選型

技術棧:github

之因此選擇 Umi 是由於在以前項目中研究過其部分源碼,開發體驗感不錯,並且 bug 也少。 還有一個緣由是我在找模板的過程當中,看到這個大佬的模板wangtianlun/umi-electron-typescript,就直接拿來用了,大大減小了我搭建開發環境的時間,在此表示感謝~web

若是你對 Umi 和 Dva 不熟,牆裂建議去學一下,分分鐘就能夠上手,並且開發效率要提升的不要太多。typescript

開發篇

React Hooks 使用問題

在開發中,全部組件、頁面都是使用 React Hooks 進行開發的。而讓我以爲最難以琢磨的一個 hooks 非 useEffect 莫屬。axios

// ...
useEffect(() => {
  ipcRenderer.on("HOTKEY", handleGlobalShortcut);
  ipcRenderer.on("DOWNLOAD", handleDownloadStatus);
  return () => {
    ipcRenderer.removeListener("HOTKEY", handleGlobalShortcut);
    ipcRenderer.removeListener("DOWNLOAD", handleDownloadStatus);
  };
}, [volume]);
// ...
const handleGlobalShortcut = (e, hotkey) => {
  switch (hotkey) {
    case "nextTrack":
      handleNext();
      break;
    case "prevTrack":
      handlePrev();
      break;
    case "volumeUp":
      const volumeUp = volume > 0.95 ? 1 : volume + 0.05;
      handleVolume(volumeUp * 100);
      break;
    case "volumeDown":
      const volumeDown = volume < 0.05 ? 0 : volume - 0.05;
      handleVolume(volumeDown * 100);
      break;
    case "changePlayState":
      handlePlayPause();
      break;
    default:
      break;
  }
};
// ...
複製代碼

爲了減小渲染次數,我會設置了第二參數爲 [volume],但這會致使一些出乎意料的狀況,好比我觸發了changePlayState,但卻並無獲得意料中的值,這個時候設置爲 [volume, playState] 就正常了。api

緣由很簡單,由於playState不在依賴中,不會觸發從新渲染

因此這條經驗就是在使用 hook 遇到問題時,能夠先試一下添加到`useEffect·中(若是有用到這個 hook 的話)

組件複用

先來看一下預覽:

能夠發現不少組件是類似的,如何提升他們的複用,這一個提升開發效率的途徑。

在這個項目中我沒有使用高階組件,而是經過反正控制或者說是render props來進行復用,在組件的指定生命週期中進行調用。

共有三個組件在其餘多個組件和頁面中複用:

  • 頁面內容加載組件
  • 專輯封面組件
  • 專輯列表組件

頁面內容加載組件以下:

export interface Content<T, R> {
  render: (result: Result) => React.ReactNode;
  genRequestList: (params?: R[]) => Array<Promise<T>>;
  rspHandler: (rspArr: any, lastResult?: any) => Result;
  params?: R[];
}

export default function({ params, // api 的請求參數 genRequestList, // 負責返回 api 請求列表,返回值會被`Content`調用請求數據,返回值給`rspHandler` rspHandler, // 處理請求返回的`Response`值,返回值給`render` render // 負責渲染結果,將值傳遞給`render`函數中的組件 }: Content<any, any>) {
  const [loading, setLoading] = useState(true);
  const [hasError, setError] = useState(false);
  const [result, setResult] = useState(null);
  useEffect(() => {
    (async () => {
      try {
        setLoading(true);
        setError(false);
        const rspArr = await Promise.all(genRequestList(params));
        setResult(rspHandler(rspArr, result));
      } catch (e) {
        setError(true);
      } finally {
        setLoading(false);
      }
    })();
  }, [params]);
  return (
    <div className={styles.contentWrap}> {loading && !result ? ( <div className={styles.loading}> <Loading /> </div> ) : hasError ? ( <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> ) : ( render(result) )} </div> ); } 複製代碼

利用緩存提升體驗度

對 axios 的 get 請求進行封裝,對每一個請求 url 生成惟一值,若是在白名單內,存入 session storage 中,默認過時時間是 3600s,在下次訪問時,直接返回該值。

這樣作的一個問題是沒法得到最新數據,但對比體驗感來講並不那麼嚴重。

const request = ({ whitelist = [], expiry = DEFAULT_EXPIRY }) => ({
  ...instance,
  get: async (url: string, config?: AxiosRequestConfig) => {
    if (config) {
      config.url = url;
    }
    const fingerprint = JSON.stringify(config || url);
    // 判斷是否須要緩存
    const isNeedCache = !whitelist.length || whitelist.includes(url);
    // 生成惟一值
    const hashKey = hash
      .sha256()
      .update(fingerprint)
      .digest("hex");

    if (expiry !== 0) {
      const cached = sessionStorage.getItem(hashKey);
      const lastCachedTS: number = +sessionStorage.getItem(`${hashKey}:TS`);
      if (cached !== null && lastCachedTS !== null) {
        const age = (Date.now() - lastCachedTS) / 1000;
        // 若是沒有過時,就直接返回該值
        if (age < expiry) {
          return JSON.parse(cached);
        }
        // 不然清除以前的舊值
        sessionStorage.removeItem(hashKey);
        sessionStorage.removeItem(`${hashKey}:TS`);
      }
    }

    const rsp = await instance.get(url, config);

    if (isNeedCache) {
      cacheRsp(rsp, hashKey);
    }
    return rsp;
  }
});

export default request({ whitelist: [] });
複製代碼

Flex justify-content: space-between 最後一行問題

在 flex 中設置justify-content: space-between後,在最後一行會出現讓人不愉悅的狀況。

對於這個問題,個人辦法是經過計算而後填充空的div進去。

const DEFAULT_WIDTH = 130;
const DEFAULT_PAGE_COUNT = 130;
const DEFAULT_WINDOW_WIDTH = 1040;
export default function({
  siderWidth = SIDE_BAR_WIDTH,
  pageCount = DEFAULT_PAGE_COUNT,
  divWidth = DEFAULT_WIDTH
}) {
  const [fillCount, setFillCount] = useState(0);
  const handleResize = debounce(e => {
    let innerWidth: number;
    if (e) {
      innerWidth = e.target.innerWidth;
    }
    // 當前容器的寬度
    const containerWidth = innerWidth || DEFAULT_WINDOW_WIDTH - siderWidth;
    // 每一行能夠放的個數
    const rowDivCount = Math.floor(containerWidth / divWidth);
    // 須要填充的個數
    const count = rowDivCount - (pageCount % rowDivCount);
    setFillCount(count);
  }, 100);

  useEffect(() => {
    handleResize();
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);
  return (
    <>
      {fillCount
        ? // 按照填充個數填進去
          Array.from({ length: fillCount }).map((_, idx) => {
            return (
              <div
                key={idx}
                style={{ width: divWidth, height: 0 }}
                className={styles.filler}
              />
            );
          })
        : null}
    </>
  );
}
複製代碼

路由的前進與後退

umi或者說是react-router中,也只有memory-router能夠判斷是否能夠前進或者後退。

只能本身記錄一下 index,而後進行判斷。

let lastHistoryLen = 0;
const NavBar = ({ history, isLogin }) => {
  const { length, action } = history;

  const [curIndx, setCurIndx] = useState(0);
  const [suggests, setSuggests] = useState(null);
  const [text, setText] = useState('');
  const [visible, setVisible] = useState(false);

  useEffect(() => {
   // 判斷最後歷史記錄的長度是否大於當前歷史記錄長度,若是是的話,把 index 歸零
    if (lastHistoryLen > length) {
      setCurIndx(0);
    }
    lastHistoryLen = length;
  });

  const fetchSuggests = debounce(async (kw) => {
    if (!kw) {
      setSuggests(null);
      return;
    }
    const {
      data: { result },
    }: { data: SuggestRspData } = await getSuggest({ kw });
    let suggests = [...result.albumResultList, ...result.queryResultList];
    if (suggests.length < 1) {
      suggests = null;
    }
    // todo (only support albumResult now)
    setSuggests(suggests);
  }, 200);

// ...

  const handleArrowClick = (n) => {
    return () => {
      setCurIndx(curIndx + n);
      router.go(n);
    };
  };

複製代碼

如何登陸

原本想着分析一下登陸接口,可是這麼作的話,若是還要加上掃碼登陸,要花很多時間。

因而乎想到了使用 webview 嵌入登陸頁面,在登陸後,若是打開了我的頁面就說明登陸成功了。

const TARGET_URL = "www.ximalaya.com/passport/sync_set";
const COOKIE_URL = "https://www.ximalaya.com";
const WebView = ({ onLoadedSession }) => {
  const [isLoading, setLoading] = useState(true);
  useEffect(() => {
    const webview = document.querySelector("#xmlyWebView") as HTMLElement;
    const handleDOMReady = e => {
      if (webview.getURL().includes(TARGET_URL)) {
        // todo fix prevent redirect
        e.preventDefault();
        const { session } = webview.getWebContents();
        onLoadedSession(session, COOKIE_URL);
        webview.reload();
      }
    };
    const handleLoadCommit = () => {
      setLoading(true);
    };
    const handleDidFinishLoad = () => {
      setLoading(false);
    };
    webview.addEventListener("dom-ready", handleDOMReady);
    webview.addEventListener("load-commit", handleLoadCommit);
    webview.addEventListener("did-finish-load", handleDidFinishLoad);
    return () => {
      webview.removeEventListener("dom-ready", handleDOMReady);
      webview.removeEventListener("load-commit", handleLoadCommit);
      webview.removeEventListener("did-finish-load", handleDidFinishLoad);
    };
  }, []);

  const props = {
    id: "xmlyWebView",
    useragent:
      // tslint:disable-next-line:max-line-length
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36",
    src: `https://${TARGET_URL}`,
    style: { widht: "750px", height: "600px" }
  };
  return (
    <div>
      <Spin tip="Loading..." spinning={isLoading}>
        <webview {...props} />
      </Spin>
    </div>
  );
};
複製代碼

最後

但願這篇文章能對你有所幫助。

下載與體驗

相關文章
相關標籤/搜索