用React+Redux寫一個RubyChina山寨版(一)

代碼地址

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)容

Paste_Image.png

進入文件目錄
cd ruby-china
輸入ls可查看目錄內(nèi)容包括以下文件

Paste_Image.png

其中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)安裝reactreact-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端口且自動打開

Paste_Image.png

如果打算自定義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缺陷有:

  1. 在監(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

Paste_Image.png

生產(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

首先我們先新建如下目錄:

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是把數(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ù)selectactions,
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ù)組,在實際應用中我們需要用到類似分頁的 fetchedPageCountnextPageUrl。

假設我們獲取的是分 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 就是把ActionReducer聯(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~!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容