基于 Electron + React 的超高颜值喜马拉雅客户端 - Mob 诞生记
前言
最近一個月沉迷喜馬拉雅無法自拔,聽相聲、段子、每日新聞,還有英語聽力,摸魚學習兩不誤。上班時候苦于沒有桌面端,用網頁版有些 bug,官方也不搞一個,只好自己動手了。樣式參考了一下 Moon FM /t/555343,顏值還過得去,自我感覺挺好 ???
簡介
Mob(モブ), 異能超能 100的男一號。
GitHub: zenghongtu/Mob
基于 Electron, Umi, Dva, Antd 構建
功能及 UI
目前實現的功能有這些:
- 一個基本的音樂播放器
- 每日必聽
- 推薦
- 排行榜
- 分類
- 訂閱
- 聽過
- 下載聲音
- 搜索專輯
技術選型
技術棧:
- Electron
- Umi
- Dva
- Antd
之所以選擇 Umi 是因為在之前項目中研究過其部分源碼,開發體驗感不錯,而且 bug 也少。 還有一個原因是我在找模板的過程中,看到這個大佬的模板wangtianlun/umi-electron-typescript,就直接拿來用了,大大減少了我搭建開發環境的時間,在此表示感謝~
如果你對 Umi 和 Dva 不熟,墻裂建議去學一下,分分鐘就可以上手,而且開發效率要提高的不要太多。
開發篇
React Hooks 使用問題
在開發中,所有組件、頁面都是使用 React Hooks 進行開發的。而讓我覺得最難以琢磨的一個 hooks 非 useEffect 莫屬。
// ... 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] 就正常了。
原因很簡單,因為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 (<divkey={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 redirecte.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>); }; 復制代碼最后
希望這篇文章能對你有所幫助。
下載與體驗
轉載于:https://juejin.im/post/5cd917f6f265da03ac0d2af6
總結
以上是生活随笔為你收集整理的基于 Electron + React 的超高颜值喜马拉雅客户端 - Mob 诞生记的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 工程图学及计算机绘图非机类答案,工程图学
- 下一篇: Python 序列数据的One Hot编