项目地址:git地址小二阅读器
项目主要基于一个开源阅读器哦豁阅读器,为了更加深刻地理解react,我重构了一个纯react版的,没有使用redux,在这里跟大家分享一下过程。
首先看一下效果:
下面是开发过程中用到的npm库,这里列一下,后面使用时会单独指出来:
项目依赖库:
- antd 蚂蚁金服的一个react界面库,阅读器的界面主要基于此ui库
- react-router-dom react路由库
- store js本地存储库,主要用于书籍列表的本地存储,另外由于没有使用redux,各个组件的state的一致性也基于store库
- whatwg-fetch 异步请求fetch库,主要用于获取书籍信息
- react-tappable react点击组件库,用于获取用户点击事件
项目依赖开发库:
webpack、webpack-dev-server、babel-core、babel-loader、babel-preset-env、babel-preset-react、babel-plugin-import、css-loader、less-loader、style-loader、url-loader
主要用到的库就这些,下面进入正题。
一、 webpack配置项目
由于项目没有使用脚手架create_react_app,需要自己从头开始配置,这样也是为了更好地理解开发过程。
- cd '项目目录'
- npm init 生成package.json文件
- npm install --save react react-dom antd react-router-dom store whatwg-fetch react-tappable
- npm install --save-dev webpack webpack-dev-server babel-core babel-loader babel-preset-env babel-preset-react babel-plugin-import css-loader style-loader less-loader url-loader
上述完成后在项目目录里创建webpack.config.js和.babelrc文件,其中webpack.config.js内容如下:
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' } ] } }
{ "presets": ["react","env"],"plugins": [["import",{ "libraryName": "antd","style": true }]] }
另外在package.json文件中设置
"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,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,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',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)'}}></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;