From 07010d44060283e2a6e456843f73b47ba9693846 Mon Sep 17 00:00:00 2001 From: lobos Date: Sat, 22 Apr 2017 18:55:51 +0800 Subject: [PATCH] README --- README.md | 1587 +++++++++++++++++++++++++++++++++- src/components/book/index.js | 5 +- 2 files changed, 1566 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index cf67992..572febd 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,41 @@ -这是一个比较简单的demo,引导一下对React感兴趣的同学吧。感觉可能有点长,所以打算分几个部分来写。 -1、环境和依赖、编译 -2、dev server 和 react-hot-loader -3、react-router,react-redux -4、mock数据,前后端分离开发 +这是一个比较简单的demo,引导一下对React感兴趣的同学吧。假定的需求是写一个图书管理的后台。稍微有点长,大概分下面几个部分。 -现在其实有挺多start-kit,还有create-react-app这样的工具,这里不打算用这些,而是从空项目入手,让大家可以多了解一些配置是做什么用途的。另外从我的项目经验来看,前端现在的发展速度,想做一个通用的start-kit,一劳永逸是很难的。一般一个项目周期3-6个月左右,这个start-kit里面的大部分依赖包可能都升级了,很多配置可能就不可用了,比如babel5升到babel6。 +0. 环境和依赖、编译 +0. dev server +0. react-hot-loader +0. react-router +0. CSS Module +0. react-ui组件库 +0. 高阶组件 +0. mock数据 +0. CRUD +0. 在弹出层中编辑 +0. 项目结构 + +现在有挺多start-kit,还有create-react-app这样的工具,这里不打算用这些,而是从空项目入手,让大家可以多了解一些配置是做什么用途的。从我的项目经验来看,前端现在的发展速度,想做一个通用的start-kit,一劳永逸是很难的。一般一个项目周期3-6个月左右,这个start-kit里面的大部分依赖包可能都升级了,很多配置可能就不可用了,比如babel5升到babel6。 整个demo里可能会有一些“私货”,比如自己写的组件,一些开发习惯等等,如果不喜欢的话,无视就好。 -这里说一些配置环境和安装依赖包的经验: -1、添加新的东西之前,git commit一次,遇到问题跑不起来了,可以安全回到之前的环境。 -2、加一点新的东西,运行一次,不要一次加很多东西,环境跑不起来了,也没法定位是哪个的问题。 -3、最好明确的知道,为什么要用这个工具/组件?会带来什么样的收益,是否有其它更好的方式解决。使用的外部依赖越多,出现问题的概率也越高。 +这里贴出来的代码有些可能并不完整,每一段结束我都打了一个tag,可以checkout出来执行。如果有报错,先看下nodejs的版本是否大于 7.6.0,是否有依赖没有安装。 本文假设你已经了解了React的基本语法,和一些es6的语法。 -## 安装nodejs -这里推荐node v7.6.0以上版本,因为后面会使用koa2。 + +## 环境和依赖、编译 + +### 安装nodejs +这里需要node v7.6.0以上版本,因为后面会使用koa2。 可以[使用 n 或者 nvm 来管理node版本](http://yijiebuyi.com/blog/b1328ffe88cdde6b4102894635cf8f11.html) **npm** npmjs和aws国内访问比较慢,可以换淘宝的镜像源,这里在项目根目录建一个.npmrc文件 + ``` phantomjs_cdnurl=http://cnpmjs.org/downloads sass_binary_site=https://npm.taobao.org/mirrors/node-sass/ registry=https://registry.npm.taobao.org ``` + 也可以使用 [nrm 切换npm源](https://github.com/Pana/nrm),很方便。 + ``` $ npm install -g nrm $ nrm ls @@ -39,15 +50,17 @@ $ nrm ls $ nrm use taobao ``` -## 创建一个项目 +### 创建一个项目 首先创建一个空的文件夹,在下面执行 + ``` $ npm init ``` -##安装依赖包 +### 安装依赖包 + ``` -$ npm install react react-dom --save +$ npm install react react-dom prop-types --save $ npm install webpack babel-core babel-loader babel-plugin-react-require babel-plugin-transform-object-rest-spread babel-preset-es2015 babel-preset-react autoprefixer css-loader less-loader postcss-loader sass-loader style-loader url-loader file-loader less node-sass --save-dev ``` @@ -74,13 +87,15 @@ $ npm install webpack babel-core babel-loader babel-plugin-react-require babel-p - less:less-loader的依赖包 - node-sass:sass-loader的依赖包 -## 使用eslint (可选) +### 使用eslint (可选) 推荐使用eslint做代码检查,安装依赖包,这里用了airbnb的代码规范 + ``` $ npm install babel-eslint eslint eslint-config-airbnb eslint-plugin-react eslint-plugin-jsx-a11y eslint-plugin-import eslint-import-resolver-webpack --save-dev ``` 在项目根目录建一个.eslintrc文件,因为我是无分号党,所以semi设置了"never" + ``` { "extends": ["airbnb"], @@ -108,14 +123,16 @@ $ npm install babel-eslint eslint eslint-config-airbnb eslint-plugin-react eslin "rules": { // 允许js后缀 "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], + "react/forbid-prop-types": 0, // 强制无分号 "semi": [2, "never"] } } ``` -## 配置webpack config +### 配置webpack config 建一个webpack.config.js文件。这里是一个比较常用的配置,有些细节的配置可以参考相关文档。 + ``` const path = require('path') const webpack = require('webpack') @@ -242,8 +259,9 @@ module.exports = { } ``` -## 写一个Hello World,编译 +### 写一个Hello world src/index.js + ``` import React, { Component } from 'react' import ReactDOM from 'react-dom' @@ -268,6 +286,7 @@ ReactDOM.render(, document.getElementById('root')) ``` src/styles/index.scss + ``` body { font-size: 14px; @@ -275,6 +294,7 @@ body { ``` console下执行 + ``` $ node_module/.bin/webpack @@ -295,13 +315,19 @@ app.js 29.7 kB 0 [emitted] app ``` 这里可能会有一个“DeprecationWarning: loaderUtils.parseQuery()”,babel-loader的问题,下个版本应该会修复 +``` +$ git checkout step-1 +``` + ## 建一个server 我们这里使用koa2来做开发服务器,首先,安装koa + ``` -$ npm install koa koa-router koa-send save-dev +$ npm install koa koa-router koa-send http-proxy save-dev ``` 在demo文件夹下建一个index.html + ``` @@ -318,6 +344,7 @@ $ npm install koa koa-router koa-send save-dev ``` 在根目录下建一个server.js + ``` const Koa = require('koa') const send = require('koa-send') @@ -334,8 +361,8 @@ router.get('/app.js', async function (ctx) { await send(ctx, 'build/app.js') }) -// 线上会使用压缩版本的React,而在开发的时候,我们需要使用react-with-addons来查看错误信息 -// 所以这里把React和ReactDOM代理到本地未压缩的文件 +// 线上会使用压缩版本的React,而在开发的时候,我们需要使用react-with-addons的版本来查看错误信息 +// 所以这里我通常会把React和ReactDOM代理到本地未压缩的文件 router.get('**/react.min.js', async function (ctx) { await send(ctx, 'demo/react-with-addons.js') }) @@ -351,9 +378,1525 @@ app.listen(3000, function () { ``` 在终端执行 + ``` $ node server.js ``` -打开浏览器,输入 localhost:3000 ,就可以看到 “hello world” 了。 +打开浏览器,输入 localhost:3000 ,就可以看到 “Hello world” 了。 + +``` +$ git checkout step-2 +``` + + +## 加入react-hot-loader +hot loader有两个方案,一个方案是使用 webpack-dev-middleware和webpack-hot-middleware,优点是可以和开发服务器共用一个server,缺点是配置比较繁琐。 +另一个方案是用react-hot-loader,优点是配置比较简单,缺点是要另外启动一个server来代理资源。 +因为react-hot-loader 3现在还是beta版,所以需要加 @next 安装 + +``` +npm install --save react-hot-loader@next webpack-dev-server --save-dev +``` + +在项目根目录添加一个 webpack.dev.config.js 文件,和webpack.config.js稍有不同,去除了代码压缩的配置,增加了react-hot-loader的插件配置 + +``` +const path = require('path') +const webpack = require('webpack') +const autoprefixer = require('autoprefixer') + +module.exports = { + devtool: 'cheap-module-source-map', + entry: { + // 需要编译的入口文件,增加了react-hot-loader的配置 + app: [ + 'react-hot-loader/patch', + 'webpack-dev-server/client?http://localhost:3001', + 'webpack/hot/only-dev-server', + './src/index.js', + ], + }, + output: { + // 输出文件名称规则,这里会生成 'app.js' + filename: '[name].js', + publicPath: '/', + }, + + // 引用但不打包的文件 + externals: { react: 'React', 'react-dom': 'ReactDOM' }, + + plugins: [ + new webpack.HotModuleReplacementPlugin(), + ], + + resolve: { + // 给src目录一个路径,避免出现'../../'这样的引入 + alias: { _: path.resolve(__dirname, 'src') }, + }, + + module: { + rules: [ + { + test: /\.jsx?$/, + use: { + loader: 'babel-loader', + + // 可以在这里配置babelrc,也可以在项目根目录加.babelrc文件 + options: { + + // false是不使用.babelrc文件 + babelrc: false, + + // webpack2 需要设置modules 为false + presets: [ + ['es2015', { modules: false }], + 'react', + ], + + // babel的插件 + plugins: [ + 'react-hot-loader/babel', + 'react-require', + 'transform-object-rest-spread', + ], + }, + }, + }, + + // 这是sass的配置,less配置和sass一样,把sass-loader换成less-loader即可 + // webpack2 使用use来配置loader,并且不支持字符串形式的参数了,必须使用options + // loader的加载顺序是从后向前的,这里是 sass -> postcss -> css -> style + { + test: /\.scss$/, + use: [ + { loader: 'style-loader' }, + + { + loader: 'css-loader', + + // 开启了CSS Module功能,避免类名冲突问题 + options: { + modules: true, + localIdentName: '[name]-[local]', + }, + }, + + { + loader: 'postcss-loader', + options: { + plugins() { + return [ + autoprefixer, + ] + }, + }, + }, + + { + loader: 'sass-loader', + }, + ], + }, + + // 当图片文件大于10KB时,复制文件到指定目录,小于10KB转为base64编码 + { + test: /\.(png|jpg|jpeg|gif)$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10000, + name: './images/[name].[ext]', + }, + }, + ], + }, + ], + }, +} +``` + +在 server.js 里加入代码,启动hot loader server + +``` +const webpack = require('webpack') +const WebpackDevServer = require('webpack-dev-server') +const config = require('./webpack.dev.config') + +const DEVPORT = 3001 + +new WebpackDevServer(webpack(config), { + publicPath: config.output.publicPath, + hot: true, + quiet: false, + noInfo: true, + stats: { + colors: true + } +}).listen(DEVPORT, 'localhost', function (err, result) { + if (err) { + return console.log(err) + } +}) +``` + +把 app.js 重定向到 webpack-dev-server + +``` +/* 删掉这一段 +router.get('/app.js', async function (ctx) { + await send(ctx, 'build/app.js') +}) +*/ +router.get('**/*.js(on)?', async function (ctx) { + ctx.redirect(`http://localhost:${DEVPORT}/${ctx.path}`) +}) +``` + +这时执行 node server 访问 localhost:3000 会出现一个 “React Hot Loader: App in ..../index.js will not hot reload correctly because index.js uses during module definition. For hot reloading to work, move App into a separate file and import it from index.js.” 警告,我们需要拆分 src/index.js 文件 + +src/index.js + +``` +import React from 'react' +import ReactDOM from 'react-dom' +import App from './App' + +ReactDOM.render(, document.getElementById('root')) + +// 注意,要增加这句 +module.hot && module.hot.accept() +``` + +src/App.js + +``` +import React, { Component } from 'react' + +import '_/styles/index.scss' + +class App extends Component { + constructor(props) { + super(props) + + this.state = {} + } + + render() { + return ( +
Hello world.
+ ) + } +} + +export default App +``` + +重启服务,修改App.js代码试试看吧。 + +``` +$ git checkout step-3 +``` + +## 加入react-router 4.0 +``` +$ npm install react-router-dom --save +``` + +这里使用hashRouter,修改App.js代码(暂时忽略样式) + +``` +import React from 'react' +import { HashRouter as Router, Route, Link } from 'react-router-dom' + +import Author from '_/components/author' +import Category from '_/components/category' +import Book from '_/components/book' + +import '_/styles/index.scss' + +function App() { + return ( + +
+
+ 作者 + 分类 + 书籍 +
+ +
+ + + +
+
+
+ ) +} + +export default App +``` + +在 components 下面建3个文件夹author/category/book,每个放入一个index.js文件,先简单render一个div + +``` +export default function () { + return ( +
书籍列表
+ ) +} +``` +启动服务,点击链接,看下url变化 + +``` +$ git checkout step-4 +``` + +## CSS Module +css-loader 提供了CSS Module的功能,在开发SPA应用的时候,可以减少css类名冲突带来的问题 +之前webpack已经配置过了,现在可以直接使用 +``` +options: { + modules: true, + // 这个的配置是 "文件名-类名",比较简单,实际项目中,可以加入hash,例如'[name]-[local]-[hash:base64:5]' + localIdentName: '[name]-[local]', +}, +``` + +在styles文件夹下面加两个文件 +src/styles/header.scss + +``` +.container { + width: 100%; + height: 50px; +} +``` + +src/styles/menu.scss + +``` +.container { + width: 200px; +} +``` + +修改App.js + +``` +import React from 'react' +import { HashRouter as Router, Route, Link } from 'react-router-dom' + +import Author from '_/components/author' +import Category from '_/components/category' +import Book from '_/components/book' + +import '_/styles/index.scss' +// 和引入js文件一样 +import _header from '_/styles/header.scss' +import _menu from '_/styles/menu.scss' + +function App() { + return ( + +
+ {/* 和使用对象一样使用类名 */} +
+ React Example +
+ +
+ 作者 + 分类 + 书籍 +
+ +
+ + + +
+
+
+ ) +} + +export default App +``` + +render后的代码,可以看到两个组件使用了两个相同的类名,但是在两个不同的文件里,生成的类名也不同 + +``` +
...
+ +``` + +完整代码checkout step-5 + +``` +$ git checkout step-5 +``` + +## 使用ui组件库 +组件库这里加点私货,使用了[react-ui](https://github.com/Lobos/react-ui)。[文档可以参考这里](http://lobos.github.io/react-ui/)。 + +``` +$ npm install rctui classnames query-string refetch --save +``` + +在src/components/author下面加一个List.js文件,这是一个比较常见的使用state的流程,组件加载后获取数据,重新设置数据,再渲染 + +``` +import React, { Component } from 'react' +import { Table, Card } from 'rctui' +import fetch from 'refetch' + +class List extends Component { + constructor(props) { + super(props) + this.state = { + data: { + list: [], + }, + } + } + + componentWillMount() { + fetch.get('/authorlist.json').then((res) => { + // 实际项目中,这里最好判断一下组件是否已经unmounted + this.setState({ data: res.data }) + }) + } + + render() { + // 这里可以根据data的状态,返回其它内容,例如 + // if (!this.state.data) return + + return ( + + 作者列表 + + + ) + } +} + +export default List +``` + +修改src/components/author/index.js,增加一条Route + +``` + render() { + const { match } = this.props + + return ( +
+ } + /> +
+ ) + } +``` + +在demo下加了一个authorlist.json + +``` +{ + "data": { + "total": 2, + "page": 1, + "size": 10, + "list": [ + { + "id": 1, + "name": "乔治.R.R.马丁", + "birthday": "1948-09-20", + "nationality": "美国" + }, + { + "id": 2, + "name": "托尔金", + "birthday": "1892-01-03", + "nationality": "英国" + } + ] + } +} +``` + +完整代码 + +``` +$ git checkout step-6 +``` + +## 高阶组件 +在上面的示例中,通过[ fetch -> setState -> render ] 这样一个流程来处理数据。一个项目中,可能有很多地方会有类似的场景和使用方式。可以通过高阶组件的方式来抽取这个流程,使它可以在更多的地方使用。 +在项目中新建一个文件 src/hoc/fetch.js + +``` +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import refetch from 'refetch' +import { Mask, Spin } from 'rctui' + +const PENDING = 0 +const SUCCESS = 1 +const FAILURE = 2 + +export default function (Origin) { + class Fetch extends Component { + constructor(props) { + super(props) + this.state = { + data: null, + status: props.fetch ? PENDING : SUCCESS, + } + + this.fetchData = this.fetchData.bind(this) + } + + componentWillMount() { + if (this.props.fetch) this.fetchData() + this.isUnmounted = false + } + + componentWillUnmount() { + this.isUnmounted = true + } + + fetchData() { + let { fetch } = this.props + if (typeof fetch === 'string') fetch = { url: fetch } + + // 设置状态为加载中 + this.setState({ data: null, status: PENDING }) + refetch.get(fetch.url, fetch.data).then((res) => { + // 如果组件已经卸载,不处理返回数据 + if (this.isUnmounted) return + + // demo数据格式统一为,成功返回data,失败返回error + if (res.data) { + this.setState({ status: SUCCESS, data: res.data }) + } else { + this.setState({ status: FAILURE, message: res.error }) + } + }).catch((e) => { + if (this.isUnmounted) return + this.setState({ status: FAILURE, message: e.message }) + }) + } + + render() { + const { status, data } = this.state + + // 状态为成功,返回组件,并且传入data + if (status === SUCCESS) { + return + } + + // 加载中,返回一个动态的加载中 + if (status === PENDING) { + return ( +
+ + + +
+ ) + } + + // 处理失败信息 + if (status === FAILURE) { + return
{this.state.message}
+ } + return null + } + } + + Fetch.propTypes = { + fetch: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + ]) + } + Fetch.defaultProps = { + fetch: null + } + + return Fetch +} +``` +修改之前的src/components/author/List.js,移除了state相关的代码,变成了一个纯粹展示的组件,所以直接写成一个函数 + +``` +import React from 'react' +import PropTypes from 'prop-types' +import { Table, Card } from 'rctui' +import fetch from '_/hoc/fetch' + +function List(props) { + const { data } = props + return ( + + 作者列表 +
+ + ) +} + +List.propTypes = { + data: PropTypes.object.isRequired, +} + +export default fetch(List) +``` + +src/components/author/index.js 也要稍作修改 + +``` + render() { + const { match } = this.props + + return ( +
+ } + /> +
+ ) + } +``` + +完整代码 + +``` +$ git checkout step-7 +``` + +## 加入mock数据 +前面用了一个json文件来模拟数据,通常可以使用mock.js或者faker.js来模拟数据。这里再加一点私货,用一个我之前写的系统qenya,[项目地址在这里](https://github.com/Lobos/qenya)。暂时还有一些功能待补全,文档也还没有写,不过这里可以拿来mock数据。 + +首先,安装一下 + +``` +$ npm install qenya --save +``` +在server下面加入启动代码 + +``` +const qenya = require('qenya') + +// qenya 会启动两个服务,一个是数据管理平台,可以设置数据表和api +// 另一个是api服务,通过在数据管理平台配置的api访问 +qenya({ + appPort: 3002, + apiPort: 3003, + render: function (res) { + if (res.data) { + return res.data + } else { + return { + error: res.errors[0].message + } + } + } +}) + +// api请求跳转到api服务器 +router.get('/api/*', async function (ctx) { + ctx.redirect(`http://localhost:3003${ctx.path}`) +}) +``` + +qenya会在项目下面创建一个data文件夹,数据会保存在里面。 +这里暂时忽略这些配置,只要知道有接口就好。如果感兴趣,可以checkout代码,访问localhost:3002 看下api配置,后面我会慢慢完善文档。 + +``` +$ git checkout step-8 +``` + +## CRUD +checkout代码,已经在后台配置好了四个数据接口,用来模拟服务端 + +``` +get /api/authorlist 获取列表数据 +get /api/author/:id 根据id获取单条记录 +post /api/author 添加或编辑数据 +delete /api/author 删除一条数据 +``` + +新增 src/components/author/Edit.js 文件 + +``` +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Card, Form, FormControl, Message, Button } from 'rctui' +import refetch from 'refetch' +import fetch from '_/hoc/fetch' + +class Edit extends Component { + constructor(props) { + super(props) + this.handleSubmit = this.handleSubmit.bind(this) + this.handleCancel = this.handleCancel.bind(this) + } + + handleSubmit(data) { + refetch.post('/api/author', data).then((res) => { + if (res.data) { + this.props.history.push('/author') + Message.success('保存成功') + } else { + Message.error(res.error) + } + }) + } + + handleCancel() { + this.props.history.goBack() + } + + render() { + const { data } = this.props + + return ( + + 作者编辑 + +
+
+ + + + + + + + +
+
+ ) + } +} + +Edit.propTypes = { + data: PropTypes.object, + history: PropTypes.object.isRequired, +} + +Edit.defaultProps = { + data: {}, +} + +// 使用之前的高阶组件fetch来获取数据 +export default fetch(Edit) +``` + +修改 src/components/author/index.js 文件,加入路由 + +``` +import React from 'react' +import PropTypes from 'prop-types' +import { Route, Switch } from 'react-router-dom' +import List from './List' +import Edit from './Edit' + +function Author(props) { + const { url } = props.match + + return ( + + {/* 新增作者,不需要fetch data */} + + {/* 编辑作者,使用fetch获取数据 */} + + } + /> + {/* 列表,因为加入了分页,数据处理放到了List里面 */} + + + ) +} + +Author.propTypes = { + match: PropTypes.object.isRequired, +} + +export default Author +``` + +修改 src/components/author/List.js 文件,因为加入分页功能,拆分了这个页面 + +``` +import React from 'react' +import PropTypes from 'prop-types' +import { Card, Button } from 'rctui' +import queryString from 'query-string' +import TableList from './TableList' + +function List(props) { + const { history } = props + + // 从queryString中获取分页信息,格式为 ?page=x&size=x + const query = queryString.parse(history.location.search) + // 每页数据数量 + if (!query.size) query.size = 10 + + return ( + + 作者列表 +
+ +
+ + +
+ ) +} + +List.propTypes = { + history: PropTypes.object.isRequired, +} + +export default List + +``` + +src/components/author/TableList.js 代码 + +``` +import React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' +import { Table, Pagination } from 'rctui' +import fetch from '_/hoc/fetch' +import DelButton from './DelButton' + +function TableList(props) { + const { data, history, fetchData } = props + return ( +
+
( + + 编辑 + {' '} + + + ), + }, + ]} + /> +
+ history.push(`/author?page=${page}`)} + /> +
+ + ) +} + +TableList.propTypes = { + data: PropTypes.object.isRequired, + fetchData: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, +} + +export default fetch(TableList) + +``` + +可以看到,通过react-router和高阶组件fetch,我们把author下面的所有组件(列表、分页、编辑)都变成了无状态的组件。每个组件只关心route提供了什么参数,应该怎样去展示,当需要变化的时候,history.push到相应的route就行了。 + +完整代码 + +``` +$ git checkout step-9 +``` + +## Redux +接下来写分类管理,这次我们使用redux来处理。首先,仍然是安装包 + +``` +$ npm install redux react-redux redux-thunk --save +``` + +数据结构非常简单 + +``` +{ + "id": "8", + "name": "科学幻想", + "desc": "简称科幻,是虚构作品的一种类型,描述诸如未来科技、时间旅行、超光速旅行、平行宇宙、外星生命、人工智能、错置历史等有关科学的想象性内容。" +} +``` + +同样的,有3个后端接口 + + +``` +get /api/genres 获取列表数据 +post /api/genre 添加或编辑数据 +delete /api/genre 删除一条数据 +``` + +之前随手写了一个category占位,这里统一改成genre。 + +先在src下面建一个文件夹 src/actions,用来存放 redux 的 actions。这里做了一些简化,一次从服务端拉取所有数据存在store中,没有考虑分页的问题。也没有单条数据的请求,编辑时直接从list里面获取了。 + +src/actions/genre.js + +``` +import { Message } from 'rctui' +import refetch from 'refetch' + +export const GENRE_LIST = 'GENRE_LIST' +function handleList(status, data, message) { + return { + type: GENRE_LIST, + status, + data, + message, + } +} + +// 从服务端获取数据 +function fetchList() { + return (dispatch) => { + dispatch(handleList(0)) + refetch.get('/api/genres', { size: 999 }).then((res) => { + if (res.data) { + dispatch(handleList(1, res.data.list)) + } else { + dispatch(handleList(2, null, res.error)) + } + }).catch((err) => { + dispatch(handleList(2, null, err.message)) + }) + } +} + +// 对外获取列表的接口 +export function getGenreList() { + return (dispatch, getState) => { + const { data, status } = getState().genre + + // 如果数据已存在,直接返回 + if (status === 1 && data && data.length > 0) { + return Promise.resolve() + } + + return dispatch(fetchList()) + } +} + +// 保存数据接口 +export function saveGenre(body, onSuccess) { + return (dispatch, getState) => { + refetch.post('/api/genre', body, { dataType: 'json' }).then((res) => { + if (res.data) { + onSuccess() + + // 如果是修改,从数组里把原数据剔除 + const data = getState().genre.data.filter(d => d.id !== res.data.id) + + data.unshift(res.data) + dispatch(handleList(1, data)) + + Message.success('保存成功') + } else { + Message.error(res.error) + } + }).catch((err) => { + Message.error(err.message) + }) + } +} + +// 删除数据接口 +export function removeGenre(id) { + return (dispatch, getState) => { + refetch.delete('/api/genre', { id }).then((res) => { + if (res.data === 1) { + Message.success('删除成功') + + // 删除直接从store的列表里剔除数据,不再发请求到服务端 + const data = getState().genre.data.filter(d => d.id !== id) + + dispatch(handleList(1, data)) + } + }).catch((err) => { + Message.error(err.message) + }) + } +} + +``` + +接下来增加一个 src/reducers/genre.js,这个比较简单,只有一个 action type + +``` +import { GENRE_LIST } from '_/actions/genre' + +export default function (state = { + status: 0, + data: undefined, +}, action) { + switch (action.type) { + case GENRE_LIST: + return Object.assign({}, state, { + status: action.status, + data: action.data, + message: action.message, + }) + default: + return state + } +} + +``` + +虽然只有一个reducer,为了掩饰结构,还是建一个 src/reducers/index.js 文件 + +``` +import { combineReducers } from 'redux' +import genre from './genre' + +export default combineReducers({ + genre, +}) + +``` + +接下来是 src/store.js,这里使用 redux-thunk 来处理异步数据 + +``` +import { createStore, applyMiddleware } from 'redux' +import thunk from 'redux-thunk' +import reducer from './reducers' + +const createStoreWithMiddleware = applyMiddleware(thunk)(createStore) + +const store = createStoreWithMiddleware(reducer) + +export default store + +``` + +最后,把 store 注入到 App,修改 src/index.js + +``` +import React from 'react' +import ReactDOM from 'react-dom' +import { Provider } from 'react-redux' +import App from './App' +import store from './store' + +ReactDOM.render( + + + , + document.getElementById('root')) + +module.hot && module.hot.accept() +``` + +现在可以开始写 genre 的代码了 + +src/components/genre/index.js + +``` +import React, { Component } from 'react' +import { connect } from 'react-redux' +import PropTypes from 'prop-types' +import { getGenreList } from '_/actions/genre' +import { Route, Switch } from 'react-router-dom' +import Loading from '_/components/comm/Loading' +import List from './List' +import Edit from './Edit' + +class Genre extends Component { + constructor(props) { + super(props) + this.state = {} + this.renderEdit = this.renderEdit.bind(this) + } + + componentDidMount() { + this.props.dispatch(getGenreList()) + } + + renderEdit({ history, match }) { + const { genre } = this.props + + // 这里没有从服务端获取,而是从list里面获取的单条数据 + const data = genre.data.find(d => d.id === match.params.id) + + return + } + + render() { + const { genre, history, match } = this.props + const { url } = match + + // 当没有数据的时候展示一个 Loading + if (genre.status === 0) { + return + } + + if (genre.status === 2) { + return
{genre.message}
+ } + + // 和 author 一样,都是三条路由,只是数据已经从props里拿到,这里直接传入 + return ( + + + + } + /> + + ) + } +} + +Genre.propTypes = { + dispatch: PropTypes.func.isRequired, + genre: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + match: PropTypes.object.isRequired, +} + +const mapStateToProps = (state) => { + const { genre } = state + return { genre } +} + +export default connect(mapStateToProps)(Genre) + +``` + +src/components/genre/List.js + +``` +import React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' +import { Card, Table, Button } from 'rctui' +import DelButton from './DelButton' + +function List(props) { + const { data, history } = props + return ( + + 类型列表 + +
+ +
+ +
parseInt(a.id, 10) > parseInt(b.id, 10) ? 1 : -1, + (a, b) => parseInt(a.id, 10) < parseInt(b.id, 10) ? 1 : -1, + ], + }, + { name: 'name', width: 160, header: '名称', sort: true }, + { name: 'desc', header: '简介' }, + { + width: '120px', + content: d => ( + + 编辑 + {' '} + + + ), + }, + ]} + {/* 因为拿到的是全部的数据,这里使用了Table内置的分页 */} + pagination={{ size: 10, position: 'center' }} + /> + + ) +} + +List.propTypes = { + data: PropTypes.array.isRequired, + history: PropTypes.object.isRequired, +} + +export default List + +``` + +src/components/genre/Edit.js + +``` +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Card, Form, FormControl, Button } from 'rctui' +import { saveGenre } from '_/actions/genre' + +class Edit extends Component { + constructor(props) { + super(props) + this.handleSubmit = this.handleSubmit.bind(this) + this.handleCancel = this.handleCancel.bind(this) + } + + handleSubmit(data) { + // 这里调用了 actions 里的方法 + this.props.dispatch(saveGenre(data, this.props.history.goBack)) + } + + handleCancel() { + this.props.history.goBack() + } + + render() { + const { data } = this.props + + return ( + + 类型编辑 + +
+
+ + + + + + + +
+
+ ) + } +} + +Edit.propTypes = { + data: PropTypes.object, + dispatch: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, +} + +Edit.defaultProps = { + data: {}, +} + +export default connect()(Edit) +``` + +对比一下author的示例,使用redux之后,实际是要复杂很多的,如果加上分页,会更加复杂一些。修改代码的时候,很可能需要在action,reducer,component的代码里找一圈。并且要时刻关心store里面的数据,比如一条数据更新或者删除了,列表里的数据也要及时更新,后期的维护成本会比较高一些。当然,优点也是显而易见的,代码结构比较清晰,任意的跨组件通信,服务端请求次数减少。 + +个人认为,除了一些全局的数据,比如用户登陆信息,权限等等,可以放在redux里维护之外,和业务相关的大部分列表页,详情页等数据,都可以使用state维护,用完就丢,可以减少很多维护成本。 + +完整代码 + +``` +$ git checkout step-10 +``` + +## 在弹出层中编辑 + +书籍这里,换一个不一样的交互方式吧,列表改为card,编辑改为弹出层。 + +数据结构 + +``` +{ + "id": "17", + "title": "沉默的大多数", + "author": "1", + "genres": "1,2", + "publishAt": "1997-01", + "cover": "https://img1.doubanio.com/lpic/s1447349.jpg", + "desc": "" +} +``` + +src/components/book/index.js,采用弹出层的设计,所以这里不再需要子路由 + +``` +import React from 'react' +import PropTypes from 'prop-types' +import { Card } from 'rctui' +import queryString from 'query-string' +import List from './List' + +function Book(props) { + const { history } = props + + const query = queryString.parse(history.location.search) + if (!query.size) query.size = 12 + + return ( + + 书籍管理 + + + + ) +} + +Book.propTypes = { + history: PropTypes.object.isRequired, +} + +export default Book +``` + +src/components/book/List.js + +``` +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Media, Image, Button, Modal, Pagination } from 'rctui' +import { getGenreList } from '_/actions/genre' +import fetch from '_/hoc/fetch' +import Edit from './Edit' + +class List extends Component { + componentDidMount() { + this.props.dispatch(getGenreList()) + } + + handleEdit(book) { + // 这里从store里获取类别,传递给Edit使用 + const { genres } = this.props + + const fc = book ? { url: `/api/book/${book.id}` } : undefined + + const mid = Modal.open({ + header: '书籍编辑', + width: 800, + content: ( + { + this.props.fetchData() + Modal.close(mid) + }} + /> + ), + buttons: { + 提交: 'submit', + 取消: true, + }, + }) + } + + render() { + const { data, history } = this.props + return ( +
+
+ +
+ + {data.list.map(d => ( +
+ + + + + +

{d.title}

+
作者:{d.author}
+
出版时间:{d.publishAt}
+
类型:{d.genres}
+ +
+
+
+ ))} + +
+ history.push(`/book?page=${page}`)} + /> +
+
+ ) + } +} + +List.propTypes = { + data: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + fetchData: PropTypes.func.isRequired, + genres: PropTypes.array, + history: PropTypes.object.isRequired, +} + +List.defaultProps = { + genres: [], +} + +const mapStateToProps = (state) => { + const { genre } = state + return { genres: genre.data } +} + +export default fetch(connect(mapStateToProps)(List)) + +``` + +src/components/book/Edit.js + +``` +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Form, FormControl, Message } from 'rctui' +import fetch from '_/hoc/fetch' +import refetch from 'refetch' + +class Edit extends Component { + constructor(props) { + super(props) + + this.handleSubmit = this.handleSubmit.bind(this) + } + + handleSubmit(data) { + refetch.post('/api/book', data).then((res) => { + if (res.data) { + this.props.onSuccess() + Message.success('保存成功') + } else { + Message.error(res.error) + } + }) + } + + render() { + const { data, genres } = this.props + + return ( +
+ + + res.data.list }} + valueTpl="{id}" optionTpl="{name}" + /> + + + + + + + + + + ) + } +} + +Edit.propTypes = { + data: PropTypes.object, + genres: PropTypes.array.isRequired, + onSuccess: PropTypes.func.isRequired, +} + +Edit.defaultProps = { + data: {}, +} + +export default fetch(Edit) +``` + +完整代码 + +``` +$ git checkout step-11 +``` + +## 项目结构 +最后说下项目结构吧,可能不是最好的,不过算是我做了一些项目下来比较顺手的。 + +``` +|- build/ 生产代码,发布到cdn的 +|- demo/ 放一些本地开发需要用到的文件,html,第三方库等等 +|- src/ 源代码目录 +| |- actions/ redux actions 目录 +| |- components/ 对于SPA应用,个人习惯把所有组件都放在这个目录下 +| |- hoc/ 高阶组件目录 +| |- reducers/ redux reducer 目录 +| |- styles/ 个人习惯把样式文件统一起来管理,因为可能会有一些全局的变量文件, + 实际上文件并不多,如果不是复用的样式或者是伪类,都直接写在组件上 +| |- utils/ 一些工具类的文件 +| |- App.js 项目框架文件 +| |- index.js 项目入口文件 +| |- store.js +|- .eslintrc eslint 配置文件 +|- .npmrc 这个文件可以放在全局,不过我的机器上有些项目在内网,所以习惯放在项目下面单独管理 +|- server.js 开发服务器 +|- webpack.dev.config.js 开发时用的webpack配置 +|- webpack.config.js 发布时用的配置 +``` \ No newline at end of file diff --git a/src/components/book/index.js b/src/components/book/index.js index a7339c2..d2cbae2 100644 --- a/src/components/book/index.js +++ b/src/components/book/index.js @@ -1,6 +1,6 @@ import React from 'react' import PropTypes from 'prop-types' -import { Card, Button } from 'rctui' +import { Card } from 'rctui' import queryString from 'query-string' import List from './List' @@ -23,7 +23,4 @@ Book.propTypes = { history: PropTypes.object.isRequired, } -Book.defaultProps = { -} - export default Book