代碼地址以下:
http://www.demodashi.com/demo/12052.htmljavascript
在線地址:http://myreader.linxins.comcss
手機掃碼體驗:html
store的設計與實現前端
書架java
effects 的邏輯處理node
本地存儲 redux-persistreact
UI部分webpack
刪除實現ios
優化web
最後
本項目沒有使用任何腳手架工具和ui框架,由於本項目比較小,在時間容許的狀況下,仍是但願儘量本身走一遍流程。
開發環境依然是react全家桶,基於最新版的webpack3
、react15.6
、react-router4
、redux
、redux-saga
實現,就是不折騰不痛快。過程當中略有小坑,好比熱更新啦,dll動態連接庫啦,preact不兼容啦,以及最新版本帶來的不兼容什麼的,不過都已經被社區大神趟平了。
首先來實現閱讀器部分,關於電子閱讀器咱們能夠總結出三個核心概念:書源、章節列表和章節內容。換源就是在書源中切換、跳轉章節就是在章節列表中切換,咱們只須要記錄當前書源和當前章節就能夠完整保存用戶閱讀進度。至於書籍詳情固然也不能少,咱們得知道當前到底看的是那一本書。
reader表明閱讀器和當前書籍,這裏咱們跳過優質書源,緣由你們都懂。
╮( ̄▽ ̄)╭
const initState = { id: null, // 當前書籍id,默認沒有書籍 currentSource: 1, // 當前源下標:默認爲1,跳過優質書源 currentChapter: 0, // 當前章節下標 source: [], // 源列表 chapters: [], // 章節列表 chapter: {}, // 當前章節 detail: {}, // 書籍詳情 menuState: false, // 底部菜單是否展開,默認不展開 }; function reader(state = initState, action) { switch (action.type) { case 'reader/save': return { ...state, ...action.payload, }; case 'reader/clear': return initState; default: return { ...state, }; } } export default reader;
由於咱們並非要作只能閱讀一本書的雞肋,咱們要的是能在多本書籍之間快速切換,不但可以保存閱讀進度(當前書源和當前章節),而且能夠在緩存中讀取數據,過濾掉那些沒必要要的服務器請求。
爲此,咱們能夠模仿現實中的書架來實現這個功能:前面提到的reader是當前正在閱讀的書籍,它是完整的包含了一本書籍全部信息的個體,而書架則是不少個這樣的個體的集合。所以切換書籍的動做,其實就是將書籍放回書架,再從書架中拿出一本書的過程,若是在書架中找到了這本書,便直接取出,進而獲得上次閱讀這本書的所有數據,若是沒有找到這本書,就從服務器獲取並初始化閱讀器。
function store(state = {}, action) { switch (action.type) { case 'store/put': { // 將書籍放入書架 if (action.key) { return { ...state, [action.key]: { ...state[action.key], ...action.payload, }, }; } else { return { ...state, }; } } case 'store/save': // 初始化書架 return { ...state, ...action.payload, }; case 'store/delete': // 刪除書籍 return { ...state, [action.key]: undefined, }; case 'store/clear': // 清空書架 return {}; default: return { ...state, }; } } export default store;
獲取書源,能夠說是項目中最核心的功能了。其實這個方法叫換源有些欠妥,應該叫作換書。主要功能就是實現了上文提到的將當前閱讀書籍放回書架,並取出新書這個功能。而且這個方法只有在閱讀一本新書時纔會調用。
要考慮的狀況基本就是用戶第一次打開應用,沒有當前閱讀書籍,此時直接獲取書源進行下一步下一步便可。當用戶已經在看一本書,而且切換到同一本書時,直接返回,若是切換到另外一本書,則將當前數據連同書籍信息一塊兒打包放回書架,固然在此以前要先查看書架中有無這本書,有則取出,無則繼續獲取書源。須要注意的是,這裏不要使用數組,而是將書籍id做爲鍵值存在書架中,這會使得獲取和查找都十分方便。
須要注意的一點是,項目本質上是web應用,用戶可能從url進入任意頁面,因此要作好異常狀況的處理,例如沒有書籍詳情等。
/** * 獲取書源 * @param query */ function* getSource({ query }) { try { const { id } = query; // 這裏得到整個緩存中的store,並對應上reader的store。其reader的store結構參考store/reducer/reader.js initState const { reader: { id: currentId, detail: { title } } } = yield select(); if (currentId) { if (id !== currentId) { const { reader, store: { [id]: book } } = yield select(); console.log(`將《${title}》放回書架`); yield put({ type: 'store/put', payload: { ...reader }, key: currentId }); yield put({ type: 'reader/clear' }); if (book && book.detail && book.source) { console.log(`從書架取回《${book.detail.title}》`); yield put({ type: 'reader/save', payload: { ...book } }); return; } } else { return; } } let { search: { detail } } = yield select(); yield put({ type: 'common/save', payload: { loading: true } }); if (!detail._id) { console.log('詳情不存在,前往獲取'); detail = yield call(readerServices.getDetail, id); } const data = yield call(readerServices.getSource, id); console.log(`從網絡獲取《${detail.title}》`); yield put({ type: 'reader/save', payload: { source: data, id, detail } }); console.log(`閱讀:${detail.title}`); yield getChapterList(); } catch (error) { console.log(error); } yield put({ type: 'common/save', payload: { loading: false } }); }
獲取章節列表和章節內容比較簡單,只需稍稍作些異常狀況的處理便可。
/** * 章節列表 */ function* getChapterList() { try { const { reader: { source, currentSource } } = yield select(); console.log('獲取章節列表', currentSource, source.length, JSON.stringify(source)); if (currentSource >= source.length) { console.log('走到這裏說明全部書源都已經切換完了'); yield put({ type: 'reader/save', payload: { currentSource: 0 } }); yield getChapterList(); return; } const { _id, name = '未知來源' } = source[currentSource]; console.log(`書源: ${name}`); const { chapters } = yield call(readerServices.getChapterList, _id); yield put({ type: 'reader/save', payload: { chapters } }); yield getChapter(); } catch (error) { console.log(error); } } /** * 獲取章節內容 */ function* getChapter() { try { const { reader: { chapters, currentChapter, downloadStatus, chaptersContent } } = yield select(); if (downloadStatus) { // 已下載直接從本地獲取 const chapter = chaptersContent[currentChapter || 0]; console.log(`章節: ${chapter.title}`); yield put({ type: 'reader/save', payload: { chapter } }); window.scrollTo(0, 0); } else { const { link } = chapters[currentChapter || 0]; yield put({ type: 'common/save', payload: { loading: true } }); const { chapter } = yield call(readerServices.getChapter, link); if (chapter) { console.log(`章節: ${chapter.title}`); yield put({ type: 'reader/save', payload: { chapter } }); window.scrollTo(0, 0); } else { console.log('章節獲取失敗'); yield getNextSource(); } } } catch (error) { console.log(error); } yield put({ type: 'common/save', payload: { loading: false } }); }
同是核心功能,這個必須有。換源其實很是簡單,作一個智(sha)能(gua)換源吧(根據書源獲取具體章節
,若是獲取不到就拿下一個書源再獲取其具體章節
,直到獲取到正確的爲止)。
換源其實就是操做標記書源的指針,這很容易,咱們關心的是什麼時候換源。通過測試,發現獲取章節列表這一步幾乎都沒有問題,錯誤基本上是發生在獲取具體章節
這一步。所以,咱們只要在章節列表中稍做判斷便可實現自動換源。換源方法以下。
/** * 獲取下一個書源。 * 在獲取書源後沒法獲取 具體章節 便會獲取下一個書源。直到全部書源換完爲止 */ function* getNextSource() { try { const { reader: { source, currentSource } } = yield select(); let nextSource = (currentSource || 1) + 1; console.log(`開始第${nextSource}個書源`); if (nextSource >= source.length) { console.log('沒有可用書源,切換回優質書源'); nextSource = 0; } console.log(`正在嘗試切換到書源: ${source[nextSource] && source[nextSource].name}`); yield put({ type: 'reader/save', payload: { currentSource: nextSource } }); yield getChapterList(); } catch (error) { console.log(error); } }
效果以下,當1號書源出錯後咱們自動跳轉到下一個書源,很方便有木有。
很是簡單,稍微作下異常處理就好。
function* goToChapter({ payload }) { try { const { reader: { chapters } } = yield select(); const nextChapter = payload.nextChapter; if (nextChapter > chapters.length) { console.log('沒有下一章啦'); return; } if (nextChapter < 0) { console.log('沒有上一章啦'); return; } yield put({ type: 'reader/save', payload: { currentChapter: nextChapter } }); yield getChapter(); } catch (error) { console.log(error); } }
考慮到節約流量問題,獲取一個可用的書源後對每一個章節去下載相應的章節內容,而後存儲在本地(chaptersContent)。
/** * 離線下載書籍 獲取書源 * @param query */ function* downGetSource({ query }) { try { const { id, download } = query; // 這裏得到整個緩存中的store,並對應上reader的store。其reader的store結構參考store/reducer/reader.js initState // 同時獲取該書是否下載的狀態 const { reader: { id: currentId, detail: { title } } } = yield select(); console.log(`當前書信息currentId:${currentId} , id:${id}, title:${title}`); if (download) { const judgeRet = yield findBookByStoreId(id); console.log('判斷返回的結果:', judgeRet); if (judgeRet.has && judgeRet.downloadStatus) { console.log('已下載,直接閱讀'); yield put({ type: 'reader/save', payload: { downloadStatus: true } }); return; } yield put({ type: 'common/save', payload: { loading: true } }); let { search: { detail } } = yield select(); if (!detail._id) { console.log('下載時詳情不存在,前往獲取'); detail = yield call(readerServices.getDetail, id); } // 得到的全部書源 const sourceList = yield call(readerServices.getSource, id); let sourceIndex = 0; // 標記書源當前腳標 let chapterList = []; // 初始化可用章節列表 // 循環得到一個可用的書源,達到自動換源的效果 for (let i = 0, len = sourceList.length; i < len; i += 1) { if (sourceList[i].name !== '優質書源') { const { chapters } = yield call(readerServices.getChapterList, sourceList[i]._id); if (chapters.length) { const { chapter, ok } = yield call(readerServices.getChapter, chapters[i].link); if (ok && chapter) { console.log(`成功獲取一個書源 index: ${sourceIndex} 章節總數 ${chapters.length}`); console.log('要下載的書源', sourceList[sourceIndex]); // 成功獲取一個書源,並將相關信息先存下來 yield put({ type: 'reader/save', payload: { source: sourceList, id, detail, chapters, chapter, downloadPercent: 0, currentSource: sourceIndex, currentChapter: 0 } }); chapterList = chapters; break; } } } sourceIndex += 1; } // 開始循環章節得到章節內容,並保存在本地 const chaptersContent = []; // 章節列表及其內容 for (let i = 0, len = chapterList.length; i < len; i += 1) { const { chapter } = yield call(readerServices.getChapter, chapterList[i].link); chaptersContent[i] = chapter; // 添加下載進度 yield put({ type: 'reader/save', payload: { downloadPercent: (i / len) * 100 } }); } // 取消下載進度 yield put({ type: 'reader/save', payload: { downloadPercent: 0 } }); console.log('保存的章節內容', chaptersContent); yield put({ type: 'reader/save', payload: { chaptersContent } }); // 沒有下載 if (!judgeRet.downloadStatus) { const { reader, store: { [id]: book }, search: { detail: searchDetail } } = yield select(); reader.downloadStatus = true; // 設定已下載 console.log('將書籍存入書架'); yield put({ type: 'store/put', payload: { ...reader }, key: id }); yield put({ type: 'reader/clear' }); if (book && book.detail && book.source) { // 若是原書架中有對應的書則取出,不然用當前的書 console.log(`從書架取回《${book.detail.title}》`); yield put({ type: 'reader/save', payload: { ...book } }); } else { console.log('原書架沒書,用當前書'); yield put({ type: 'reader/save', payload: { ...reader } }); } searchDetail.downloadStatus = true; yield put({ type: 'search/save', payload: { searchDetail } }); } } } catch (error) { console.log(error); } yield put({ type: 'common/save', payload: { loading: false } }); }
這裏我們使用了 redux-persist
來作本地存儲,很是方便,redux先關數據自動存儲和獲取
import { REHYDRATE } from 'redux-persist/constants'; /** * 本地存儲調用 * @param payload */ function* reStore({ payload }) { try { const { reader, store, setting } = payload; yield put({ type: 'reader/save', payload: { ...reader } }); yield put({ type: 'store/save', payload: { ...store } }); yield put({ type: 'setting/save', payload: { ...setting } }); } catch (error) { console.log(error); } } export default [ takeLatest(REHYDRATE, reStore), ];
以上基本上已經完整實現了閱讀器的核心部分,至於搜索和詳情頁,限於篇幅再也不贅述。
本想使用material-ui,但它實在是過重了,而我但願這個項目是輕量且高效的,最後仍是決定自行設計ui。
首頁比較糾結,曾經放了不少自覺得炫酷的高斯模糊和動畫,但過多的效果會下降體驗,最終仍是選擇了走了簡潔的路子。
上半部分是當前閱讀書籍,僅顯示一些關鍵信息。下半部分是書架,存放以往的閱讀進度。
從redux獲取數據
function mapStateToProps(state) { const { detail } = state.reader; const list = state.store; const store = Object.keys(list).map((id) => { // 找出書架上全部書籍的詳細信息 return list[id] ? list[id].detail : {}; }).filter((i) => { // 過濾掉異常數據和當前閱讀 return i._id && i._id !== detail._id; }); return { store, // 若是是一本書都沒有,推薦src/utils/recommond.js的第一個《鬥破蒼穹》 current: detail._id ? detail : recommend, }; }
ok,扯了許久,終於見到本尊了,這是閱讀器最核心的頁面,談不上有什麼設計,就是追求簡潔易用。
主體部分就是原生的body
,這樣滾動起來會很是流暢。須要注意下api
提供的數據如何顯示在react
中。代碼很短,大意就是將換行符做爲依據轉換成數組顯示,這樣方便設置css樣式。
export default ({ content, style }) => (<div className={styles.content} style={style}> { content && content.split('\n').map(i => <p>{i}</p>) } </div>);
稍微體驗下能夠發現,頭部可收縮,顯示當前書籍和當前章節,以及一個關閉按鈕。基於react-headroom
組件實現。
爲了追求簡潔,咱們把菜單作成一個可展開以及關閉的形式,點擊右側的按鈕會在頁面最下方顯示出菜單,這樣更方便隨時能夠查看下一章、上一章、章節列表、設置。
菜單隻有4個,設置、章節列表、上一章和下一章。點擊設置會彈出框,支持換膚和調節字體大小,這些只是基本的,有時間再作亮度調節自動翻頁和語音朗讀吧。實現方法很簡單,貼出這段代碼你必定秒懂。
this.stopEvent = (e) => { // 阻止合成事件間的冒泡 e.stopPropagation(); // 阻止合成事件與最外層document上的事件間的冒泡 e.nativeEvent.stopImmediatePropagation(); e.preventDefault(); return false; };
章節列表更(mei)加(you)簡(yong)易(xin),稍微注意下如何將當前章節顯示在列表中吧。我是利用錨點連接實現的,再配合一個sider
組件,某修仙傳幾千章節跳轉起來也很輕鬆。
// 滑動頂部進度條 sider this.skip = () => { setTimeout(() => { document.getElementById(this.range.value).scrollIntoView(false); }, 100); }
提及來很好實現,無非是先預設一套主題參數,須要哪一個點那個。
export const COLORS = [ { background: '#b6b6b6', }, { background: '#999484', }, { background: '#a0b89c', }, { background: '#cec0a4', }, { background: '#d5b2be', }, { color: 'rgba(255,255,255,0.8)', background: '#011721', }, { color: 'rgba(255,255,255,0.7)', background: '#2c2926', }, { background: '#c4ada4', }, ];
在redux
中維護一個setting字段,專門放用戶設置。在閱讀器中獲取並設置爲主題便可。
function mapStateToProps(state) { const { chapter, chapters, currentChapter = 0, detail, menuState } = state.reader; const { logs } = state.common; return { logs, chapter, chapters, detail, currentChapter, menuState, ...state.setting, }; }
切換皮膚的時候將新的數據保存到redux就實現了換膚功能。
// 設置主題顏色 this.setThemeColor = (key, val) => { this.props.dispatch({ type: 'setting/save', payload: { [key]: val, }, }); }; // 調整字體大小 this.setFontSize = (num) => { const fontSize = this.props.style.fontSize + num; this.props.dispatch({ type: 'setting/save', payload: { style: { ...this.props.style, fontSize, }, }, }); };
爲了避免再增長新的ui,決定使用長按刪除。可是這個列表不只須要支持長按和短按,還須要支持滾動,我又不想使用hammer.js
這種重型庫,只得手寫了一個同時支持長按和短按的組件。
export default ({ children, onPress, onTap }) => { let timeout; let pressed = false; let cancel = false; function touchStart() { timeout = setTimeout(() => { pressed = true; if (onPress) onPress(); }, 500); return false; } function touchEnd() { clearTimeout(timeout); if (pressed) { pressed = false; return; } if (cancel) { cancel = false; return; } if (onTap) onTap(); return false; } function touchCancel() { cancel = true; } return (<div onTouchMove={touchCancel} onTouchCancel={touchCancel} onTouchStart={touchStart} onTouchEnd={touchEnd} > { children } </div>); };
至於長按彈窗的ui我懶得設計了,短期也作不出什麼好的效果,仍是繼續使用sweet-alert2
吧,這個插件着實不錯。
至此咱們已經實現了所有功能和ui。
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0;" name="viewport" /> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta name="apple-mobile-web-app-capable" content="yes"> // 這個比較重要,能夠在ios系統自帶safari中添加到主屏幕,這條設置會啓用全屏模式,體驗不錯 <meta name="apple-mobile-web-app-status-bar-style" content="black"> <link rel="apple-touch-icon-precomposed" href="icon.png"/> <link rel="apple-touch-startup-image" sizes="2048x1496" href=""> <link rel="apple-touch-icon" href="icon.png"/>
* { user-select: none; // 禁止用戶選中文本 -webkit-appearance: none; // 改變按鈕默認風格 -webkit-touch-callout: none; // 禁用系統默認菜單 } input { user-select: auto; -webkit-touch-callout: auto; // 解除對input組件的限制,不然沒法正常輸入 }
解決fetch瀏覽器不兼容問題
import 'fetch-polyfill';
若是 viewport meta
標籤 中設置了 width=device-width
, Android
上的 Chrome 32+
會禁用 300ms 延時。
import FastClick from 'fastclick'; FastClick.attach(document.body);
你懂得,移除移動端300毫秒延遲,不過這會帶來其餘問題,好比長按事件異常,滾動事件異常什麼的。由於滑動touchmove觸發了touchend事件,須要先取消掉touchstart上掛載的動做。
項目初期打包後居然有700k+,首次加載速度不忍直視。前面已經提到,放棄各類框架和動畫以後,體積已經大幅減小。不過有react,react-router,redux,redux-saga這些依賴在,體積再小也小不到那裏去。但好消息是咱們可使用preact替換react,從而節省約120kb左右。
只須要安裝preact並設置別名便可。此處有幾個小坑,一是別名的第三句,找了很久纔在有個issue下發現,沒有就沒法運行。二是preact和react-hot-loader不太兼容,一塊兒用會致使熱更新失效。三是preact仍然有不兼容react的地方,須要仔細驗證。
npm i -S preact preact-compat resolve: { alias: { react: 'preact-compat', 'react-dom': 'preact-compat', 'preact-compat': 'preact-compat/dist/preact-compat', //比較坑的是最後一句官網並未給出,致使一直報錯,找了好久 }, },
以及一系列優化以及gzip以後,項目index.js減少到了240kb,相比初期只有十分之一大小。
項目中全部數據來自追書神器,很是感謝!!
喜歡的同窗能夠star
哦,歡迎提出建議。
本項目僅做用於在實戰中學習前端技術,請勿他用。
在線地址:MyReader
cnpm i -D babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-stage-0 babel-preset-react webpack webpack-dev-server html-webpack-plugin eslint@^3.19.0 eslint-plugin-import eslint-loader eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-react babel-plugin-import file-loader babel-plugin-transform-runtime babel-plugin-transform-remove-console redux-devtools style-loader less-loader css-loader postcss-loader autoprefixer rimraf extract-text-webpack-plugin copy-webpack-plugin react-hot-loader@next less cnpm i -S react react-dom react-router react-router-dom redux react-redux redux-saga material-ui@next material-ui-icons fetch-polyfill cnpm i -S preact preact-compat react-router react-router-dom redux react-redux redux-saga proxy: { '/api': { target: 'http://api.zhuishushenqi.com/', changeOrigin: true, pathRewrite: { '^/api': '' }, }, '/chapter': { target: 'http://chapter2.zhuishushenqi.com/', changeOrigin: true, pathRewrite: { '^/api': '' }, }, '/agent': { target: 'http://statics.zhuishushenqi.com/', changeOrigin: true, pathRewrite: { '^/api': '' }, }, },
(The MIT License)
Copyright (c) 2017 linxins liufulin90@163.com
基於React實現的【綠色版電子書閱讀器】,支持離線下載
代碼地址以下:
http://www.demodashi.com/demo/12052.html
注:本文著做權歸做者,由demo大師代發,拒絕轉載,轉載須要做者受權