用 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 並可以返回首頁。
使用技術
- React
- Redux
- Redux Thunk
- React Router
- ImmutableJS
- Fetch
- Material UI
- Roboto Font from Google Font
- Github API(https://api.github.com/users/torvalds)
不過要注意的是 Github API 若沒有使用 App key 的話可以呼叫 API 的次數會受限
專案成果截圖
環境安裝與設定
安裝 Node 和 NPM
安裝所需套件
$ 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
$ 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
接下來我們先設定一下開發文檔。
設定 Babel 的設定檔:
.babelrc
{ "presets": [ "es2015", "react", ], "plugins": [] }
設定 ESLint 的設定檔和規則:
.eslintrc
{ "extends": "airbnb", "rules": { "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], }, "env" :{ "browser": true, } }
設定 Webpack 設定檔:
webpack.config.js
// 讓你可以動態插入 bundle 好的 .js 檔到 .index.html const HtmlWebpackPlugin = require('html-webpack-plugin'); const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({ template: `${__dirname}/src/index.html`, filename: 'index.html', inject: 'body', }); // entry 為進入點,output 為進行完 eslint、babel loader 轉譯後的檔案位置 module.exports = { entry: [ './src/index.js', ], output: { path: `${__dirname}/dist`, filename: 'index_bundle.js', }, module: { preLoaders: [ { test: /\.jsx$|\.js$/, loader: 'eslint-loader', include: `${__dirname}/src`, exclude: /bundle\.js$/ } ], loaders: [{ test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader', query: { presets: ['es2015', 'react'], }, }], }, // 啟動開發測試用 server 設定(不能用在 production) devServer: { inline: true, port: 8008, }, plugins: [HTMLWebpackPluginConfig], };
太好了!這樣我們就完成了開發環境的設定可以開始動手實作 Github Finder
應用程式了!
動手實作
Setup Mockup
HTML Markup(
src/index.html
):<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>GithubFinder</title> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet"> </head> <body> <div id="app"></div> </body> </html>
設定
webpack.config.js
的進入點src/index.js
:import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { browserHistory, Router, Route, IndexRoute } from 'react-router'; import injectTapEventPlugin from 'react-tap-event-plugin'; import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; import Main from './components/Main'; import HomePageContainer from './containers/HomePageContainer'; import ResultPageContainer from './containers/ResultPageContainer'; import store from './store'; // 引入 react-tap-event-plugin 避免 material-ui onTouchTap event 會遇到的問題 // Needed for onTouchTap // http://stackoverflow.com/a/34015469/988941 injectTapEventPlugin(); // 用 react-redux 的 Provider 包起來將 store 傳遞下去,讓每個 components 都可以存取到 state // 這邊使用 browserHistory 當做 history,並使用 material-ui 的 MuiThemeProvider 包裹整個 components // 由於這邊是簡易的 App 我們設計了 Main 為母模版,其有兩個子元件 HomePageContainer 和 ResultPageContainer,其中 HomePageContainer 為根位置的子元件 ReactDOM.render( <Provider store={store}> <MuiThemeProvider> <Router history={browserHistory}> <Route path="/" component={Main}> <IndexRoute component={HomePageContainer} /> <Route path="/result" component={ResultPageContainer} /> </Route> </Router> </MuiThemeProvider> </Provider>, document.getElementById('app') );
Actions
首先先定義 actions 常數:
export const SHOW_SPINNER = 'SHOW_SPINNER'; export const HIDE_SPINNER = 'HIDE_SPINNER'; export const GET_GITHUB_INITIATE = 'GET_GITHUB_INITIATE'; export const GET_GITHUB_SUCCESS = 'GET_GITHUB_SUCCESS'; export const GET_GITHUB_FAIL = 'GET_GITHUB_FAIL'; export const CHAGE_USER_ID = 'CHAGE_USER_ID';
現在我們來規劃我們的 actions 的部份,這個範例我們使用到了
redux-thunk
來處理非同步的 action(若讀者對於新的 Ajax 處理方式 fetch() 不熟悉可以先參考這個文件)。以下是src/actions/githubActions.js
完整程式碼:// 這邊引入了 fetch 的 polyfill,考以讓舊的瀏覽器也可以使用 fetch import 'whatwg-fetch'; // 引入 actionTypes 常數 import { GET_GITHUB_INITIATE, GET_GITHUB_SUCCESS, GET_GITHUB_FAIL, CHAGE_USER_ID, } from '../constants/actionTypes'; // 引入 uiActions 的 action import { showSpinner, hideSpinner, } from './uiActions'; // 這邊是這個範例的重點,要學習我們之前尚未講解的非同步 action 處理方式:不同於一般同步 action 直接發送 action,非同步 action 會回傳一個帶有 dispatch 參數的 function,裡面使用了 Ajax(這裡使用 fetch())進行處理 // 一般和 API 互動的流程:INIT(開始請求/秀出 spinner)-> COMPLETE(完成請求/隱藏 spinner)-> ERROR(請求失敗) // 這次我們雖然沒有使用 redux-actions 但我們還是維持標準 Flux Standard Action 格式:{ type: '', payload: {} } export const getGithub = (userId = 'torvalds') => { return (dispatch) => { dispatch({ type: GET_GITHUB_INITIATE }); dispatch(showSpinner()); fetch('https://api.github.com/users/' + userId) .then(function(response) { return response.json() }) .then(function(json) { dispatch({ type: GET_GITHUB_SUCCESS, payload: { data: json } }); dispatch(hideSpinner()); }) .catch(function(response) { dispatch({ type: GET_GITHUB_FAIL }) }); } } // 同步 actions 處理,回傳 action 物件 export const changeUserId = (text) => ({ type: CHAGE_USER_ID, payload: { userId: text } });
以下是
src/actions/uiActions.js
負責處理 UI 的行為:import { createAction } from 'redux-actions'; import { SHOW_SPINNER, HIDE_SPINNER, } from '../constants/actionTypes'; // 同步 actions 處理,回傳 action 物件 export const showSpinner = () => ({ type: SHOW_SPINNER}); export const hideSpinner = () => ({ type: HIDE_SPINNER});
透過於
src/actions/index.js
將我們 actions 輸出export * from './uiActions'; export * from './githubActions';
Reducers
接下來我們要來設定一下 Reducers 和 models(initialState 格式)的設計,注意我們這個範例都是使用
ImmutableJS
。以下是src/constants/models.js
:import Immutable from 'immutable'; export const UiState = Immutable.fromJS({ spinnerVisible: false, }); // 我們使用 userId 來暫存使用者 ID,data 存放 Ajax 取回的資料 export const GithubState = Immutable.fromJS({ userId: '', data: {}, });
以下是
src/reducers/data/githubReducers.js
:import { handleActions } from 'redux-actions'; import { GithubState } from '../../constants/models'; import { GET_GITHUB_INITIATE, GET_GITHUB_SUCCESS, GET_GITHUB_FAIL, CHAGE_USER_ID, } from '../../constants/actionTypes'; const githubReducers = handleActions({ // 當使用者按送出按鈕,發出 GET_GITHUB_SUCCESS action 時將接收到的資料 merge GET_GITHUB_SUCCESS: (state, { payload }) => ( state.merge({ data: payload.data, }) ), // 當使用者輸入使用者 ID 會發出 CHAGE_USER_ID action 時將接收到的資料 merge CHAGE_USER_ID: (state, { payload }) => ( state.merge({ 'userId': payload.userId }) ), }, GithubState); export default githubReducers;
以下是
src/reducers/ui/uiReducers.js
:import { handleActions } from 'redux-actions'; import { UiState } from '../../constants/models'; import { SHOW_SPINNER, HIDE_SPINNER, } from '../../constants/actionTypes'; // 隨著 fetch 結果顯示 spinner const uiReducers = handleActions({ SHOW_SPINNER: (state) => ( state.set( 'spinnerVisible', true ) ), HIDE_SPINNER: (state) => ( state.set( 'spinnerVisible', false ) ), }, UiState); export default uiReducers;
將 reduces 使用
redux-immutable
的combineReducers
在一起。以下是src/reducers/index.js
:import { combineReducers } from 'redux-immutable'; import ui from './ui/uiReducers';// import routes from './routes'; import github from './data/githubReducers';// import routes from './routes'; const rootReducer = combineReducers({ ui, github, }); export default rootReducer;
運用 redux 提供的 createStore API 把
rootReducer
、initialState
、middlewares
整合後創建出 store。以下是src/store/configureSotore.js
import { createStore, applyMiddleware } from 'redux'; import reduxThunk from 'redux-thunk'; import createLogger from 'redux-logger'; import Immutable from 'immutable'; import rootReducer from '../reducers'; const initialState = Immutable.Map(); export default createStore( rootReducer, initialState, applyMiddleware(reduxThunk, createLogger({ stateTransformer: state => state.toJS() })) );
Build Component
終於我們進入了 View 的細節設計,首先我們先針對母模版,也就是每個頁面都會出現的
AppBar
做設計。以下是src/components/Main/Main.js
:import React from 'react'; // 引入 AppBar import AppBar from 'material-ui/AppBar'; const Main = (props) => ( <div> <AppBar title="Github Finder" showMenuIconButton={false} /> <div> {props.children} </div> </div> ); // 進行 propTypes 驗證 Main.propTypes = { children: React.PropTypes.object, }; export default Main;
以下是
src/components/ResultPage/HomePage.js
:import React from 'react'; // 使用 react-router 的 Link 當做超連結,傳送 userId 當作 query import { Link } from 'react-router'; import RaisedButton from 'material-ui/RaisedButton'; import TextField from 'material-ui/TextField'; import IconButton from 'material-ui/IconButton'; import FontIcon from 'material-ui/FontIcon'; const HomePage = ({ userId, onSubmitUserId, onChangeUserId, }) => ( <div> <TextField hintText="Please Key in your Github User Id." onChange={onChangeUserId} /> <Link to={{ pathname: '/result', query: { userId: userId } }}> <RaisedButton label="Submit" onClick={onSubmitUserId(userId)} primary /> </Link> </div> ); export default HomePage;
以下是
src/components/ResultPage/ResultPage.js
,將userId
當作props
傳給<GithubBox />
:
```javascript
import React from 'react';
import GithubBox from '../../components/GithubBox';
const ResultPage = (props) => (
<div>
<GithubBox data={props.data} userId={props.location.query.userId} />
</div>
);
export default ResultPage;
```
以下是 `src/components/GithubBox/GithubBox.js`,負責擷取的 Github 資料呈現:
```javascript
import React from 'react';
import { Link } from 'react-router';
// 引入 material-ui 的卡片式元件
import { Card, CardActions, CardHeader, CardMedia, CardTitle, CardText } from 'material-ui/Card';
// 引入 material-ui 的 RaisedButton
import RaisedButton from 'material-ui/RaisedButton';
// 引入 ActionHome icon
import ActionHome from 'material-ui/svg-icons/action/home';
const GithubBox = (props) => (
<div>
<Card>
<CardHeader
title={props.data.get('name')}
subtitle={props.userId}
avatar={props.data.get('avatar_url')}
/>
<CardText>
Followers : {props.data.get('followers')}
</CardText>
<CardText>
Following : {props.data.get('following')}
</CardText>
<CardActions>
<Link to="/">
<RaisedButton
label="Back"
icon={<ActionHome />}
secondary={true}
/>
</Link>
</CardActions>
</Card>
</div>
);
export default GithubBox;
```
Connect State to Component
最後,我們要將 Container 和 Component 連接在一起(若忘記了,請先回去複習 Container 與 Presentational Components 入門!)。以下是
src/containers/HomePage/HomePage.js
,負責將 userId 和使用到的事件處理方法用 props 傳進 component :import { connect } from 'react-redux'; import HomePage from '../../components/HomePage'; import { getGithub, changeUserId, } from '../../actions'; export default connect( (state) => ({ userId: state.getIn(['github', 'userId']), }), (dispatch) => ({ onChangeUserId: (event) => ( dispatch(changeUserId(event.target.value)) ), onSubmitUserId: (userId) => () => ( dispatch(getGithub(userId)) ), }), (stateProps, dispatchProps, ownProps) => { const { userId } = stateProps; const { onSubmitUserId } = dispatchProps; return Object.assign({}, stateProps, dispatchProps, ownProps, { onSubmitUserId: onSubmitUserId(userId), }); } )(HomePage);
以下是
src/containers/ResultPage/ResultPage.js
:import { connect } from 'react-redux'; import ResultPage from '../../components/ResultPage'; export default connect( (state) => ({ data: state.getIn(['github', 'data']) }), (dispatch) => ({}) )(ResultPage);
That's it
若一切順利的話,這時候你可以在終端機下
$ npm start
指令,然後在http://localhost:8008
就可以看到你的努力成果囉!
總結
本章帶領讀者們從零開始整合 React + Redux + ImmutableJS + React Router 搭配 Github API 製作一個簡單的 Github 使用者查詢應用。下一章我們將挑戰進階應用,學習 Server Side Rendering 方面的知識,並用 React + Redux + Node(Isomorphic)開發一個食譜分享網站。
延伸閱讀
- Tutorial: build a weather app with React
- OpenWeatherMap
- Weather Icons
- Weather API Icons
- Material UI
- 【翻译】这个API很“迷人”——(新的Fetch API)
- Redux: trigger async data fetch on React view event
- Github API
- 传统 Ajax 已死,Fetch 永生
:door: 任意門
| 回首頁 | 上一章:Container 與 Presentational Components 入門 | 下一章:React Redux Sever Rendering(Isomorphic JavaScript)入門 |
| 勘誤、提問或許願 |