從零開始搭建React應用(二)——React應用架構

Mello_Z發表於2018-07-17

上一篇文章——從零開始搭建 React 應用(一)——基礎搭建講述瞭如何使用 webpack 搭建一個非常基礎的 react 開發環境。本文將詳細講述搭建一個 React 應用的架構。

倉庫地址:github.com/MrZhang123/…

redux

在我們開發過程中,很多時候,我們需要讓元件共享某些資料,雖然可以通過元件傳遞資料實現資料共享,但是如果元件之間不是父子關係的話,資料傳遞是非常麻煩的,而且容易讓程式碼的可讀性降低,這時候我們就需要一個 state(狀態)管理工具。常見的狀態管理工具有 redux,mobx,這裡選擇 redux 進行狀態管理。值得注意的是 React 16.3 帶來了全新的Context API,我們也可以使用新的 Context API 做狀態管理。

Redux 是 JavaScript 狀態容器,提供可預測化的狀態管理。可以讓你構建一致化的應用,執行於不同的環境(客戶端、伺服器、原生應用),並且易於測試。不僅於此,它還提供非常好的開發體驗,比如有一個時間旅行偵錯程式可以編輯後實時預覽。

redux 的資料流如下圖所示:

從零開始搭建React應用(二)——React應用架構

redux 的三大原則:

  1. 整個應用的state都被儲存在一棵 object tree 中,並且這個 object tree 只存在於唯一的 store 中,但是這並不意味使用 redux 就需要將所有的 state 存到 redux 上。
  2. state 是隻讀的,唯一改變 state 的方式是觸發actionaction是一個用於描述已發生事件的普通物件。
  3. 使用純函式來執行修改,為了描述 action 如何改變 state tree,需要編寫 reducers。

中介軟體(Redux middleware)

Redux middleware 提供位於 action 發起之後,到達 reducer 之前的擴充套件點。dispatch 發起的 action 依次經過中介軟體,最終到達 reducer。我們可以利用 Redux middleware 來進行日誌記錄、建立崩潰報告、呼叫非同步介面或者路由等等。本質上來講中介軟體只是擴充了 store.dispatch 方法

增強器(Store enhancer)

store enhancer 用於增強 store 的功能,一個 store enhancer 實際上就是一個高階函式,返回一個新的強化過的 store creator。

const logEnhancer = createStore => (reducer, initialState, enhancer) => {
  const store = createStore(reducer, initialState, enhancer)
  function dispatch(action) {
    console.log(`dispatch an action: ${JSON.stringify(action)}`)
    const res = store.dispatch(action)
    const newState = store.getState()
    console.log(`current state: ${JSON.stringify(newState)}`)
    return res
  }
  return { ...store, dispatch }
}
複製程式碼

可以看到logEnhancer改變了 store 的預設行為,在每次dispatch前後,都會輸出日誌。

從零開始搭建React應用(二)——React應用架構

react-redux

redux 本身是一個狀態 JS 的狀態庫,可以結合 react,vue,angular 甚至是原生 JS 應用使用,為了讓 redux 幫我們管理 react 應用的狀態,需要把 redux 與 react 連線,官方提供了react-redux庫。

react-redux 提供Provider元件通過 context 的方式嚮應用注入 store,然後元件使用connect高階方法獲取並監聽 store,然後根據 store state 和元件自身的 props 計算得到新的 props,注入該元件,並且可以通過監聽 store,比較計算出的新 props 判斷是否需要更新元件。

render(
  <Provider store={store}>
    <ConnectedRouter history={history}>
      <App />
    </ConnectedRouter>
  </Provider>,
  document.getElementById('app')
)
複製程式碼

整合 redux 到 react 應用

合併 reducer

在一個 react 應用中只有一個 store,元件通過呼叫 action 函式,傳遞資料到 reducer,reducer 根據資料更改對應的 state。但是隨著應用複雜度的提升,reducer 也會變得越來越大,此時可以考慮將 reducer 拆分成多個單獨的函式,拆分後的每個函式負責獨立管理 state 的一部分。

redux 提供combineReducers輔助函式,將分散的 reducer 合併成一個最終的 reducer 函式,然後在 createStore 的時候使用。

整合 middleware

有時候我們需要多個 middleware 組合在一起形成 middleware 鏈來增強store.dispatch,在建立 store 時候,我們需要將 middleware 鏈整合到 store 中,官方提供applyMiddleware(...middleware)將 middleware 鏈在一起。

整合 store enhancer

store enhancer 用於增強 store,如果我們有多個 store enhancer 時需要將多個 store enhancer 整合,這時候就會用到compose(...functions)

使用compose合併多個函式,每個函式都接受一個引數,它的返回值將作為一個引數提供給它左邊的函式以此類推,最右邊的函式可以接受多個引數。compose(funA,funB,funC)可以理解為compose(funA(funB(funC()))),最終返回從右到左接收到的函式合併後的最終函式。

建立 Store

redux 通過createStore建立一個 Redux store 來以存放應用中所有的 statecreateStore的引數形式如下:

createStore(reducer, [preloadedState], enhancer)
複製程式碼

所以我們建立 store 的程式碼如下:

import thunk from 'redux-thunk'
import { createStore, applyMiddleware } from 'redux'

import reducers from '../reducers'

const initialState = {}

const store = createStore(reducers, initialState, applyMiddleware(thunk))

export default store
複製程式碼

之後將建立的 store 通過Provider元件注入 react 應用即可將 redux 與 react 應用整合在一起。

注:應用中應有且僅有一個 store

React Router

React Router 是完整的 React 的路由解決方案,它保持 UI 與 URL 的同步。專案中我們整合最新版的 React Router v4。

在 react-router v4 中 react-router 被劃分為三個包:react-router,react-router-dom 和 react-router-native,區別如下:

  • react-router:提供核心路由元件和函式
  • react-router-dom:供瀏覽器使用的 react router
  • react-router-native:供 react native 使用的 react router

redux 與 react router

React Router 與 Redux 一起使用時大部分情況下都是正常的,但是偶爾會出現路由更新但是子路由或活動導航連結沒有更新。這個情況發生在:

  1. 元件通過connect()(Comp)連線 redux。
  2. 元件不是一個“路由元件”,即元件並沒有像<Route component={SomeConnectedThing} />這樣渲染。

這個問題的原因是 Redux 實現了shouldComponentUpdate,當路由變化時,該元件並沒有接收到 props 更新。

解決這個問題的方法很簡單,找到connect並且將它用withRouter包裹:

// before
export default connect(mapStateToProps)(Something)

// after
import { withRouter } from 'react-router-dom'
export default withRouter(connect(mapStateToProps)(Something))
複製程式碼

將 redux 與 react-router 深度整合

有時候我們可能希望將 redux 與 react router 進行更深度的整合,實現:

  • 將 router 的資料與 store 同步,並且從 store 訪問
  • 通過 dispatch actions 導航
  • 在 redux devtools 中支援路由改變的時間旅行除錯

這些可以通過 connected-react-router 和 history 兩個庫將 react-router 與 redux 進行深度整合實現。

官方文件中提到的是 react-router-redux,並且它已經被整合到了 react-router v4 中,但是根據 react-router-redux 的文件,該倉庫不再維護,推薦使用 connected-react-router。

首先安裝 connected-react-router 和 history 兩個庫:

$ npm install --save connected-react-router
$ npm install --save history
複製程式碼

然後給 store 新增如下配置:

  • 建立history物件,因為我們的應用是瀏覽器端,所以使用createBrowserHistory建立
  • 使用connectRouter包裹 root reducer 並且提供我們建立的history物件,獲得新的 root reducer
  • 使用routerMiddleware(history)實現使用 dispatch history actions,這樣就可以使用push('/path/to/somewhere')去改變路由(這裡的 push 是來自 connected-react-router 的)
import thunk from 'redux-thunk'
import { createBrowserHistory } from 'history'

import { createStore, applyMiddleware } from 'redux'
import { connectRouter, routerMiddleware } from 'connected-react-router'

import reducers from '../reducers'

export const history = createBrowserHistory()
const initialState = {}

const store = createStore(
  connectRouter(history)(reducers),
  initialState,
  applyMiddleware(thunk, routerMiddleware(history))
)

export default store
複製程式碼

在根元件中,我們新增如下配置:

  • 使用ConnectedRouter包裹路由,並且將 store 中建立的history物件引入,作為 props 傳入應用
  • ConnectedRouter元件要作為Provider的子元件
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'

import App from './App'
import store from './redux/store'
import { history } from './redux/store'

render(
  <Provider store={store}>
    <ConnectedRouter history={history}>
      <App />
    </ConnectedRouter>
  </Provider>,
  document.getElementById('app')
)
複製程式碼

這樣我們就將 redux 與 react-router 整合完畢。

使用dispatch切換路由

完成以上配置後,就可以使用dispatch切換路由了:

import { push } from 'react-router-redux'
// Now you can dispatch navigation actions from anywhere!
store.dispatch(push('/about'))
複製程式碼

react-router-config

react-router v4 之前——靜態路由

在 react-router v4 之前的版本中,我們可以直接使用靜態路由來配置應用程式的路由,它允許在渲染之前對路由進行檢查和匹配。

在 router.js 中一般會有這樣的程式碼:

const routes = (
  <Router>
    <Route path="/" component={App}>
      <Route path="about" component={About} />
      <Route path="users" component={Users}>
        <Route path="/user/:userId" component={User} />
      </Route>
      <Route path="*" component={NoMatch} />
    </Route>
  </Router>
)
export default routes
複製程式碼

然後在初始化的時候把路由匯入,然後渲染:

import ReactDOM from 'react-dom'
import routes from './config/routes'

ReactDOM.render(routes, document.getElementById('app'))
複製程式碼

react-router v4——動態路由

從 v4 版本開始,react-router 使用動態元件代替路徑配置,即 react-router 就是 react 應用的一個普通元件,隨用隨寫,不必像之前那樣,路由跟元件分離。因此 react 應用新增 react-router,首先引入我們需要的東西。

import React from 'react'
import { BrowserRouter as Router, Route, Link } from 'react-router-dom'
複製程式碼

這裡我們將BrowserRouter引入並重新命名為RouterBrowserRouter允許 react-router 將應用的路由資訊通過context傳遞給任何需要的元件。因此要讓 react-router 正常工作,需要在應用程式的根結點中渲染BrowserRouter

import React from 'react'
import { BrowserRouter as Router, Route, Link } from 'react-router-dom'

class App extends Component {
  render() {
    return (
      <Router>
        <div>
          <div>
            <Link to="/">Home</Link>
          </div>
          <hr />
          <Route exact path="/" component={Home} />
        </div>
      </Router>
    )
  }
}
複製程式碼

以還使用了Route,當應用程式的 location 匹配到某個路由時,Route將渲染制定的 component,否則渲染null

想要加入更多的路由,新增Route元件即可,但是這樣的寫法也許我們會感覺到有點兒亂,因為畢竟路由被分散到元件各處,很難像以前那樣很容易的看到整個應用的路由,而且如果專案之前是用的 react-router v4 之前的版本,那麼升級 v4 也是成本很大的,官方為解決該問題,提供了專門用來處理靜態路由配置的庫——react-router-config。

新增 react-router-config 實現使用靜態路由

新增了 react-router-config 之後,我們就可以寫我們熟悉的靜態路由了。同時,利用它,可以將路由配置分散在各個元件中,最後使用renderRoutes將分散的路由片段在根元件合併,渲染即可。

配置靜態路由:

import Home from './views/Home'
import About from './views/About'

const routes = [
  {
    path: '/',
    exact: true,
    component: Home
  },
  {
    path: '/about',
    component: About
  }
]
export default routes
複製程式碼

然後在根元件中合併,渲染:

import { renderRoutes } from 'react-router-config'

import HomeRoute from './views/Home/router'
import AboutRoute from './views/About/router'
// 合併路由
const routes = [...HomeRoute, ...AboutRoute]

class App extends Component {
  render() {
    return (
      <Router>
        <div className="screen">{renderRoutes(routes)}</div>
      </Router>
    )
  }
}
複製程式碼

renderRoutes其實幫我們做了類似的事兒:

const routes = (
  <Router>
    <Route path="/" component={App}>
      <Route path="about" component={About} />
      <Route path="users" component={Users}>
        <Route path="/user/:userId" component={User} />
      </Route>
      <Route path="*" component={NoMatch} />
    </Route>
  </Router>
)
複製程式碼

這樣就給 React 應用新增了靜態路由。

新增模組熱替換(Hot Module Replacement)

模組熱替換(HMR)功能會在應用程式執行過程中替換、新增或刪除模組,而無需重新載入整個頁面。主要通過以下幾種方式:

  • 保留在完全重新載入頁面時丟失的應用狀態
  • 只更新變更的內容以節省開發時間
  • 更改樣式不需要重新整理頁面

在開發模式中,HMR 可以替代 LiveReload,webpack-dev-server 支援hot模式,在試圖重新載入整個頁面之前,hot模式嘗試使用 HMR 來更新。

啟用 HMR

在 webpack 配置檔案中新增 HMR 外掛:

plugins: [new webpack.HotModuleReplacementPlugin(), new webpack.NamedModulesPlugin()]
複製程式碼

這裡新增的NamedModulesPlugin外掛,

設定 webpack-dev-server 開啟hot模式:

const server = new WebpackDevServer(compiler, {
+  hot: true,
  // noInfo: true,
  quiet: true,
  historyApiFallback: true,
  filename: config.output.filename,
  publicPath: config.output.publicPath,
  stats: {
    colors: true
  }
});
複製程式碼

這樣,當修改 react 程式碼的時候,頁面會自動重新整理,修改 css 檔案,頁面不重新整理,直接呈現樣式。

但是會發現一個問題,頁面的自動重新整理會導致我們 react 元件的狀態丟失,那麼能否做到更改 react 元件像更改 css 檔案那樣,頁面不重新整理(儲存頁面的狀態),直接替換呢?答案是肯定的,可以使用 react-hot-loader。

新增 react-hot-loader

新增 react-hot-loader 非常簡單,只需要在根元件匯出的時候新增高階方法hot即可:

import { hot } from "react-hot-loader";

class App extends Component {
	...
}

export default hot(module)(App);
複製程式碼

這樣,整個應用在開發時候就可以修改 react 元件而保持狀態了。

注:

在開發過程中,查閱了一些文章說,為了配合 redux,需要在 store.js 中新增如下程式碼:

if (process.env.NODE_ENV === 'development') {
  if (module.hot) {
    module.hot.accept('../reducers/index.js', () => {
      // const nextReducer = combineReducers(require('../reducers'))
      // store.replaceReducer(nextReducer)
      store.replaceReducer(require('../reducers/index.js').default)
    })
  }
}
複製程式碼

但是,在 react-hot-loader v4 中,是不需要的,直接新增hot就可以了。

非同步載入元件(Code Splitting)

完成以上配置後,我們的主體已經搭建的差不多了,但是當開啟開發者工具會發現,應用開始載入的時候直接把整個應用的 JS 全部載入進來,但是我們期望進入哪個頁面載入哪個頁面的程式碼,那麼如何實現應用的 Code Splitting 呢?

其實實現 React Code Splitting 的庫有很多,例如:

選用其中之一即可,我專案中選用的是 react-loadable。

之前我們已經在專案中配置了靜態路由,元件是直接引入的,我們只需要對之前的直接引入的元件做處理就可以,程式碼如下:

import loadable from 'react-loadable'
import Loading from '../../components/Loading'

export const Home = loadable({
  loader: () => import('./Home'),
  loading: Loading
})
export const About = loadable({
  loader: () => import('./About'),
  loading: Loading
})

const routes = [
  {
    path: '/',
    exact: true,
    component: Home
  },
  {
    path: '/about',
    component: About
  }
]
export default routes
複製程式碼

非同步任務流管理

實現非同步操作的思路

大部分情況下我們的應用中都是同步操作,即 dispatch action 時,state 會被立即更新,但是有些時候我們需要做非同步操作。同步操作只要發出一種 Action 即可,但是非同步操作需要發出三種 Acion。

  • 操作發起時的 Action
  • 操作成功時的 Action
  • 操作失敗時的 Action

為了區分這三種 action,可能在 action 裡新增一個專門的status欄位作為標記位:

{ type: 'FETCH_POSTS' }
{ type: 'FETCH_POSTS', status: 'error', error: 'Oops' }
{ type: 'FETCH_POSTS', status: 'success', response: { ... } }
複製程式碼

或者為它們定義不同的 type:

{ type: 'FETCH_POSTS_REQUEST' }
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }
複製程式碼

所以想要實現非同步操作需要做到:

  • 操作開始時,發出一個 Action,觸發 State 更新為“正在操作”,View 重新渲染
  • 操作結束後,再發出一個 Action,觸發 State 更新為“操作結束”,View 再次重新渲染

redux-thunk

非同步操作至少送出兩個 Action,第一個 Action 跟同步操作一樣,直接送出即可,那麼如何送出第二個 Action 呢?

我們可以在送出第一個 Action 的時候送一個 Action Creator 函式,這樣第二個 Action 可以在非同步執行完成後自動送出。

componentDidMount() {
   store.dispatch(fetchPosts())
}
複製程式碼

在元件載入成功後,送出一個 Action 用來請求資料,這裡的fetchPosts就是 Action Creator。fetchPosts 程式碼如下:

export const SET_DEMO_DATA = createActionSet('SET_DEMO_DATA')

export const fetchPosts = () => async (dispatch, getState) => {
  store.dispatch({ type: SET_DEMO_DATA.PENDING })
  await axios
    .get('https://jsonplaceholder.typicode.com/users')
    .then(response => store.dispatch({ type: SET_DEMO_DATA.SUCCESS, payload: response }))
    .catch(err => store.dispatch({ type: SET_DEMO_DATA.ERROR, payload: err }))
}
複製程式碼

fetchPosts是一個 Action Creator,執行返回一個函式,該函式執行時dispatch一個 action,表明馬上要進行非同步操作;非同步執行完成後,根據請求結果的不同,分別dispatch不同的 action 將非同步操作的結果返回回來。

這裡需要說明幾點:

  1. fetchPosts返回了一個函式,而普通的 Action Creator 預設返回一個物件。
  2. 返回的函式的引數是dispatchgetState這兩個 Redux 方法,普通的 Action Creator 的引數是 Action 的內容。
  3. 在返回的函式之中,先發出一個 store.dispatch({type: SET_DEMO_DATA.PENDING}),表示非同步操作開始。
  4. 非同步操作結束之後,再發出一個 store.dispatch({ type: SET_DEMO_DATA.SUCCESS, payload: response }),表示操作結束。

但是有一個問題,store.dispatch正常情況下,只能傳送物件,而我們要傳送函式,為了讓store.dispatch可以傳送函式,我們使用中介軟體——redux-thunk。

引入 redux-thunk 很簡單,只需要在建立 store 的時候使用applyMiddleware(thunk)引入即可。

開發除錯工具

開發過程中免不了除錯,常用的除錯工具有很多,例如 redux-devtools-extension,redux-devtools,storybook 等。

redux-devtools-extension

redux-devtools-extension 是一款除錯 redux 的工具,用來監測 action 非常方便。

首先根據瀏覽器在Chrome Web Store或者Mozilla Add-ons中下載該外掛。

然後在建立 store 時候,將其加入到 store enhancer 配置中即可:

import thunk from "redux-thunk";
import { createBrowserHistory } from "history";

import { createStore, applyMiddleware } from "redux";
+ import { composeWithDevTools } from "redux-devtools-extension/logOnlyInProduction";
import { connectRouter, routerMiddleware } from "connected-react-router";

import reducers from "../reducers";

export const history = createBrowserHistory();
const initialState = {};

+  const composeEnhancers = composeWithDevTools({
+   // options like actionSanitizer, stateSanitizer
+ });

const store = createStore(
  connectRouter(history)(reducers),
  initialState,
+  composeEnhancers(applyMiddleware(thunk, routerMiddleware(history)))
);
複製程式碼

寫在最後

本文梳理了自己對 React 應用架構的認識以及相關庫的具體配置,進一步加深了對 React 應用架構的理解,但是像資料 Immutable ,持久化,webpack優化等這些,本文並未涉及,未來會繼續研究相關的東西,力求搭建更加完善的 React 應用。

另外在搭建專案過程中升級最新的 babel 後發現@babel/preset-stage-0 即將棄用,建議使用其他代替,更多細節參考:

關鍵字:

  • redux
  • react-router
  • react-router-config
  • 非同步載入(Code Splitting)
  • 熱更新
  • 非同步任務管理——redux-thunk
  • react-redux
  • redux-devtools-extension

部分用到的庫

參考

相關文章