用React+Redux写一个RubyChina山寨版(一)
代码地址
https://github.com/hql123/reactJS-ruby-china
Demo
https://hql123.github.io/reactJS-ruby-china/
相关
用React+Redux写一个RubyChina山寨版(二)
项目简介
项目不断更新完善中,目前实现的功能不多,是一边写代码一边写的文档,每个人搭建React项目的时候习惯都不一样,我只是希望把我自己在学习React中的经验分享出来,如果觉得我的项目对你的初学有帮助的话,可以拜托给个start咩?求轻拍~
使用之前请先阅读Redux中文文档
步骤一:启动项目并初始化
全局安装create-react-app
npm install -g create-react-app
初始化项目(ruby-china是文件夹名称)
create-react-app ruby-china
安装成功以后会有以下内容
进入文件目录
cd ruby-china
输入ls
可查看目录内容包括以下文件
其中
node_modules
是第三方的安装包,在.gitignore
中是默认忽略,package.json
是第三方库安装配置文件,public
内存储静态html或图片等,src
是应用目录,js或jsx文件、css文件、打包文件等写在里面。这是使用create-react-app
启动的默认目录结构,当然也可以自定义。
现在你已经拥有一个最简单的“Welcome to React”的项目,下面我们正式开始。
首先我们先确保使用create-react-app已经安装react
和react-dom
,如果没有请手动执行以下命令
npm init
这个命令之后会要求填写一些配置选项,包括入口文件、git地址等,根据个人需求填写就行,我基本都默认
npm install --save react react-dom
建议翻墙,没有翻墙的建议修改npm的镜像,如:
npm config set registry https://registry.npm.taobao.orgnpm
以上地址有可能有修改,以最新版本的镜像为主
Paste_Image.png另外react-script自带打包构建的命令,可以直接执行
npm start
默认是localhost:3000端口且自动打开
如果打算自定义webpack或者gulp构建打包项目,可以在package.json
中自定义启动命令,如:
"scripts": {
"start": "node server.js",
"build": "node build.js",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
在这里我直接使用webpack来构建,当然也可以gulp+webpack结合使用,可以达到分任务流的效果,我尝试过,但是目前为止总体来说其实webpack就够用了,开发过程中如果遇到webpack效率低不得不用gulp来解决的情况大概才需要结合起来使用,目前我发现的webpack缺陷有:
- 在监听文件变化的时候把index.html排除了,需要我们手动刷新,也就是说只有修改src目录下的文件才能生效
由于我们是使用create-react-app
来初始化项目的,项目本身已经包含了react-script
下所有的第三方,所以可以不用另外安装webpack的第三方包(:зゝ∠)。
下一步我们安装相关的库(再启动服务之前需要安装,不然会报错:Uncaught SyntaxError: Unexpected token import):
npm install --save-dev babel-cli babel-preset-es2015 babel-preset-react
npm install --save-dev babel-eslint eslint eslint-loader eslint-plugin-react eslint-config-react-app
npm install --save-dev babel-loader style-loader less less-loader file-loader url-loader css-loader
创建.babelrc
文件
touch .babelrc
并且添加以下代码:
{
"presets": ["es2015", "react"]
}
创建.eslintrc
文件
touch .eslintrc
并且添加以下代码:
{
"extends": "react-app"
}
那么我们开始配置webpack吧,首先我们要新建一个config文件夹来存储配置文件:
mkdir config
touch config/webpack.config.dev.js //开发环境配置
touch config/webpack.config.prod.js//生产环境配置
touch config/paths.js //文件路径配置
touch server.js //启动文件
webpack.config.prod.js和server.js我参考react-scripts的配置文件做出一些小的修改
ruby-china/config/webpack.config.dev.js
//../config/webpack.config.dev.js
module.exports = {
entry: [
require.resolve('react-dev-utils/webpackHotDevClient'),//去掉就无法监听文件实时变化并刷新
paths.appIndexJs
],
output: {
path: path.join(__dirname, 'build'),
pathinfo: true,
filename: 'static/js/bundle.js',
publicPath: publicPath
},
devtool: 'cheap-module-source-map',
plugins: [
new InterpolateHtmlPlugin({
PUBLIC_URL: publicUrl
}),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appHtml,
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'development'
)
}),
new webpack.HotModuleReplacementPlugin(),
new CaseSensitivePathsPlugin(),
new WatchMissingNodeModulesPlugin(paths.appNodeModules)
],
...
module: {
preLoaders: [
{
test: /\.(js|jsx)$/,
loader: 'eslint',
include: paths.appSrc,
}
],
loaders: [{
test: /\.(js|jsx)$/,
include: paths.appSrc,
loader: 'babel',
query: {
babelrc: false,
presets: [require.resolve('babel-preset-react-app')],
cacheDirectory: true
}
}, {
test: /\.(jpg|png|svg)$/,
loader: 'file',
query: {
name: 'static/media/[name].[hash:8].[ext]'
}
}
...
//代码略
...
]
}
}
ruby-china/config/webpack.config.prod.js
//../config/webpack.config.prod.js
...//前后部分代码省略
if (process.env.NODE_ENV !== "production") {
throw new Error('Production builds must have NODE_ENV=production.');
}
...
ruby-china/server.js(代码略)
ruby/china/build.js(代码略)
到这一步,我们已经配置好基础的web静态服务、热加载自动刷新和生产环境的打包。
启动服务(不要忘记在paths.js设置端口号(:зゝ∠),默认为8890)
npm run start
生产环境下打包:
npm run build
步骤二:添加react-route+redux
我们安装先一下之后需要用到的库:
npm install react-router --save redux
npm install isomorphic-fetch moment redux-logger react-redux react-router-redux redux-thunk --save-dev
moment.js
可以轻松管理时间和日期,moment.js例子。
使用react-css-modules来为每一个 CSS 类在加载 CSS 文档的时候生成一个唯一的名字。
npm install --save-dev react-css-modules
1. redux
首先我们先新建如下目录:
Paste_Image.png这是一个普通的view层的例子:
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
var {addToSchedule} = require('../../actions');
class Sessions extends Component{
constructor(props) {
super(props);
}
render(){
//代码
}
}
function select(store, props) {
return {
isLoggedIn: store.user.isLoggedIn,
};
}
function actions(dispatch, props) {
let id = props.session.id;
return {
addToSchedule: () => dispatch(addToSchedule(id)),
}
module.exports = connect(select, actions)(Sessions);
1. Action
新建src/actions/index.js用来组合各个action:
//../src/actions/index.js
'use strict';
// const loginActions = require('./login');
// const scheduleActions = require('./schedule');
// const filterActions = require('./filter');
// const notificationActions = require('./notifications');
// const configActions = require('./config');
module.exports = {
//...loginActions,
// ...scheduleActions,
// ...filterActions,
// ...notificationActions,
// ...configActions,
};
Action是把数据从应用传到store的载体,是store数据的唯一来源,栗子:
//常见action
export function skipLogin(): Action {
return {
type: 'SKIPPED_LOGIN',
};
}
...
//异步处理数据的action
export function logIn(): ThunkAction {
return (dispatch) => {
//登录接口操作回调代码
// TODO: Make sure reducers clear their state
return dispatch({
type: 'LOGGED_IN',
data: []
});
};
}
当然我们可以吧 action.type 写一个配置文件初始化返回数据的数据结构,如:
// ..src/config/types.js
'use strict';
export type Action =
{ type: 'LOGGED_IN', data: { id: string; name: string; } }
| { type: 'SKIPPED_LOGIN' }
| { type: 'LOGGED_OUT' }
;
提前想好对象的数据结构是个好习惯(:з っ )っ,虽然我每次也挺懒的。
需要注意的是,在异步处理数据的时候,我们最好将请求数据、接收数据、刷新数据、显示数据分开不同的action,减少请求数据和特定的 UI 事件耦合。
connect帮助器例子:
function mapStateToProps(state, props) {
return {
isLoggedIn: store.user.isLoggedIn,
};
}
function mapDispatchToProps(dispatch, props) {
let id = props.session.id;
return {
addToSchedule: () => dispatch(addToSchedule(id)),
}
module.exports = connect(mapStateToProps, mapDispatchToProps)(Sessions);
我们这里只了解connect() 接收的两个参数select和actions,
mapStateToProps就是将store数据作为props绑定到组件上的函数,store作为这个函数方法的参数传入。
mapDispatchToProps是将action中的方法通过dispatch序列化后作为props绑定到组件中。
在 mapStateToProps 中 store 能直接通过 store.dispatch() 调用 dispatch() 方法,但是多数情况下我们会使用mapDispatchToProps方法直接接受 dispatch 参数。bindActionCreators() 可以自动把多个 action 创建函数 绑定到 dispatch() 方法上。
function mapDispatchToProps(dispatch, props) {
let id = props.session.id;
return bindActionCreators({
addToSchedule: () => action.addToSchedule(id),
});
}
2. Reducer
Action 对数据进行处理之后,需要更新到组件中,这个时候我们需要 Reducer 更新state。
首先我们可以造一个初始化的state来决定需要这棵对象树(Object tree)里面的某个reducer分支需要操作的有哪些数据:
//reducers/users.js
const initialState = {
isLoggedIn: false,
hasSkippedLogin: false,
id: null,
name: null,
};
如果我们是通过网络请求获取的数据对象,那么在初始化的 state
中我们可以规定保留如下字段:isFetching
来显示数据的获取进度, didInvalidate
来标记数据是否过期, lastUpdated
来存放数据的最后更新时间,还有使用 data
来存放数据数组,在实际应用中我们需要用到类似分页的 fetchedPageCount
和 nextPageUrl
。
假设我们获取的是分 Tab 的列表数据,建议将这些列表数据分开存储,保证用户来回切换可以立即更新。
**Action ** 和 Reducer 有明确的分工,Reducer里面需要尽量保持整洁,永远不在 Reducer 里面执行以下操作:
- 修改传入参数;
- 执行有副作用的操作,如 API 请求和路由跳转;
- 调用非纯函数,如
Date.now()
或Math.random()
。
Redux 文档中有明确提示:
只要传入参数相同,返回计算得到的下一个 state 就一定相同。没有特殊情况、没有副作用,没有 API 请求、没有变量修改,单纯执行计算。
栗子:
//reducers/users.js
function user(state = initialState, action) {
switch (action.type) {
case 'LOGGED_IN':
return {
...state,
isLoggedIn: true,
hasSkippedLogin: false,
action.data
};
case 'SKIPPED_LOGIN':
return {
...state,
hasSkippedLogin: true,
};
case 'LOGGED_OUT':
return initialState;
default:
return state;
}
}
我并没有使用文档推荐的 Object.assign()
来新建state的副本,用以上方式也可以避免直接修改 state ,我主要是为了减少缩进和看起来好像复杂了的写法。详情请看ES7对象展开运算符。
**每个 Reducer 都有专属管理的 State **, 拆分之后用 combineReducers()
工具类将各个 reducers 整合到 reducers/index.js 文件中。如:
然后我们在reducers中新建一个index.js文件,用来组合多个reducer
//../src/reducers/index.js
'use strict';
var { combineReducers } = require('redux');
module.exports = combineReducers({
// sessions: require('./sessions'),
// user: require('./user'),
// topics: require('./topics'),
});
当然你也可以这样写:
// ../reducers/index.js
import { combineReducers } from 'redux'
import * as reducers from './reducers'
module.exports = combineReducers(reducers)
前提是每一个reducer里面的函数都使用 export
将所有函数暴露出来:
//../reducers/user.js
...
module.exports = user;
/*或者用这种方式返回多个函数
*/
module.exports = {
user1,
user2,
};
3. Store
Store
就是把Action
和Reducer
联系到一起的对象。Store 有以下职责:
维持应用的 state
;
提供 getState()
方法获取 state;
提供 dispatch(action)
方法更新 state;
通过 subscribe(listener)
注册监听器;
通过 subscribe(listener)
返回的函数注销监听器。
再次强调一下 Redux
应用只有一个单一的store
。当需要拆分数据处理逻辑时,你应该使用 reducer
组合 而不是创建多个store
。
以上是官方解释。
下面以这个例子解释一下 Store
的配置:
这一步是为了将 根 reducer
返回的完整 state 树 保存到 单一 的Store
中。
// ../store/configStore.js
var reducers = require('../reducers');
import {createStore} from 'redux';
let store = createStore(reducers)
重构 store
为了方便我们更好得处理接下来的编写,我们对代码目录结构进行一下小的调整,大致如下:
如图所示,我们将 configureStore.js拆分成三个文件以响应不同环境下的配置:
if (process.env.NODE_ENV === 'production') {
module.exports = require('./configureStore.prod')
} else {
module.exports = require('./configureStore.dev')
}
上面说我们已经将 reducer
返回的 state
树挂到 store
中,接下来我们为了之后处理稍微复杂一点的逻辑需要再挂个 middlewares
(中间件),middlewares
配置如下:
const logger = createLogger();
const middlewares = [thunk, logger];
var createRubyChinaStore = applyMiddleware(...middlewares)(createStore);
middlewares
中中包含了 react-thunk
异步加载插件 和 react-logger
的状态树跟踪记录插件。
整合之后:
//../src/store/configureStore.dev.js
/**
方法包含在const configureStore = (initialState) => {}函数体内
*/
...
const store = createStore(
reducers,
initialState,
compose(
applyMiddleware(...middlewares),
DevTools.instrument()
)
)
...
接下来我们开始配置 router
。
2. react-router和react-router-redux
我们要做的是一个网站,既然是网站就要有路由。
以上是废话。
我们先需要了解一下为啥我们要用到这个路由配置,首先是 React-Router
:"它通过管理 URL,实现组件的切换和状态的变化,开发复杂的应用几乎肯定会用到。"
我们可以看到在 React-Route官方手册 中对于路由的基础配置有详细的描写,这里我们不做赘述,由于我们使用的是 Redux
来作为状态管理器,那么我这边就直接上手配置react-router-redux
。这里有官方示例:react-router-redux
关于为啥要用react-router-redux
而不用 Redux + React-Route
,我知道反正没有人想知道原因,做就是了!
配置WebpackDevServer
// server.js
var historyApiFallback = require('connect-history-api-fallback');
devServer: {
historyApiFallback: true,
}
配置入口文件
我们在阅读官方文档的时候看到这样一段话来解释 history
: "React Router
是建立在 history
之上的。 简而言之,一个 history
知道如何去监听浏览器地址栏的变化, 并解析这个 URL
转化为 location
对象, 然后 router
使用它匹配到路由,最后正确地渲染对应的组件。"如:
import { browserHistory } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';
const store = configureStore();
const history = syncHistoryWithStore(browserHistory, store);
//然后将它们传递给<Router>
<Root store={store} history={history} />
// ./src/index.js
import { browserHistory } from 'react-router'
import { syncHistoryWithStore } from 'react-router-redux'
import Root from './containers/root'
import configureStore from './store/configureStore'
const store = configureStore()
const history = syncHistoryWithStore(browserHistory, store)
render(
<Root store={store} history={history} />,
document.getElementById('root')
);
/**
./src/containers/root.dev.js */
import routes from '../config/route'
import { Router } from 'react-router'
const Root = ({ store, history }) => (
<Provider store={store}>
<div>
<Router history={history} routes={routes} />
</div>
</Provider>
);
Root.propTypes = {
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired
};
// .src/config/route.js
import React from 'react'
import { Route } from 'react-router'
import App from '../containers/app'
const RouteConfig = (
<Route path="/" component={App}>
</Route>
);
export default RouteConfig;
配置reducers的根文件
// ./src/reducers/index.js
var { combineReducers } = require('redux');
import { routerReducer } from 'react-router-redux';
module.exports = combineReducers({
routing: routerReducer,
});
路由基本已经配置完成了,下面我们可以尝试在首页获取 RubyChina
的帖子数据并且渲染出来,实现异步加载的方式。
3. 异步获取数据
前面提到我们在进行异步加载数据的时候最好将各种情况分开封装起来,减少耦合。
在调用 API 的时候我们需要对数据进行以下三种判断:
- 一种通知 reducer 请求开始的 action
- 一种通知 reducer 请求成功结束的 action。
- 一种通知 reducer 请求失败的 action。
我们先写一个通用的 fetch 函数:
import fetch from 'isomorphic-fetch';
//跨域的反向代理已经设置,注意,当使用这个 CORS 跨域处理的时候,webpackDevServer 会出现不正常连接的错误导致有些文件无法实现热加载自动刷新,所以如果请求的服务端并不要求跨域才能访问,用原始地址如:https://ruby-china.org/api/v3/ 即可。
const url = 'https://localhost:8890/api/v3/'
const urlTranslate = (tag) => {
switch(tag) {
case 'jobs': //招聘节点是node_id=25的topics
return 'topics?node_id=25'
default :
return 'topics'
}
}
//获取数据
const fetchData = (tag, method = 'get', params = null): Promise<Action> => {
const api = url + urlTranslate(tag);
console.log(decodeURI(api));
return fetch(api, { method: method, body: params})
.then(response =>{
if (!response.ok) {
return Promise.reject(response);
}
return Promise.resolve(response.json());
}).catch(error => {
return Promise.reject("服务器异常,请稍后再试");
})
}
这段代码对 fetch
数据做了封装回调,接下来我们只要在 action
中进行调用就可以了,我将请求成功和请求失败分开两个 action type
写,这个可以看个人习惯,一般保持团队内部人员规范就行了。
const fetchTopics = (tab) => dispatch => {
dispatch(requestTopics(tab))
return fetchData(tab).then(response => {
dispatch(receiveTopics(tab, response))
}).catch(error => {
//请求数据失败
dispatch({
type: 'RECEIVE_TOPICS_FAILURE',
error: error,
})
})
}
const receiveTopics = (tab, json) => ({
type: 'RECEIVE_TOPICS_SUCCESS',
tab,
topics: json.topics,
receivedAt: Date.now()
})
当我们需要请求数据的时候会发起一个 action :
//请求帖子列表开始
const requestTopics = tab => ({
type: 'REQUEST_TOPICS',
tab
})
这个时候我们在 reducer
中就开始更新 isFetching
的状态, 显示开始加载数据,用户在这段时间再次请求的时候就会先判断是否处于 isFetching == true
,如果处于这个条件,那么就不会重复请求。
//.src/reducers/topics.js
const initialState = {
isFetching: false,
didInvalidate: false,
items: []
}
//更新选择的标签页
const selectedTab = (state = 'topics', action) => {
switch (action.type) {
case 'SELECT_TAB':
return action.tab
default:
return state
}
}
const topics = (state = initialState, action) => {
switch (action.type) {
case 'REQUEST_TOPICS':
return {
...state,
isFetching: true,
}
case 'RECEIVE_TOPICS_SUCCESS':
return {
...state,
isFetching: false,
items: action.topics,
lastUpdated: action.receivedAt
}
case 'RECEIVE_TOPICS_FAILURE':
return {
...state,
isFetching: false,
err: action.error
}
default:
return state
}
}
我们在更新数据之前需要先确定是否满足更新的条件:
/**
./src/actions/topic.js
*/
//是否需要更新帖子
const fetchTopicsIfNeeded = tab => (dispatch, getState) => {
if (shouldFetchTopics(getState(), tab)) {
return dispatch(fetchTopics(tab))
}
}
const shouldFetchTopics = (state, tab) => {
//当前状态树中挂着一个topicsByTab的分支
//解析这个topicsByTab的分支对象,对象中作为tab的key是个变量,其value是另一个state对象
const topics = state.topicsByTab[tab]
if (!topics) {
return true
}
//对象存在且正在获取新数据中
if (topics.isFetching) {
return false
}
return topics.didInvalidate
}
/**
.src/reducers/topics.js
*/
const topicsByTab = (state = { }, action) => {
switch (action.type) {
case 'INVALIDATE_TAB':
case 'RECEIVE_TOPICS_SUCCESS':
case 'RECEIVE_TOPICS_FAILURE':
case 'REQUEST_TOPICS':
return {
...state,
[action.tab]: topics(state[action.tab], action)
}
default:
return state
}
}
以上就是我们异步获取数据的主要步骤。
到这一步我们已经对项目的基本框架有了一定的了解,下面我们就开始正式开发山寨版 RubyChina~!