{"id":13671874,"url":"https://github.com/Lobos/react-example","last_synced_at":"2025-04-27T18:31:52.358Z","repository":{"id":148877802,"uuid":"89104988","full_name":"Lobos/react-example","owner":"Lobos","description":"react example project","archived":false,"fork":false,"pushed_at":"2017-12-21T03:49:47.000Z","size":243,"stargazers_count":59,"open_issues_count":2,"forks_count":16,"subscribers_count":5,"default_branch":"master","last_synced_at":"2024-11-11T09:44:13.957Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Lobos.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2017-04-22T23:20:48.000Z","updated_at":"2021-02-24T09:05:59.000Z","dependencies_parsed_at":"2024-01-14T17:19:38.930Z","dependency_job_id":null,"html_url":"https://github.com/Lobos/react-example","commit_stats":null,"previous_names":[],"tags_count":11,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Lobos%2Freact-example","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Lobos%2Freact-example/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Lobos%2Freact-example/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Lobos%2Freact-example/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Lobos","download_url":"https://codeload.github.com/Lobos/react-example/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":251187335,"owners_count":21549620,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-08-02T09:01:20.722Z","updated_at":"2025-04-27T18:31:51.150Z","avatar_url":"https://github.com/Lobos.png","language":"JavaScript","readme":"这是一个比较简单的demo，适合对React有一定了解，但是在项目中应用还有些困惑的同学。阅读前最好已经了解了React的基本语法，和一些es6的语法。\n\n本文假定的需求是写一个图书管理的后台。稍微有点长，大概分下面几个部分。\n\n0. 环境和依赖、编译\n0. dev server\n0. react-hot-loader\n0. react-router\n0. CSS Module\n0. react-ui组件库\n0. 高阶组件\n0. mock数据\n0. CRUD\n0. 在弹出层中编辑\n0. 项目结构\n\n现在有挺多start-kit，还有create-react-app这样的工具，这里不打算用这些，而是从空项目入手，让大家可以多了解一些配置是做什么用途的。从我的项目经验来看，前端现在的发展速度，想做一个通用的start-kit，一劳永逸是很难的。一般一个项目周期3-6个月左右，这个start-kit里面的大部分依赖包可能都升级了，很多配置可能就不可用了，比如babel5升到babel6。\n\n整个demo里可能会有一些“私货”，比如自己写的组件，一些开发习惯等等。\n\n这里贴出来的代码有些可能并不完整，每一段结束我都打了一个tag，可以checkout出来执行。如果有报错，先看下nodejs的版本是否大于 7.6.0，是否有依赖没有安装。\n\n\n## 环境和依赖、编译\n\n### 安装nodejs\n这里需要node v7.6.0以上版本，因为后面会使用koa2。\n可以[使用 n 或者 nvm 来管理node版本](http://yijiebuyi.com/blog/b1328ffe88cdde6b4102894635cf8f11.html)\n**npm** npmjs和aws国内访问比较慢，可以换淘宝的镜像源，这里在项目根目录建一个.npmrc文件\n\n```\nphantomjs_cdnurl=http://cnpmjs.org/downloads\nsass_binary_site=https://npm.taobao.org/mirrors/node-sass/\nregistry=https://registry.npm.taobao.org\n```\n\n也可以使用 [nrm 切换npm源](https://github.com/Pana/nrm)，很方便。\n\n```\n$ npm install -g nrm\n$ nrm ls\n\n* npm -----  https://registry.npmjs.org/\n  cnpm ----  http://r.cnpmjs.org/\n  taobao --  https://registry.npm.taobao.org/\n  nj ------  https://registry.nodejitsu.com/\n  rednpm -- http://registry.mirror.cqupt.edu.cn\n  skimdb -- https://skimdb.npmjs.com/registry\n  \n$ nrm use taobao\n```\n\n### 创建一个项目\n首先创建一个空的文件夹，在下面执行\n\n```\n$ npm init\n```\n\n### 安装依赖包\n\n```\n$ npm install react react-dom prop-types --save\n\n$ 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\n```\n\n**babel**\n\n- babel-core\n- babel-loader：babel的webpack插件。\n- babel-plugin-react-require：如果出现 “React is not defined” 的问题，可以安装这个插件。原因是把React打包到项目里，而某些组件没有import React导致。建议不要把React打包到项目里。\n- babel-plugin-transform-object-rest-spread：es6解构赋值插件。\n- babel-preset-es2015：es6语法转换\n- babel-preset-react：jsx语法转换\n\n**css**\n\n- autoprefixer：PostCSS插件，自动添加各种前缀\n- css-loader：读取js文件中引入的css文件，内置了CSS Module的功能\n- file-loader：处理js引入的文件\n- less-loader：less语法转换为css\n- postcss-loader：PostCSS转换器\n- sass-loader：sass语法转换为css\n- style-loader：把转换的css文件转为js模块\n- url-loader：file-loader的封装，提供一些额外功能\n- less：less-loader的依赖包\n- node-sass：sass-loader的依赖包\n\n### 使用eslint （可选）\n推荐使用eslint做代码检查，安装依赖包，这里用了airbnb的代码规范\n\n```\n$ npm install babel-eslint eslint eslint-config-airbnb eslint-plugin-react eslint-plugin-jsx-a11y eslint-plugin-import eslint-import-resolver-webpack --save-dev\n```\n\n在项目根目录建一个.eslintrc文件，因为我是无分号党，所以semi设置了\"never\"\n\n```\n{\n    \"extends\": [\"airbnb\"],\n    \"parserOptions\": {\n        \"ecmaVersion\": 2016,\n        \"sourceType\": \"module\",\n        \"ecmaFeatures\": {\n            \"jsx\": true\n        }\n    },\n    \"env\": {\n        \"browser\": true,\n        \"es6\": true,\n        \"node\": true\n    },\n    \"settings\": {\n        \"import/parser\": \"babel-eslint\",\n        \"import/resolver\": {\n            \"webpack\": {\n                // webpack 文件路径\n                \"config\": \"webpack.config.js\"\n            }\n        }\n    },\n    \"rules\": {\n        // 允许js后缀\n        \"react/jsx-filename-extension\": [1, { \"extensions\": [\".js\", \".jsx\"] }],\n        \"react/forbid-prop-types\": 0,\n        // 强制无分号\n        \"semi\": [2, \"never\"]\n    }\n}\n```\n\n### 配置webpack config\n建一个webpack.config.js文件。这里是一个比较常用的配置，有些细节的配置可以参考相关文档。\n\n```\nconst path = require('path')\nconst webpack = require('webpack')\nconst autoprefixer = require('autoprefixer')\n\nmodule.exports = {\n  entry: {\n    // 需要编译的入口文件\n    app: './src/index.js'\n  },\n  output: {\n    path: path.join(__dirname, '/build'),\n\n    // 输出文件名称规则，这里会生成 'app.js'\n    filename: '[name].js'\n  },\n\n  // 引用但不打包的文件\n  externals: { 'react': 'React', 'react-dom': 'ReactDOM' },\n\n  plugins: [\n\n    // webpack2 需要设置 LoaderOptionsPlugin 开启代码压缩\n    new webpack.LoaderOptionsPlugin({\n      minimize: true,\n      debug: false\n    }),\n\n    // Uglify的配置\n    new webpack.optimize.UglifyJsPlugin({\n      beautify: false,\n      comments: false,\n      compress: {\n        warnings: false,\n        drop_console: true,\n        collapse_vars: true\n      }\n    })\n  ],\n\n  resolve: {\n    // 给src目录一个路径，避免出现'../../'这样的引入\n    alias: { _: path.resolve(__dirname, 'src') }\n  },\n\n  module: {\n    rules: [\n      {\n        test: /\\.jsx?$/,\n        use: {\n          loader: 'babel-loader',\n\n          // 可以在这里配置babelrc，也可以在项目根目录加.babelrc文件\n          options: {\n\n            // false是不使用.babelrc文件\n            babelrc: false,\n\n            // webpack2 需要设置modules 为false\n            presets: [\n              ['es2015', { 'modules': false }],\n              'react'\n            ],\n\n            // babel的插件\n            plugins: [\n              'react-require',\n              'transform-object-rest-spread'\n            ]\n          }\n        }\n      },\n\n      // 这是sass的配置，less配置和sass一样，把sass-loader换成less-loader即可\n      // webpack2 使用use来配置loader，并且不支持字符串形式的参数，x需要使用options\n      // loader的加载顺序是从后向前的，这里是 sass -\u003e postcss -\u003e css -\u003e style\n      {\n        test: /\\.scss$/,\n        use: [\n          { loader: 'style-loader' },\n\n          {\n            loader: 'css-loader',\n\n            // 开启了CSS Module功能，避免类名冲突问题\n            options: {\n              modules: true,\n              localIdentName: '[name]-[local]'\n            }\n          },\n\n          {\n            loader: 'postcss-loader',\n            options: {\n              plugins: function () {\n                return [\n                  autoprefixer\n                ]\n              }\n            }\n          },\n\n          {\n            loader: 'sass-loader'\n          }\n        ]\n      },\n\n      // 当图片文件大于10KB时，复制文件到指定目录，小于10KB转为base64编码\n      {\n        test: /\\.(png|jpg|jpeg|gif)$/,\n        use: [\n          {\n            loader: 'url-loader',\n            options: {\n              limit: 10000,\n              name: './images/[name].[ext]'\n            }\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n### 写一个Hello world\nsrc/index.js\n\n```\nimport React, { Component } from 'react'\nimport ReactDOM from 'react-dom'\n\nimport '_/styles/index.scss'\n\nclass App extends Component {\n  constructor(props) {\n    super(props)\n\n    this.state = {}\n  }\n\n  render() {\n    return (\n      \u003cdiv\u003eHello world.\u003c/div\u003e\n    )\n  }\n}\n\nReactDOM.render(\u003cApp /\u003e, document.getElementById('root'))\n```\n\nsrc/styles/index.scss\n\n```\nbody {\n  font-size: 14px;\n}\n```\n\nconsole下执行\n\n```\n$ node_module/.bin/webpack\n\n Asset     Size  Chunks             Chunk Names\napp.js  29.7 kB       0  [emitted]  app\n   [0] ./src/styles/index.scss 1.24 kB {0} [built]\n   [3] ./~/base64-js/index.js 3.5 kB {0} [built]\n   [4] ./~/buffer/index.js 48.8 kB {0} [built]\n   [5] ./~/buffer/~/isarray/index.js 131 bytes {0} [built]\n   [6] ./~/css-loader/lib/css-base.js 2.19 kB {0} [built]\n   [7] ./~/ieee754/index.js 2.08 kB {0} [built]\n   [8] ./~/style-loader/fixUrls.js 3 kB {0} [built]\n   [9] (webpack)/buildin/global.js 808 bytes {0} [built]\n  [10] ./src/index.js 2.14 kB {0} [built]\n  [11] ./~/css-loader?{\"modules\":true,\"localIdentName\":\"[name]-[local]\"}!./~/postcss-loader?{}!./~/sass-loader/lib/loader.js!./src/styles/index.scss 186 bytes {0} [built]\n  [12] ./~/style-loader/addStyles.js 8.51 kB {0} [built]\n    + 2 hidden modules\n```\n这里可能会有一个“DeprecationWarning: loaderUtils.parseQuery()”，babel-loader的问题，下个版本应该会修复\n\n```\n$ git checkout step-1\n```\n\n## 建一个server\n我们这里使用koa2来做开发服务器，首先，安装koa\n\n```\n$ npm install koa koa-router koa-send http-proxy save-dev\n```\n\n在demo文件夹下建一个index.html\n\n```\n\u003c!doctype html\u003e\n\u003chtml\u003e\n  \u003chead\u003e\n    \u003ctitle\u003eReact Example\u003c/title\u003e\n  \u003c/head\u003e\n  \u003cbody\u003e\n    \u003cdiv id='root'\u003e\u003c/div\u003e\n    \u003cscript src=\"/react.min.js\"\u003e\u003c/script\u003e\n    \u003cscript src=\"/react-dom.min.js\"\u003e\u003c/script\u003e\n    \u003cscript src=\"/app.js\"\u003e\u003c/script\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\n在根目录下建一个server.js\n\n```\nconst Koa = require('koa')\nconst send = require('koa-send')\nconst Router = require('koa-router')\n\nconst app = new Koa()\nconst router = new Router()\n\nrouter.get('/', async function (ctx) {\n  await send(ctx, 'demo/index.html')\n})\n\nrouter.get('/app.js', async function (ctx) {\n  await send(ctx, 'build/app.js')\n})\n\n// 线上会使用压缩版本的React，而在开发的时候，我们需要使用react-with-addons的版本来查看错误信息\n// 所以这里我通常会把React和ReactDOM代理到本地未压缩的文件\nrouter.get('**/react.min.js', async function (ctx) {\n  await send(ctx, 'demo/react-with-addons.js')\n})\nrouter.get('**/react-dom.min.js', async function (ctx) {\n  await send(ctx, 'demo/react-dom.js')\n})\n\napp.use(router.routes())\n\napp.listen(3000, function () {\n  console.log('server running on http://localhost:3000')\n})\n```\n\n在终端执行\n\n```\n$ node server.js\n```\n\n打开浏览器，输入 localhost:3000 ，就可以看到 “Hello world” 了。\n\n```\n$ git checkout step-2\n```\n\n\n## 加入react-hot-loader\nhot loader有两个方案，一个方案是使用 webpack-dev-middleware和webpack-hot-middleware，优点是可以和开发服务器共用一个server，缺点是配置比较繁琐。\n另一个方案是用react-hot-loader，优点是配置比较简单，缺点是要另外启动一个server来代理资源。\n因为react-hot-loader 3现在还是beta版，所以需要加 @next 安装\n\n```\nnpm install --save react-hot-loader@next webpack-dev-server --save-dev\n```\n\n在项目根目录添加一个 webpack.dev.config.js 文件，和webpack.config.js稍有不同，去除了代码压缩的配置，增加了react-hot-loader的插件配置\n\n```\nconst path = require('path')\nconst webpack = require('webpack')\nconst autoprefixer = require('autoprefixer')\n\nmodule.exports = {\n  devtool: 'cheap-module-source-map',\n  entry: {\n    // 需要编译的入口文件，增加了react-hot-loader的配置\n    app: [\n      'react-hot-loader/patch',\n      'webpack-dev-server/client?http://localhost:3001',\n      'webpack/hot/only-dev-server',\n      './src/index.js',\n    ],\n  },\n  output: {\n    // 输出文件名称规则，这里会生成 'app.js'\n    filename: '[name].js',\n    publicPath: '/',\n  },\n\n  // 引用但不打包的文件\n  externals: { react: 'React', 'react-dom': 'ReactDOM' },\n\n  plugins: [\n    new webpack.HotModuleReplacementPlugin(),\n  ],\n\n  resolve: {\n    // 给src目录一个路径，避免出现'../../'这样的引入\n    alias: { _: path.resolve(__dirname, 'src') },\n  },\n\n  module: {\n    rules: [\n      {\n        test: /\\.jsx?$/,\n        use: {\n          loader: 'babel-loader',\n\n          // 可以在这里配置babelrc，也可以在项目根目录加.babelrc文件\n          options: {\n\n            // false是不使用.babelrc文件\n            babelrc: false,\n\n            // webpack2 需要设置modules 为false\n            presets: [\n              ['es2015', { modules: false }],\n              'react',\n            ],\n\n            // babel的插件\n            plugins: [\n              'react-hot-loader/babel',\n              'react-require',\n              'transform-object-rest-spread',\n            ],\n          },\n        },\n      },\n\n      // 这是sass的配置，less配置和sass一样，把sass-loader换成less-loader即可\n      // webpack2 使用use来配置loader，并且不支持字符串形式的参数了，必须使用options\n      // loader的加载顺序是从后向前的，这里是 sass -\u003e postcss -\u003e css -\u003e style\n      {\n        test: /\\.scss$/,\n        use: [\n          { loader: 'style-loader' },\n\n          {\n            loader: 'css-loader',\n\n            // 开启了CSS Module功能，避免类名冲突问题\n            options: {\n              modules: true,\n              localIdentName: '[name]-[local]',\n            },\n          },\n\n          {\n            loader: 'postcss-loader',\n            options: {\n              plugins() {\n                return [\n                  autoprefixer,\n                ]\n              },\n            },\n          },\n\n          {\n            loader: 'sass-loader',\n          },\n        ],\n      },\n\n      // 当图片文件大于10KB时，复制文件到指定目录，小于10KB转为base64编码\n      {\n        test: /\\.(png|jpg|jpeg|gif)$/,\n        use: [\n          {\n            loader: 'url-loader',\n            options: {\n              limit: 10000,\n              name: './images/[name].[ext]',\n            },\n          },\n        ],\n      },\n    ],\n  },\n}\n```\n\n在 server.js 里加入代码，启动hot loader server\n\n```\nconst webpack = require('webpack')\nconst WebpackDevServer = require('webpack-dev-server')\nconst config = require('./webpack.dev.config')\n\nconst DEVPORT = 3001\n\nnew WebpackDevServer(webpack(config), {\n  publicPath: config.output.publicPath,\n  hot: true,\n  quiet: false,\n  noInfo: true,\n  stats: {\n    colors: true\n  }\n}).listen(DEVPORT, 'localhost', function (err, result) {\n  if (err) {\n    return console.log(err)\n  }\n})\n```\n\n把 app.js 重定向到 webpack-dev-server\n\n```\n/* 删掉这一段\nrouter.get('/app.js', async function (ctx) {\n  await send(ctx, 'build/app.js')\n})\n*/\nrouter.get('**/*.js(on)?', async function (ctx) {\n  ctx.redirect(`http://localhost:${DEVPORT}/${ctx.path}`)\n})\n```\n\n这时执行 node server 访问 localhost:3000 会出现一个 “React Hot Loader: App in ..../index.js will not hot reload correctly because index.js uses \u003cApp /\u003e during module definition. For hot reloading to work, move App into a separate file and import it from index.js.” 警告，我们需要拆分 src/index.js 文件\n\nsrc/index.js\n\n```\nimport React from 'react'\nimport ReactDOM from 'react-dom'\nimport App from './App'\n\nReactDOM.render(\u003cApp /\u003e, document.getElementById('root'))\n\n// 注意，要增加这句\nmodule.hot \u0026\u0026 module.hot.accept()\n```\n\nsrc/App.js\n\n```\nimport React, { Component } from 'react'\n\nimport '_/styles/index.scss'\n\nclass App extends Component {\n  constructor(props) {\n    super(props)\n\n    this.state = {}\n  }\n\n  render() {\n    return (\n      \u003cdiv\u003eHello world.\u003c/div\u003e\n    )\n  }\n}\n\nexport default App\n```\n\n重启服务，修改App.js代码试试看吧。\n\n```\n$ git checkout step-3\n```\n\n## 加入react-router 4.0\n```\n$ npm install react-router-dom --save\n```\n\n这里使用hashRouter，修改App.js代码（暂时忽略样式）\n\n```\nimport React from 'react'\nimport { HashRouter as Router, Route, Link } from 'react-router-dom'\n\nimport Author from '_/components/author'\nimport Category from '_/components/category'\nimport Book from '_/components/book'\n\nimport '_/styles/index.scss'\n\nfunction App() {\n  return (\n    \u003cRouter\u003e\n      \u003cdiv\u003e\n        \u003cdiv\u003e\n          \u003cLink to=\"/author\"\u003e作者\u003c/Link\u003e\n          \u003cLink to=\"/category\"\u003e分类\u003c/Link\u003e\n          \u003cLink to=\"/book\"\u003e书籍\u003c/Link\u003e\n        \u003c/div\u003e\n\n        \u003cdiv\u003e\n          \u003cRoute path=\"/author\" component={Author} /\u003e\n          \u003cRoute path=\"/category\" component={Category} /\u003e\n          \u003cRoute path=\"/book\" component={Book} /\u003e\n        \u003c/div\u003e\n      \u003c/div\u003e\n    \u003c/Router\u003e\n  )\n}\n\nexport default App\n```\n\n在 components 下面建3个文件夹author/category/book，每个放入一个index.js文件，先简单render一个div\n\n```\nexport default function () {\n  return (\n    \u003cdiv\u003e书籍列表\u003c/div\u003e\n  )\n}\n```\n启动服务，点击链接，看下url变化\n\n```\n$ git checkout step-4\n```\n\n## CSS Module\ncss-loader 提供了CSS Module的功能，在开发SPA应用的时候，可以减少css类名冲突带来的问题\n之前webpack已经配置过了，现在可以直接使用\n\n```\noptions: {\n  modules: true,\n  // 这个的配置是 \"文件名-类名\"，比较简单，实际项目中，可以加入hash，例如'[name]-[local]-[hash:base64:5]'\n  localIdentName: '[name]-[local]',\n},\n```\n\n在styles文件夹下面加两个文件\nsrc/styles/header.scss\n\n```\n.container {\n  width: 100%;\n  height: 50px;\n}\n```\n\nsrc/styles/menu.scss\n\n```\n.container {\n  width: 200px;\n}\n```\n\n修改App.js\n\n```\nimport React from 'react'\nimport { HashRouter as Router, Route, Link } from 'react-router-dom'\n\nimport Author from '_/components/author'\nimport Category from '_/components/category'\nimport Book from '_/components/book'\n\nimport '_/styles/index.scss'\n// 和引入js文件一样\nimport _header from '_/styles/header.scss'\nimport _menu from '_/styles/menu.scss'\n\nfunction App() {\n  return (\n    \u003cRouter\u003e\n      \u003cdiv\u003e\n        {/* 和使用对象一样使用类名 */}\n        \u003cdiv className={_header.container}\u003e\n          React Example\n        \u003c/div\u003e\n\n        \u003cdiv className={_menu['container']}\u003e\n          \u003cLink to=\"/author\"\u003e作者\u003c/Link\u003e\n          \u003cLink to=\"/category\"\u003e分类\u003c/Link\u003e\n          \u003cLink to=\"/book\"\u003e书籍\u003c/Link\u003e\n        \u003c/div\u003e\n\n        \u003cdiv\u003e\n          \u003cRoute path=\"/author\" component={Author} /\u003e\n          \u003cRoute path=\"/category\" component={Category} /\u003e\n          \u003cRoute path=\"/book\" component={Book} /\u003e\n        \u003c/div\u003e\n      \u003c/div\u003e\n    \u003c/Router\u003e\n  )\n}\n\nexport default App\n```\n\nrender后的代码，可以看到两个组件使用了两个相同的类名，但是在两个不同的文件里，生成的类名也不同\n\n```\n\u003cdiv class=\"header-container\"\u003e...\u003c/div\u003e\n\u003cdiv class=\"menu-container\"\u003e...\u003c/div\u003e\n```\n\n完整代码checkout step-5\n\n```\n$ git checkout step-5\n```\n\n## 使用ui组件库\n组件库这里加点私货，使用了[react-ui](https://github.com/Lobos/react-ui)。[文档可以参考这里](http://lobos.github.io/react-ui/)。\n\n```\n$ npm install rctui classnames query-string refetch --save\n```\n\n在src/components/author下面加一个List.js文件，这是一个比较常见的使用state的流程，组件加载后获取数据，重新设置数据，再渲染\n\n```\nimport React, { Component } from 'react'\nimport { Table, Card } from 'rctui'\nimport fetch from 'refetch'\n\nclass List extends Component {\n  constructor(props) {\n    super(props)\n    this.state = {\n      data: {\n        list: [],\n      },\n    }\n  }\n\n  componentWillMount() {\n    fetch.get('/authorlist.json').then((res) =\u003e {\n      // 实际项目中，这里最好判断一下组件是否已经unmounted\n      this.setState({ data: res.data })\n    })\n  }\n\n  render() {\n    // 这里可以根据data的状态，返回其它内容，例如\n    // if (!this.state.data) return \u003cLoading /\u003e\n    \n    return (\n      \u003cCard\u003e\n        \u003cCard.Header\u003e作者列表\u003c/Card.Header\u003e\n        \u003cTable\n          data={this.state.data.list}\n          columns={[\n            { name: 'id', header: 'ID' },\n            { name: 'name', header: '姓名' },\n            { name: 'nationality', header: '国籍' },\n            { name: 'birthday', header: '生日' },\n          ]}\n        /\u003e\n      \u003c/Card\u003e\n    )\n  }\n}\n\nexport default List\n```\n\n修改src/components/author/index.js，增加一条Route\n\n```\n  render() {\n    const { match } = this.props\n\n    return (\n      \u003cdiv\u003e\n        \u003cRoute\n          exact\n          path={`${match.url}`}\n          {/* 这里可以直接用 component={List}，不过我们后面要对这里做一些修改 */}\n          render={() =\u003e \u003cList /\u003e}\n        /\u003e\n      \u003c/div\u003e\n    )\n  }\n```\n\n在demo下加了一个authorlist.json\n\n```\n{\n  \"data\": {\n    \"total\": 2,\n    \"page\": 1,\n    \"size\": 10,\n    \"list\": [\n      {\n        \"id\": 1,\n        \"name\": \"乔治.R.R.马丁\",\n        \"birthday\": \"1948-09-20\",\n        \"nationality\": \"美国\"\n      },\n      {\n        \"id\": 2,\n        \"name\": \"托尔金\",\n        \"birthday\": \"1892-01-03\",\n        \"nationality\": \"英国\"\n      }\n    ]\n  }\n}\n```\n\n完整代码\n\n```\n$ git checkout step-6\n```\n\n## 高阶组件\n在上面的示例中，通过[ fetch -\u003e setState -\u003e render ] 这样一个流程来处理数据。一个项目中，可能有很多地方会有类似的场景和使用方式。可以通过高阶组件的方式来抽取这个流程，使它可以在更多的地方使用。\n在项目中新建一个文件 src/hoc/fetch.js\n\n```\nimport React, { Component } from 'react'\nimport PropTypes from 'prop-types'\nimport refetch from 'refetch'\nimport { Mask, Spin } from 'rctui'\n\nconst PENDING = 0\nconst SUCCESS = 1\nconst FAILURE = 2\n\nexport default function (Origin) {\n    class Fetch extends Component {\n    constructor(props) {\n      super(props)\n      this.state = {\n        data: null,\n        status: props.fetch ? PENDING : SUCCESS,\n      }\n\n      this.fetchData = this.fetchData.bind(this)\n    }\n\n    componentWillMount() {\n      if (this.props.fetch) this.fetchData()\n      this.isUnmounted = false\n    }\n\n    componentWillUnmount() {\n      this.isUnmounted = true\n    }\n\n    fetchData() {\n      let { fetch } = this.props\n      if (typeof fetch === 'string') fetch = { url: fetch }\n      \n      // 设置状态为加载中\n      this.setState({ data: null, status: PENDING })\n      refetch.get(fetch.url, fetch.data).then((res) =\u003e {\n        // 如果组件已经卸载，不处理返回数据\n        if (this.isUnmounted) return\n        \n        // demo数据格式统一为，成功返回data，失败返回error\n        if (res.data) {\n          this.setState({ status: SUCCESS, data: res.data })\n        } else {\n          this.setState({ status: FAILURE, message: res.error })\n        }\n      }).catch((e) =\u003e {\n        if (this.isUnmounted) return\n        this.setState({ status: FAILURE, message: e.message })\n      })\n    }\n\n    render() {\n      const { status, data } = this.state\n\n      // 状态为成功，返回组件，并且传入data\n      if (status === SUCCESS) {\n        return \u003cOrigin {...this.props} data={data} fetchData={this.fetchData} /\u003e\n      }\n\n      // 加载中，返回一个动态的加载中\n      if (status === PENDING) {\n        return (\n          \u003cdiv style={{ position: 'relative' }}\u003e\n            \u003cMask \u003e\n              \u003cSpin size={40} type=\"simple-circle\" /\u003e\n            \u003c/Mask\u003e\n          \u003c/div\u003e\n        )\n      }\n\n      // 处理失败信息\n      if (status === FAILURE) {\n        return \u003cdiv\u003e{this.state.message}\u003c/div\u003e\n      }\n      return null\n    }\n  }\n\n  Fetch.propTypes = {\n    fetch: PropTypes.oneOfType([\n      PropTypes.string,\n      PropTypes.object,\n    ])\n  }\n  Fetch.defaultProps = {\n    fetch: null\n  }\n\n  return Fetch\n}\n```\n修改之前的src/components/author/List.js，移除了state相关的代码，变成了一个纯粹展示的组件，所以直接写成一个函数\n\n```\nimport React from 'react'\nimport PropTypes from 'prop-types'\nimport { Table, Card } from 'rctui'\nimport fetch from '_/hoc/fetch'\n\nfunction List(props) {\n  const { data } = props\n  return (\n    \u003cCard\u003e\n      \u003cCard.Header\u003e作者列表\u003c/Card.Header\u003e\n      \u003cTable\n        data={data.list}\n        columns={[\n          { name: 'id', header: 'ID' },\n          { name: 'name', header: '姓名', sort: true },\n          { name: 'nationality', header: '国籍' },\n          { name: 'birthday', header: '生日', sort: true },\n        ]}\n      /\u003e\n    \u003c/Card\u003e\n  )\n}\n\nList.propTypes = {\n  data: PropTypes.object.isRequired,\n}\n\nexport default fetch(List)\n```\n\nsrc/components/author/index.js 也要稍作修改\n\n```\n  render() {\n    const { match } = this.props\n\n    return (\n      \u003cdiv\u003e\n        \u003cRoute\n          exact\n          path={`${match.url}`}\n          {/* 这里加了fetch的属性 */}\n          render={() =\u003e \u003cList fetch={{ url: '/authorlist.json' }} /\u003e}\n        /\u003e\n      \u003c/div\u003e\n    )\n  }\n```\n\n完整代码\n\n```\n$ git checkout step-7\n```\n\n## 加入mock数据\n前面用了一个json文件来模拟数据，通常可以使用mock.js或者faker.js来模拟数据。这里再加一点私货，用一个我之前写的系统qenya，[项目地址在这里](https://github.com/Lobos/qenya)。暂时还有一些功能待补全，文档也还没有写，不过这里可以拿来mock数据。\n\n首先，安装一下\n\n```\n$ npm install qenya --save\n```\n在server下面加入启动代码\n\n```\nconst qenya = require('qenya')\n\n// qenya 会启动两个服务，一个是数据管理平台，可以设置数据表和api\n// 另一个是api服务，通过在数据管理平台配置的api访问\nqenya({\n  appPort: 3002,\n  apiPort: 3003,\n  render: function (res) {\n    if (res.data) {\n      return res.data\n    } else {\n      return {\n        error: res.errors[0].message\n      }\n    }\n  }\n})\n\n// api请求跳转到api服务器\nrouter.get('/api/*', async function (ctx) {\n  ctx.redirect(`http://localhost:3003${ctx.path}`)\n})\n```\n\nqenya会在项目下面创建一个data文件夹，数据会保存在里面。\n这里暂时忽略这些配置，只要知道有接口就好。如果感兴趣，可以checkout代码，访问localhost:3002 看下api配置，后面我会慢慢完善文档。\n\n```\n$ git checkout step-8\n```\n\n## CRUD\ncheckout代码，已经在后台配置好了四个数据接口，用来模拟服务端\n\n```\nget      /api/authorlist  获取列表数据\nget      /api/author/:id  根据id获取单条记录\npost     /api/author      添加或编辑数据\ndelete   /api/author      删除一条数据\n```\n\n新增 src/components/author/Edit.js 文件\n\n```\nimport React, { Component } from 'react'\nimport PropTypes from 'prop-types'\nimport { Card, Form, FormControl, Message, Button } from 'rctui'\nimport refetch from 'refetch'\nimport fetch from '_/hoc/fetch'\n\nclass Edit extends Component {\n  constructor(props) {\n    super(props)\n    this.handleSubmit = this.handleSubmit.bind(this)\n    this.handleCancel = this.handleCancel.bind(this)\n  }\n\n  handleSubmit(data) {\n    refetch.post('/api/author', data).then((res) =\u003e {\n      if (res.data) {\n        this.props.history.push('/author')\n        Message.success('保存成功')\n      } else {\n        Message.error(res.error)\n      }\n    })\n  }\n\n  handleCancel() {\n    this.props.history.goBack()\n  }\n\n  render() {\n    const { data } = this.props\n\n    return (\n      \u003cCard\u003e\n        \u003cCard.Header\u003e作者编辑\u003c/Card.Header\u003e\n\n        \u003cdiv style={{ padding: 20 }}\u003e\n          \u003cForm data={data} onSubmit={this.handleSubmit} \u003e\n            \u003cFormControl label=\"姓名\" name=\"name\" grid={1 / 3} type=\"text\" required min={2} max={20} /\u003e\n            \u003cFormControl label=\"生日\" name=\"birthday\" type=\"date\" required /\u003e\n            \u003cFormControl label=\"国籍\" name=\"nationality\" type=\"text\" /\u003e\n            \u003cFormControl\u003e\n              \u003cButton type=\"submit\" status=\"primary\"\u003e提交\u003c/Button\u003e\n              \u003cButton onClick={this.handleCancel}\u003e取消\u003c/Button\u003e\n            \u003c/FormControl\u003e\n          \u003c/Form\u003e\n        \u003c/div\u003e\n      \u003c/Card\u003e\n    )\n  }\n}\n\nEdit.propTypes = {\n  data: PropTypes.object,\n  history: PropTypes.object.isRequired,\n}\n\nEdit.defaultProps = {\n  data: {},\n}\n\n// 使用之前的高阶组件fetch来获取数据\nexport default fetch(Edit)\n```\n\n修改 src/components/author/index.js 文件，加入路由\n\n```\nimport React from 'react'\nimport PropTypes from 'prop-types'\nimport { Route, Switch } from 'react-router-dom'\nimport List from './List'\nimport Edit from './Edit'\n\nfunction Author(props) {\n  const { url } = props.match\n\n  return (\n    \u003cSwitch\u003e\n      {/* 新增作者，不需要fetch data */}\n      \u003cRoute path={`${url}/new`} component={Edit} /\u003e\n      {/* 编辑作者，使用fetch获取数据 */}\n      \u003cRoute\n        path={`${url}/edit/:id`}\n        render={\n          ({ history, match }) =\u003e \u003cEdit history={history} fetch={{ url: `/api/author/${match.params.id}` }} /\u003e\n        }\n      /\u003e\n      {/* 列表，因为加入了分页，数据处理放到了List里面 */}\n      \u003cRoute path={`${url}`} component={List} /\u003e\n    \u003c/Switch\u003e\n  )\n}\n\nAuthor.propTypes = {\n  match: PropTypes.object.isRequired,\n}\n\nexport default Author\n```\n\n修改 src/components/author/List.js 文件，因为加入分页功能，拆分了这个页面\n\n```\nimport React from 'react'\nimport PropTypes from 'prop-types'\nimport { Card, Button } from 'rctui'\nimport queryString from 'query-string'\nimport TableList from './TableList'\n\nfunction List(props) {\n  const { history } = props\n\n  // 从queryString中获取分页信息，格式为 ?page=x\u0026size=x\n  const query = queryString.parse(history.location.search)\n  // 每页数据数量\n  if (!query.size) query.size = 10\n\n  return (\n    \u003cCard\u003e\n      \u003cCard.Header\u003e作者列表\u003c/Card.Header\u003e\n      \u003cdiv style={{ padding: 12 }}\u003e\n        \u003cButton status=\"success\" onClick={() =\u003e history.push('/author/new')}\u003e添加作者\u003c/Button\u003e\n      \u003c/div\u003e\n\n      \u003cTableList\n        history={history}\n        fetch={{ url: '/api/authorlist', data: query }}\n      /\u003e\n    \u003c/Card\u003e\n  )\n}\n\nList.propTypes = {\n  history: PropTypes.object.isRequired,\n}\n\nexport default List\n\n```\n\nsrc/components/author/TableList.js 代码\n\n```\nimport React from 'react'\nimport PropTypes from 'prop-types'\nimport { Link } from 'react-router-dom'\nimport { Table, Pagination } from 'rctui'\nimport fetch from '_/hoc/fetch'\nimport DelButton from './DelButton'\n\nfunction TableList(props) {\n  const { data, history, fetchData } = props\n  return (\n    \u003cdiv\u003e\n      \u003cTable\n        data={data.list}\n        columns={[\n          { name: 'id', width: '60px', header: 'ID' },\n          { name: 'name', header: '姓名' },\n          { name: 'nationality', header: '国籍' },\n          { name: 'birthday', header: '生日' },\n          {\n            width: '120px',\n            content: d =\u003e (\n              \u003cspan\u003e\n                \u003cLink to={`/author/edit/${d.id}`}\u003e编辑\u003c/Link\u003e\n                {' '}\n                \u003cDelButton onSuccess={fetchData} data={d} /\u003e\n              \u003c/span\u003e\n            ),\n          },\n        ]}\n      /\u003e\n      \u003cdiv style={{ textAlign: 'center' }}\u003e\n        \u003cPagination\n          page={data.page} size={data.size} total={data.total}\n          onChange={page =\u003e history.push(`/author?page=${page}`)}\n        /\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n  )\n}\n\nTableList.propTypes = {\n  data: PropTypes.object.isRequired,\n  fetchData: PropTypes.func.isRequired,\n  history: PropTypes.object.isRequired,\n}\n\nexport default fetch(TableList)\n\n```\n\n通过react-router和高阶组件fetch，我们把author下面的所有组件（列表、分页、编辑）都变成了无状态的组件。每个组件只关心route提供了什么参数，应该怎样去展示，当需要变化的时候，history.push到相应的route就行了。\n\n完整代码\n\n```\n$ git checkout step-9\n```\n\n## Redux\n接下来写分类管理，这次我们使用redux来处理。首先，仍然是安装包\n\n```\n$ npm install redux react-redux redux-thunk --save\n```\n\n数据结构非常简单\n\n```\n{\n  \"id\": \"8\",\n  \"name\": \"科学幻想\",\n  \"desc\": \"简称科幻，是虚构作品的一种类型，描述诸如未来科技、时间旅行、超光速旅行、平行宇宙、外星生命、人工智能、错置历史等有关科学的想象性内容。\"\n}\n```\n\n同样的，有3个后端接口\n\n\n```\nget      /api/genres     获取列表数据\npost     /api/genre      添加或编辑数据\ndelete   /api/genre      删除一条数据\n```\n\n之前随手写了一个category占位，这里统一改成genre。\n\n先在src下面建一个文件夹 src/actions，用来存放 redux 的 actions。这里做了一些简化，一次从服务端拉取所有数据存在store中，没有考虑分页的问题。也没有单条数据的请求，编辑时直接从list里面获取了。\n\nsrc/actions/genre.js\n\n```\nimport { Message } from 'rctui'\nimport refetch from 'refetch'\n\nexport const GENRE_LIST = 'GENRE_LIST'\nfunction handleList(status, data, message) {\n  return {\n    type: GENRE_LIST,\n    status,\n    data,\n    message,\n  }\n}\n\n// 从服务端获取数据\nfunction fetchList() {\n  return (dispatch) =\u003e {\n    dispatch(handleList(0))\n    refetch.get('/api/genres', { size: 999 }).then((res) =\u003e {\n      if (res.data) {\n        dispatch(handleList(1, res.data.list))\n      } else {\n        dispatch(handleList(2, null, res.error))\n      }\n    }).catch((err) =\u003e {\n      dispatch(handleList(2, null, err.message))\n    })\n  }\n}\n\n// 对外获取列表的接口\nexport function getGenreList() {\n  return (dispatch, getState) =\u003e {\n    const { data, status } = getState().genre\n    \n    // 如果数据已存在，直接返回\n    if (status === 1 \u0026\u0026 data \u0026\u0026 data.length \u003e 0) {\n      return Promise.resolve()\n    }\n    \n    return dispatch(fetchList())\n  }\n}\n\n// 保存数据接口\nexport function saveGenre(body, onSuccess) {\n  return (dispatch, getState) =\u003e {\n    refetch.post('/api/genre', body, { dataType: 'json' }).then((res) =\u003e {\n      if (res.data) {\n        onSuccess()\n        \n        // 如果是修改，从数组里把原数据剔除\n        const data = getState().genre.data.filter(d =\u003e d.id !== res.data.id)\n        \n        data.unshift(res.data)\n        dispatch(handleList(1, data))\n        \n        Message.success('保存成功')\n      } else {\n        Message.error(res.error)\n      }\n    }).catch((err) =\u003e {\n      Message.error(err.message)\n    })\n  }\n}\n\n// 删除数据接口\nexport function removeGenre(id) {\n  return (dispatch, getState) =\u003e {\n    refetch.delete('/api/genre', { id }).then((res) =\u003e {\n      if (res.data === 1) {\n        Message.success('删除成功')\n        \n        // 删除直接从store的列表里剔除数据，不再发请求到服务端\n        const data = getState().genre.data.filter(d =\u003e d.id !== id)\n        \n        dispatch(handleList(1, data))\n      }\n    }).catch((err) =\u003e {\n      Message.error(err.message)\n    })\n  }\n}\n\n```\n\n接下来增加一个 src/reducers/genre.js，这个比较简单，只有一个 action type\n\n```\nimport { GENRE_LIST } from '_/actions/genre'\n\nexport default function (state = {\n  status: 0,\n  data: undefined,\n}, action) {\n  switch (action.type) {\n    case GENRE_LIST:\n      return Object.assign({}, state, {\n        status: action.status,\n        data: action.data,\n        message: action.message,\n      })\n    default:\n      return state\n  }\n}\n\n```\n\n虽然只有一个reducer，为了演示结构，还是建一个 src/reducers/index.js 文件\n\n```\nimport { combineReducers } from 'redux'\nimport genre from './genre'\n\nexport default combineReducers({\n  genre,\n})\n\n```\n\n接下来是 src/store.js，这里使用 redux-thunk 来处理异步数据\n\n```\nimport { createStore, applyMiddleware } from 'redux'\nimport thunk from 'redux-thunk'\nimport reducer from './reducers'\n\nconst createStoreWithMiddleware = applyMiddleware(thunk)(createStore)\n\nconst store = createStoreWithMiddleware(reducer)\n\nexport default store\n\n```\n\n最后，把 store 注入到 App，修改 src/index.js\n\n```\nimport React from 'react'\nimport ReactDOM from 'react-dom'\nimport { Provider } from 'react-redux'\nimport App from './App'\nimport store from './store'\n\nReactDOM.render(\n  \u003cProvider store={store}\u003e\n    \u003cApp /\u003e\n  \u003c/Provider\u003e,\n  document.getElementById('root'))\n\nmodule.hot \u0026\u0026 module.hot.accept()\n```\n\n现在可以开始写 genre 的代码了\n\nsrc/components/genre/index.js\n\n```\nimport React, { Component } from 'react'\nimport { connect } from 'react-redux'\nimport PropTypes from 'prop-types'\nimport { getGenreList } from '_/actions/genre'\nimport { Route, Switch } from 'react-router-dom'\nimport Loading from '_/components/comm/Loading'\nimport List from './List'\nimport Edit from './Edit'\n\nclass Genre extends Component {\n  constructor(props) {\n    super(props)\n    this.state = {}\n    this.renderEdit = this.renderEdit.bind(this)\n  }\n\n  componentDidMount() {\n    this.props.dispatch(getGenreList())\n  }\n\n  renderEdit({ history, match }) {\n    const { genre } = this.props\n    \n    // 这里没有从服务端获取，而是从list里面获取的单条数据\n    const data = genre.data.find(d =\u003e d.id === match.params.id)\n\n    return \u003cEdit history={history} data={data} /\u003e\n  }\n\n  render() {\n    const { genre, history, match } = this.props\n    const { url } = match\n\n    // 当没有数据的时候展示一个 Loading\n    if (genre.status === 0) {\n      return \u003cLoading height={300} /\u003e\n    }\n\n    if (genre.status === 2) {\n      return \u003cdiv\u003e{genre.message}\u003c/div\u003e\n    }\n\n    // 和 author 一样，都是三条路由，只是数据已经从props里拿到，这里直接传入\n    return (\n      \u003cSwitch\u003e\n        \u003cRoute path={`${url}/new`} component={Edit} /\u003e\n        \u003cRoute path={`${url}/edit/:id`} render={this.renderEdit} /\u003e\n        \u003cRoute\n          path={`${url}`}\n          render={() =\u003e \u003cList history={history} data={genre.data} /\u003e}\n        /\u003e\n      \u003c/Switch\u003e\n    )\n  }\n}\n\nGenre.propTypes = {\n  dispatch: PropTypes.func.isRequired,\n  genre: PropTypes.object.isRequired,\n  history: PropTypes.object.isRequired,\n  match: PropTypes.object.isRequired,\n}\n\nconst mapStateToProps = (state) =\u003e {\n  const { genre } = state\n  return { genre }\n}\n\nexport default connect(mapStateToProps)(Genre)\n\n```\n\nsrc/components/genre/List.js\n\n```\nimport React from 'react'\nimport PropTypes from 'prop-types'\nimport { Link } from 'react-router-dom'\nimport { Card, Table, Button } from 'rctui'\nimport DelButton from './DelButton'\n\nfunction List(props) {\n  const { data, history } = props\n  return (\n    \u003cCard\u003e\n      \u003cCard.Header\u003e类型列表\u003c/Card.Header\u003e\n\n      \u003cdiv style={{ padding: 12 }}\u003e\n        \u003cButton status=\"success\" onClick={() =\u003e history.push('/genre/new')}\u003e添加类型\u003c/Button\u003e\n      \u003c/div\u003e\n\n      \u003cTable\n        data={data}\n        columns={[\n          {\n            name: 'id',\n            width: 100,\n            header: 'ID',\n            sort: [\n              (a, b) =\u003e parseInt(a.id, 10) \u003e parseInt(b.id, 10) ? 1 : -1,\n              (a, b) =\u003e parseInt(a.id, 10) \u003c parseInt(b.id, 10) ? 1 : -1,\n            ],\n          },\n          { name: 'name', width: 160, header: '名称', sort: true },\n          { name: 'desc', header: '简介' },\n          {\n            width: '120px',\n            content: d =\u003e (\n              \u003cspan\u003e\n                \u003cLink to={`/genre/edit/${d.id}`}\u003e编辑\u003c/Link\u003e\n                {' '}\n                \u003cDelButton data={d} /\u003e\n              \u003c/span\u003e\n            ),\n          },\n        ]}\n        {/* 因为拿到的是全部的数据，这里使用了Table内置的分页 */}\n        pagination={{ size: 10, position: 'center' }}\n      /\u003e\n    \u003c/Card\u003e\n  )\n}\n\nList.propTypes = {\n  data: PropTypes.array.isRequired,\n  history: PropTypes.object.isRequired,\n}\n\nexport default List\n\n```\n\nsrc/components/genre/Edit.js\n\n```\nimport React, { Component } from 'react'\nimport PropTypes from 'prop-types'\nimport { connect } from 'react-redux'\nimport { Card, Form, FormControl, Button } from 'rctui'\nimport { saveGenre } from '_/actions/genre'\n\nclass Edit extends Component {\n  constructor(props) {\n    super(props)\n    this.handleSubmit = this.handleSubmit.bind(this)\n    this.handleCancel = this.handleCancel.bind(this)\n  }\n\n  handleSubmit(data) {\n    // 这里调用了 actions 里的方法\n    this.props.dispatch(saveGenre(data, this.props.history.goBack))\n  }\n\n  handleCancel() {\n    this.props.history.goBack()\n  }\n\n  render() {\n    const { data } = this.props\n\n    return (\n      \u003cCard\u003e\n        \u003cCard.Header\u003e类型编辑\u003c/Card.Header\u003e\n\n        \u003cdiv style={{ padding: 20 }}\u003e\n          \u003cForm data={data} style={{ width: 700 }} onSubmit={this.handleSubmit} \u003e\n            \u003cFormControl label=\"名称\" name=\"name\" grid={1 / 3} type=\"text\" required min={2} max={20} /\u003e\n            \u003cFormControl label=\"简介\" name=\"desc\" type=\"textarea\" max={200} /\u003e\n            \u003cFormControl\u003e\n              \u003cButton type=\"submit\" status=\"primary\"\u003e提交\u003c/Button\u003e\n              \u003cButton onClick={this.handleCancel}\u003e取消\u003c/Button\u003e\n            \u003c/FormControl\u003e\n          \u003c/Form\u003e\n        \u003c/div\u003e\n      \u003c/Card\u003e\n    )\n  }\n}\n\nEdit.propTypes = {\n  data: PropTypes.object,\n  dispatch: PropTypes.func.isRequired,\n  history: PropTypes.object.isRequired,\n}\n\nEdit.defaultProps = {\n  data: {},\n}\n\nexport default connect()(Edit)\n```\n\n对比一下author的示例，使用redux之后，实际是要复杂很多的，如果加上分页，会更加复杂一些。修改代码的时候，很可能需要在action，reducer，component的代码里找一圈。并且要时刻关心store里面的数据，比如一条数据更新或者删除了，列表里的数据也要及时更新，后期的维护成本会比较高一些。当然，优点也是显而易见的，代码结构比较清晰，任意的跨组件通信，服务端请求次数减少。\n\n个人认为，除了一些全局的数据，比如用户登陆信息，权限等等，可以放在redux里维护之外，和业务相关的大部分列表页，详情页等数据，都可以使用state维护，用完就丢，可以减少很多维护成本。\n\n完整代码\n\n```\n$ git checkout step-10\n```\n\n## 在弹出层中编辑\n\n书籍这里，换一个不一样的交互方式吧，列表改为card，编辑改为弹出层。\n\n数据结构\n\n```\n{\n  \"id\": \"17\",\n  \"title\": \"沉默的大多数\",\n  \"author\": \"1\",\n  \"genres\": \"1,2\",\n  \"publishAt\": \"1997-01\",\n  \"cover\": \"https://img1.doubanio.com/lpic/s1447349.jpg\",\n  \"desc\": \"\"\n}\n```\n\nsrc/components/book/index.js，采用弹出层的设计，所以这里不再需要子路由\n\n```\nimport React from 'react'\nimport PropTypes from 'prop-types'\nimport { Card } from 'rctui'\nimport queryString from 'query-string'\nimport List from './List'\n\nfunction Book(props) {\n  const { history } = props\n\n  const query = queryString.parse(history.location.search)\n  if (!query.size) query.size = 12\n\n  return (\n    \u003cCard\u003e\n      \u003cCard.Header\u003e书籍管理\u003c/Card.Header\u003e\n\n      \u003cList history={history} fetch={{ url: '/api/booklist', data: query }} /\u003e\n    \u003c/Card\u003e\n  )\n}\n\nBook.propTypes = {\n  history: PropTypes.object.isRequired,\n}\n\nexport default Book\n```\n\nsrc/components/book/List.js\n\n```\nimport React, { Component } from 'react'\nimport PropTypes from 'prop-types'\nimport { connect } from 'react-redux'\nimport { Media, Image, Button, Modal, Pagination } from 'rctui'\nimport { getGenreList } from '_/actions/genre'\nimport fetch from '_/hoc/fetch'\nimport Edit from './Edit'\n\nclass List extends Component {\n  componentDidMount() {\n    this.props.dispatch(getGenreList())\n  }\n\n  handleEdit(book) {\n    // 这里从store里获取类别，传递给Edit使用\n    const { genres } = this.props\n\n    const fc = book ? { url: `/api/book/${book.id}` } : undefined\n\n    const mid = Modal.open({\n      header: '书籍编辑',\n      width: 800,\n      content: (\n        \u003cEdit\n          genres={genres}\n          fetch={fc}\n          onSuccess={() =\u003e {\n            this.props.fetchData()\n            Modal.close(mid)\n          }}\n        /\u003e\n      ),\n      buttons: {\n        提交: 'submit',\n        取消: true,\n      },\n    })\n  }\n\n  render() {\n    const { data, history } = this.props\n    return (\n      \u003cdiv\u003e\n        \u003cdiv style={{ padding: 20 }}\u003e\n          \u003cButton status=\"success\" onClick={() =\u003e this.handleEdit(null)}\u003e添加书籍\u003c/Button\u003e\n        \u003c/div\u003e\n\n        {data.list.map(d =\u003e (\n          \u003cdiv key={d.id}\u003e\n            \u003cMedia\u003e\n              \u003cMedia.Left\u003e\n                \u003cImage src={d.cover} width={100} height={150} type=\"fill\" /\u003e\n              \u003c/Media.Left\u003e\n              \u003cMedia.Body style={{ fontSize: 12, paddingLeft: 10, color: '#666' }}\u003e\n                \u003ch4 style={{ fontSize: 18, marginBottom: 16 }}\u003e{d.title}\u003c/h4\u003e\n                \u003cdiv\u003e作者：{d.author}\u003c/div\u003e\n                \u003cdiv\u003e出版时间：{d.publishAt}\u003c/div\u003e\n                \u003cdiv\u003e类型：{d.genres}\u003c/div\u003e\n                \u003cButton\n                  style={{ position: 'absolute', right: 0, bottom: 0, fontSize: 12 }}\n                  status=\"link\"\n                  onClick={() =\u003e this.handleEdit(d)}\n                \u003e编辑\u003c/Button\u003e\n              \u003c/Media.Body\u003e\n            \u003c/Media\u003e\n          \u003c/div\u003e\n        ))}\n\n        \u003cdiv style={{ textAlign: 'center' }}\u003e\n          \u003cPagination\n            page={data.page} size={data.size} total={data.total}\n            onChange={page =\u003e history.push(`/book?page=${page}`)}\n          /\u003e\n        \u003c/div\u003e\n      \u003c/div\u003e\n    )\n  }\n}\n\nList.propTypes = {\n  data: PropTypes.object.isRequired,\n  dispatch: PropTypes.func.isRequired,\n  fetchData: PropTypes.func.isRequired,\n  genres: PropTypes.array,\n  history: PropTypes.object.isRequired,\n}\n\nList.defaultProps = {\n  genres: [],\n}\n\nconst mapStateToProps = (state) =\u003e {\n  const { genre } = state\n  return { genres: genre.data }\n}\n\nexport default fetch(connect(mapStateToProps)(List))\n\n```\n\nsrc/components/book/Edit.js\n\n```\nimport React, { Component } from 'react'\nimport PropTypes from 'prop-types'\nimport { Form, FormControl, Message } from 'rctui'\nimport fetch from '_/hoc/fetch'\nimport refetch from 'refetch'\n\nclass Edit extends Component {\n  constructor(props) {\n    super(props)\n\n    this.handleSubmit = this.handleSubmit.bind(this)\n  }\n\n  handleSubmit(data) {\n    refetch.post('/api/book', data).then((res) =\u003e {\n      if (res.data) {\n        this.props.onSuccess()\n        Message.success('保存成功')\n      } else {\n        Message.error(res.error)\n      }\n    })\n  }\n\n  render() {\n    const { data, genres } = this.props\n\n    return (\n      \u003cForm data={data} onSubmit={this.handleSubmit}\u003e\n        \u003cFormControl label=\"书名\" name=\"title\" grid={1 / 2} type=\"text\" required min={2} max={20} /\u003e\n\n        \u003cFormControl\n          label=\"作者\" name=\"author\" type=\"select\" required grid={1 / 3}\n          fetch={{ url: '/api/authorlist?size=999', then: res =\u003e res.data.list }}\n          valueTpl=\"{id}\" optionTpl=\"{name}\"\n        /\u003e\n\n        \u003cFormControl label=\"出版时间\" type=\"text\" name=\"publishAt\" grid={1 / 2} /\u003e\n\n        \u003cFormControl label=\"封面图片\" type=\"text\" name=\"cover\" grid={7 / 8} /\u003e\n\n        \u003cFormControl\n          label=\"类别\" type=\"checkbox-group\" name=\"genres\"\n          data={genres} valueTpl=\"{id}\" textTpl=\"{name}\"\n        /\u003e\n\n        \u003cFormControl label=\"简介\" type=\"textarea\" rows={3} name=\"desc\" grid={7 / 8} /\u003e\n      \u003c/Form\u003e\n    )\n  }\n}\n\nEdit.propTypes = {\n  data: PropTypes.object,\n  genres: PropTypes.array.isRequired,\n  onSuccess: PropTypes.func.isRequired,\n}\n\nEdit.defaultProps = {\n  data: {},\n}\n\nexport default fetch(Edit)\n```\n\n完整代码\n\n```\n$ git checkout step-11\n```\n\n## 项目结构\n最后说下项目结构吧，可能不是最好的，不过算是我做了一些项目下来比较顺手的。\n\n```\n|- build/   生产代码，发布到cdn的\n|- demo/    放一些本地开发需要用到的文件，html，第三方库等等\n|- src/     源代码目录\n|   |- actions/     redux actions 目录\n|   |- components/  对于SPA应用，个人习惯把所有组件都放在这个目录下\n|   |- hoc/         高阶组件目录\n|   |- reducers/    redux reducer 目录\n|   |- styles/      个人习惯把样式文件统一起来管理，因为可能会有一些全局的变量文件，\n\t\t\t\t\t 实际上文件并不多，如果不是复用的样式或者是伪类，都直接写在组件上\n|   |- utils/       一些工具类的文件\n|   |- App.js       项目框架文件\n|   |- index.js     项目入口文件\n|   |- store.js\n|- .eslintrc        eslint 配置文件\n|- .npmrc           这个文件可以放在全局，不过我的机器上有些项目在内网，所以习惯放在项目下面单独管理\n|- server.js        开发服务器\n|- webpack.dev.config.js  开发时用的webpack配置\n|- webpack.config.js      发布时用的配置\n```","funding_links":[],"categories":["JavaScript"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FLobos%2Freact-example","html_url":"https://awesome.ecosyste.ms/projects/github.com%2FLobos%2Freact-example","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FLobos%2Freact-example/lists"}