代碼地址
https://github.com/hql123/reactJS-ruby-china
Demo
https://hql123.github.io/reactJS-ruby-china/
相關(guān)
用React+Redux寫一個RubyChina山寨版(二)
項目簡介
項目不斷更新完善中,目前實現(xiàn)的功能不多,是一邊寫代碼一邊寫的文檔,每個人搭建React項目的時候習慣都不一樣,我只是希望把我自己在學習React中的經(jīng)驗分享出來,如果覺得我的項目對你的初學有幫助的話,可以拜托給個start咩?求輕拍~
使用之前請先閱讀Redux中文文檔
步驟一:啟動項目并初始化
全局安裝create-react-app
npm install -g create-react-app
初始化項目(ruby-china是文件夾名稱)
create-react-app ruby-china
安裝成功以后會有以下內(nèi)容
進入文件目錄
cd ruby-china
輸入ls
可查看目錄內(nèi)容包括以下文件
其中
node_modules
是第三方的安裝包,在.gitignore
中是默認忽略,package.json
是第三方庫安裝配置文件,public
內(nèi)存儲靜態(tài)html或圖片等,src
是應用目錄,js或jsx文件、css文件、打包文件等寫在里面。這是使用create-react-app
啟動的默認目錄結(jié)構(gòu),當然也可以自定義。
現(xiàn)在你已經(jīng)擁有一個最簡單的“Welcome to React”的項目,下面我們正式開始。
首先我們先確保使用create-react-app已經(jīng)安裝react
和react-dom
,如果沒有請手動執(zhí)行以下命令
npm init
這個命令之后會要求填寫一些配置選項,包括入口文件、git地址等,根據(jù)個人需求填寫就行,我基本都默認
npm install --save react react-dom
建議翻墻,沒有翻墻的建議修改npm的鏡像,如:
npm config set registry https://registry.npm.taobao.orgnpm
以上地址有可能有修改,以最新版本的鏡像為主
另外react-script自帶打包構(gòu)建的命令,可以直接執(zhí)行
npm start
默認是localhost:3000端口且自動打開
如果打算自定義webpack或者gulp構(gòu)建打包項目,可以在package.json
中自定義啟動命令,如:
"scripts": {
"start": "node server.js",
"build": "node build.js",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
在這里我直接使用webpack來構(gòu)建,當然也可以gulp+webpack結(jié)合使用,可以達到分任務流的效果,我嘗試過,但是目前為止總體來說其實webpack就夠用了,開發(fā)過程中如果遇到webpack效率低不得不用gulp來解決的情況大概才需要結(jié)合起來使用,目前我發(fā)現(xiàn)的webpack缺陷有:
- 在監(jiān)聽文件變化的時候把index.html排除了,需要我們手動刷新,也就是說只有修改src目錄下的文件才能生效
由于我們是使用create-react-app
來初始化項目的,項目本身已經(jīng)包含了react-script
下所有的第三方,所以可以不用另外安裝webpack的第三方包(:зゝ∠)。
下一步我們安裝相關(guān)的庫(再啟動服務之前需要安裝,不然會報錯: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
創(chuàng)建.babelrc
文件
touch .babelrc
并且添加以下代碼:
{
"presets": ["es2015", "react"]
}
創(chuàng)建.eslintrc
文件
touch .eslintrc
并且添加以下代碼:
{
"extends": "react-app"
}
那么我們開始配置webpack吧,首先我們要新建一個config文件夾來存儲配置文件:
mkdir config
touch config/webpack.config.dev.js //開發(fā)環(huán)境配置
touch config/webpack.config.prod.js//生產(chǎn)環(huán)境配置
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'),//去掉就無法監(jiān)聽文件實時變化并刷新
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(代碼略)
到這一步,我們已經(jīng)配置好基礎的web靜態(tài)服務、熱加載自動刷新和生產(chǎn)環(huán)境的打包。
啟動服務(不要忘記在paths.js設置端口號(:зゝ∠),默認為8890)
npm run start
生產(chǎn)環(huán)境下打包:
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
首先我們先新建如下目錄:
這是一個普通的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是把數(shù)據(jù)從應用傳到store的載體,是store數(shù)據(jù)的唯一來源,栗子:
//常見action
export function skipLogin(): Action {
return {
type: 'SKIPPED_LOGIN',
};
}
...
//異步處理數(shù)據(jù)的action
export function logIn(): ThunkAction {
return (dispatch) => {
//登錄接口操作回調(diào)代碼
// TODO: Make sure reducers clear their state
return dispatch({
type: 'LOGGED_IN',
data: []
});
};
}
當然我們可以吧 action.type 寫一個配置文件初始化返回數(shù)據(jù)的數(shù)據(jù)結(jié)構(gòu),如:
// ..src/config/types.js
'use strict';
export type Action =
{ type: 'LOGGED_IN', data: { id: string; name: string; } }
| { type: 'SKIPPED_LOGIN' }
| { type: 'LOGGED_OUT' }
;
提前想好對象的數(shù)據(jù)結(jié)構(gòu)是個好習慣(:з っ )っ,雖然我每次也挺懶的。
需要注意的是,在異步處理數(shù)據(jù)的時候,我們最好將請求數(shù)據(jù)、接收數(shù)據(jù)、刷新數(shù)據(jù)、顯示數(shù)據(jù)分開不同的action,減少請求數(shù)據(jù)和特定的 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() 接收的兩個參數(shù)select和actions,
mapStateToProps就是將store數(shù)據(jù)作為props綁定到組件上的函數(shù),store作為這個函數(shù)方法的參數(shù)傳入。
mapDispatchToProps是將action中的方法通過dispatch序列化后作為props綁定到組件中。
在 mapStateToProps 中 store 能直接通過 store.dispatch() 調(diào)用 dispatch() 方法,但是多數(shù)情況下我們會使用mapDispatchToProps方法直接接受 dispatch 參數(shù)。bindActionCreators() 可以自動把多個 action 創(chuàng)建函數(shù) 綁定到 dispatch() 方法上。
function mapDispatchToProps(dispatch, props) {
let id = props.session.id;
return bindActionCreators({
addToSchedule: () => action.addToSchedule(id),
});
}
2. Reducer
Action 對數(shù)據(jù)進行處理之后,需要更新到組件中,這個時候我們需要 Reducer 更新state。
首先我們可以造一個初始化的state來決定需要這棵對象樹(Object tree)里面的某個reducer分支需要操作的有哪些數(shù)據(jù):
//reducers/users.js
const initialState = {
isLoggedIn: false,
hasSkippedLogin: false,
id: null,
name: null,
};
如果我們是通過網(wǎng)絡請求獲取的數(shù)據(jù)對象,那么在初始化的 state
中我們可以規(guī)定保留如下字段:isFetching
來顯示數(shù)據(jù)的獲取進度, didInvalidate
來標記數(shù)據(jù)是否過期, lastUpdated
來存放數(shù)據(jù)的最后更新時間,還有使用 data
來存放數(shù)據(jù)數(shù)組,在實際應用中我們需要用到類似分頁的 fetchedPageCount
和 nextPageUrl
。
假設我們獲取的是分 Tab 的列表數(shù)據(jù),建議將這些列表數(shù)據(jù)分開存儲,保證用戶來回切換可以立即更新。
**Action ** 和 Reducer 有明確的分工,Reducer里面需要盡量保持整潔,永遠不在 Reducer 里面執(zhí)行以下操作:
- 修改傳入?yún)?shù);
- 執(zhí)行有副作用的操作,如 API 請求和路由跳轉(zhuǎn);
- 調(diào)用非純函數(shù),如
Date.now()
或Math.random()
。
Redux 文檔中有明確提示:
只要傳入?yún)?shù)相同,返回計算得到的下一個 state 就一定相同。沒有特殊情況、沒有副作用,沒有 API 請求、沒有變量修改,單純執(zhí)行計算。
栗子:
//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里面的函數(shù)都使用 export
將所有函數(shù)暴露出來:
//../reducers/user.js
...
module.exports = user;
/*或者用這種方式返回多個函數(shù)
*/
module.exports = {
user1,
user2,
};
3. Store
Store
就是把Action
和Reducer
聯(lián)系到一起的對象。Store 有以下職責:
維持應用的 state
;
提供 getState()
方法獲取 state;
提供 dispatch(action)
方法更新 state;
通過 subscribe(listener)
注冊監(jiān)聽器;
通過 subscribe(listener)
返回的函數(shù)注銷監(jiān)聽器。
再次強調(diào)一下 Redux
應用只有一個單一的store
。當需要拆分數(shù)據(jù)處理邏輯時,你應該使用 reducer
組合 而不是創(chuàng)建多個store
。
以上是官方解釋。
下面以這個例子解釋一下 Store
的配置:
這一步是為了將 根 reducer
返回的完整 state 樹 保存到 單一 的Store
中。
// ../store/configStore.js
var reducers = require('../reducers');
import {createStore} from 'redux';
let store = createStore(reducers)
重構(gòu) store
為了方便我們更好得處理接下來的編寫,我們對代碼目錄結(jié)構(gòu)進行一下小的調(diào)整,大致如下:

如圖所示,我們將 configureStore.js拆分成三個文件以響應不同環(huán)境下的配置:
if (process.env.NODE_ENV === 'production') {
module.exports = require('./configureStore.prod')
} else {
module.exports = require('./configureStore.dev')
}
上面說我們已經(jīng)將 reducer
返回的 state
樹掛到 store
中,接下來我們?yōu)榱酥筇幚砩晕碗s一點的邏輯需要再掛個 middlewares
(中間件),middlewares
配置如下:
const logger = createLogger();
const middlewares = [thunk, logger];
var createRubyChinaStore = applyMiddleware(...middlewares)(createStore);
middlewares
中中包含了 react-thunk
異步加載插件 和 react-logger
的狀態(tài)樹跟蹤記錄插件。
整合之后:
//../src/store/configureStore.dev.js
/**
方法包含在const configureStore = (initialState) => {}函數(shù)體內(nèi)
*/
...
const store = createStore(
reducers,
initialState,
compose(
applyMiddleware(...middlewares),
DevTools.instrument()
)
)
...
接下來我們開始配置 router
。
2. react-router和react-router-redux
我們要做的是一個網(wǎng)站,既然是網(wǎng)站就要有路由。
以上是廢話。
我們先需要了解一下為啥我們要用到這個路由配置,首先是 React-Router
:"它通過管理 URL,實現(xiàn)組件的切換和狀態(tài)的變化,開發(fā)復雜的應用幾乎肯定會用到。"
我們可以看到在 React-Route官方手冊 中對于路由的基礎配置有詳細的描寫,這里我們不做贅述,由于我們使用的是 Redux
來作為狀態(tài)管理器,那么我這邊就直接上手配置react-router-redux
。這里有官方示例:react-router-redux
關(guān)于為啥要用react-router-redux
而不用 Redux + React-Route
,我知道反正沒有人想知道原因,做就是了!
配置WebpackDevServer
// server.js
var historyApiFallback = require('connect-history-api-fallback');
devServer: {
historyApiFallback: true,
}
配置入口文件
我們在閱讀官方文檔的時候看到這樣一段話來解釋 history
: "React Router
是建立在 history
之上的。 簡而言之,一個 history
知道如何去監(jiān)聽瀏覽器地址欄的變化, 并解析這個 URL
轉(zhuǎn)化為 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,
});
路由基本已經(jīng)配置完成了,下面我們可以嘗試在首頁獲取 RubyChina
的帖子數(shù)據(jù)并且渲染出來,實現(xiàn)異步加載的方式。
3. 異步獲取數(shù)據(jù)
前面提到我們在進行異步加載數(shù)據(jù)的時候最好將各種情況分開封裝起來,減少耦合。
在調(diào)用 API 的時候我們需要對數(shù)據(jù)進行以下三種判斷:
- 一種通知 reducer 請求開始的 action
- 一種通知 reducer 請求成功結(jié)束的 action。
- 一種通知 reducer 請求失敗的 action。
我們先寫一個通用的 fetch 函數(shù):
import fetch from 'isomorphic-fetch';
//跨域的反向代理已經(jīng)設置,注意,當使用這個 CORS 跨域處理的時候,webpackDevServer 會出現(xiàn)不正常連接的錯誤導致有些文件無法實現(xiàn)熱加載自動刷新,所以如果請求的服務端并不要求跨域才能訪問,用原始地址如:https://ruby-china.org/api/v3/ 即可。
const url = 'https://localhost:8890/api/v3/'
const urlTranslate = (tag) => {
switch(tag) {
case 'jobs': //招聘節(jié)點是node_id=25的topics
return 'topics?node_id=25'
default :
return 'topics'
}
}
//獲取數(shù)據(jù)
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
數(shù)據(jù)做了封裝回調(diào),接下來我們只要在 action
中進行調(diào)用就可以了,我將請求成功和請求失敗分開兩個 action type
寫,這個可以看個人習慣,一般保持團隊內(nèi)部人員規(guī)范就行了。
const fetchTopics = (tab) => dispatch => {
dispatch(requestTopics(tab))
return fetchData(tab).then(response => {
dispatch(receiveTopics(tab, response))
}).catch(error => {
//請求數(shù)據(jù)失敗
dispatch({
type: 'RECEIVE_TOPICS_FAILURE',
error: error,
})
})
}
const receiveTopics = (tab, json) => ({
type: 'RECEIVE_TOPICS_SUCCESS',
tab,
topics: json.topics,
receivedAt: Date.now()
})
當我們需要請求數(shù)據(jù)的時候會發(fā)起一個 action :
//請求帖子列表開始
const requestTopics = tab => ({
type: 'REQUEST_TOPICS',
tab
})
這個時候我們在 reducer
中就開始更新 isFetching
的狀態(tài), 顯示開始加載數(shù)據(jù),用戶在這段時間再次請求的時候就會先判斷是否處于 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
}
}
我們在更新數(shù)據(jù)之前需要先確定是否滿足更新的條件:
/**
./src/actions/topic.js
*/
//是否需要更新帖子
const fetchTopicsIfNeeded = tab => (dispatch, getState) => {
if (shouldFetchTopics(getState(), tab)) {
return dispatch(fetchTopics(tab))
}
}
const shouldFetchTopics = (state, tab) => {
//當前狀態(tài)樹中掛著一個topicsByTab的分支
//解析這個topicsByTab的分支對象,對象中作為tab的key是個變量,其value是另一個state對象
const topics = state.topicsByTab[tab]
if (!topics) {
return true
}
//對象存在且正在獲取新數(shù)據(jù)中
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
}
}
以上就是我們異步獲取數(shù)據(jù)的主要步驟。
到這一步我們已經(jīng)對項目的基本框架有了一定的了解,下面我們就開始正式開發(fā)山寨版 RubyChina~!