用 React + Router + Redux + ImmutableJS 写一个 Github 查询应用

前言

学了一身本领后,本章将带大家完成一个单页式应用程式(Single Page Application),整合 React + Redux + ImmutableJS + React Router 搭配 Github API 制作一个简单的 Github 使用者查询应用,实际体验一下开发 React App 的感受。

功能规划

让访客可以使用 Github ID 搜寻 Github 使用者,展示 Github 使用者名称、follower、following、avatar_url 并可以返回首页。

使用技术

  1. React
  2. Redux
  3. Redux Thunk
  4. React Router
  5. ImmutableJS
  6. Fetch
  7. Material UI
  8. Roboto Font from Google Font
  9. Github API(https://api.github.com/users/torvalds)

不过要注意的是 Github API 若没有使用 App key 的话可以呼叫 API 的次数会受限

专案成果截图

React Redux

React Redux

环境安装与设定

  1. 安装 Node 和 NPM

  2. 安装所需套件

  1. $ npm install --save react react-dom redux react-redux react-router immutable redux-immutable redux-actions whatwg-fetch redux-thunk material-ui react-tap-event-plugin
  1. $ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-1 eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server redux-logger

接下来我们先设定一下开发文档。

  1. 设定 Babel 的设定档: .babelrc

    1. {
    2. "presets": [
    3. "es2015",
    4. "react",
    5. ],
    6. "plugins": []
    7. }
  2. 设定 ESLint 的设定档和规则: .eslintrc

    1. {
    2. "extends": "airbnb",
    3. "rules": {
    4. "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
    5. },
    6. "env" :{
    7. "browser": true,
    8. }
    9. }
  3. 设定 Webpack 设定档: webpack.config.js

    1. // 让你可以动态插入 bundle 好的 .js 档到 .index.html
    2. const HtmlWebpackPlugin = require('html-webpack-plugin');
    3. const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({
    4. template: `${__dirname}/src/index.html`,
    5. filename: 'index.html',
    6. inject: 'body',
    7. });
    8. // entry 为进入点,output 为进行完 eslint、babel loader 转译后的档案位置
    9. module.exports = {
    10. entry: [
    11. './src/index.js',
    12. ],
    13. output: {
    14. path: `${__dirname}/dist`,
    15. filename: 'index_bundle.js',
    16. },
    17. module: {
    18. preLoaders: [
    19. {
    20. test: /\.jsx$|\.js$/,
    21. loader: 'eslint-loader',
    22. include: `${__dirname}/src`,
    23. exclude: /bundle\.js$/
    24. }
    25. ],
    26. loaders: [{
    27. test: /\.js$/,
    28. exclude: /node_modules/,
    29. loader: 'babel-loader',
    30. query: {
    31. presets: ['es2015', 'react'],
    32. },
    33. }],
    34. },
    35. // 启动开发测试用 server 设定(不能用在 production)
    36. devServer: {
    37. inline: true,
    38. port: 8008,
    39. },
    40. plugins: [HTMLWebpackPluginConfig],
    41. };

太好了!这样我们就完成了开发环境的设定可以开始动手实作 Github Finder 应用程式了!

动手实作

  1. Setup Mockup

    HTML Markup(src/index.html):

    1. <!DOCTYPE html>
    2. <html lang="en">
    3. <head>
    4. <meta charset="UTF-8">
    5. <title>GithubFinder</title>
    6. <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
    7. </head>
    8. <body>
    9. <div id="app"></div>
    10. </body>
    11. </html>

    设定 webpack.config.js 的进入点 src/index.js

    1. import React from 'react';
    2. import ReactDOM from 'react-dom';
    3. import { Provider } from 'react-redux';
    4. import { browserHistory, Router, Route, IndexRoute } from 'react-router';
    5. import injectTapEventPlugin from 'react-tap-event-plugin';
    6. import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
    7. import Main from './components/Main';
    8. import HomePageContainer from './containers/HomePageContainer';
    9. import ResultPageContainer from './containers/ResultPageContainer';
    10. import store from './store';
    11. // 引入 react-tap-event-plugin 避免 material-ui onTouchTap event 会遇到的问题
    12. // Needed for onTouchTap
    13. // http://stackoverflow.com/a/34015469/988941
    14. injectTapEventPlugin();
    15. // 用 react-redux 的 Provider 包起来将 store 传递下去,让每个 components 都可以存取到 state
    16. // 这边使用 browserHistory 当做 history,并使用 material-ui 的 MuiThemeProvider 包裹整个 components
    17. // 由于这边是简易的 App 我们设计了 Main 为母模版,其有两个子组件 HomePageContainer 和 ResultPageContainer,其中 HomePageContainer 为根位置的子组件
    18. ReactDOM.render(
    19. <Provider store={store}>
    20. <MuiThemeProvider>
    21. <Router history={browserHistory}>
    22. <Route path="/" component={Main}>
    23. <IndexRoute component={HomePageContainer} />
    24. <Route path="/result" component={ResultPageContainer} />
    25. </Route>
    26. </Router>
    27. </MuiThemeProvider>
    28. </Provider>,
    29. document.getElementById('app')
    30. );
  2. Actions

    首先先定义 actions 常数:

    1. export const SHOW_SPINNER = 'SHOW_SPINNER';
    2. export const HIDE_SPINNER = 'HIDE_SPINNER';
    3. export const GET_GITHUB_INITIATE = 'GET_GITHUB_INITIATE';
    4. export const GET_GITHUB_SUCCESS = 'GET_GITHUB_SUCCESS';
    5. export const GET_GITHUB_FAIL = 'GET_GITHUB_FAIL';
    6. export const CHAGE_USER_ID = 'CHAGE_USER_ID';

    现在我们来规划我们的 actions 的部份,这个范例我们使用到了 redux-thunk 来处理非同步的 action(若读者对于新的 Ajax 处理方式 fetch() 不熟悉可以先参考这个文件)。以下是 src/actions/githubActions.js 完整程式码:

    1. // 这边引入了 fetch 的 polyfill,考以让旧的浏览器也可以使用 fetch
    2. import 'whatwg-fetch';
    3. // 引入 actionTypes 常数
    4. import {
    5. GET_GITHUB_INITIATE,
    6. GET_GITHUB_SUCCESS,
    7. GET_GITHUB_FAIL,
    8. CHAGE_USER_ID,
    9. } from '../constants/actionTypes';
    10. // 引入 uiActions 的 action
    11. import {
    12. showSpinner,
    13. hideSpinner,
    14. } from './uiActions';
    15. // 这边是这个范例的重点,要学习我们之前尚未讲解的非同步 action 处理方式:不同于一般同步 action 直接发送 action,非同步 action 会回传一个带有 dispatch 参数的 function,里面使用了 Ajax(这里使用 fetch())进行处理
    16. // 一般和 API 互动的流程:INIT(开始请求/秀出 spinner)-> COMPLETE(完成请求/隐藏 spinner)-> ERROR(请求失败)
    17. // 这次我们虽然没有使用 redux-actions 但我们还是维持标准 Flux Standard Action 格式:{ type: '', payload: {} }
    18. export const getGithub = (userId = 'torvalds') => {
    19. return (dispatch) => {
    20. dispatch({ type: GET_GITHUB_INITIATE });
    21. dispatch(showSpinner());
    22. fetch('https://api.github.com/users/' + userId)
    23. .then(function(response) { return response.json() })
    24. .then(function(json) {
    25. dispatch({ type: GET_GITHUB_SUCCESS, payload: { data: json } });
    26. dispatch(hideSpinner());
    27. })
    28. .catch(function(response) { dispatch({ type: GET_GITHUB_FAIL }) });
    29. }
    30. }
    31. // 同步 actions 处理,回传 action 物件
    32. export const changeUserId = (text) => ({ type: CHAGE_USER_ID, payload: { userId: text } });

    以下是 src/actions/uiActions.js 负责处理 UI 的行为:

    1. import { createAction } from 'redux-actions';
    2. import {
    3. SHOW_SPINNER,
    4. HIDE_SPINNER,
    5. } from '../constants/actionTypes';
    6. // 同步 actions 处理,回传 action 物件
    7. export const showSpinner = () => ({ type: SHOW_SPINNER});
    8. export const hideSpinner = () => ({ type: HIDE_SPINNER});

    透过于 src/actions/index.js 将我们 actions 输出

    1. export * from './uiActions';
    2. export * from './githubActions';
  3. Reducers

    接下来我们要来设定一下 Reducers 和 models(initialState 格式)的设计,注意我们这个范例都是使用 ImmutableJS。以下是 src/constants/models.js

    1. import Immutable from 'immutable';
    2. export const UiState = Immutable.fromJS({
    3. spinnerVisible: false,
    4. });
    5. // 我们使用 userId 来暂存使用者 ID,data 存放 Ajax 取回的资料
    6. export const GithubState = Immutable.fromJS({
    7. userId: '',
    8. data: {},
    9. });

    以下是 src/reducers/data/githubReducers.js

    1. import { handleActions } from 'redux-actions';
    2. import { GithubState } from '../../constants/models';
    3. import {
    4. GET_GITHUB_INITIATE,
    5. GET_GITHUB_SUCCESS,
    6. GET_GITHUB_FAIL,
    7. CHAGE_USER_ID,
    8. } from '../../constants/actionTypes';
    9. const githubReducers = handleActions({
    10. // 当使用者按送出按钮,发出 GET_GITHUB_SUCCESS action 时将接收到的资料 merge
    11. GET_GITHUB_SUCCESS: (state, { payload }) => (
    12. state.merge({
    13. data: payload.data,
    14. })
    15. ),
    16. // 当使用者输入使用者 ID 会发出 CHAGE_USER_ID action 时将接收到的资料 merge
    17. CHAGE_USER_ID: (state, { payload }) => (
    18. state.merge({
    19. 'userId':
    20. payload.userId
    21. })
    22. ),
    23. }, GithubState);
    24. export default githubReducers;

    以下是 src/reducers/ui/uiReducers.js

    1. import { handleActions } from 'redux-actions';
    2. import { UiState } from '../../constants/models';
    3. import {
    4. SHOW_SPINNER,
    5. HIDE_SPINNER,
    6. } from '../../constants/actionTypes';
    7. // 随着 fetch 结果显示 spinner
    8. const uiReducers = handleActions({
    9. SHOW_SPINNER: (state) => (
    10. state.set(
    11. 'spinnerVisible',
    12. true
    13. )
    14. ),
    15. HIDE_SPINNER: (state) => (
    16. state.set(
    17. 'spinnerVisible',
    18. false
    19. )
    20. ),
    21. }, UiState);
    22. export default uiReducers;

    将 reduces 使用 redux-immutablecombineReducers 在一起。以下是 src/reducers/index.js

    1. import { combineReducers } from 'redux-immutable';
    2. import ui from './ui/uiReducers';// import routes from './routes';
    3. import github from './data/githubReducers';// import routes from './routes';
    4. const rootReducer = combineReducers({
    5. ui,
    6. github,
    7. });
    8. export default rootReducer;

    运用 redux 提供的 createStore API 把 rootReducerinitialStatemiddlewares 整合后创建出 store。以下是 src/store/configureSotore.js

    1. import { createStore, applyMiddleware } from 'redux';
    2. import reduxThunk from 'redux-thunk';
    3. import createLogger from 'redux-logger';
    4. import Immutable from 'immutable';
    5. import rootReducer from '../reducers';
    6. const initialState = Immutable.Map();
    7. export default createStore(
    8. rootReducer,
    9. initialState,
    10. applyMiddleware(reduxThunk, createLogger({ stateTransformer: state => state.toJS() }))
    11. );
  4. Build Component

    终于我们进入了 View 的细节设计,首先我们先针对母模版,也就是每个页面都会出现的 AppBar 做设计。以下是 src/components/Main/Main.js

    1. import React from 'react';
    2. // 引入 AppBar
    3. import AppBar from 'material-ui/AppBar';
    4. const Main = (props) => (
    5. <div>
    6. <AppBar
    7. title="Github Finder"
    8. showMenuIconButton={false}
    9. />
    10. <div>
    11. {props.children}
    12. </div>
    13. </div>
    14. );
    15. // 进行 propTypes 验证
    16. Main.propTypes = {
    17. children: React.PropTypes.object,
    18. };
    19. export default Main;

    以下是 src/components/ResultPage/HomePage.js

    1. import React from 'react';
    2. // 使用 react-router 的 Link 当做超连结,传送 userId 当作 query
    3. import { Link } from 'react-router';
    4. import RaisedButton from 'material-ui/RaisedButton';
    5. import TextField from 'material-ui/TextField';
    6. import IconButton from 'material-ui/IconButton';
    7. import FontIcon from 'material-ui/FontIcon';
    8. const HomePage = ({
    9. userId,
    10. onSubmitUserId,
    11. onChangeUserId,
    12. }) => (
    13. <div>
    14. <TextField
    15. hintText="Please Key in your Github User Id."
    16. onChange={onChangeUserId}
    17. />
    18. <Link to={{
    19. pathname: '/result',
    20. query: { userId: userId }
    21. }}>
    22. <RaisedButton label="Submit" onClick={onSubmitUserId(userId)} primary />
    23. </Link>
    24. </div>
    25. );
    26. export default HomePage;

    以下是 src/components/ResultPage/ResultPage.js,将 userId 当作 props 传给 <GithubBox />

  1. ```javascript
  2. import React from 'react';
  3. import GithubBox from '../../components/GithubBox';
  4. const ResultPage = (props) => (
  5. <div>
  6. <GithubBox data={props.data} userId={props.location.query.userId} />
  7. </div>
  8. );
  9. export default ResultPage;
  10. ```
  11. 以下是 `src/components/GithubBox/GithubBox.js`,负责撷取的 Github 资料呈现:
  12. ```javascript
  13. import React from 'react';
  14. import { Link } from 'react-router';
  15. // 引入 material-ui 的卡片式组件
  16. import { Card, CardActions, CardHeader, CardMedia, CardTitle, CardText } from 'material-ui/Card';
  17. // 引入 material-ui 的 RaisedButton
  18. import RaisedButton from 'material-ui/RaisedButton';
  19. // 引入 ActionHome icon
  20. import ActionHome from 'material-ui/svg-icons/action/home';
  21. const GithubBox = (props) => (
  22. <div>
  23. <Card>
  24. <CardHeader
  25. title={props.data.get('name')}
  26. subtitle={props.userId}
  27. avatar={props.data.get('avatar_url')}
  28. />
  29. <CardText>
  30. Followers : {props.data.get('followers')}
  31. </CardText>
  32. <CardText>
  33. Following : {props.data.get('following')}
  34. </CardText>
  35. <CardActions>
  36. <Link to="/">
  37. <RaisedButton
  38. label="Back"
  39. icon={<ActionHome />}
  40. secondary={true}
  41. />
  42. </Link>
  43. </CardActions>
  44. </Card>
  45. </div>
  46. );
  47. export default GithubBox;
  48. ```
  1. Connect State to Component

    最后,我们要将 Container 和 Component 连接在一起(若忘记了,请先回去复习 Container 与 Presentational Components 入门!)。以下是 src/containers/HomePage/HomePage.js,负责将 userId 和使用到的事件处理方法用 props 传进 component :

    1. import { connect } from 'react-redux';
    2. import HomePage from '../../components/HomePage';
    3. import {
    4. getGithub,
    5. changeUserId,
    6. } from '../../actions';
    7. export default connect(
    8. (state) => ({
    9. userId: state.getIn(['github', 'userId']),
    10. }),
    11. (dispatch) => ({
    12. onChangeUserId: (event) => (
    13. dispatch(changeUserId(event.target.value))
    14. ),
    15. onSubmitUserId: (userId) => () => (
    16. dispatch(getGithub(userId))
    17. ),
    18. }),
    19. (stateProps, dispatchProps, ownProps) => {
    20. const { userId } = stateProps;
    21. const { onSubmitUserId } = dispatchProps;
    22. return Object.assign({}, stateProps, dispatchProps, ownProps, {
    23. onSubmitUserId: onSubmitUserId(userId),
    24. });
    25. }
    26. )(HomePage);

    以下是 src/containers/ResultPage/ResultPage.js

    1. import { connect } from 'react-redux';
    2. import ResultPage from '../../components/ResultPage';
    3. export default connect(
    4. (state) => ({
    5. data: state.getIn(['github', 'data'])
    6. }),
    7. (dispatch) => ({})
    8. )(ResultPage);
  2. That’s it

    若一切顺利的话,这时候你可以在终端机下 $ npm start 指令,然后在 http://localhost:8008 就可以看到你的努力成果囉!

    React Redux

总结

本章带领读者们从零开始整合 React + Redux + ImmutableJS + React Router 搭配 Github API 制作一个简单的 Github 使用者查询应用。下一章我们将挑战进阶应用,学习 Server Side Rendering 方面的知识,并用 React + Redux + Node(Isomorphic)开发一个食谱分享网站。

延伸阅读

  1. Tutorial: build a weather app with React
  2. OpenWeatherMap
  3. Weather Icons
  4. Weather API Icons
  5. Material UI
  6. 【翻译】这个API很“迷人”——(新的Fetch API)
  7. Redux: trigger async data fetch on React view event
  8. Github API
  9. 传统 Ajax 已死,Fetch 永生

| 勘误、提问或许愿 |