基於React實現的【綠色版電子書閱讀器】,支持離線下載

代碼地址以下:
http://www.demodashi.com/demo/12052.htmljavascript

MyReader 綠色版電子書閱讀器

main_all

在線地址:http://myreader.linxins.comcss

手機掃碼體驗:html

online-qrcode


目錄索引

store的設計與實現前端

開始

本項目沒有使用任何腳手架工具和ui框架,由於本項目比較小,在時間容許的狀況下,仍是但願儘量本身走一遍流程。

開發環境依然是react全家桶,基於最新版的webpack3react15.6react-router4reduxredux-saga實現,就是不折騰不痛快。過程當中略有小坑,好比熱更新啦,dll動態連接庫啦,preact不兼容啦,以及最新版本帶來的不兼容什麼的,不過都已經被社區大神趟平了。

store的設計與實現

首先來實現閱讀器部分,關於電子閱讀器咱們能夠總結出三個核心概念:書源章節列表章節內容。換源就是在書源中切換、跳轉章節就是在章節列表中切換,咱們只須要記錄當前書源和當前章節就能夠完整保存用戶閱讀進度。至於書籍詳情固然也不能少,咱們得知道當前到底看的是那一本書。

reader表明閱讀器和當前書籍,這裏咱們跳過優質書源,緣由你們都懂。
╮( ̄▽ ̄)╭

閱讀器

  • src/store/reducer/reader.js
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是當前正在閱讀的書籍,它是完整的包含了一本書籍全部信息的個體,而書架則是不少個這樣的個體的集合。所以切換書籍的動做,其實就是將書籍放回書架,再從書架中拿出一本書的過程,若是在書架中找到了這本書,便直接取出,進而獲得上次閱讀這本書的所有數據,若是沒有找到這本書,就從服務器獲取並初始化閱讀器。

  • src/store/reducer/store.js
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;

effects 的邏輯處理

獲取書源,能夠說是項目中最核心的功能了。其實這個方法叫換源有些欠妥,應該叫作換書。主要功能就是實現了上文提到的將當前閱讀書籍放回書架,並取出新書這個功能。而且這個方法只有在閱讀一本新書時纔會調用。

要考慮的狀況基本就是用戶第一次打開應用,沒有當前閱讀書籍,此時直接獲取書源進行下一步下一步便可。當用戶已經在看一本書,而且切換到同一本書時,直接返回,若是切換到另外一本書,則將當前數據連同書籍信息一塊兒打包放回書架,固然在此以前要先查看書架中有無這本書,有則取出,無則繼續獲取書源。須要注意的是,這裏不要使用數組,而是將書籍id做爲鍵值存在書架中,這會使得獲取和查找都十分方便。

須要注意的一點是,項目本質上是web應用,用戶可能從url進入任意頁面,因此要作好異常狀況的處理,例如沒有書籍詳情等。

獲取書源

  • src/store/effects/reader.js
/**
 * 獲取書源
 * @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 } });
}

章節列表-章節內容

獲取章節列表和章節內容比較簡單,只需稍稍作些異常狀況的處理便可。

  • src/store/effects/reader.js
/**
 * 章節列表
 */
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)換源吧(根據書源獲取具體章節,若是獲取不到就拿下一個書源再獲取其具體章節,直到獲取到正確的爲止)。

換源其實就是操做標記書源的指針,這很容易,咱們關心的是什麼時候換源。通過測試,發現獲取章節列表這一步幾乎都沒有問題,錯誤基本上是發生在獲取具體章節這一步。所以,咱們只要在章節列表中稍做判斷便可實現自動換源。換源方法以下。

  • src/store/effects/reader.js
/**
 * 獲取下一個書源。
 * 在獲取書源後沒法獲取 具體章節 便會獲取下一個書源。直到全部書源換完爲止
 */
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號書源出錯後咱們自動跳轉到下一個書源,很方便有木有。

retry

切換章節

很是簡單,稍微作下異常處理就好。

  • src/store/effects/reader.js
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)。

  • src/store/effects/reader.js
/**
 * 離線下載書籍 獲取書源
 * @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-persist 來作本地存儲,很是方便,redux先關數據自動存儲和獲取

  • src/store/effects/reader.js
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),
];

以上基本上已經完整實現了閱讀器的核心部分,至於搜索和詳情頁,限於篇幅再也不贅述。

UI部分

本想使用material-ui,但它實在是過重了,而我但願這個項目是輕量且高效的,最後仍是決定自行設計ui。

首頁

首頁比較糾結,曾經放了不少自覺得炫酷的高斯模糊和動畫,但過多的效果會下降體驗,最終仍是選擇了走了簡潔的路子。

上半部分是當前閱讀書籍,僅顯示一些關鍵信息。下半部分是書架,存放以往的閱讀進度。

從redux獲取數據

  • src/routes/IndexPage/index.js
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,
  };
}

閱讀器

reader_all

ok,扯了許久,終於見到本尊了,這是閱讀器最核心的頁面,談不上有什麼設計,就是追求簡潔易用。

主體部分就是原生的body,這樣滾動起來會很是流暢。須要注意下api提供的數據如何顯示在react中。代碼很短,大意就是將換行符做爲依據轉換成數組顯示,這樣方便設置css樣式。

  • src/routes/Reader/Content.js
export default ({ content, style }) => (<div className={styles.content} style={style}>
  { content && content.split('\n').map(i => <p>{i}</p>) }
</div>);

稍微體驗下能夠發現,頭部可收縮,顯示當前書籍和當前章節,以及一個關閉按鈕。基於react-headroom組件實現。

爲了追求簡潔,咱們把菜單作成一個可展開以及關閉的形式,點擊右側的按鈕會在頁面最下方顯示出菜單,這樣更方便隨時能夠查看下一章、上一章、章節列表、設置。

菜單隻有4個,設置、章節列表、上一章和下一章。點擊設置會彈出框,支持換膚和調節字體大小,這些只是基本的,有時間再作亮度調節自動翻頁和語音朗讀吧。實現方法很簡單,貼出這段代碼你必定秒懂。

  • src/routes/Reader/Setting.js
this.stopEvent = (e) => {
      // 阻止合成事件間的冒泡
      e.stopPropagation();
      // 阻止合成事件與最外層document上的事件間的冒泡
      e.nativeEvent.stopImmediatePropagation();
      e.preventDefault();
      return false;
};

章節列表更(mei)加(you)簡(yong)易(xin),稍微注意下如何將當前章節顯示在列表中吧。我是利用錨點連接實現的,再配合一個sider組件,某修仙傳幾千章節跳轉起來也很輕鬆。

  • src/routes/Chapters/index.js
// 滑動頂部進度條 sider
this.skip = () => {
      setTimeout(() => {
        document.getElementById(this.range.value).scrollIntoView(false);
      }, 100);
 }

換膚

提及來很好實現,無非是先預設一套主題參數,須要哪一個點那個。

  • src/utils/constants.js
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字段,專門放用戶設置。在閱讀器中獲取並設置爲主題便可。

  • src/routes/Reader/index.js
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就實現了換膚功能。

  • src/routes/Reader/Setting.js
// 設置主題顏色
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這種重型庫,只得手寫了一個同時支持長按和短按的組件。

  • src/components/Touch/index.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吧,這個插件着實不錯。

delete_all

至此咱們已經實現了所有功能和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"/>

CSS

* {
    user-select: none;
    // 禁止用戶選中文本
    
    -webkit-appearance: none;
    // 改變按鈕默認風格
    
    -webkit-touch-callout: none;
    // 禁用系統默認菜單
}

input {
    user-select: auto;
    -webkit-touch-callout: auto;
    // 解除對input組件的限制,不然沒法正常輸入
}

fetch-polyfill

解決fetch瀏覽器不兼容問題

  • src/utils/request.js
import 'fetch-polyfill';

fastclick

若是 viewport meta 標籤 中設置了 width=device-widthAndroid 上的 Chrome 32+ 會禁用 300ms 延時。

  • myreader/src/router.js
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哦,歡迎提出建議。
本項目僅做用於在實戰中學習前端技術,請勿他用。

線上環境

  • 這裏使用node環境作本地server,啓動: node server.js &

在線地址: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': '' },
  },
},

License

(The MIT License)

Copyright (c) 2017 linxins liufulin90@163.com

基於React實現的【綠色版電子書閱讀器】,支持離線下載

代碼地址以下:
http://www.demodashi.com/demo/12052.html

注:本文著做權歸做者,由demo大師代發,拒絕轉載,轉載須要做者受權

相關文章
相關標籤/搜索