項目地址:git地址小二閱讀器
項目主要基於一個開源閱讀器哦豁閱讀器,爲了更加深入地理解react,我重構了一個純react版的,沒有使用redux,在這裏跟你們分享一下過程。
首先看一下效果:
css
下面是開發過程當中用到的npm庫,這裏列一下,後面使用時會單獨指出來:node
項目依賴庫:react
項目依賴開發庫:webpack
webpack、webpack-dev-server、babel-core、babel-loader、babel-preset-env、babel-preset-react、babel-plugin-import、css-loader、less-loader、style-loader、url-loadergit
主要用到的庫就這些,下面進入正題。github
因爲項目沒有使用腳手架create_react_app,須要本身從頭開始配置,這樣也是爲了更好地理解開發過程。web
上述完成後在項目目錄裏建立webpack.config.js和.babelrc文件,其中webpack.config.js內容以下:npm
const webpack = require('webpack'); module.exports = { entry: __dirname + "/src/main.js",//已屢次說起的惟一入口文件 devtool: 'eval-source-map', output: { path: __dirname + "/public",//打包後的文件存放的地方 filename: "bundle.js"//打包後輸出文件的文件名 }, plugins: [ new webpack.optimize.UglifyJsPlugin() ], devServer: { contentBase: './public', historyApiFallback: true, inline: true, //代理設置,本地開發時須要設置代理,否則沒法獲取數據 proxy: { '/api': { target: 'http://api.zhuishushenqi.com/', pathRewrite: {'^/api' : '/'}, changeOrigin: true }, '/chapter': { target: 'http://chapter2.zhuishushenqi.com/', pathRewrite: {'^/chapter' : '/chapter'}, changeOrigin: true } } }, module: { rules: [ { test: /(\.jsx|\.js)$/, use: { loader: 'babel-loader'}, exclude: /node_modules/ }, { test: /\.css$/, use: [{loader:'style-loader'},{loader:'css-loader?modules&localIdentName=[name]_[local]-[hash:base64:5]'}] }, { test: /\.(png|jpg|gif|woff|woff2)$/, loader: 'url-loader?limit=8192' }, { test: /\.less/, loader: 'style-loader!css-loader!less-loader' } ] } }
.babelrc文件內容以下:json
{ "presets": ["react", "env"], "plugins": [["import", { "libraryName": "antd", "style": true }]] }
另外在package.json文件中設置redux
"scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "webpack", "server": "webpack-dev-server --open" }
這樣在終端中運行npm run server會有一個本地測試環境
以上就是項目配置了,下面進入正式的開發過程。
這裏只簡要講一下流程,具體內容可查看項目源碼
1.閱讀器首頁組件home.js
閱讀器首頁頭部有一個搜索圖標、一個下拉圖標,點擊搜索圖標進入搜索組件,下拉圖標有‘個人’和‘關於’路由,‘個人’組件目前沒有作,‘關於組件’就是一個簡單的文字顯示組件。首頁的內容是以前經過搜索加入書架的書籍,這裏須要一個列表顯示,數據呢從本地存儲裏面獲取,這裏用到了store.js庫,搜索書籍的時候將加入書架的書籍存儲到本地localStorage中,首頁就能夠獲取相關數據了。
import React, { Component } from 'react'; import {Layout,Menu,Dropdown,Icon} from 'antd';//具體使用方法可查看antd官方文檔 import styles from './home.css'; import store from 'store/dist/store.legacy'; import { Link } from 'react-router-dom'; import BookItem from './bookItem'; const {Header,Content} = Layout; let menuPng = require('./images/menu.png');//加載圖片地址這裏用到了url-loader class App extends Component { constructor(props){ super(props); //下拉框下拉內容 this.menu = ( <Menu> <Menu.Item key='0'> <a href="#"><Icon type="user" /> 個人</a> </Menu.Item> <Menu.Item> <Link to="/about"><Icon type="copyright" /> 關於</Link> </Menu.Item> </Menu> ); this.state = { //從本地存儲中獲取書籍列表,是一個數組,數組裏面存放的是書籍信息 bookList: store.get('bookList')||[] }; //長按書籍列表刪除書籍 this.deleteBook = (key)=>{ let bookList = store.get('bookList'); let bookIdList = store.get('bookIdList'); bookList.splice(key,1); bookIdList.splice(key,1); store.set('bookList',bookList); store.set('bookIdList',bookIdList); this.setState({bookList:bookList}); } } componentDidMount(){ } render() { return ( <Layout> <Header className={styles.header}> <span className={styles.title}>小二閱讀</span> <Dropdown overlay={this.menu} placement="bottomRight"> <img src={menuPng} className={styles.dropdown}/> </Dropdown> <Link to='/search'><Icon type="search" className={styles.search}/></Link> </Header> <Content className={styles.content}> { this.state.bookList.length===0?( <div className={styles.null}>書架空空如也,快去添加吧!</div> ):this.state.bookList.map((item,index)=>( <Link to={`/read/${index}`} key={index}><BookItem data={item} deleteBook={this.deleteBook} key={index} arg={index}/></Link> )) } </Content> </Layout> ); } } export default App;
2.閱讀器搜索組件search.js
搜索組件頭部有一個返回圖標,一個輸入框和一個搜索圖標,用戶經過輸入書名點擊搜索圖標或按enter鍵進行搜索,組件裏面有一個函數獲取用戶輸入發起異步請求獲取書籍信息,這裏主要是一些antd組件的使用,方法均可以查詢官方文檔
import React from 'react'; import {Layout, Icon, Input, Spin, Tag} from 'antd'; import { Link } from 'react-router-dom'; import styles from './search.css'; import store from 'store/dist/store.legacy'; import randomcolor from 'randomcolor'; import 'whatwg-fetch'; import ResultBookItem from './resultBookItem'; import {url2Real} from "./method"; const { Header, Content } = Layout; class Search extends React.Component{ constructor(props){ super(props); this.state = { searchValue: '', bookList: [], loading: false, searchHistory: store.get('searchHistory') || [] }; this.flag = this.state.searchValue.length ? false : true; this.tagColorArr = this.state.searchHistory.map(item => randomcolor({luminosity: 'dark'})); this.clearHistory = ()=>{ let searchHistory = []; this.setState({searchHistory}); store.set('searchHistory',searchHistory); }; this.searchBook = (value)=>{ this.flag = false; value = value === undefined ? this.state.searchValue : value; if (new Set(value).has(' ') || value === '') { alert('輸入爲空!'); return; }; //更新搜索歷史 let searchHistory = new Set(this.state.searchHistory); if(!searchHistory.has(value)){ searchHistory = this.state.searchHistory; searchHistory.unshift(value); store.set('searchHistory',searchHistory); } this.tagColorArr.push(randomcolor({luminosity: 'dark'})); this.setState({loading:true,searchHistory}); //發起異步請求獲取書籍信息 fetch(`/api/book/fuzzy-search?query=${value}&start=0`) .then(res=>res.json()) .then(data => { data.books.map((item)=>{item.cover=url2Real(item.cover);}); return data.books; }) .then(data=>{ this.setState({bookList:data,loading:false}); }) .catch(err=>{console.log(err)}); } this.handleChange = (e)=>{ this.setState({ searchValue:e.target.value }); } this.wordSearch = (e)=>{ let word = e.target.textContent; this.setState({searchValue: word}); this.searchBook(word); } this.clearInput = () => { this.flag = true; this.setState({searchValue:''}); } } render(){ return ( <Layout> <Header className={styles.header}> <Link to="/"><Icon type="arrow-left" className={styles.pre}/></Link> <Input ref="search" placeholder="請輸入搜索的書名" className={styles.searchInput} value={this.state.searchValue} onChange={this.handleChange} onPressEnter={ () => this.searchBook()} suffix={<Icon type="close-circle" onClick={this.clearInput} />} /> <Icon type='search' className={styles.search} onClick={() => this.searchBook()}/> </Header> <Spin className={styles.loading} spinning={this.state.loading} tip="書籍搜索中..."> <Content className={styles.content}> { this.flag ? ( <div className='tagBox'> <h2>最近搜索歷史</h2> <div className={styles.tags}> { this.state.searchHistory.map((item, index) => <Tag onClick={this.wordSearch} className={styles.tag} color={this.tagColorArr[index]} key={index}>{item}</Tag> ) } </div> <div className={styles.clear} onClick={this.clearHistory}><Icon type="delete" />清空搜索歷史</div> </div> ) : ( this.state.bookList.length !== 0 ? this.state.bookList.map((item, index) => <ResultBookItem data={item} key={index}/>) : (<div className={styles.noResult}>沒有找到搜索結果</div>) ) } </Content> </Spin> </Layout> ) } } export default Search;
3.閱讀器書籍詳情組件bookIntroduce.js
這個組件是用戶點擊搜索出來的書籍進入的組件,會顯示相關書籍的一個較詳細信息,用戶點擊搜索出來的書籍會向組件傳入一個id,組件根據id在componentDidMount方法裏發起異步請求獲取書籍詳細信息而後顯示,用戶點擊追更新按鈕時會調用addBook函數繼續發起異步請求獲取更詳細的書籍信息並將信息保存在本地存儲中,用戶點擊閱讀按鈕時一樣會由addBook發起異步請求,不過這一次會進入閱讀界面,同時將書籍保存在本地存儲中。
import React from 'react'; import {Layout, Icon, Spin, Button, Tag, message, Modal} from 'antd'; import { Link } from 'react-router-dom'; import styles from './bookIntroduce.css'; import randomcolor from 'randomcolor'; import { time2Str, url2Real, wordCount2Str } from './method.js'; import store from 'store/dist/store.legacy'; const {Header,Content} = Layout; let errorLoading = require('./images/error.jpg'); class BookIntroduce extends React.Component{ constructor(props){ super(props); this.state={ loading:true, save:false, data:{} }; message.config({top:500,duration:2}); this.addBook = ()=>{ let dataIntroduce = this.state.data; fetch(`/api/toc?view=summary&book=${this.state.data._id}`) .then(res=>res.json()) .then(data=>{ let sourceId = data.length>1?data[1]._id:data[0]._id; for(let item of data){ if(item.source === 'my176'){ sourceId = item._id; } } dataIntroduce.sourceId = sourceId; return fetch(`/api/toc/${sourceId}?view=chapters`); }) .then(res=>res.json()) .then(data=>{ data.readIndex = 0; dataIntroduce.list = data; let localList = store.get('bookList')||[]; let localIdList = store.get('bookIdList')||[]; if(localIdList.indexOf(dataIntroduce._id)!==-1){ message.info('書籍已在書架中');return; } localList.unshift(dataIntroduce); localIdList.unshift(dataIntroduce._id); store.set('bookList',localList); store.set('bookIdList',localIdList); message.info(`《${this.state.data.title}》加入書架`); this.setState({save:true}); return; }) .catch(err=>{console.log(err)}); } this.readBook = ()=>{ this.addBook(); //react-router-dom 頁面跳轉 this.props.history.push({pathname: '/read/' + 0}); } this.deleteBook = ()=>{ let localList = store.get('bookList'); let localIdList = store.get('bookIdList'); localList.shift(); localIdList.shift(); store.set('bookList',localList); store.set('bookIdList',localIdList); this.setState({save:false}); } } componentDidMount(){ fetch(`/api/book/${this.props.match.params.id}`) .then(res=>res.json()) .then(data=>{ data.cover = url2Real(data.cover); data.wordCount = wordCount2Str(data.wordCount); data.updated = time2Str(data.updated); this.setState({data:data,loading:false}); }) .catch(err=>console.log(err)); } handleImageErrored(e){ e.target.src = errorLoading; } render(){ return ( <Layout> <Header className={styles.header}> <Link to={'/search'}><Icon type="arrow-left" className={styles.pre} /></Link> <span className={styles.title}>書籍詳情</span> </Header> <Spin className={styles.loading} spinning={this.state.loading} tip="書籍詳情正在加載中..."> <Content className={styles.content}> { this.state.loading?'':( <div> <div className={styles.box}> <img src={this.state.data.cover} onError={this.handleImageErrored}/> <p> <span className={styles.bookName}>{this.state.data.title}</span><br/> <span className={styles.bookMsg}><em>{this.state.data.author}</em> | {this.state.data.minorCate} | {this.state.data.wordCount}</span> <span className={styles.updated}>{this.state.data.updated}前更新</span> </p> </div> <div className={styles.control}> { this.state.save ? (<Button icon='minus' size='large' className={styles.cancel} onClick={this.deleteBook}>移出書架</Button>) : (<Button icon='plus' size='large' onClick={this.addBook}>加入書架</Button>) } <Button icon='search' size='large' onClick={this.readBook}>開始閱讀</Button> </div> <div className={styles.number}> <p><span>追書人數</span><br/>{this.state.data.latelyFollower}</p> <p><span>讀者留存率</span><br/>{this.state.data.retentionRatio}%</p> <p><span>日更新字數</span><br/>{this.state.data.serializeWordCount}</p> </div> <div className={styles.tags}> { this.state.data.tags.map((item, index) => <Tag className={styles.tag} color={randomcolor({luminosity: 'dark'})} key={index}>{item}</Tag> ) } </div> <div className={styles.introduce}> <p>{this.state.data.longIntro}</p> </div> </div> ) } </Content> </Spin> </Layout> ); } } export default BookIntroduce;
4.閱讀器閱讀組件read.js
閱讀組件主要獲取書籍章節信息並顯示,該組件實現了章節選取,字體調整,背景調整等功能,具體可查看源碼實現
import React from 'react'; import { Link } from 'react-router-dom' import {Layout, Spin, message, Icon, Modal} from 'antd'; import styles from './read.less'; import 'whatwg-fetch'; import store from 'store/dist/store.legacy'; const { Header, Footer } = Layout; var _ = require('underscore'); class Read extends React.Component{ constructor(props) { super(props); this.flag = true; //標記第一次進入, 判斷是否讀取上一次閱讀的scrollTop this.pos = this.props.match.params.id; //書籍在列表的序號 this.index = store.get('bookList')[this.pos].readIndex || 0; //章節號 this.chapterList = store.get('bookList')[this.pos].list.chapters; //this.readSetting = store.get('readSetting') || {fontSize: '18', backgroundColor: 'rgb(196, 196 ,196)'}; this.state = { loading: true, chapter: '', show: false, readSetting: store.get('readSetting') || {fontSize: '18', backgroundColor: 'rgb(196, 196 ,196)'}, chapterListShow: false, readSettingShow: false } this.getChapter = (index) => { if (index < 0) { message.info('已是第一章了!'); this.index = 0; return; } else if(index >= this.chapterList.length) { message.info('已是最新的一章了!'); this.index = this.chapterList.length - 1; index = this.index; } this.setState({loading: true}); let chapters = store.get('bookList')[this.pos].list.chapters; if (_.has(chapters[index], 'chapter')) { this.setState({loading: false, chapter: chapters[index].chapter}, () => { this.refs.box.scrollTop = 0; }); let bookList = store.get('bookList'); bookList[this.pos].readIndex = index; store.set('bookList', bookList); return; } fetch(`/chapter/${encodeURIComponent(this.chapterList[index].link)}?k=2124b73d7e2e1945&t=1468223717`) .then(res => res.json()) .then( data => { if (!data.ok) { message.info('章節內容丟失!'); return this.setState({loading: false}); } let content = _.has(data.chapter, 'cpContent') ? data.chapter.cpContent : data.chapter.body; data.chapter.cpContent = ' ' + content.replace(/\n/g, "\n "); data.chapter.title = this.chapterList[index].title; let bookList = store.get('bookList'); bookList[this.pos].readIndex = index; store.set('bookList', bookList); this.setState({loading: false, chapter: data.chapter}) }) .catch(error => message.info(error)) } this.nextChapter = (e) => { e.stopPropagation(); this.getChapter(++this.index); } this.preChapter = (e) => { e.stopPropagation(); this.getChapter(--this.index); } this.targetChapter = (e) => { e.stopPropagation(); this.index = e.target.id this.getChapter(this.index); this.setState({chapterListShow: false}); } this.showSetting = () => { this.setState({show: !this.state.show}); } this.fontUp = () => { let setting = {}; Object.assign(setting,this.state.readSetting); setting.fontSize++; //this.readSetting.fontSize++; this.setState({readSetting: setting}); store.set('readSetting', this.readSetting); } this.fontDown = () => { if (this.state.readSetting.fontSize <=12) { return; } let setting = {}; Object.assign(setting,this.state.readSetting); setting.fontSize--; this.setState({readSetting: setting}); store.set('readSetting', this.readSetting); } this.changeBackgroudColor = (e) => { let setting = {}; Object.assign(setting,this.state.readSetting); setting.backgroundColor = e.target.style.backgroundColor; this.setState({readSetting: setting}); store.set('readSetting', this.readSetting); } this.readScroll = () => { let bookList = store.get('bookList'); bookList[this.pos].readScroll = this.refs.box.scrollTop; store.set('bookList', bookList); } this.showChapterList = (chapterListShow) => { this.setState({ chapterListShow }); } this.downloadBook = () => { let pos = this.pos; Modal.confirm({ title: '緩存', content: ( <div> <p>是否緩存後100章節?</p> </div> ), onOk() { let bookList = store.get('bookList'); let chapters = bookList[pos].list.chapters; let download = (start, end) => { if (start > end || start >= chapters.length) { message.info('緩存完成'); return; } if(_.has(chapters[start], 'chapter')) { download(++start, end); return; } fetch(`/chapter/${encodeURIComponent(chapters[start].link)}?k=2124b73d7e2e1945&t=1468223717`) .then(res => res.json()) .then( data => { let content = _.has(data.chapter, 'cpContent') ? data.chapter.cpContent : data.chapter.body; data.chapter.cpContent = ' ' + content.replace(/\n/g, "\n "); chapters[start].chapter = data.chapter; bookList[pos].list.chapters = chapters; store.set('bookList', bookList); download(++start, end); }) .catch(error => message.info(error)) } for(let i = 0; i < bookList[pos].readIndex; i++) { delete chapters[i].chapter; } download(bookList[pos].readIndex, bookList[pos].readIndex + 100); }, onCancel() { }, }); } this.readSettingShowControl = (e) => { e.stopPropagation(); let value = !this.state.readSettingShow; this.setState({readSettingShow: value}); } } componentWillMount() { this.getChapter(this.index); // 刷新最近閱讀的書籍列表順序 let bookList = store.get('bookList'); bookList.unshift(bookList.splice(this.pos, 1)[0]); store.set('bookList', bookList); this.pos = 0; } componentDidUpdate(prevProps, prevState) { if (this.flag) { //加載上次閱讀進度 let bookList = store.get('bookList'); this.refs.box.scrollTop = _.has(bookList[this.pos], 'readScroll') ? bookList[this.pos].readScroll : 0; this.flag = false; } else if(prevState.loading !== this.state.loading){ this.refs.box.scrollTop = 0; } let list = document.querySelector('.chapterList .ant-modal-body'); if (list !== null) { list.scrollTop = 45 * (this.index - 3); } } render() { return ( <Spin className='loading' spinning={this.state.loading} tip="章節內容加載中"> <Layout > <Modal className="chapterList" title="Vertically centered modal dialog" visible={this.state.chapterListShow} onOk={() => this.showChapterList(false)} onCancel={() => this.showChapterList(false)} > { this.chapterList.map((item,index) => (<p id={index} className={parseInt(this.index, 10) == index ? 'choosed' : ''} onClick={this.targetChapter} key={index}>{item.title}</p>)) } </Modal> { this.state.show ? (() => { return ( <Header className={styles.header}> <Link to="/"><Icon type="arrow-left" className={styles.pre}/></Link> </Header> ) })() : '' } <div ref='box' className={styles.box} style={this.state.readSetting} onClick={this.showSetting} onScroll={this.readScroll}> {this.state.loading ? '' : (()=>{ return ( <div> <h3>{this.state.chapter.title}</h3> <p>{this.state.chapter.cpContent}</p> <h1 className={styles.control}> <span onClick={this.preChapter}>上一章</span> <span onClick={this.nextChapter}>下一章</span> </h1> </div> ) })()} </div> { this.state.show ? (() => { return ( <Footer className={styles.footer}> <div className={styles.setting} tabIndex="100" onClick={this.readSettingShowControl} onBlur={this.readSettingShowControl}> <Icon type="setting" /><br/>設置 { this.state.readSettingShow ? ( <div onClick={(e) => e.stopPropagation()}> <div className={styles.font}> <span onClick={this.fontDown}>Aa -</span> <span onClick={this.fontUp}>Aa +</span> </div> <div className={styles.color}> <i onClick={this.changeBackgroudColor} style={{backgroundColor: 'rgb(196, 196 ,196)'}}></i> <i onClick={this.changeBackgroudColor} style={{backgroundColor: 'rgb(162, 157, 137)'}}></i> <i onClick={this.changeBackgroudColor} style={{backgroundColor: 'rgb(173, 200, 169)'}}></i> </div> </div> ) : '' } </div> <div><Icon type="download" onClick={this.downloadBook}/><br/>下載</div> <div onClick={() => this.showChapterList(true)}><Icon type="bars" /><br/>目錄</div> </Footer> ) })() : '' } </Layout> </Spin> ) } } export default Read;
以上就是閱讀器大概內容了,固然了實際操做純react方式並不可取,可查看redux版本獲取更多知識。