用 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 永生
| 勘误、提问或许愿 |